From c864304695d5e9ed288b27d91d336d413791b4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 00:44:52 -0400 Subject: [PATCH] OmaPhoto 1.0.0: the Linux port of Compositor A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude --- .github/workflows/release.yml | 70 +++ .gitignore | 7 + AGENTS.md | 152 ++++++ CMakeLists.txt | 460 ++++++++++++++++ Dockerfile.dev | 25 + LICENSE | 22 + README.md | 69 +++ TASKS.md | 402 ++++++++++++++ flake.lock | 27 + flake.nix | 41 ++ kernels/AdjustPixels.c | 96 ++++ kernels/AdjustPixels.h | 19 + kernels/BrushPixels.c | 50 ++ kernels/BrushPixels.h | 10 + kernels/ContentFill.c | 93 ++++ kernels/ContentFill.h | 4 + kernels/HealPixels.c | 259 +++++++++ kernels/HealPixels.h | 19 + kernels/LensPixels.c | 37 ++ kernels/LensPixels.h | 12 + kernels/LevelsPixels.c | 28 + kernels/LevelsPixels.h | 4 + kernels/NoisePixels.c | 41 ++ kernels/NoisePixels.h | 12 + kernels/WandPixels.c | 174 ++++++ kernels/WandPixels.h | 21 + licenses/U-2-Net.txt | 201 +++++++ packaging/arch/PKGBUILD | 22 + packaging/icons/app-icon-128.png | Bin 0 -> 18762 bytes packaging/icons/app-icon-16.png | Bin 0 -> 729 bytes packaging/icons/app-icon-256.png | Bin 0 -> 71530 bytes packaging/icons/app-icon-32.png | Bin 0 -> 1742 bytes packaging/icons/app-icon-512.png | Bin 0 -> 299853 bytes packaging/icons/app-icon-64.png | Bin 0 -> 5315 bytes ...io.github.ZacharyZhang_NY.OmaPhoto.desktop | 11 + .../io.github.ZacharyZhang_NY.OmaPhoto.xml | 8 + scripts/dev.sh | 40 ++ scripts/distro-check.sh | 18 + scripts/distro/arch/Dockerfile | 10 + scripts/distro/fedora/Dockerfile | 11 + scripts/distro/nixos/Dockerfile | 3 + scripts/heic-fixtures.sh | 13 + scripts/heic-fixtures/Dockerfile | 5 + scripts/heic-fixtures/generate.sh | 16 + scripts/heic-fixtures/sources.cpp | 46 ++ scripts/install/arch.sh | 25 + scripts/install/fedora.sh | 28 + scripts/install/nixos.sh | 12 + scripts/install/ubuntu.sh | 26 + scripts/lint.py | 405 ++++++++++++++ scripts/mutate.py | 102 ++++ src/CompositorApp.cpp | 33 ++ src/ContentView+Panels.cpp | 114 ++++ src/ContentView+Status.cpp | 60 +++ src/ContentView.cpp | 453 ++++++++++++++++ src/ContentView.h | 93 ++++ src/Document/AdjustmentEditing+Session.h | 12 + src/Document/AdjustmentEditing.cpp | 147 ++++++ src/Document/BlurTool+Session.h | 4 + src/Document/BlurTool.cpp | 52 ++ src/Document/BrushStroke+Commit.cpp | 180 +++++++ src/Document/BrushStroke+Stroke.cpp | 466 ++++++++++++++++ src/Document/BrushStroke.cpp | 131 +++++ src/Document/BrushStroke.h | 190 +++++++ src/Document/CanvasSize.cpp | 75 +++ src/Document/CanvasSize.h | 45 ++ src/Document/CloneStamp+Session.h | 10 + src/Document/CloneStamp.cpp | 61 +++ src/Document/CloneStamp.h | 10 + src/Document/ColorPalette+Session.h | 32 ++ src/Document/ColorPalette.cpp | 338 ++++++++++++ src/Document/ColorPalette.h | 61 +++ src/Document/ContentFill.cpp | 33 ++ src/Document/ContentFill.h | 16 + src/Document/Crop+Session.h | 15 + src/Document/Crop.cpp | 293 ++++++++++ src/Document/Crop.h | 36 ++ src/Document/Curves.cpp | 67 +++ src/Document/Curves.h | 22 + src/Document/Distort+Session.h | 14 + src/Document/Distort.cpp | 482 +++++++++++++++++ src/Document/Distort.h | 72 +++ src/Document/DocumentHistory.cpp | 131 +++++ src/Document/DocumentHistory.h | 55 ++ src/Document/EditorSession+Brush.cpp | 407 ++++++++++++++ src/Document/EditorSession+Brush.h | 24 + src/Document/EditorSession+Imports.cpp | 124 +++++ src/Document/EditorSession+Jobs.h | 136 +++++ src/Document/EditorSession+Layers.cpp | 181 +++++++ src/Document/EditorSession+Model.cpp | 126 +++++ src/Document/EditorSession+Model.h | 85 +++ src/Document/EditorSession+Projects.cpp | 71 +++ src/Document/EditorSession+Projects.h | 7 + src/Document/EditorSession+Transform.cpp | 296 +++++++++++ src/Document/EditorSession.cpp | 347 ++++++++++++ src/Document/EditorSession.h | 437 +++++++++++++++ src/Document/Filters+Session.cpp | 335 ++++++++++++ src/Document/Filters+Session.h | 16 + src/Document/Filters.cpp | 242 +++++++++ src/Document/Filters.h | 141 +++++ src/Document/FloatingSelection+Session.h | 10 + src/Document/FloatingSelection.cpp | 196 +++++++ src/Document/FloatingSelection.h | 23 + src/Document/Gradient+Session.h | 14 + src/Document/Gradient.cpp | 152 ++++++ src/Document/Gradient.h | 34 ++ src/Document/GuidedMatte.cpp | 124 +++++ src/Document/GuidedMatte.h | 17 + src/Document/HueSaturation+Filter.cpp | 190 +++++++ src/Document/HueSaturation+Session.cpp | 276 ++++++++++ src/Document/HueSaturation+Session.h | 26 + src/Document/HueSaturation.cpp | 246 +++++++++ src/Document/HueSaturation.h | 160 ++++++ src/Document/ImageAdjustments.cpp | 133 +++++ src/Document/ImageAdjustments.h | 76 +++ src/Document/LayerAdjustment+Session.h | 6 + src/Document/LayerAdjustment.cpp | 157 ++++++ src/Document/LayerAdjustment.h | 50 ++ src/Document/LayerAppearance+Session.h | 12 + src/Document/LayerAppearance.cpp | 130 +++++ src/Document/LayerAppearance.h | 20 + src/Document/LayerEffects+Renderer.cpp | 126 +++++ src/Document/LayerEffects+Renderer.h | 21 + src/Document/LayerEffects+Session.cpp | 217 ++++++++ src/Document/LayerEffects+Session.h | 27 + src/Document/LayerEffects.cpp | 178 +++++++ src/Document/LayerEffects.h | 108 ++++ src/Document/LayerFlip+Session.h | 5 + src/Document/LayerFlip.cpp | 71 +++ src/Document/LayerGroups+Session.h | 13 + src/Document/LayerGroups.cpp | 405 ++++++++++++++ src/Document/LayerGroups.h | 17 + src/Document/LayerMask+Session.h | 20 + src/Document/LayerMask.cpp | 493 +++++++++++++++++ src/Document/LayerMask.h | 84 +++ src/Document/LayerMerge+Session.h | 8 + src/Document/LayerMerge.cpp | 140 +++++ src/Document/LayerTransform.cpp | 282 ++++++++++ src/Document/LayerTransform.h | 111 ++++ src/Document/Levels+Session.h | 17 + src/Document/Levels.cpp | 369 +++++++++++++ src/Document/Levels.h | 98 ++++ src/Document/LevelsAutomatic+Session.h | 5 + src/Document/LevelsAutomatic.cpp | 153 ++++++ src/Document/LevelsAutomatic.h | 13 + src/Document/LiveLayerMask+Session.h | 22 + src/Document/LiveLayerMask.cpp | 384 ++++++++++++++ src/Document/LiveLayerMask.h | 12 + src/Document/MagicWand+Session.h | 8 + src/Document/MagicWand.cpp | 174 ++++++ src/Document/MagicWand.h | 40 ++ src/Document/MaskTracing+Session.h | 4 + src/Document/MaskTracing.cpp | 146 +++++ src/Document/MaskTracing.h | 10 + src/Document/PixelAdjust.cpp | 236 +++++++++ src/Document/PixelAdjust.h | 19 + src/Document/PixelInvert.cpp | 29 + src/Document/PixelInvert.h | 18 + src/Document/ProjectWorkspace.cpp | 499 ++++++++++++++++++ src/Document/ProjectWorkspace.h | 96 ++++ src/Document/Selection+Session.h | 39 ++ src/Document/Selection.cpp | 418 +++++++++++++++ src/Document/Selection.h | 53 ++ src/Document/SelectionClipboard+Session.h | 24 + src/Document/SelectionClipboard.cpp | 344 ++++++++++++ src/Document/SelectionClipboard.h | 18 + src/Document/SelectionEdits+Session.h | 23 + src/Document/SelectionEdits.cpp | 347 ++++++++++++ src/Document/SelectionEdits.h | 18 + src/Document/ShapeTool+Session.h | 31 ++ src/Document/ShapeTool.cpp | 280 ++++++++++ src/Document/ShapeTool.h | 60 +++ src/Document/SmudgeLiquify+Session.h | 5 + src/Document/SmudgeLiquify.cpp | 254 +++++++++ src/Document/SmudgeLiquify.h | 53 ++ src/Document/SubjectRemoval.cpp | 238 +++++++++ src/Document/SubjectRemoval.h | 28 + src/Document/TypeTool+Session.h | 24 + src/Document/TypeTool.cpp | 307 +++++++++++ src/Document/TypeTool.h | 56 ++ src/IO/CanvasResizer.cpp | 89 ++++ src/IO/CanvasResizer.h | 8 + src/IO/ImageExporter.cpp | 173 ++++++ src/IO/ImageExporter.h | 47 ++ src/IO/ImageFileDrop.cpp | 104 ++++ src/IO/ImageFileDrop.h | 21 + src/IO/ImageImporter.cpp | 243 +++++++++ src/IO/ImageImporter.h | 48 ++ src/IO/ImageResizer+Session.h | 4 + src/IO/ImageResizer.cpp | 140 +++++ src/IO/ImageResizer.h | 15 + src/IO/ProjectController+Export.cpp | 171 ++++++ src/IO/ProjectController+Resize.cpp | 118 +++++ src/IO/ProjectController.cpp | 368 +++++++++++++ src/IO/ProjectController.h | 84 +++ src/IO/ProjectStore+Adjustment.cpp | 216 ++++++++ src/IO/ProjectStore+Effects.cpp | 93 ++++ src/IO/ProjectStore+Json.h | 33 ++ src/IO/ProjectStore+Manifest.cpp | 242 +++++++++ src/IO/ProjectStore.cpp | 326 ++++++++++++ src/IO/ProjectStore.h | 95 ++++ src/Logging.cpp | 5 + src/Logging.h | 6 + src/Rendering/AdjustmentSurface.cpp | 35 ++ src/Rendering/AdjustmentSurface.h | 9 + src/Rendering/BrushCoverage.cpp | 238 +++++++++ src/Rendering/BrushCoverage.h | 33 ++ src/Rendering/BrushCursorOverlay.cpp | 92 ++++ src/Rendering/BrushCursorOverlay.h | 33 ++ src/Rendering/CanvasViewport.cpp | 71 +++ src/Rendering/CanvasViewport.h | 34 ++ src/Rendering/DownsampleCache.cpp | 268 ++++++++++ src/Rendering/DownsampleCache.h | 44 ++ src/Rendering/EditorCanvas+Brush.cpp | 231 ++++++++ src/Rendering/EditorCanvas+Crop.cpp | 112 ++++ src/Rendering/EditorCanvas+Cursors.cpp | 373 +++++++++++++ src/Rendering/EditorCanvas+Effects.cpp | 72 +++ src/Rendering/EditorCanvas+Gradient.cpp | 45 ++ src/Rendering/EditorCanvas+Navigation.cpp | 498 +++++++++++++++++ src/Rendering/EditorCanvas+Picking.cpp | 131 +++++ src/Rendering/EditorCanvas+Selection.cpp | 289 ++++++++++ src/Rendering/EditorCanvas+Shape.cpp | 40 ++ src/Rendering/EditorCanvas+Transform.cpp | 290 ++++++++++ src/Rendering/EditorCanvas.cpp | 499 ++++++++++++++++++ src/Rendering/EditorCanvas.h | 347 ++++++++++++ src/Rendering/EffectsPreviewCache.cpp | 165 ++++++ src/Rendering/EffectsPreviewCache.h | 60 +++ src/Rendering/EyedropperIcon.cpp | 33 ++ src/Rendering/EyedropperIcon.h | 11 + src/Rendering/HslBlend.cpp | 130 +++++ src/Rendering/HslBlend.h | 10 + src/Rendering/InlineTextEditor+Editing.cpp | 305 +++++++++++ src/Rendering/InlineTextEditor+Input.cpp | 219 ++++++++ src/Rendering/InlineTextEditor+Pointer.cpp | 315 +++++++++++ src/Rendering/InlineTextEditor.cpp | 237 +++++++++ src/Rendering/InlineTextEditor.h | 157 ++++++ src/Rendering/LayerEffectsKernel.cpp | 235 +++++++++ src/Rendering/LayerEffectsKernel.h | 9 + src/Rendering/LayerEffectsSurface.cpp | 126 +++++ src/Rendering/LayerEffectsSurface.h | 43 ++ src/Rendering/LayerRenderer.cpp | 318 +++++++++++ src/Rendering/LayerRenderer.h | 64 +++ src/Rendering/LiveMaskRenderer.cpp | 238 +++++++++ src/Rendering/LiveMaskRenderer.h | 49 ++ src/Rendering/PoolMap.h | 26 + src/Rendering/RasterSnapshot.cpp | 177 +++++++ src/Rendering/RasterSnapshot.h | 37 ++ src/Rendering/SampleRingOverlay.cpp | 33 ++ src/Rendering/SampleRingOverlay.h | 21 + src/Rendering/SelectionIcons.cpp | 42 ++ src/Rendering/SelectionIcons.h | 11 + src/Rendering/TextLayout.cpp | 250 +++++++++ src/Rendering/TextLayout.h | 71 +++ src/Rendering/TiledLayerRenderer.cpp | 428 +++++++++++++++ src/Rendering/TiledLayerRenderer.h | 86 +++ src/Rendering/TransformOverlay.cpp | 379 +++++++++++++ src/Rendering/TransformOverlay.h | 62 +++ src/UI/BlendModePicker.cpp | 65 +++ src/UI/BlendModePicker.h | 23 + src/UI/BrushControls.cpp | 175 ++++++ src/UI/BrushControls.h | 54 ++ src/UI/ByteCounts.cpp | 37 ++ src/UI/ByteCounts.h | 10 + src/UI/CanvasSizeSheet.cpp | 292 ++++++++++ src/UI/CanvasSizeSheet.h | 44 ++ src/UI/CanvasThumbnail.cpp | 91 ++++ src/UI/CanvasThumbnail.h | 20 + src/UI/ColorPaletteControls.cpp | 186 +++++++ src/UI/ColorPaletteControls.h | 46 ++ src/UI/ColorPickerSheet.cpp | 362 +++++++++++++ src/UI/ColorPickerSheet.h | 68 +++ src/UI/CompositorMenus.cpp | 327 ++++++++++++ src/UI/CompositorMenus.h | 34 ++ src/UI/CropControls.cpp | 57 ++ src/UI/CropControls.h | 22 + src/UI/CurvesControls.cpp | 181 +++++++ src/UI/CurvesControls.h | 36 ++ src/UI/EffectsSheet.cpp | 256 +++++++++ src/UI/EffectsSheet.h | 45 ++ src/UI/FilterSheet.cpp | 423 +++++++++++++++ src/UI/FilterSheet.h | 82 +++ src/UI/FloatingPanel.cpp | 118 +++++ src/UI/FloatingPanel.h | 35 ++ src/UI/GradientControls.cpp | 129 +++++ src/UI/GradientControls.h | 49 ++ src/UI/HueSaturationSheet.cpp | 465 ++++++++++++++++ src/UI/HueSaturationSheet.h | 67 +++ src/UI/ImageSizeSheet.cpp | 270 ++++++++++ src/UI/ImageSizeSheet.h | 47 ++ src/UI/JPEGExportSheet.cpp | 253 +++++++++ src/UI/JPEGExportSheet.h | 52 ++ src/UI/LassoControls.cpp | 214 ++++++++ src/UI/LassoControls.h | 75 +++ src/UI/LayerAppearanceControls.cpp | 157 ++++++ src/UI/LayerAppearanceControls.h | 33 ++ src/UI/LayerIcons.cpp | 245 +++++++++ src/UI/LayerIcons.h | 22 + src/UI/LayerMaskMenu.cpp | 29 + src/UI/LayerMaskMenu.h | 18 + src/UI/LayersPanel.cpp | 166 ++++++ src/UI/LayersPanel.h | 43 ++ src/UI/LevelsSheet.cpp | 424 +++++++++++++++ src/UI/LevelsSheet.h | 50 ++ src/UI/NativeLayerList+Buttons.cpp | 185 +++++++ src/UI/NativeLayerList+Cell.cpp | 381 +++++++++++++ src/UI/NativeLayerList+Drag.cpp | 376 +++++++++++++ src/UI/NativeLayerList+Effects.cpp | 178 +++++++ src/UI/NativeLayerList.cpp | 241 +++++++++ src/UI/NativeLayerList.h | 273 ++++++++++ src/UI/NavigationToolHeader.cpp | 83 +++ src/UI/NavigationToolHeader.h | 26 + src/UI/NewCanvasSheet.cpp | 134 +++++ src/UI/NewCanvasSheet.h | 34 ++ src/UI/OmarchyTheme.cpp | 222 ++++++++ src/UI/OmarchyTheme.h | 51 ++ src/UI/ProjectTabs.cpp | 282 ++++++++++ src/UI/ProjectTabs.h | 73 +++ src/UI/ProjectWorkspaceView.cpp | 112 ++++ src/UI/ProjectWorkspaceView.h | 38 ++ src/UI/ShapeControls.cpp | 95 ++++ src/UI/ShapeControls.h | 36 ++ src/UI/SliderSnap.cpp | 73 +++ src/UI/SliderSnap.h | 7 + src/UI/ToolHeaderStyle.cpp | 29 + src/UI/ToolHeaderStyle.h | 24 + src/UI/ToolIcons.cpp | 229 ++++++++ src/UI/ToolIcons.h | 16 + src/UI/TransformInspector.cpp | 265 ++++++++++ src/UI/TransformInspector.h | 73 +++ src/UI/TypeControls.cpp | 248 +++++++++ src/UI/TypeControls.h | 60 +++ tests/AddressSpaceLimit.h | 35 ++ tests/AdjustmentEditorTests.cpp | 477 +++++++++++++++++ tests/AdjustmentLayerTests.cpp | 375 +++++++++++++ tests/AdjustmentPanelTests.cpp | 227 ++++++++ tests/AdjustmentSurfaceTests.cpp | 196 +++++++ tests/BlendModePickerTests.cpp | 160 ++++++ tests/BlendModeTests.cpp | 281 ++++++++++ tests/BlendShortcutTests.cpp | 52 ++ tests/BlurToolTests.cpp | 302 +++++++++++ tests/BrushCanvasFixtures.h | 22 + tests/BrushCanvasTests.cpp | 279 ++++++++++ tests/BrushControlsTests.cpp | 278 ++++++++++ tests/BrushCoverageTests.cpp | 139 +++++ tests/BrushCursorOverlayTests.cpp | 242 +++++++++ tests/BrushFixtures.h | 71 +++ tests/BrushGestureTests.cpp | 384 ++++++++++++++ tests/BrushIntersectionTests.cpp | 112 ++++ tests/BrushKeyTests.cpp | 184 +++++++ tests/BrushPerformanceTests.cpp | 86 +++ tests/BrushPreviewTests.cpp | 307 +++++++++++ tests/BrushRasterTests.cpp | 84 +++ tests/BrushSessionTests.cpp | 390 ++++++++++++++ tests/BrushTests.cpp | 476 +++++++++++++++++ tests/ByteCountsTests.cpp | 39 ++ tests/CanvasDisplayTests.cpp | 423 +++++++++++++++ tests/CanvasFixtures.h | 44 ++ tests/CanvasNavigationTests.cpp | 171 ++++++ tests/CanvasPerformanceTests.cpp | 99 ++++ tests/CanvasSizeSheetTests.cpp | 304 +++++++++++ tests/CanvasSizeTests.cpp | 357 +++++++++++++ tests/CanvasThumbnailTests.cpp | 83 +++ tests/ClippingStackTests.cpp | 135 +++++ tests/CloneStampCanvasTests.cpp | 289 ++++++++++ tests/CloneStampTests.cpp | 349 ++++++++++++ tests/ColorPaletteControlsTests.cpp | 179 +++++++ tests/ColorPaletteFixtures.h | 69 +++ tests/ColorPickerSheetTests.cpp | 262 +++++++++ tests/ColorPickerTests.cpp | 310 +++++++++++ tests/ColorSwatchTests.cpp | 161 ++++++ tests/CompositorMenusTests.cpp | 499 ++++++++++++++++++ tests/CompositorTests.cpp | 199 +++++++ tests/CompositorUITestsLaunchTests.cpp | 35 ++ tests/ContentViewStatusTests.cpp | 191 +++++++ tests/ContentViewTests.cpp | 404 ++++++++++++++ tests/CropCanvasTests.cpp | 408 ++++++++++++++ tests/CropControlsTests.cpp | 111 ++++ tests/CropFailureTests.cpp | 185 +++++++ tests/CropFixtures.h | 14 + tests/CropSessionTests.cpp | 190 +++++++ tests/CropTests.cpp | 320 +++++++++++ tests/CursorTests.cpp | 231 ++++++++ tests/CurvesControlsTests.cpp | 306 +++++++++++ tests/DesktopIntegrationTests.cpp | 149 ++++++ tests/DialogDesk.h | 97 ++++ tests/DistortCanvasTests.cpp | 235 +++++++++ tests/DistortMaskTests.cpp | 239 +++++++++ tests/DistortTests.cpp | 353 +++++++++++++ tests/DownsampleTests.cpp | 496 +++++++++++++++++ tests/DrawCoverageTests.cpp | 340 ++++++++++++ tests/EditorCanvasTests.cpp | 437 +++++++++++++++ tests/EditorSessionSignalTests.cpp | 457 ++++++++++++++++ tests/EditorSessionTests.cpp | 494 +++++++++++++++++ tests/EffectsCanvasTests.cpp | 422 +++++++++++++++ tests/EffectsFailureTests.cpp | 93 ++++ tests/EffectsPanelTests.cpp | 433 +++++++++++++++ tests/EffectsPreviewCacheTests.cpp | 351 ++++++++++++ tests/ExportTests.cpp | 323 ++++++++++++ tests/EyedropperFixtures.h | 38 ++ tests/EyedropperTests.cpp | 208 ++++++++ tests/FilterControlsTests.cpp | 411 +++++++++++++++ tests/FilterFailureTests.cpp | 48 ++ tests/FilterModelTests.cpp | 356 +++++++++++++ tests/FilterSessionTests.cpp | 376 +++++++++++++ tests/FilterSheetTests.cpp | 173 ++++++ tests/FilterTests.cpp | 142 +++++ tests/FiltersTests.cpp | 91 ++++ tests/FloatingPanelTests.cpp | 285 ++++++++++ tests/FloatingSelectionTests.cpp | 428 +++++++++++++++ tests/FolderMaskClipTests.cpp | 307 +++++++++++ tests/GradientCanvasTests.cpp | 364 +++++++++++++ tests/GradientTests.cpp | 464 ++++++++++++++++ tests/GroupSessionTests.cpp | 253 +++++++++ tests/GroupTests.cpp | 283 ++++++++++ tests/GroupingSelectionTests.cpp | 394 ++++++++++++++ tests/GuidedMatteTests.cpp | 165 ++++++ tests/HighDpiCanvasTests.cpp | 179 +++++++ tests/HistoryTests.cpp | 461 ++++++++++++++++ tests/HueSaturationCanvasTests.cpp | 333 ++++++++++++ tests/HueSaturationFailureTests.cpp | 115 ++++ tests/HueSaturationModelTests.cpp | 332 ++++++++++++ tests/HueSaturationSessionTests.cpp | 392 ++++++++++++++ tests/HueSaturationSheetFixtures.h | 116 ++++ tests/HueSaturationSheetTests.cpp | 306 +++++++++++ tests/HueSaturationSpectrumTests.cpp | 181 +++++++ tests/HueSaturationTests.cpp | 323 ++++++++++++ tests/ImageAdjustmentModelTests.cpp | 221 ++++++++ tests/ImageAdjustmentTests.cpp | 237 +++++++++ tests/ImageFileDropTests.cpp | 358 +++++++++++++ tests/ImageImportSessionTests.cpp | 333 ++++++++++++ tests/ImageImportTests.cpp | 488 +++++++++++++++++ tests/ImageSizeSheetTests.cpp | 285 ++++++++++ tests/ImageSizeTests.cpp | 400 ++++++++++++++ tests/InlineTextDrawFixtures.h | 60 +++ tests/InlineTextDrawTests.cpp | 400 ++++++++++++++ tests/InlineTextEditorTests.cpp | 461 ++++++++++++++++ tests/InlineTextFixtures.h | 46 ++ tests/InlineTextHandleTests.cpp | 266 ++++++++++ tests/InlineTextInputTests.cpp | 379 +++++++++++++ tests/InlineTextPointerTests.cpp | 444 ++++++++++++++++ tests/InlineTextPreeditTests.cpp | 110 ++++ tests/JPEGExportSheetTests.cpp | 259 +++++++++ tests/JPEGExportTests.cpp | 177 +++++++ tests/LassoControlsTests.cpp | 275 ++++++++++ tests/LayerAdjustmentTests.cpp | 436 +++++++++++++++ tests/LayerAppearanceControlsTests.cpp | 175 ++++++ tests/LayerAppearanceTests.cpp | 453 ++++++++++++++++ tests/LayerDragFixtures.h | 76 +++ tests/LayerDragGestureTests.cpp | 303 +++++++++++ tests/LayerDragTests.cpp | 326 ++++++++++++ tests/LayerEffectRowTests.cpp | 492 +++++++++++++++++ tests/LayerEffectsKernelTests.cpp | 320 +++++++++++ tests/LayerEffectsRendererTests.cpp | 243 +++++++++ tests/LayerEffectsSessionTests.cpp | 466 ++++++++++++++++ tests/LayerEffectsSurfaceTests.cpp | 192 +++++++ tests/LayerEffectsTests.cpp | 337 ++++++++++++ tests/LayerFlipTests.cpp | 225 ++++++++ tests/LayerListFocusTests.cpp | 388 ++++++++++++++ tests/LayerMaskEditTests.cpp | 404 ++++++++++++++ tests/LayerMaskSessionTests.cpp | 287 ++++++++++ tests/LayerMaskTests.cpp | 415 +++++++++++++++ tests/LayerMergeTests.cpp | 324 ++++++++++++ tests/LayerRendererTests.cpp | 489 +++++++++++++++++ tests/LayerTests.cpp | 342 ++++++++++++ tests/LayersPanelTests.cpp | 213 ++++++++ tests/LevelsCanvasTests.cpp | 296 +++++++++++ tests/LevelsEdgeTests.cpp | 239 +++++++++ tests/LevelsFailureTests.cpp | 95 ++++ tests/LevelsFixtures.h | 62 +++ tests/LevelsHandleTests.cpp | 153 ++++++ tests/LevelsModelTests.cpp | 170 ++++++ tests/LevelsSessionTests.cpp | 228 ++++++++ tests/LevelsSheetFixtures.h | 125 +++++ tests/LevelsSheetTests.cpp | 359 +++++++++++++ tests/LevelsTests.cpp | 216 ++++++++ tests/LiveMaskBakeTests.cpp | 232 ++++++++ tests/LiveMaskDeleteTests.cpp | 293 ++++++++++ tests/LiveMaskEditTests.cpp | 346 ++++++++++++ tests/LiveMaskFixtures.h | 83 +++ tests/LiveMaskRendererTests.cpp | 380 +++++++++++++ tests/LiveMaskSessionTests.cpp | 218 ++++++++ tests/LiveMaskTests.cpp | 115 ++++ tests/MagicWandTests.cpp | 390 ++++++++++++++ tests/MaskPlacementTests.cpp | 222 ++++++++ tests/MaskSignalTests.cpp | 172 ++++++ tests/MaskTracingTests.cpp | 186 +++++++ tests/MaskTransformTests.cpp | 162 ++++++ tests/MenuFixtures.h | 26 + tests/NativeLayerListTests.cpp | 488 +++++++++++++++++ tests/NavigationToolHeaderTests.cpp | 230 ++++++++ tests/NewCanvasSheetTests.cpp | 231 ++++++++ tests/OmarchyThemeTests.cpp | 395 ++++++++++++++ tests/PickingTests.cpp | 279 ++++++++++ tests/PixelEditTests.cpp | 290 ++++++++++ tests/PixelInvertTests.cpp | 116 ++++ tests/PixelMoveTests.cpp | 326 ++++++++++++ tests/PoolMapTests.cpp | 49 ++ tests/ProjectControllerTests.cpp | 390 ++++++++++++++ tests/ProjectCopyTests.cpp | 420 +++++++++++++++ tests/ProjectDropTests.cpp | 245 +++++++++ tests/ProjectExportFailureTests.cpp | 177 +++++++ tests/ProjectExportTests.cpp | 261 +++++++++ tests/ProjectFixtures.h | 63 +++ tests/ProjectManifestTests.cpp | 473 +++++++++++++++++ tests/ProjectOpenTests.cpp | 305 +++++++++++ tests/ProjectReceiveTests.cpp | 264 +++++++++ tests/ProjectReplaceTests.cpp | 372 +++++++++++++ tests/ProjectResizeFailureTests.cpp | 148 ++++++ tests/ProjectResizeTests.cpp | 163 ++++++ tests/ProjectSessionTests.cpp | 304 +++++++++++ tests/ProjectTests.cpp | 417 +++++++++++++++ tests/ProjectWorkspaceFlowTests.cpp | 460 ++++++++++++++++ tests/ProjectWorkspaceSignalTests.cpp | 133 +++++ tests/ProjectWorkspaceTests.cpp | 362 +++++++++++++ tests/ProjectWorkspaceViewTests.cpp | 391 ++++++++++++++ tests/RasterSnapshotSessionTests.cpp | 161 ++++++ tests/RasterSnapshotTests.cpp | 454 ++++++++++++++++ tests/RemoveBackgroundTests.cpp | 181 +++++++ tests/RenderFixtures.h | 131 +++++ tests/SelectMenuTests.cpp | 372 +++++++++++++ tests/SelectionCanvasFixtures.h | 64 +++ tests/SelectionCanvasTests.cpp | 323 ++++++++++++ tests/SelectionClipboardTests.cpp | 471 +++++++++++++++++ tests/SelectionEditTests.cpp | 427 +++++++++++++++ tests/SelectionFixtures.h | 54 ++ tests/SelectionOverlayTests.cpp | 343 ++++++++++++ tests/SelectionSignalTests.cpp | 161 ++++++ tests/SelectionSourceTests.cpp | 241 +++++++++ tests/SelectionTests.cpp | 398 ++++++++++++++ tests/SessionFixtures.h | 60 +++ tests/SessionGateTests.cpp | 177 +++++++ tests/SessionRecord.h | 112 ++++ tests/ShapeCanvasTests.cpp | 485 +++++++++++++++++ tests/ShapeLayerTests.cpp | 460 ++++++++++++++++ tests/ShapeToolTests.cpp | 401 ++++++++++++++ tests/SliderSnapTests.cpp | 135 +++++ tests/SmartEditTests.cpp | 497 +++++++++++++++++ tests/SmearCanvasTests.cpp | 138 +++++ tests/SmudgeLiquifyTests.cpp | 403 ++++++++++++++ tests/SpotHealingTests.cpp | 300 +++++++++++ tests/SubjectRemovalFailureTests.cpp | 55 ++ tests/SubjectRemovalModelTests.cpp | 60 +++ tests/SubjectRemovalTests.cpp | 312 +++++++++++ tests/TextLayoutTests.cpp | 394 ++++++++++++++ tests/TiledLayerTests.cpp | 358 +++++++++++++ tests/TiledPieceTests.cpp | 185 +++++++ tests/TiledStrokeTests.cpp | 470 +++++++++++++++++ tests/ToolIconTests.cpp | 348 ++++++++++++ tests/TransformInspectorTests.cpp | 310 +++++++++++ tests/TransformOverlayTests.cpp | 219 ++++++++ tests/TransformPressTests.cpp | 325 ++++++++++++ tests/TransformSessionTests.cpp | 409 ++++++++++++++ tests/TransformSnapTests.cpp | 109 ++++ tests/TransformTests.cpp | 423 +++++++++++++++ tests/TypeControlsFixtures.h | 56 ++ tests/TypeControlsTests.cpp | 465 ++++++++++++++++ tests/TypeSessionTests.cpp | 498 +++++++++++++++++ tests/TypeStoreTests.cpp | 239 +++++++++ tests/TypeToolTests.cpp | 221 ++++++++ tests/fixtures/ToolIcons.png | Bin 0 -> 20050 bytes tests/fixtures/p3-nclx.heic | Bin 0 -> 1013 bytes tests/fixtures/premultiplied.heic | Bin 0 -> 1169 bytes tests/fixtures/red.heic | Bin 0 -> 1150 bytes tests/fixtures/turned-p3.heic | Bin 0 -> 1403 bytes 564 files changed, 100804 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CMakeLists.txt create mode 100644 Dockerfile.dev create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TASKS.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 kernels/AdjustPixels.c create mode 100644 kernels/AdjustPixels.h create mode 100644 kernels/BrushPixels.c create mode 100644 kernels/BrushPixels.h create mode 100644 kernels/ContentFill.c create mode 100644 kernels/ContentFill.h create mode 100644 kernels/HealPixels.c create mode 100644 kernels/HealPixels.h create mode 100644 kernels/LensPixels.c create mode 100644 kernels/LensPixels.h create mode 100644 kernels/LevelsPixels.c create mode 100644 kernels/LevelsPixels.h create mode 100644 kernels/NoisePixels.c create mode 100644 kernels/NoisePixels.h create mode 100644 kernels/WandPixels.c create mode 100644 kernels/WandPixels.h create mode 100644 licenses/U-2-Net.txt create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/icons/app-icon-128.png create mode 100644 packaging/icons/app-icon-16.png create mode 100644 packaging/icons/app-icon-256.png create mode 100644 packaging/icons/app-icon-32.png create mode 100644 packaging/icons/app-icon-512.png create mode 100644 packaging/icons/app-icon-64.png create mode 100644 packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop create mode 100644 packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml create mode 100755 scripts/dev.sh create mode 100755 scripts/distro-check.sh create mode 100644 scripts/distro/arch/Dockerfile create mode 100644 scripts/distro/fedora/Dockerfile create mode 100644 scripts/distro/nixos/Dockerfile create mode 100755 scripts/heic-fixtures.sh create mode 100644 scripts/heic-fixtures/Dockerfile create mode 100755 scripts/heic-fixtures/generate.sh create mode 100644 scripts/heic-fixtures/sources.cpp create mode 100755 scripts/install/arch.sh create mode 100755 scripts/install/fedora.sh create mode 100755 scripts/install/nixos.sh create mode 100755 scripts/install/ubuntu.sh create mode 100755 scripts/lint.py create mode 100755 scripts/mutate.py create mode 100644 src/CompositorApp.cpp create mode 100644 src/ContentView+Panels.cpp create mode 100644 src/ContentView+Status.cpp create mode 100644 src/ContentView.cpp create mode 100644 src/ContentView.h create mode 100644 src/Document/AdjustmentEditing+Session.h create mode 100644 src/Document/AdjustmentEditing.cpp create mode 100644 src/Document/BlurTool+Session.h create mode 100644 src/Document/BlurTool.cpp create mode 100644 src/Document/BrushStroke+Commit.cpp create mode 100644 src/Document/BrushStroke+Stroke.cpp create mode 100644 src/Document/BrushStroke.cpp create mode 100644 src/Document/BrushStroke.h create mode 100644 src/Document/CanvasSize.cpp create mode 100644 src/Document/CanvasSize.h create mode 100644 src/Document/CloneStamp+Session.h create mode 100644 src/Document/CloneStamp.cpp create mode 100644 src/Document/CloneStamp.h create mode 100644 src/Document/ColorPalette+Session.h create mode 100644 src/Document/ColorPalette.cpp create mode 100644 src/Document/ColorPalette.h create mode 100644 src/Document/ContentFill.cpp create mode 100644 src/Document/ContentFill.h create mode 100644 src/Document/Crop+Session.h create mode 100644 src/Document/Crop.cpp create mode 100644 src/Document/Crop.h create mode 100644 src/Document/Curves.cpp create mode 100644 src/Document/Curves.h create mode 100644 src/Document/Distort+Session.h create mode 100644 src/Document/Distort.cpp create mode 100644 src/Document/Distort.h create mode 100644 src/Document/DocumentHistory.cpp create mode 100644 src/Document/DocumentHistory.h create mode 100644 src/Document/EditorSession+Brush.cpp create mode 100644 src/Document/EditorSession+Brush.h create mode 100644 src/Document/EditorSession+Imports.cpp create mode 100644 src/Document/EditorSession+Jobs.h create mode 100644 src/Document/EditorSession+Layers.cpp create mode 100644 src/Document/EditorSession+Model.cpp create mode 100644 src/Document/EditorSession+Model.h create mode 100644 src/Document/EditorSession+Projects.cpp create mode 100644 src/Document/EditorSession+Projects.h create mode 100644 src/Document/EditorSession+Transform.cpp create mode 100644 src/Document/EditorSession.cpp create mode 100644 src/Document/EditorSession.h create mode 100644 src/Document/Filters+Session.cpp create mode 100644 src/Document/Filters+Session.h create mode 100644 src/Document/Filters.cpp create mode 100644 src/Document/Filters.h create mode 100644 src/Document/FloatingSelection+Session.h create mode 100644 src/Document/FloatingSelection.cpp create mode 100644 src/Document/FloatingSelection.h create mode 100644 src/Document/Gradient+Session.h create mode 100644 src/Document/Gradient.cpp create mode 100644 src/Document/Gradient.h create mode 100644 src/Document/GuidedMatte.cpp create mode 100644 src/Document/GuidedMatte.h create mode 100644 src/Document/HueSaturation+Filter.cpp create mode 100644 src/Document/HueSaturation+Session.cpp create mode 100644 src/Document/HueSaturation+Session.h create mode 100644 src/Document/HueSaturation.cpp create mode 100644 src/Document/HueSaturation.h create mode 100644 src/Document/ImageAdjustments.cpp create mode 100644 src/Document/ImageAdjustments.h create mode 100644 src/Document/LayerAdjustment+Session.h create mode 100644 src/Document/LayerAdjustment.cpp create mode 100644 src/Document/LayerAdjustment.h create mode 100644 src/Document/LayerAppearance+Session.h create mode 100644 src/Document/LayerAppearance.cpp create mode 100644 src/Document/LayerAppearance.h create mode 100644 src/Document/LayerEffects+Renderer.cpp create mode 100644 src/Document/LayerEffects+Renderer.h create mode 100644 src/Document/LayerEffects+Session.cpp create mode 100644 src/Document/LayerEffects+Session.h create mode 100644 src/Document/LayerEffects.cpp create mode 100644 src/Document/LayerEffects.h create mode 100644 src/Document/LayerFlip+Session.h create mode 100644 src/Document/LayerFlip.cpp create mode 100644 src/Document/LayerGroups+Session.h create mode 100644 src/Document/LayerGroups.cpp create mode 100644 src/Document/LayerGroups.h create mode 100644 src/Document/LayerMask+Session.h create mode 100644 src/Document/LayerMask.cpp create mode 100644 src/Document/LayerMask.h create mode 100644 src/Document/LayerMerge+Session.h create mode 100644 src/Document/LayerMerge.cpp create mode 100644 src/Document/LayerTransform.cpp create mode 100644 src/Document/LayerTransform.h create mode 100644 src/Document/Levels+Session.h create mode 100644 src/Document/Levels.cpp create mode 100644 src/Document/Levels.h create mode 100644 src/Document/LevelsAutomatic+Session.h create mode 100644 src/Document/LevelsAutomatic.cpp create mode 100644 src/Document/LevelsAutomatic.h create mode 100644 src/Document/LiveLayerMask+Session.h create mode 100644 src/Document/LiveLayerMask.cpp create mode 100644 src/Document/LiveLayerMask.h create mode 100644 src/Document/MagicWand+Session.h create mode 100644 src/Document/MagicWand.cpp create mode 100644 src/Document/MagicWand.h create mode 100644 src/Document/MaskTracing+Session.h create mode 100644 src/Document/MaskTracing.cpp create mode 100644 src/Document/MaskTracing.h create mode 100644 src/Document/PixelAdjust.cpp create mode 100644 src/Document/PixelAdjust.h create mode 100644 src/Document/PixelInvert.cpp create mode 100644 src/Document/PixelInvert.h create mode 100644 src/Document/ProjectWorkspace.cpp create mode 100644 src/Document/ProjectWorkspace.h create mode 100644 src/Document/Selection+Session.h create mode 100644 src/Document/Selection.cpp create mode 100644 src/Document/Selection.h create mode 100644 src/Document/SelectionClipboard+Session.h create mode 100644 src/Document/SelectionClipboard.cpp create mode 100644 src/Document/SelectionClipboard.h create mode 100644 src/Document/SelectionEdits+Session.h create mode 100644 src/Document/SelectionEdits.cpp create mode 100644 src/Document/SelectionEdits.h create mode 100644 src/Document/ShapeTool+Session.h create mode 100644 src/Document/ShapeTool.cpp create mode 100644 src/Document/ShapeTool.h create mode 100644 src/Document/SmudgeLiquify+Session.h create mode 100644 src/Document/SmudgeLiquify.cpp create mode 100644 src/Document/SmudgeLiquify.h create mode 100644 src/Document/SubjectRemoval.cpp create mode 100644 src/Document/SubjectRemoval.h create mode 100644 src/Document/TypeTool+Session.h create mode 100644 src/Document/TypeTool.cpp create mode 100644 src/Document/TypeTool.h create mode 100644 src/IO/CanvasResizer.cpp create mode 100644 src/IO/CanvasResizer.h create mode 100644 src/IO/ImageExporter.cpp create mode 100644 src/IO/ImageExporter.h create mode 100644 src/IO/ImageFileDrop.cpp create mode 100644 src/IO/ImageFileDrop.h create mode 100644 src/IO/ImageImporter.cpp create mode 100644 src/IO/ImageImporter.h create mode 100644 src/IO/ImageResizer+Session.h create mode 100644 src/IO/ImageResizer.cpp create mode 100644 src/IO/ImageResizer.h create mode 100644 src/IO/ProjectController+Export.cpp create mode 100644 src/IO/ProjectController+Resize.cpp create mode 100644 src/IO/ProjectController.cpp create mode 100644 src/IO/ProjectController.h create mode 100644 src/IO/ProjectStore+Adjustment.cpp create mode 100644 src/IO/ProjectStore+Effects.cpp create mode 100644 src/IO/ProjectStore+Json.h create mode 100644 src/IO/ProjectStore+Manifest.cpp create mode 100644 src/IO/ProjectStore.cpp create mode 100644 src/IO/ProjectStore.h create mode 100644 src/Logging.cpp create mode 100644 src/Logging.h create mode 100644 src/Rendering/AdjustmentSurface.cpp create mode 100644 src/Rendering/AdjustmentSurface.h create mode 100644 src/Rendering/BrushCoverage.cpp create mode 100644 src/Rendering/BrushCoverage.h create mode 100644 src/Rendering/BrushCursorOverlay.cpp create mode 100644 src/Rendering/BrushCursorOverlay.h create mode 100644 src/Rendering/CanvasViewport.cpp create mode 100644 src/Rendering/CanvasViewport.h create mode 100644 src/Rendering/DownsampleCache.cpp create mode 100644 src/Rendering/DownsampleCache.h create mode 100644 src/Rendering/EditorCanvas+Brush.cpp create mode 100644 src/Rendering/EditorCanvas+Crop.cpp create mode 100644 src/Rendering/EditorCanvas+Cursors.cpp create mode 100644 src/Rendering/EditorCanvas+Effects.cpp create mode 100644 src/Rendering/EditorCanvas+Gradient.cpp create mode 100644 src/Rendering/EditorCanvas+Navigation.cpp create mode 100644 src/Rendering/EditorCanvas+Picking.cpp create mode 100644 src/Rendering/EditorCanvas+Selection.cpp create mode 100644 src/Rendering/EditorCanvas+Shape.cpp create mode 100644 src/Rendering/EditorCanvas+Transform.cpp create mode 100644 src/Rendering/EditorCanvas.cpp create mode 100644 src/Rendering/EditorCanvas.h create mode 100644 src/Rendering/EffectsPreviewCache.cpp create mode 100644 src/Rendering/EffectsPreviewCache.h create mode 100644 src/Rendering/EyedropperIcon.cpp create mode 100644 src/Rendering/EyedropperIcon.h create mode 100644 src/Rendering/HslBlend.cpp create mode 100644 src/Rendering/HslBlend.h create mode 100644 src/Rendering/InlineTextEditor+Editing.cpp create mode 100644 src/Rendering/InlineTextEditor+Input.cpp create mode 100644 src/Rendering/InlineTextEditor+Pointer.cpp create mode 100644 src/Rendering/InlineTextEditor.cpp create mode 100644 src/Rendering/InlineTextEditor.h create mode 100644 src/Rendering/LayerEffectsKernel.cpp create mode 100644 src/Rendering/LayerEffectsKernel.h create mode 100644 src/Rendering/LayerEffectsSurface.cpp create mode 100644 src/Rendering/LayerEffectsSurface.h create mode 100644 src/Rendering/LayerRenderer.cpp create mode 100644 src/Rendering/LayerRenderer.h create mode 100644 src/Rendering/LiveMaskRenderer.cpp create mode 100644 src/Rendering/LiveMaskRenderer.h create mode 100644 src/Rendering/PoolMap.h create mode 100644 src/Rendering/RasterSnapshot.cpp create mode 100644 src/Rendering/RasterSnapshot.h create mode 100644 src/Rendering/SampleRingOverlay.cpp create mode 100644 src/Rendering/SampleRingOverlay.h create mode 100644 src/Rendering/SelectionIcons.cpp create mode 100644 src/Rendering/SelectionIcons.h create mode 100644 src/Rendering/TextLayout.cpp create mode 100644 src/Rendering/TextLayout.h create mode 100644 src/Rendering/TiledLayerRenderer.cpp create mode 100644 src/Rendering/TiledLayerRenderer.h create mode 100644 src/Rendering/TransformOverlay.cpp create mode 100644 src/Rendering/TransformOverlay.h create mode 100644 src/UI/BlendModePicker.cpp create mode 100644 src/UI/BlendModePicker.h create mode 100644 src/UI/BrushControls.cpp create mode 100644 src/UI/BrushControls.h create mode 100644 src/UI/ByteCounts.cpp create mode 100644 src/UI/ByteCounts.h create mode 100644 src/UI/CanvasSizeSheet.cpp create mode 100644 src/UI/CanvasSizeSheet.h create mode 100644 src/UI/CanvasThumbnail.cpp create mode 100644 src/UI/CanvasThumbnail.h create mode 100644 src/UI/ColorPaletteControls.cpp create mode 100644 src/UI/ColorPaletteControls.h create mode 100644 src/UI/ColorPickerSheet.cpp create mode 100644 src/UI/ColorPickerSheet.h create mode 100644 src/UI/CompositorMenus.cpp create mode 100644 src/UI/CompositorMenus.h create mode 100644 src/UI/CropControls.cpp create mode 100644 src/UI/CropControls.h create mode 100644 src/UI/CurvesControls.cpp create mode 100644 src/UI/CurvesControls.h create mode 100644 src/UI/EffectsSheet.cpp create mode 100644 src/UI/EffectsSheet.h create mode 100644 src/UI/FilterSheet.cpp create mode 100644 src/UI/FilterSheet.h create mode 100644 src/UI/FloatingPanel.cpp create mode 100644 src/UI/FloatingPanel.h create mode 100644 src/UI/GradientControls.cpp create mode 100644 src/UI/GradientControls.h create mode 100644 src/UI/HueSaturationSheet.cpp create mode 100644 src/UI/HueSaturationSheet.h create mode 100644 src/UI/ImageSizeSheet.cpp create mode 100644 src/UI/ImageSizeSheet.h create mode 100644 src/UI/JPEGExportSheet.cpp create mode 100644 src/UI/JPEGExportSheet.h create mode 100644 src/UI/LassoControls.cpp create mode 100644 src/UI/LassoControls.h create mode 100644 src/UI/LayerAppearanceControls.cpp create mode 100644 src/UI/LayerAppearanceControls.h create mode 100644 src/UI/LayerIcons.cpp create mode 100644 src/UI/LayerIcons.h create mode 100644 src/UI/LayerMaskMenu.cpp create mode 100644 src/UI/LayerMaskMenu.h create mode 100644 src/UI/LayersPanel.cpp create mode 100644 src/UI/LayersPanel.h create mode 100644 src/UI/LevelsSheet.cpp create mode 100644 src/UI/LevelsSheet.h create mode 100644 src/UI/NativeLayerList+Buttons.cpp create mode 100644 src/UI/NativeLayerList+Cell.cpp create mode 100644 src/UI/NativeLayerList+Drag.cpp create mode 100644 src/UI/NativeLayerList+Effects.cpp create mode 100644 src/UI/NativeLayerList.cpp create mode 100644 src/UI/NativeLayerList.h create mode 100644 src/UI/NavigationToolHeader.cpp create mode 100644 src/UI/NavigationToolHeader.h create mode 100644 src/UI/NewCanvasSheet.cpp create mode 100644 src/UI/NewCanvasSheet.h create mode 100644 src/UI/OmarchyTheme.cpp create mode 100644 src/UI/OmarchyTheme.h create mode 100644 src/UI/ProjectTabs.cpp create mode 100644 src/UI/ProjectTabs.h create mode 100644 src/UI/ProjectWorkspaceView.cpp create mode 100644 src/UI/ProjectWorkspaceView.h create mode 100644 src/UI/ShapeControls.cpp create mode 100644 src/UI/ShapeControls.h create mode 100644 src/UI/SliderSnap.cpp create mode 100644 src/UI/SliderSnap.h create mode 100644 src/UI/ToolHeaderStyle.cpp create mode 100644 src/UI/ToolHeaderStyle.h create mode 100644 src/UI/ToolIcons.cpp create mode 100644 src/UI/ToolIcons.h create mode 100644 src/UI/TransformInspector.cpp create mode 100644 src/UI/TransformInspector.h create mode 100644 src/UI/TypeControls.cpp create mode 100644 src/UI/TypeControls.h create mode 100644 tests/AddressSpaceLimit.h create mode 100644 tests/AdjustmentEditorTests.cpp create mode 100644 tests/AdjustmentLayerTests.cpp create mode 100644 tests/AdjustmentPanelTests.cpp create mode 100644 tests/AdjustmentSurfaceTests.cpp create mode 100644 tests/BlendModePickerTests.cpp create mode 100644 tests/BlendModeTests.cpp create mode 100644 tests/BlendShortcutTests.cpp create mode 100644 tests/BlurToolTests.cpp create mode 100644 tests/BrushCanvasFixtures.h create mode 100644 tests/BrushCanvasTests.cpp create mode 100644 tests/BrushControlsTests.cpp create mode 100644 tests/BrushCoverageTests.cpp create mode 100644 tests/BrushCursorOverlayTests.cpp create mode 100644 tests/BrushFixtures.h create mode 100644 tests/BrushGestureTests.cpp create mode 100644 tests/BrushIntersectionTests.cpp create mode 100644 tests/BrushKeyTests.cpp create mode 100644 tests/BrushPerformanceTests.cpp create mode 100644 tests/BrushPreviewTests.cpp create mode 100644 tests/BrushRasterTests.cpp create mode 100644 tests/BrushSessionTests.cpp create mode 100644 tests/BrushTests.cpp create mode 100644 tests/ByteCountsTests.cpp create mode 100644 tests/CanvasDisplayTests.cpp create mode 100644 tests/CanvasFixtures.h create mode 100644 tests/CanvasNavigationTests.cpp create mode 100644 tests/CanvasPerformanceTests.cpp create mode 100644 tests/CanvasSizeSheetTests.cpp create mode 100644 tests/CanvasSizeTests.cpp create mode 100644 tests/CanvasThumbnailTests.cpp create mode 100644 tests/ClippingStackTests.cpp create mode 100644 tests/CloneStampCanvasTests.cpp create mode 100644 tests/CloneStampTests.cpp create mode 100644 tests/ColorPaletteControlsTests.cpp create mode 100644 tests/ColorPaletteFixtures.h create mode 100644 tests/ColorPickerSheetTests.cpp create mode 100644 tests/ColorPickerTests.cpp create mode 100644 tests/ColorSwatchTests.cpp create mode 100644 tests/CompositorMenusTests.cpp create mode 100644 tests/CompositorTests.cpp create mode 100644 tests/CompositorUITestsLaunchTests.cpp create mode 100644 tests/ContentViewStatusTests.cpp create mode 100644 tests/ContentViewTests.cpp create mode 100644 tests/CropCanvasTests.cpp create mode 100644 tests/CropControlsTests.cpp create mode 100644 tests/CropFailureTests.cpp create mode 100644 tests/CropFixtures.h create mode 100644 tests/CropSessionTests.cpp create mode 100644 tests/CropTests.cpp create mode 100644 tests/CursorTests.cpp create mode 100644 tests/CurvesControlsTests.cpp create mode 100644 tests/DesktopIntegrationTests.cpp create mode 100644 tests/DialogDesk.h create mode 100644 tests/DistortCanvasTests.cpp create mode 100644 tests/DistortMaskTests.cpp create mode 100644 tests/DistortTests.cpp create mode 100644 tests/DownsampleTests.cpp create mode 100644 tests/DrawCoverageTests.cpp create mode 100644 tests/EditorCanvasTests.cpp create mode 100644 tests/EditorSessionSignalTests.cpp create mode 100644 tests/EditorSessionTests.cpp create mode 100644 tests/EffectsCanvasTests.cpp create mode 100644 tests/EffectsFailureTests.cpp create mode 100644 tests/EffectsPanelTests.cpp create mode 100644 tests/EffectsPreviewCacheTests.cpp create mode 100644 tests/ExportTests.cpp create mode 100644 tests/EyedropperFixtures.h create mode 100644 tests/EyedropperTests.cpp create mode 100644 tests/FilterControlsTests.cpp create mode 100644 tests/FilterFailureTests.cpp create mode 100644 tests/FilterModelTests.cpp create mode 100644 tests/FilterSessionTests.cpp create mode 100644 tests/FilterSheetTests.cpp create mode 100644 tests/FilterTests.cpp create mode 100644 tests/FiltersTests.cpp create mode 100644 tests/FloatingPanelTests.cpp create mode 100644 tests/FloatingSelectionTests.cpp create mode 100644 tests/FolderMaskClipTests.cpp create mode 100644 tests/GradientCanvasTests.cpp create mode 100644 tests/GradientTests.cpp create mode 100644 tests/GroupSessionTests.cpp create mode 100644 tests/GroupTests.cpp create mode 100644 tests/GroupingSelectionTests.cpp create mode 100644 tests/GuidedMatteTests.cpp create mode 100644 tests/HighDpiCanvasTests.cpp create mode 100644 tests/HistoryTests.cpp create mode 100644 tests/HueSaturationCanvasTests.cpp create mode 100644 tests/HueSaturationFailureTests.cpp create mode 100644 tests/HueSaturationModelTests.cpp create mode 100644 tests/HueSaturationSessionTests.cpp create mode 100644 tests/HueSaturationSheetFixtures.h create mode 100644 tests/HueSaturationSheetTests.cpp create mode 100644 tests/HueSaturationSpectrumTests.cpp create mode 100644 tests/HueSaturationTests.cpp create mode 100644 tests/ImageAdjustmentModelTests.cpp create mode 100644 tests/ImageAdjustmentTests.cpp create mode 100644 tests/ImageFileDropTests.cpp create mode 100644 tests/ImageImportSessionTests.cpp create mode 100644 tests/ImageImportTests.cpp create mode 100644 tests/ImageSizeSheetTests.cpp create mode 100644 tests/ImageSizeTests.cpp create mode 100644 tests/InlineTextDrawFixtures.h create mode 100644 tests/InlineTextDrawTests.cpp create mode 100644 tests/InlineTextEditorTests.cpp create mode 100644 tests/InlineTextFixtures.h create mode 100644 tests/InlineTextHandleTests.cpp create mode 100644 tests/InlineTextInputTests.cpp create mode 100644 tests/InlineTextPointerTests.cpp create mode 100644 tests/InlineTextPreeditTests.cpp create mode 100644 tests/JPEGExportSheetTests.cpp create mode 100644 tests/JPEGExportTests.cpp create mode 100644 tests/LassoControlsTests.cpp create mode 100644 tests/LayerAdjustmentTests.cpp create mode 100644 tests/LayerAppearanceControlsTests.cpp create mode 100644 tests/LayerAppearanceTests.cpp create mode 100644 tests/LayerDragFixtures.h create mode 100644 tests/LayerDragGestureTests.cpp create mode 100644 tests/LayerDragTests.cpp create mode 100644 tests/LayerEffectRowTests.cpp create mode 100644 tests/LayerEffectsKernelTests.cpp create mode 100644 tests/LayerEffectsRendererTests.cpp create mode 100644 tests/LayerEffectsSessionTests.cpp create mode 100644 tests/LayerEffectsSurfaceTests.cpp create mode 100644 tests/LayerEffectsTests.cpp create mode 100644 tests/LayerFlipTests.cpp create mode 100644 tests/LayerListFocusTests.cpp create mode 100644 tests/LayerMaskEditTests.cpp create mode 100644 tests/LayerMaskSessionTests.cpp create mode 100644 tests/LayerMaskTests.cpp create mode 100644 tests/LayerMergeTests.cpp create mode 100644 tests/LayerRendererTests.cpp create mode 100644 tests/LayerTests.cpp create mode 100644 tests/LayersPanelTests.cpp create mode 100644 tests/LevelsCanvasTests.cpp create mode 100644 tests/LevelsEdgeTests.cpp create mode 100644 tests/LevelsFailureTests.cpp create mode 100644 tests/LevelsFixtures.h create mode 100644 tests/LevelsHandleTests.cpp create mode 100644 tests/LevelsModelTests.cpp create mode 100644 tests/LevelsSessionTests.cpp create mode 100644 tests/LevelsSheetFixtures.h create mode 100644 tests/LevelsSheetTests.cpp create mode 100644 tests/LevelsTests.cpp create mode 100644 tests/LiveMaskBakeTests.cpp create mode 100644 tests/LiveMaskDeleteTests.cpp create mode 100644 tests/LiveMaskEditTests.cpp create mode 100644 tests/LiveMaskFixtures.h create mode 100644 tests/LiveMaskRendererTests.cpp create mode 100644 tests/LiveMaskSessionTests.cpp create mode 100644 tests/LiveMaskTests.cpp create mode 100644 tests/MagicWandTests.cpp create mode 100644 tests/MaskPlacementTests.cpp create mode 100644 tests/MaskSignalTests.cpp create mode 100644 tests/MaskTracingTests.cpp create mode 100644 tests/MaskTransformTests.cpp create mode 100644 tests/MenuFixtures.h create mode 100644 tests/NativeLayerListTests.cpp create mode 100644 tests/NavigationToolHeaderTests.cpp create mode 100644 tests/NewCanvasSheetTests.cpp create mode 100644 tests/OmarchyThemeTests.cpp create mode 100644 tests/PickingTests.cpp create mode 100644 tests/PixelEditTests.cpp create mode 100644 tests/PixelInvertTests.cpp create mode 100644 tests/PixelMoveTests.cpp create mode 100644 tests/PoolMapTests.cpp create mode 100644 tests/ProjectControllerTests.cpp create mode 100644 tests/ProjectCopyTests.cpp create mode 100644 tests/ProjectDropTests.cpp create mode 100644 tests/ProjectExportFailureTests.cpp create mode 100644 tests/ProjectExportTests.cpp create mode 100644 tests/ProjectFixtures.h create mode 100644 tests/ProjectManifestTests.cpp create mode 100644 tests/ProjectOpenTests.cpp create mode 100644 tests/ProjectReceiveTests.cpp create mode 100644 tests/ProjectReplaceTests.cpp create mode 100644 tests/ProjectResizeFailureTests.cpp create mode 100644 tests/ProjectResizeTests.cpp create mode 100644 tests/ProjectSessionTests.cpp create mode 100644 tests/ProjectTests.cpp create mode 100644 tests/ProjectWorkspaceFlowTests.cpp create mode 100644 tests/ProjectWorkspaceSignalTests.cpp create mode 100644 tests/ProjectWorkspaceTests.cpp create mode 100644 tests/ProjectWorkspaceViewTests.cpp create mode 100644 tests/RasterSnapshotSessionTests.cpp create mode 100644 tests/RasterSnapshotTests.cpp create mode 100644 tests/RemoveBackgroundTests.cpp create mode 100644 tests/RenderFixtures.h create mode 100644 tests/SelectMenuTests.cpp create mode 100644 tests/SelectionCanvasFixtures.h create mode 100644 tests/SelectionCanvasTests.cpp create mode 100644 tests/SelectionClipboardTests.cpp create mode 100644 tests/SelectionEditTests.cpp create mode 100644 tests/SelectionFixtures.h create mode 100644 tests/SelectionOverlayTests.cpp create mode 100644 tests/SelectionSignalTests.cpp create mode 100644 tests/SelectionSourceTests.cpp create mode 100644 tests/SelectionTests.cpp create mode 100644 tests/SessionFixtures.h create mode 100644 tests/SessionGateTests.cpp create mode 100644 tests/SessionRecord.h create mode 100644 tests/ShapeCanvasTests.cpp create mode 100644 tests/ShapeLayerTests.cpp create mode 100644 tests/ShapeToolTests.cpp create mode 100644 tests/SliderSnapTests.cpp create mode 100644 tests/SmartEditTests.cpp create mode 100644 tests/SmearCanvasTests.cpp create mode 100644 tests/SmudgeLiquifyTests.cpp create mode 100644 tests/SpotHealingTests.cpp create mode 100644 tests/SubjectRemovalFailureTests.cpp create mode 100644 tests/SubjectRemovalModelTests.cpp create mode 100644 tests/SubjectRemovalTests.cpp create mode 100644 tests/TextLayoutTests.cpp create mode 100644 tests/TiledLayerTests.cpp create mode 100644 tests/TiledPieceTests.cpp create mode 100644 tests/TiledStrokeTests.cpp create mode 100644 tests/ToolIconTests.cpp create mode 100644 tests/TransformInspectorTests.cpp create mode 100644 tests/TransformOverlayTests.cpp create mode 100644 tests/TransformPressTests.cpp create mode 100644 tests/TransformSessionTests.cpp create mode 100644 tests/TransformSnapTests.cpp create mode 100644 tests/TransformTests.cpp create mode 100644 tests/TypeControlsFixtures.h create mode 100644 tests/TypeControlsTests.cpp create mode 100644 tests/TypeSessionTests.cpp create mode 100644 tests/TypeStoreTests.cpp create mode 100644 tests/TypeToolTests.cpp create mode 100644 tests/fixtures/ToolIcons.png create mode 100644 tests/fixtures/p3-nclx.heic create mode 100644 tests/fixtures/premultiplied.heic create mode 100644 tests/fixtures/red.heic create mode 100644 tests/fixtures/turned-p3.heic diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..29ebbde --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +# A version tag builds the three packages, each on its own system, and publishes them. +name: Release +on: + push: + tags: ['v*'] +permissions: + contents: write +jobs: + deb: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + # The dev image carries ONNX Runtime, its notices and the model. + - run: docker build -t omaphoto-dev -f Dockerfile.dev . + - run: | + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-dev sh -c ' + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ + -DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx && + cmake --build build --target omaphoto && cd build && cpack -G DEB' + mv build/omaphoto_*.deb omaphoto_amd64.deb + - uses: actions/upload-artifact@v4 + with: + name: deb + path: omaphoto_amd64.deb + rpm: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: docker build -t omaphoto-fedora scripts/distro/fedora + - run: | + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-fedora sh -c ' + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ + -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx && + cmake --build build --target omaphoto && cd build && cpack -G RPM' + mv build/omaphoto-*.x86_64.rpm omaphoto.x86_64.rpm + - uses: actions/upload-artifact@v4 + with: + name: rpm + path: omaphoto.x86_64.rpm + arch: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: docker build -t omaphoto-arch scripts/distro/arch + # makepkg refuses root; a user of the runner's id builds. + - run: | + docker run --rm -v "$PWD:/src" omaphoto-arch sh -c " + useradd -u $(id -u) builder && chown -R builder /src && + su builder -c 'cd /src/packaging/arch && makepkg -f'" + mv packaging/arch/omaphoto-[0-9]*-x86_64.pkg.tar.zst omaphoto-x86_64.pkg.tar.zst + - uses: actions/upload-artifact@v4 + with: + name: arch + path: omaphoto-x86_64.pkg.tar.zst + publish: + needs: [deb, rpm, arch] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: packages + - working-directory: packages + run: sha256sum omaphoto_amd64.deb omaphoto.x86_64.rpm omaphoto-x86_64.pkg.tar.zst > SHA256SUMS + - env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" packages/* --title "OmaPhoto ${GITHUB_REF_NAME#v}" \ + --notes "Install with the script for your system: see the README's Install section." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb712e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +/build-*/ +/result +/packaging/arch/src/ +/packaging/arch/pkg/ +/packaging/arch/*.pkg.tar.zst +/packaging/arch/u2net.onnx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ac8e3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# OmaPhoto — agent notes + +OmaPhoto is the Linux port of Compositor, the macOS app at https://github.com/robbietilton/Compositor. That repository's Swift tree (`Compositor/`, `CompositorTests/`) is the specification; this repository mirrors it. Keep a checkout of it beside this one to read the Swift sources; never edit it for OmaPhoto work. + +## Trees + +- The repository root: the app. C++20, Qt 6 Widgets (floor Qt 6.4), CMake, Qt Test. CPU raster only: QPainter plus the C kernels in `kernels/`, copied unchanged from Compositor's `Compositor/Rendering/*.c` and compiled in place. +- `packaging/`: the desktop entry, MIME type, icons (Compositor's app icon) and the Arch `PKGBUILD`; `flake.nix` at the root for NixOS. +- `scripts/install/`: one install script per distribution family, each fetching the latest release's package. +- `.github/workflows/release.yml`: a `v*` tag builds the DEB, RPM and Arch package and publishes them. +- `TASKS.md`: ordered task list and progress truth. Its entries before 11.4b speak of the port's old home, `linux/` inside Compositor's repository. + +Names: the app is OmaPhoto (binary `omaphoto`, app id `io.github.ZacharyZhang_NY.OmaPhoto`, logging `omaphoto.*`, build options `OMAPHOTO_*`). Compositor's project format stays whole (`.comp`, `application/x-compositor-project`), so projects move between the two apps; C++ types keep their Swift twins' names (`CompositorApp`, `CompositorMenus`). + +## Mirror rule + +Each Swift file has one Linux twin under `src/` in the same folder (`Document`, `Rendering`, `UI`, `IO`) with the same type and function names. Tests in `tests/` twin `CompositorTests/`. A Swift file over 500 lines splits as `Name+Part`. A Swift extension of `EditorSession` declares its members in `Name+Session.h` (`EditorSession+Brush.h` and `EditorSession+Projects.h` for the extensions named after the session): C++ declares members inside the class, so `EditorSession.h`, the twin of `EditorSession.swift` with every stored property, includes each fragment inside its body. A fragment holds declarations alone, its extension's private helpers under `private:`; state stays in `EditorSession.h`, as Swift keeps stored properties in the class. Qt types are used directly: `QImage` for `CGImage`, `QRectF`, `QPointF`, `QTransform`, `QUuid`. No wrapper types. Nothing outside `UI/` includes `UI/` headers, except where the Swift source does. + +Platform adaptations, each forced by a missing Qt analog: + +- `ImportedImage` exposes pixels through `image()`, size through `size()`, identity through `identity()`. CoreGraphics backs a painted layer's `CGImage` with a lazy data provider; Qt has none. `image()` flattens painted tiles on first use, so commits, display and history never flatten a layer. Never cache its result in a model type. +- Image identity (`===` on `CGImage`) is `ImageIdentity`: the `RasterSnapshot` pointer for painted assets, `QImage::cacheKey()` otherwise. +- Layer pixels are `QImage::Format_RGBA8888_Premultiplied`; masks are `QImage::Format_Grayscale8`. `BrushRaster::context` makes both. +- Contexts are top-left already, so the CoreGraphics y-flips vanish. `BrushRaster::draw` has no `mask` flag: both CoreGraphics paths are a plain copy in Qt. It takes an optional source part instead of `CGImage.cropping(to:)`, which Qt cannot do without a copy. +- `BrushRaster::visibleRect` stands in for `boundingBoxOfClipPath`. +- Images are drawn into explicit target rects. The positional `drawImage(x, y, image)` scales by the source's device pixel ratio; CoreGraphics never does. +- `DownsampleCache` halves with its own Lanczos-3 filter (vImage has no Linux twin), threaded with QtConcurrent. Swift's label-overloaded `image(_:drawnAt:)` and `image(_:level:)` are `imageDrawnAt` and `imageAtLevel`. Its constructor takes a pixel budget so eviction can be tested; the app uses `shared()`. +- `LayerRenderer::draw` takes Swift's labelled arguments as an `Options` struct. QPainter has no mask clip, so a masked layer is composed aside in device pixels; the mask is drawn with the same placement into a zero-filled `Alpha8` surface and multiplied in 1:1 (`DestinationIn`), so nothing outside the mask survives; the result is composited with the layer's opacity and blend mode. Work that can throw finishes before `save()`. `LayerRenderer` needs a painter on a `QImage`. +- CoreGraphics multiplies nested mask clips inside the context; QPainter has no soft clip. `FolderMaskClip::apply` multiplies a folder mask into device-sized `Alpha8` coverage, `FolderMaskClip::draw` hands each layer the product of its folders' coverage (memoized per folder), and draw routines take it as `LayerRenderer::Options::clip`. +- `TiledLayerRenderer` keeps Swift's pieces, interiors, margins and piece cache, but not its clip surgery. CoreGraphics needs a transparency layer, cleared interiors, subtracted clip paths and device snapping so tiles blend exactly once; the twin draws the base, then for each piece clears and redraws the device pixels whose centres lie in its interior, a half-open `QRegion` computed per scanline, so neighbouring pieces share no pixel and leave none out at any rotation, and the region stays hard while the image keeps its antialiased outline (a path clip turns soft once `Antialiasing` is on). Where an interior ends with its image, the region opens one device pixel outward for that outline, measured through the painter's whole device transform, so it holds at any reduction and under unequal painter scales; the interior already spans the halving overhang there. All of it happens inside `LayerRenderer::composite`, which applies opacity, blend mode, mask and folder clip once. QPainter steps texture coordinates in fixed point, so a piece can differ from the whole image by 3/255 on noise (the tests allow 4 upright, 12 rotated as Swift does). A layer edge off the halving grid differs more, on macOS too: a piece keeps the halving spill past that edge, a whole image crops it (10/255 measured at 0.05×). +- Live strokes reuse that design. `LayerRenderer::composite` takes an optional `veil` callback that repaints the mask coverage after the mask is drawn (coverage starts full when the layer has no mask). `drawStroke` draws the unchanged pixels, then its pieces; where a piece reaches past the old bounds it repaints the coverage with a mask piece (white, then the old mask), so paint there is revealed, where Swift draws a second unmasked pass with holes. `drawMaskStroke` draws the layer once and lets mask pieces replace their interiors of the coverage, where Swift redraws the layer clipped to each piece. A commit's larger pieces take over stretches of the old outline that the base drew during the stroke; the base crops its halving spill, so that outline changes by up to 12/255 on commit, on macOS too. +- `LayerRenderer::drawBrushPreview` replaces Swift's even-odd clips with `CompositionMode_Source` copies into the aside surface, hard-edged (`composite` runs with nearest sampling and the layer's interpolation quality); the source stays clipped to the grid and a raster's base to the source bounds, as Swift's clips keep them. Its `BrushPreview` struct carries Swift's labelled arguments. A raster counts as a source by itself, so a caller never flattens a painted asset just to pass a non-nil image. It throws `std::logic_error` on an empty pixel grid, where Swift would divide by zero. +- `LiveMaskRenderer` has no `bounds`. Swift allocates its coverage and group surfaces over the clip's bounding box, one pixel per user unit, and clips with CoreGraphics; the twin's surfaces are the painter's device in device pixels, drawn with the painter's device transform, because clip coverage must be what `Options::clip` takes. `drawOwn` receives that coverage, and an outer clip (folder masks) multiplies in; a clipping stack composites its group one to one through `LayerRenderer::composite`, under the painter's own opacity as in Swift; `draw` saves and restores the painter around `drawOwn`, exceptions included. The canvas keeps this cheap by painting each dirty rect through an image surface (item 4.4). The constructor takes a pixel budget so tests can reach the limit; passing it, or failing to allocate, logs a warning on `omaphoto.rendering` where Swift skips in silence. +- `AdjustmentSurface::draw` allocates what the painter can show in device pixels (Swift: one pixel per user unit), lets the body draw in the painter's own coordinates, and draws the surface back under the painter's own opacity, composition mode and clip, as `CGContext.draw` inherits them. A surface that cannot be allocated, or passes the budget, logs a warning and draws nothing. On Linux it also gives the hand-blended HSL modes a backdrop on devices without readable pixels (widgets, pixmaps). +- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, scaled to 1000 pixels at most. `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`. +- `ProjectStore` is a namespace too. Swift's synthesized `Codable` is written by hand in `IO/ProjectStore+Manifest.cpp`: the same keys, the same refusals (wrong types, missing required keys, non-canonical UUIDs), unknown keys ignored, `CGPoint` and `CGSize` as two-number arrays, UUIDs upper case (`uuidString`), two-space indentation so the 4 MiB limit bites where Swift's does. Asset files carry the upper-case names macOS writes; Linux file systems tell the cases apart. +- Saving stages a hidden sibling folder, syncs its files and folders to disk, then swaps it in with `renameat2(RENAME_EXCHANGE)`; a first save is a plain `rename`. Where the system knows no exchange (FAT sticks, old kernels) the store logs a warning and replaces in two moves. Either way the parent folder is synced after the swap, the old project is kept until that sync succeeds, and a failed sync puts it back before the error is thrown. If the way back fails too, nothing is deleted: the old project stays where the swap left it (the hidden sibling, or its `.old` neighbour after two moves) and a critical log on `omaphoto.io` names that path. `ProjectReplaceTests` defines `rename`, `renameat2`, `fsync` and `read` in the test executable, where they take libc's place, to order and fail each call. There is no `NSFileCoordinator`. A missing manifest is `ProjectError(invalid)` and a missing asset `missingImage`, where Swift passes on a Cocoa error; a failed write throws `std::runtime_error` with the system's message. `QFile` reports no error when a read fails after partial data, so the manifest's byte count is checked against the file's size. The manifest's `version`, `width` and `height` are `qint64`, Swift's `Int`, read from Qt's exact integer (`QJsonValue::toVariant` as `LongLong`), never through a double: a wild number decodes and is refused by its own rule, with every digit of its value in the version error. One fact of Qt's parser stays, checked against Swift 6.0.3's decoder: a number token outside Int64, or one with a fraction or an exponent, passes through a double inside Qt. So the 1,024 integers below Int64's minimum and `-9.223372036854775808e18` read as the minimum where Swift refuses them, and `9007199254740993.0` loses its last digit where Swift keeps it. Every such value lies outside each valid range; only the kind or the number of the refusal differs. `ProjectTests` pins one. A whole number with a fraction or an exponent (`5.0`, `5e0`) reads exactly, as Swift's slow path reads it; a token that underflows a double (`1e-400`) fails Qt's whole parse as an illegal number, where Swift reads zero. Only a Grain seed (9.5a), every `UInt32` of which is valid, turns that into a project Swift opens and the twin refuses; `aSeedReadsWhatQtsParserReads` pins it. +- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store and the importer: their own pixel budgets are checked from the file headers before any pixel is read. +- `ImageImporter` is a namespace; the Swift actor only serialises calls and caches a `CIContext`. The type comes from the file's bytes, as `CGImageSourceGetType` reads it: `QImageReader::setDecideFormatFromContent` for JPEG, PNG and TIFF (TIFF needs `qt6-image-formats-plugins`), libheif's `heif_get_file_mime_type` for HEIC (`image/heic`: HEVC in HEIF). Other ISO media (AVIF, plain HEIF, video) and every other format Qt recognises are `unsupported`; bytes nothing recognises are `unreadable`. HEIC decodes through libheif, which applies the file's own turn and crop boxes, so its EXIF orientation is never applied again. Its colours come from the ICC profile, else from the `nclx` box, which ImageIO reads for Swift: the box's primaries (libheif hands over their chromaticities) with the curve it names: sRGB, linear, gamma 2.2 or 2.8 through Qt's own transfer functions, and BT.709's curve (shared by BT.601, BT.2020, xvYCC and BT.1361 between zero and one) as a table; unspecified primaries or curve count as sRGB's, like untagged pixels. Qt 6.4's `QColorSpace` constructor that takes points and a table builds an invalid space (probed), so the table is set afterwards. Curves with no conversion here (PQ and HLG, which need tone mapping; SMPTE 240M, SMPTE 428, the logarithmic ones), primaries Qt cannot hold and a box libheif rejects are `unreadable`, each with its reason in the log: no HEIC imports with colours the importer knows to be wrong. `heif_init` loads the decoder plugins (Ubuntu ships HEVC decoding as `libheif-plugin-libde265`); a missing decoder is `unreadable`, with libheif's message in the log. For Qt's formats the importer reads the pixels as stored, converts them to sRGB while the ICC profile is still attached, and only then applies the EXIF or TIFF orientation itself: Qt 6.4's `setAutoTransform` drops the colour space. Untagged pixels count as sRGB, as CoreImage counts them. Qt 6.4 reads no orientation from a PNG's `eXIf` chunk (probed: `supportsOption(ImageTransformation)` is false), so such a PNG imports as stored; JPEG, TIFF and HEIC turn upright. The result is `RGBA8888_Premultiplied`, tagged sRGB. A thumbnail's long side is 96 and its short side rounds up, as Swift's `extent.integral` does. The HEIC fixtures in `tests/fixtures` are committed; `scripts/heic-fixtures.sh` makes them and stops at the first failure, because the dev image carries no HEVC encoder. The tests patch the lossless `p3-nclx.heic` in memory to name other primaries and curves. +- `LayerRenderer::drawCoverage`: Swift clips to the mask and fills white; QPainter has no soft clip. The twin builds white whose alpha is the mask (premultiplied, from the halved mask `draw` would use, since CoreGraphics filters a shrinking mask and QPainter's bilinear filter aliases) and draws it under the painter's own opacity and clip, in the composition mode under which alpha stands for a clip's coverage: the painter's own for every mode that is linear in the source and keeps pixels where the source is clear (source-over, the blend modes, the rest of Porter and Duff's), and its exact stand-in for the seven that are not (Source and Plus become SourceOver, Clear becomes DestinationOut, SourceIn SourceAtop, SourceOut Xor, DestinationIn Destination, DestinationAtop DestinationOver). `DrawCoverageTests` holds all 24 modes against the oracle, a fill of opaque white mixed in by coverage, over opaque, half clear and clear pixels. Those seven take no painter opacity (CoreGraphics scales the source by it, Qt mixes the result by it, and only linear modes make the two agree): below full opacity they are a `logic_error`, as is any raster operation. No caller sets a mode today. Source-over that is white through coverage, 255 a + d(1 - a), on gray and colour devices alike. It takes the placement `draw` takes, `radians()` included, so a wild angle turns both alike. A mask that is not gray is a `logic_error`; a white image that cannot be allocated is `ExportError(render)`. +- QPainter places an unscaled, unturned image on whole device pixels whatever the hints say; CoreGraphics would blur it by the fraction. Layers the app makes sit on whole pixels, Shape lines aside (8.8), and any scale or turn takes QPainter's filtered path, so tests of soft edges draw under a scale. +- `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on neither shape, effects nor text; the canvas resizer passes on shape and text, not effects). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable. +- `EditorSession` is a `QObject`. One signal, `changed()`, stands in for Swift's observation: every method that changes state emits it, a refused request stays silent, and widgets compare what they show with what the session holds, as `CanvasView.synchronizeDisplay` does. `beginEdit` emits as `endEdit` does, because an open transaction closes `canUndo` and `canRedo`. `fit` and `zoom` emit only when the view changed, so a zoom that is no number stays silent. The canvas edits `viewport` in place and then calls `notify()`. A Swift property with a `didSet` is a private member with a getter and a setter (`activeLayerID`, `isProjectBusy`, `showsNewDocument`, `showsImporter`, `isImporting`, `importError`, `renamingLayerID`); `document`, `tool`, `collapsedGroupIDs` and `selectedLayerIDs` are read through getters and change only through session methods; `history` and `viewport` are public members, as Swift's tests use them. `activeLayer()` returns a copy, as Swift's computed property does. `EditorSessionSignalTests` holds the signal's contract: every change is announced, a refusal is silent, and the last signal of a change sees the state the call leaves behind, read inside the slot. +- `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates; `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`. +- `groupSelectedLayers` follows Swift's steps (what stays keeps its place, the folder sits at the topmost selected branch, the wrapped layers go last in hierarchy order) with two terms dropped because they are dead in the twin: the `nil` that ends each ancestor chain (the twin's shared parent starts as the root) and the clamp of the insertion index (it counts layers that stay, so it cannot pass their number). `placeLayer` drops Swift's `target != id` for the same reason: the target is sought after the layer is taken out, so a layer is never found above itself. `canPlaceLayer` is tested by itself as well as through `placeLayer`, whose validation would hide its terms; the panel asks it before a drop. `selectLayers` and `extendSelection` fall back on any selected layer, as Swift's `Set.first` does: tests never depend on which. A session's document always passes `LayerHierarchy::validate` (every mutator checks before it commits), so every `parentID` names a folder of the document; `groupSelectedLayers` reads parents through `std::map::at`, which throws if that ever breaks. +- Transform edits: `TransformEdit` holds what exists so far (`layerID`, `draft`, `group`); `persistent`, `corners`, `mask` and `floating` arrive with the items that read them (`TASKS.md`, "Session hooks owed"). A draft changes only through `previewTransform`, which refuses an invalid value, and begins as a valid transform or box, so it is valid by construction. `commitTransform` therefore gives each layer what `pendingTransform` gives it, one definition for preview and commit, inside one transaction, and skips a layer whose own result is invalid (a box squeezed until a member falls under a pixel); Swift's `edit.draft.isValid` and `firstIndex` guards are left out, since nothing can reach them. An edit that moves nothing records nothing, as in Swift: the history drops a transaction that changed nothing. `canTransform` and `groupTransformMembers` drop Swift's `isGroup` terms (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), and `beginTransform` drops `transformEdit == nil` (`canTransform` reads it through `canEditLayers`). `groupTransformBox` keeps `max(1, …)`: rounding leaves a one-pixel layer at origin 1.3 a box under one pixel, and a test holds that. `DistortWarp::corners` came forward from 6.2 for the group box. +- Swift's `async` session calls (`importImages`, `waitForProjectAccess`, `waitForFileRequest`) take a completion callback in the twin. A waiter runs at once when the session allows it; otherwise it is kept, and the setter that frees it posts each waiter to the event loop (`Qt::QueuedConnection`), where it looks again when its turn comes, as a Swift continuation re-enters its `while` loop: no waiter runs inside the setter, and the first may take the project before the second looks. `importImages` takes `QList` (a URL that is no local file is `unsupported`, Swift's `isFileURL` guard), decodes one file at a time through `QtConcurrent::run` and a `QFutureWatcher` (the worker catches `ImageImportError` and hands back the asset or the failure text; anything else is a bug and is left to crash), inserts on the UI thread, and wraps each request in one `Import Images` transaction. A request's callback is posted to the event loop too, so the caller goes on as a resumed Swift task does: after the transaction ended and, for the last request, after `isImporting` fell and the failures were published; for an earlier request while the next one decodes. Only a project that stops being busy frees project waiters: saying busy again leaves them in line. `showsBusy` is a single-shot `QTimer` of `busyIndicatorDelay` (250 ms), a `Qt::PreciseTimer` because a coarse one fires up to a twentieth early, and a child of the session so a test can read its remaining time; tests of time assert floors and states, never ceilings; `createDocument` clears its two flags and then resumes waiting file requests once, where Swift's two `didSet`s each try. The session therefore needs an event loop: every test that makes one uses `QTEST_GUILESS_MAIN`. +- Appearance: `allLayerBlendModes` stands for Swift's `LayerBlendMode.allCases`, the order of the blend menu. Swift's `refreshCanvasPreview` callback has no twin: `changed()` reaches the canvas, and `previewBlendMode` emits it only when the preview changes, so hovering over one entry redraws once. `blendPreview` is a small struct where Swift has a tuple. `opacityEditLayerID` is a private member: only the session reads it; an open opacity drag shows outside as an open transaction (`canUndo` is false). Inside a drag `setLayerOpacity` opens a nested transaction where Swift writes into the open one: the history counts depth, so the drag is still one step, and the branch Swift needs is gone. +- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`. +- Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files. +- Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too. +- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). +- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead. +- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own. +- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`. +- `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render. +- `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it. +- `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first). +- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the cell's `actions()`, named as Swift's menu items. Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception. +- Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, a copy with a folder among the rows is refused, every row must pass `canPlaceLayer`; `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row that is no folder and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's. +- `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it, one `Duplicate Layer` step; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing folders and a place `canPlaceLayer` refuses. +- `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; every target yields a copy (`duplicateActiveLayer` refuses nothing a target passes), so Swift's `copies.isEmpty` branch and its `copy != id` test are left out. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`. +- The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests. +- Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` call a bow tie unusable, but the source folds one as two triangles (both halves have area), so the twin's tests use a collapsed corner for the refused shape, as the source refuses it. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`. +- `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document. +- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered). The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes. +- The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's `mKeyChoosesTheMarqueeThenSwitchesItsShape` and `lKeyChoosesTheLassoThenSwitchesItsMode` and the bar's `help` texts expect a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x), so the twin tests the code's rule and its tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind. +- The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds. +- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and counts the clipboard's `dataChanged` signals from the first copy as Swift reads the pasteboard's change count: a paste puts our pixels back in place while the count still matches, else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer that is no folder. Swift's beeps are warnings on `omaphoto.app`. +- Content-Aware Fill (7.5): `ContentFill.c` compiles in place beside the other kernels; `Document/ContentFill.cpp` includes its header in angle brackets, because the twin's own header shares the name. `ContentFill::run` draws the job's image into a premultiplied context and takes the pixels to make from `PixelAdjust::coverage` (the selection through the inverse mapping, as Swift clips the mask context and fills white), the kernel's 0 is `ContentFillError(noSource)` with Swift's message and its −1 `ExportError(render)`. `Document/Filters.h` holds what Swift's `Filters.swift` holds as far as it exists: `FilterKind` (`rawValue` and `isAutomatic` are free functions; the other kinds came with 9.4a), `FilterJob` (its settings, scale and seed came with 9.4a), `PixelFilter::run` (the kind's filter, then `PixelAdjust::blend` through the selection) and `FilterEdit`, a plain class in a `std::optional` of the session, since only the session writes it; its `id` stands for Swift's object identity, so a preview lands on the edit it was made for. `FilterEdit` grows through `grow` (the blur's growth and the preview copy came with 9.4a). The session's extension is `Filters+Session.cpp`: Swift's file holds 473 lines, its twin's two halves about 500. `canContentAwareFill` without its `isMaskSelected`, `filterEdit` and `isEmpty` terms (`canAdjustColors` holds all three; it sits in `HueSaturation.cpp`, the twin of its Swift file, alone until 9.3, with the terms that exist), `beginFilter` (`cancelCrop` waits for the crop), `updateFilter` (its settings came with 9.4a), `cancelFilter`, `commitFilter` with a completion callback, `finishFilterCommit` (Swift's guards, the mask carried through `LayerMask::clipImage`). Swift's `previewTask` is one `QFutureWatcher`: `renderFilterPreview` marks the edit preparing and starts the worker, or leaves the job pending behind the one running and runs it when that finishes, a run counting as running until its finish has run (`m_filterRendering`, as Levels' queue); a commit that awaits the preview (`isAutomatic`) is kept in `m_filterCommitWaiting` with its edit's id and called again from `finishFilterPreview` (every waiter is, and one whose edit has gone just returns); the wait is on `preparing`, which only the delivery clears; Preview off is 9.4a's; the workers catch `ContentFillError` and `ExportError` alone. `brushRevision` moves where Swift's does (a delivered preview, Preview switched either way, a cancel, a commit's end), so the canvas's `DisplayState` sees each new preview. `displayedTransform` and `displayedMaskPlacement` take the grown transform while the edit previews, and the canvas draws `previewImage` in the layer's place. `canEditLayers` and `ProjectWorkspace::canSwitch` read `filterEdit`. `FilterSheet` (a `filterPanel`'s content since 9.4b): the kind's words, Preview, the error in `BrightText`, "Limited to the selection" while a selection exists (never for an adjustment layer's editor, since 9.5d), Cancel, the spinner with "Working…" or "Applying…", and OK as the default button, disabled while an automatic kind prepares or failed, so Return then does nothing; Escape and the close button cancel, as Swift's `cancelAction` and `onClose`. The Edit menu's Content-Aware Fill… is Shift+Backspace (Swift's `.delete` is that key) and follows `canContentAwareFill`; the canvas takes Shift with Backspace or Delete ahead of the selection keys, as Swift's `keyDown` does. +- The brush on the CPU (8.1): `Rendering/BrushCoverage` twins `MetalBrushCoverage`: the shader's maths in float, pixel for pixel (the centre sample, the hard tip's antialiased silhouette over `max`, the soft tip's optical density under Gauss–Legendre quadrature per segment at the deposition spacing, the 20 cap, the permanent paint apart from the replaceable tail), one band of 32 rows a QtConcurrent task where Metal dispatched one tile a thread group. A `Tile` keeps the permanent density alone: Swift's preview buffer is the gray coverage context itself, written row by row, so nothing is copied back. A tile that cannot be allocated is `ExportError(render)`. The class with its `shared` device is a namespace. `Document/BrushStroke` keeps Swift's continuous path alone: the dab fallback for machines without Metal (`walk`, `dab`, `tip`, `stamp`, `gridTip`, `falloff`, `drawTail`, `removeTail`, `curve`, `tailBackup`, `distanceToNext`, `dirtyTiles`) is dead where the CPU coverage always exists, and `spacingFraction` stays because the integral reads it. `BrushStroke.swift` runs to 899 lines, so the twin splits: `BrushStroke.cpp` (`BrushRaster`, with `fill`, source-over of a colour at coverage × clip × alpha byte by byte on RGBA and gray alike, 1:1 by construction), `BrushStroke+Stroke.cpp` (the grid, samples, the centripetal Catmull–Rom subdivided within 0.2 px, keys, tiles, coverage and `publish`) and `BrushStroke+Commit.cpp` (`transform(for:)`, `paintSnapshot`, `commitInput`, `BrushCommit` as a namespace, since Swift's actor only serialises). The grid is worked out first (`Grid`) so the const members can follow Swift's `let`s; Swift's guards throw `ProjectError(tooLarge)` as one, and the extent is judged as a double before it becomes an `int`, because past `int` the conversion is undefined where Swift's `Int` just holds the number. The stroke's source is the asset itself (`m_source`, an `ImportedImage`), so a painted layer draws from its tiles and never flattens; `commitInput` hands the asset on, and `BrushCommit::render` draws it the same way. A selection clips the context in Swift; `fill` takes the selection's `PixelAdjust::coverage` on the tile's grid as its clip and rounds coverage × clip × alpha once, as CoreGraphics composites a clipped context. A commit whose crop cannot be allocated is `ExportError(render)`, as Swift's `cropping` returning nil; so is a tile's context that cannot be copied on publish (`QImage::detach` leaves a null image where CoreGraphics' `makeImage` returns nil). Tile keys (row × columns + column) are `qint64`, Swift's `Int`: a small layer scaled to a point across a big canvas passes `int`. A tile is rebuilt whole on every publish (`dirtyTiles` only ever narrowed the dab fallback's rebuilds); `base` is the tile's first pixels, kept apart by QImage's copy on write. `BrushSettings` holds the tip and colour; `erasing`, `healing` and `healingMode` arrive with 8.2 and 8.4, `selectionClip` is here since `publish` reads it, `clone`, `isBlur`, `replacesWithClone`, `editName`, `fillGradient`, `fill`, `clearPixels`, `paintCanvas`, `liftSelection`, `moveLifted` and `heal` with their items. `BrushIntersectionTests` runs the one path where Swift loops over `useGPU`; `BrushTests` holds the stroke-level halves of Swift's session tests, driven on a `BrushStroke` made from a layer, with `LayerRenderer::drawBrushPreview` as the live view and `BrushCommit::render` as the commit; the session halves arrive with 8.2. The early return of a segment past the tip's radius is an equivalent mutant: the density there is zero anyway. +- The session's brush (8.2): `EditorSession+Brush.cpp` twins Swift's extension as far as the tools exist: `canPaint` with the terms that exist (the adjustment term since 9.5a), `makeRasterEdit` (the budgets, the selection clip; `pixelLimit` is `qint64`, Swift's `Int`, and may run below zero when other layers use more than the budget), `beginBrush` for the Brush (Spot Healing joins with 8.4, the other brush tools with their items; erase and the mask's black or white as Swift sets them), `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately` (busy, it waits; otherwise it flushes and commits the snapshot), `finishBrush` (Swift's `async` wrapper is a plain call here), `commitPaintSnapshot` (the tiles land at once with their undo entry; a mask that covered the old grid is regrown through `RasterSnapshot::replacing`), `commitRasterEdit` (a completion callback, the render through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ExportError` alone, the UI side keeping Swift's guards and the mask's `isEnabled` as it stands at commit), `usesOpacityKeys`, `typeOpacityDigit` (Swift's uptime is `std::chrono::steady_clock` unless the caller passes a time, as the tests do), `changeBrushHardness`, `changeBrushSize`. `brushStroke` is a `std::unique_ptr` read through a pointer, since a stroke's const members forbid copying; `brushSettings`, `brushMode` (`BrushToolMode` in `Document/SmudgeLiquify.h`, whose smear and liquify arrive with 8.6) and `maskPaintWhite` are getters and setters that notify; `lastBrushPoint` and `pendingOpacityDigit` are private structs. `brushRevision` exists after all, Swift's counter, because the canvas's `DisplayState` must change with every step of a stroke, whose patches it does not hold; a step that changes nothing else repaints the stroke's `dirtyDocumentRect` alone, in view points two points out (`strokeDirtyRect`), as Swift's `setNeedsDisplay` does, and any other change repaints the view. While a folder's mask is painted, the clip for the layers inside comes from the edit's tiles (`liveFolderMaskClip`, Swift's `liveMaskEdit`): `drawBrushPreview` draws the old mask and the patches, nearest, into gray coverage the size of the device, hidden outside the mask's grid, and that multiplies into the folder coverage; the old mask, or a painted mask's base, is halved near the size drawn first, as Swift's `displayImage` does, so a fine mask keeps its shade while painted. The session's private structs (`Decoded`, `Wanded`, `RasterMade` and the rest) live in `EditorSession+Jobs.h` as `SessionJobs` and are aliased in the class, so the header stays under 500 lines. The gates (`canInvert` and `canAdjustColors` among them), `selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` refuse while a stroke lasts, `canStartProjectOperation` too, and `importImages` finishes a stroke first, as Swift's do; the tips' parking by tool family arrived with 8.5. `SelectionEdits.cpp` gains `canEditPixels`, `fillSelection` (the palette's colours are black and white until 8.10's `ColorPalette`; on a mask the background is the opposite of `maskPaintWhite`, as Swift's `paletteColor`), `clearSelectedPixels`, `deleteKeyPressed` (its effects term since 9.6a) and `applyPixelEdit`; `cutSelection` sits in `SelectionClipboard.cpp`. `BrushStroke` gains `erasing` in its settings (`BrushRaster::fill` takes `DestinationOut` for it, Swift's blend mode on the context; a mask never erases, so that mode on a gray context is a `logic_error`), `paintCanvas` (the base redrawn, the body under the inverse mapping and a hard canvas clip, then `PixelAdjust::blend` through the selection where Swift clips), `fill` and `clearPixels`; `placedMaskPreview` is declared in `BrushStroke.h` and defined in `LayerMask.cpp`, as Swift's extension, and a preview that cannot be made logs on `omaphoto.rendering` where Swift's `try?` is silent. On the canvas a pixel stroke draws its whole grid placed by `paintTransform` through `TiledLayerRenderer::drawStroke`, a mask stroke through `drawMaskStroke` under the layer's transform, and a mask on its own placement draws the layer through `placedMaskPreview`; the mask's clip takes the stroke's owner as Swift's `base` does. Delete and Backspace without Ctrl, Alt or Meta are `deleteKeyPressed` on the canvas and, plain, in the layer list; Shift with them stays the fill's. The Edit menu gains Cut (Ctrl+X, a text field's own), Fill with Foreground Color (Alt+Backspace) and Fill with Background Color (Ctrl+Backspace), which in a text field delete a word or the line as Swift's `deleteWordBackward` and `deleteToBeginningOfLine` (the selection when there is one, else backwards from the caret, so nothing goes at the start) (reachable from the opacity field alone: a rename or an empty document disables them, as `canEditPixels` does), and Clear Selection Pixels. `tests/BrushFixtures.h` shares the fixtures; `BrushSessionTests` and `PixelEditTests` hold the session halves of Swift's `BrushTests`, `RasterSnapshotSessionTests` the five session cases, `BrushCanvasTests` the two `canvasShift` cases and the canvas's Delete key. +- Moving and floating selected pixels (8.2b): `PixelMove` is a class in `Document/SelectionEdits.h`, Swift's `final class`. Its raster is a `std::shared_ptr`, because the canvas draws it while the commit runs, so `commitRasterEdit` takes a `std::shared_ptr` (the brush hands its stroke over); its `offset` starts at zero, since a default `QSizeF` is −1 by −1. `beginPixelMove`, `movePixels`, `finishPixelMove` (a completion callback; the move stays set until the commit lands, so pixels and outline never show at the old place), `cancelPixelMove` and `nudgePixels` (Swift's beep is a warning on `omaphoto.app`) live in `SelectionEdits.cpp`; `displayedSelection` follows a pixel move and a floating edit. `BrushStroke::liftSelection` draws the source into the region, from its tiles when painted, and multiplies the selection's coverage in (`PixelAdjust::coverage` under `DestinationIn`) where Swift clips; `moveLifted` rebuilds each tile from its base, cuts the hole through the same coverage under `DestinationOut` (not for a copy) and places the lifted pixels on whole pixels: a landing between pixels (a scaled or turned layer) is resampled first by its fraction, bilinear and rows in parallel (`BrushRaster::shiftedByFraction`, shared with Clone Stamp), because QPainter snaps an unscaled image to whole pixels (probed on 6.4), where Swift draws it with `.high`. `FloatingSelection.cpp` twins Swift's file: `FloatingTransform` rides in `TransformEdit.floating` (last) as a `std::shared_ptr`, because the edit is copied freely and holds the whole document from before; `canTransformSelection`, `transformCommand`, `beginSelectionTransform` (a completion callback: the outer `Transform Selection` edit opens, `clearSelectedPixels` runs, and its callback inserts the `Floating Selection` layer), `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, and `FloatingMerge::merge`, a namespace with a `Merged` struct for Swift's tuple. The merge writes pixels, transform and mask into the source layer in place, where Swift rebuilds the layer from listed fields: the fields that list leaves out do not exist yet (`TASKS.md`, "Session hooks owed"). `DistortWarp::mapPath` came forward from 6.2's list: it walks the path's elements (Qt stores a quad as a cubic) and keeps the fill rule. `beginPixelMove` and `canTransformSelection` leave out Swift's `pixelMove == nil`, `transformEdit == nil` and `!selection.isEmpty`, which `canPaint` reads already (through `canEditPixels`). `displayedMaskPlacement`'s `floating == nil` term is dead and left out: the floating layer is made without a mask, and the open edit blocks adding one; `beginDistort` edits the edit in place, so `floating` carries over. The session's private structs sit in `SessionJobs`, now a private base struct of `EditorSession` (the aliases went; `MergePlan` and `TransformDuplicate` joined it), so the header stays under 500 lines. On the canvas a Ctrl-press inside the selection with a selection tool lifts and moves its pixels, Ctrl-Alt copies them, under `movePixelsCursor` (the arrow with scissors drawn as paths and haloed, where Swift badges the SF symbol) or the duplicate cursor, kept under Space as every drag keeps its own; `updateCursor` picks the outline and pixel drags' cursors ahead of Space, so `lassoCursor`, its only caller's helper, checks neither (Swift's has other callers); Ctrl-arrows nudge the selected pixels in any tool, Shift ten, never with Alt or Meta (macOS's Option and Control), ahead of the Move tool's arrows; losing focus cancels a pixel drag; `drawOwn` draws the move's raster as a stroke, and a step repaints the whole view, as Swift's (only a brush stroke repaints its rect). The Layer menu's Ctrl+T is `transformCommand`, titled `Transform Selection` while `canTransformSelection` holds and enabled by either gate. `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`. +- The brush on the canvas (8.3): `EditorCanvas+Brush.cpp` holds Swift's gestures. A press begins a stroke, Shift drawing on from `shiftLineStart` through the press point; a drag continues it, and Shift holds an axis that the first three pixels from its anchor settle (the press under Shift, else the last pixel reached), the pixel waiting at the anchor until then. Swift clears the axis when Shift is let go and again when a new anchor is set; the first is dead and left out. The release reaches its own point, then `finishBrushImmediately` commits. A busy project holds the stroke: a drag, the release, Escape and a lost focus leave it. A stroke goes on only under a drag with the left button, as Swift's `mouseDragged` (a hover after a busy release paints nothing), and wheels and pinches neither pan nor zoom while it lasts, as Swift's `scrollWheel` and `magnify` refuse. A right-drag with a brush tool, no stroke and no Space, sizes the tip from the press (two view points a pixel, rounded, 1 to 2000), or with Shift sets hardness over 200 points, each putting the other back; the circle stays at the press. `BrushCursorOverlay` is no widget, like `TransformOverlay`: the canvas draws the circle last, white 2.5 under black 1, and while hardness is dragged a dashed inner ring (four points on, three off, given in pen widths as Qt counts dashes); `update` returns the old and new rings three points out, as Swift's `setNeedsDisplay`. Swift's `hardness > 0` is dead (a zero ellipse strokes nothing) and left out. The circle follows the pointer, hover included but not a pan (Swift's `mouseDragged` pans first), hides under Space, on leaving and on a lost focus, and shows with the brush tools alone. The canvas's `event` takes Tab before the focus chain (Shift-Tab stays the chain's) for `cycleToolMode`, which switches the Marquee's and the Lasso's shapes, the Brush's mode and Spot Healing's type (the other tools' modes arrive with them); B and E choose the Brush and its mode; a digit reaches `typeOpacityDigit` only where `usesOpacityKeys` holds, else it goes on to the parent, as Swift's `super.keyDown`; brackets step size and hardness, and the application filter takes them from any control but a text field unless the canvas has focus, so a focused canvas's `keyPressEvent` re-reads the modifiers as Swift's `keyDown` does. The layer list takes Tab and Shift-Tab (Swift's table cycles on both), B, E and digits alike. `BrushControls` is every brush tool's `ToolHeaderBar`: Paint and Erase in an exclusive group, size (1–2000), hardness and opacity as a slider in thousandths beside a whole-percent field (opacity from 1%), the sliders following the session under `QSignalBlocker` so nothing rounded is written back, the fields stepping with Up and Down from the session's exact value as Swift's `arrowSteps` does (a slider's 33.3% steps to 34.3%; `SelectionAmountField` takes Swift's `value` and `change` closures, and the Lasso and Wand fields round in `change` as theirs do, an equivalent mutant since their values are whole), and the mask's black-or-white picker and note while a mask is selected; the colour swatch arrived with 8.10a. `ContentView+Status.cpp` holds `percent`, `iconKind` and `hint`, the eraser's words and icon among them; `iconKind` checks its tool although every consumer ignores a kind that is not its own (an equivalent mutant). `updateCursor` and the modifier helpers moved to `EditorCanvas+Cursors.cpp`. `ContentView`'s destructor stops listening first: `~QWidget` closes a shown window after the members are gone, and the canvas's focus-out then notifies (`cancelBrush` bumps `brushRevision`, as Swift's does). +- Brush speed (8.3's gate: 4K at a scale of 2, an 800 px soft brush, median frame at most 8 ms): 6.8–7.4 ms measured, from 47. The soft tip's density comes from a table of 8192 steps over d²/r² from h² to the rim, in radii because hardness alone shapes it, linear between steps; inside h the tip is solid, the table's first value, so a near-hard tip keeps every step across its fade (from 0.9995 on, a table over the whole radius missed the falloff by up to 46 levels). Once the table's steps are finer than float can tell apart ((1 − h²) / 8192 under float's epsilon, from about 0.9995), even that table misses by a few levels: there `tipDensity` evaluates the shader's own formula, float for float, for dabs and segments alike, its solid and empty ends returning at once what the formula yields, so only samples in the thin fade pay for `exp` and `log`. `BrushCoverageTests` holds dabs and strokes on both sides of the switch against the shader's own quadrature, replayed in float. The kernel keeps the shader's order of float operations to the letter (`midpoint ± halfLength · node − projection`, never regrouped): 8192 pixels along a near-hard stroke a regrouping moved a pixel three levels, and a reference written from the twin's own code repeated it. Its one read per lookup is checked at the upper step, so a lost bound aborts. Only a segment passing through the solid core can look up inside it, so the lower clamp runs for those alone (`quadrature`): for every segment it cost 0.3 ms a frame. Bands of 32 rows are the tasks. The halving's bands take their rows, sums and source row from the caller's scratch, so a worker allocates nothing and a failed allocation throws where the caller catches it (QtConcurrent wraps a worker's exception in `QUnhandledException`). `publish` copies each tile and makes its selection coverage on the calling thread, so a failure throws there and a worker's malloc arena never meets a starved allocator, then fills side by side. Pieces compose and halve side by side (`sideBySide`), each halving unthreaded: Qt 6.4 deadlocks on a `blockingMap` inside a map task. The Lanczos colour rows sum four channels in a GCC vector (`vector_size(16)`), each lane as the scalar sum, and the vertical pass runs tap by tap over whole rows; `BrushCoverage.cpp` and `DownsampleCache.cpp` build at `-O3`, where GCC vectorises them. The shadow is skipped when the dirty rect lies inside the document, whose opaque fill covers it. Equivalent mutants: that skip, `fill`'s skip of a zero weight, the colour row's right clamp (it only ever reads padding), the table's interpolation (under a third of a level), its lower clamp at step 1 (the first steps of every fade sit at the density's cap), the clamped quadrature for every segment, the formula for every hardness and `tipDensity`'s two early returns (each only costs time). `BrushPerformanceTests` runs under `BRUSH_BENCHMARK=1`. +- Canvas speed (11.1): `CanvasPerformanceTests` (under `CANVAS_BENCHMARK=1`, at a backing scale of two, as the brush benchmark) pans a 1000-point canvas by trackpad deltas through the canvas's own wheel path, at fit and at Actual Pixels (`zoom(1)`, as the menu sets it), and zooms it in 5% steps around its middle, over a 3840 × 2160 document of ten noise layers (photographs and halves, opacities, multiply, screen, overlay and two Hue layers). Measured on the dev container (32 cores): pan at fit 30 ms median (p95 37), pan at Actual Pixels 68 ms (p95 74), zoom 77 ms (p95 86); with every layer normal 16, 15 and 29 ms. The hand-blended HSL modes are the cost: `HslBlend::blend` ran one thread over the layer's device area, 40 ms a Hue layer at fit; its rows now blend side by side (pointers taken first, since `scanLine` detaches), the same bytes, 3× faster (pan at fit 85 → 30 ms), and the mode is checked before the rows start, since a worker's exception comes back wrapped. At Actual Pixels a Hue layer still costs about 25 ms at 2× (the backdrop's copy and conversion, the enlarged surface and its draw back); the rest is QPainter's single-threaded smooth scaling, about 40 megapixels a frame at this size. Painting the canvas in parallel bands would need its caches (pieces, strokes, effects previews) made safe across threads, and is not done. The guards sit at half again these medians (45, 100 and 115 ms), so a regression fails. Compared with `docs/brush-performance.md`: Swift measures strokes only (2.6–2.8 ms median on macOS); the twin's stroke gate is 8.3's (6.8–7.4 ms at 4K, scale two), and Swift has no pan or zoom figures to compare. `Rendering/PoolMap` (Linux-only) wraps every `QtConcurrent::blockingMap`: Qt 6.4's deadlocks when a pool thread waits on a full pool (a worker's blur or blend on one core, or two workers on two), so a pool thread lends its place while it waits (`releaseThread`, then `reserveThread`, Qt's documented remedy); `PoolMapTests` runs the blend, the blur and a map on a pool of one thread. +- Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and JPEG, PNG, HEIC and TIFF. `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test. +- Packages (11.3): one CMake install serves every package. It lays down the app, its entry, MIME package, icons and both licences (the app's MIT `LICENSE` and U²-Net's, under `share/licenses/omaphoto`), and `OMAPHOTO_MODEL` adds the model. `OMAPHOTO_BUNDLE_ORT` (off by default) installs ONNX Runtime beside the app in `lib/compositor` with the app's run path set there, for Ubuntu 24.04, which packages none, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where `Dockerfile.dev` keeps them from the release; a configure that cannot find them fails); every other system brings its own. `DesktopIntegrationTests` expects the runtime (in the configured library folder), its version link and its notices in a bundling build, as it expects the model in a build given one, reading what CMake itself evaluated through compile definitions (both checked by hand in a second build). CPack makes the DEB (Ubuntu) and the RPM (Fedora), each on its own system: the DEB's library dependencies come from `dpkg-shlibdeps`, which finds the bundled runtime through the run path, and it names what no library does (`libheif-plugin-libde265` for HEVC, `qt6-image-formats-plugins` for TIFF, `shared-mime-info`, `hicolor-icon-theme`); the RPM asks for `qt6-qtimageformats`, `shared-mime-info` and `hicolor-icon-theme`, its libraries found by rpmbuild, and suggests RPM Fusion's `libheif-freeworld`, since Fedora's own libheif decodes no HEVC. Packages stage their install (`DESTDIR`), so their systems' hooks and triggers refresh the MIME and icon caches. `Dockerfile.dev` gains `dpkg-dev`, `file` and `fakeroot` for the DEB. `packaging/arch/PKGBUILD` builds this checkout for Arch and Omarchy (`makepkg` there), fetching the model by its checksum and depending on `onnxruntime`, which `onnxruntime-cpu` and the GPU builds provide. `flake.nix` sits at the repository root; it builds the app alone and installs staged (`DESTDIR=/`) so the store holds no MIME cache. The DEB was built and read back in the dev image; the RPM, the PKGBUILD and the flake are built on their own systems in 11.4. +- Distro checks (11.4): `scripts/distro-check.sh arch|fedora|nixos` builds `scripts/distro//Dockerfile` and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since Qt 6.11's `QTransform::map` clips w for points past the horizon where Swift divides whatever its sign; a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. +- Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it. +- Clone Stamp (8.5): `Document/CloneStamp.h` holds `CloneSettings`, `CloneStamp.cpp` Swift's extension: `setCloneSource` (a point that is no number is refused; a new source restarts the alignment), `cloneStrokeOffset`, `cloneSamplePoint` and `cloneSample`, whose context that cannot be allocated logs on `omaphoto.app` where Swift's `try?` is silent. The session keeps `cloneSource`, `cloneSettings` (a setter that notifies), `cloneOffset` and `parkedBrushTips` by `tipFamily` (Clone Stamp 1, the Smear 2, the rest 0; the two parked tips start at Swift's 40, 0, 1). `selectTool` parks size, hardness and opacity when the family changes and reads the tip it restores with `at`: the session starts in family 0 and leaving a family parks it, so Swift's `if let` always finds one. `beginTransform` and Transform Selection set the Move tool directly, as Swift's do, and park nothing. `beginBrush` takes Clone Stamp on pixels alone, asks for a source first (`brushError`: "Alt-click where Clone Stamp should copy from first."), samples the document as the stroke starts and fixes the offset; Tab toggles the sample; the step is `Clone Stamp`. `BrushStroke::Clone` is Swift's tuple. `publish` makes each tile's view of the sample on the calling thread (`clonePixels`): under a scale or turn QPainter draws it bilinear, Swift's medium quality; a whole translation copies it; a fractional one is drawn at the floor and shifted by `BrushRaster::shiftedByFraction`, since QPainter snaps an unscaled image. `fillClone` then composites it source-over through coverage × opacity × selection, each byte rounded once, where Swift clips the context to the coverage. Swift's `!isMask || isBlur` holds since 8.6: a mask takes a clone only from Blur. The bar shows Aligned (a `QCheckBox` written on `clicked`, so the session's own changes write nothing back), This Layer and All Layers, and "Alt-click to set the source" in `PlaceholderText` while there is no source. On the canvas an Alt-press with Clone Stamp sets the source and paints nothing; Swift's cursor refresh there is dead (Alt is held, so the crosshair stays) and left out. With a source the pointer hides unless Alt or Space is held. `updateBrushCursor` gives the overlay the source crosshair in view points and, between strokes and without Alt, `clonePreview` (the source around the stamped point at screen resolution, at most 1024 pixels, kept under Swift's key; Swift's `.medium` there is overridden by `LayerRenderer`'s own quality and left out) with `cloneTip` (one click painted by the brush engine, kept by size and hardness). `BrushCursorOverlay` gains `marker`, `preview`, `previewOpacity` and `tip`, images compared by `cacheKey` as Swift's `!==`; under an antialiased ellipse clip at the brush's opacity it draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer, so the tip applies at the drawing's resolution however far a capped preview is enlarged, and draws the crosshair white 3 under black 1 with round caps. A modifier change refreshes the brush cursor, as Swift's monitor does. Equivalent mutants: `displayedTransform` in `cloneSample` and `clonePreview` (it differs from the stored transform only in a transform edit, which leaves Clone Stamp for Move, or a filter edit, which refuses strokes; the preview would show it only with a filter sheet open over Clone Stamp). +- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre). +- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now. +- The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing. +- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passes it on, the image resizer not. The manifest's `text` is Swift's `Codable`: every key required but `boxSize` (synthesized decoding ignores the defaults), the alignment by raw value, the box as two numbers; the store refuses a project whose text fails `isValid` or has no pixels (Swift's `validate`; its adjustment term is dead, since an adjustment layer's own rule refuses pixels, 9.5a), and a snapshot installed past the store loads such text as pixels (`LayerText::loaded`). `TypeControls` is the tool's `ToolHeaderBar`: the face as a `QComboBox` of fontconfig's names and the style's own, sorted; size, tracking and leading as `TextStyleField`s, `QLineEdit`s that take each readable edit as Swift's value bindings do, show the session's number unless focused, step by one on the arrows (Shift ten), keep their typing while a menu or popup borrows the focus, and keep the focus on Return, showing the number again, selected, as an `NSTextField` does: Swift's Type bar alone has no `releasesFocusOnCommit`; Escape is the field's own. They print Swift's `.number`: at most three decimals, none trailing; digits are not grouped, as in every field of the twin. The size's parse check is left out: text that is no number reads as zero, which no size passes. The size steps within 1–2000; the leading shows Auto as its placeholder, clamps what is typed to 0–5000 and steps from the height Auto works out to. The alignments are checkable `QToolButton`s with Swift's `text.align` glyphs drawn as paths, redrawn on `PaletteChange` as the Move bar's lock is; Cancel and Done show while a draft is open, else Edit Text, enabled on live text; the bar rests without a document or while busy shows. The fields scroll as the Move tool's do. The colour swatch and `openTextColorPicker` came with 8.10a. A live text row in the panel shows the rail's Type glyph 1.2 times up, centred (Swift's `textformat`, 1.21 times up), "Editable text layer", "Select text: " and "Text · Double-click to edit"; `ThumbnailKey` carries `editableText`, so text that loads as pixels redraws. A double click on a control makes the row active, then opens its text (Swift's `renameClickedLayer`); on the name it renames. The inline editor, the canvas's gestures and the edited layer hidden under its draft come with 8.9b. Equivalent mutants: `ImageLayer`'s equality without its text term (every text change brings new pixels); `TextLines` without its stop after a paragraph that overflowed (every later paragraph overflows at once, so the stop spares only their layouts). +- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLineControl::processInputMethodEvent` on the text without the preedit: input (a commit, a changed preedit, a replacement) takes the selection first, then the replacement counts from the cursor, a range removed whole or not at all, a zero length inserting at its offset, and where nothing commits the cursor goes where Qt leaves it; an event that is no input changes nothing (Qt's fields remove a selection only on input, and ibus sends empty preedits when an engine switches modes). A `Selection` attribute sets the selection after the commit; while a preedit shows, that selection stays beside it (the preedit's `anchor`, in the text without it), drawn and answered to queries, until the next input takes it, and the attribute ends a typing run as a move does; a draft set over a composing one ends the composing; `TextFormat` attributes style the preedit, counted from its start (a format that is none is passed over, as Qt's fields do), else it is underlined; a `Cursor` attribute places the caret, hides it at length zero and colours it with a `QColor` value. Copy, Cut, Paste and Select All, from the menu or their keys, work around a preedit as Qt's fields do: on the text without it and the selection beside it (`plain`), the preedit then going back at the cursor (`settle`), so Select All then a commit replaces everything; undo takes a preedit away with the step, since it records nothing. Other keys reach the editor while a preedit shows only when the input method lets them through, and act on the text as it stands: typing and deletions end the composing, the preedit staying as text, as NSTextView unmarks it. Text holds 100,000 UTF-16 units (`shouldChangeTextIn`). The canvas makes the editor in `synchronizeDisplay` (Swift's `synchronizeInlineText`, whose twin lives in `InlineTextEditor.cpp`), skips the edited layer in `drawOwn`, the editor's repaints covering its box while it is edited; a draft that begins, ends or is replaced repaints the whole canvas, as Swift's `needsDisplay`, because the editor's box need not cover the layer's (text shrunk while edited, a layer stretched one way, which point text shows at its width's scale); while a draft is open every key goes to the editor (a key it leaves goes on to `QWidget::keyPressEvent`, which ignores it for the ancestors, none of which takes keys: its mutant is equivalent; so is the blend keys' focus term, since `canEditLayers` refuses a blend step while text is open), the text view's standard keys and Backspace with any modifier (the fills' keys; Delete has no menu key and needs no claim) win `ShortcutOverride` ahead of the menus, Shift+Tab stays put, focus shows and hides the caret (its blink is `caretBlink`, a named child timer of the canvas, running only with focus, so tests read it), input methods are enabled through `setAttribute` alone (Qt updates the input method itself and commits a preedit on the way out, so the editor goes first and that commit finds none) and follow the caret: every edit, move and view change updates them while the canvas is the focus object, as `QWidget::updateMicroFocus`, and a focus request leaves the keys where they are, as Swift's `consumeFocusRequest`. The menus follow Swift's text branch: with a draft open Undo and Redo are bare and enabled, and Undo, Redo, Cut, Copy, Paste and Select All reach the editor of the focused canvas, a text field's first. The pointer (placing the caret, dragging a selection, the handles, the text box, the cursors) comes with 8.9c. +- The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (known differences, the first a fix). Equivalent mutants: `drawTextBoxDraft`'s save, restore and brush (it draws last, on a painter each overlay before it restores) and its guard (no box maps to a null rect, which draws nothing). Known differences: `NSTextView`'s context menu and its dragging of selected text are AppKit's own; the Edit menu carries Cut, Copy and Paste. `arrowPath` and `duplicateCursor` moved to `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. +- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; its close button cancels, and Swift's guard there is left out, since closing no picker does nothing. `UI/ColorPaletteControls` twins Swift's file under the rail: two 24-point swatches (a 1.5 white ring inside a black rim), the swap (X) and defaults (D) glyphs drawn as paths in the secondary ink, dimmed while disabled, the whole resting while the palette cannot be edited, both swatches repainted whole on every change (they overlap); a mask's swatch offers Black · Hide or White · Reveal in a popup under the swatches (Swift's popover), which a change of target closes, and a mask chosen cancels an open picker; the panel follows the session's picker by its id. Swift's frame is 36 points and its two buttons reach 3 points past it, so the twin's widget is 42 points square with the frame inset by 3, in a rail column 42 wide with the tools centred, the swatches 18 points below the last tool (Swift's spacing of 10 and padding of 8). `SwatchButton` draws every swatch: the palette's (radius 6, white ring 1.5), the Brush bar's (34 by 18, radius 4, white ring 1, black rim), the Shape bar's Fill and the Type bar's (36 by 18, radius 3, a half-black rim). The Brush bar's shows the foreground for the Brush and Spot Healing, not Clone Stamp, the Smear or a mask, resting while the palette cannot be edited; the Type bar's shows `typeColor` and opens `openTextColorPicker`. X and D swap and reset the palette on the canvas and in the list, Shift allowed, not with Ctrl, Alt or Meta; `ProjectWorkspace::canSwitch` refuses while a picker is open. Tests wait for the panel to take the keys (`QApplication::activeWindow`): a tool window reads as active whenever its parent is, and offscreen a hidden panel stays the focus window, where a window manager hands the keys back. Equivalent mutants: `raise` (offscreen keeps no stacking order), `refocus`'s `isVisible` (offscreen activates no hidden window), `show`'s `wasVisible` guard (a shown panel stands where it was remembered), and in `sampleCompositeColor` Swift's `min(alpha, value)` (a premultiplied channel never passes its alpha), `painter.end()` before the read (the raster engine writes at once) and the warning. +- The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush). +- Levels in the session (9.1a): `Document/Levels.h/.cpp` twin Swift's file, `Levels+Session.h` its session half; `LevelsAutomatic.h/.cpp` and `LevelsAutomatic+Session.h` the other file. `LevelsPixels.c` compiles in place beside the other kernels. `LevelsChannel`, `LevelsSample` and `LevelsAuto` keep Swift's raw values (`rawValue`) and case order (`allLevelsChannels`, `allLevelsSamples`, `allLevelsAutos`); `LevelsSample` is declared opaquely in `Levels.h`, since `LevelsEdit` holds one, and defined with the automatic levels, as Swift's. `LevelsSettings::sampling` is declared in `Levels.h` and defined in `LevelsAutomatic.cpp`, as Swift's extension; `settings(mode, histogram)` stands for `LevelsAuto.settings(histogram:)`; `setCurrent` is Swift's `current` setter, storing the range normalized. A histogram is `LevelsHistogram`, four arrays of 256 bins where Swift has `[[Double]]`, and `LevelsHistogramDisplay::scale` takes one channel's bins. `LevelsFilter::run` leaves soft edges to the kernel. Known difference: Swift's latest commit (cc5aac4, "Fix dark soft edges in Hue/Saturation and Levels") unpremultiplies each soft edge before `levels_apply`, which unpremultiplies again, so an edge is processed twice and Swift's own `inputClippingGammaOutputInversionAndAlpha` fails: an inverted (64, 32, 0, 128) comes out (0, 32, 64, 128) where the test expects (64, 96, 128, 128); the twin holds the test. The histogram hands the kernel one row at a time with the mask's own row, since Qt pads a gray image's rows and the kernel accumulates; neither it nor the filter's tables take heap memory of their own, whose failure would throw `std::bad_alloc` past the worker's catch. `LevelsEdit` is a plain class in a `std::optional` of the session, like `FilterEdit`, its `id` standing for Swift's object identity; past 8000 pixels a side its preview source is a scaled copy (sides truncated, one pixel at least), drawn from a painted layer's tiles without flattening them. The session keeps a watcher each for the histogram, the preview and the commit, with the edit each serves: where Swift cancels a task, the twin forgets whose it is (`m_levelsPreviewFor`; a histogram needs no token, since a new edit's count replaces the watcher's future, whose signal Qt then drops), so a result for a turned-off, closed or committing edit drops; one preview runs at a time, the newest job waiting, and a commit drops the waiting job too. A run counts as running until its finish has run (`m_levelsRendering`), as Swift's task clears itself only after its await: Qt's `setFuture` drops a replaced future's pending signal, and `isRunning` and `isFinished` both end with the worker (probed on 6.4.2). A histogram or preview that fails logs on `omaphoto.app` where Swift's `try?` is silent; a failed histogram leaves the bins empty and ready, as Swift's. `setLevelsSampleMode` is the sheet's write (Swift's sheet sets `sampleMode` and bumps `brushRevision` itself); it notifies. The commit writes the layer in place and drops shape and text, as the filter's does. `beginLevels` leaves out Swift's `levels == nil` and its `activeLayer` and `document` guard (`canAdjustColors` reads all three); `hueSaturation` arrives with 9.3, `cancelCrop` with 10.1, the adjustment layer hooks of `updateLevels`, `cancelLevels` and `commitLevels` came with 9.5c. The gates, `canAdjustColors` and `canStartProjectOperation` read `levels`, `selectLayer` and `selectTool` refuse while it is open, and closing it resumes file requests, Swift's `didSet`; the commit's end resumes them through `setIsProjectBusy(false)`, after the edit is gone. `commitLevels` hands its worker the asset, so a painted layer flattens there, where a failure is caught (Swift's lazy image flattens as its task draws). Left out as dead: the display scale's zero-peak guard (with no bin counted the interior is empty too), the preview flag in `previewImage` and in a landing preview (turning it off clears the preview and forgets the run), and in the automatic levels contrast's `low < high` (each channel's low lies below its high, so theirs do), neutral's `0 < mean < 1` and its 9.99 cap (weight at both ends of a range keeps the mean a thousandth inside, so gamma stays under 9.97), and the sample's 0 and 255 clamps (a sample is 0 to 1). Equivalent mutants: the white sample's `max(black + 1, …)` (`normalized` keeps white above black), `sampleLevels`' bounds on the layer's own pixels (a pixel off the image draws clear, which the alpha test refuses), the histogram's `committing` check (a count finishing during a commit, a race no test orders), the commit's check that the layer kept its pixels and place (nothing edits a layer while the project is busy), the tables' reservation and the warnings. `LevelsFailureTests` starves allocations through `AddressSpaceLimit` in a binary of its own: a canvas-wide stroke leaves hundreds of MB free inside malloc's arenas, which `malloc_trim` cannot unmap, so its painted layers are rasters built directly; worker threads allocate within their own reserved arenas, so only asks over 64 MB fail there. +- The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint. +- Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point). +- Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift. +- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held. +- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and 100 megapixels at most (`ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, since Swift's expects a blur clamped at the layer's old edge where the code grows and trims), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again). +- The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing). +- Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`. +- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`. +- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does. +- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`. +- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. A fix beyond Swift: Swift 1.1 gave the record an `effects` key and taught its exporter to draw it, but its `projectSnapshot` and `installProject` never carry the key (the same commit wired `text` through both), so Swift's saves and exports drop every effect its canvas shows. The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, the filter's, Levels' and Hue/Saturation's commits, a floating merge, a copy between projects and both resizers leave them out. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy. +- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, 100 million. Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning). +- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too. +- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels). +- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena. +- Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, ` color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete `. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none). +- Effects in the list (9.6e): `LayerEffectRow` (declared in `NativeLayerList.h`, its code in `NativeLayerList+Effects.cpp`) twins Swift's private class: a 24-point row under its layer for each effect, in Swift's order, the cell 52 points tall and 24 more for each (`rowHeight`, `effectHeight`). The list activates its column's layout after configuring the cells, as `noteHeightOfRows` changes heights at once, so no press lands on a row that a taller one still covers. The eye (20 by 22, 38 points in plus the layer's step, flat, `eye` or `eye.slash` in `PlaceholderText`, named `Hide ` or `Show `) toggles the effect in one step and rests while `canEditLayers` does not hold; the name (11 pixels, `WindowText`, `PlaceholderText` while hidden) ends in an ellipsis when narrow, as `.byTruncatingTail`; the row's tooltip is Swift's with Alt for Option, its accessible name ` effect`, and an accessible interface of Swift's group role chooses the effect on the press action (`accessibilityPerformPress`). The rows are made anew only when the kinds change, else reconfigured in place; the old ones are hidden and deleted later, because a row's own double click may cancel a new effect's panel on the same layer, whose Cancel removes that effect and rebuilds the rows under the press, where Swift's reference counting keeps the row alive. A chosen effect's row shows `Highlight` at 30%, and while one is chosen the cell's selection hides, as Swift's table selection does. Presses follow Swift's `mouseDown` order: Alt without Ctrl on the strip (the bottom 10 points of the taller row, over its last effect; Swift's `min(10, height / 3)` is dead, rows being at least 52) clips through `clickRow`; on an effect, Alt without Ctrl arms a copy while layers can be edited, the release choosing the effect and a move past `startDragDistance` with Alt still held starting a `QDrag` of `UUID:Kind` under `effectType` with the list's token, offering Copy alone under the duplicate cursor, as the row's Alt drag does; any other left press chooses the effect, a double click editing it (Swift's `clickCount > 1`); the eye's Alt presses are the row's, ignored in its filter so they go on to it (Swift's `hitTest`). An effect dropped from the same list (Swift compares the source's session) lands on the row under the pointer, filled, as a copy, where `canCopyEffect` allows. A left press below every row lets go of the chosen effect and, without Ctrl or Shift, of every layer, as Swift's table with `allowsEmptySelection` does (a gap the list of 4.6 left). Known difference: the two points between rows take no press, where Swift's rows take their spacing. Swift has no tests for the list's effects: `LayerEffectRowTests` pins them, with `QDrag::exec` defined in its binary as in `LayerDragGestureTests`. +- Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counts), `RemoveBackgroundTests`, the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests. +- Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's `onChange`), the frame's size as whole pixels (Swift's `Int`, truncated) grouped through the English locale like the status bar (`monospacedDigit` has no Qt 6.4 twin), and Cancel and Apply Crop resting without a frame; the bar rests while busy shows or without a document. `ContentView` shows it for the Crop tool and the "Couldn’t crop" alert over `cropError`. Left out as dead: `renderBounds`' tool test (a frame lives under the Crop tool alone: every other way to another tool cancels it or asks `canEditLayers`, which a frame refuses) and the strips' `max(0, …)` in `cropResizeRegions` (a strip it would clip lies under the corners' regions, which take the point first). Equivalent mutants: `contains` on the visible frame for the drawn one (while a frame is drawn they are one), the tolerance's `max(…, 0.0001)` (the zoom's floor keeps points per pixel above it), `cropMove`'s answer after a lost release and after a step (the rest of the move only refreshes the cursor), the strips' ends (under the corners), the frame's caps and joins (its corners lie under the grips) `drawCrop`'s `setBrush(Qt::NoBrush)` (the painter arrives with none) and `takeResult` for `result()` in the landing (a copy asks only more memory, and its failure is caught the same). Tests: `CropTests` (Swift's six session and geometry tests; symmetric drags, snapping by side, bounds, ratios), `CropSessionTests` (a failed crop, refusals and the busy project, the signals, every hook, a flipped frame, one step that ends redo), `CropFixtures.h` (`committed`), `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay`; drags, Alt and Ctrl mid-drag, keys and lost releases, cursors, the dimming, a frame past the canvas), `CropControlsTests`, `CropFailureTests` (a binary of its own whose `main` shares the main heap among threads, `M_ARENA_MAX` 1 before any thread starts, and maps large asks, so a worker's allocations meet `AddressSpaceLimit`: a worker in an arena of its own grows its reserved heap past the limit; the snapshot, the worker and the landing each run out in turn), the bar and the alert in `ContentViewTests`. +- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom Swift's `ColorPicker` as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha, the desktop's colour dialog where SwiftUI opens the system panel; then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `CompositorMenusTests`. +- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 preview on gray 0.12 (the encoded preview fitted, centred and smooth; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha (the same colour asks nothing, as Swift's `onChange`), the raster's size grouped with "px · sRGB", then the error in `BrightText`, or the file's size and "· encoded preview, fitted to window", or "Updating preview…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). +- File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`. +- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. +- 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 the mean of its edge pixels, as Swift's `edgeTone`. `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`, a spacer 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 `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (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. +- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update). +- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`. +- `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible. +- `SeparableBlend` has no twin. It exists because CoreGraphics gets Color Dodge and Color Burn wrong; QPainter's follow the PDF formulas (`LayerRendererTests::everyBlendModeMatchesThePdfFormulas` proves all thirteen modes within 2/255). +- `Rendering/HslBlend` is Linux-only: Hue, Saturation, Color and Luminosity, which QPainter lacks, blended by hand over a copy of the backdrop. +- Known difference: CoreGraphics enlarges "High quality" layers with a sharper kernel; QPainter offers bilinear only, so `InterpolationQuality::high` and `low` draw alike when enlarging. Reductions are sharp on both (halvings). +- Where an Apple framework threads internally (vImage, Core Image, Metal), the twin threads with QtConcurrent. +- A Swift error enum becomes an exception class with a `Kind` enum; `what()` carries the Swift `errorDescription`. +- `@Observable` classes stay plain C++ where only `EditorSession` mutates them; the session notifies the UI. + +Deliberate name breaks: `MetalBrushCoverage` → `BrushCoverage`, `MetalLayerEffects` → `LayerEffectsKernel`. + +## Linux-only behaviour + +- Tool icons: SF Symbols have no Linux twin, and icon themes differ by distro, so `src/UI/ToolIcons` draws each tool's icon as paths in an 18-point square, in one colour that the caller takes from the palette (the Omarchy theme retints them for free). Swift's two hand-drawn icons keep their geometry there (`GradientToolIcon`'s Floyd–Steinberg fade, `CloneStampToolIcon`'s four parts); their Swift homes (`GradientControls`, `BrushControls`) arrive later. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (ellipse marquee, polygonal lasso, eraser) arrive with that state. `tests/fixtures/ToolIcons.png` is the reference sheet: `ToolIconTests` redraws it, allows a font a few pixels and a stroke none, and writes the fresh sheet beside the test binary; after a wanted change, look at it and commit it. +- Omarchy theme: `src/UI/OmarchyTheme` reads `~/.local/state/omarchy/current/theme/colors.toml` (Omarchy 4.x: flat `key = "value"` lines, `mode` dark or light, the twenty-two stock themes' keys required, `orange` optional and `yellow` its stand-in) into `OmarchyColors`, applies them as the application palette (Window and Text from `background` and `foreground`, Base from `darker_background`, Button from `lighter_background`, Highlight and Link from `accent` with the background as highlighted text, PlaceholderText from `dark_foreground`, BrightText from `orange` or `yellow`, Mid from `muted`, the disabled roles from `muted`) and sets the Fusion style, which follows a palette on every desktop where a platform style does not; no stylesheet is needed. Keys and values read in double or single quotes. A missing key, or a recognised key whose value is bare or half-quoted or no `#rrggbb`, refuses the file with the key's name; a file that exists but cannot be opened, or sits behind a folder that cannot be searched (`stat` fails other than `ENOENT`), is refused too; either way the app keeps the palette it has and logs a warning, where an absent file means the built-in dark, Swift's grays, installed before the first load. `omarchy-theme-set` removes `current/theme`, moves `next-theme` in and writes `theme.name`, so the watcher holds `current/`, `current/theme` and the file, adds them back after each load (a removed path leaves a `QFileSystemWatcher`) and renews the file's watch every load (it follows the inode, which an atomic replacement swaps), watches the nearest ancestor the watcher accepts while `current/` is missing or barred, letting it go once the directory is watched, and loads once 100 ms after the last event. A read that stops short (an error, or fewer bytes than the file holds) is refused like a malformed file. Each load logs the theme's name and mode on `omaphoto.app`. `main` makes the theme before the window. Widgets take colours from the palette, never from literals; the canvas keeps Swift's document grays. The UI font stays Qt's default: `omarchy font current` is not followed. +- No updater. Distro packages own updates. +- Distribution (11.4b): releases live on GitHub under fixed asset names (`omaphoto_amd64.deb`, `omaphoto.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`) with `SHA256SUMS`, so `releases/latest/download/<name>` always reaches the newest. `scripts/install/ubuntu.sh`, `fedora.sh` and `arch.sh` read `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`), refuse another family or another machine than x86_64 (the DEB, built on Ubuntu 24.04, names Ubuntu's `t64` libraries, which Debian 13 lacks, so Debian is no family of it), verify the package against the sums and install it with apt, dnf or pacman under sudo, so the package manager resolves dependencies; apt reads the file as its own user, so the download folder is opened to it. pacman asks which build provides `onnxruntime`. Fedora's script names RPM Fusion's `libheif-freeworld` when it is missing, since Fedora's libheif decodes no HEVC, and adds no repository itself. `nixos.sh` installs the flake into the user's profile (`nix profile install`, the name every Nix still takes). The README runs each script after downloading it, so the package manager's questions read the terminal and fish runs it too. `release.yml` builds each package with the command the distro checks use, the DEB bundling ONNX Runtime from the dev image, the Arch package through `makepkg` as a user; the version is CMake's `project` version, the PKGBUILD's and the flake's, kept equal by hand. +- Remove Background runs an open-licensed model on ONNX Runtime. +- A `.comp` project is a directory on Linux; its layout matches the macOS package. +- Shortcuts map ⌘ to Ctrl and ⌥ to Alt. + +## Commands + +No sudo is needed. Docker does the work. + +- `scripts/dev.sh build` — configure and build in the Ubuntu 24.04 container, warnings as errors. With no `CMAKE_BUILD_TYPE` the build is `RelWithDebInfo`: pixel code must stay optimized. Every target is compiled with `_GLIBCXX_ASSERTIONS`: an empty `std::optional` or a bad index aborts, in the tests and in the app, where it would otherwise pass by luck. +- `scripts/dev.sh test` — build, then run every test headless (`QT_QPA_PLATFORM=offscreen`). `HOME` and `/tmp` sit on a tmpfs, as in the distro checks: the store's syncs on the container's disk wait on a busy host. +- `scripts/dev.sh run` — run the app on the host Wayland display. +- `scripts/mutate.py FILE [--targets T1,T2]` — mutation runner, run from the repository root. Reads `old ==> new` blocks separated by `@@` lines on stdin, or with `--delete [--from N --to M]` removes each statement line in turn. A mutant counts as caught only when it builds and a test fails. Run it on every item before review. +- `scripts/heic-fixtures.sh` — rewrites the HEIC test fixtures in a throwaway container that has `heif-enc` and x265. Its parts are real files under `scripts/heic-fixtures/` (`Dockerfile`, `sources.cpp`, `generate.sh`), so lint reads each in its own language. +- `scripts/lint.py` — the house rules, from the repository root: every file under 500 lines, every comment one line and at most ten words. Each file is read by a scanner for its own language: C++ (`//` and `/* */` outside literals), Python (its own tokenizer), CMake (`#` outside quotes, escapes and bracket arguments, which open only where an argument begins, tracked as state through escapes; bracket comments too), Docker (any line that begins with `#`) and shell. The shell scanner reads the subset the scripts keep to (quotes, escapes, `$( )` and `${ }` to any depth, here-strings, `#` where a word begins) and refuses the rest by name and line, so nothing passes unread: `<<` (a heredoc or a shift), backticks, `$'…'`, arithmetic, `((`, `case` inside `$( )`, and a continued line anywhere but between words (it could split `$(`, `<<` or a keyword). Write scripts inside that subset. A file of any other kind under `scripts` fails lint. It reads `src`, `tests`, everything under `scripts`, `CMakeLists.txt` and `Dockerfile.dev`. `--self-test` checks the checker. Run both before every review. + +## Code rules + +Fail fast. No silent fallbacks. Fix causes. Log key paths through `QLoggingCategory` (`src/Logging.h`). No dead code. Files stay under 500 lines. Comments: one line, ten words at most, only when needed. No abstraction layers, no reserved extension points. Tests assert concrete values and fail when the code breaks. Tests read a returned `std::optional` through `.value()`, never `*` or `->`: an empty one must fail the test, not read stale memory. Code reads a found element through a checked form, a layer by `indexOf` and `[]`, a map by `at`: when its guard is lost the read aborts or throws, where an iterator at `end()` reads wild memory and the tests pass by luck (`_GLIBCXX_ASSERTIONS` checks `[]`, not iterators). diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..83cf52b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,460 @@ +cmake_minimum_required(VERSION 3.22) +project(omaphoto VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_AUTOMOC ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE RelWithDebInfo) +endif() + +option(OMAPHOTO_WERROR "Treat warnings as errors" OFF) + +find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test) +find_package(libheif REQUIRED) +find_package(Fontconfig REQUIRED) +find_package(onnxruntime REQUIRED) + +add_library(compositor_core STATIC + src/Logging.cpp + src/ContentView.cpp + src/ContentView+Status.cpp + src/ContentView+Panels.cpp + src/Document/BrushStroke.cpp + src/Document/BrushStroke+Stroke.cpp + src/Document/BrushStroke+Commit.cpp + src/Document/CanvasSize.cpp + src/Document/Distort.cpp + src/Document/DocumentHistory.cpp + src/Document/EditorSession.cpp + src/Document/EditorSession+Brush.cpp + src/Document/EditorSession+Imports.cpp + src/Document/EditorSession+Layers.cpp + src/Document/EditorSession+Model.cpp + src/Document/EditorSession+Projects.cpp + src/Document/EditorSession+Transform.cpp + src/Document/LayerAdjustment.cpp + src/Document/LayerEffects.cpp + src/Document/LayerEffects+Session.cpp + src/Document/LayerEffects+Renderer.cpp + src/Document/AdjustmentEditing.cpp + src/Document/LayerAppearance.cpp + src/Document/LayerGroups.cpp + src/Document/LayerMask.cpp + src/Document/LayerTransform.cpp + src/Document/LiveLayerMask.cpp + src/Document/PixelAdjust.cpp + src/Document/ProjectWorkspace.cpp + src/Document/SelectionClipboard.cpp + src/Document/Crop.cpp + src/Document/Filters.cpp + src/Document/GuidedMatte.cpp + src/Document/SubjectRemoval.cpp + src/Document/FloatingSelection.cpp + src/Document/Gradient.cpp + src/Document/Filters+Session.cpp + src/Document/Levels.cpp + src/Document/LevelsAutomatic.cpp + src/Document/Curves.cpp + src/Document/ImageAdjustments.cpp + src/Document/HueSaturation.cpp + src/Document/HueSaturation+Filter.cpp + src/Document/HueSaturation+Session.cpp + src/Document/BlurTool.cpp + src/Document/CloneStamp.cpp + src/Document/ColorPalette.cpp + src/Document/ContentFill.cpp + src/Document/LayerFlip.cpp + src/Document/LayerMerge.cpp + src/Document/MagicWand.cpp + src/Document/MaskTracing.cpp + src/Document/PixelInvert.cpp + src/Document/Selection.cpp + src/Document/SelectionEdits.cpp + src/Document/ShapeTool.cpp + src/Document/TypeTool.cpp + src/Document/SmudgeLiquify.cpp + src/UI/BlendModePicker.cpp + src/UI/BrushControls.cpp + src/UI/CropControls.cpp + src/UI/CanvasSizeSheet.cpp + src/UI/ImageSizeSheet.cpp + src/UI/JPEGExportSheet.cpp + src/UI/ByteCounts.cpp + src/UI/SliderSnap.cpp + src/UI/GradientControls.cpp + src/UI/ShapeControls.cpp + src/UI/TypeControls.cpp + src/UI/CanvasThumbnail.cpp + src/UI/ColorPaletteControls.cpp + src/UI/ColorPickerSheet.cpp + src/UI/CompositorMenus.cpp + src/UI/LayerAppearanceControls.cpp + src/UI/LayerIcons.cpp + src/UI/LassoControls.cpp + src/UI/LayerMaskMenu.cpp + src/UI/LayersPanel.cpp + src/UI/LevelsSheet.cpp + src/UI/HueSaturationSheet.cpp + src/UI/CurvesControls.cpp + src/UI/NativeLayerList.cpp + src/UI/NativeLayerList+Buttons.cpp + src/UI/NativeLayerList+Cell.cpp + src/UI/NativeLayerList+Drag.cpp + src/UI/NativeLayerList+Effects.cpp + src/UI/NavigationToolHeader.cpp + src/UI/TransformInspector.cpp + src/UI/EffectsSheet.cpp + src/UI/FilterSheet.cpp + src/UI/FloatingPanel.cpp + src/UI/NewCanvasSheet.cpp + src/UI/OmarchyTheme.cpp + src/UI/ProjectTabs.cpp + src/UI/ProjectWorkspaceView.cpp + src/UI/ToolHeaderStyle.cpp + src/UI/ToolIcons.cpp + src/IO/CanvasResizer.cpp + src/IO/ImageExporter.cpp + src/IO/ImageImporter.cpp + src/IO/ImageResizer.cpp + src/IO/ProjectController.cpp + src/IO/ProjectController+Resize.cpp + src/IO/ProjectController+Export.cpp + src/IO/ImageFileDrop.cpp + src/IO/ProjectStore.cpp + src/IO/ProjectStore+Adjustment.cpp + src/IO/ProjectStore+Effects.cpp + src/IO/ProjectStore+Manifest.cpp + src/Rendering/AdjustmentSurface.cpp + src/Rendering/CanvasViewport.cpp + src/Rendering/DownsampleCache.cpp + src/Rendering/EffectsPreviewCache.cpp + src/Rendering/EditorCanvas.cpp + src/Rendering/EditorCanvas+Brush.cpp + src/Rendering/EditorCanvas+Crop.cpp + src/Rendering/EditorCanvas+Effects.cpp + src/Rendering/EditorCanvas+Gradient.cpp + src/Rendering/EditorCanvas+Cursors.cpp + src/Rendering/BrushCursorOverlay.cpp + src/Rendering/EditorCanvas+Navigation.cpp + src/Rendering/EditorCanvas+Picking.cpp + src/Rendering/EditorCanvas+Selection.cpp + src/Rendering/EditorCanvas+Shape.cpp + src/Rendering/InlineTextEditor.cpp + src/Rendering/InlineTextEditor+Editing.cpp + src/Rendering/InlineTextEditor+Input.cpp + src/Rendering/InlineTextEditor+Pointer.cpp + src/Rendering/TextLayout.cpp + src/Rendering/EditorCanvas+Transform.cpp + src/Rendering/SelectionIcons.cpp + src/Rendering/EyedropperIcon.cpp + src/Rendering/SampleRingOverlay.cpp + src/Rendering/TransformOverlay.cpp + src/Rendering/HslBlend.cpp + src/Rendering/LayerEffectsKernel.cpp + src/Rendering/LayerEffectsSurface.cpp + src/Rendering/LayerRenderer.cpp + src/Rendering/LiveMaskRenderer.cpp + src/Rendering/BrushCoverage.cpp + src/Rendering/RasterSnapshot.cpp + src/Rendering/TiledLayerRenderer.cpp +) +# Shared macOS pixel kernels, always optimized. +set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels) +target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c + ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c) +set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c + ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c + PROPERTIES COMPILE_OPTIONS -O2) +# The per-pixel loops vectorise only at -O3. +set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3) +target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS}) +target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime) +target_compile_options(compositor_core PUBLIC -Wall -Wextra + $<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>) +# Empty optionals and bad indexes abort, not pass by luck. +target_compile_definitions(compositor_core PUBLIC + OMAPHOTO_VERSION="${PROJECT_VERSION}" _GLIBCXX_ASSERTIONS) + +add_executable(omaphoto src/CompositorApp.cpp) +target_link_libraries(omaphoto PRIVATE compositor_core) +include(GNUInstallDirs) +install(TARGETS omaphoto RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) +install(FILES packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages) +# Swift's app icon, one size a hicolor folder. +foreach(size 16 32 64 128 256 512) + install(FILES packaging/icons/app-icon-${size}.png + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${size}x${size}/apps RENAME io.github.ZacharyZhang_NY.OmaPhoto.png) +endforeach() +install(FILES licenses/U-2-Net.txt LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/omaphoto) +# A direct install registers the project type; packages use hooks. +install(CODE " + if(\"\$ENV{DESTDIR}\" STREQUAL \"\") + find_program(UPDATE_MIME update-mime-database REQUIRED) + execute_process(COMMAND \${UPDATE_MIME} \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/mime\" COMMAND_ERROR_IS_FATAL ANY) + endif()") +# Ubuntu 24.04 packages no ONNX Runtime: its package carries one. +option(OMAPHOTO_BUNDLE_ORT "Install ONNX Runtime beside the app" OFF) +set(OMAPHOTO_ORT_NOTICES /usr/local/share/doc/onnxruntime CACHE PATH "ONNX Runtime's LICENSE and notices") +if(OMAPHOTO_BUNDLE_ORT) + # Microsoft's licence travels with its library. + foreach(notice LICENSE ThirdPartyNotices.txt) + if(NOT EXISTS ${OMAPHOTO_ORT_NOTICES}/${notice}) + message(FATAL_ERROR "bundling ONNX Runtime needs ${OMAPHOTO_ORT_NOTICES}/${notice}") + endif() + install(FILES ${OMAPHOTO_ORT_NOTICES}/${notice} DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/omaphoto/onnxruntime) + endforeach() + install(IMPORTED_RUNTIME_ARTIFACTS onnxruntime::onnxruntime LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/omaphoto) + set_target_properties(omaphoto PROPERTIES INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/omaphoto") +endif() +# Debian and Fedora packages; each built on its own system. +set(CPACK_PACKAGE_NAME omaphoto) +set(CPACK_PACKAGE_VENDOR "ZacharyZhang-NY") +set(CPACK_PACKAGE_CONTACT "ZacharyZhang-NY") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Layered image editing for Linux, a port of Compositor") +set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/ZacharyZhang-NY/OmaPhoto") +set(CPACK_PACKAGING_INSTALL_PREFIX /usr) +set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) +set(CPACK_DEBIAN_PACKAGE_SECTION graphics) +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) +# What no library names: HEVC decoding, TIFF, the MIME tools. +set(CPACK_DEBIAN_PACKAGE_DEPENDS "libheif-plugin-libde265, qt6-image-formats-plugins, shared-mime-info, hicolor-icon-theme") +set(CPACK_RPM_FILE_NAME RPM-DEFAULT) +set(CPACK_RPM_PACKAGE_LICENSE MIT) +set(CPACK_RPM_PACKAGE_GROUP Applications/Multimedia) +set(CPACK_RPM_PACKAGE_REQUIRES "qt6-qtimageformats, shared-mime-info, hicolor-icon-theme") +# Fedora decodes HEVC through RPM Fusion's libheif-freeworld. +set(CPACK_RPM_PACKAGE_SUGGESTS libheif-freeworld) +include(CPack) +# Remove Background's model, which packages fetch by checksum. +set(OMAPHOTO_MODEL "" CACHE FILEPATH "u2net.onnx to install") +if(OMAPHOTO_MODEL) + # The file itself: a link leads back to this machine. + file(REAL_PATH "${OMAPHOTO_MODEL}" model) + install(FILES ${model} DESTINATION ${CMAKE_INSTALL_DATADIR}/omaphoto RENAME u2net.onnx) +endif() + +enable_testing() +function(compositor_test name) + add_executable(${name} tests/${name}.cpp) + target_link_libraries(${name} PRIVATE compositor_core Qt6::Test) + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() + +compositor_test(AdjustmentSurfaceTests) +compositor_test(BlendModePickerTests) +compositor_test(BlendShortcutTests) +compositor_test(LayersPanelTests) +compositor_test(LayerListFocusTests) +compositor_test(LayerDragTests) +compositor_test(LayerDragGestureTests) +compositor_test(ProjectDropTests) +compositor_test(BlendModeTests) +compositor_test(BrushCanvasTests) +compositor_test(BrushGestureTests) +compositor_test(BrushKeyTests) +compositor_test(BrushCursorOverlayTests) +compositor_test(BrushControlsTests) +compositor_test(BrushPerformanceTests) +compositor_test(CanvasPerformanceTests) +compositor_test(PoolMapTests) +compositor_test(DesktopIntegrationTests) +# The install it checks needs the app built. +add_dependencies(DesktopIntegrationTests omaphoto) +# What this build installs, as CMake itself reads it. +target_compile_definitions(DesktopIntegrationTests PRIVATE OMAPHOTO_BUNDLED=$<BOOL:${OMAPHOTO_BUNDLE_ORT}> + OMAPHOTO_LIBDIR="${CMAKE_INSTALL_LIBDIR}" OMAPHOTO_MODEL_PATH="${OMAPHOTO_MODEL}") +compositor_test(BrushIntersectionTests) +compositor_test(BrushSessionTests) +compositor_test(BrushPreviewTests) +compositor_test(BrushRasterTests) +compositor_test(BrushTests) +compositor_test(BrushCoverageTests) +compositor_test(SpotHealingTests) +compositor_test(CloneStampTests) +compositor_test(CloneStampCanvasTests) +compositor_test(SmudgeLiquifyTests) +compositor_test(BlurToolTests) +compositor_test(SmearCanvasTests) +compositor_test(GradientTests) +compositor_test(GradientCanvasTests) +compositor_test(ShapeToolTests) +compositor_test(ShapeLayerTests) +compositor_test(ShapeCanvasTests) +compositor_test(TypeToolTests) +compositor_test(TextLayoutTests) +compositor_test(TypeSessionTests) +compositor_test(TypeStoreTests) +compositor_test(TypeControlsTests) +compositor_test(InlineTextEditorTests) +compositor_test(InlineTextInputTests) +compositor_test(InlineTextDrawTests) +compositor_test(InlineTextPreeditTests) +compositor_test(InlineTextPointerTests) +compositor_test(InlineTextHandleTests) +compositor_test(ColorPickerTests) +compositor_test(FloatingPanelTests) +compositor_test(ColorSwatchTests) +compositor_test(ColorPaletteControlsTests) +compositor_test(ColorPickerSheetTests) +compositor_test(EyedropperTests) +compositor_test(PickingTests) +compositor_test(LevelsTests) +compositor_test(LevelsSessionTests) +compositor_test(LevelsModelTests) +compositor_test(LevelsEdgeTests) +compositor_test(LevelsFailureTests) +compositor_test(LevelsSheetTests) +compositor_test(LevelsHandleTests) +compositor_test(LevelsCanvasTests) +compositor_test(ImageAdjustmentTests) +compositor_test(ImageAdjustmentModelTests) +compositor_test(CurvesControlsTests) +compositor_test(HueSaturationTests) +compositor_test(HueSaturationModelTests) +compositor_test(HueSaturationSessionTests) +compositor_test(HueSaturationFailureTests) +compositor_test(HueSaturationSheetTests) +compositor_test(HueSaturationSpectrumTests) +compositor_test(HueSaturationCanvasTests) +compositor_test(CanvasDisplayTests) +compositor_test(CanvasNavigationTests) +compositor_test(CanvasSizeTests) +compositor_test(CanvasThumbnailTests) +compositor_test(ClippingStackTests) +compositor_test(CompositorTests) +compositor_test(CompositorMenusTests) +compositor_test(CompositorUITestsLaunchTests) +compositor_test(ContentViewTests) +compositor_test(ContentViewStatusTests) +compositor_test(DistortTests) +compositor_test(DownsampleTests) +compositor_test(DrawCoverageTests) +compositor_test(EditorSessionSignalTests) +compositor_test(EditorSessionTests) +compositor_test(ExportTests) +compositor_test(FolderMaskClipTests) +compositor_test(EditorCanvasTests) +compositor_test(GroupSessionTests) +compositor_test(GroupTests) +compositor_test(GroupingSelectionTests) +compositor_test(HighDpiCanvasTests) +compositor_test(HistoryTests) +compositor_test(ImageImportSessionTests) +compositor_test(ImageImportTests) +compositor_test(ImageSizeTests) +compositor_test(JPEGExportTests) +compositor_test(LayerAppearanceControlsTests) +compositor_test(LayerAppearanceTests) +compositor_test(LayerMaskEditTests) +compositor_test(LayerMaskSessionTests) +compositor_test(LayerMaskTests) +compositor_test(LayerRendererTests) +compositor_test(LayerTests) +compositor_test(LiveMaskRendererTests) +compositor_test(LiveMaskBakeTests) +compositor_test(LiveMaskDeleteTests) +compositor_test(LiveMaskEditTests) +compositor_test(LiveMaskSessionTests) +compositor_test(LiveMaskTests) +compositor_test(MaskPlacementTests) +compositor_test(MaskSignalTests) +compositor_test(MaskTracingTests) +compositor_test(MaskTransformTests) +compositor_test(ProjectManifestTests) +compositor_test(NativeLayerListTests) +compositor_test(NavigationToolHeaderTests) +compositor_test(NewCanvasSheetTests) +compositor_test(OmarchyThemeTests) +compositor_test(ProjectControllerTests) +compositor_test(ProjectCopyTests) +compositor_test(ProjectOpenTests) +compositor_test(ProjectReceiveTests) +compositor_test(ProjectReplaceTests) +compositor_test(ProjectWorkspaceFlowTests) +compositor_test(ProjectWorkspaceSignalTests) +compositor_test(ProjectWorkspaceTests) +compositor_test(ProjectWorkspaceViewTests) +# It starts the real app beside itself. +add_dependencies(ProjectWorkspaceViewTests omaphoto) +compositor_test(ProjectSessionTests) +compositor_test(ProjectTests) +compositor_test(RasterSnapshotSessionTests) +compositor_test(RasterSnapshotTests) +compositor_test(SessionGateTests) +compositor_test(TiledLayerTests) +compositor_test(TiledPieceTests) +compositor_test(TiledStrokeTests) +compositor_test(TransformSessionTests) +compositor_test(ToolIconTests) +compositor_test(TransformTests) +compositor_test(TransformOverlayTests) +compositor_test(TransformPressTests) +compositor_test(TransformInspectorTests) +compositor_test(TransformSnapTests) +compositor_test(CursorTests) +compositor_test(DistortCanvasTests) +compositor_test(DistortMaskTests) +compositor_test(FiltersTests) +compositor_test(FilterTests) +compositor_test(FilterModelTests) +# It checks what the filters hand Swift's C kernels. +target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS}) +compositor_test(FilterSessionTests) +compositor_test(FilterFailureTests) +compositor_test(LayerAdjustmentTests) +compositor_test(AdjustmentLayerTests) +compositor_test(AdjustmentEditorTests) +compositor_test(AdjustmentPanelTests) +compositor_test(LayerEffectsTests) +compositor_test(LayerEffectsSessionTests) +compositor_test(LayerEffectsKernelTests) +compositor_test(EffectsPreviewCacheTests) +compositor_test(LayerEffectsSurfaceTests) +compositor_test(EffectsCanvasTests) +compositor_test(EffectsFailureTests) +compositor_test(EffectsPanelTests) +compositor_test(LayerEffectRowTests) +compositor_test(GuidedMatteTests) +compositor_test(SubjectRemovalTests) +compositor_test(SubjectRemovalModelTests) +compositor_test(SubjectRemovalFailureTests) +compositor_test(RemoveBackgroundTests) +compositor_test(LayerEffectsRendererTests) +compositor_test(LayerFlipTests) +compositor_test(LayerMergeTests) +compositor_test(PixelEditTests) +compositor_test(PixelInvertTests) +compositor_test(PixelMoveTests) +compositor_test(SelectionTests) +compositor_test(SelectionSourceTests) +compositor_test(SelectionEditTests) +compositor_test(SelectionSignalTests) +compositor_test(SelectMenuTests) +compositor_test(SelectionCanvasTests) +compositor_test(SelectionOverlayTests) +compositor_test(LassoControlsTests) +compositor_test(MagicWandTests) +compositor_test(SelectionClipboardTests) +compositor_test(SmartEditTests) +compositor_test(FilterSheetTests) +compositor_test(FilterControlsTests) +compositor_test(FloatingSelectionTests) +compositor_test(CropTests) +compositor_test(CropSessionTests) +compositor_test(CropCanvasTests) +compositor_test(CropControlsTests) +compositor_test(CropFailureTests) +compositor_test(CanvasSizeSheetTests) +compositor_test(ImageSizeSheetTests) +compositor_test(ProjectResizeTests) +compositor_test(JPEGExportSheetTests) +compositor_test(ProjectExportTests) +compositor_test(ProjectExportFailureTests) +compositor_test(ByteCountsTests) +compositor_test(ImageFileDropTests) +compositor_test(SliderSnapTests) +compositor_test(ProjectResizeFailureTests) diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..a0b0e71 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,25 @@ +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \ + qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev \ + ca-certificates curl dpkg-dev file fakeroot \ + && rm -rf /var/lib/apt/lists/* +# Ubuntu 24.04 packages no ONNX Runtime: Microsoft's own build. +RUN curl -fsSL -o /tmp/ort.tgz \ + https://github.com/microsoft/onnxruntime/releases/download/v1.21.0/onnxruntime-linux-x64-1.21.0.tgz \ + && echo "7485c7e7aac6501b27e353dcbe068e45c61ab51fbaf598d13970dfae669d20bf /tmp/ort.tgz" | sha256sum -c - \ + && mkdir -p /tmp/ort /usr/local/include/onnxruntime /usr/local/lib64 \ + && tar -xzf /tmp/ort.tgz -C /tmp/ort --strip-components=1 \ + && cp -r /tmp/ort/include/. /usr/local/include/onnxruntime/ \ + && mkdir -p /usr/local/lib/cmake && mv /tmp/ort/lib/cmake/onnxruntime /usr/local/lib/cmake/ \ + && cp -a /tmp/ort/lib/. /usr/local/lib64/ \ + && mkdir -p /usr/local/share/doc/onnxruntime \ + && cp /tmp/ort/LICENSE /tmp/ort/ThirdPartyNotices.txt /usr/local/share/doc/onnxruntime/ \ + && echo /usr/local/lib64 > /etc/ld.so.conf.d/onnxruntime.conf && ldconfig \ + && rm -rf /tmp/ort /tmp/ort.tgz +# U²-Net (Apache-2.0), as rembg converted it (MIT), for Remove Background. +RUN mkdir -p /usr/local/share/omaphoto \ + && curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \ + https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \ + && echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e123cf0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Wonder Assembly LLC (Compositor) +Copyright (c) 2026 ZacharyZhang-NY (OmaPhoto, the Linux port) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a07096b --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# OmaPhoto + +OmaPhoto is a layered image editor for Linux, made first for [Omarchy](https://omarchy.org). It follows your Omarchy theme and switches with it. + +It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) by Wonder Assembly, a macOS app written in Swift. Compositor's source is the specification: OmaPhoto keeps its tools, its menus and its shortcuts (Ctrl for ⌘, Alt for ⌥). It reads and writes Compositor's `.comp` projects, so a project moves between the two apps. OmaPhoto is independent of Wonder Assembly. + +## What it does + +- Layers, folders, masks, clipping masks, blend modes and opacity +- Selections: marquee, lasso, magic wand, load from a layer, expand and contract +- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type +- Move, transform and distort layers and selections +- Levels, Curves, Hue/Saturation, Exposure, Gradient Map and Grain, as edits or as adjustment layers +- Layer effects: stroke, drop shadow, color overlay, inner shadow +- Filters: Gaussian and motion blur, noise, lens correction, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline) +- Crop, canvas size, image size; PNG and JPEG export +- Imports JPEG, PNG, TIFF and HEIC + +## Install + +Each script downloads the newest release's package, checks it against the release's `SHA256SUMS` and installs it with your system's package manager. Packages are built for x86_64. + +Arch and Omarchy: + +```sh +curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/arch.sh && bash arch.sh +``` + +Ubuntu 24.04 or later, and its derivatives (Linux Mint 22, Pop!_OS 24.04): + +```sh +curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/ubuntu.sh && bash ubuntu.sh +``` + +The DEB uses Ubuntu's package names, so Debian itself builds from source (below). + +Fedora: + +```sh +curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/fedora.sh && bash fedora.sh +``` + +Fedora's own libheif decodes no HEVC. For HEIC import, add [RPM Fusion](https://rpmfusion.org/Configuration) and install `libheif-freeworld`; the script reminds you. + +NixOS installs from the flake into your profile: + +```sh +curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/nixos.sh && bash nixos.sh +``` + +Or add `github:ZacharyZhang-NY/OmaPhoto` as a flake input and put its `packages.x86_64-linux.default` in your configuration. + +The packages themselves sit on the [releases page](https://github.com/ZacharyZhang-NY/OmaPhoto/releases). + +## Build from source + +The build runs in Docker, so the host needs only Docker: + +```sh +scripts/dev.sh build # configure and build in Ubuntu 24.04 +scripts/dev.sh test # every test, headless +scripts/dev.sh run # the app on your Wayland display +``` + +By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos` builds and tests on those systems. + +## Licence + +MIT, as Compositor is; see `LICENSE`. Remove Background uses U²-Net (Apache-2.0, `licenses/U-2-Net.txt`) through ONNX Runtime (MIT). The Ubuntu package carries ONNX Runtime with its notices. diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..a8da1f7 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,402 @@ +# Compositor for Linux — task list + +This file is the progress truth. Work top to bottom. Do not skip. One item at a time, main agent only. + +## Ground rules + +- No PRD exists. Chapters follow the README feature sections; order bends only where a dependency forces it. +- The Swift tree is the spec and stays untouched. Each Swift file gets a Linux twin with the same type and function names. +- Swift files over 500 lines split by concern with the repo's own `Name+Part` convention. +- Done means: code, twin tests green in the dev container, codex review passed (max 3 rounds), box ticked. +- A failed third round ends an item's reviews. Its findings are fixed, proven by tests and mutants, logged as such, and named in the next item's review scope. +- Review: Herdr split pane, codex agent `reviewer` on `gpt-6-astra` at effort `max` in YOLO mode, scoped to the item's files. The user's shell aliases `codex` to `codex --yolo`, so the pane start passes only `-m gpt-6-astra -c model_reasoning_effort="max"`. +- No commits unless the user asks. + +## Decisions (user-confirmed 2026-09-20) + +- Stack: C++20, Qt 6 Widgets (floor: Qt 6.4, Ubuntu 24.04). CPU raster: QPainter plus the eight C kernels, compiled in place from `Compositor/Rendering/*.c`. +- Remove Background: ONNX Runtime with an open-licensed model. +- No updater on Linux. No Dokploy. Final output: packages and the acceptance table. +- Omarchy source: `~/.local/state/omarchy/current/theme/colors.toml` (Omarchy 4.x). Absent file means built-in dark. Omarchy 3.x is out of scope. + +## Cut (razor) + +GPU backend (unless item 8.3 fails its gate), AppImage, Flatpak, CI pipelines, theme plug-ins, theme settings UI. + +## 0 Foundation + +- [x] 0.1 Dev container, CMake skeleton, logging categories, `AGENTS.md`. New: `linux/Dockerfile.dev`, `linux/CMakeLists.txt`, `linux/scripts/dev.sh`, `linux/src/Logging`, `linux/src/CompositorApp.cpp` (`main`), `linux/src/ContentView` (window shell: title, 1180×780 default, 800×520 minimum). Verify: container builds, the window test passes and saves an offscreen screenshot. + +## 1 Document model + +- [x] 1.1 `LayerTransform`, `LayerAppearance`, `CanvasViewport`, `ImageLayer`, `CanvasDocument`, `NavigationTool`, `ImportedImage`. Tests: twins of the pure-geometry cases in `TransformTests`, `LayerAppearanceTests`. +- [x] 1.2 `DocumentHistory`. Tests: `HistoryTests`. +- [x] 1.3 `RasterSnapshot`, `BrushRaster`/`BrushPatch` helpers. Tests: `RasterSnapshotTests`. +- [x] 1.4 `LayerGroups`: `LayerHierarchy`, `hierarchyRecord`, the `CanvasDocument` hierarchy views; `ProjectLayerRecord` and `ProjectError` arrive here. Tests: `GroupTests` (hierarchy cases). The `EditorSession` extension and its tests move to 4.1. +- [x] 1.5 `LayerMask` core: the struct, `solid`, `assetFrom`, placement rules, `background`, `placed`, `drawSmooth`, `maskTransform`, mask fields in the record and in history. Tests: `LayerMaskTests` (class level). Moved on: `clipImage`, `MaskPlacementCache`, `FolderMaskClip` to 2.1; `ProjectSnapshot.mask` to 3.1; session mask editing and the Swift `LayerMaskTests` twins to 4.1; mask distort to 6.2; `placedMaskPreview` to 8.2. +- [x] 1.6 `LiveMaskGraph.validate`, `maskSourceID` on the layer and record, `MaskTracing`. Tests: `LiveMaskTests` (graph cases), `MaskTracingTests`. Moved on: the session clipping functions, `adoptClipping`, `releaseDetachedClipping`, `LiveMaskBaker`, the delete choice and the Swift `LiveMaskTests`/`MaskTransformTests` twins to 4.1; `drawLiveComposite` to 4.4; `loadMaskSelection`/`loadLayerSelection` to 7.1; the adjustment-source rule of `validate` to 9.5. + +## 2 Rendering + +- [x] 2.1a `DownsampleCache`: Lanczos halvings in place of vImage, threaded with QtConcurrent, `rgba_clamp_premultiplied` from `AdjustPixels.c`. Tests: the cache cases of `DownsampleTests`. +- [x] 2.1b `LayerRenderer.draw`: placement, opacity, every blend mode (the four non-separable ones by hand in `HslBlend`), soft masks without a CoreGraphics mask clip; decide `SeparableBlend` by measuring Qt's dodge and burn against the PDF formulas. Tests: the drawing cases of `DownsampleTests`, blend and mask cases. `drawCoverage` moves to 3.3, its first caller; `drawBrushPreview` to 2.2. +- [x] 2.1c From `LayerMask`: `clipImage`, `MaskPlacementCache`, `FolderMaskClip`. +- [x] 2.2a `TiledLayerRenderer` for committed rasters: `Piece`, `support`, `aligned`, `interiors`, `piece`, `TiledPieceCache`, `Frame`, `drawRaster`; `LayerRenderer`'s compose-aside path becomes shared. Tests: the committed part of `tiledLayersDrawLikeOneImage`, geometry and cache cases. +- [x] 2.2b `TiledLayerRenderer.drawStroke`, `drawMaskStroke`, `drawCropped`, `Piece.offsetBy` and the offset of `drawCommitted`, `Frame.device` and `Frame.sampling` (first read by `drawMaskStroke`), `LayerRenderer.drawBrushPreview`. Tests: the live-stroke parts of `TiledLayerTests` that need no session (`TiledStrokeTests`), `BrushPreviewTests`. +- [x] 2.3 `LiveMaskRenderer` (coverage, chains, clipping stacks, `drawComposite`), `AdjustmentSurface`; `BrushPixels.c` joins the build for its alpha kernels. Tests: `LiveMaskRendererTests`, `ClippingStackTests` (the pixel values of the Swift `LiveMaskTests`, drawn without a session), `AdjustmentSurfaceTests`. Moved on: `LiveMaskRenderer.adjust` and the adjustment hooks to 9.5 (they need `LayerAdjustment`); `LayerEffectsSurface` to 9.6 (it needs `LayerEffects` and the effects kernel). +- [x] 2.4 `ImageExporter` (flatten, PNG, JPEG, resolution metadata, atomic write); `ProjectManifest`, `ProjectSnapshot` and `ProjectSnapshot.mask` arrive here as data, because `render` takes a snapshot. Tests: `ExportTests`, `JPEGExportTests`. Moved on: layer effects in `render` to 9.6, adjustment layers in `render` to 9.5, the cancellation points of `jpeg` to 10.3 (their only caller). + +## 3 IO + +- [x] 3.1 `ProjectStore`: `.comp` read and write at versions 1–7 (verified in `ProjectStore.swift`: `(1...7).contains`), the manifest codec (`ProjectStore+Manifest`), every validation rule and limit, atomic replace with a two-step fallback, `rawValue` for `LayerBlendMode` and `LayerSampling`. Tests: `ProjectTests` (store level; the session cases wait for 4.1 and 4.2), `ProjectManifestTests`, `ProjectReplaceTests`. Moved on: the record fields `adjustment` (9.5), `shape` (8.8), `effects` (9.6) and `text` (8.9) with their validation rules; until then their keys are ignored on load. +- [x] 3.2 `ImageImporter`: JPEG, PNG, TIFF, HEIC (libheif), EXIF orientation, sRGB conversion, pixel budget, thumbnails, `ImageImportError`. Tests: `ImageImportTests` (decode level), HEIC fixtures from `scripts/heic-fixtures.sh`. Moved on: `importPlacementAndPartialFailure`, `dropPositionUsesDocumentCoordinates` and `queuedImportsAreNotLost` to 4.1 (the session's import queue); `fileDropProvidersReachImporterInOrder` to 10.4 (`ImageFileDrop`). Packaging (11.x) must depend on the HEVC decoder plugin by name (`libheif-plugin-libde265` on Ubuntu; Fedora keeps it in RPM Fusion as `libheif-freeworld`). +- [x] 3.3 `ImageResizer`, `CanvasResizer`, `CanvasSize`, `LayerRenderer.drawCoverage`. Tests: `ImageSizeTests`, `CanvasSizeTests` (store level), `DrawCoverageTests`. Also: `lint.py` reads the comments of shell and Python scripts, `CMakeLists.txt` and `Dockerfile.dev` (3.2 round 2). Moved on: `EditorSession.applyImageSize` and `applyDocumentSize` with the undo halves of the Swift tests to 4.1; `CanvasUnit.allCases` to 10.2, its first reader; the record fields the resizers drop or keep (shape 8.8, text 8.9, effects 9.6) are named in AGENTS.md; 7.x must give `drawCoverage` an opaque device under a soft selection clip. + +## 4 Shell, canvas, theme, layers panel + +- [x] 4.1a `EditorSession` core: the class and its change notification, document and tool state, the gates (`canEditLayers`, `canUseHistory`, each reading only the state that exists so far; see "Session hooks owed"), `selectLayer`, `selectTool`, history (`beginEdit`, `endEdit`, `undo`, `redo`), layers (add, delete, rename, visibility and its swipe, reorder, move), `createDocument`, `insert`, `fit`, `zoom`, and from the `LayerGroups` extension what the core's own folder branches need to be tested: `descendantIDs`, `addGroup`, `toggleGroupExpansion`. New: `EditorSessionTests`, `EditorSessionSignalTests` (the `changed()` contract). Tests: `LayerTests` (the session cases), `CompositorTests` (replace `zoomStaysWithinItsRange` with the `limitsAndNewDocumentReset` twin), the session cases of `HistoryTests`. In scope by the third-round rule: the `lint.py` fixes of 3.3 round 3. Moved on: `CanvasEntryTests` to 4.3 and 4.4 (they test `CanvasView` and `NewCanvasSheet`); `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` to 4.6 (`NativeLayerList`); `selectionAndRenameDoNotInvalidateCanvasButPixelChangesDo` to 4.4. +- [x] 4.1b Groups and selection in the session (the rest of the `LayerGroups` extension): `selectLayers`, `extendSelection`, `groupSelectedLayers`, `canPlaceLayer`, `placeLayer`, `moveActiveLayerOutOfGroup`, `layerRows`, and the multi-delete owed since 4.1a: `deleteSelectedLayers`, `finishDeletingLayers`. Tests: `GroupingSelectionTests`; of `GroupTests` the session cases `nestedGroupsMoveOutCollapseAndDeleteUndo` and `hiddenParentOverridesChildrenAndExportOrderFollowsGroups` (its session half, an inserted image standing in for the import); the new mutators join `EditorSessionSignalTests` and the round trip of `HistoryTests`. Moved on: the export lines of `hiddenParentOverrides…` and all of `groupsRoundTripAndSurviveImageAndCanvasResize` to 4.2 (they need `projectSnapshot`); the `canTransform` lines of both test files to 4.1c; the import in `hiddenParentOverrides…` to 4.1d; `placeLayer`'s clipping calls to 4.1e (no session call can clip a layer before `linkMask`). +- [x] 4.1c Transform edits in the session: `TransformEdit`, `TransformGroup`, `canTransform`, `beginTransform`, `previewTransform`, `commitTransform`, `cancelTransform`, `nudgeLayer`, the group box (`transformsAsGroup`, `groupTransformMembers`, `groupTransformBox`), `transformPixelSize`, `displayedTransform`, `editedTransform`, `pendingTransform`, and the hooks owed: `selectLayer`, `selectTool`, `selectLayers` and `createDocument` commit an open edit, the gates read it, `extendSelection` works through it. `DistortWarp::corners` comes forward from 6.2 (the group box is made of corners). Tests: the session cases of `TransformTests` (`blankLayersHaveNoTransformHandlesOrEditing` without its overlay lines, `previewCommitCancelAndUndoPreserveSources`, `scalePercentSetsBothSidesAboutTheCenter` through the session, `noOpInvalidValuesAndSwitchingTools`); new `TransformSessionTests` (Swift tests no group box, nudge or hook); the `canTransform` lines of `GroupTests` and `GroupingSelectionTests`; the transform calls join `EditorSessionSignalTests` and the round trip of `HistoryTests`. Moved on: `TransformEdit.persistent` with the argument of `beginTransform`, `snapGuides` and the overlay lines of `blankLayers…` to 6.1 (canvas and overlay read them); `beginDuplicateTransform`, `transformDuplicate` and `duplicateTransformPreservesOriginalAndSupportsUndoAndCancel` to 5.1 (they need `duplicateActiveLayer`); `layerListToolKeysAndTransformNudge` to 4.6 (it drives the layer table); mask-alone edits, `transformTargetsMask`, mask placements on commit, `blendPreview` and `finishOpacityEdit` to 4.1e; `corners` to 6.2; floating edits to 7.4; the grown transform of `displayedTransform` to 7.5. +- [x] 4.1d Import queue and busy gating: `importImages` (URLs in, a callback when the request is in), the queue and its drain, `waitForProjectAccess`, `waitForFileRequest`, `canStartProjectOperation`, `showsBusy` and its delay; the hooks owed: the six setters resume file requests, `isProjectBusy` frees project waiters and drives `showsBusy`, `importImages` commits a transform first. Tests: new `ImageImportSessionTests` (the twin's `ImageImportTests` stands at 480 lines) with Swift's `importPlacementAndPartialFailure`, `dropPositionUsesDocumentCoordinates`, `queuedImportsAreNotLost` and the queue's own cases (one undo step a request, waiting while busy, the pixel budget over what the canvas holds, what each callback sees, the decoding thread, a session that ends mid-import); new `SessionGateTests` (every gate of a file request, waiters that look again and keep their line, waiters that end with their session, the quarter second); an import and a long wait in `EditorSessionSignalTests`; `GroupTests::hiddenParentOverridesChildrenAndExportOrderFollowsGroups` imports its image. Moved on: `applyImageSize`, `applyDocumentSize` and the undo halves of `ImageSizeTests` and `CanvasSizeTests` to 4.2 (those tests start from `projectSnapshot`); the security scope of a URL has no Linux twin. +- [x] 4.1e1 Appearance in the session (the `LayerAppearance` extension; 4.1e is split in three because its Swift sources run to 750 lines): `blendPreview`, `displayedBlendMode`, `previewBlendMode`, `canEditAppearance`, `opacityEditLayerID`, `beginOpacityEdit`, `finishOpacityEdit`, `setLayerOpacity`, `setSelectedLayersOpacity`, `cycleBlendMode`, `setLayerBlendMode`; the hook owed: `commitTransform` clears the preview and finishes an opacity edit. Tests: of `LayerAppearanceTests` the cases `hoverPreviewIsTemporaryAndNeverChangesSavedState` (its snapshot line waits for 4.1e2) and `opacityDragIsOneUndoAndKeepsSources`; the selected layers' opacity and the blend cycle by cases of their own. Moved on: `moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo` to 8.x (`typeOpacityDigit` lives in `EditorSession+Brush`); `blendModesAndOpacityMatchKnownPixels` and `appearancePersistsThroughSaveResizeAndTransparentExport` to 4.1e2 (`projectSnapshot`). `refreshCanvasPreview` has no twin: `changed()` reaches the canvas. +- [x] 4.1e2 Projects in the session (`EditorSession+Projects`, 56 lines, brought forward from 4.2): `projectSnapshot`, `installProject`, `clearProject`, `createNewProject`, with `applyImageSize` and `applyDocumentSize` from the `ImageResizer` file. It comes before the masks because the twin's document is read-only from outside: where Swift's tests write a mask straight into `session.document`, the twin's tests install a snapshot. Tests, all waiting on `projectSnapshot` until now: the export lines of `GroupTests::hiddenParentOverridesChildrenAndExportOrderFollowsGroups` and all of `groupsRoundTripAndSurviveImageAndCanvasResize`; the undo halves of `ImageSizeTests` and `CanvasSizeTests`; of `LayerAppearanceTests` the snapshot line of `hoverPreview…`, `blendModesAndOpacityMatchKnownPixels` and `appearancePersistsThroughSaveResizeAndTransparentExport`; `LayerTests::newCanvasStartsWithOneSelectedEmptyLayer` (its `canPaint` line waits for 8.x); new `ProjectSessionTests` for the five functions themselves; the session cases of `GroupTests` moved to `GroupSessionTests` (the file stood at 483 lines). +- [x] 4.1e3 Layer masks in the session (the `LayerMask` extension): `isMaskSelected`, `selectLayerTarget`, `canEditMask`, `addLayerMask`, `toggleLayerMask`, `deleteLayerMask`, `canCopyMask`, `copyMask`, `toggleMaskLink`, `displayedMaskPlacement`, `commitMaskTransform`; the hooks owed: `setActiveLayerID` and `restore` keep or drop the mask target, `installProject` and `clearProject` drop it, a mask alone transforms (`TransformEdit.mask`, `transformTargetsMask`, `beginTransform`, `editedTransform`, `pendingTransform`, `transformPixelSize`), `commitTransform` moves mask placements. Tests: new `LayerMaskSessionTests` (the seven session cases of Swift's `LayerMaskTests`, their direct writes going through `rewrite`), new `MaskTransformTests` (Swift's five), new `LayerMaskEditTests` (copying, links, the target across undo and projects, group moves, a mask alone), new `MaskSignalTests`; `tests/SessionFixtures.h` and `tests/SessionRecord.h` share fixtures and the observer's record. Moved on: `addMask` from a selection to 7.1; distorted masks (`corners` in `displayedMaskPlacement` and `commitMaskTransform`, `maskDistortPreview`) to 6.2; the grown transform to 7.5; floating edits to 7.4; `folderMaskCanBePaintedInvertedAndLoadedAsASelection`, the painting half of `placementAndLinkAreSavedAndPaintingFollowsThePlacement` and `placedMaskPreview` to 8.x; `canvasShowsAFolderMaskWhileItIsBeingPainted` and the live-composite half of `renderBoth` to 4.4. +- [x] 4.1e4 Live masks in the session (the `LiveLayerMask` extension with `LiveMaskBaker`): `canLinkMask`, `linkMask`, `adoptClipping`, `removeLiveMask`, `canToggleClippingMask`, `toggleClippingMask`, `releaseDetachedClipping`, `deleteWithLiveMaskChoice` with its alert and its bake off the UI thread, `brushError`; the hooks owed: `deleteLayer` and `deleteSelectedLayers` ask the choice, `finishDeletingLayer(s)` take `baked` and free live masks, `placeLayer` adopts and releases clipping. `PixelAdjust::thumbnail` comes forward from 9.2 for the baker. Tests: new `LiveMaskSessionTests` (the five session cases of Swift's `LiveMaskTests`), new `LiveMaskEditTests` (links and their refusals, runs freed by a release, siblings, adoption, stacks broken by a move, dependents freed baked or not, a folder with the null id, clipping under a selection of several), new `LiveMaskBakeTests` (a bake with an offset target and a translucent source, a masked source in a shape that is no square, a stretched source with a placed mask and no stated opacity, a source without pixels, a snapshot that repeats an id, thumbnails), new `LiveMaskDeleteTests` (the real alert answered three ways, the plural text, folders, a bake that queues for a worker while the caller goes on, a failing bake), clipping in `MaskSignalTests`. Moved on: the adjustment term of `canLinkMask` to 9.5; `drawLiveComposite` stays with 4.4. +- [x] 4.2a `ProjectController` without a workspace (the session's own project functions went ahead as 4.1e2): `canStart`, `begin`, `save` and Save As (the save dialog, the store off the UI thread, the session's `projectPath` setter, `markSaved`, the save generation), `open` (a supplied path or the open dialog, validation before the live document is touched, `confirmReplacement`, the reload when the confirmation's save replaced the opened file, `installProject`), `newCanvas`, `close`, `confirmQuit`, `confirmReplacement` (the alert answered three ways), `showError`, `receive` and its queue (`waitForFileRequest`, more than one project refused, a project opened before the images go in, the point only without a project). Tests: new `ProjectControllerTests`, the real dialogs driven from timers. In scope by the third-round rule: the target-mask fixture of 4.1e4 round 3 (`LiveMaskBakeTests::aBakeMapsTheSourceIntoTheTargetsOwnPixels`). Moved on: `exportPNG` and `exportJPEG` to 10.3, `canvasSize` and `imageSize` to 10.2, `cancelCrop` in `begin` to 10.1, the workspace branches to 4.2b; recent documents and security scopes have no twin. +- [x] 4.2b `ProjectWorkspace` and `ProjectTab`: tabs, `canSwitch`, `addTab`, `select`, `newCanvas`, `open` and `loadProject`, `close`, `removeTab`, `quitOrder`, `confirmQuit`, `closeWindow`, `receive`, `copyLayer`, the layer-row branch of `receiveProviders`, the controller's workspace branches (`canStart`, `open`, `newCanvas`, `close`, `receive`), the session's `skipsInitialClipboardCanvasSize`. Tests: `ProjectWorkspaceTests` (Swift's six and the tabs' own rules), new `ProjectWorkspaceFlowTests`, `ProjectCopyTests`, `ProjectWorkspaceSignalTests`. In scope by the third-round rule: the default-completion test of 4.2a round 3 (`ProjectControllerTests::aCallWithoutACompletionIsWhole`). Moved on: the file branch of `receiveProviders` to 10.4 (`ImageFileDrop`), `canReceiveDrag` to 4.3 (`ProjectTabs`), the adjustment, `pixelMove` and `colorPicker` terms of `canSwitch` to the items that bring that state. +- [x] 4.3a Tool icons (`UI/ToolIcons`, Linux only: SF Symbols have no twin): one drawn icon a tool (paths tinted from the palette, no icon theme: they differ by distro), the Swift extras that exist so far (`GradientToolIcon`, `CloneStampToolIcon`); variants that follow tool state (ellipse marquee, polygonal lasso, eraser) arrive with that state. Tests: every tool has an icon that paints pixels, follows the palette, and differs from the others. +- [x] 4.3b `ContentView` as one project's editor area (a `QWidget`; the window moves to 4.3c): the tool header slot (`ToolHeaderStyle`; the idle and eyedropper bars, `NavigationToolHeader` for Hand and Zoom; every other tool's bar arrives with its tool), the tool rail's buttons (labels as tooltips, the selected state, scrolling when short; icons from 4.3a), the canvas slot with the welcome (`NewCanvasSheet`: fields, validation, `Create canvas` as the default button, `Open project`, `Import image`, the clipboard's size skipped once for the first tab), the status bar (zoom, size, colour note, busy and importing notes, each tool's hint), the importer's file dialog, the alerts for `importError` and `brushError`. GUI test: the twin of `testCreateCanvasAndNavigation` as far as it needs no window chrome. Moved on: the canvas itself to 4.4, the layers panel to 4.6, `cropError` to 10.1, the floating panels to 9.x, drops to 10.4, `ColorPaletteControls` to 8.10. +- [x] 4.3c The window: `ProjectWorkspaceView` (a `QMainWindow`: 1180×780, minimum 800×520, the current tab's `ContentView` rebuilt on a switch and disabled while the workspace manages), the toolbar (New canvas, the `ProjectTabStrip` with its tab buttons, modified dot and close button, Fit, 100%, zoom in and out), closing through `closeWindow`, quitting through `confirmQuit`, files from argv through `receive`. GUI tests: the launch test moves here; tabs select, close and show their state. Moved on: drops on tabs and `canReceiveDrag` to 4.7 and 10.4. +- [x] 4.3d Menus and shortcuts (⌘→Ctrl, ⌥→Alt): the `CompositorApp` commands whose session functions exist (File, Edit's undo and redo with their names and the text-field rule, View, Layer); each later item adds its own entries (see "Menu items owed"). Tests: every entry's shortcut, enabling and effect. +- [x] 4.4 `EditorCanvas` display: compositing (`drawLiveComposite`), zoom, pan, pixel grid, checkerboard, focus. Paint each dirty rect through an image surface (`AdjustmentSurface`): HSL blend modes and clip coverage need an image-backed device, and device-sized coverage then costs only the dirty area. Probe `BrushRaster::visibleRect` on a widget painter at device pixel ratio 2 and test it. From `LayerMaskTests`: `canvasShowsAFolderMaskWhileItIsBeingPainted` (with 8.x) and the live-composite half of `renderBoth` in `LayerMaskSessionTests` (export and canvas must agree). +- [x] 4.5 **Omarchy theme**: `UI/OmarchyTheme` reads `colors.toml`, builds the palette (no stylesheet: Fusion follows the palette, and a stylesheet would freeze it), watches `current/`, logs each load; the canvas surround takes the palette's `Base` so a light theme's welcome reads. Tests: parse, light/dark, missing file, live switch changes grabbed pixels, a switch reaching the real window. In scope by the third-round rule: 4.4's Alt-from-any-window fix. +- [x] 4.6 Layers panel A: `LayersPanel` (heading, empty words, footer, the resize edge and its stored width), `NativeLayerList` rows (hierarchy, clipping marks, hidden fade, masks and their link), select (plain, Ctrl, Shift, Alt on the strip, thumbnails and mask targets), rename, eye swipe, the context menu, keys, thumbnails that redraw only on change, `BlendModePicker`, `LayerAppearanceControls`, `LayerMaskMenu`, `LayerIcons`. Tests: `CanvasThumbnailTests`, `BlendShortcutTests`, `NativeLayerListTests`, `LayersPanelTests`, `BlendModePickerTests`, `LayerAppearanceControlsTests`; the moved `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` and `layerListToolKeysAndTransformNudge`. In scope by the third-round rule: the ancestor watch and the short-read check of 4.5 round 3. Moved on: see "Layers panel owed". +- [x] 4.7 Layers panel B: drag reorder and nest (`dragData`, `dropTarget`, `acceptDrop`, the `LayerColumn` indicator), Alt-drag duplicate (`duplicateActiveLayer` and `duplicateLayer` forward from 5.1) and its cursor, the clipping cursors under Alt, mask drags (`maskType`, `copyMask`), drags between projects (`draggedLayerSource`, `canReceiveDrag`, drops on tabs, the canvas and the New canvas button, several ids a drop). Tests: `LayerDragTests`, `ProjectDropTests`, the twin of `duplicatingALayerByDraggingPlacesTheCopyAsOneStep` in `EditorSessionTests`. In scope by the third-round rule: the four fixes of 4.6 round 3. Not driven: `QDrag::exec` (no drag loop offscreen). Moved on: file and picture drops to 10.4. + +### Menu items owed + +The menu bar of 4.3d holds only what works. Each item adds its entries with its tests: 9.x the Image menu's adjustments (Levels… since 9.1b, Hue/Saturation… since 9.3b, Curves… and the rest since 9.4b), the Filter menu (9.4b), New Adjustment Layer and Edit Adjustment (9.5d); 10.1 nothing; 10.2 Canvas Size, Image Size, Flip Canvas (done); 10.3 Export PNG, Export JPEG (done). `Check for Updates` and the Hide entries have no twin (no updater; the desktop hides windows). + +### Tool bars, hints and icons owed + +`ContentView` of 4.3b shows an empty bar for a tool whose bar has not arrived, the hint of each tool's first mode, and one icon a tool. Each item adds its own, with tests: 10.1 `CropControls` and the `cropError` alert (done); 9.x the floating panels (done); 10.4 drops on the canvas. The canvas of 4.4 draws layers, masks, folder masks and live masks, pans and zooms; each item adds its own, with tests: 9.x adjustments (9.5b, done) and effects (9.6c, done) in `DisplayState` and `drawLayers`, `picking`'s hue sample mode with its press (9.3b, done), and the press clearing `effectSelection` (9.6d, done); 10.1 the crop union in `renderBounds` and the crop cursors (done). + +### Layers panel owed + +The panel of 4.6 shows and edits what the session holds so far. Each item adds its own, with tests: the effects rows under a layer came with 9.6e, the footer's effects button and the delete words came with 9.6d, the `deleteLayerOrMask` effects term with 9.6a; the adjustment icons, their double click and the footer's adjustment button came with 9.5d. + +### Session hooks owed + +`EditorSession` grows with the tools. Each item below adds its own terms to the core written in 4.1a, with tests. + +- 4.1e1: `commitTransform` clears `blendPreview` and finishes an opacity edit. +- 4.1e2: `installProject` and `clearProject` drop an open transform; from 4.1e3 on they clear `isMaskSelected`, from 10.1 they cancel a crop. +- 9.5: the store's text rule refuses an adjustment layer too. +- 9.6 (done in 9.6a): where Swift rebuilds a layer from listed fields (`commitPaintSnapshot`, `commitRasterEdit`, `invertPixels`, `mergeFloatingTransform`, the filter's, Levels' and Hue/Saturation's commits), each rebuild that leaves out `effects` drops them there, as the shape and text are dropped since 8.8 and 8.9a. The adjustment needs no such drop (9.5c, checked): every rebuild edits a layer's pixels, which an adjustment layer never has, and mask edits write in place. +- 9.x: the gates read `levels` (9.1a, done), `hueSaturation` (9.3a, done) and `adjustmentEditingID` (9.5c, done); `beginLevels` commits a pending gradient first (9.1a, done) and `beginHueSaturation` resolves one (9.3a, done); `beginLevels`, `canContentAwareFill` and `beginFilter` read `hueSaturation` (9.3a, done); `updateLevels`, `cancelLevels` and `commitLevels` hand an adjustment layer's edit its preview, cancel and commit first, as `updateFilter`, `cancelFilter` and `commitFilter` do, and the Hue/Saturation three (`previewAdjustmentEditing`, `finishAdjustmentEditing`, 9.5c, done); the colour picker's effect target and `previewEffectColor` (9.6a, done); the Levels sheet's caption says underlying pixels for an adjustment layer (9.5d); `selectLayer` and `selectLayers` clear `effectSelection` (9.6a, done); `canStartProjectOperation` reads `levels` (9.1a, done) and `adjustmentEditingID` (9.5c, done), and their setters resume file requests; `selectLayerTarget` clears `effectSelection` too (9.6a, done); `canLinkMask` refuses an adjustment layer as a source (9.5a, done); `deleteLayerOrMask` gains its effects term (9.6a, done), as does `deleteKeyPressed`. +- 10.1 (done): `selectTool` cancels and starts a crop; `restore` and `beginTransform` cancel it; `canEditLayers` reads `cropRect`; `importImages` cancels a crop. `ProjectController::begin` cancels a crop; so do `beginLevels` and `beginFilter`. +- `ProjectWorkspace::canSwitch` (4.2b) gains a term with each state it reads: `pixelMove` (8.2b, done), `gradientEdit` (8.7, done), `colorPicker` (8.10a, done), `hueSaturation` (9.3a, done). +- 10.2 and 10.3 bring the controller's `canvasSize` and `imageSize` (10.2, done), `exportPNG` and `exportJPEG` (10.3, done); 10.4 the file branch of `receiveProviders`. + +## 5 Layers (README) + +- [x] 5.1 `LayerMerge` (`mergePlan`, `canMergeLayers`, `mergeTitle`, `mergeLayers`, with `PixelFilter::trimmed` in `Filters.cpp`), `LayerFlip` (`LayerTransform::mirrored`, `flipLayers`), `beginDuplicateTransform` and `transformDuplicate` (`duplicateActiveLayer` and `duplicateLayer` went to 4.7); the Layer menu's Duplicate Layer, Merge and Flip Layer entries. Clipping masks in the panel were done by 4.6 and 4.7 (the indent, the Alt-click and its cursors) and 4.3d (the menu entry). Tests: `LayerMergeTests`, `LayerFlipTests`, `FiltersTests`, the twin of `duplicateTransformPreservesOriginalAndSupportsUndoAndCancel` and a folder case in `TransformTests`, the entries in `CompositorMenusTests`, the calls in `EditorSessionSignalTests`. In scope by the third-round rule: the three fixes of 4.7 round 3. Moved on: `flipCanvas` to 10.2, `layerViaCopy` to 7.4. + +## 6 Transform (README) + +- [x] 6.1 `TransformOverlay` (`TransformOverlayGeometry`, drawn by the canvas), the Move tool's drags on the canvas (move, resize, rotate, Alt duplicate, Ctrl picking, Ctrl+Shift extending, auto-select), snapping and guides (`transformSnapTargets`, `snappedMove`, `snapGuides`), the Move cursors, `TransformInspector` with its fields' arrow steps, `TransformEdit.persistent`, `transformAutoSelect`, `showsTransformControls`, `locksTransformRatio`, the Transform Layer and Show Transform Controls entries. Tests: `TransformOverlayTests` (the two geometry cases of Swift's `TransformTests`), `TransformPressTests` (Swift's two, and the handles, snapping, auto-select, Escape, focus, the wheel and a selection), `CursorTests` (Swift's two Move cases), `TransformInspectorTests`, `TransformSnapTests`, the entries in `CompositorMenusTests`, the bar in `ContentViewTests`, the settings in `EditorSessionSignalTests`; `blankLayersHaveNoTransformHandlesOrEditing`'s overlay lines in `TransformOverlayTests`. Moved on: the distort cursor, `showsRotation` and the corner geometry to 6.2; Transform Selection to 7.4; the selection cases of `CursorTests` to 7.x. +- [x] 6.2 `Distort` (`DistortWarp`: usability, convexity, `homography`, `warp`, `warpTrimmed`, `carried`, `warpMask`; `beginDistort`, `previewCorners`, `distortPreview`, `commitDistort`, `TransformEdit.corners`), the axis lock through `TransformDrag::corners`, mask distort (`commitMaskTransform`'s corners path, `MaskDistortPreviewCache`, `maskDistortPreview`, `displayedMaskPlacement`'s corners cases), the overlay's corner geometry, the canvas's Ctrl-drag, distort cursor and previews, the inspector's rest. Tests: `DistortTests` (Swift's three and the corner order, plus folded shapes, flips, `carried`, `warpMask`, nudges, groups), `DistortMaskTests` (masks along, a mask alone, split at the 500-line rule), `DistortCanvasTests`. Moved on: `mapPath` to 7.x, `distortedEffects` and `DistortEffectsCache` to 9.x. + +## 7 Selections (README) + +- [x] 7.1 `Selection` (`DocumentSelection`, `SelectionClip`, `LassoKind`, `SelectionMode`, `DragBox`, `LassoDraft`, the session's outlines, modes, held keys, moves, nudges, Expand and Contract, Select All, Deselect, Inverse), `SelectionEdits` (`deleteLayerOrMask`, `canInvert`, `invertPixels` with `PixelInvert` and `PixelAdjust::coverage` and `blend`), `loadMaskSelection` and `loadLayerSelection`, `addMask` from a selection, the Select menu, the Image menu's Invert, the panel's Ctrl-click loading and its cursor; the hooks owed: `CanvasDocument.selection`, `selectTool` cancels a lasso. Tests: `SelectionTests` and `SelectionSourceTests` (Swift's session cases; the four that drive `CanvasView` move to 7.2), `SelectionEditTests` (masks from a selection, the delete target, Invert; the fill, clear, gradient, brush and pixel-move cases move to 7.4 and 8.x), new `PixelInvertTests`, `SelectionSignalTests`, `SelectMenuTests` (the menu fixture now in `tests/MenuFixtures.h`), `LayerTests::deletingAMultiSelectionRemovesEveryLayerInOneStep`, the Ctrl-click and cursor cases of `NativeLayerListTests`, `LayersPanelTests` and `LayerDragGestureTests`. In scope by the third-round rule: the three fixes of 6.2 round 3. Moved on: `pressMarqueeKey`, `pressLassoKey`, `displayedSelection`, `CanvasView.selectionCursors` and `moveSelectionCursor` to 7.2; `canEditPixels`, `fillSelection`, `clearSelectedPixels`, `deleteKeyPressed` and `brushRevision` to 8.x; `PixelMove` and its calls to 7.4. +- [x] 7.2 The Marquee and the Lasso on the canvas (`lassoMouseDown`, `moveSelectionTool`, `releaseSelectionTool`, `selectionKey`, `endSelectionGestures`, `dragSelection`, `dragMarqueeDraft`, a double click closing a polygonal outline, Shift tracked like Alt and Ctrl, `pressMarqueeKey`, `pressLassoKey`, `displayedSelection`), the marching ants and the draft drawn by `TransformOverlay` (`antsPhase`, `drawSelection`, `drawLassoDraft`, `drawnRect`), the marquee autoscroll, the cursors (`moveSelectionCursor`, `selectionCursor`, `wandCursor`, with `Rendering/SelectionIcons` shared by `ToolIcons`), `LassoControls` with `SelectionAmountField`, the ellipse Marquee's and the polygonal Lasso's rail icons and hints. Tests: new `SelectionCanvasTests` (Swift's four canvas cases of `SelectionTests`, the drafts, moves, keys, cursors, ants, autoscroll, drawing and focus), new `LassoControlsTests`; the kinds in `ToolIconTests` (the reference sheet regrown) and `ContentViewTests`. In scope by the third-round rule: the loaders' flatten guard of 7.1 round 3. Swift's `CursorTests` cases: `leavingTheCanvasRestoresTheArrowWithEveryTool` and `aDragReleasedOutsideTheCanvasRestoresTheArrow` have no twin (a Qt widget's cursor never leaves it); the layer-list cases are 4.6's and 4.7's; the selection part of `duplicateAndDistortCursors` (Ctrl and Ctrl-Alt inside the selection) waits for 7.4. Moved on: the wand's click and cursor use to 7.3; the Ctrl-drag of pixels, `pixelDragStart` and the scissors to 7.4. +- [x] 7.3 `MagicWand` (`WandSampleSize`, `WandSettings`, `MagicWandError`, `MagicWand::select` and `outline` over `WandPixels.c`; the session's `wandSettings`, `magicWand`, `wandSample`), the wand's click on the canvas and its click inside a selection, the wand's controls in `LassoControls`. Tests: new `MagicWandTests` (Swift's six, the last through the canvas; plus refusals, a document gone mid-wand, the too-detailed error and Swift's titles), the wand's controls in `LassoControlsTests`, its settings and click in `SelectionSignalTests`. In scope by the third-round rule: the three fixes of 7.2 round 3. +- 7.3 round 1: FAIL, six findings. A painted asset's flatten threw past the sample's catch (the try covers the drawing, tested with a 48 MB raster starved by `AddressSpaceLimit`); a point that is no number passed the negative bounds and deselected (Swift's positive bounds refuse it); the amount field's validator refused a seventh digit (digits alone, a long long clamped, more digits than one holds the top); the active layer's own pixels were sampled without tests of placement, hidden and faint layers (a moved, scaled, hidden, masked, faint layer and a pending transform are read as they show); the document guard had no test (a snapshot with another id installed mid-wand drops the outline); the tests read `->path` (now `.value()`). +- 7.3 round 2: FAIL, four findings. The flatten test's 48 MB canvas sample failed before the raster flattened, so the narrowed catch passed alone (a tiny canvas keeps the large raster, and `malloc_trim` returns freed heap before the limit); the overflow test began at 255 (it starts at 7); two comments ran long; one test read `*path` (now `.value()`). Also in this round: `ProjectControllerTests::nothingToSaveOrABusySessionIsRefusedUnasked` took its record before the busy indicator had dimmed and compared it after up to a quarter second of work, a ceiling that a loaded machine breaks; it now waits for the dim first. +- 7.3 round 3: FAIL, two findings, the last round allowed. A new wand outline's Anti-alias setting had no test (a hard outline is asserted); one comment ran long. By the third-round rule the reviews of 7.3 end here; both fixes join 7.4's review scope. +- 7.4 round 1: FAIL, five findings. Paste dereferenced a cleared clipboard's missing `QMimeData` (`canPaste` and `paste` check the pointer; the refusals test clears the clipboard). Another app's image kept its own colour space's numbers (`sRGBCopy` converts a tagged image to sRGB first and tags the result; a linear gray 128 pastes as 188). A mask copied through a soft selection edge took the coverage once where Swift clips the ground and the white each (the two passes are multiplied on their own; `aSoftSelectionEdgeClipsBothMaskPasses` reads 192 at half coverage). Layer via Copy kept Duplicate Layer's enablement (now `canCopyPixels` or no selection on a pixel layer; a blank layer's Select All disables it). A working copy that could not be allocated set `brushError` where Swift's `try?` only beeps (a warning; `anImageTooLargeToCopyWarnsWithoutAnAlert` starves the address space). Eleven mutants over the fixes, all caught. +- 7.4 round 2: FAIL, two findings, both test gaps. Copy Merged's clip was tested through rectangles alone (a triangle lasso now asserts a clear far corner and a soft hypotenuse). A single layer's copy was not held to Swift's rule that the pixels come as stored, at their transformed places (`aCopyTakesTheLayersOwnPixelsAtTheirTransformedPlaces`: a hidden bar at 25% under a black mask, turned 90°, copies opaque where the turn puts it). Five mutants over those rules (the merged clip, opacity, mask, visibility, rotation), all caught. +- 7.4 round 3: PASS. +- 7.5 round 1: FAIL, six findings. A commit waiting for the preview could land on a later edit (the waiter carries its edit's id and returns without applying once that edit is gone); a second waiting caller lost its callback (every waiter is kept and resumed, the first commits, the next finds it committing); a commit between the worker's end and its delivery took nothing (the wait is on `preparing`, which only the delivery clears); Return on a focused Cancel cancelled (`setAutoDefault(false)`: Return is OK's from anywhere, nothing while OK waits); the mask carry was tested with white alone (a drawn mask's block is read at its carried place); growth was not held to the layer's turn (a layer turned 90° keeps it, its red block at the same document point after the commit). Seven mutants over the fixes (the waiter's id, the waiters kept, the wait on `preparing` twice, the mask carried as white, the turn dropped, Cancel's auto-default), all caught. +- 7.5 round 2: FAIL, two findings. Preview off while a job waited its turn behind a cancelled edit's worker cleared the job and left the edit preparing for good (an automatic kind's queued job now runs on and caches unseen; the clearing of `pending` and `preparedPreview` is 9.4's, for kinds with settings). The undelivered-preview test slept where it should have waited for the worker (`QThreadPool::globalInstance()->waitForDone()`, so the finish slot is queued and not yet run). Two mutants, both caught. +- 7.5 round 3: FAIL, one finding, the last round allowed. The Preview-off test did not force its job to wait behind the cancelled worker (the pool now holds one thread with a gated task ahead, and the test asserts the job is pending before Preview goes off). By the third-round rule the reviews of 7.5 end here; the fix joins 8.1's review scope. +- [x] 7.4 `SelectionClipboard` (`PixelClipboard`, `CopiedPixels`, `selectionCopyRegion`, `canCopyPixels`, `renderSelectedPixels`, `canCopyMerged`, `renderMergedPixels`, `copyMergedSelection`, `copySelection`, `store`, `canPaste`, `paste`, `layerViaCopy`, `addPixelLayer`, `nextLayerName`), system clipboard images both ways, the Edit menu's Copy, Copy Merged and Paste, the Layer menu's Layer via Copy. Tests: new `SelectionClipboardTests` (Swift's copy, layer-via-copy, lasso and merged cases; plus a mask's gray, the region's rounding, another app's image, the refusals and the signals), the entries in `SelectMenuTests`, the counts in `CompositorMenusTests`. In scope by the third-round rule: the two fixes of 7.3 round 3. Moved on: `cutSelection`, `PixelMove` (`beginPixelMove`, `movePixels`, `finishPixelMove`, `cancelPixelMove`, `nudgePixels`, the scissors and the Ctrl-drag on the canvas) and all of `FloatingSelection` (`FloatingTransform`, `canTransformSelection`, `transformCommand`, `beginSelectionTransform`, `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, `FloatingMerge`, `TransformEdit.floating`, `displayedSelection`'s floating term, the Transform Selection entry) to 8.2, since they clear or lift pixels through the brush raster; Swift's `cutLeavesAHole…`, `transformSelectionMoves…`, `escapeRestores…`, `applyingAnUnchangedTransform…` and `scalingAndMovingPastTheLayerEdge…` go with them. `SmartEditTests` is Content-Aware Fill's and Subject Removal's: 7.5 and 9.x. +- [x] 7.5 `ContentFill` (`ContentFill.c`), extend past edges: `ContentFillError`, `ContentFill::run`; from `Filters.swift` what the fill needs: `FilterKind` (its one case), `FilterJob`, `PixelFilter::run`, `FilterEdit` (`grow`, `previewImage`, `previewJob`), the session's `canAdjustColors`, `canContentAwareFill`, `beginFilter`, `updateFilter`, `renderFilterPreview`, `cancelFilter`, `commitFilter`; the grown transform in `displayedTransform` and `displayedMaskPlacement`; the canvas's preview and Shift-Delete; `FilterSheet` for the automatic kind; the Edit menu's Content-Aware Fill…; `canEditLayers` and `ProjectWorkspace::canSwitch` read `filterEdit`. Tests: new `SmartEditTests` (Swift's three fill cases; plus the growth past the edge with the mask carried, a solid, placed and disabled mask, a soft edge, the refusals and hooks, a layer changed meanwhile, the signals), new `FilterSheetTests`, the entry in `SelectMenuTests` and `CompositorMenusTests`, the keys and the preview in `SelectionCanvasTests`, the switch in `ProjectWorkspaceTests`. Moved on: `FilterSettings`, `FilterJob.settings`, `scale` and `seed`, the preview copy (`previewSource`, `previewScale`, `previewMapping`, `previewLimit`), `blurMargin`, `growForBlur`, `grownMargin`, the other kinds of `PixelFilter::run`, `beginFilter`'s beep guard and Gradient Map start, `filterSettings`, the trim on commit, `commitBackgroundMask` and the Filter menu to 9.4 and 9.7; `cancelCrop` in `beginFilter` to the crop; `previewAdjustmentEditing`, `finishAdjustmentEditing` and `adjustmentOriginal` to 9.5; `colorPicker` to 8.10; `FloatingPanel` to 9.1; `visionRequestRunsOnAnImage` to 9.7. + +## 8 Painting and retouching (README) + +- [x] 8.1 Brush coverage on CPU: `Rendering/BrushCoverage` (`Tile`, `Work`, `tile`, `render`; `BrushSegment` for Swift's SIMD4), `BrushSettings`, `BrushRaster::fill`, `BrushStroke` (the constructor and its guards, `spacingFraction`, `append`, `flush`, `continuousCurve`, `continuousKeys`, `renderContinuous`, `publish`, `allocateTile`, `patches`, `dirtyDocumentRect`, `selectionClip`, `pixelLimit`, `committedBounds`, `committedTransform`, `transform(for:)`, `paintSnapshot`, `commitInput`), `BrushCommit` (`render`, `expandMask`). Tests: new `BrushIntersectionTests` (Swift's three; `exportCrossingExample` drives the session and goes with 8.2), new `BrushTests` (the stroke-level halves of Swift's `continuousStrokeCrossesTiles…`, `softBrushProducesPartialAlpha…`, `brushStaysCircular…`, `maskedImagePainting…`, `paintedBoundsTrim…`, `opacityCaps…`, `softStrokeBuilds…`, `spacedDabs…`, `sparseMouseSamples…`, `liveStrokeReaches…`, `largeBlankCanvas…`; plus a selection clip, mask painting and `expandMask`, the refusals, the hard rim and the canvas edge, growth past the layer, a painted source that never flattens), `tests/BrushFixtures.h`. In scope by the third-round rule: the fix of 7.5 round 3. Moved on: the dab fallback (dead here, see AGENTS.md); `erasing`, `healing`, `healingMode`, `clearPixels`, `paintCanvas`, `liftSelection`, `moveLifted`, the session halves of `BrushTests` and `exportCrossingExample` to 8.2; the key tests (`bracketKeys…`, `shiftBrackets…`, `numberKeys…`) to 8.3; `heal` to 8.4; `clone`, `replacesWithClone` to 8.5; `isBlur` to 8.6; `fillGradient` and `fill` to 8.7; `editName` with its first writer. +- 8.1 round 1: FAIL, five findings. Tile keys overflowed `int` on a big grid (a 1200 px layer scaled to a point far across a 10,000 px canvas; keys, sets and maps are `qint64`); a crop that could not allocate returned an empty asset where Swift throws (`image.isNull()` is `ExportError(render)`, tested under `AddressSpaceLimit`); the tests read no untouched pixel inside a touched tile (both an ordinary and a painted source now read one beside the dab, in the preview and the commit); the antialias width under a scale was untested (a 64 px source shown at 128 reads 64 at the rim); an assertion ended in `|| true` (the materialisation is asserted before the read that flattens). Six mutants over the fixes: five caught, `columns` narrowed to `int` is equivalent (only the product passes int). +- 8.1 round 2: FAIL, three findings. A tile copy that could not be allocated (the tile's context from its base, or the coverage's copy under a selection) left a null image for `fill` to refuse as a logic error, where Swift throws its render error (both detach explicitly and throw `ExportError(render)`; `aTileThatCannotBeCopiedIsARenderError` takes every free chunk under an address-space limit, so the copy must map and fails); the selection's coverage was never tested on a transformed grid (a 64 px source shown at 128 from (40, 20), a selection from x = 80, a dab at (80, 80): 85 opaque, 75 clear); a mask on its own placement was never tested (a 16 px mask shown at 32 from (80, 40) takes a dab at (88, 48) in its pixel (4, 4)). Four mutants over the fixes: three caught, the coverage's null check is equivalent (a null coverage reaches the same error through its selection coverage's context). +- 8.1 round 3: FAIL, two findings, the last round allowed. The grid's extent became an `int` before its guard (a 30,000 px strip shown at a point far across a big canvas makes three billion pixels: undefined in C++, a plain refusal in Swift's `Int`; the extent is judged as a double first). The coverage-copy test could not tell the fix from the old logic error: under exhaustion the selection coverage's own context fails with the same error, and sparing one chunk for the copy does not hold (the painter's freed buffers serve the later copy), so that mutant stays equivalent in effect and the check keeps the error at the copy. By the third-round rule the reviews of 8.1 end here; the extent guard and this note join 8.2's review scope. +- [x] 8.2 `EditorSession+Brush` (`canPaint`, `makeRasterEdit`, `beginBrush`, `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately`, `finishBrush`, `commitPaintSnapshot`, `commitRasterEdit`, `usesOpacityKeys`, `typeOpacityDigit`, `changeBrushHardness`, `changeBrushSize`; `brushStroke`, `brushSettings`, `brushMode`, `maskPaintWhite`, `brushRevision`), erase, mask painting, Shift lines, `placedMaskPreview`; `BrushStroke::paintCanvas`, `fill`, `clearPixels`; `SelectionEdits` (`canEditPixels`, `fillSelection`, `clearSelectedPixels`, `deleteKeyPressed`, `applyPixelEdit`), `cutSelection`; the gates and hooks that read the stroke; the canvas's live stroke and Delete key, the list's Delete key; the Edit menu's Cut, the two fills and Clear Selection Pixels. Tests: new `BrushSessionTests` and `PixelEditTests` (the session halves of Swift's `BrushTests`, `shiftBracketsStepHardnessByQuarters`, the brush half of `numberKeysSetBrushAndGradientOpacity`, `largeBlankCanvas…`, `foldersHiddenLayers…`; plus the stroke's hold on the session, the Shift line, erase, the fills and clears, the Delete key, the signals), new `RasterSnapshotSessionTests` (Swift's five), new `BrushCanvasTests` (`canvasShift` twice, the repaint per step, a placed mask's stroke, the Delete key), new `BrushRasterTests` (failed tile copies, `clearPixels` and `fill`), `folderMaskCanBePaintedInvertedAndLoadedAsASelection` in `LayerMaskSessionTests`, the painting half of `placementAndLinkAreSavedAndPaintingFollowsThePlacement`, the `canPaint` line of `LayerTests`, `moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo`, the cut in `SelectionClipboardTests`, the entries in `SelectMenuTests` and `CompositorMenusTests`, the list's Delete key. In scope by the third-round rule: the two fixes of 8.1 round 3. Moved on: `PixelMove` and `FloatingSelection` (`liftSelection`, `moveLifted`, the scissors, the Ctrl-drag, Transform Selection) to 8.2b; the palette's colours in `fillSelection` and `paletteColor` to 8.10; the tips' parking (`parkedBrushTips`, `tipFamily`) to 8.5; `warpStroke` to 8.6; the gradient half of `numberKeysSetBrushAndGradientOpacity` to 8.7; the canvas's brush press, drag, release, cursor and keys, `exportCrossingExample` and the rail's eraser icon to 8.3. +- 8.2 round 1: FAIL, five findings. Invert and the fill opened mid-stroke (`canInvert` and `canAdjustColors` read the stroke; a stroke on a red layer refuses both and the cancel frees them). Painting a folder's mask showed nothing until the commit (the live clip comes from the edit's tiles, tested at 1x and 3x). Every step of a stroke repainted the whole canvas (a step repaints the stroke's dirty rect alone, two points out; a paint spy reads the first tile and then two). A raster commit's mask guard was untested (the mask replaced while a clear is in flight: nothing lands, the project is freed). The session-wide budget was untested (six 16-million layers leave a stroke 4 million, and a 2000-pixel tip is refused; `pixelLimit` became `qint64` without a clamp, as Swift's `Int`, so seven other masks leave −12 million). Eight mutants over the fixes, all caught. +- 8.2 round 2: FAIL, two findings. The live folder-mask clip drew the old mask, or a painted mask's base, at full size under nearest sampling, so a fine mask shimmered while painted (both are halved near the size drawn first, as Swift's `displayImage`; a one-pixel-striped folder mask at 25% keeps an untouched pixel's shade within 3/255 during and after two strokes, the second on the painted mask's tiles). A raster commit put back the mask's switch as the stroke had read it, so a mask switched off meanwhile came back on (the commit keeps `isEnabled` as it stands, Swift's `current.mask?.isEnabled ?? mask.isEnabled`; a mask switched off while a clear is in flight stays off). Three mutants over the fixes, all caught. Since then, for the mirror rule and for Qt 6.11: `canAdjustColors` moved to `HueSaturation.cpp`, the twin of its Swift file, and `ToolHeaderBar` gained `Q_OBJECT`. Docker could not start containers that day (another job saturated the disk), so the build and tests ran in the same image copied into RAM, through bubblewrap. +- 8.2 round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after five, two and one. In a text field both fills deleted forward at the start (`del()` after an empty backward selection turned "45" into "5"); they now delete the selection when there is one, else backwards from the caret through `backspace()`, as Swift's `deleteWordBackward` and `deleteToBeginningOfLine`, so nothing goes at the start. Proven by new cases in `cutTheFillsAndClearFollowTheSelectionAndTheField` (the caret at the start, mid-text, a selection either way round) and four mutants, all caught; carried into 8.2b's review. +- [x] 8.2b `PixelMove` (`beginPixelMove`, `movePixels`, `finishPixelMove`, `cancelPixelMove`, `nudgePixels`, `displayedSelection`'s terms, `BrushStroke::liftSelection` and `moveLifted`, the scissors, the Ctrl-drag and the Ctrl-arrows on the canvas) and `FloatingSelection` (`FloatingTransform`, `canTransformSelection`, `transformCommand`, `beginSelectionTransform`, `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, `FloatingMerge`, `TransformEdit.floating`, `DistortWarp::mapPath`, the floating branches of `commitTransform`, `cancelTransform` and `transformPixelSize`, the Transform Selection entry); `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`; `brushRevision` moves at the rest of Swift's sites (the invert; the filter's preview, Preview switched, cancel and commit), so the canvas's filter-preview key went. Tests: new `PixelMoveTests` (Swift's four pixel-move cases of `SelectionEditTests`, plus the hold on the session, a failed move, a move across tiles and back, a layer scaled and hanging off the canvas, a landing between pixels computed by hand, and what a move needs and ends), new `FloatingSelectionTests` (Swift's `transformSelectionMoves…`, `escapeRestores…`, `applyingAnUnchangedTransform…` and `scalingAndMovingPastTheLayerEdge…`, plus a distorted selection, a merge that cannot grow, the refusals, the floating layer's place and look, a source gone mid-lift, and a mask grown right, left and placed apart), `mapPathCarriesCurvesFlipsAndTheFillRule` in `DistortTests`, `cutLeavesAHoleAndPasteRestoresThePixels` (8.2's cut test under Swift's name), the scissors in `CursorTests`, the Ctrl-drag and Ctrl-arrows in `SelectionCanvasTests`, the switch in `ProjectWorkspaceTests`, the entry in `SelectMenuTests`, the counter in `SmartEditTests` and `SelectionEditTests`. In scope by the third-round rule: the fills' text-field fix of 8.2 round 3. +- 8.2b round 1: FAIL, three findings. Space mid-drag showed the open hand where Swift keeps the scissors or the copy cursor (`updateCursor` picks the pixel drag's cursor ahead of Space, and `lassoCursor` lost its two dead drag branches; `CursorTests` holds Space during a move and a copy drag). A merge onto a scaled or turned source was untested (`aTransformedSourceTakesTheMergeInItsOwnGrid`: a source doubled at 20,10 keeps its grid and transform with the block where it was put, and one turned a quarter lands the block below where it was lifted). A folded distortion's dropped outline was untested (`aFoldedDistortionDropsTheOutline`: a bow tie commits, the selection goes, Undo brings it back). Five mutants over the fixes, all caught. +- 8.2b round 2: FAIL, one finding. A move from a painted layer was untested, so a lift that flattened the whole raster passed (`aPaintedLayerMovesWithoutFlattening`: a dab makes the layer a raster, and the lift, the move and the commit leave it unflattened before any pixel is read; the hole and the landing are checked after). One mutant, caught. +- 8.2b round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after three, one and one. The painted-source test moved red pixels onto red, so a lift that placed nothing passed; the move now goes 60 pixels into the blue half and red is asserted there. One mutant (the raster's pixels never drawn into the lift), caught; carried into 8.3's review. +- [x] 8.3 `BrushControls`, `BrushCursorOverlay`, the canvas's brush gestures (press, drag, release, Shift lines and their axis, the right-drag tip), keys on the canvas and in the list (Tab and `cycleToolMode`, B, E, digits, brackets), the rail's eraser icon and hint (`ContentView+Status.cpp`). **Gate** passed: twin of `BrushPerformanceTests`, 4K at a scale of 2, 800 px soft; median frame 6.8–7.4 ms, from 47 (see AGENTS.md, "Brush speed"). Fixed on the way: a shown `ContentView` destroyed with its canvas focused read freed members (it stops listening first), and a replaced tab's session ended before its editor (the window holds the shown tab). Tests: new `BrushGestureTests`, `BrushKeyTests`, `BrushControlsTests`, `BrushCursorOverlayTests`, `BrushPerformanceTests` (under `BRUSH_BENCHMARK=1`), `ContentViewStatusTests` (split from `ContentViewTests`); `aSoftDabFollowsItsFalloffWithinAStep` and `aSoftClipScalesEachCoverageByteOnce` in `BrushTests`, `aMaskHalvesAsItsColorTwin` and `aMaskEdgeRepeatsItsLastColumn` in `DownsampleTests`, `tabSwitchesEachToolsShapeOrMode` in `EditorSessionTests`, `tabAndTheBrushKeysStayWithTheList` in `LayerListFocusTests`, `aShownViewClosesWithoutHearingItsSession` in `ContentViewTests`, `aReplacedTabOutlivesItsEditor` in `ProjectWorkspaceViewTests`, the eraser in `ToolIconTests`, `exportCrossingExample` in `BrushIntersectionTests` (under its variable); the key tests moved on from 8.1. In scope by the third-round rule: the painted-source test of 8.2b round 3. Moved on: the colour swatch, the x and d keys and Alt's eyedropper to 8.10; the other brush tools' circle, tip, brackets, digits and modes to 8.4–8.8; the bracket filter's `levels` term to 9.x. +- 8.3 round 1: FAIL, five findings. A halving band allocated its sums and source row inside the worker, where QtConcurrent wraps `bad_alloc` past the caller's catch (the bands now take them from the caller's scratch; `bandsTakeTheirScratchFromTheCaller` refuses any lone row of sums). Wheel and pinch panned and zoomed mid-stroke (both refuse now, as Swift's; `aStrokeHoldsTheViewStill`). A hover after a busy release painted into the kept stroke (only a left-button drag goes on; `aBusyProjectHoldsTheStroke`). Near-hard soft tips missed the falloff by up to 46 levels, because the table spread its steps over the whole radius (it spans h² to the rim now, solid inside; `BrushCoverageTests` holds 0.9995, 0.9999 and 0.99999 across the fade, the reviewer's own pixel among them); the lower clamp that the core needs runs only for segments through it, which keeps the gate's 2.75 ms model time. The bar's arrows stepped from the rounded number (they step from the session's value, as Swift's `arrowSteps`). Seventeen mutants over the fixes: eleven caught; two found the table's double build and span no better than float to 0.99999, so both are float now; four are equivalents (the lower clamp at step 1, the clamped quadrature everywhere, the Lasso fields' rounding of whole values, twice). +- 8.3 round 2: FAIL, one finding. At hardness 0.99999 and above the table still missed the shader's formula (190 for 188 on a 10 px dab, 22 levels at 0.999999 and 2000 px): its steps had passed float's spacing. Where (1 − h²) / 8192 falls under float's epsilon, `tipDensity`, the shader's own float formula, now replaces the table for dabs and segments; the table builds from the same function. A scratch probe against the shader's quadrature, replayed in float, found the kernel exact on both sides of the switch (hardness 0 to 0.999999, 10 to 2000 px) and the table off by 4 levels on a near-hard segment. New `nearFullHardnessTheFormulaReplacesTheTable` (dabs from 0.999 to 0.999999, both pixels named in the reviews) and `aStrokesSegmentsFollowTheShaderEitherSideOfTheSwitch` in `BrushCoverageTests`. Eight mutants: five caught (the table on segments only once the stroke test existed); three equivalents (the formula for every hardness and the two early returns of `tipDensity`), recorded in AGENTS.md. +- 8.3 round 3: FAIL, one finding, which ends the item's reviews by the third-round rule after five, one and one. The quadrature's template regrouped the shader's `midpoint ± halfLength · node − projection` as `(midpoint − projection) ± halfLength · node`; 8192 pixels along a 0.999999-hard 10 px stroke that gave 14 where the shader gives 17, and the test's reference, copied from the twin, repeated the regrouping. The shader's order is back in both, and `farAlongAStrokeTheNodesRoundAsTheShaders` pins 17 at (8202, 10) before and after a flush. Two mutants (the regrouped order for both nodes, and for one), both caught; carried into 8.4's review. +- [x] 8.4 Spot Healing (`HealPixels.c`): `SpotHealingMode`, `BrushSettings.healing` and `healingMode`, the wash in `publish`, `BrushStroke::heal`; the session's `spotHealingMode`, Spot Healing in `beginBrush`, the heal in `finishBrushImmediately` and its step's name, the type on Tab; Swift's `isBrushTool` for the circle, the tip drag, the brackets, the digits and the bar; `BrushControls` titled for every brush tool, with the types and Strength. Tests: new `SpotHealingTests` (Swift's `healsTheBlemishUnderTheBrushAndNothingElse` in each type; plus a mask never healed, Tab and the digits, the wash then the surface, a mask stroke, the selection's bound, a painted source, the kernel's and the coverage's failed allocations, the types' difference and random grain, opacity part way, a spot across tiles), `spotHealingTakesTheBrushGestures` in `BrushGestureTests`, `eachBrushToolTitlesTheBarAndShowsItsPicker` in `BrushControlsTests`, the four brush tools' bars in `ContentViewTests`. In scope by the third-round rule: the fix of 8.3 round 3. Moved on: the clone's Aligned, Sample and Tab to 8.5; the Smear's modes and hints to 8.6; Alt's eyedropper under Spot Healing to 8.10. +- 8.4 round 1: FAIL, one finding. `aPaintedSourceHealsFromItsTiles` gave its raster no patches, so a heal that read the raster's base alone passed every test. The raster now paints the blemished stripes over a blue base: the spot must heal to the stripes' gray and five pixels away from the brush must keep the painted stripes, with nothing flattened. Two mutants (the heal drawing the base alone, and nothing), both caught. The 8.3 fix carried in held. +- 8.4 round 2: PASS. The painted-source test holds the heal to the raster's patches; the item closes after one finding and none. +- [x] 8.5 `CloneStamp` and its preview tip: `CloneSettings`, `setCloneSource`, `cloneStrokeOffset`, `cloneSamplePoint`, `cloneSample`; the session's clone state and `parkedBrushTips` in `selectTool`; the clone in `beginBrush`, `publish` (`clonePixels`, `fillClone`) and the step's name; Tab's sample; the bar's Aligned, Sample and note; the overlay's crosshair and preview; the canvas's Alt-click, hidden pointer and preview. On the way: the session header split into per-extension `Name+Session.h` fragments (AGENTS.md, mirror rule), `displayedSelection` moved to `SelectionEdits.cpp`, its Swift file. Tests: new `CloneStampTests` (Swift's two; plus the families' opacity and the Smear's tip, the sample point and offset, a refused source, the active or every visible layer, a mask, a scaled layer, half and quarter pixels, opacity and the selection), new `CloneStampCanvasTests` (the Alt-click, the crosshair and preview through a stroke, Alt and Space, the kept preview and what redraws it, one click's softness and the tip's size, the Brush's Alt-click, zoom, the layer or every layer, a cancelled stroke), `cloneStampShowsAlignedSampleAndTheSourceNote` in `BrushControlsTests`, the marker, the cut preview and identity in `BrushCursorOverlayTests`. Moved on: `replacesWithClone` and the clone on a mask to 8.6, with their first writer. +- 8.5 round 1: FAIL, one finding. The overlay cut the tip into the preview at the preview's size, which is capped at 1024 pixels, so a 2000-pixel hard brush at 200% previewed 220 of 255 three pixels inside its rim. The overlay now draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer at the drawing's resolution, and keeps no cut copy. `theTipCutsAtTheDrawingsResolution` draws a tip of one-pixel columns over a preview enlarged four times, each column keeping or dropping the green, and checks the enlarged preview is smoothed. Five mutants (the old cut, the tip ignored, no smoothing, a square clip, no opacity), all caught. The header split was audited: no declaration lost or changed in access. +- 8.5 round 2: PASS. The tip cuts at the drawing's resolution, the header split holds; the item closes after one finding and none. +- [x] 8.6 `BlurTool`, `SmudgeLiquify`: `BlurToolMode`, `WarpStroke` (`append`, `pickUp`, `smudge`, `push`, `weight`), `beginWarp`, `finishWarp`, `blurSample`, `PixelAdjust::gaussianBlur` (Core Image's Gaussian); `BrushStroke`'s `isBlur`, `replacesWithClone` and `editName`, gray clones; the session's `blurMode` and `warpStroke` in Swift's six places, the warp in `beginBrush`, `continueBrush`, `cancelBrush` and `finishBrushImmediately`, Tab's mode, the step names; the bar's three modes and the Smear's hints; the canvas's warp copy and its holds. Tests: new `SmudgeLiquifyTests` (the warp against Swift's arithmetic written again, four brushes along one path; the clamps; Liquify's step, what it lands and Undo; Smudge's carry; clear pixels clearing; the mask refused; the session held; a changed layer; Tab), new `BlurToolTests` (the bell curve and a line's profile, clear and clamped edges, gray and refusals, sigma by size, a mask's tone, placement and turn, Blur strokes on pixels and masks, a mask between pixels), new `SmearCanvasTests` (the copy then the commit, Escape, the held view and tip, the copy through a mask and at half opacity), `theSmearShowsItsThreeModes` in `BrushControlsTests`, the Smear's words in `ContentViewStatusTests`. Swift has no tests of these tools; the twin's are its own. The owed note that `selectLayers` and `selectLayerTarget` read the warp was wrong: Swift's read the brush stroke alone. +- 8.6 round 1: FAIL, one finding. Blur resampled a mask's gray sample in gray, so at a fractional placement the padding past the image came in as opaque black: a white mask placed half a pixel past the canvas's left edge fell to 128 in its first column under a hard Blur stroke. `clonePixels` now resamples every clone into premultiplied colour, a gray one too, so the edge keeps its coverage as alpha, and `fillClone` composites that alpha into gray masks; `shiftedByFraction` is colour-only again. `aMaskPastTheCanvasEdgeKeepsItsTone` holds 255 over white and 128 over black in that column. Two mutants (the sample's alpha ignored, and gray taken as opaque), both caught. +- 8.6 round 2: PASS. The mask edge keeps its tone; the item closes after one finding and none. +- [x] 8.7 `Gradient` (`GradientStyle`, `GradientShape`, `GradientSettings`, `GradientEdit`; `beginGradient`, `moveGradient`, `refreshGradient`, `gradientColors`, `endGradientDrag`, `cancelGradient`, `commitGradient`, `resolveGradient`; `gradientSettings` and `gradientEdit`), `BrushStroke::fillGradient`, the palette's model from 8.10 (`PaletteColor`, `foregroundColor`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`) and its colours in `fillSelection`; the hooks owed (`selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` resolve it, `undo`, `canUndo` and `restore`, `canEditLayers`, `invertPixels` and `beginFilter` commit it first, `ProjectWorkspace::canSwitch`); Tab's shape and the digits' opacity; the overlay's line and ends; the canvas's drag, ends, Shift, keys and a folder mask's live clip; `GradientControls` with its swatch. Tests: new `GradientTests` (Swift's eight; plus the resolve on a switch of tool, layer or target, the holds on layer edits and the workspace, Invert and filters applying it first, the gradient half of `numberKeysSetBrushAndGradientOpacity` and Tab, the palette, a mask's palette, a kept target, revisions, busy and lineless commits), new `GradientCanvasTests` (the drag and its line, the ends within ten points, Shift at 45°, Escape and Return, the overlay as Swift draws it, a folder's pending mask, the bar, the swatch), the bar in `ContentViewTests`. Moved on: the Type bar's share of `setPaletteColor` to 8.9; the picker, the swatches, Alt's eyedropper and the x and d keys to 8.10. +- 8.7 round 1: FAIL, one finding. The new tests read the pending gradient, the document and the overlay's geometry through `->`, against the rule that tests read a returned optional through `.value()`: a drag that left no edit would have aborted the binary under `_GLIBCXX_ASSERTIONS` instead of failing the test. Both files now read through `.value()`, and so do four older tests that read `document()` the same way (`CloneStampTests`, `CloneStampCanvasTests`, `ProjectDropTests`, `SmudgeLiquifyTests`). A mutant that loses the drag's edit now fails with QtTest's report. The reviewer found no functional issue; its own oracle matched 128 gradients within 2/255. +- 8.7 round 2: PASS. The tests read optionals through `.value()`, a lost edit fails with a report; the item closes after one finding and none. +- [x] 8.8 `ShapeTool` (`ShapeKind`, `path`, `LayerShapeStyle`, `LayerShape`, `ShapeDraft`, `liveShape`; the session's `shapeKind`, `shapeCornerRadius`, `shapeLineWidth`, `shapeDraft`, `beginShape`, `shapeLineEnds`, `dragShape`, `cancelShape`, `toggleShapeKind`, `finishShape`, `nextShapeName`, `redrawShape`, `shapeTransformPreview`, `shapeImage`), `ImageLayer.shape`, the manifest's `shape`, the hooks owed (`selectTool`, `commitTransform`, Tab) and the rebuilds that drop a shape, the resizers, the canvas's draft, gestures and keys, the Move tool's rounded preview, `ShapeControls`, the Shape hint by kind. Tests: new `ShapeToolTests` (Swift's four; plus a line's ends, box, width, caps and dot, Shift's eighths, the budget, names, refusals, Tab and kinds, signals, `path`, flat drags, smooth edges and thin lines), new `ShapeLayerTests` (redraws at the radius, rounded and one side, masks, groups, what is not redrawn, the Move preview and its cache, painting and every rebuild dropping the shape, a mask stroke and a duplicate keeping it, projects keeping live shapes only, the manifest's refusals, the resizers, older lines against independent strokes, a line's ends, a distorted shape), new `ShapeCanvasTests` (the draft and the layer, its place above the active layer, Shift and Alt, Escape and focus, Shift-U, a line's width and caps at any zoom, a rounded draft's corners, the repainted areas, a folder mask's clip, the Move preview, the bar), the Shape bar in `ContentViewTests`, the hint by kind in `ContentViewStatusTests`. Moved on: the Fill swatch and its picker to 8.10. +- 8.8 round 1: FAIL, two findings. A line a pixel wide took QPainter's thin-line path, so a diagonal covered a pixel at 64 where CoreGraphics' stroke gives 144: lines now fill a round-capped outline (`strokedLine`, `QPainterPathStroker`) on the layer and on the draft, and a width of 1 matches 1.001 within 3/255 in `edgesAreSmoothAndLinesAPixelWide` and `aPixelWideDraftIsItsOutline`. A draft on a blank layer stayed stale when the layer moved mid-drag, since `DisplayState` skips blank layers where Swift's observation redraws: the draft's target now counts, and `aDraftFollowsItsLayerInTheStack` raises it over a sibling, hides it, and holds that another blank layer still counts for nothing. Seven mutants (the old pen twice, the dot, the cap, the width, the filter twice), all caught. +- 8.8 round 2: FAIL, one finding. A new active layer mid-drag (Undo restoring an earlier one) moved the draft below another layer without a repaint, since both layers had pixels and `ShapeShown` did not name the draft's target: it now carries the active layer's id, and `aDraftFollowsANewTarget` undoes mid-drag, expects a paint and the upper layer on top. One mutant (the target left out), caught. +- 8.8 round 3: PASS. The draft follows its target through Undo; the item closes after three findings over two rounds. +- [x] 8.9a `TypeTool` (`TextAlignment`, `LayerTextStyle`, `LayerText`, `TextDraft`, `liveText`; the session's `textDraft`, `textDefaults`, `beginText` at a point and in a box, `editActiveText`, `applyText`, `finishText`, `cancelText`, `recolorText`, `currentTextStyle`, `changeTextStyle`, `layerName`, `textBoxSize`, `textImage`), `Rendering/TextLayout` (Linux-only: fontconfig's faces by PostScript name, TextKit's lines on `QTextLayout`), `ImageLayer.text`, the manifest's `text`, the hooks owed (the gates, `canStartProjectOperation`, `selectLayer`, `selectLayers` and `selectTool` finishing text, the swatch's colour, Fill's recolouring) and the rebuilds that drop text, the resizers, `TypeControls`, the panel's text rows. Tests: new `TypeToolTests` (Swift's eight), new `TextLayoutTests` (faces, measures, boxes, wrapping and dropping, alignment, baselines, the limits, a box under its padding, colour), new `TypeSessionTests` (the gates and file requests, switching, the swatch, Fill, styles, names, a click's target, the corner and the mask, dragged boxes, the style's bounds, the signals, what `applyText` takes, recolouring), new `TypeStoreTests` (the manifest's keys and refusals, invalid styles, the store's rule, stale text, every rebuild, the resizers), new `TypeControlsTests` (the bar's fields, formats, steps, borrowed focus, Return, alignments and their ink under a new theme, faces, buttons, layout, rest, and the list's text row), the Type bar in `ContentViewTests`, a macOS text style in `ProjectManifestTests`. Fixed on the way: the Move bar's fields took half the spare room and grew their buttons, where Swift's `ScrollView` takes it all and keeps its content's size (`theFieldsTakeTheSpareRoom` in `TransformInspectorTests`). Moved on: the colour swatch and `openTextColorPicker` to 8.10. +- 8.9a round 1: FAIL, four findings. `finishText` handed `applyText` the session's own draft by reference, and `applyText` cleared it before reading it (undefined: an -O0 probe lost the text; the -O2 suite passed with it, so no test can hold it): the draft is now taken by value, as Swift passes its struct. `TextLines` parted paragraphs at LF alone: CR, CRLF and U+2029 part them too (Qt breaks at U+2028 itself). Tabs took Qt's 80-pixel default: `NSParagraphStyle`'s twelve stops 28 points apart hold now, and a tab past the last one moves down a line and leads it, the paragraph laid in pieces (`setNumColumns` left the tab hanging on the line above). Two tests read an optional through `->`; they read `.value()`. Tests: `paragraphsBreakWhereCocoaBreaksThem` and a tab test in `TextLayoutTests`. Mutants of the stops, the pieces and the separators: 17, all caught; a dead offset in the tab scan went. +- 8.9a round 2: FAIL, two findings, both in round 1's emulation of Cocoa's late tabs (a tab past the twelfth stop moving down a line): the scan measured from a line's logical start, so after right-to-left text a late tab stayed put, and each late tab laid the rest of its paragraph again, 11.7 s for 10,000 tabs on a commit. The emulation went: tabs stop every 28 points throughout (`setTabStopDistance`), Cocoa's stops up to 336, and past them the stops go on where Cocoa wraps, a known difference recorded in AGENTS.md. `tabsStopEvery28Points` in `TextLayoutTests` holds the stops, a tab after text and the thirteenth stop; two mutants of the distance, caught. +- 8.9a round 3: PASS. Tabs stop every 28 points, the known difference past 336 recorded; 10,000 tabs lay out in about a millisecond. The item closes after six findings over two rounds. +- [x] 8.9b `InlineTextEditor` (`Rendering/InlineTextEditor.h/.cpp`, `InlineTextEditor+Editing.cpp`, `InlineTextEditor+Input.cpp`), the editing half: the model (content, selection, marked text), `synchronize` (logical size, shown transform, point text growing at the layer's scale, handle size, the clamp, a fresh draft's undo and focus), the text surface drawn under the layer's transform with its flips (text, selection, caret, marked underline), the frame, the handles and the overflow plus; the keys (typing as `QInputControl` judges it, deletions, movement, selection, Return, Tab, Escape, Ctrl+Return, Alt's spacing), input methods (marked text in the content, replacements counted from the preedit, events that bring nothing), local undo, the plain-text clipboard, the 100,000-unit cap; `TextLines` gains ranges, lines, the caret, line bounds, positions, a draw with a selection and marked text, and no line past the laid text; the canvas hides the edited layer, hands the editor its keys and input methods, lets its keys win over shortcuts, keeps Shift+Tab and takes the focus for a fresh draft; the menus' Undo, Redo, Cut, Copy, Paste and Select All reach the editor while a draft is open. Tests: new `InlineTextEditorTests` (growth and handles, undo steps, a draft set over another, moves, columns, wrapped and hidden lines, deletions, Return, Tab, Escape and finishing, spacing), new `InlineTextInputTests` (what types, Shift-+ and Shift-− in open text, the cap, the clipboard, composing, replacements, input methods following the caret), new `InlineTextDrawTests` (the draft instead of its layer, the frame and handles, a turned and flipped layer, the caret and its blink, the selection, the plus, focus, keys ahead of shortcuts, the Edit menu, repaints of edits, a paste, a shrunk layer left or replaced, the caret's line, a stretched layer opened), new `InlineTextPreeditTests` (the underline, the input method's formats and caret), `linesStopWhereTheBoxDoes` and `aSelectionFollowsItsRunsToTheLinesEdge` in `TextLayoutTests`, the fixtures `InlineTextFixtures.h` and `InlineTextDrawFixtures.h`. +- 8.9b round 1: FAIL, eight findings. A final newline had no line, so Home after Return went to the line above: `TextLines` lays TextKit's extra line for the caret, even past the box, and measures leave it out. Cancelling a composition dropped the redo: history follows Qt's input method contract, the preedit recording nothing and what commits or removes text making a typing step from the text before it. Input method events ignored a zero-length replacement's offset, `Selection` attributes, `TextFormat` attributes and the `Cursor` attribute's visibility and colour: each is applied now. Arrows moved forward through right-to-left text: in such a paragraph Right goes back and Left on, words alike, and a plain arrow collapses to its own side, as Qt's fields move. A selection across directions was drawn by logical extents: it comes from Qt's visual runs per line, stretched to the fixed line, and runs on to a line's edge where it passes the line, as TextKit's. Opening a layer stretched one way left its pixels showing: a draft that begins, ends or is replaced repaints the canvas, as Swift's `needsDisplay`. Tests: the extra line, right-to-left moves over LF, CR and U+2029, the preedit's history, the zero-length replacement, the `Selection` attribute, a stretched layer, the caret line's repaint, the new `InlineTextPreeditTests`, and the selection's runs, edges and margins. Mutants of the fixes: 53, all caught once two tests came in (a tall line's full highlight, letters after a preedit left bare); `drawSelection`'s early return went, since it only saved work. +- 8.9b round 2: FAIL, five findings; round 1's eight hold. A composition outlived a draft set over it: `synchronize` clears it with the steps. `inputMethod` now follows `QWidgetLineControl::processInputMethodEvent` step by step: input takes the selection before an offset counts, a range goes whole or not at all, the cursor lands where Qt's leaves it, and a `Selection` attribute's selection stays beside a preedit as its `anchor` until the next input takes it, drawn and queried, its move ending a typing run. A final U+2028 now gets the line Qt's `createLine` makes after it, the caret's when it ends the text, and a line ends before the separator breaking it. Tests: a composition ended by another draft, a final U+2028 and the line end before it, input taking a selection, the selection beside a preedit (drawn before and after it, queried, kept by an update, taken by a commit), a Selection ending a run, a mid-text deletion, a range whole or not at all, a final CR and U+2029, U+2028 inside a paragraph and a cut box. Mutants of the fixes: 35, all caught once one test came in (a selection after the preedit read without it); a redundant anchor write went. +- 8.9b round 3: FAIL, two findings; round 2's five hold. While a preedit showed, Copy, Cut and Paste ignored the selection an input method set beside it, and Select All was ignored by the next commit. The Edit commands now work around a preedit as Qt's fields do: on the text without it and its selection (`plain`), the preedit then going back at the cursor (`settle`); the input method's formats count from the preedit's start, so it can move. The input methods and the clipboard moved to `InlineTextEditor+Input.cpp`, keeping the editing file under 500 lines. Tests: `theEditMenuWorksAroundAPreedit` (copy, cut and its undo past a later commit, paste, Select All then a commit), a paste before a preedit, a format on a preedit that does not start the text. Mutants of the fixes: 15, all caught once three tests came in (Select All ending a typing run, a paste dropping the redo, typing straight into a paste). Under the third-round rule the reviews end here; these fixes go to 8.9c's review. +- [x] 8.9c The pointer on open text (`InlineTextEditor+Pointer.cpp`): the canvas's text gestures (`beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`), the editor's `press` (Swift's `hitTest`: the handles' bands, the padding, the text view), `NSTextView`'s clicks (the caret, Shift, words and paragraphs by double and triple clicks, drags by the click's unit, through the text's mirror), the handles' resizing (point text becoming a box, the 16-pixel floor, the limits) and their cursors turned with the box, a click or a handle landing a preedit, `TextLines::character`; the canvas's Type-tool press, double click, drag, release and Escape, and its cursor over the editor. Tab cycles a mode only without a draft, as 8.9b's keys already route it. Tests: new `InlineTextPointerTests` (a click or a drag beginning text, the box drawn in the accent and antialiased, dropped, thin drags, a refused box repainted away; open text finished and other text opened by a click, active or not, hidden text left shut, a box widened leftwards landing where it shows; the caret, drags, Shift and its drag, a move without the button, a later line, a flipped layer's clicks; words from any letter, word drags both ways, paragraphs with CRLF, a fourth click and a far third single; a click and a handle landing a preedit), new `InlineTextHandleTests` (the eight cursors, the bands either side of each edge, a press's cursor and a release's, a narrow box's third; a press making a box, each edge dragged, the 16-pixel floor, the 30,000-pixel limit; a turned box's edges; a scaled layer's growth kept and its 300,000-pixel limit; the padding and a drag from it). +- 8.9c round 1: FAIL, five findings; both carried 8.9b findings hold. An undone New Canvas mid-drag crashed the box's repaint: Swift's guards hold (`dragTextGesture`, and `textBoxDraftRect` for the drawing), so without a document the box draws, drags and makes nothing. Qt can lose a release where AppKit cannot: a move without the left button, a lost focus or the next press ends a text gesture (`endTextGesture`: the box drawn goes, repainted away; a selection or resize stops where it is). Shift-click extended by characters after a double or triple click: the click's unit outlives its release, and Shift drags on from it while the selection it made stands; a key's selection, other text or another draft bring the caret's unit back. A triple click on an empty last line took the paragraph before it: paragraphs are found from the position itself, and an empty last line has no word either. Clicks below the lines landed on the last one and drags above the first kept their x: below is the end of the laid text, above the start. Typing while the button is down ends the drag. Tests: `anUndoneCanvasLeavesTheBoxInert`, `aLostReleaseDropsTheBox`, `shiftExtendsByTheLastClicksUnit` and `blankSpaceAndEmptyLinesTakeClicks` in `InlineTextPointerTests`, `aLostReleaseEndsAResize` in `InlineTextHandleTests`. Mutants of the fixes: 39, then 6 again, all caught but `drawTextBoxDraft`'s guard (no box maps to a null rect, which draws nothing; recorded). The survivors took out a dead term (`synchronize`'s first call is always fresh) and `release`'s return value (the canvas's own release path ends alike), and brought a test: below an overfull box's lines is the end of what shows. +- 8.9c round 2: FAIL, three findings; round 1's five hold. A far drag over a squashed layer (a 30,000-pixel box ten pixels tall, font size 1) turned a row past `int`'s range into a wild index: the row is judged as a double first. An edit kept a held gesture: typing during a resize was undone by the next move, which wrote back the draft the resize began with (Swift's drag would too), and the same letter typed over its selection left the drag running; an edit (`publish`) and a spacing key (`changeSpacing`) now end a drag or a resize, as a press does. A style change kept Shift's unit, where Swift's style sync sets the selection anew and resets NSTextView's granularity: the unit goes wherever the selection is clamped, which also ends a drag there (a fresh draft of the same style keeps it, as Swift's). Tests: `aSquashedLayerTakesFarDrags` and `anEditEndsAResize` (typing, a spacing key) in `InlineTextHandleTests`; in `shiftExtendsByTheLastClicksUnit`, the spacing keys and a style set elsewhere, a style set mid-drag and the same letter typed over its selection (a fresh draft of the same style is no longer tested for a reset: Swift keeps its unit). Mutants of the fixes: 5, all caught. +- 8.9c round 3: FAIL, one finding; round 2's three hold. A valid rotation of 10^12 degrees drew the box as 280 degrees but gave its corners the cursor of none: the count of eighths passed `int`'s range, where Swift's `Int` holds it. The rotation is reduced by 360 first. Test: a 10^12-degree layer's corner cursor in `aTurnedBoxResizesAlongItsEdges`. Mutant: 1, caught. Under the third-round rule the reviews end here; this fix goes to 8.10's review. +- [x] 8.10a The palette and its picker: the rest of `ColorPalette` (`PickerHSB`, `quantized`, `hex`, the hex parser, `ColorPickerTarget` for the palette and text, `ColorPickerState`; the session's `colorPicker`, `openColorPicker`, `typeColor`, `openTextColorPicker`, `closeColorPicker`, `setColorPickerHSB`, `sampleIntoColorPicker`, `sampleCompositeColor`), `FloatingPanel` (from 9.1), `ColorPickerSheet` in its panel, `ColorPaletteControls` under the rail (swatches, swap, defaults, the mask's choice), the Brush bar's colour swatch, the Shape bar's Fill swatch and the Type bar's colour swatch, the X and D keys on the canvas and in the list, `ProjectWorkspace::canSwitch` reading `colorPicker`. Tests: new `ColorPickerTests` (Swift's eight, the hex parser's edges, a half-clear pixel sampled exactly, the gates, the text picker with and without a draft, a mask keeping its foreground), new `ColorPickerSheetTests` (the field and the strip picking with the left button, their drawing, the entries' typing, steps, bounds and borrowed focus, Return, Escape and the close button), new `ColorPaletteControlsTests` (the swatches and their repaints, the picker's panel, swap and defaults and their ink, the mask's choice, a busy project, the rail), new `ColorSwatchTests` (the three bars' swatches, X and D), new `FloatingPanelTests` (Swift's layout pass, placing, keeping and remembering, the keys on every show, the close button and Escape, refocus by name), the picker's gate in `ProjectWorkspaceTests`, the Fill label in `ShapeCanvasTests`. Moved on: the inline editor standing aside while the picker is open (Swift's `hitTest` and cursor) to 8.10b, with the picking it serves. +- 8.10a round 1: FAIL, two findings; the carried 8.9c finding holds. The picker's controls had no accessible names: each carries Swift's label (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color), each entry Swift's title as its placeholder, and the strip the hue's rounded degrees as its description, since Qt's plain widgets hold no settable value. The hue arrows were clipped at 0° and 360°, where SwiftUI lets them overflow the strip: the sheet draws them over the strip's place and repaints five points past either end. Tests: `everyControlCarriesSwiftsLabel` (read through `QAccessible`) in `ColorPickerSheetTests`, and in `theFieldStripAndPreviewDrawAsSwifts` the arrows past both ends, their antialiased edge and their repainted room. Mutants of the fixes: 23, all caught once the edge test came in. +- 8.10a round 2: PASS. The labels and the arrows' overflow hold at 1× and 2×; the item closes after two findings over one round. +- [x] 8.10b The Eyedropper and picking on the canvas: the tool's bar with `showsSampleRing`, `SampleRingOverlay`, the eyedropper cursor (`Rendering/EyedropperIcon`, shared with the rail), sampling by the Eyedropper, by Alt under the Brush, Spot Healing and the Gradient, and into the open picker, which takes the keys back; the canvas's focus request sparing the picker; the inline editor standing aside while the picker is open (its cursor and `hitTest`, through picking's rank). Tests: new `EyedropperTests` (the composite into the foreground, off the document, busy and with a mask; the ring's place, colours, repaint and setting; Alt under the three tools and not under Clone Stamp or the Shape tool, never during a stroke or a gradient drag, and again after a refused gradient press; the open picker taking samples and giving the keys back; the cursor, its drawing and its rank over Space; picking ending mid-drag; lost releases; Space panning; the inline editor standing aside, double clicks included; the brush circle; a mounted canvas; the bar). +- 8.10b round 1: FAIL, three findings. When Alt first showed in the press itself, the foreground's signal synced the display, saw picking begin and ended the sampling just started, so the drag sampled nothing and the ring stayed after the release: `syncPicking` runs before a sampling begins and on every move (Alt let go unseen ends it there). Picking's hover swallowed a right-button drag with Alt held or the picker open: the hover counts only with no button held, as Swift's `mouseMoved`, so the tip sizes as Swift's `rightMouseDragged` does. The "Sample Ring" checkbox did not follow the session: it follows every change under a `QSignalBlocker`. The tests split into `EyedropperTests` and `PickingTests` over `EyedropperFixtures.h`. Tests: `altFirstSeenInThePressSamplesOn` (the canvas synced as `ContentView` syncs it) and `aRightDragSizesTheTipWhilePicking` in `PickingTests`, the checkbox set from the session in `theBarTogglesTheRing`. Mutants of the fixes: 11, all caught. +- 8.10b round 2: PASS. The three fixes hold against the original probes; the item closes after three findings over one round. + +## 9 Adjustments and filters (README) + +- [x] 9.1a Levels in the session: `Levels` (`LevelsChannel`, `LevelRange`, `LevelsSettings`, `LevelsHistogramDisplay`, `LevelsJob`, `LevelsFilter` over `LevelsPixels.c`, `LevelsEdit`; the session's `levels`, `beginLevels`, `updateLevels`, `setLevelsSampleMode`, `cancelLevels`, `commitLevels`), `LevelsAutomatic` (`LevelsSample`, `LevelsAuto`, `LevelsSettings::sampling`, `autoLevels`, `sampleLevels`), and the hooks owed: the gates, `canAdjustColors` and `canStartProjectOperation` read `levels`, `selectLayer` and `selectTool` refuse while it is open, closing it resumes file requests, and a pending gradient lands first. Tests: new `LevelsTests` (Swift's nine, the soft edge held to Swift's test), new `LevelsSessionTests` (the gates and a waiting file request, a commit's busy project and its refusals, the gradient first, the scaled preview from a painted layer's tiles and a one-pixel line, the rebuild dropping a shape, one preview at a time, Auto waiting for the histogram, the sample mode's signal, sampling through the layer's placement), new `LevelsModelTests` (clamps and defaults, the input clamped, settings stored and compared normalized, the scale's sorted finite interior, auto's thousandth tails, a single tone, the widest channel, neutral's gamma and floor, sampling past a channel's range, the composite and outputs reset), new `LevelsEdgeTests` (an open transform and lasso ended, every signal and redraw, the queue dropped with Preview, an identity commit at once, stale runs that never land, the rebuild dropping text, the preview for its own layer, truncated scaled sides, samples at the document's edge, on clear and soft pixels), new `LevelsFailureTests` (a failed scaled copy, sample and commit reaching `brushError`, a failed count and preview leaving nothing), the shared `LevelsFixtures.h`. Fixed on the way: the commit flattened a painted layer on the UI thread outside any catch; the flatten moved into the worker. +- 9.1a round 1: FAIL, two findings. The histogram packed the selection's coverage into a `std::vector`, whose failed allocation threw `std::bad_alloc` past the worker's catch and aborted the app: the kernel accumulates, so it now takes one row at a time with the mask's own row, and the histogram's bins and the filter's tables live on the stack. A commit forgot the running preview but kept the job queued behind it, which then started and landed mid-commit: a commit drops the queue too. Tests: `histogramRowsKeepTheirOwnCoverage` in `LevelsModelTests` (a two-row selection three pixels wide, where mask rows are padded), a queued preview dropped by a commit in `stalePreviewsNeverLand`. Mutants of the fixes: 4, all caught. +- 9.1a round 2: PASS. Both fixes hold against the original memory-limit and commit-race probes; the item closes after two findings over one round. +- [x] 9.1b The Levels sheet in its panel and on the canvas: `LevelsSheet` (the channel, the histogram and its loading note, the input and output handles and fields, the eyedroppers and their note, Auto, Preview with Alt+P, Reset, the caption, Cancel and OK with the spinner, resting while it commits), the panel (`levelsPanel`, cancelled by its close), the canvas's preview in place of the layer, the sample mode in `picking` and its press with the panel refocused, presses held to Space, Hand and Zoom while Levels is open, its keys (Escape, Return, Alt+P, Space alone), the brackets refused, `ContentView`'s drop guard and New canvas button, the Image menu's Levels… (Ctrl+L), Undo and Redo bare, Import Images resting. Tests: new `LevelsSheetTests` (the loading note, names and labels, the layout, the histogram's bars, colours and repaint, entries as Swift's bindings, the eyedroppers and their glyph, Auto, Preview and Reset, Cancel and the resting commit, a theme change), new `LevelsHandleTests` (the triangles, every handle's drag and bounds, the topmost press), new `LevelsCanvasTests` (the preview in place, a sample press handing the panel the keys, presses held to Space, the Hand and the Zoom, the keys, the brackets, the panel following the edit, Return from an entry or any button reaching OK), the shared `LevelsSheetFixtures.h`, the Levels panel's layout pass in `FloatingPanelTests`, the menu entry and its gates in `SelectMenuTests` and `CompositorMenusTests`, the eyedroppers' device pixels in `HighDpiCanvasTests`. +- 9.1b round 1: FAIL, two findings. The entries read and printed numbers with a period whatever the locale, so a German `2,5` went back to the old gamma, where Swift's `.number` format follows the user's locale: they read and print through the widget's locale now. Two new tests read a layer's asset through `->`: `.value()` now. Tests: `entriesReadAndWriteTheLocale` in `LevelsSheetTests`. Mutants of the fix: 2, both caught. The same gap in the Type bar's size and tracking (8.9a) is carried into 9.2. +- 9.1b round 2: PASS. Both fixes hold against the round's locale and panel probes; the item closes after two findings over one round. +- [x] 9.2 `Curves` (`CurvePoint`, `CurvesSettings`: validity, Swift's monotone Hermite `value`, `apply` through `levels_apply`), `CurvesControls` (the channel, the graph and its gesture, the readout, Remove point, Reset curve), `ImageAdjustments` (`ImageAdjustmentPixels`, `AdjustmentColor`, `ExposureSettings`, `GradientMapSettings`, `GrainSettings` over `AdjustPixels.c`); `PixelAdjust` and `PixelInvert` came forward in 7.1, 8.x and 9.1a, and Swift's `PixelInvert.thumbnail` shim has no twin (its callers call `PixelAdjust::thumbnail`). Their callers arrive with 9.4 (filters) and 9.5 (adjustment layers). Tests: new `ImageAdjustmentTests` (Swift's exposure, gradient map and grain tests, and the model half of `curvesIdentityAndImageCommandPreserveAlpha`), new `ImageAdjustmentModelTests` (curves' validity, interpolation and channels, every setting's range, normalizing and table, the gradient map's ends and colours, the grain's seed and place, the kernel's pixels), new `CurvesControlsTests`. In scope, carried from 9.1b's review: the Type bar's size and tracking read and print the locale's decimal mark, as Swift's `.number` fields (`sizeAndTrackingFollowTheLocale` in `TypeControlsTests`, whose fixture moved to `TypeControlsFixtures.h`). +- 9.2 round 1: PASS. No findings; the item closes in one round. +- [x] 9.3a Hue/Saturation in the session: `HueSaturation` (`ColorRange`, `HueBand`, `HueSampleMode`, `HueTargetDrag`, `RangeAdjustment`, `HueSaturationSettings`, `HueSaturationJob`, `AdjustedPixels`, `HueSaturationFilter` with Core Image's colour cube on the CPU, `HueSaturationEdit`; the session's `hueSaturation`, `beginHueSaturation`, `updateHueSaturation`, `commitHueSaturation`, `sampledHue`, `sampleHueRange`, the targeted adjustment, `cancelHueSaturation`), and the hooks owed: `canEditLayers`, `ProjectWorkspace::canSwitch`, `beginLevels`, `canContentAwareFill` and `beginFilter` read `hueSaturation`, `beginHueSaturation` resolves a pending gradient. Tests: `HueSaturationTests` (Swift's twelve), new `HueSaturationModelTests` (ranges, bands, settings, the HSL maths, the cube, the filter, the preview's size), `HueSaturationSessionTests` (gates, queue, commits, signals, samples, targeting), `HueSaturationFailureTests` (memory: the preview, the edit's copy, the commit's flatten, the filter's rows). +- 9.3a round 1: FAIL, one finding. A preview whose worker had finished but whose callback had not yet run counted as idle, so a new job replaced the watcher's future and Qt dropped the old result, and with it a failure's `brushError`, where Swift's task clears itself only after its await. A run now counts as running until its finish has run (`m_hueSaturationRendering`); Levels' and the filters' queues had the same gate (`isRunning`, and `isFinished` too, end with the worker: probed on 6.4.2) and take the same fix. Tests: `aDoneRenderLandsBeforeTheNextStarts` in `HueSaturationSessionTests`, `aFailureReportsThoughTheNextPreviewWaits` in `HueSaturationFailureTests`, `aDoneRunLandsBeforeTheNextStarts` in `LevelsEdgeTests`, a done but undelivered fill in `commitsThatWaitForThePreviewEachResume` (`SmartEditTests`). Mutants of the fixes: 9, all caught. +- 9.3a round 2: PASS. The fix holds against the round's reproduction, and restoring the old gate fails each queue's regression; the item closes after one finding over one round. +- [x] 9.3b The Hue/Saturation sheet in its panel and on the canvas: `HueSaturationSheet` with `SpectrumEditor`, the panel (`adjustmentPanel`), the canvas's preview, `picking`'s hue sample mode and targeting with their presses, drags and double clicks, the Image menu's Hue/Saturation… (Ctrl+U) and the `hueSaturation` term of Levels…. Tests: new `HueSaturationSheetTests` (layout, bindings, fields, toggles), `HueSaturationSpectrumTests` (the drawn glyphs, the spectrum's drags and paint), `HueSaturationCanvasTests` (preview, eyedropper, targeting, the panel, Return); `adjustmentPanelSurvivesALayoutPass` in `FloatingPanelTests`, `hueSaturationOpensOnPixelsAndRestsLevels` in `SelectMenuTests`, the shortcut in `CompositorMenusTests`. +- 9.3b round 1: FAIL, one finding. With the Brush chosen, Alt pressed during a targeted drag turned the cursor into the eyedropper, and it stayed so while the drag still changed saturation, where Swift sets the resize cursor again on every targeted drag. `targetingMove` does the same now. Test: Alt mid-drag in `targetingDragsSaturationOrWithCtrlTheHue`. Mutants of the fix: 3, all caught. +- 9.3b round 2: PASS. The fix holds against the round's reproduction, and removing it fails the new test; the item closes after one finding over one round. +- [x] 9.4a Filters in the session: `FilterKind`'s blurs, noise, lens and four image adjustments (Remove Background comes with 9.7), `FilterSettings`, `FilterJob` with settings, scale and seed, `PixelFilter::run` for each kind (`NoisePixels.c` and `LensPixels.c` compiled in place, Core Image's motion blur on the CPU), `FilterEdit`'s preview copy and blur growth, the session's remembered `filterSettings`, `beginFilter` for every kind (Gradient Map from the palette), `updateFilter(settings, preview)`, the commit's no-op closes and the blur's trim, and the colour picker's Gradient Map target. Tests: new `FilterTests` (Swift's), `FilterModelTests`, `FilterSessionTests` and `FilterFailureTests`; in `ImageAdjustmentTests` Swift's `imageMenuExposureChangesTheLayerInOneStep`, `gradientMapColorsUseTheAppColorPicker` and the command half of `curvesIdentityAndImageCommandPreserveAlpha`. A fix beyond Swift: a covering mask follows a trimmed blur's box. The Filter menu's `isImageAdjustment` and its assertion in Swift's Exposure test arrive with 9.4b. +- 9.4a round 1: no verdict. The service withheld the reviewer's output while it probed allocation failures through a malloc interposer; its probes pointed at two paths, both fixed before round 2. `PixelFilter::trimmed` handed on a null crop when the copy found no memory, so a blur could commit a null asset: it throws `ExportError(render)` now (`aTrimThatFindsNoMemoryIsARenderError`). The motion blur's row list, and the Gaussian's now that it runs in the filter worker, threw `bad_alloc` past the worker's catch: both go through `rowNumbers`, which turns it into `ExportError(render)` (`theBlursFailAsAContextWhenTheirRowsFindNoMemory`); removing either conversion, or the crop's check, fails the new tests. +- 9.4a round 2: PASS. Both allocation fixes hold, and the reviewer's four mutants of them (the crop's check, the conversion, each blur's use of it) fail the new tests; the item closes after two rounds, the first without a verdict. +- [x] 9.4b The filter sheet in its panel: `FilterSheet` (each kind's controls, `GradientMapControls`, `CurvesControls` hosted) in a `FloatingPanel` (`filterPanel`), the Image menu's Curves…, Exposure…, Gradient Map… and Grain…, and the Filter menu (with `isImageAdjustment` and the list of kinds, its readers). Tests: `FilterSheetTests` (7.5's, moved to the panel), new `FilterControlsTests`, the menus' in `SelectMenuTests` and `CompositorMenusTests`, `allFilterKinds` and `isImageAdjustment` in `FilterModelTests`, and the `isImageAdjustment` line of Swift's `imageMenuExposureChangesTheLayerInOneStep`. +- 9.4b round 1: FAIL, one finding. Arrow keys stalled on the filter sliders: a step of a thousandth rounded back to the setting, the update still notified, and the sync put the thumb back, so 100 Right presses left Radius at 1. A step that rounds to the setting it already holds now changes nothing, so the thumb keeps its place and steps add up (`arrowKeysStepTheSlidersOn`: 100 presses take Radius from 1 to 3.8 and Angle from 0 to 31; removing the guard fails it). +- 9.4b round 2: PASS. The fix clears every stall of round 1, drags that return, typed precision and Preview off hold, and removing the guard fails the regression; the item closes after one finding over one round. +- [x] 9.5a Adjustment layers in the document and the store: `AdjustmentKind`, `LayerAdjustment` (validity, `resolvedHSV`), `ImageLayer.adjustment` and everything that carries a layer's fields (the record, the snapshot and its install, `hierarchyRecord`, duplicates, copies between projects, the resizers), the manifest's `adjustment` (Swift's synthesized `Codable`: optional settings left out when absent, `ColorRange`-keyed dictionaries as key and value pairs), the store's rule (version 7, no pixels, no folder, valid; text never on an adjustment), `LiveMaskGraph.validate`'s rule that an adjustment layer is no clipping source, `canLinkMask`'s and `canPaint`'s terms, `addAdjustment` and `updateAdjustment`. Tests: Swift's `settingsSaveAndOlderAdjustmentsStillOpen` (in `ImageAdjustmentTests`) and `legacyHSVStillDecodesAndRenders` (a new `AdjustmentLayerTests`), new `LayerAdjustmentTests`, an unbaked copy in `ProjectCopyTests`. +- 9.5a round 1: FAIL, one finding. A Grain seed written `1e-400` fails Qt's whole parse (an illegal number) where Swift's decoder reads zero, and nothing recorded it. AGENTS.md's note on Qt's parser now names it, and `aSeedReadsWhatQtsParserReads` pins it beside what agrees: a whole number written `5.0` or `5e0` reads as Swift reads it. +- 9.5a round 2: PASS. The note and the pin match the round's reproduction; the item closes after one finding over one round. +- [x] 9.5b Adjustment layers drawn: `LayerAdjustment::apply` with its region, `LiveMaskRenderer.adjust` with its three hooks and the adjustment branches of `prepareStacks` and `drawComposite`, their wiring in `ImageExporter.render`, `drawLiveComposite` (through `AdjustmentSurface` when an adjustment exists) and the canvas, a clipped adjustment inside its stack. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`) with five of the twin's own, two cases in `LiveMaskRendererTests`, three in `CanvasDisplayTests`. +- 9.5b round 1: FAIL, three findings. `adjust` mixed bytes as RGBA while its snapshot kept the target's format, so an ARGB32 target swapped red and blue; and its copy back kept the target's pixel ratio, so a ratio-2 target drew doubled. The snapshot is now `RGBA8888_Premultiplied` at ratio one, and the copy back cancels the painter's whole device transform (`anAdjustmentTakesAnyImageTarget`: ARGB32, and ratio two against a painter scaled twice, under normal and Multiply; undoing any of the three changes fails it). The app reaches `adjust` only with such images already, so the fixes guard the renderer's own contract. Third, AGENTS.md claimed Grain stays in the document on the canvas; like Swift's, the canvas hands it the view's region: the entry now says so and `grainFollowsTheViewOnTheCanvas` pins it. +- 9.5b round 2: FAIL, one finding. Both renderer fixes hold against the round 1 probes, and the entry matches the canvas. But `grainFollowsTheViewOnTheCanvas` kept the random seed its layer was made with, and under seed 95 the pan leaves the sampled pixel at 133: the test now pins seed 7, which passes both of its checks every run. +- 9.5b round 3: PASS. The pinned seed holds; the item closes after four findings over two rounds. +- [x] 9.5c Editing adjustments in the session: `AdjustmentEditing` (`beginAdjustmentEditing` over the layers beneath, `previewAdjustmentEditing`, `finishAdjustmentEditing`), `adjustmentOriginal` and `adjustmentEditingID` with its setter, `addAdjustment` opening the editor, the hooks in Levels, Hue/Saturation and the filters, `adjustmentEditingID` in `canEditLayers` and `canStartProjectOperation`, `AdjustmentKind`'s `filterKind`. Tests: Swift's `AdjustmentEditorTests` (every kind) and `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel`, with the twin's own for the refusals, a failed render, the gates and file requests. +- 9.5c round 1: FAIL, one finding. `finishAdjustmentEditing` called directly with a Gradient Map picker open closes the filter edit under the picker, and closing the picker then read the missing edit through `value()` and threw: 9.4a had left out `setGradientMapColor`'s guards as dead, which 9.5c revived. The guards are back as Swift has them (no filter, or another kind, writes nothing; the `committing` term stays out as dead). Test: `aGradientMapPickerOutlivesItsEditor` (OK and Cancel, the picker closed over no filter and over a Curves edit opened meanwhile). Mutants of the fix: 3, all caught. The first pass ran 76 mutants: 72 caught once four rewrites built, two equivalent, and the dead folder term removed. +- 9.5c round 2: PASS. The crash no longer reproduces on OK or Cancel, and the `committing` term's omission matches the commit's order; the item closes after one finding over one round. +- [x] 9.5d Editing adjustments in the window: the layers panel's `.task(id:)` that begins editing, its footer's New adjustment layer menu, the rows' adjustment icons (six kinds, drawn), their words and double click, no link for an adjustment's mask; the Layer menu's New Adjustment Layer and Edit Adjustment…; the sheets' captions for an adjustment. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` (every kind) in `FloatingPanelTests`, the panel's and the menus' entries, the rows. Also: `GradientCanvasTests::theBarSetsTheGradient` waited for the commit's step but not for the edit to end, which happens one turn of the event loop later, so under a loaded suite its mask was refused; it now waits for both. +- 9.5d round 1: PASS. The menus, rows, glyphs, captions and the editor's lifecycle match Swift and the recorded adaptations; probes of all six kinds, reopening and a panel made anew passed. The item closes with no findings. +- [x] 9.6a Layer effects in the document and the session: `LayerEffects` (the four effects, `LayerEffectKind`, `LayerEffectSelection`), the manifest's `effects`, `ImageLayer.effects` where Swift lists it (equality, the record, duplicates; the rebuilds and a copy between projects that leave it out), the session's `effectsEditing`, `effectsEditingOriginal`, `effectSelection`, `canEditEffects`, `activeEffects`, `editingEffects`, `selectedEffect`, `addEffect`, `selectEffect`, `finishEffectsEditing`, `setEffects`, `changeEffects`, `canCopyEffect`, `copyEffect`, `toggleEffect`, `removeSelectedEffect`, the colour picker's effect target (`openEffectColorPicker`, `previewEffectColor`), and the hooks (`selectLayer`, `selectLayers` and `selectLayerTarget` clear `effectSelection`; `deleteLayerOrMask` removes a selected effect first). Swift has no effects tests: the twin's pin Swift's code. +- 9.6a round 1: PASS. The code matches Swift and the recorded adaptations (the fix beyond Swift that saves and loads effects among them); probes of picker transitions, refused selections and codec edge cases passed. The item closes with no findings. Mutants: 119, all caught once ten were rewritten to build, except three `isGroup` terms removed as dead. +- [x] 9.6b Layer effects drawn: `LayerEffectsKernel` (Metal's passes on the CPU, threaded), `LayerEffectsRenderer` (`cached`, `placed`, `margin`, `render` and its coverages), effects in `ImageExporter::render` and `drawLiveComposite`. Tests: new `LayerEffectsKernelTests` (the shader's loops replayed, byte for byte) and `LayerEffectsRendererTests`. +- 9.6b round 1: FAIL, three findings. A mask smaller than its layer was scaled up nearest, so its edge went hard where CoreGraphics' clip scales smoothly: `masked` now sets `SmoothPixmapTransform` (a 2 by 1 mask over 64 by 16 ramps 123 to 132 across the middle). The composition's workers called `scanLine()` on the shared result, and `detach()` bumps a non-atomic counter: they now write through one pointer taken first (the result's detach count stays under 8 on a 4,000-row image). The spread's queues took the larger side squared, 112 MB for a 30,000 by 10 layer: each pass now needs its bands times its line length. Tests: `thinLayersAndTallOnesAreCheap` (the thin layer under a 64 MiB limit, the detach count) and the mask ramp in `renderingPadsMasksThenRunsTheKernel`; undoing each fix fails them. The other parallel workers in the tree already take their pointers first. +- 9.6b round 2: PASS. The three fixes hold against the original reproductions; the item closes after three findings over one round. +- [x] 9.6c Layer effects on the canvas: `EffectsPreviewCache` (one worker, a 60 ms wait, the newest request, seeds, the shared budget), `LayerEffectsSurface` (a painted layer's effects redone as far as the paint reaches), `DistortEffectsCache`, `distortedEffects` and `commitDistort`'s seed, the canvas's `DisplayState` effects, `prepare`, the surface's hand-over and `drawOwn`'s branches in Swift's order. Fixes beyond Swift, recorded in AGENTS.md: the inner shadow scaled in a reduced preview; the surface widened and read by a reach that counts the inner shadow, its windows stopped at its bounds; one request a draw. Tests: new `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests`, `EffectsCanvasTests`, `EffectsFailureTests`; a distorted layer's mask in `DistortCanvasTests`. +- 9.6c round 1: FAIL, two findings. A reduced preview halved a mask by its layer's reduction, so a 2 by 1 mask on a 6,000-pixel layer became one gray pixel and the whole preview half clear: each source is now reduced by its own measure (a 2 by 1 mask in `aMaskHidesPixelsInTheReducedPreview`). A surface pass that could not copy the surface painted into a null image, published it and marked its tiles taken, so the effects vanished for the rest of the stroke: a pass now paints on a copy, checks its painter, and publishes the image and marks its tiles only once whole (`aStarvedLaterPassKeepsTheLastSurface`, first in `EffectsFailureTests`, since heap memory an earlier test freed would serve the copy without a new mapping). Undoing each fix fails its test; the redundant `detach` before the painter (which detaches itself) went. +- 9.6c round 2: FAIL, one finding. The round 1 fix took the larger of the two axes' scales, so a source that shrinks across and grows down went unfiltered: a 6000 by 1 striped mask on a 1527 by 25 preview aliased to alpha 0 to 255. Each source is now scaled by `QImage::scaled`'s smooth filter, each axis on its own (a mask as alpha, so its values pass exactly); the stripes a row high reduce to alpha 125 to 129, as the same stripes a hundred rows high, in `aMaskHidesPixelsInTheReducedPreview`. Undoing the filter, the alpha route or its format fails the test. +- 9.6c round 3: PASS. The fix holds against the stripes in both directions and the earlier reproductions (allocation failure included); 44 masks of constant coverage keep their values exactly. The item closes after three findings over three rounds. +- [x] 9.6d Layer effects' panel: `EffectsSheet` (each kind's controls, the colour swatches, Cancel and OK) in `ContentView`'s `effectsPanel` with the vanished-effect check, the footer's effects menu, the delete words for an effect (the trash's help, the Layer menu's title), the canvas press that clears `effectSelection`, and the session writers they need. +- 9.6d round 1: FAIL, one finding. An arrow step changed the effect from the session's value but left typed text in the field, so Return then applied the typing over the step: with Size at 4, typing 12 and pressing Up gave 5, then 12. A step now drops the typing and shows the stepped value, even where it clamps, as the picker's entries do (`theSheetEditsItsEffect`, at 23 and at the 500 bound). Leaving out either the flag's reset or the refresh fails the test. +- 9.6d round 2: FAIL, one finding. A wheel over the Size slider changed the size while the field kept the keys and its typing, so Return then applied the typing over the wheel: from 4, typing 12 and a notch down gave 2.497, then 12. The slider's change now drops the field's typing and shows its value, as a step does (`aWheelOnTheSliderReplacesTheTyping`, then Return keeping the wheel's value). Leaving out either the flag's reset or the refresh fails the test. The older sheets keep a field's typing across their own sliders, handles and pickers too (Levels, Hue/Saturation, the filters, the colour picker); 9.6f fixes them. +- 9.6d round 3: PASS. Both fixes hold, clamped steps and wheel changes included; 48 probes of the controls passed, unrelated pending typing and the panel's layer among them. The item closes after two findings over three rounds. +- [x] 9.6e Layer effects in the list: the effect rows under a layer (eye, name, selection, a click to choose, a double click to edit, an Alt-drag copy onto another row), the rows' heights, the list's press order (the clipping strip, then an effect row, else the choice clears) and its selection hidden while an effect is chosen. +- 9.6e round 1: PASS. Window-level probes of the panel's focus, a row rebuilt under its own press, eye and row drags, the selection's painting and a stale armed press all held; 86 mutants, all caught once six were rewritten to build. +- [x] 9.6f Pending typing yields to the same value's other controls in the older sheets, as 9.6d's review found: a wheel over a slider (the filters, Hue/Saturation), a dragged Levels handle, the colour picker's field and strip. Each replaces its entry's typing and shows its value, with tests. +- 9.6f round 1: PASS. Window-level probes held, and eight independent mutants removing a reset or a refresh were caught; 14 mutants, all caught once the Levels test pressed a handle without moving. +- [x] 9.7 Remove Background: `SubjectRemoval` (U²-Net on ONNX Runtime where Swift runs Vision: the model's input and mask, `noSubject` and a `model` failure, the mask cache, Advanced's refine, shift and contrast, the preview and the commit's mask), `GuidedMatte`, the kind with its quality and three settings, the full-size preview, `commitBackgroundMask` (a fix beyond Swift: a placed mask's placement goes with its pixels), the sheet's words, Quality and Advanced rows, the Filter menu's entry. The model chosen with its licence checked on the upstream repositories: U²-Net (Apache-2.0, `linux/licenses/U-2-Net.txt`) as rembg converted it (MIT); ONNX Runtime (MIT) from Microsoft's build in the dev image, from the distributions' packages elsewhere; both fetched by pinned checksum in `Dockerfile.dev`. Tests: new `GuidedMatteTests`, `SubjectRemovalTests`, `SubjectRemovalModelTests`, `RemoveBackgroundTests`; the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests. +- 9.7 round 1: FAIL, one finding. The preview's result shared the layer's pixels until the loop wrote them, so a copy that found no memory left a null image, stored as the preview with no error (a 6000 by 4000 layer with its mask cached, 8 MiB of room). The result is now copied before the loop, and a failed copy is `ExportError(render)` (`SubjectRemovalFailureTests`, a binary of its own). Leaving out the copy or its check fails the test. +- 9.7 round 2: FAIL, one finding. The model's input buffer threw `std::bad_alloc`, which passed the workers' catches and rethrew on the UI thread (an uncached 320 by 320 image, 512 KiB of room). `vision`, the door every model step goes through, now turns `std::bad_alloc` into `ExportError(render)`, covering ONNX Runtime's wrapper as well (`aModelRunThatFindsNoMemoryIsARenderError`, glibc's mmap threshold fixed so the buffer maps memory the limit counts). Leaving out the conversion fails the test. Swept after: `subjectMask` now refuses a kept mask of another grid as a `logic_error` rather than reading past it. +- 9.7 round 3: PASS. Both earlier failures now report recoverable errors; a failed commit keeps the layer and the history; independent mutants removing either fix were caught. The item closes after two findings over three rounds. + +## 10 Canvas and files (README) + +- [x] 10.1 `Crop`: `CropGeometry`, `CropDrag`, `CropSnap`, the session's `cropSnapTargets`, `visibleCropRect`, `cropRatio`, `cancelCrop`, `changeCropRatio` and `commitCrop`, its state (`cropRect`, `cropRatioChoice`, `cropError`) and hooks (see "Session hooks owed"); the canvas's gestures (`EditorCanvas+Crop.cpp`), cursors, the overlay's frame and the crop union in `renderBounds`; `CropControls` and the `Couldn’t crop` alert. Tests: new `CropTests` (Swift's six session and geometry tests and the twin's own geometry), `CropSessionTests`, `CropFailureTests`, `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay` and the gestures), `CropControlsTests`, the bar and the alert in `ContentViewTests`. Moved on: `flipCanvas`'s `cancelCrop` to 10.2. +- 10.1 round 1: FAIL, two findings. A crop that found no memory for the resized records (1,024 layers, 512 KiB of room) threw `std::bad_alloc` past the worker's catch, which rethrew on the UI thread and left the project busy: `CanvasResizer::resize` now turns it into `ExportError(render)`, which the worker catches (`CropFailureTests`, a binary of its own whose workers share the main heap). A press that drew a new frame took its cursor after dropping the old frame, so beside the canvas's edge it showed a resize arrow where Swift keeps the crosshair it showed: the cursor is now taken first (`theCursorShowsWhatAPressTakes`, a press at 5, 150). Leaving out the conversion, the worker's catch or the early cursor fails its test. +- 10.1 round 2: FAIL, one finding. Memory could still run out on the UI thread: the snapshot copied before the worker started, and the landing (the result's copy, the new document, the history's step) after the frame was dropped, so the project stayed busy and `done` never ran. `commitCrop` now takes the snapshot and starts the worker before anything changes, `finishCropCommit` moves the result out and lands it, dropping the frame only once it has landed, and `applyDocumentSize` builds the document and records the step on a copy of the history before publishing either (`DocumentHistory::adopt`); each failure sets `cropError` to the render error and frees the project. Tests in `CropFailureTests`: the snapshot, the worker and the landing each run out in turn, and a sweep of rooms from nothing to 16 MiB finds every size change landing whole or not at all; `aCropIsOneStepThatEndsRedoAndMarksTheProject` pins what `adopt` carries. Leaving out any guard, the staging or any of `adopt`'s three fields fails a test; taking a copy of the result instead of moving it only asks more memory. +- 10.1 round 3: FAIL, one finding (P3). A test read a returned optional through `->` (`cancellationAndViewportMappingDoNotEditDocument`, three reads), against the tests' rule; they read through `.value()` now, and the crop tests hold no other such read. The runtime findings of rounds 1 and 2 were verified fixed, with eleven independent mutants caught. The item closes after three rounds, the last finding fixed. +- [x] 10.2 `CanvasSizeSheet`, `ImageSizeSheet`, the controller's `canvasSize` and `imageSize` (the sheets over the window, the resize off the UI thread, alerts), `flipCanvas`, the Image menu's Canvas Size, Image Size and Flip Canvas entries. Tests: new `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests`, `ProjectResizeFailureTests`; the flips in `LayerFlipTests`, the entries in `CompositorMenusTests`. +- 10.2 round 1: FAIL, two findings. A wheel over Units changes the unit without taking the focus, so a size still being typed was read in the new unit (50 pixels typed, Units scrolled to Inches, Return: 5,000 pixels at 100 dpi); both sheets now take the focus to Units first, so the typing commits in the unit it meant (`aWheelOverUnitsCommitsTheTypingFirst` in both sheet tests, which activate the sheet since focus moves only in the active window; two mutants, caught). Three test reads of optionals used `->` or `*`; they read through `.value()` now. +- 10.2 round 2: FAIL, one finding (P3). The sheets printed a halfway number away from zero (17 pixels at 16 per inch showed 1.063 inches) where Foundation's formatter rounds halves to even (1.062); both sheets round the thousandths with `std::nearbyint` before printing (`halfwayNumbersRoundToEven` in both sheet tests, 1.0625 and 1.1875; two mutants, caught). +- 10.2 round 3: PASS, no findings. +- [x] 10.3 `JPEGExportSheet`, the controller's `exportPNG` and `exportJPEG` (the panels, the sheet over the window, the work off the UI thread, alerts), the cancellation points of `ImageExporter.jpeg`, the render's memory door, `ByteCounts` (Foundation's byte counts, shared with the canvas sheet), the File menu's Export PNG and Export JPEG entries; Copy Merged came with 7.4. Tests: new `JPEGExportSheetTests`, `ProjectExportTests`, `ProjectExportFailureTests`, `ByteCountsTests`; the cancellation points in `JPEGExportTests`, the entries in `CompositorMenusTests`. +- 10.3 round 1: FAIL, three findings. The JPEG encoding let `std::bad_alloc` escape its worker (a 24-megapixel noisy image with 170 MiB of room); `jpeg` turns it into `ExportError(render)` at its door, as `render` does (`anEncodingWithoutRoomIsARenderError`). The PNG snapshot's copy for the worker ran unguarded in the panel's callback, leaving the project busy; it is guarded and explained, as is `exportWork`'s launch (`aPanelsHandOffThatFindsNoMemoryIsExplained`). The JPEG sheet still used a file-local byte count; it calls `ByteCounts::file`. Mutants: the two doors' conversions and the hand-off's guard, caught; the launch's own guard is recorded as unreached. +- 10.3 round 2: FAIL, one finding. The panel's callback, and the refusal's alert, still copied the completion that holds the PNG snapshot (a megabyte for 1,024 layers), so a copy could fail before the panel opened or while the refusal was prepared; `exportPanel` and `showError` move their callbacks now (`thePanelOpensWithoutCopyingTheSnapshot`, room for one snapshot but not two; `aRefusedNameCopiesNoSnapshot`; three mutants, caught). +- 10.3 round 3: PASS, no findings. +- [x] 10.4 `ImageFileDrop` (`importProviders`, `holdsImages`): files and pictures dropped from file managers and other apps, the file branch of `receiveProviders`, and files and pictures taken by the canvas, the tab buttons and the New canvas button. Tests: new `ImageFileDropTests` (Swift's `fileDropProvidersReachImporterInOrder`, a picture copied to a file, what cannot be read, the drop targets). +- 10.4 round 1: FAIL, four findings. A dropped picture's copy was trusted after `QFile::write`, which only buffers, so a copy cut short (a file size limit) imported gray pixels; the flush is checked and a failed copy removed (`aPictureThatCannotBeKeptIsUnreadable`). Several files went to the importer at once, so a second failure replaced the first; `receiveProviders` now imports one provider at a time behind the error before it, Swift's gate (`eachFileWaitsBehindTheErrorBeforeIt`). An open import alert kept its first words; `showAlert` updates it (`anOpenAlertShowsTheGrownError`). A canvas without a workspace refused files; it imports into its own session under Swift's guards (`aCanvasAloneImportsUnderSwiftsGuards`). +- 10.4 round 2: FAIL, one finding. The whole-drag provider copied every advertised format, so a lazy drag offering five pictures handed over 54 MB for a 22 KB PNG and could run out of memory past every catch; it now copies the preferred picture alone (`aLazyDragGivesOnlyThePictureChosen`, a drag that records what it is asked for). Mutants: copying every format again, caught; keeping the web addresses, equivalent and so left out. +- 10.4 round 3: PASS, no findings. +- [x] 10.5 `SliderSnap` (an application event filter that snaps a track press before Qt tracks it, installed in `main`); `NavigationToolHeader`, `ToolHeaderStyle`, Hand and Zoom came with 4.3b and 4.4. Tests: new `SliderSnapTests`. +- 10.5 round 1: FAIL, two findings. A slider whose owner rounds the value (Gaussian Blur's radius) moved the knob away from the press, so Qt took the press for a groove click and left the slider held down; the snap now presses where the knob is and consumes the original. A left press with another button held snapped, though Qt refuses it; the snap now asks for the left button alone. Tests: a rounding owner's drag and release, a held button; four mutants, caught. +- 10.5 round 2: PASS, no findings. + +## 11 Fluency, packaging, distros + +- [x] 11.1 Performance pass: pan/zoom frame time at 4K with 10 layers, timings logged, compared with `docs/brush-performance.md`. Done so far: `CanvasPerformanceTests` (logged medians and p95s, regression guards) and `HslBlend::blend` in parallel rows (Hue layers 3× faster); see AGENTS.md, "Canvas speed (11.1)". +- 11.1 round 1: FAIL, two findings. The parallel blend deadlocked an export worker on a one-thread pool; so did the older parallel kernels (the Gaussian blur, probed), since Qt 6.4's `blockingMap` waits on a full pool from inside it. All eleven now go through `PoolMap::blocking`, which lends a pool thread's place while it waits (`PoolMapTests`; two mutants, caught). The benchmark's "100%" set zoom 2, which is 200% at a backing scale of two; it sets `zoom(1)` now, as Actual Pixels does, and asserts it; the honest median there is 68 ms, and the guards and the entry say so. +- 11.1 round 2: PASS, no findings. +- [x] 11.2 Desktop integration: `.desktop`, icon, `.comp` MIME type, `cmake --install` (`linux/packaging`, the install rules, the desktop file name in `main`, `COMPOSITOR_MODEL`). Tests: new `DesktopIntegrationTests`. +- 11.2 round 1: FAIL, four findings. `StartupWMClass` named the app id where Qt 6.4's X11 class is `Compositor`; it says `Compositor`. The model kept its own file name; it is installed as `u2net.onnx`. The install test could not pass for a build given the model; it reads the build's cache and expects the model then (checked by hand in a second build configured with it). A direct install left the project type unregistered; it runs `update-mime-database`, required, when no `DESTDIR` stages it (`aDirectInstallRegistersTheProjectType`, a stand-in tool; two mutants, caught). +- 11.2 round 2: FAIL, two findings. A model given as a link was installed as the link, dangling once the build machine's file went; the file itself is installed now (`file(REAL_PATH)`), and the staged test asserts no link. The direct-install test inherited a caller's `DESTDIR`; it clears it. Both checked by hand, since the suite runs without a model or a `DESTDIR`: a second build given a linked model, and the suite under `DESTDIR` set. +- 11.2 round 3: PASS, no findings. +- [x] 11.3 Packaging: `PKGBUILD` (Arch, Omarchy), `flake.nix` (NixOS), CPack DEB (Ubuntu) and RPM (Fedora); the app's licence installed, `COMPOSITOR_BUNDLE_ORT` for Ubuntu, packaging tools in `Dockerfile.dev`. The DEB is built and read back here; the others are built in 11.4. +- 11.3 round 1: FAIL, three findings. The bundled runtime shipped without Microsoft's licence and notices; the image keeps them from the release and a bundling install lays them under `share/licenses/compositor/onnxruntime`, a configure without them failing. The install test failed for a bundling build; it reads `COMPOSITOR_BUNDLE_ORT` and expects the library, its version link and the notices (a second build, bundling with the model, passes). A flake comment ran to eleven words. +- 11.3 round 2: FAIL, two findings in the install test: it looked for the runtime in `lib`, where a `/usr` prefix on Ubuntu gives `lib/x86_64-linux-gnu`, and took only `ON` for the bundle option, where CMake takes any true value. CMake now hands the test its own evaluated values (`COMPOSITOR_BUNDLED`, `COMPOSITOR_LIBDIR`, `COMPOSITOR_MODEL_PATH`), and a second build bundling with `TRUE` under `/usr` passes. +- 11.3 round 3: PASS, no findings. +- [ ] 11.4 Distro checks in Docker: build and full test run on Ubuntu 24.04, Fedora, Arch, NixOS. Seen early on the Arch host (Qt 6.11.2, GCC 16), in a build without warnings as errors: `findChild` needs `Q_OBJECT` on the class (fixed in 8.2: `ToolHeaderBar`); 13 of 107 targets fail or time out: BrushTests (the address-space crop test commits), HighDpiCanvasTests, ImageImportTests, TiledPieceTests, ToolIconTests, TransformInspectorTests, FilterSheetTests (Return on a focused Cancel no longer waits for OK), CompositorMenusTests; ContentViewTests, ProjectOpenTests, ProjectWorkspaceFlowTests, ProjectWorkspaceViewTests and ProjectControllerTests time out. Each is judged here: a Qt change the app must follow, or a test that leans on 6.4. +- 11.4 state: `linux/scripts/distro-check.sh arch|fedora|nixos` builds `linux/scripts/distro/<name>/Dockerfile` and runs dev.sh's configure, build and full test run in it (warnings as errors, the tree mounted read-only); NixOS builds the flake's package, then runs the tests in the flake's new `devShells.default` (plugin path, DejaVu through fontconfig, the model on the data path). Real containers start now through `sudo docker`; Ubuntu is the dev image itself. Still to do: run Arch (started), Fedora and NixOS, judge each failure, build the RPM on Fedora (`cpack -G RPM`) and the PKGBUILD on Arch (`makepkg`, as a user, not root), then review. +- 11.4 Arch run 1 (Qt 6.11.2, GCC 16, in a container): the build needed three fixes (`QImage::flipped` from Qt 6.9 in `ImageImporter`, `Q_OBJECT` on `PickerField` and `SwatchButton` for Qt 6.11's `findChild`, a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators); 193 of 205 tests pass. Failing, to judge: BrushTests, SpotHealingTests, TextLayoutTests, ImageImportTests, OmarchyThemeTests, ProjectWorkspaceViewTests, ProjectTests, EffectsCanvasTests (aborted), EffectsFailureTests, SubjectRemovalFailureTests, ProjectExportTests, ImageFileDropTests. Log: the scratchpad's arch-check.log. +- 11.4 Arch run 2 onward: running as uid 1000 (as dev.sh) cleared the four tests root broke (read-only folders and unreadable files mean nothing to root). Fixed since: TextLayoutTests counted Ubuntu's DejaVu split (now: the named faces, sorted, once each); ImageFileDropTests dropped before the last import ended (now waits for `canSwitch`); ImageImportTests (libheif 1.23's longer message; Qt 6.9's TIFF plugin logs libtiff errors as criticals, expected from 6.9 on); EffectsCanvasTests: Qt 6.11's `QTransform::map` clips w for points past the horizon where Swift divides by it whatever its sign, so `DistortWarp` projects points itself (`project` in `Distort.cpp`). Left: ProjectWorkspaceViewTests (the app launched with a file) and the memory-starvation tests (BrushTests, SpotHealingTests, EffectsFailureTests, SubjectRemovalFailureTests). A kept Arch build: `~/.cache/compositor-arch` (mount it at /build in compositor-arch). +- 11.4 Arch, later fixes: ProjectWorkspaceViewTests launched the app, whose logs a journald-built Qt sends to the journal when stderr is no console; the test sets `QT_FORCE_STDERR_LOGGING` for it. The five starvation tests (BrushTests' crop, SpotHealingTests' two, EffectsFailureTests' later pass, SubjectRemovalFailureTests' model run) place their failure by Qt 6.4's own allocations (the address limit itself works on Arch, probed: 20 MB refused under a 2 MB margin); they skip off Qt 6.4 (`placesStarvation` in `AddressSpaceLimit.h`), and BrushTests, SpotHealingTests and EffectsFailureTests fix glibc's mmap threshold as the other failure binaries do. Full Arch check rerunning. +- 11.4 Arch, the last timeout: ProjectManifestTests ran 50–120 s in the container against 0.7 s on Ubuntu, its CPU idle: files synced under a `HOME` on the container's overlay filesystem wait on this machine's busy disk (1 s with `HOME` in RAM). The check now keeps `HOME` and `TMPDIR` on a 4 GB tmpfs, as the dev image's RAM build does; an environment matter, not the app's. +- 11.4 after the reboot (real Docker, no sudo): Arch 205 of 205. Fedora 42 (Qt 6.10) 203 of 205, both fonts: Fedora splits DejaVu, so its image installs Serif and Mono beside Sans, as Ubuntu's `fonts-dejavu-core` and Arch's `ttf-dejavu` carry them; and Fedora's own default sans is Noto (`default-fonts-core-sans`), in which the Type tool's "Aa" draws, so `ToolIconTests` compares the Type cells with the DejaVu sheet only under DejaVu Sans (the other tests hold that icon's ink, box and uniqueness under any face; a band 8 points narrower fails on Fedora). Rerun: 205 of 205. +- 11.4 packages: the RPM (`cpack -G RPM` on Fedora) installs with dnf on a fresh Fedora 42 with RPM Fusion, which brings `libheif-freeworld`; its trigger registers the project type, and every library resolves. The PKGBUILD builds with `makepkg` as a user on Arch and installs with pacman, whose hook registers the project type; every library resolves. It builds from the checkout, `Compositor/Assets.xcassets` (the icons) included. +- 11.4 NixOS: the flake had no `flake.lock`, and the tree is read-only there; the lock is committed now, pinning nixpkgs. The package builds. Its first test run, as root, failed the four tests root breaks and three on fonts: the shell's `makeFontsConf` reads its rules from `/etc/fonts/conf.d`, which NixOS keeps and a bare container lacks, so Helvetica fell to DejaVu Math TeX Gyre; the shell now includes fontconfig's own `conf.d` from the store. Nix writes its store as root with root's home, and the tests then drop to uid 1000 (`chroot --userspec`, coreutils) with `HOME` and `TMPDIR` back on the tmpfs, since `nix develop` points `TMPDIR` at a folder root made. +- [ ] 11.4b Standalone OmaPhoto: the port moves to its own public repository, `ZacharyZhang-NY/OmaPhoto` (the user's decision, 2026-09-26), the app at the root, the kernels in `kernels/`, the icons in `packaging/icons/`; renamed where a user sees it (name, binary, app id, desktop entry, icon, packages, logging, build options), Compositor's project format kept; the README credits Compositor; one install script per family in `scripts/install/`; `release.yml` builds and publishes the packages on a tag. Version 1.0.0. +- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table. + +## Skills by phase + +Planning (done): geju, goudi, hai-razor, hai-architecture, senior-architect, clean-architecture, ai-coding-discipline, karpathy-guidelines, be-serious, herdr. Coding (from 0.1): clean-code, code-simplification. IO (3.1, loaded): ddia-principles, applied as encoding and evolution (new code reads versions 1 to 7, unknown keys are ignored, a newer version is refused by name), reliability (a save is whole or absent, synced before the swap) and limits checked before bytes are read. UI (4.3): steve-jobs-perspective. Tests (4.3): e2e-testing-patterns, systematic-debugging. Research (9.7): deep-research. Packaging (11.2): devops-engineer. auth-implementation-patterns: loaded at 3.1; the app has no accounts, sessions or network auth, so only its one general rule applies: a project file is untrusted input, validated before use. + +## Review log + +One line per review: item, round, verdict, findings fixed. + +- 0.1 round 1: FAIL. `dev.sh run` prefixed an absolute `WAYLAND_DISPLAY` with `XDG_RUNTIME_DIR`. Fixed. +- 0.1 round 2: PASS. +- 1.1 round 1: FAIL. `CanvasViewport` lacked `==`; `validDimension` accepted a trailing newline and an embedded NUL; no test asserted the pan update. Fixed. +- 1.1 round 2: FAIL. The viewport inequality check changed two fields at once. Fixed: one field per assertion, every `==` term mutation-checked. +- 1.1 round 3: PASS. +- 1.2 round 1: FAIL. No test proved that `reset()` issues a fresh revision. Fixed; every revision line mutation-checked. +- 1.2 round 2: PASS. +- 1.3 round 1: FAIL. `visibleRect` ignored the window mapping and device pixel ratio; the nearest-sampling test began with smoothing off; nothing proved layer equality leaves painted tiles unflattened. Fixed after measuring QPainter in the container. +- 1.3 round 2: PASS. +- 1.4 round 1: FAIL. `renderLayers` overwrote duplicate ids where Swift traps; no test used an absent `isGroup`; `visibleLayers` order unchecked. Fixed. +- 1.4 round 2: FAIL (new codex session after the Herdr workspace changed). Nested collapse, absent-flag validation and error kinds were untested. Fixed; `ProjectError` now forces the version payload. +- 1.4 round 3: PASS. +- 1.5 round 1: FAIL. A null thumbnail from `scaled()` was accepted where Swift throws; flag tests used equal values; `placed` was tested square only; painted-mask equality and masks on pixel layers were untested. Fixed. +- 1.5 round 2: PASS. +- 1.6 round 1: FAIL. The tracer converted pixels instead of drawing over a cleared bitmap (alpha mishandled, null conversion unchecked); source-id equality untested for two present ids. Fixed. Tests now read returned optionals through `.value()`. +- 1.6 round 2: FAIL. A positional `drawImage` shrank images with a device pixel ratio. Fixed; rule recorded in `AGENTS.md`. +- 1.6 round 3: PASS. +- 2.1a round 1: FAIL. Workers raced on `QImage::scanLine()`'s detach counter; a scratch `bad_alloc` escaped `halve()`; colour fixtures had equal channels. Fixed: raw row pointers, one pre-allocated scratch block, a distinct-channel test, a test-only `operator new` that fails on demand. +- 2.1a round 2: PASS. +- 2.1b round 1: FAIL. A rotated layer leaked through a black mask at antialiased edges; the HSL copy inherited painter opacity; HSL rounding could exceed alpha; a throw after `save()` skipped `restore()`; the test file passed 500 lines. Fixed: mask drawn into a zero-filled `Alpha8` surface and multiplied 1:1, absolute opacity, alpha-first rounding, throwing work before `save()`, tests split. +- 2.1b round 2: FAIL. Mask-painter antialiasing was unverified. Fixed with the coverage-squared law; every hint of every painter mutated. +- 2.1b round 3: PASS. +- 2.1c round 1: FAIL. A failed copy of shared coverage passed as "no folder masks"; the thumbnail background, `flipY` and the 64-folder limit were untested. Fixed; folder clip tests split out; the RLIMIT boilerplate became `tests/AddressSpaceLimit.h`. +- 2.1c round 2: PASS. +- 2.2a round 1: FAIL. Replacing with `Source` doubled antialiased coverage at outer edges; `mutate.py` could report "caught" when nothing ran; every fixture had a zero alignment. Fixed: pieces clear and redraw an exact half-open device-pixel region, the runner judges builds and tests apart, an aligned fixture was added. +- 2.2a round 2: FAIL. The opening for the antialiased outline shrank below a device pixel at extreme reductions. Fixed with room measured in device pixels. +- 2.2a round 3: FAIL, the last round allowed. The room used the painter's horizontal scale, so unequal painter scales under-opened the vertical outline. Fixed: the room comes from the whole device-to-grid transform. Proven by `theOutlineOpensByOneDevicePixel` (the reviewer's regression and two sheared rows) and 22 mutants of `opened` and the culling, all caught. Also in this round: the halving overhang left the opening (proved dead), `Piece.offsetBy`, the offset of `drawCommitted`, `Frame.device` and `Frame.sampling` moved to 2.2b, piece and cache tests split into `TiledPieceTests`. The fix is in 2.2b's review scope. +- 2.2b round 1: FAIL. `drawBrushPreview` drew a cropped raster's base past the source bounds; Swift clips it. I had skipped the clip on an unchecked assumption. Fixed: the base is clipped to the source bounds and the source to the grid; both pinned by tests and mutants. +- 2.2b round 2: FAIL. The grid-clipping test used a plain image only, so the nested raster clips went unchecked (`IntersectClip` to `ReplaceClip` on the base clip passed). Fixed with a painted-source row that keeps the overhanging tile and the untouched-pixel assertions. +- 2.2b round 3: PASS. The round also judged the 2.2a round-3 fix (`opened` from the whole device transform) and found nothing. +- 2.3 round 1: FAIL. The adjustment surface and a stack's group ignored the painter's own opacity and composition mode; a surface that could not be allocated threw where Swift returns; `draw` lacked Swift's save and restore around `drawOwn`; child order and rectangular devices were untested. Fixed: the surface is drawn back by hand under the painter's state, allocation failure logs and returns, a scoped save guards `drawOwn`, tests for each. +- 2.3 round 2: FAIL. `LiveMaskRendererTests.cpp` had grown to 510 lines. Split: the stack cases moved to `ClippingStackTests`, the fixtures to `LiveMaskFixtures.h`. +- 2.3 round 3: PASS. +- 2.4 round 1: FAIL. A 2400x1 JPEG asked for a 1000x0 preview and threw; the two mask flags were only tested with equal values; only width reached the 30,000 edge; comments passed ten words. Fixed: the preview keeps one pixel across, both flag combinations, a 1x30,000 render, all 93 long comments in `linux/` shortened, and `linux/scripts/lint.py` now checks file length and comments. +- 2.4 round 2: FAIL, on the new `lint.py` only: it missed block comments, read `//` inside string literals as comments, and let a 500-line file without a final newline pass. Fixed with a small tokenizer (line and block comments, string, character and raw string literals), `splitlines()` for the count, and a `--self-test` of 14 fixtures whose every rule was mutated. +- 2.4 round 3: FAIL, the last round allowed, again on `lint.py` only (the exporter drew no finding after round 1): a digit separator (`1'000`) opened a character literal and hid every later comment, and a continued string did not advance the line count. Fixed: numbers and identifiers are tokens, newlines inside literals count; proven by `--self-test` (20 fixtures) and 13 mutants of the checker, all caught; the tree is still clean. The fix is in 3.1's review scope. +- 3.1 round 1: FAIL. A parent-folder sync failing after the swap threw while the scope guard deleted the old project; a manifest read failing after partial data went unnoticed (`QFile` reports no error then); the UUID pattern's `$` let a trailing newline pass; `version`, `width` and `height` were narrowed to 32 bits where Swift's `Int` holds 64. Fixed: every way in has its way back, taken when the sync fails; the byte count is checked against the file's size; `\\A…\\z`; `qint64` with the value kept in the version error. The lint fix of 2.4 drew no finding. +- 3.1 round 2: FAIL. When the parent sync failed and the way back failed too, the scope guard still deleted the hidden sibling, which then held the old project, and the log pointed at it; `version`, `width` and `height` were read through a double, so 9007199254740993 lost a digit and 9223372036854775807 was `invalid` where Swift names the version. Fixed: a failed way back sets the guard's `keep`, nothing is deleted, and the critical log names where the old project stays (rows for an exchange, for each of the two moves back, and for a first save); integers come from Qt's exact `LongLong`, in the header and the codec alike, with rows at 2^53 + 1 and both ends of Int64. Swift's decoder was run in a `swift:6.0-jammy` container to settle the edges; one Qt parser fact is declared in AGENTS.md and pinned. +- 3.1 round 3: PASS. The reviewer ran Swift's decoder too and accepted the declared parser fact. +- 3.2 round 1: FAIL. A HEIC that names Display P3 in an `nclx` box instead of an ICC profile kept its numbers and was tagged sRGB (the reviewer swapped the fixture's profile in flight to show it); `heic-fixtures.sh` went on after a failed step and still exited 0. Fixed: the importer reads the `nclx` box (primaries by chromaticity, the sRGB or the BT.709 curve, everything else refused with its reason), proven by rows patched from one lossless fixture with independently derived values; the script runs under `set -eu` with each step on its own line, and a broken generator now exits 1 and writes nothing. +- 3.2 round 2: FAIL. Linear light and the two gamma curves were refused though Qt converts them; two new comments in `heic-fixtures.sh` ran to 11 and 12 words, and `lint.py` does not read scripts. Fixed: linear, gamma 2.2 and 2.8 go through Qt's transfer functions, xvYCC and BT.1361 share the video table, each with a row of derived values and its mutants; both comments are shortened and every comment in `linux/scripts`, `CMakeLists.txt` and `Dockerfile.dev` was counted once by hand. Teaching `lint.py` to read scripts is taken up in 3.3. +- 3.2 round 3: PASS. +- 3.3 round 1: FAIL, seven findings. `drawCoverage` forced Screen and so ignored the painter's own mode, and turned by `rotate(degrees)` where `draw` turns by `radians()`; `lint.py` missed `#` comments without a space and flagged `#` lines inside quoted strings; the tests read optionals with `*`, gave `maskEnabled` and `maskLinked` the same value, left masks out of the resolution-only case, and proved an unflattened asset by pointer only. Fixed: white with the mask as alpha under the inherited mode, opacity and clip, placed as `draw` places; real scanners for shell, CMake and Docker with the fixture generator split into real files; `.value()`, opposite flags by rows, a mask through the resolution-only path, `hasMaterializedPixels` before and after. +- 3.3 round 2: FAIL, five findings. Under Source or Clear the white-with-alpha source erased where the mask was zero, which a clip would not; the CMake scanner counted a bracket comment's closer as a word; the shell scanner took `$((1 << shift))` for a heredoc, cut hyphenated tags short, missed escaped blanks and `\#`, and skipped comments inside `"$( )"`. Fixed: each composition mode maps to the one under which alpha equals a clip's coverage, held against an oracle for all 24 modes over opaque, half clear and clear pixels, raster operations refused; the shell scanner was rewritten as a strict subset that refuses what it cannot read (113 self-test cases, 58 mutants, one equivalent); CMake escapes and bracket closers fixed. +- 3.3 round 3: FAIL, the last round allowed, on `lint.py` only (the C++ and its tests drew no finding): an escaped blank before `[[` was taken for an argument boundary in CMake, and a continued line splitting `$` from `(` hid a command substitution in shell. Fixed: the CMake scanner tracks whether it stands inside an unquoted argument, escapes included; the shell scanner reads a continued line only between words (after a blank, a tab or a newline) and refuses it anywhere else, inside double quotes after `$` too, so no operator or keyword can be split. Proven by the reviewer's two inputs as regressions among 133 self-test cases and by 74 mutants of the scanners, all caught. The fix is in 4.1's review scope. +- 4.1a round 1: FAIL, five findings. `beginEdit` closed undo and redo without telling observers; `zoom` with a value that is no number, refused by the viewport, still signalled; `descendantIDs` copied the layer list once per visited folder, quadratic on a wide folder; the tests never read back what the setters stored and read one optional through `->`; the CMake scanner of `lint.py` forgot it stood inside an unquoted argument after a quote, so `a"b"[[` opened a bracket argument. Fixed: `beginEdit` emits; `fit` and `zoom` emit only when the view changed; one lookup of children by parent, built once, walked once (`aWideFolderIsWalkedOnce` walks 9,999 children in under two seconds); `settersKeepExactlyWhatTheyAreGiven` and `observersSeeFinishedStates`; `.value()`; the quote keeps the argument state, with two regressions among 135 self-test cases and 75 scanner mutants, all caught. The standard library's assertions (`_GLIBCXX_ASSERTIONS`) are on for every target, after a mutant that read an empty optional passed by luck; with them it aborts. Nine mutants of the fixes, all caught. +- 4.1a round 2: FAIL, one finding. The transaction test read `canUndo()` only after `beginEdit` returned, so a signal sent before `history.begin` passed while observers stayed stale. Fixed as a class, not a case: `theLastSignalOfEveryChangeSeesWhatItLeaves` records everything an observer can read (active layer, selection, folded folders, tool, flags, error, rename, undo and redo with their names and count, the modified mark, the view, every layer) inside the `changed()` slot and compares it with the state each call leaves, for every mutator of the item; a call that says nothing fails too. The signal tests moved to `EditorSessionSignalTests` (the session file stood at 496 lines). 29 mutants that move a signal or a transaction's end ahead of the state it covers: 27 caught at once; two showed that redo after `insert` and `addGroup` was never checked for the active layer, so `everyLayerEditRoundTripsWithSelection` gained both steps, and all 29 die. +- 4.1a round 3: PASS. +- 4.1b round 1: FAIL, two findings, both in the tests (the code matched Swift line by line). No fixture nested deep enough: mutants that looked only at immediate parents, for the shared ancestor and for a selected ancestor, passed. And the check that taking a non-active layer out of a selection keeps the active one leaned on hash order: a mutant passed 7 of 40 runs. Fixed: `nestedSelectionsShareAnAncestorAboveTheirParents` builds G holding A(C(a)) and B(b), selects A, a and b with b first in hierarchy order, and holds the wrapper's parent, every parent link and the flat order; the deselection check takes the same third layer out of the same three twice, once under each survivor as primary, so no hash order can save a wrong choice. Added on my own reading: `wrappedLayersKeepTheirStackingOrder` (hierarchy order, not list order) and a target that beats `atBottom` in `placeLayer`. Eleven mutants of these, all caught, the reviewer's three among them. +- 4.1b round 2: PASS. +- 4.1c round 1: FAIL, two findings, both in the tests (no source line drew one). The 64-step walk from a layer up to a selected folder had no fixture at its edge: 63 and 65 passed. And no group edit ran on members with a turn, flips or sampling, so originals rebuilt from origin and size alone passed. Fixed: `aFolderReachesSixtyThreeLevelsDown` nests 64 folders around an image: the second folder carries it, the first does not; `aGroupMoveKeepsEachTurnFlipAndSampling` gives two members opposite flips, different turns and samplings, holds the saved originals, the preview and the commit of a group nudge to the whole transform, and holds a single edit's draft the same way. Added on my own reading: a nudge past the valid origins changes nothing, and a hidden folder's handles fall back to its own transform. Eleven mutants, all caught, the reviewer's three among them. +- 4.1c round 2: PASS. +- 4.1d round 1: FAIL, four findings. A request's callback ran inline, so the last caller saw `isImporting` still true and no error yet, where Swift's resumed caller sees both settled; the waiters were released on every write of `isProjectBusy`, so saying busy again could put a later waiter ahead of an earlier one (Swift releases only when busy ends); the default coarse timer fired at 243 ms; and two tests held ceilings on time (a 4 to 1 ratio for the import call, 400 ms for the indicator) that a scheduler pause breaks. Fixed: callbacks are posted to the event loop; the release stands under `if (!busy)`; the timer is precise and a child of the session; the tests hold the callback's view of both a first and a last request, the line of waiters across a repeated busy, the floor of 250 ms read inside the signal handler, the timer's remaining time right after busy is said again, and the decoding thread taken from the importer's own log. Five mutants of the fixes, all caught; 20 runs under full CPU load, no failure. +- 4.1d round 2: FAIL, three findings, all in the tests. A `qWait` before the timer was inspected could deliver the timer under a scheduler pause; a non-empty import without a callback was never run, so the guard on `done` was unpinned; and no test ended a session while a posted callback was still pending, so a callback posted to a longer-lived receiver passed. Fixed: `qSleep` holds the events back until the timer is read; an import with the default callback; a high-priority event ends the session between the drain's end and the callback's delivery, and the callback must never run. The same property is now held for both kinds of waiter (`waitersEndWithTheirSession`). The gate and busy tests moved to `SessionGateTests` (the import file stood at 481 lines). Four mutants, all caught. +- 4.1d round 3: PASS. +- 4.1e1 round 1: FAIL, four findings, all in the tests. Every display without a preview expected Normal, so a constant passed; the preview's clearing in `commitTransform` was only reached through `selectLayer`, whose later signal hid a missing one; no transform was committed inside an open opacity drag, so ending the drag only when no transform is open passed; and the selected-layers setter met NaN but no infinity. Fixed: a stored Multiply shows with and after a preview; `commitTransform` called with only a preview open must signal, and the slot must see it cleared; a nudge committed inside a drag gives two steps that undo one by one (`Transform Layer`, then `Layer Opacity`); NaN and both infinities go through both setters with opacity, history and signal count held. Five mutants, all caught. +- 4.1e1 round 2: FAIL, three findings, again in the tests. Both layers of the cross-layer case held Normal, so a fallback to the active layer's mode passed; no cycle ran under an open preview, so a cycle starting from the displayed mode passed; and no drag lost its layer, so the guard for a missing target, and a signal on that refusal, were unpinned. Fixed: two layers with different stored modes, each shown as its own with and without a preview on the other; a cycle from stored Screen under a Hue preview gives Overlay and clears the preview; a drag whose layer is deleted changes neither document nor history and says nothing, and the deletion rides in the drag's one step. Added by the same reading: a refused cycle leaves a preview and says nothing; a lone opacity step is named. Six mutants, all caught. +- 4.1e1 round 3: PASS. +- 4.1e2 round 1: FAIL, three findings, all in the tests. The accepted edges of `createNewProject` were held on one axis each (30,000 wide, 1 high) and not on the other; the fixture's assets bore their records' names, so a layer named from its asset passed; and the resolution-only resize was never applied through the session, so an `applyDocumentSize` that refused unchanged dimensions passed. Fixed: 1 by 30,000 is taken as 30,000 by 1 is; the fixture's images are named like camera files while the layers keep their own names; the resolution-only case installs, applies, holds 300 and one step, undoes and holds 72. Six mutants, all caught. +- 4.1e2 round 2: FAIL, two findings, in the tests. No project without an active layer was installed over a session that had one, so keeping the old selection passed; and no snapshot was taken of a canvas without layers, so answering none for it passed. Fixed: an unselected project clears the active layer and the selection; an empty canvas gives a snapshot with its id, size, resolution and nothing else, and installs as an empty document. Added by the same reading: applying a size leaves the session's active layer and path alone, whatever the snapshot calls active. Three mutants, all caught. +- 4.1e2 round 3: PASS. +- 4.1e3 round 1: FAIL, three findings, all in the tests. Every copy of a mask came from the active layer, so pixels or placement read from the active layer passed; the mask outside the group had no placement, so dropping it in the preview passed; and the unknown-id case stopped before asking `canEditMask`, so the guard for a missing active layer was unpinned. Fixed: a copy between two layers that are not active, with a third active and masked, holds the source's pixels, placement and flags; the outsider's mask has a placement that the preview and the commit must keep; one selected id without a layer can edit no mask, and `addLayerMask` changes nothing. Four mutants, all caught. +- 4.1e3 round 2: FAIL, two findings, in the tests, both about what Swift permits. `copyMask` and `toggleMaskLink` ask only for editable layers, yet every test ran them with one layer selected, so the stricter `canEditMask` passed; and folders appeared only as forbidden targets, never as the valid mask sources they are. Fixed: `whatOnlyNeedsEditableLayersWorksWithSeveralSelected` copies a folder's placed mask onto a layer and toggles links on a layer and on a folder, all under a selection of two; a mask target is chosen while a sheet is up. By the same reading: a mask switched off still shows its placement and rides along with its layer. Six stricter-gate mutants, all caught. +- 4.1e3 round 3: PASS. +- 4.1e4 round 1: FAIL, six findings. One bug: `releaseDetachedClipping` keyed its runs by `parentID.value_or(QUuid())`, so a folder whose id is all zeros shared the root's run and a move freed a valid link; the runs are keyed by the optional parent now. One missing trap: the baker let a snapshot with a repeated id overwrite its own records, where Swift's dictionary of unique keys traps; it throws `logic_error` after the target and its pixels are found, and the worker catches `ExportError` alone so that a bug crashes. Four test gaps: no source with a raster mask of its own in a bake; only square targets, so swapped sides passed; clipping never run under a selection of several, so the stricter `canEditMask` passed for all four gates; busy after the call did not prove a worker. Fixed with `LiveMaskBakeTests` (three by two, six mask values), `clippingWorksWithSeveralLayersSelected`, and a bake that queues behind a held thread pool with a lazy raster: the call returns before the raster flattens, and it flattens while the caller runs no event. +- 4.1e4 round 2: FAIL, four findings, all in the tests. The worker test stranded its held threads when a check failed before their release, and the process hung at exit: a scope guard lets them go and joins them now. The baker's source and target always shared their pixel size, so the target's sides passed for the source's grid: `aStretchedSourceBakesThroughItsOwnGrid` stretches three by two pixels over six by four under a placed mask and checks all 24 alphas. Every baked source stated its opacity, so `.value()` passed for `.value_or(1)`: that source states none now. No source lacked pixels, so the guard before the image read could go: `aSourceWithoutPixelsHidesTheTarget` bakes under a blank layer and under a link the snapshot lacks; both give a clear image. Looking for more of that kind showed a class: with the guard before an iterator read removed, `*end()` read wild memory and every test passed by luck (the image guard, five end-of-list guards, two sibling bounds). The reads are checked now (`indexOf`, moved to `EditorSession+Model`, with `[]` under `_GLIBCXX_ASSERTIONS`; `map.at`), so a lost guard aborts or throws; AGENTS.md makes that a code rule. `finishDeletingLayer` of an unknown id reads nothing after its guard, so its silence is asserted in `MaskSignalTests`. +- 4.1e4 round 3: FAIL, one finding, in the tests: no bake fixture gave the target a raster mask of its own, so a baker that pressed that mask into the pixels as well (applied twice after the deletion) passed. Fixed and proven: the target of `aBakeMapsTheSourceIntoTheTargetsOwnPixels` wears a 128-gray mask, the baked alpha stays 128, and the mutant that bakes the mask gives 64 and fails. The third round ends the reviews of 4.1e4; this fix is in the scope of the review of 4.2a. +- 4.2a round 1: FAIL, four findings. A use-after-free: the session keeps the drop queue's waiter and the import's callback, both of which captured the controller, so a controller that ended first was called after its death; both hold it through a `QPointer` now, and `aControllerThatEndsLeavesTheSessionSafe` ends one behind a sheet and one mid-import (the thread pool held). A save name with a foreign suffix made a project that drops then read as a picture: `.comp` is appended unless the name ends in it. A path with a trailing slash left the project without a name in the question and in Save As: `open` cleans its path. And nothing proved the store off the caller's thread: the store's own log lines are now caught with the thread they come from. +- 4.2a round 2: FAIL, two findings, both in the fixes of round 1. The suffix appended after the panel closed could land on an existing project and replace it unasked (`Kept.png` over `Kept.png.comp`): a completed name that meets anything on disk is refused with an alert now, and the old project's manifest is asserted to survive. `QDir::cleanPath` collapsed `link/../Other.comp`, which names another folder when `link` is a symbolic link: `open` trims only the slashes at the end, and a test opens distinct projects at the two places. +- 4.2a round 3: FAIL, one finding, in the tests: every save and open passed a completion, so `finish` without its `if (done)` passed, and a plain `controller.save()` then threw `std::bad_function_call` from the event loop. Fixed and proven: `aCallWithoutACompletionIsWhole` saves, opens and is refused with nobody to tell; the mutant fails it with the unhandled exception; the queue's two completion guards were checked the same way (both caught). The third round ends the reviews of 4.2a; this fix is in the scope of the review of 4.2b. +- 4.2b round 1: FAIL, five findings. `receiveNext` did not hold the tab it imported into, so `removeTab` during the import destroyed the session with its callback and left the workspace managing for good: the callback holds the tab now (tested with the decode held). `samePlace` matched any two places that no longer exist, both canonicalising to the empty string, so a missing project "opened" as the tab of another that had moved away: it falls back on the cleaned absolute path. After a copy the workspace's last signal came before the sessions were freed and showed `canSwitch` false for good: managing ends last. A project named `.comp` had an empty title: `nameWithoutSuffix` cuts as Swift does. And two reads broke the checked-read rule (the source tab's iterator, the baked map): a shared pointer, whose null read aborts, and `at`; `removeTab` erases by predicate and returns when nothing went. +- 4.2b round 2: FAIL, two findings, both in the tests. The suffix cases called only the helper, so `title()` back on `completeBaseName()` passed: they go through a session's path and `title()` now. And every workspace call passed a completion, the very gap of 4.2a round 3 one class further: `everyCallIsWholeWithoutACompletion` runs `open`, `confirmQuit`, `close`, `receive`, `receiveProviders` and `copyLayer` with nobody to tell, refused and carried out; both `finish` guards die without it. Added by the same reading: `ProjectWorkspaceSignalTests` (the last signal of every flow sees what the flow leaves; an empty drop; a workspace that ends under a question or during a copy), and the window handed to the tab that comes forward, not the one that leaves. +- 4.2b round 3: PASS. +- 4.3a round 1: FAIL, three findings, all in `ToolIcons.cpp`. The fade's `setClipPath` replaced the caller's clip, so a widget repainting a strip got ink beside it: it intersects now, and `anIconKeepsToTheCallersClip` draws every icon under a clip of eight pixels. The fade's frame was stroked after the clip ended, where Swift strokes inside it, so half the frame lay outside Swift's square: the stroke moved inside the clip, the outer margin is asserted clear, the reference sheet renewed. And `idle` was refused after the painter had been saved and changed: it is refused first, and the test holds the painter's state and the absence of Qt's warning. +- 4.3a round 2: FAIL, two findings, both leaks into the drawing. The fade's dots were filled one by one, which leaves translucent seams between neighbours at a fractional scale (alpha 173 at side 22.5): they are one path now, as in Swift, and `theFadeHasNoSeamsAtAFractionalScale` holds every pixel that lies wholly inside lit dots at 255. A caller's opaque background mode filled the marquee's dashes and the letters with its background colour: the mode is set transparent inside the saved state. By the same reading the letters no longer start from the caller's font, and `nothingOfTheCallersPainterShowsInAnIcon` draws every icon from a hostile painter (dotted red pen, blue brush, red opaque background, underlined slanted spaced font, a brush origin) and wants the very pixels a fresh painter gives, with the caller's settings back afterwards. +- 4.3a round 3: PASS. +- 4.3b round 1: FAIL, five findings. A rail button toggled itself on a click the busy session refused, so two were down: the click's slot sets every button again. The welcome was hidden and shown, so it kept its fields and its clipboard suggestion across projects where Swift makes a fresh view: it is made anew when the document goes, and the journey test clears a project with a picture copied. The Eyedropper's bar lacked its title. Return with a button in focus created nothing (`setDefault` reaches only a `QDialog`): sheet-wide shortcuts now. And the importer's test picked one file, so a handoff of the first alone passed: it picks two and checks their order. +- 4.3b round 2: FAIL, one finding, in the tests: the refusal test never showed its sheet, so Return reached no shortcut and the guard in `create` went unproven. The sheet is shown and active there now, and each refused size is pressed by button, by Return in the width, by Enter in the height and by Return with a button in focus; the guard's three mutants die. +- 4.3b round 3: PASS. +- 4.3c round 1: FAIL, five findings. The `closeConfirmed` mark stayed on the window, so a window shown again with new work closed unasked: the mark is cleared when consumed. The strip scrolled to the front tab before the row was laid out, so a tab beyond the viewport stayed out of sight; and the fix showed the worse fault under it: the resizable scroll area squeezed every tab to 40 pixels instead of scrolling; the row is sized to its tabs by hand before the scroll, tested with eleven tabs. The editor kept its 520-pixel minimum under the toolbar, so at 800 × 520 the status bar sat below the window: the window owns the minimum now, asserted with the status bar inside. The title stayed `Compositor` and lacked Qt's `[*]`, so `setWindowModified` warned: the title is the front tab's with the placeholder. And the launch test called `receive` itself, so `main` was untested: it now starts the built app with a project and a picture and reads their arrival in its log. +- 4.3c round 2: FAIL, two findings. A title's late layout widened the row after the scroll, leaving the front tab's close button outside the viewport: the row's layout request scrolls again, tested with a front title that grows at the far end. And the launch test read arrival but not order: it asserts the project's log line before the picture's; `main` with the arguments reversed fails it. +- 4.3c round 3: PASS. +- 4.3d round 1: FAIL, five findings. Focus changes never reached the menus, so Undo stayed disabled in the welcome's field and stale after leaving the zoom field: the menus sync on `QApplication::focusChanged`, tested without a `notify()`. A `QPlainTextEdit` branch advertised a text undo that both handlers lacked: gone. Three test gaps: Actual Pixels was triggered at 100% (the zoom is halved first now), Import Images' importing gate went untested, and every multi-selection carried a chosen mask, so the plural title passed as the singular (a bare pair asserts it). +- 4.3d round 2: FAIL, one finding. Alt+E opens Edit from the keyboard and the menu bar takes focus, so the field's Undo went to the document's: the menus keep the last field to hold focus while the bar borrows it (`focusMoved`), and a test opens Edit with Alt+E over the welcome and over a canvas, undoes through the field, and sees focus come back. +- 4.3d round 3: FAIL, one finding. The borrowed-focus test undid through the field but never redid, so Redo reverting to the focus widget passed: the test now triggers Redo while the bar holds focus and sees the text return; that mutant is caught. By the third-round rule the reviews of 4.3d end here; the fix joins 4.4's review scope. +- 4.4 round 1: FAIL, four findings. The pixel grid's path filled odd-even, so a crossing of two hairlines stayed unfilled (Swift fills the union): `Qt::WindingFill`, with a test half a point over where each line fills a whole pixel and their crossing fills once. Swift's window-wide key monitor was missing: an application event filter, installed while the canvas is shown, steps the blend mode on Shift-+ and Shift-− from any control of the window but a text field, and reads Alt for the Zoom cursor from any control, where the canvas alone saw its own keys; tested from the canvas, a button, a field, another window, and hidden. The smoothing test accepted the shadow's and the border's blends: it now reads the two pixels astride the boundary at 190%. +- 4.4 round 2: FAIL, four findings. Alt held before the canvas mounted, or released while it was hidden, left the Zoom cursor wrong: `showEvent` reads `QApplication::keyboardModifiers()` as Swift reads the flags when mounting, tested through the window's own key events for both sequences. The canvas slot's grid kept Qt's nine-point margins, a gutter Swift's canvas has not: zero now, and the view test holds the canvas to its container. The high-DPI surface test grabbed the child alone, whose grab drops the window offset: it grabs the window too and reads the child's corners there, so a surface placed by `combinedTransform` fails. The navigation tests zoomed about the viewport's centre, the default anchor: their pointers sit off-centre and the document point under each stays put. +- 4.4 round 3: FAIL, one finding. An Alt release delivered to an open menu, a window of its own, missed the canvas's window guard and left the Zoom cursor on the minus: the filter reads Alt from every window, as Swift's application-wide modifier monitor does, and keeps the window guard for the blend keys; the test pops a menu, releases Alt into it and sees the plus; the guard put back before Alt fails it. By the third-round rule the reviews of 4.4 end here; the fix joins 4.5's review scope. +- 4.5 round 1: FAIL, six findings. A malformed file at launch left Qt's own palette (the built-in dark is installed before the first load); a file that exists but cannot be opened counted as absent and dropped the theme (absence and a failed open are told apart; a failed open keeps the palette with a warning); single-quoted values were refused (either quote reads); a state directory that did not exist at launch was never found (the nearest existing ancestor is watched until it appears, then dropped); the tab strip's row kept the fill `setWidget` turns on (off now, with the margin's pixel held to the toolbar's); the built-in `Base` had no assertion of its own. +- 4.5 round 2: FAIL, four findings. A file replaced atomically under a held handle left the watcher on the old inode, so a later edit went unseen (the file's watch is renewed on each load); a folder without search permission made the file look absent and dropped the theme (`stat` tells ENOENT from a barred path; the latter keeps the palette); keys in quotes, valid TOML, were refused (keys read in either quote); a malformed `orange` fell to yellow in silence (a recognised key with a bare or half-quoted value refuses the file). +- 4.7 round 3: FAIL, three findings. The mask press's release filter cleared the press on the window's copy of the release before the button's own (a release seen on a `QWindow` no longer counts); another button's release ended a pending mask press (only the left one's does); the autoscroll's stop on leave and drop was untested (`autoscrolling()` is read before and after both). Tests in `LayerDragGestureTests` (Alt and Alt+Shift clicks through the window, a middle button mid-press) and `LayerDragTests`. By the third-round rule the reviews of 4.7 end here; the three fixes join 5.1's review scope. +- 5.1 round 1: FAIL, one finding. The merge tests never baked a mask, a folder mask, a blend mode or a clipping stack inside the merge, so dropping any of them from the flat copy passed. `LayerMergeTests::masksBlendsAndClipsBakeAsTheCanvasShowsThem` compares the live composite before and after each such merge and pins pixels; five mutants that clear a field of the copy are caught. +- 5.1 round 2: FAIL, three findings. The linked-mask flip asserted only the mask's centre and size (its corners through `unitToDocument` are pinned now, and mutants that zero the rotation or the flip of the followed placement are caught); the trim's sampling assertion compared defaults (the fixture is `nearest` now, a mutant forcing `high` is caught); one test comment ran to eleven words. +- 5.1 round 3: PASS. +- 6.1 round 1: FAIL, six findings. A menu borrowing a field's focus reformatted its typing (`12.` became `12`); Return and Escape did nothing from the bar's other controls; a snapped drag released under a Ctrl+T edit left its guide drawn; a Ctrl released while the canvas was hidden stayed held; Space mid-drag replaced the drag's cursor; the lock's icon kept its colour through a theme switch. Tests in `TransformInspectorTests`, `TransformPressTests` (paint events) and `CursorTests`. +- 6.1 round 2: FAIL, three findings. No canvas drag carried Shift (a Shift-resize of a locked corner now asserts 150 × 125 where the lock would give 138 × 138); no test pinched mid-drag (a `ZoomNativeGesture` during a drag now leaves the viewport as it was); one comment ran to twelve words. +- 6.1 round 3: PASS. +- 6.2 round 1: FAIL, four findings, all in `Distort.cpp`. The folded halves' path clip went soft once the image drew antialiased (a device-pixel region now, as the tiled renderer's) and drew source-over with smoothing where Swift copies (`BrushRaster::draw`); a placed linked mask under a folded shape previewed at its followed placement where the commit keeps the stored one; a mask warp that threw discarded the layer's preview where Swift catches it alone. Tests in `DistortTests` (a seam-free diagonal, a half-transparent fold, the stored placement, a mask too wide to warp). +- 6.2 round 2: FAIL, three findings. The integer polygon region dropped edge pixels a hard path clip keeps (the region is built per scanline from the triangle's floating-point edges now); `commitMaskTransform` made the mask's asset inside its transaction, so a throw left it open (the asset comes first now); no test distorted a placed linked mask under a convex shape. Tests in `DistortTests`. +- 6.2 round 3: FAIL, three findings. The two halves met a fractional shared edge from opposite ends, so rounding left holes along the diagonal (each edge's ends are ordered by y now); the transaction test threw before the asset was made (`DistortTests` now takes over `QImage::scaled` in its executable, as `ProjectReplaceTests` takes over libc, and fails the thumbnail after a successful warp); the widened right bound changed pixels beside the diagonal unseen (a patterned source pins them). By the third-round rule the reviews of 6.2 end here; the three fixes join 7.1's review scope. +- 7.1 round 1: FAIL, seven findings. An invert landing after `clearProject` read the missing document (the index is looked up only while a document exists, tested with a project cleared mid-invert); the mask half of the identity guard had no test (a mask replaced mid-invert keeps its gray and records nothing); the hard-clip test sampled beside the diagonal (it samples the pixel centres on it); the lasso tests could not tell winding from odd-even (the same square traced twice fills); `PixelAdjust::coverage`'s smooth sampling had no test (a clip enlarged 2× shows a soft boundary); the mask thumbnail's tooltip lacked Swift's Ctrl words (and the pixel thumbnail's is `configure`'s "Select image pixels", as Swift's `configure` overwrites the longer text); two record reads used `*` and `->` (now `.value()`). The three 6.2 fixes drew no finding. +- 7.1 round 2: FAIL, two findings. A selection move dragged on after `clearProject` read the missing document (`moveSelection` returns without one, tested with a project cleared mid-drag); a painted asset whose flatten fails threw past the invert's cleanup, leaving the project busy with no error and no callback (the image is fetched inside the try; a 48 MB raster starved by `AddressSpaceLimit` reports, frees the project and completes). +- 7.1 round 3: FAIL, one finding, the last round allowed. The two loaders let a painted asset's flatten failure escape their handlers (`ExportError` out of a Ctrl-click or a menu entry): each fetches the pixels inside a try, warns on `compositor.app` and returns, tested with two 48 MB rasters starved by `AddressSpaceLimit`. By the third-round rule the reviews of 7.1 end here; the fix joins 7.2's review scope. +- 7.2 round 1: FAIL, nine findings. A pan left the ants' old rect cached, so a later deselect repainted the wrong place (the overlay's rect is refreshed with every full repaint); the amount field lost its typing to a menu (borrowed focus keeps it, as `TransformValueField`); a double click while Space panned closed a polygonal outline (`!m_spaceHeld`); a held M or L's repeats chose their tools again, ending a fresh draft (repeats are swallowed, as Swift ignores them); Space mid-drag put the hand over a moving outline (the outline drag keeps its pointer like the transform drag); the ants and the draft were drawn aliased (antialiased now, as CoreGraphics draws them); the wand cursor was the rail's icon (Swift's own paths: hot spot 7, 7, the stick down right, the badge at 17, 5); the ants test could not see a missing repaint or a running timer (a paint spy counts the ticks and their rect, then their absence); the autoscroll test held no curve (each tick's 4.8 points and the 40-point cap are pinned). +- 7.2 round 2: FAIL, five findings. An ants tick repainted the whole overlay rect, the Move tool's box included (a tick repaints `selectionRect` alone, tested with the box around a layer); Qt's square caps stretched each dash by its width where CoreGraphics' butt caps do not (`FlatCap` on the ants and the cursors' dashed boxes; a 2× row shows eight on, eight off); the wand test took transparent black for a stick (the pixel's alpha is asserted); the autoscroll interval had no test (16 ms and the ants' 120 ms are read from the named timers); a popup's borrowed focus had no test (the amount field is tried with `PopupFocusReason` too). +- 7.2 round 3: FAIL, three findings, the last round allowed. The amount field's validator refused a fourth digit where Swift's binding clamps (digits only now, the bounds clamp; 1000 typed reads 500); the draft's pens kept Qt's square caps (butt caps, tested at 2× beyond an open endpoint); the cursors' dashed boxes had no test of their caps (their white gaps are counted at 2×). By the third-round rule the reviews of 7.2 end here; the three fixes join 7.3's review scope. +- 4.7 round 2: FAIL, six findings. A spent press stayed armed for presses that arm nothing (every left press first drops the earlier arm); Ctrl mid-drag made a local drop a duplicate (the list duplicates only when a copy is offered alone, and sets Move or Copy itself); an Alt mask press released in a popup lived on (its release is watched application-wide, as the eye's); the project targets accepted every move after a valid enter (a move is judged again and ignored when invalid); autoscrolling stopped with the pointer (a timer scrolls on until the pointer moves back inside or the drag ends); an Alt click that clipped left the old cursor (the cursor is set for the next click at once). Tests in `LayerDragTests`, `LayerDragGestureTests` (the gesture half, split off at 500 lines) and `ProjectDropTests`. +- 4.7 round 1: FAIL, nine findings. Thumbnail presses never armed a row drag (they arm it after targeting, as Swift's `mouseDown` falls through to the table); a stranger list's rows with the same ids moved local layers (a drag carries its list's token, a drop takes only its own); an Alt press on the clipping strip armed a copy drag (a press that clipped arms nothing); a spent press dragged from the middle button (a drag needs the left button held); drags never autoscrolled (the list scrolls by the overshoot into a 16-point margin); hover over rows and Ctrl changes left the cursor stale (every cell tracks the mouse, the filter reads hover, Alt and Ctrl, the last hover point is kept); plain drags offered Move alone while project targets copy (Move and Copy offered, project targets accept Copy); a malformed mask id could name an all-zero layer (`ProjectWorkspace::layerID` parses it). `LayerDragTests` defines `QDrag::exec` in the test executable, so presses, thresholds and actions are tested to the call; 24 mutants over the fixes. +- 4.6 round 1: FAIL, ten findings. An eye swipe cut short by a row rebuild left its transaction open (the button's destructor ends it from the event loop); a fractional opacity was applied twice, rounded the second time (Return and Escape only clear focus, the FocusOut applies once); Alt+E committed a rename and the opacity field (a FocusOut by the menu bar or a popup is no leaving); thumbnail clicks targeted before reading Shift and Alt (Swift's `mouseDown` order restored); a cancelled blend menu kept its target and refused the next keyboard choice (the queued close clears it); panel icons kept the old theme's colours (icons redraw on `PaletteChange`); Shift+Down replaced the selection and Down walked out of view (extend, and `ensureWidgetVisible`); eye swipes never autoscrolled (scroll by the overshoot); a double click on a pixel thumbnail did not rename (the thumbnail leaves it to the row); names were read as HTML (`Qt::PlainText`). Each has a test in `LayerListFocusTests` or the existing files; 16 mutants over the fixes all die. +- 4.6 round 2: FAIL, four findings. A swipe whose release landed in a popup opened mid-press stayed open (the eye watches the left button's release application-wide, as Swift's tracking loop misses none); Shift+Up after two Shift+Downs dropped the middle row (Shift ranges from the anchor, the far end of the selected run); Alt-click on a mask thumbnail chose nothing (it chooses the mask on a release without a drag, as `dragMaskCopy` does); a Shift double click on a mask renamed the layer (a double click with a modifier is a second press of the button's own). Tests in `LayerListFocusTests`; the mutants over the fixes all die. +- 4.6 round 3: FAIL, four findings. A slider drag whose release landed in a popup opened mid-drag kept its opacity step open (the controls watch the left button's release application-wide while the slider is down and let it go, so `sliderReleased` finishes the edit); a middle button's release ended an eye swipe (only the left one's does, as Swift waits for it); an Alt double click on the clipping strip renamed the layer (the second press clips again, as Swift's two `mouseDown`s); Alt+Shift on a mask targeted it without toggling (`selectMask`'s Shift toggle runs on the release). Tests in `LayerListFocusTests`, driven through the window's event path; the mutants over the fixes all die. By the third-round rule the reviews of 4.6 end here; the four fixes join 4.7's review scope. +- 4.5 round 3: FAIL, two findings. A state directory that could not be watched (no permission) was never found again once opened: `watch` climbs to the nearest ancestor `QFileSystemWatcher` accepts and lets it go once the directory is watched, tested with a barred `current/` opened later. A read cut short by an error applied its valid prefix as the theme: the bytes read are checked against the error state and the file's size, as `ProjectStore::load` checks its manifest, tested with a `read` defined in the test executable that gives 64 bytes and then `EIO`. By the third-round rule the reviews of 4.5 end here; both fixes join 4.6's review scope. Widgets take a changed application palette from the event loop: tests of a kept palette read `QApplication::palette()`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..388599b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790323409, + "narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ab7a4b3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + # OmaPhoto: Compositor for Linux, built with CMake. + description = "OmaPhoto, layered image editing and compositing for Linux"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + outputs = { self, nixpkgs }: + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + # Remove Background's model, pinned by its checksum. + model = pkgs.fetchurl { + url = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx"; + sha256 = "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491"; + }; + in { + packages.x86_64-linux.default = pkgs.stdenv.mkDerivation { + pname = "omaphoto"; + version = "1.0.0"; + src = self; + nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ]; + buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 fontconfig onnxruntime ]; + cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ]; + ninjaFlags = [ "omaphoto" ]; + # Staged: profiles build MIME caches outside the store. + installPhase = '' + runHook preInstall + DESTDIR=/ cmake --install . --prefix $out + runHook postInstall + ''; + }; + # The tests' shell: plugins, fonts and the model found at run time. + devShells.x86_64-linux.default = pkgs.mkShell { + inputsFrom = [ self.packages.x86_64-linux.default ]; + QT_PLUGIN_PATH = "${pkgs.qt6.qtimageformats}/lib/qt-6/plugins:${pkgs.qt6.qtbase}/lib/qt-6/plugins"; + FONTCONFIG_FILE = pkgs.makeFontsConf { + fontDirectories = [ pkgs.dejavu_fonts ]; + # fontconfig's own rules, which NixOS keeps in /etc. + includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + }; + XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share"; + }; + }; +} diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c new file mode 100644 index 0000000..90b76b2 --- /dev/null +++ b/kernels/AdjustPixels.c @@ -0,0 +1,96 @@ +#include "AdjustPixels.h" +#include <math.h> + +void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table) { + for (size_t y = 0; y < height; y++) { + uint8_t *p = rgba + y * stride; + for (size_t x = 0; x < width; x++, p += 4) { + unsigned a = p[3]; + if (a == 0) continue; + unsigned r = p[0], g = p[1], b = p[2]; + if (a < 255) { + r = (r * 255u + a / 2) / a; + g = (g * 255u + a / 2) / a; + b = (b * 255u + a / 2) / a; + if (r > 255) r = 255; + if (g > 255) g = 255; + if (b > 255) b = 255; + } + unsigned level = (2126u * r + 7152u * g + 722u * b + 5000u) / 10000u; + const uint8_t *color = table + (level > 255 ? 255 : level) * 3; + p[0] = (uint8_t)((color[0] * a + 127u) / 255u); + p[1] = (uint8_t)((color[1] * a + 127u) / 255u); + p[2] = (uint8_t)((color[2] * a + 127u) / 255u); + } + } +} + +static inline uint32_t mix32(uint32_t x) { + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +// A value in −1…1 for an integer lattice point, fixed by the point and the seed. Two uniform halves +// summed give a triangular spread, closer to film grain than flat noise. +static inline float lattice(int64_t ix, int64_t iy, uint32_t seed) { + uint32_t h = mix32((uint32_t)ix * 0x9E3779B1U ^ mix32((uint32_t)iy * 0x85EBCA77U ^ seed)); + return (float)(h & 0xFFFFU) / 65535.0f + (float)(h >> 16) / 65535.0f - 1.0f; +} + +static inline float clamp255(float value) { return value < 0 ? 0 : value > 255 ? 255 : value; } + +void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size, + double roughness, uint32_t seed, double originX, double originY, double unitsPerPixel) { + if (!(amount > 0) || !(unitsPerPixel > 0)) return; + if (!(size > 0)) size = 1; + float strength = (float)(amount > 100 ? 1.0 : amount / 100.0) * 0.35f * 255.0f; + float rough = (float)(roughness < 0 ? 0.0 : roughness > 100 ? 1.0 : roughness / 100.0); + uint32_t fineSeed = mix32(seed ^ 0xA511E9B3U); + for (size_t y = 0; y < height; y++) { + double v = originY + ((double)y + 0.5) * unitsPerPixel; + double cellY = floor(v / size); + float ty = (float)(v / size - cellY); + ty = ty * ty * (3.0f - 2.0f * ty); + int64_t iy = (int64_t)cellY, fineY = (int64_t)floor(v); + uint8_t *p = rgba + y * stride; + for (size_t x = 0; x < width; x++, p += 4) { + unsigned a = p[3]; + if (a == 0) continue; + double u = originX + ((double)x + 0.5) * unitsPerPixel; + double cellX = floor(u / size); + float tx = (float)(u / size - cellX); + tx = tx * tx * (3.0f - 2.0f * tx); + int64_t ix = (int64_t)cellX; + float n00 = lattice(ix, iy, seed), n10 = lattice(ix + 1, iy, seed); + float n01 = lattice(ix, iy + 1, seed), n11 = lattice(ix + 1, iy + 1, seed); + float top = n00 + (n10 - n00) * tx, bottom = n01 + (n11 - n01) * tx; + // Blending neighbors narrows the spread; scaling restores about the lattice's own. + float smooth = (top + (bottom - top) * ty) * 1.6f; + float fine = lattice((int64_t)floor(u), fineY, fineSeed); + float noise = smooth + (fine - smooth) * rough; + float unpremultiply = a == 255 ? 1.0f : 255.0f / (float)a; + float r = p[0] * unpremultiply, g = p[1] * unpremultiply, b = p[2] * unpremultiply; + float level = (0.2126f * r + 0.7152f * g + 0.0722f * b) / 255.0f; + if (level > 1) level = 1; + // Film grain shows most in the midtones. + float delta = noise * strength * (0.4f + 2.4f * level * (1.0f - level)); + float coverage = (float)a / 255.0f; + p[0] = (uint8_t)(clamp255(r + delta) * coverage + 0.5f); + p[1] = (uint8_t)(clamp255(g + delta) * coverage + 0.5f); + p[2] = (uint8_t)(clamp255(b + delta) * coverage + 0.5f); + } + } +} + +void rgba_clamp_premultiplied(uint8_t *rgba, size_t count) { + for (size_t i = 0; i < count; i++, rgba += 4) { + uint8_t a = rgba[3]; + if (rgba[0] > a) rgba[0] = a; + if (rgba[1] > a) rgba[1] = a; + if (rgba[2] > a) rgba[2] = a; + } +} diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h new file mode 100644 index 0000000..9abeb3e --- /dev/null +++ b/kernels/AdjustPixels.h @@ -0,0 +1,19 @@ +#ifndef AdjustPixels_h +#define AdjustPixels_h +#include <stdint.h> +#include <stddef.h> +// Gradient Map on premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row): each pixel's +// luminance picks a color from `table` (256 × 3 straight sRGB bytes, darkest first). Alpha is kept and +// fully transparent pixels are left alone. +void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table); +// Film grain on premultiplied RGBA pixels: the same brightness change on all three channels, strongest +// in the midtones. `amount` is 0–100, `size` the grain's scale in document units, and `roughness` +// (0–100) mixes in per-unit noise. Pixel (x, y) sits at (originX + (x + 0.5) × unitsPerPixel, +// originY + (y + 0.5) × unitsPerPixel), and its grain depends only on that position and `seed`, so a +// piece of an image gets the same grain as that part of the whole. +void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size, + double roughness, uint32_t seed, double originX, double originY, double unitsPerPixel); +// After resampling with a filter that rings (Lanczos), premultiplied RGBA colors can exceed their alpha; +// this clamps each channel back to its pixel's alpha. `count` is the number of pixels. +void rgba_clamp_premultiplied(uint8_t *rgba, size_t count); +#endif diff --git a/kernels/BrushPixels.c b/kernels/BrushPixels.c new file mode 100644 index 0000000..e0be3cf --- /dev/null +++ b/kernels/BrushPixels.c @@ -0,0 +1,50 @@ +#include "BrushPixels.h" + +void brush_alpha_bounds(const uint8_t *bytes, size_t width, size_t height, size_t stride, size_t bounds[4]) { + size_t left = width, right = 0, top = height, bottom = 0; + for (size_t y = 0; y < height; ++y) { + const uint8_t *row = bytes + y * stride; + size_t first = 0; + while (first < width && row[first * 4 + 3] == 0) ++first; + if (first == width) continue; + size_t last = width; + while (last > first && row[(last - 1) * 4 + 3] == 0) --last; + if (first < left) left = first; + if (last > right) right = last; + if (y < top) top = y; + bottom = y + 1; + } + bounds[0] = right ? left : 0; + bounds[1] = right ? top : 0; + bounds[2] = right; + bounds[3] = bottom; +} + +void layer_unpremultiply_opaque(uint8_t *rgba, size_t stride, size_t width, size_t height) { + for (size_t y = 0; y < height; ++y) { + uint8_t *p = rgba + y * stride; + for (size_t x = 0; x < width; ++x, p += 4) { + unsigned a = p[3]; + for (int c = 0; c < 3; ++c) { + unsigned v = a ? (p[c] * 255u + a / 2) / a : 0; + p[c] = v > 255 ? 255 : v; + } + p[3] = 255; + } + } +} +void layer_restore_alpha(uint8_t *rgba, size_t stride, const uint8_t *alpha, size_t alphaStride, size_t width, size_t height) { + for (size_t y = 0; y < height; ++y) { + uint8_t *p = rgba + y * stride; + for (size_t x = 0; x < width; ++x, p += 4) { + unsigned a = alpha[y * alphaStride + x]; + for (int c = 0; c < 3; ++c) p[c] = (p[c] * a + 127) / 255; + p[3] = a; + } + } +} +void layer_extract_alpha(const uint8_t *rgba, size_t rgbaStride, uint8_t *gray, size_t grayStride, size_t width, size_t height) { + for (size_t y = 0; y < height; ++y) + for (size_t x = 0; x < width; ++x) + gray[y * grayStride + x] = rgba[y * rgbaStride + x * 4 + 3]; +} diff --git a/kernels/BrushPixels.h b/kernels/BrushPixels.h new file mode 100644 index 0000000..fba5916 --- /dev/null +++ b/kernels/BrushPixels.h @@ -0,0 +1,10 @@ +#ifndef BrushPixels_h +#define BrushPixels_h +#include <stdint.h> +#include <stddef.h> +// Half-open bounds of nonzero alpha in premultiplied RGBA. Empty returns all zero. +void brush_alpha_bounds(const uint8_t *bytes, size_t width, size_t height, size_t stride, size_t bounds[4]); +void layer_extract_alpha(const uint8_t *rgba, size_t rgbaStride, uint8_t *gray, size_t grayStride, size_t width, size_t height); +void layer_unpremultiply_opaque(uint8_t *rgba, size_t stride, size_t width, size_t height); +void layer_restore_alpha(uint8_t *rgba, size_t stride, const uint8_t *alpha, size_t alphaStride, size_t width, size_t height); +#endif diff --git a/kernels/ContentFill.c b/kernels/ContentFill.c new file mode 100644 index 0000000..e2ee057 --- /dev/null +++ b/kernels/ContentFill.c @@ -0,0 +1,93 @@ +#include "ContentFill.h" +#include <stdlib.h> +#include <string.h> +#include <float.h> + +static uint32_t next_random(uint32_t *state) { + *state = *state * 1664525u + 1013904223u; + return *state; +} +static double match(const uint8_t *pixels, size_t stride, const uint8_t *known, + int w, int h, int p, int q, int radius) { + int px=p%w, py=p/w, qx=q%w, qy=q/w, count=0; + double sum=0; + for(int dy=-radius; dy<=radius; ++dy) for(int dx=-radius; dx<=radius; ++dx) { + int x=px+dx,y=py+dy,sx=qx+dx,sy=qy+dy; + if(x<0||y<0||x>=w||y>=h||sx<0||sy<0||sx>=w||sy>=h||!known[y*w+x]) continue; + const uint8_t *a=pixels+y*stride+x*4, *b=pixels+sy*stride+sx*4; + for(int c=0;c<4;++c) { int d=a[c]-b[c]; sum+=d*d; } + ++count; + } + return count ? sum/count : DBL_MAX; +} +int content_fill(uint8_t *pixels, size_t stride, const uint8_t *mask, size_t ms, int w, int h) { + size_t n=(size_t)w*h; + uint8_t *known=calloc(n,1), *target=calloc(n,1), *valid=calloc(n,1), *queued=calloc(n,1); + int *donors=malloc(n*sizeof(int)), *queue=malloc(n*sizeof(int)), *chosen=malloc(n*sizeof(int)); + if(!known||!target||!valid||!queued||!donors||!queue||!chosen) { + free(known);free(target);free(valid);free(queued);free(donors);free(queue);free(chosen);return -1; + } + int radius=(w>=5 && h>=5)?2:0; + size_t missing=0, donorCount=0, head=0,tail=0, scan=0; + // Selected pixels are filled. Unselected opaque pixels are the image to match and copy from; unselected + // transparent ones are neither — nothing to match against, and left as they are. + for(int y=0;y<h;++y) for(int x=0;x<w;++x) { + int p=y*w+x; target[p]=mask[y*ms+x]!=0; known[p]=!target[p] && pixels[y*stride+x*4+3]==255; chosen[p]=-1; + if(target[p]) ++missing; + } + if(!missing) { donorCount=1; goto done; } + for(int y=0;y<h;++y) for(int x=0;x<w;++x) { + int p=y*w+x; + if(!known[p]) continue; + int ok=1; + for(int dy=-radius;dy<=radius&&ok;++dy) for(int dx=-radius;dx<=radius;++dx) { + int sx=x+dx,sy=y+dy; + if(sx<0||sy<0||sx>=w||sy>=h||!known[sy*w+sx]) {ok=0;break;} + } + if(ok) { valid[p]=1;donors[donorCount++]=p; } + } + if(!donorCount) goto done; + for(int y=0;y<h;++y) for(int x=0;x<w;++x) { + int p=y*w+x; + if(target[p] && ((x&&known[p-1])||(x+1<w&&known[p+1])||(y&&known[p-w])||(y+1<h&&known[p+w]))) { + queue[tail++]=p;queued[p]=1; + } + } + uint32_t seed=0x6d2b79f5; + for(;;) { + while(head<tail) { + int p=queue[head++],x=p%w,y=p/w,best=-1; + double score=DBL_MAX; + int neighbors[4]={x?p-1:-1,x+1<w?p+1:-1,y?p-w:-1,y+1<h?p+w:-1}; + // Propagate coherent source offsets, then refine with randomized patch search. + for(int k=0;k<28;++k) { + int q=-1; + if(k<4) { + int t=neighbors[k]; + if(t>=0) q=(chosen[t]>=0?chosen[t]:t)+(p-t); + } else q=donors[next_random(&seed)%donorCount]; + if(q<0||(size_t)q>=n||!valid[q]) continue; + double s=match(pixels,stride,known,w,h,p,q,radius); + if(best<0||s<score) {score=s;best=q;} + } + if(best<0) best=donors[0]; + for(int r=64;r>=1;r/=2) { + int qx=best%w+(int)(next_random(&seed)%(2*r+1))-r; + int qy=best/w+(int)(next_random(&seed)%(2*r+1))-r; + if(qx<0||qy<0||qx>=w||qy>=h||!valid[qy*w+qx]) continue; + int q=qy*w+qx; double s=match(pixels,stride,known,w,h,p,q,radius); + if(s<score) {score=s;best=q;} + } + memcpy(pixels+y*stride+x*4,pixels+(best/w)*stride+(best%w)*4,4); + known[p]=1;chosen[p]=best; + for(int k=0;k<4;++k) { int q=neighbors[k]; if(q>=0&&target[q]&&!known[q]&&!queued[q]) {queued[q]=1;queue[tail++]=q;} } + } + // A selected area that only transparency touches starts from the best random donor, then spreads. + while(scan<n && (!target[scan]||known[scan])) ++scan; + if(scan>=n) break; + queue[tail++]=(int)scan; queued[scan]=1; + } +done: + free(known);free(target);free(valid);free(queued);free(donors);free(queue);free(chosen); + return donorCount ? 1 : 0; +} diff --git a/kernels/ContentFill.h b/kernels/ContentFill.h new file mode 100644 index 0000000..a459b5f --- /dev/null +++ b/kernels/ContentFill.h @@ -0,0 +1,4 @@ +#include <stddef.h> +#include <stdint.h> +// Returns 1 on success, 0 when no source patch exists, -1 on allocation failure. +int content_fill(uint8_t *rgba, size_t stride, const uint8_t *mask, size_t maskStride, int width, int height); diff --git a/kernels/HealPixels.c b/kernels/HealPixels.c new file mode 100644 index 0000000..4f5f632 --- /dev/null +++ b/kernels/HealPixels.c @@ -0,0 +1,259 @@ +#include "HealPixels.h" +#include <math.h> +#include <stdlib.h> +#include <string.h> + +enum { OUTSIDE = 0, RING = 1, HOLE = 2 }; + +void heal_coverage_bounds(const uint8_t *gray, size_t width, size_t height, size_t stride, long bounds[4]) { + long x0 = (long)width, y0 = (long)height, x1 = 0, y1 = 0; + for (size_t y = 0; y < height; ++y) { + const uint8_t *row = gray + y * stride; + for (size_t x = 0; x < width; ++x) { + if (!row[x]) continue; + if ((long)x < x0) x0 = (long)x; + if ((long)x + 1 > x1) x1 = (long)x + 1; + if ((long)y < y0) y0 = (long)y; + if ((long)y + 1 > y1) y1 = (long)y + 1; + } + } + if (x1 <= x0 || y1 <= y0) x0 = y0 = x1 = y1 = 0; + bounds[0] = x0; bounds[1] = y0; bounds[2] = x1; bounds[3] = y1; +} + +static inline uint32_t heal_hash(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352dU; + x ^= x >> 15; x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} +static inline double heal_unit(uint32_t key) { return (double)(heal_hash(key) >> 8) / 16777216.0; } + +// Mean squared difference between the ring around the spot and the ring around the patch +// offset by (dx, dy). Infinite when the patch would overlap the spot or leave the image. +static double heal_score(const uint8_t *rgba, size_t stride, const uint8_t *role, long wx0, long wy0, + long ww, long wh, long dx, long dy, long W, long H) { + if (labs(dx) < ww && labs(dy) < wh) return INFINITY; + if (wx0 + dx < 0 || wy0 + dy < 0 || wx0 + ww + dx > W || wy0 + wh + dy > H) return INFINITY; + double sum = 0; + long n = 0; + for (long y = 0; y < wh; ++y) { + for (long x = 0; x < ww; ++x) { + if (role[y * ww + x] != RING) continue; + const uint8_t *t = rgba + (size_t)(wy0 + y) * stride + (size_t)(wx0 + x) * 4; + const uint8_t *s = rgba + (size_t)(wy0 + y + dy) * stride + (size_t)(wx0 + x + dx) * 4; + for (int c = 0; c < 4; ++c) { double d = (double)t[c] - s[c]; sum += d * d; } + ++n; + } + } + return n ? sum / n : INFINITY; +} + +// Solves for smooth values over HOLE pixels, fixed to the RING values around them. A coarser +// copy is solved first and used as the starting point, so large spots settle in few passes. +static void heal_solve(float *value, const uint8_t *role, long w, long h, int depth) { + int iterations = 300; + if (w > 32 && h > 32 && depth < 16) { + long cw = (w + 1) / 2, ch = (h + 1) / 2; + float *coarse = calloc((size_t)(cw * ch * 4), sizeof(float)); + uint8_t *coarseRole = calloc((size_t)(cw * ch), 1); + if (coarse && coarseRole) { + for (long y = 0; y < ch; ++y) { + for (long x = 0; x < cw; ++x) { + int known = 0, hole = 0; + float knownSum[4] = {0, 0, 0, 0}, holeSum[4] = {0, 0, 0, 0}; + for (long j = 0; j < 2; ++j) { + for (long i = 0; i < 2; ++i) { + long fx = x * 2 + i, fy = y * 2 + j; + if (fx >= w || fy >= h) continue; + long p = fy * w + fx; + if (role[p] == RING) { ++known; for (int c = 0; c < 4; ++c) knownSum[c] += value[p * 4 + c]; } + else if (role[p] == HOLE) { ++hole; for (int c = 0; c < 4; ++c) holeSum[c] += value[p * 4 + c]; } + } + } + long q = y * cw + x; + if (known) { coarseRole[q] = RING; for (int c = 0; c < 4; ++c) coarse[q * 4 + c] = knownSum[c] / known; } + else if (hole) { coarseRole[q] = HOLE; for (int c = 0; c < 4; ++c) coarse[q * 4 + c] = holeSum[c] / hole; } + } + } + heal_solve(coarse, coarseRole, cw, ch, depth + 1); + for (long y = 0; y < h; ++y) { + for (long x = 0; x < w; ++x) { + long p = y * w + x, q = (y / 2) * cw + x / 2; + if (role[p] == HOLE && coarseRole[q] == HOLE) memcpy(value + p * 4, coarse + q * 4, 4 * sizeof(float)); + } + } + iterations = 40; + } + free(coarse); + free(coarseRole); + } + const float omega = 1.8f; + for (int it = 0; it < iterations; ++it) { + for (long y = 0; y < h; ++y) { + for (long x = 0; x < w; ++x) { + long p = y * w + x; + if (role[p] != HOLE) continue; + float sum[4] = {0, 0, 0, 0}; + int n = 0; + long neighbors[4][2] = {{x - 1, y}, {x + 1, y}, {x, y - 1}, {x, y + 1}}; + for (int k = 0; k < 4; ++k) { + long nx = neighbors[k][0], ny = neighbors[k][1]; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + long q = ny * w + nx; + if (role[q] == OUTSIDE) continue; + for (int c = 0; c < 4; ++c) sum[c] += value[q * 4 + c]; + ++n; + } + if (!n) continue; + for (int c = 0; c < 4; ++c) value[p * 4 + c] += omega * (sum[c] / n - value[p * 4 + c]); + } + } + } +} + +int spot_heal(uint8_t *rgba, const uint8_t *coverage, size_t width, size_t height, size_t stride, + float opacity, int mode, uint32_t seed) { + long W = (long)width, H = (long)height; + long bounds[4]; + heal_coverage_bounds(coverage, width, height, width, bounds); + if (bounds[2] <= bounds[0]) return 0; + long bw = bounds[2] - bounds[0], bh = bounds[3] - bounds[1], size = bw > bh ? bw : bh; + long ring = size / 8; + if (ring < 2) ring = 2; + if (ring > 16) ring = 16; + // Work box: the spot plus its ring, clipped to the image. + long wx0 = bounds[0] - ring < 0 ? 0 : bounds[0] - ring, wy0 = bounds[1] - ring < 0 ? 0 : bounds[1] - ring; + long wx1 = bounds[2] + ring > W ? W : bounds[2] + ring, wy1 = bounds[3] + ring > H ? H : bounds[3] + ring; + long ww = wx1 - wx0, wh = wy1 - wy0, wn = ww * wh; + + uint8_t *role = calloc((size_t)wn, 1), *near = calloc((size_t)wn, 1); + long *prefix = malloc((size_t)((ww > wh ? ww : wh) + 1) * sizeof(long)); + float *value = malloc((size_t)wn * 4 * sizeof(float)); + int status = -1; + if (!role || !near || !prefix || !value) goto done; + for (long y = 0; y < wh; ++y) + for (long x = 0; x < ww; ++x) + role[y * ww + x] = coverage[(size_t)(wy0 + y) * width + (size_t)(wx0 + x)] ? HOLE : OUTSIDE; + // The ring: pixels within `ring` of the spot (a square dilation, row pass then column pass). + for (long y = 0; y < wh; ++y) { + prefix[0] = 0; + for (long x = 0; x < ww; ++x) prefix[x + 1] = prefix[x] + (role[y * ww + x] == HOLE); + for (long x = 0; x < ww; ++x) { + long lo = x - ring < 0 ? 0 : x - ring, hi = x + ring + 1 > ww ? ww : x + ring + 1; + near[y * ww + x] = prefix[hi] - prefix[lo] > 0; + } + } + for (long x = 0; x < ww; ++x) { + prefix[0] = 0; + for (long y = 0; y < wh; ++y) prefix[y + 1] = prefix[y] + near[y * ww + x]; + for (long y = 0; y < wh; ++y) { + long lo = y - ring < 0 ? 0 : y - ring, hi = y + ring + 1 > wh ? wh : y + ring + 1; + if (role[y * ww + x] == OUTSIDE && prefix[hi] - prefix[lo] > 0) role[y * ww + x] = RING; + } + } + long ringCount = 0; + for (long p = 0; p < wn; ++p) ringCount += role[p] == RING; + if (!ringCount) { status = 0; goto done; } + + // Source patch for Content-Aware and Proximity Match. + long ox = 0, oy = 0; + int haveSource = 0; + if (mode != 1) { + static const double factors[5] = {1.05, 1.35, 1.75, 2.25, 2.8}; + int count = mode == 2 ? 2 : 5; + double best = INFINITY; + for (int f = 0; f < count; ++f) { + for (int a = 0; a < 24; ++a) { + double angle = a * M_PI / 12.0; + long dx = lround(cos(angle) * factors[f] * ww), dy = lround(sin(angle) * factors[f] * wh); + double score = heal_score(rgba, stride, role, wx0, wy0, ww, wh, dx, dy, W, H); + if (!isfinite(score)) continue; + score *= mode == 2 ? 1.0 + 0.6 * f : 1.0 + 0.1 * f; // nearer patches win ties + if (score < best) { best = score; ox = dx; oy = dy; } + } + } + if (isfinite(best)) { + // Fine-tune the alignment so repeating texture lines up. + long cx = ox, cy = oy; + double refined = heal_score(rgba, stride, role, wx0, wy0, ww, wh, cx, cy, W, H); + for (long j = -3; j <= 3; ++j) { + for (long i = -3; i <= 3; ++i) { + double score = heal_score(rgba, stride, role, wx0, wy0, ww, wh, cx + i, cy + j, W, H); + if (score < refined) { refined = score; ox = cx + i; oy = cy + j; } + } + } + haveSource = 1; + } + } + + // Membrane: the edge difference between the original and the patch (or the original itself + // for a smooth fill), spread across the spot. + double mean[4] = {0, 0, 0, 0}, detail[3] = {0, 0, 0}; + for (long y = 0; y < wh; ++y) { + for (long x = 0; x < ww; ++x) { + long p = y * ww + x; + if (role[p] != RING) { memset(value + p * 4, 0, 4 * sizeof(float)); continue; } + long ix = wx0 + x, iy = wy0 + y; + const uint8_t *t = rgba + (size_t)iy * stride + (size_t)ix * 4; + const uint8_t *s = haveSource ? rgba + (size_t)(iy + oy) * stride + (size_t)(ix + ox) * 4 : NULL; + for (int c = 0; c < 4; ++c) { + value[p * 4 + c] = (float)t[c] - (s ? s[c] : 0); + mean[c] += value[p * 4 + c]; + } + if (!haveSource) { + // Fine detail around the spot: each pixel against the average of its neighbours. + for (int c = 0; c < 3; ++c) { + double around = 0; int n = 0; + long offsets[4][2] = {{ix - 1, iy}, {ix + 1, iy}, {ix, iy - 1}, {ix, iy + 1}}; + for (int k = 0; k < 4; ++k) { + if (offsets[k][0] < 0 || offsets[k][1] < 0 || offsets[k][0] >= W || offsets[k][1] >= H) continue; + around += rgba[(size_t)offsets[k][1] * stride + (size_t)offsets[k][0] * 4 + c]; + ++n; + } + if (n) { double d = t[c] - around / n; detail[c] += d * d; } + } + } + } + } + for (int c = 0; c < 4; ++c) mean[c] /= ringCount; + for (long p = 0; p < wn; ++p) + if (role[p] == HOLE) for (int c = 0; c < 4; ++c) value[p * 4 + c] = (float)mean[c]; + heal_solve(value, role, ww, wh, 0); + for (int c = 0; c < 3; ++c) detail[c] = sqrt(detail[c] / ringCount) * 0.9; + + for (long y = 0; y < wh; ++y) { + for (long x = 0; x < ww; ++x) { + long p = y * ww + x; + if (role[p] != HOLE) continue; + long ix = wx0 + x, iy = wy0 + y; + uint8_t *t = rgba + (size_t)iy * stride + (size_t)ix * 4; + const uint8_t *s = haveSource ? rgba + (size_t)(iy + oy) * stride + (size_t)(ix + ox) * 4 : NULL; + double amount = coverage[(size_t)iy * width + (size_t)ix] / 255.0 * opacity; + double grain = 0; + if (!haveSource) { + uint32_t key = heal_hash(seed ^ heal_hash((uint32_t)(iy * W + ix))); + double u1 = heal_unit(key), u2 = heal_unit(key ^ 0x68e31da4U); + grain = sqrt(-2.0 * log(1.0 - u1)) * cos(2.0 * M_PI * u2); + } + double out[4]; + for (int c = 0; c < 4; ++c) { + double healed = (s ? s[c] : 0) + value[p * 4 + c] + (c < 3 ? grain * detail[c] : 0); + out[c] = t[c] + (healed - t[c]) * amount; + } + double alpha = out[3] < 0 ? 0 : out[3] > 255 ? 255 : out[3]; + t[3] = (uint8_t)lround(alpha); + for (int c = 0; c < 3; ++c) { + double v = out[c] < 0 ? 0 : out[c] > t[3] ? t[3] : out[c]; + t[c] = (uint8_t)lround(v); + } + } + } + status = 0; +done: + free(role); + free(near); + free(prefix); + free(value); + return status; +} diff --git a/kernels/HealPixels.h b/kernels/HealPixels.h new file mode 100644 index 0000000..e1a9ee8 --- /dev/null +++ b/kernels/HealPixels.h @@ -0,0 +1,19 @@ +#ifndef HealPixels_h +#define HealPixels_h +#include <stdint.h> +#include <stddef.h> +// Half-open bounds of nonzero bytes in a gray bitmap; all zero when empty. +void heal_coverage_bounds(const uint8_t *gray, size_t width, size_t height, size_t stride, long bounds[4]); +// Spot healing, in place, over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row). +// `coverage` (width * height bytes, 0–255) marks what to heal. +// mode 0, Content-Aware: copies texture from the nearby patch whose surrounding ring of pixels +// best matches the ring around the spot; +// mode 1, Create Texture: fills smoothly from the spot's edges and adds grain matching the +// detail around it; +// mode 2, Proximity Match: like 0, but takes the closest good patch. +// Copied texture is blended so it meets the surrounding tone exactly: the difference along the +// spot's edge is spread smoothly across it (a membrane fill). The result replaces the original +// by coverage × opacity. Returns 0, or -1 when memory runs out. +int spot_heal(uint8_t *rgba, const uint8_t *coverage, size_t width, size_t height, size_t stride, + float opacity, int mode, uint32_t seed); +#endif diff --git a/kernels/LensPixels.c b/kernels/LensPixels.c new file mode 100644 index 0000000..5c7d0ef --- /dev/null +++ b/kernels/LensPixels.c @@ -0,0 +1,37 @@ +#include "LensPixels.h" +#include <math.h> + +void lens_distort(const uint8_t *source, uint8_t *destination, size_t width, size_t height, size_t stride, double k) { + double cx = width * 0.5, cy = height * 0.5; + double halfDiagonal2 = cx * cx + cy * cy; + for (size_t y = 0; y < height; ++y) { + double dy = y + 0.5 - cy; + uint8_t *out = destination + y * stride; + for (size_t x = 0; x < width; ++x) { + double dx = x + 0.5 - cx; + double scale = 1.0 - k * (dx * dx + dy * dy) / halfDiagonal2; + // Source position in pixel-center coordinates. + double sx = cx + dx * scale - 0.5, sy = cy + dy * scale - 0.5; + double fx0 = floor(sx), fy0 = floor(sy); + double fx = sx - fx0, fy = sy - fy0; + long x0 = (long)fx0, y0 = (long)fy0; + double sums[4] = {0, 0, 0, 0}; + for (int j = 0; j < 2; ++j) { + long row = y0 + j; + if (row < 0 || row >= (long)height) continue; + double wy = j ? fy : 1 - fy; + if (wy == 0) continue; + const uint8_t *line = source + (size_t)row * stride; + for (int i = 0; i < 2; ++i) { + long column = x0 + i; + if (column < 0 || column >= (long)width) continue; + double weight = wy * (i ? fx : 1 - fx); + if (weight == 0) continue; + const uint8_t *p = line + (size_t)column * 4; + for (int c = 0; c < 4; ++c) sums[c] += weight * p[c]; + } + } + for (int c = 0; c < 4; ++c) out[x * 4 + c] = (uint8_t)lround(sums[c]); + } + } +} diff --git a/kernels/LensPixels.h b/kernels/LensPixels.h new file mode 100644 index 0000000..e4f7aa4 --- /dev/null +++ b/kernels/LensPixels.h @@ -0,0 +1,12 @@ +#ifndef LensPixels_h +#define LensPixels_h +#include <stdint.h> +#include <stddef.h> +// Radial lens distortion over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row, same +// layout for source and destination). Each destination pixel samples the source bilinearly at +// its offset from the image center scaled by (1 - k * r²), where r is that offset relative to the +// half-diagonal: k > 0 pulls samples inward (straightens barrel distortion, corners crop), k < 0 +// pushes them outward (straightens pincushion distortion, corners turn transparent). Pixels +// outside the source are transparent. k = 0 copies the source exactly. +void lens_distort(const uint8_t *source, uint8_t *destination, size_t width, size_t height, size_t stride, double k); +#endif diff --git a/kernels/LevelsPixels.c b/kernels/LevelsPixels.c new file mode 100644 index 0000000..a744088 --- /dev/null +++ b/kernels/LevelsPixels.c @@ -0,0 +1,28 @@ +#include "LevelsPixels.h" +#include <math.h> +void levels_apply(uint8_t *pixels, size_t count, const float *tables) { + for (size_t i=0; i<count; ++i) { + uint8_t *p = pixels + i*4; + float alpha = p[3]; + if (!alpha) continue; + for (int channel=0; channel<3; ++channel) { + float x = fminf(255, p[channel]*255.0f/alpha); + int lo = (int)x, hi = lo < 255 ? lo+1 : 255; + const float *table = tables + channel*256; + float result = table[lo] + (table[hi]-table[lo])*(x-lo); + p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha))); + } + } +} +void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins) { + for (size_t i=0; i<count; ++i) { + const uint8_t *p = pixels+i*4; + if (!p[3]) continue; + double weight = p[3]/255.0 * (coverage ? coverage[i]/255.0 : 1); + for (int channel=0; channel<3; ++channel) { + int value = (int)fmin(255, round(p[channel]*255.0/p[3])); + bins[(channel+1)*256+value] += weight; + bins[value] += weight/3.0; + } + } +} diff --git a/kernels/LevelsPixels.h b/kernels/LevelsPixels.h new file mode 100644 index 0000000..2fbb385 --- /dev/null +++ b/kernels/LevelsPixels.h @@ -0,0 +1,4 @@ +#include <stdint.h> +#include <stddef.h> +void levels_apply(uint8_t *pixels, size_t count, const float *tables); +void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins); diff --git a/kernels/NoisePixels.c b/kernels/NoisePixels.c new file mode 100644 index 0000000..52b41fc --- /dev/null +++ b/kernels/NoisePixels.c @@ -0,0 +1,41 @@ +#include "NoisePixels.h" +#include <math.h> + +// A well-mixed 32-bit hash, so neighbouring pixels get unrelated values. +static inline uint32_t noise_hash(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352dU; + x ^= x >> 15; x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +// Uniform in [0, 1). +static inline float noise_unit(uint32_t key) { return (float)(noise_hash(key) >> 8) * (1.0f / 16777216.0f); } + +void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride, + float amount, int gaussian, int monochromatic, uint32_t seed) { + float spread = amount / 100.0f * 127.5f; + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + unsigned alpha = p[3]; + if (!alpha) continue; + uint32_t base = noise_hash(seed ^ noise_hash((uint32_t)(y * width + x))); + for (int c = 0; c < 3; ++c) { + uint32_t key = monochromatic ? base : base + (uint32_t)c * 0x9e3779b9U; + float n; + if (gaussian) { + // Box–Muller: two uniform values make one normally distributed one. + float u1 = noise_unit(key), u2 = noise_unit(key ^ 0x68e31da4U); + n = sqrtf(-2.0f * logf(1.0f - u1)) * cosf(6.2831853f * u2) * spread * (2.0f / 3.0f); + } else { + n = (noise_unit(key) * 2.0f - 1.0f) * spread; + } + float value = (float)p[c] * 255.0f / (float)alpha + n; + value = value < 0 ? 0 : value > 255 ? 255 : value; + p[c] = (uint8_t)lroundf(value * (float)alpha / 255.0f); + } + } + } +} diff --git a/kernels/NoisePixels.h b/kernels/NoisePixels.h new file mode 100644 index 0000000..17551d6 --- /dev/null +++ b/kernels/NoisePixels.h @@ -0,0 +1,12 @@ +#ifndef NoisePixels_h +#define NoisePixels_h +#include <stdint.h> +#include <stddef.h> +// Adds noise to the color of premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per +// row), leaving alpha untouched and fully transparent pixels alone. `amount` is Photoshop's +// percentage: uniform noise spans ±amount% of half the range, Gaussian noise has a standard +// deviation of two thirds of that. Monochromatic adds the same value to all three channels. +// Each pixel's noise depends only on its position and `seed`, so the same seed gives the same grain. +void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride, + float amount, int gaussian, int monochromatic, uint32_t seed); +#endif diff --git a/kernels/WandPixels.c b/kernels/WandPixels.c new file mode 100644 index 0000000..2e51225 --- /dev/null +++ b/kernels/WandPixels.c @@ -0,0 +1,174 @@ +#include "WandPixels.h" +#include <stdlib.h> +#include <string.h> + +enum { EAST = 1, SOUTH = 2, WEST = 4, NORTH = 8 }; +// Outlines with more pixel edges than this are refused: the path would be too slow to draw. +static const size_t wand_edge_limit = 8000000; + +static inline int wand_matches(const uint8_t *p, const int reference[4], int tolerance) { + for (int c = 0; c < 4; ++c) { + int d = (int)p[c] - reference[c]; + if (d < -tolerance || d > tolerance) return 0; + } + return 1; +} + +long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride, + size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask) { + if (!width || !height) return 0; + memset(mask, 0, width * height); + if (seedX >= width || seedY >= height) return 0; + size_t x0 = seedX > radius ? seedX - radius : 0, x1 = seedX + radius < width ? seedX + radius : width - 1; + size_t y0 = seedY > radius ? seedY - radius : 0, y1 = seedY + radius < height ? seedY + radius : height - 1; + unsigned long sums[4] = {0, 0, 0, 0}, samples = 0; + for (size_t y = y0; y <= y1; ++y) + for (size_t x = x0; x <= x1; ++x, ++samples) + for (int c = 0; c < 4; ++c) sums[c] += rgba[y * stride + x * 4 + c]; + int reference[4]; + for (int c = 0; c < 4; ++c) reference[c] = (int)((sums[c] + samples / 2) / samples); + + long count = 0; + if (!contiguous) { + for (size_t y = 0; y < height; ++y) { + const uint8_t *row = rgba + y * stride; + uint8_t *out = mask + y * width; + for (size_t x = 0; x < width; ++x) + if (wand_matches(row + x * 4, reference, tolerance)) { out[x] = 255; ++count; } + } + return count; + } + + // Scanline flood fill: each popped seed fills its whole horizontal run, then pushes one + // seed per matching run in the rows directly above and below it. + size_t capacity = 4096, top = 1; + size_t *stack = malloc(capacity * 2 * sizeof(size_t)); + if (!stack) return -1; + stack[0] = seedX; + stack[1] = seedY; + while (top) { + --top; + size_t x = stack[top * 2], y = stack[top * 2 + 1]; + const uint8_t *row = rgba + y * stride; + uint8_t *out = mask + y * width; + if (out[x] || !wand_matches(row + x * 4, reference, tolerance)) continue; + size_t left = x, right = x; + while (left > 0 && !out[left - 1] && wand_matches(row + (left - 1) * 4, reference, tolerance)) --left; + while (right + 1 < width && !out[right + 1] && wand_matches(row + (right + 1) * 4, reference, tolerance)) ++right; + memset(out + left, 255, right - left + 1); + count += (long)(right - left + 1); + for (int side = 0; side < 2; ++side) { + if (side == 0 ? y == 0 : y + 1 >= height) continue; + size_t ny = side == 0 ? y - 1 : y + 1; + const uint8_t *nrow = rgba + ny * stride; + const uint8_t *nout = mask + ny * width; + int inRun = 0; + for (size_t nx = left; nx <= right; ++nx) { + int candidate = !nout[nx] && wand_matches(nrow + nx * 4, reference, tolerance); + if (candidate && !inRun) { + if (top == capacity) { + size_t *grown = realloc(stack, capacity * 4 * sizeof(size_t)); + if (!grown) { free(stack); return -1; } + stack = grown; + capacity *= 2; + } + stack[top * 2] = nx; + stack[top * 2 + 1] = ny; + ++top; + } + inRun = candidate; + } + } + } + free(stack); + return count; +} + +// Headings, clockwise on screen (y grows downward): east, south, west, north. +static inline int turn_right(int d) { return d == NORTH ? EAST : d << 1; } +static inline int turn_left(int d) { return d == EAST ? NORTH : d >> 1; } + +int wand_trace(const uint8_t *mask, size_t width, size_t height, + int32_t **points, size_t *pointCount, int32_t **loops, size_t *loopCount) { + *points = NULL; + *loops = NULL; + *pointCount = 0; + *loopCount = 0; + if (!width || !height) return 0; + if (width >= INT32_MAX || height >= INT32_MAX) return -1; + // Each vertex of the (width + 1) × (height + 1) grid records the directed boundary edges + // leaving it: a selected pixel's unselected sides, walked clockwise around the pixel. + size_t stride = width + 1, vertices = stride * (height + 1), edges = 0; + uint8_t *out = calloc(vertices, 1); + if (!out) return -1; + for (size_t y = 0; y < height; ++y) { + const uint8_t *row = mask + y * width; + for (size_t x = 0; x < width; ++x) { + if (!row[x]) continue; + if (y == 0 || !mask[(y - 1) * width + x]) { out[y * stride + x] |= EAST; ++edges; } + if (x + 1 == width || !row[x + 1]) { out[y * stride + x + 1] |= SOUTH; ++edges; } + if (y + 1 == height || !mask[(y + 1) * width + x]) { out[(y + 1) * stride + x + 1] |= WEST; ++edges; } + if (x == 0 || !row[x - 1]) { out[(y + 1) * stride + x] |= NORTH; ++edges; } + } + if (edges > wand_edge_limit) { free(out); return -2; } + } + + size_t pointCapacity = 1024, loopCapacity = 256, np = 0, nl = 0; + int32_t *pts = malloc(pointCapacity * 2 * sizeof(int32_t)); + int32_t *lens = malloc(loopCapacity * sizeof(int32_t)); + if (!pts || !lens) goto fail; + for (size_t start = 0; start < vertices; ++start) { + while (out[start]) { + size_t first = np, v = start; + int heading = 0, initial = 0; + do { + int bits = out[v], d; + // Where two loops meet at a corner, turning right keeps them apart. + if (!heading) d = bits & -bits; + else if (bits & turn_right(heading)) d = turn_right(heading); + else if (bits & heading) d = heading; + else if (bits & turn_left(heading)) d = turn_left(heading); + else d = bits & -bits; + if (!d) break; + out[v] &= (uint8_t)~d; + if (d != heading) { + if (np == pointCapacity) { + int32_t *grown = realloc(pts, pointCapacity * 4 * sizeof(int32_t)); + if (!grown) goto fail; + pts = grown; + pointCapacity *= 2; + } + pts[np * 2] = (int32_t)(v % stride); + pts[np * 2 + 1] = (int32_t)(v / stride); + ++np; + } + if (!heading) initial = d; + heading = d; + v = d == EAST ? v + 1 : d == WEST ? v - 1 : d == SOUTH ? v + stride : v - stride; + } while (v != start); + // The start is a corner unless the loop arrives on the heading it left with. + if (heading == initial && np > first) { + memmove(pts + first * 2, pts + (first + 1) * 2, (np - first - 1) * 2 * sizeof(int32_t)); + --np; + } + if (nl == loopCapacity) { + int32_t *grown = realloc(lens, loopCapacity * 2 * sizeof(int32_t)); + if (!grown) goto fail; + lens = grown; + loopCapacity *= 2; + } + lens[nl++] = (int32_t)(np - first); + } + } + free(out); + *points = pts; + *loops = lens; + *pointCount = np; + *loopCount = nl; + return 0; +fail: + free(out); + free(pts); + free(lens); + return -1; +} diff --git a/kernels/WandPixels.h b/kernels/WandPixels.h new file mode 100644 index 0000000..4ff2d13 --- /dev/null +++ b/kernels/WandPixels.h @@ -0,0 +1,21 @@ +#ifndef WandPixels_h +#define WandPixels_h +#include <stdint.h> +#include <stddef.h> +// Magic Wand match over premultiplied RGBA (4 bytes per pixel, `stride` bytes per row, rows +// top-down). The reference color is the average over a (2 * radius + 1)² square around the +// seed, clipped to the image. A pixel matches when every channel, alpha included, is within +// `tolerance` of it. Contiguous fills 4-connected from the seed (nothing when the seed itself +// doesn't match); otherwise every matching pixel. Writes 255 for selected, 0 elsewhere, into +// `mask` (width * height bytes). Returns the number selected, or -1 when memory runs out. +long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride, + size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask); +// Outline of the nonzero pixels of `mask`, along pixel edges, as closed loops of corner points +// (x, y pairs in pixel-edge coordinates). Outer boundaries run clockwise and holes +// counterclockwise in top-left coordinates, so the winding rule fills exactly those pixels. +// `points` receives 2 * pointCount values and `loops` each loop's corner count; both are +// malloc'd and must be freed. Returns 0 on success, -1 when memory runs out, and -2 when the +// outline is too detailed to be worth drawing. +int wand_trace(const uint8_t *mask, size_t width, size_t height, + int32_t **points, size_t *pointCount, int32_t **loops, size_t *loopCount); +#endif diff --git a/licenses/U-2-Net.txt b/licenses/U-2-Net.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/licenses/U-2-Net.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..c52a242 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,22 @@ +# Built from this checkout: run makepkg in linux/packaging/arch. +pkgname=omaphoto +pkgver=1.0.0 +pkgrel=1 +pkgdesc='Layered image editing for Linux, a port of Compositor' +arch=('x86_64') +url='https://github.com/ZacharyZhang-NY/OmaPhoto' +license=('MIT') +depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libde265' 'fontconfig' 'onnxruntime' 'hicolor-icon-theme' 'shared-mime-info') +makedepends=('cmake' 'ninja') +source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx') +sha256sums=('8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491') +noextract=('u2net.onnx') + +build() { + cmake -S "$startdir/../.." -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DOMAPHOTO_MODEL="$srcdir/u2net.onnx" + cmake --build build --target omaphoto +} + +package() { + DESTDIR="$pkgdir" cmake --install build +} diff --git a/packaging/icons/app-icon-128.png b/packaging/icons/app-icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..191b5904eb88adc99050c36b3aa196b75486a432 GIT binary patch literal 18762 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 literal 0 HcmV?d00001 diff --git a/packaging/icons/app-icon-16.png b/packaging/icons/app-icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..d1afb9f3ddeadcc0dc4b0ec136d1bac021f3680c GIT binary patch literal 729 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|jKx9jP7LeL$-D$|DpDgn(|mmy zw18|51{Ovs23C*~Ahu(ahO^xmH5iz|;y`oSGZ|Q*YW#pS2zUT7NDqW&Ucd-d4HR3z z1XnGvfEmFCX=A_TdK5@;7I;J!Gca%qgD@k*tT_@u(|&onIEHA5x=zZ?mNpb<ao1lR z>sh{!d*fpk@i`X)IJR8)|G(cWYI&J=?YF(9rJ7$P-d*0j`eFvp2bPEfjQdXLd=hl! zStXEF(0oJ8b7??ZWX9`6&c1GutBj`>R7nPGaa|{Hv^naJv-mC@ztoN4yO>Nlw2w$l zn!w^Dm$_Moue+T!^u&6usB7sR3Y$*3DP6n!lhN<r`^B>#%?Rh<Jd<GfMpLL;c%x6% z>0g3+2e)(NJ`7nI#2oh4D?yFZY*V21M1enV0&NecZP<0D<K)san#Y&S_ZRF`WDH!o z#d$-O)2${Y6}E|5o|BV<J?GZUov?Tr6JLP(6NhKEKSHD*&H5X4M=eRX{dUF9lb5x2 z&snp9Q>s16e6sWVW#^hVTU0-P=3ZX8=(F9!g$H+S^?jNYa!V`sZOXHIezM0lZ}<Bw zU0(eC=Kr6U=E>);|GxU;GN);g<<_<3YY*DYT6z3JqQ&o$y*Y(-3ol=P_QSL8{l&G% zy(Ox8l#fXsOSsl3nfjn>-{;rwKKLx3{wg=$-~Vss?fGX)!dU!zrz@P+j53=m*?aHM z`*rK(U(dJx{_WwfZ3~-c)=kyg#FpE=g)7cltZQ?o+x_2r|JM1p?>?QFz0cC_fX`B= zU7_9!>TMfM)Y=r!h#h{A|I*!5?)K^IqV*De2AjUbytm$SJTA^Vexb>%i~aSC&wDD% TLWMV+2PF|tS3j3^P6<r_`!^Z| literal 0 HcmV?d00001 diff --git a/packaging/icons/app-icon-256.png b/packaging/icons/app-icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..cc0805a7dad0f0470dc21529970beaaacf199311 GIT binary patch literal 71530 zcmYJaWl$Vk6E%ptyGt0{2G`&W?w;W8!94^W+#LpYcXu7!-Q6WfkdTml-ra9^Z&lan z?mB&c-9LT%bd<WP93~nW8Vn2!rh>eTCJYShe=aNx3fzCNgd@)NzsOxvP70=Omg3^S zz{g5o!CFNHhUGsW1qK0@3<mLkl>bZ&mi+(oL9k3P@c(!Izk4F=U=aQfNA*Aa-;w{% z{#Wz=AbbJr|6%`^D}eid*|0DL@c+O6zcOtrj==u_)kWUG9R>y+|9=JxlMNw(fsuew zkdf5(f%V9}F$W061Ab|&G|#z+-~P?NFH;oeapz8LZYR!-21pJZz(|5DnRNbsJU{pE z?TYP+3jXNt?e#8u_YCJvREt1@Zrawx{rm=&7c<$6V?7{2aZ0rd1<Hvmo%~`v5a;~u zo+QEA<^FjBl(bi8y7%qwJfQ#kKgA!?*#F8!P$$;|dcXJg_eO;3Pjm}Xz(3tk#a3a6 zHO`M4Cg{n9n-T^pf|I<*T}(YU`Zx&_R@Szl&@H{+jN-u+*i!a+X5q(511Rx_TW8<8 zwNg5BNd{qOu9hn^H=tWklxe!1y>BKzj)8g9`cA5kG|1qBr}`p`GO;Q6)5xR*fUrbq z?9U1K!N7m-H+rd=87Bw<OCg)@TTpQoHc_Pu<T^=)6d7S~>^v~&6kWn)x3*hNv@DAI z#$@`meSvH8jq_DDBI5^6x(+0Lw4GDyxqo*zkl4zAzu+WBnXs98OMN${V>P+eeOcg^ z^l#SmGuJ<AE?hhk;m5Cdo|E?w($bljF;OumhER(KRJ+G)7n?2~wd->}6Hs4fvkj&R zB_m90!mLEHdJ`!P!3Q_8YZmM1k|P>>#??v7nrwp5K(bys8^ERzL3j{VZqOYc93o7y zk{G8bNh*$cabix@>8JY711C^)i8r~Lkzjc#90t?Cm&dXK993kzMuoW<{ztmriUvzU zBK*z|MWyWg{rNeIlZ>U^R8^+M)x&r3qhx;cp|rTsuZE)gVf|n!+&k+JreQUx6$|;) zWOb`eLA{~d*u5fHE}b9kLL8h3w4bf};utTJ11=kwf3mwSegCJO=s%R}K&7iiMY^d~ zpyWT)8Mt|cmCye>K_qO>lI|Nrjr=mzBiXEZ6cz3!!Mv9iqn%c`PM`eMJ^vQnt&=pi z(^Jt+S{QB5^}yO>9fvfX#N69dF<q;cQ1CiZRqazt^XJrPsiD^|;OeLav9q{Y+zQ(| z;3=3xb&%p<fZ0LI^=i6+nVG;xE|?<#Euw^0I|vnc0eIcmp_H1<!%Ladq5BH&_b7Gb zCV_@x%&+Jcl$P-A+mDolDoabRr|=V|l=F|@0q?)xN1}2l$vnwZ`kBakFpYUv^W4s% zi3^lWE~556xr9S{xhiADNU)obv+&2J2IoYUeOGVAVu9%c_uYHo-Z`Q0?oRw+RuD0L z*KKMkxO#BBJJ^-Wsc%B?&1zgE-^GxJ;=nO<A%H?qz|ZRm_Cv%9L;?qxF)L8ENMQkZ zy0lLR$guUhd$2GMq097d%NQw7p;YbY);bR~GWcAD@OaCsQf}SJN>0&ypt$1dAov|Q z@fMZdOk$x+?Wdo5=Lp2raw&5(U9e1;vME8TZ}2;^Wo{a5mAYS3nFOb3!#ckX>IL4L zkDv3@hL*;hrndk5G=V55;o;R^lC-_s*|&bYuk2Xi;zBjq#i|0B<4Tcxv@4j4S>VbH zunEI}9hLNt!=!hA;>yR`5z4&@gHSUtiT2svkqFutzvwq+hg{I@*=jXCTU#Y^AFK!P zlB!8HG$(ID#tX<XiV9brb<qY1^N@BFZC;HJvqtY>YcGU!dtkT7V07J6JL&T4LAaMc zJ$o0v|0JD?%g4{WTTw*tawud+$;1xRH&w+<(??ID;5ltz93*Kn6xRE(%gbN&)v#r~ z%^91^wjCoVm(%vDXaE|fM)n)={Bf4Wcs^p6n*8)aw;^TyX8u()GvYv=BTRG>4TAlx zeV&Vm%q2*V7v{%HoijBInl$^E?~R}rVzi|Z*~UK)Mqkhc)&XHvlb}!z<k8|m5bh0E zEPfsWB`q4bTG>_)#K^7H$fVU8$r}mOa{I9=q?lHKr9jG1jar*v84tsoHbr-)vN-P2 zmuE&>opC&Fsl62kxiECx_?fiE5wuk;BP?qTU!I~3TQT!|P+ubdi@RT6R;`@3Z6FU( zlr}SU!M#%=l5g@W(x%ZmS-KJCF5;PJ+mnNG$BB(~h-Uq{Xaee;ju+PU8tHuj9QwSu zTm%PlGCeZBF`XzwfaVz&YP=6+q}V(@Tbm<29%dL%TUY|j+ppOUbiQ2djQy4jna!&Z z;EzV!I~^w@5(~lw>J0LtZEAN6s9myLpL%{Flrt^@!&Xb;b<gF)q}4FrUgCRv(BU~e zt9ttWl)V8FC{QGyB-3)VVpb@{0kFRM7xmc~d0r1W+(WSclfw$u8N1sf?T5p!X|%+* zY@r|$(AWgg37)^(Y>A@cA8FH_SDsVQZ184<TOXb|xtT@J3R2`wmY#7JSbuhu6md7P z7&s)Oh15}fTF|5WB4+!A4H5x9uvkpoFZNI}vHUZ-!H?NRfqw9&j4^T0KD4o;9l6e7 z(n7jt%!4h{ZV{Y8(Ni=kF+D5ztpI=QxaB3x(l_grj+EafmFu9x8SC`vUryp}=re}Z zcN`?a2}5Nkr`KdT&_xIjEvl?cj-DrIFgNC*_zg{PICU=d8}Eq%mdm&e*$2L21a?Ym zR<p(Pv(W66%h?fA-qIqYdu&X7dp#j6yysc8Ka6-l@o1|bGTNgXId+XDKROLY1Tbcq z5H2Dk^pDFvu{T52_j}Paa}?GdP#S*%L*?>kIZdJ(j&qcGeu`89$J);YJ{%K+QMf4T z?%*hX%#hmOYE_)^g$uW(=GC*3L_&YNm*-mH5DR?tpWbgcGx*?SsH>RI{%rrf>iubU z_?KLlV^jVdgX_{gwpkWWBXkR02-8E5GfmAK3^7S~`vD})wv017v_;-Id;KQ(=;k!D zmU;l3sgkVq5X?CNYeODpuZYCm&ahLaBT8057sUgn;F6A)B1Ttios!`!lr1P7osz|f zZ8MdZoPR^v#E9dhyUX)+1Zd6q$s4gfQiD}_#dWRM%CZ(XK?50T-lLnH{KIA(`mDYS zXRLcK<$4}}plIGLsDQ&C0H{l!MO#l+nk`7ow?cB1<z5TB!$G&}mmWZnW17~p)a~O} zqBF|ZJ`Al}@RAHQYfE9HkQlM+V~hTrAf^Ao{ueP`*IpN8a?$|in$=$-7!PSqqeaTy z#X1$97TWeNdo0N1fmv<VSC{-m*}J}#&f6m?XwDZ}eR?dycvf=wdL3Kh(;_y^x2ofp z;MOt`C@O5m$~N0FG5h0m(D3olXWUSnAxVvRygsV)G!jf01l>q(8Hn?|)f8<@ozjJq z!f|U+6n0EHLmx<C^pM!?bbG=}GU0RU9hy;-j*Fk2#nQ-tRAUqkP#O`#w8Fl94=9rK zMs|9cWg#K#QV#_=?GYJa`+|Z*AuZ}lG>;APIadV4Tak@Uxi|#i+a;1Beit@<$L>bj z-OgbVRM<E@WT$RYr(c^2aHa;8Q?l@HSA2Tol^o^%n~{K`ILaw#R&zWzC)wf7QB$Lu zKN@ck*=AS^>7h@U!_tXF1TnJkNavPTHgDvQ;;FzdJXOBd^12mHRY*fXhOHq|DZ#ZJ zYv4IU*GVodTE}B#kZhibDn)m}xA8}w)F|Um`<0|C;7?*C&w>bpN~ga+@unWQ6G!!p zJE_ZjwzBMU7(cAGKjAltt7@n)DH0TB^7txCoNsDBu9odUfh(;|8&lG17DxjAs>ieG zV<vn|9S2352#oNwRad1S+~dN>`US1GJL7tE*9n%mEz)TS!}0-mjtsH&irwi|mE;Si zGHTlE;+FrMOlRY0{K7>nhGZmPy5Ewd9EnFHk_WmyTl{lNQ*w0;O{Wbz7jH|Mwv<i@ z;>ms0N5&K7)xG+<svK0$-d63N_?+(Y_*9XkRep9Z3ykVgR=%YGyl0(tUuJ25F|u?w zCHVf{#^)^8m`3gu<AiPPI{Q{q6=*&h(rI*)HD>5s<*aFif9=}Z5vqR=n%3h{B;bH| z#C=fp-?=1r(aL7<q<iD-g;u;KmQqQlZe6dCqHvi*OlCP$^E8HRnqiDRqvAz4kWWWq zto!uEd(gunx|IdcgRUWTc83Kdo0?N+Tc$)1u5BVy9fr?F8m+$2Gb`C_z!uzNa4~bR zXN&i5QN{4Ui{GR$9WLb8GQ@?$oM#TqpEx}PjaTq|vUB*{%8n(6pK;A+$Di!zsINVm zzF>SYLC~h4MtT0Va>5wfO6t8DY-DZsXm9`HwFaEMe^ZPsWsy@>nLKn-2|kWR!%C>= zax>?Mm=rjID>bZ(3JSlr-8W@|v4mz3_Fi!iyNxUUoY1KIZu`K`D_Agra~5qp7v4XT z%&XD713&Pc9`;ze5QvFMxv7x|GmMTyyCW7a9*`PVX%vTRysxw@(EeFMGu1yf<#b{i z*y(EHq`vWb7|zcKF?LDCeZ(Z-KuLGHvLueU*GWF;G!HX#9wJs5H$QOV|I~<hTDG8C z;6k8K+hWIui45kqCxPRw@zhv4JE|RXwSKo`nLx4QGLao0k2a1a<yN%Md-n9n)LxpN zzZIZTe#!-NOWpQ_V3Qh#6?n2)#nDsW?@%4%S_&o{Fi#p7&!RnEdbAM=^~Jy=X1Jg| zD5SVPHt-lrtOw&%8R-xJGGvvB&^dUmY?oZmW_{i<3Oa!X5irQFQ_a$At<+d})vr{j zc3eCZ{lDj<e7jpaaFH-ij8d5lJ{R#E2M<ReaUL51dvnx~Yo(FOA4BMV7B&`)lcR%0 z06kAvJ!wp7z4I`J>K;O;e#VyTQ~p&qV$25--sKi@BVBNbgK{$Pad8WI0ym33u^CTs zvn8%8B-sxD#Mn>Bj;n&NMv>nnmiP;8xF~`>1eLXJ+wM6R&?e;5zI$kj@t^PQ=*{>d z-_n%%nGp&c*SwGp++qPJS4T3ZFUnZ>IA^9`oY~bG>`%X?DGJP-4MPx`zGw)~<gB@! z%$I)VOVUtq9xMb4kcnOwi@}LprSLzfNJ?W+Z;j4x__1fjl5%D*<DczVnYrZ`4Q$A9 z_{!NtSh0lmXtkb1G}qZ-1MnYLu42szStmHNzE`A8A;KCkygEsWsTuWB4n_Bx@b7xu zFjIF^OXXV96QI2+Kv$m&KsRn|b5-2y&qB<{IJsVaUJFO^-MZ!Se_~Z{8jUQ#TKUJx zJQqBqpKhSX_EZxD54Wlkz!Zpp$SD@0F~SC2Olz++<wGWhFF1!~Y=YZBA2eXD$r&$l zn5ZQPQ8B3Kjt|A}sMyZomxcoQYHjz0*M(b`i1Q26k%c3!rNLuTK?;1eSIoG~{Wx)% z8QBvGFx{R*G_|NIEZ*M_vdCjQ$I1#n+eyx>i9YWjyahenQ4KlX<(olD2bt=dEb)ej zJ@%q(U2i90`s1)otN>v+1T^xhhT1>8VVUODVOwS8GSb-y;jFl5roHCeNN5AZKMB~Q z93i4iCF|#D0}<TU{pjRL)?KCt@fEnkSw8-G@(XXthUDm|C1Hbzv6q!eI@<cw4kkf% zzFfF*dAyV|dx`o@ZNi-CP2ec*z}3=;^O++qsV0irr>Pha2EJ@1aMWXKhcH!8kIp_w zE1TkAUIZhekHd!G)wS6#YxG)>E5_U~Ij5vpC~SvZUB6);+CG`Bp<t-52^-jOJOZ+h zpW5QsN8`bb{sn{&$tC06{Gbmi5$BmFvI2x!(X_fV5Z2E5ZXn+L&F={J-8S2GuBIc4 zA+F%(Ge}%-LgQlXb^!##UEFv@Qe=N2fMNEMK6PEPW<;e<>U4>sxns!XVSi?uw%<DO zN^+UCm`9>0DMW6y{^q-I*h)*r>`2hrnJ4sca$hExJv%vw0lMxSpZn*-ZS^Px?=ssX z=w^xEf6RISN}Qi(%1s*rEcdok6RQttf!u~iCbNdUvI(sGdooO0;M{)0OCAbPw?j~T zr3#9QZh5+g<z(p0i)xdaO18M2o`m0I`eo_!^(j5!um?HVnPi9gcumHoiyUyZXRJeY zMppB#!t0$-wqUVmdy$0E=HWW0v86|974S6!wUy-LYTW_H0gVlpuaq}cdj#y?swtkn z!r1=yA;2LxC2+QaA+F;T6)p;yV61b_$0>+Z`;n`;vn2aCvW|4}sXq!2HZG^`<ujT@ z1unGaTZ*?RbGHX2sZt3AqWTpLiJ6;f`p1%y6gfrC(IufT*r4|iBP=YB86pKMB2z>y z?k*=ld3@_j!Ay=$!=vnoSTeDJY#t_U|E!EzJfo8N#nsSiOa2@VMP3EKHx;P+$a>8O zfg-DjeaT<ccGpgNsA$Z^OKh%{a-+u9ul9*uj55{X+rpK$_`v`o*vYtamYXWub82;j zXfG8j=>)%~ni^wVS|gRSy259RvGZCJO3$Q-ri?FnQKxZWFCZm%J)!?rrv6Q8?%|=| zJ1tc?SWMW%kB<6iEH*I1{te;S^;>>f>;OB=@wBX}jmFksxPx4Kwm*Ro9QM#u=Hn(> zL{p(Nkrhvvuja&E+%&Ay&XN*4VCyRHLJo}$jV8!YI)`#?`~96lffKHeF_;#F!l$;! zUl=!v^L)TYMyKr6a*Ls`dHr%3_-y0(MZ`(Oa^=8r=35(U25ffkTi4{cnqiqPSg68- z==x{Y5t)r>@|iJ;P^QZ9fdth<5l%wqz<gy7E-o4MC)uORc7H~fpwe7y3{+BzdJG5` zqqt+WtrKZNXNQrcCQgzj$JQgOH7B!K2;6A{T*aR)SzQ#VTK~DK1*(Y6!&J=jEo;>4 z&G5~~DA6%--+@${f?bq@8W)f8gx<y7Wq@Fi#e;L2jNc}&UcLUo)^qGNNoJ%rYT+V{ zNkje+$PNlKvr=Rem&(bHd67QSsl`oSLGEe<lajLpDil_x7rBiVhimKN7WpFw<sbtj z2{F`hp=$L<3Z^{i{7a2#dHQ$k`lokqG$c$@vw6laG4rtwQheEhss`a7yi8Rd)Y3or zB(o$%%5Y{h-`f9H2#e(@QM~1S_Rh5(in}`CB}32vsKAxJ=O%8r(Xws2{F;1!OzwE1 zSk*9Y!SegTyvaWW)JdifgYW9Rx~c;q#*Z<pzDlJ1Y2K)68x&6*2K)mpjhRrp;gew# zBR*6%JefIvaO9^*q{7rJX~GJ?-0KMzGSM@kz4w>(2jQ6MzS!-9R*aG-IjPjM;0@*} zD8K#+Z>#mu>MIl?3yfV7y#M-@i5UL2Wnd@NQ6DWvO$B@TkZlXMT;uC(GbO3x7M)G` z?6|EroT%kwwlZ)}n60_$28&kfZ&?sshHg+(ytw*MagbBBl7P3!8t%{^`=JHK;93_y zF#ZA~w$wqp`QhxKvP6MXH|$DpS>f~AMY(3HEF*=-EPp_^Eq4ofVa2+U44P6%&{2em zeQrUUJd7RvIhj#`3t_>#g|zbl08$TQzCIE>hVHD2c_tA%q8t@^`Vr>+W3>XVMMENM zp+@l0`}T=YjcVYvz{{8}<uoBt_ke_fGed^2@Qa1JP%N*`5sl+%M|$>_PdeLm-cieA zv^wuK<M$^985_Vk-ny<fhgY1KYpe|OQ8Bg@Fn0}YR@8ibGF=)qJAUWq$>MDAfD+&v zc0ONTugeZxn}M4^M)rV8jj4Nyi8U{&xkCoeL@?gX3N;OEQN+mJ!JN312b_%LuvvM4 zef%hr`<PE{RkV?!(N)-|BA>e0Cqzf9h+4=&C>%;7UR9mypd_PDug#UTP7Ol>22u7? zwMjLiv{L;#`k4elIPOXi65>(vmJ?w%8$9U68#uFR*j=|Di_k7SWi+02!|K|Be^C-u zufJ4UZ?;9o<<@a+$HwRamf(rM?&KRttQ)j`aNTCbKZJyTD<z8Uu~9!h&lJ(Q?XhwY zTAi0Wj$h#w8J?q%om!X*g-jB<1HKQXD2$ef%(EQs&w0Rwh;-7Q+|GZ-z6O*#k2OSV zpfk+^G6cSg?2LsS@y}eup0(7|rdLN5aWR3OVkr<ZP18^@rEkeERn>%-+Podcy^8H= zjuEY^$A9|Ps-Gg}e%Cx>wNmR5yevzt&CpInwi~dyH*eBGaJ2I-E?hWg7%j1an^P|i zo7L6M;#$1KP-G}%hjDb$<AH(O7AVkVjFrc*av3^lX&s3bU*WS<z#Uf!fAu9GaDc?C zV%XhzjYoDArT@7h`*h?n81I|e)YtaqCgsx@YajdVOc-G78!3iT7i9#k^bOHCXvhw~ zokSC)<+52wYNkPJ2;m39BC}TIKN}t&&DX{v9U1MnftF9<25OKxnzhhL(ums+1eY&Q zj9xWCWfX`hqBO&a?NC|c8Y9fvkBn>iRBt{SA)1cN6lH2A6=oB8FbqGH(^3~nxlXNx zsVp9DeKJFcc(MnNMgA@&!O#$axITjD#^cL;C<l9p+1H`L#$}Rlmdb({)#=f16P;=1 z#mS6&@6hZX^1-^5QlYkI!dpintT=4EI6nbdKl|!ArZY0*9}PbMFLmH-?df>kgJ=am ztu6aKMl~aBpjB6(4@!W?03*erAXQWn%wy+7tIA=Uv>-qK7v(8$N=8XD%u<&kz0r=; zh-j3T*s@BDud{;AL+a$eYzT!QT?|GI^9wF!k@SLhes?b8<$Bo*ULRG<JgON<^pwe_ zT*b`!EXcJow&8!QO?Yd#*XDEyEJMg5dWal_e5Y5iZGJiBz%v<7VNexwF@VMgh2HSt z82rs_d%5#E1<AjR7L3+lLQtLOh}=f?aFr{FjdY0s>@yZ5W-94%Lza<j21qD$6%Tgw zjw?cwB^FXxt{5mrSjREF-$!t6`vwX~BQfTMt9-D0c-ytvLhYIeL7@eIo4L11E&B(G zB7`{8zq3*^lxYaaRhM0Mjn{VukimeO_TULZ&D?=5&1?&n%@p&i0@p%q**ZsvYxi0r zJgNlh#Z79qQ4+4%Ehank2<~hvb0u1xrcF^oo$p!^#Yb7&&tx~$Uank<ydkOV>|IOB zb(4JBn7IbD0aC>qr|knL^Qqk}ikoid=b=JFezSHQ9$~b;k)fY)R!?u~DO<c%rlv3S z#94<)DTH}5$4$~+qYLha?Q)1+HQO5w!#RJ@4oFKItecv;x9eOErwgKJ21)(mR_?J> z9qw`*F8bbv4X{lYMy=n_yr-kkC>U@pSEyja3S_0`4nPTfka&?XoJh9hkTIqn+G`zM zA4+|5=Z%u#awnKYpp9_Rc&V3)#kb~l9@xLmVIR|!=XVP|bd@&svMk<{enh#*Q!Y(u zSu(U;BjVmT-3NZ28bjwQ*2JX{b9UVRg$JWCV+Uz3vK)1&Az`N*oXfHa6V75dqa07V zsF`;`78BGO_LYv!`vu9q9SC`|PMYhGme3C*^<=_XU}0-W>P|!&>O@_AKiruY1&j6z zXG3A^9v||;#98fpgN{~w8ZzNU)+VtO49}TN0(VPey7Q)eybF!JS9ljSvO;N%ab3`G z(v6K=RMx3PEtgkm=EA?VXPA5rQfte`&azT~XTW%X&pM8nW(VfN4{a7Ngpv#1VVis1 zkd`)5v2MZ{llhHxSka+<2b8tHkIuwBG<s`S#de=VJ*`!e*C@M~54K9Vr_pb4oZvFE z{~rC42X@3{W;M6-E=cTFQNk{pwyG%2RH#)LG}tHtQC6eW`kISV2qIMiQI5e9i)*~2 z^A!jh1HhQ#gxql#Tl48OEbh+n2wM1+I>#-a)IC|HVW^~J6exv*Q(|8--{R@8z_B=` zzlOuk`zjJQd_4UixHvCt5+}~p;rtx_T&hRT>=H3I$84+pkU7Dr{Z*5Gi+R3%{u_(L z^xSA6empolfLGRa+`l}gjik1^h^UH!YNX0!YzO8Y0j^cYg7o8;^5G%75{Dk!e92Y_ zaiDU?r_Kj693@qXj;Hm`vUY=U3&#qS`!BMe^ti?#`3tGZ$fb9ssL-kR=N>30O5=b_ z9~P<kZlD%Wd?jJe+*ru%8J+Sx2<~G?79enduN|wg1f*U?R-bvoD|tg=L~!|A<_rE3 z8YQ}mSL|vtWw^s(Z~L~+VFmIcX8e(8A@+5~j$ZV}l*=O{ZY&FZaa;olYfA|U;hX8F zY2KwY@?s|UDs$TdIg=hT&4@zNQU(8qO<o;zIiXip8Xz-s`X2wm{SQeb<DdyzeAc_W zEv~mBxe93R*Sx&jUp8Z#a%nHJl;hTtur>T9nbOxtrN91oi5)r?778)fUg^<>l@0fy z6UF(2nMM!%`m}PtjQKg2)+Bu6hc7Jm#BLOAkzdxAyEnGA3a&v2S#G+P@z3$ppdci6 z2!C$!fo&VX9swO!&N!)zFN2*bb<|WIE?Dq1h(9hG39*STpe+O#xx``MwHmkEWm}cQ zkZ!|ZnFu7)l>p}@IfCp;#@L>mGpz0!Yn_8zU?4Ffd-la5n+DEa&z#8S)v@`qGTpKj zE&z=J$?r8&Fmd)MQQ#tCK58ZTwJ`bHml*Y<gr4Og^=<Ty5-#AHB@IP+hog^8cbB7+ z!zL!8>Y3gIjF9)dr>fFES*(13y+0?8!Ds|7H4|2F>h4-7o72BAyglz#t64RJfej5r zE@Y6;xveh?UaI{|a;5F+hR~u-6D=20OXd!)>^ih^Mb6AKn1de!veqqw9g2+D5}}iS z6SO^O1>N^|jVNQSi7b5^pu<F-nwnt#izQ|;jaw`xWWb`!#k373M?UpvK4hzpgPWzG zt89E6$bg#w#$wTQ!;b;ERcW<?;QaABjr_FQaH#)qN#?~2E_#KVHlgLb1Qd>&bxkhJ z23kwJvFOh;O^bf-{u-BS=Pwj3;;DSXl;=xDd8I1-uzq^$8do$jAQwje7|%Co?Qe~M za2mszSY}nvT>QmQUfD3Ji2aLAZ;2!PR=r|>XL^F)?;S>7x*<xM*OP}6<46?hR5DG! z`(Fesx5R}zlxh}9hKRd!J!Rk=4bfBDqeCG>M+&EEGN~psmzVGt)1;WI3OLb4eE~{3 zNmUef3(IHU=eewFRr~`Y&DVFAZ%wz#Ip`X2#D^UPg(;ZqYaTJIrU#{y_v?o!3=;w} zqqak0o2d}Q(3ctymC*dYiK8M}YE-uZTgy6UC081IWlRc`L<B&#ujmWFE7Rb)qX#G^ zaYFgUlq&TX?lPAg={md66U`5vSkAQfYspqx=u{Lga;I6q!IhlA4DJkr_ZJq^aZT5r z#3aj#;ag1#mPf%x1wkOHrO_NNb)@flQ;Dk#eMDz`KlkRJy!eV*<Ul@^B87SD5VLqv zcQIu(dy0@xw@D^-XcxEg@=Fs_y426uq%~Dt?ph9Cv~)|meC1gOtL{RO2QId|rgs$f zdI&HkyAxVYW7(^6)1a`O9NWH=N}mhw2KP_=$dF<@P!iA)V5)X(n=8zvMjZB03hTBU zP7JI1|5fj8I(}NP_3L~X@C^@4Y-BGjDhy>eBD*q288VN`vm^mje^y=LW7(<9@wWXH zHEV?gwC@<k!Qse^#cuo2FY}!udE(iE7j<xwr)^0rAQ@%d9(H_dAxzM(joc~5dhe0T ze;ArfWbDD@>tq)+rbCl}C%qWofLI`9?@=z@z(fO~`}KGm7~OV2nZk!vR-#mm>pZof zjdZr%JEAr7yTheQxmgx9L9!Tw%&9K92iowN&h4@VC1{g&-1rGM9hEVPzj8Zkx0h)) zAbNc^V<cO(8y%sg07d|vWF&%lEnVhpH+hl}ZCtH{T;(xU70>#l0g=tW8dG0FkZvM8 z@<b*LO`CCK=xes0nUl;qZA~K1{w;-RcNz%3-xes>!uow>urx+zzw2B*g{f%~krN;& z1#4NB%p(<lb7-ZuDltKqlDdPxv~4d*&9q}K#q+((OYr<wG;uch_}|)M=Zdy-l357w z)WN|Y)5?5rcs(K^{~5YhQc7+;TA`9+)GFKvtiwQ*4-sbH<nCACp>2suB9%pz&Ub9m ziKPI_JBWu>kWZnc%HogU2tAtba8e@8TYNG7*);(l%P$=g%fNApC;=X;q)q*t$VP?f zYz#EW0QcyS-+cZmy8lJc#~h?s&NlC%Sr|-<8ZH_?8PCG{Tk{)DwRnK51UJssIO$12 zNmJ7dliXyiuyMMtUY5vpH~4Sl+yTN!u!h&C&Fj9zP80_e0_n#VG*ENKD^u}6OsM<I zq|*B_XnB_{LTR9I@v0_x>Y5HVrQ<i*T+`(*Sa%<{-QR{%r<f)N7U!xIuu(h5nKs6d z2ifQAU}^6_YSurAy^E}HDgznx!am<%fu;n^*HqxP{Ec}VGa63X+aBJ;IeQJ8YK}Tr zOa6;QGP31Co*sxZ2Xj24s33;=FoXlGv7LQ{aPz`f)j*vc-?LS;WmPwglu~?5Bm3iF zh;&O#TT%^VWf|^})6>F+7zK`LmY6rbxWbh#7}|bfiY-sW{hYN87Jd@s3}aA3j1Ql; zk=$^T(H!7Q^yn5RCc<H%M;^K?DeGVboTVJR{=|szNAlp6q<8l`Do~6eLIe}RH9ely z(&F$%fHMILbSj(Zz^@DUQr*00ky1MGH+^oQY4j&KL8%2V=%eOtWSj6vN&Xk-uEfFk z<27BdAkxA>++EBC86!)rc7v~{P*LyWLB;Vkc|{SN*_OX9csiLV3Zyi50sxjGgD8ap z1;-2N69Z!=6)TwI69#QH3W$cW|9_)oT*MV3w9B7T+Qazavy$5C*LiAgHzA>!6K<{m zdbm!YlA@2a%h{MT*Oa@u76~l$N={mr2X^wQ<)t`?7L5W0*V~I=kOiJo+GRaM8<sXo zrR06wkrYkxyCZ&;*4Mi%caVH1Cw9nZVOnZ3$7w`Qg6zOz7%xGW0^I6wYZ~%w$Sp$7 z;f#?~^u9sLZfp73L(bsP1n%um^97*xWr51qHT>DI=bOm}Q#q)6F@K_kF;_vGIF$_3 z=8)t?di0PGr5jpmTwtS>ZHlTx5d4IE?KC}n8R)~dHB`)Dlak4e+zaB#<0Nsx{j+B_ z2nJgS)GEuZRx1xsO7YO5LaLe-w)rExUvXB)U;deCRvWcCe1TY3z2Th4txo}s^EWFe zfORs<g+Xf}9T->`0aPY;!rRyaZ7_w22zQnFS`bj6;)5=e-0x8pOWESzBuKKaR9qTZ z_7VGYW~UJjBysj-7J@AuwqO?S2Ezlr7eII&wy*47o7xRnXBF~BUy3K0CJR-ND-!-a zaIRM7rD(2_xKuA#wQ8meI29Zm8U$^N%o~2C>J#O1C@+5y!{pw_1j@?VBH)p3U_0Jg zd;M5oC3rw?8*>hfOr<rXdkpe@$GzY}z#!je1Jp4d#Eosr-29`9ea(`UH>Hl+P)mUQ zyFl8!tB@`T#jeGoRUNka{&x`1f84jz5SXb*Yo*F$Dj2XR;Cps*-iig8rRB`2m~pWg zGwqg0EHL;S6=cn+HKvZ&3}1Ts^#+Unyr97ya%Q8gLO`0iQtN2<c(|N*%w~bL<uX9g zi0|oWW-hot-R6%WJ`uYke=6(ljCfQ`2lQ`qZv&IMaJaNT{tYeHFC_n(fjLqzlhtWj zU}6zGy_b>Ic)5sl&)uc3tAfst$^o+=>S9(6q^xDZDcG>TSI+T0<K+&MfXB!1HBm@~ z-MYYEo}|lkkHL-~0mo+d4+|T)B0SDa7}3bJ!1A@KM0gXsQ2+arV=Dafh|#<Vr|?aO zD}`HXj>MMuaYdKAweai^oYBb;-wLIm{v~vP-YGt#BDt9daRtLvUI{(Q4n<;ysoA4Y zp{AtZP~1^x-!+GEOP*<q#_d?pFj6+IAPBkpo(1wCHv}vM!<YD>{cqkg@hMl6Pexa7 z8`{HEGg^x7ofBkJ9fPsY3U{<#R?71kFLh~5vVB4mujZcPS%=3+Oz8=r-4^F}Yfi6| zDpZR;r-HeSa@@{30;RR3IiO?sJuQC)VQ8b#YD6NTeBd{(+y1gHJjtq?UjsVEk>5L3 z;|hQL={;DaA+ywiIfO=*LU)tj7sBHGjB2GbL!n;={!<g}(pY#xi{P>yqftCXIIxbQ zqf5DrnTXOnXxv)}6QQ)(Gnw5^1g*%$=+gVtjYGldV^e*p-QZE<@mlNRAH-@V7vF&A zm<^e*@SXE^d*U%Lb4|MMlr#u!(`cEQCqkNKy5<jcp31aOB$WFXfnYF0iT6bQFok$D z;mei83!cZ2nHe4ZSryD7Cux8tX=YAGiFAR+eOD~m9W|j}#eiQf2p7#?KRs%<`h-5F zX#aH`rjoHEsd2h}Ba}{}CP;{LKn_Df*{u;Ndu5k)B9cE$=OpU_#+7OKLj&oX=O^IL zM^=XAOQizNN4VA&6a}h=F3d0D#VrQkc91v+&5I4iFmuIjP~nAwdOTw@XHKnlRG>Xi zMl=kW2O`}TLG}a5kCDP>)H0h^dv<xwN#8&k#T5Bn8pVQ?h&X2p^rO5BhO62ZB^9Si zqn+vB5V^uKX@*5)zwsllSW))F%H|>5e<?l8)+9feW&G|!^47*-n6Fq$VH3$slht54 zjVa4Rp)lf$@;Dui=*!H911{=SS?lf0)*@j;Qwp!v@K`br)j)YUGlKVs<XR??3#J8V z2umeLL5u<t#Q1Bi$VEQHr3|RtA(L?xk_8L(l-^L*{F>NLiXskC+&SlBDcZRnS_(Yo z9zx@yTEpv|F}wU~>Mq9I24)vWF+a|u#od{?7hW|`+_F(UW{fZB>2W-k4MixDlr~K- z>JEeu$rwx;q2*d3@Q&w64#^F^y?h=XP*zHg)TbH}2z^#g3Ns+~@<yGy36_(xbDqrN zOh&~d3C`Ya4qdIIth`bOLV?J2RMUOT8h$Tn%aKft&<eO4#Ic%02$J^yAuVssiinQu zUBPFg>ELbH8ARQ!UplO?KoVjQiaFF>aB^Uvgi}@mGCwQI6hT;yPIdIem5;C&BMrF> z8LEP3vT+fTjN<srQN=n_1f<p1iB!dZ>2;(y$czuPJL*%nsxZ6++ac1~{zy`yS$C!$ zL_k#LehB=l{C$IlNIBBmQkFXneD@OHxA*as!>tTKPF*<7htH8CMEsX97x#Blajh0I z6Vf;Q)a%dBuuT6~MnhFI+j%PaLqKMn&C9>?laPOyw$(hTb7-5IS#Kr@J@W695QdTr zl*e5PbyPBIzJ!LSfwpin;A6M~j~OVj&d^qZ&3TiLIy-b@dSlw~=M)r)l&$<QA_yVv zNB}WQW|-VB*clIO$Ug_?3}J$>7|VoX>25LD#0W{5QDpD^YL%Mo&)L{kkKElKLi@{3 zHKxHt``;^J=sCY#3|8nCF%>I6i*uZ_FjD;YZc^o586|V_l|Z{hcB5)c<OcStf_+X1 zO>szn_ye`S8~5YFAhhGrLB=D8nb^tTep?uJFQJMvJXl<`h-m`*+YCvr3|3FqLji0` zX@~|ZE$fFQLBGNRx5STr>vRb!A-d)F%${X(>4LX?`BL}USW<JcOmY1&5LMOobaKXG ziBSYjxN8}|H0V@14F@4s4iqff=Kwrkjmef+**3KCoVVS2IPUDC?fj)i;@2r$RjLqJ zivgnfm&&&#nw2Y3mW9Nb7QoIiaHEro1vf;#b9JaZ-KFhB@B9Y#a2e8u`|>1;{GTY| zkELnaHzxONWjyJRCGGh!$cE%4e79IzG6#kpeQY}})5Q<uTCveMob~T0SVZ4%=}{nv zk8g9n`^8vNq9J=s@fccxiUF-GK6+;M0dUS4qiC{=e4fsxOd^|p7YS{D*8IhGKU^{} z>6Z`3Dt_P4=;~6O&O9cEif1a<PSG6S1kV%i05;+h(ZT%t_a$7EcjheYv$`>3Sc8V! zT=8ip;S$18?mtXs6*UQL{ghGFq=lXl$85o;4hk~80g?;X<@<JR4h7_%%wi8hF?eog zU{i_V9Wy&U&p<EBtK<73XLae$X_iWJ?JR^m(|F5l)7T(FB7$W2J{%V3p~8r8P$u+p z<DW}3s<h%K2C1zZ!Fio&`@)5~dUNW@-qI1o$(bxG8M<=K6H~5s@F*L$;Nd}RkYH)^ zRL69HCyS@LDVMhUCTNc1H$n(HY5t;zV6NTa7c%y{{vUtaKfPVE`uMtl?9O&te%_pD zDtZ9G5&I*DNB=2{$C{6ueN*Vtq113Mr$;^%+Ozc~FX3vKuL>{<R4Nl>v{Qw^mB`^T zFo&B>6yq02j^J$e?~QG-&eU)rzcIbNOX?q|dS_X>dn`xHk(D>fM0qNRsKUR;JI`FM z?~g({q;(dv>`E$<NQy;e)xHCN7YF$!h0?JoQKS%u+a)7rL6?M!XCz<h;_fx{Umu6y zOwojtSzlFXb=#5#N)%W4?3ZLv;$+Q~Sc%1{v>Jr%nz{x`)f;4OrkaLYe`|mQXW^;2 z*xf0qj02|A%7SH*)m#~9aVS-(b5}8>{cTg;1r#@MZrKr5P*EmaiwAj!l+r%XC-r?A z6@AYbDWCmyxt#2yR3az^8c|@f1-w2HbW9xBOEiQbZ509Gdp2`a90eh4z3K=GTJV6W zAEeSVmx$4oHPN<5uJ&S!2wsw0oHe}9F9|O%;(N$E61@%|_kaJs6EDGsJ@*I-Cqy_6 zIgBfm%0U31P`_(w!SKqr1PsWICl#8;;8<k~85^O6oU@QG@|Ld}m{;ucCO{fnXM9md z5pbkrNtSu}rZO;dF5THQDG@6waJB)a@k3pD?v2xHhD?M0PliH&;nHaBrqg-U<zY<t zfHoeHA#56h6QAQ~GoaCn?`TqH!ZkJXLybn8k}2@2Io9(Po9uz#lMmdA#Aw)+vi2i$ z((NN}t>+!Y;8zsGq`bf_yUXXS$sPtkqV&HQ4i!Wypw5XTKH*ekRi%Y8wQ{a<(I@md z3L(WnlG6Vs9Nq|&UN_EqnvL%wCS6LXlm_gy<V7=zTYL~k(vn5TzQ151tnq26e?P>N zzUMKNjtHqAL7>fWGURB8)cGb(ta|3JbarXCI;|~m9C_d?p0zw{7v}M@X{y>dAh&== z4KvPe(NujM8t{9=ugX;rpRo46u(UL=GBkyG5w=>hCZ_AuRuE%E(GOE0*^AI3DM|8P z7`l;&>pHIjM}22@$t`%}EB;1HUif<KlwEOO8PB1I1n5C2fzs7=4TSquiktRQOLXxT zrFyHbet%~Y7F(8zz-W=73kNw^FMYQV*kuPotv-|eIt-0qh7V`o|G5p6rc0q!J{Nnr zYWa)MqqpW+EzYZ`Ef*Ti=yT4>jtV(&9OAYar^v>A;<eO_|8P-vP=Bmhr;No=qlk>o zF8XFJo7?b%7)2bPI^SA`HKmG&k`A`(T|0m^2Fg^~?pNCQw*w5W@H!V7SUQ{W%#sR{ z6EoMQ`l}4UK{-X!sPpGfz$pvZ{yb7Jl4|P25GMW+lW_~H{I3N5j+uQlLShMNJkmBC z;9~lc<*B}#0hMF$L8#63QA@uViuk=gor~8m6>!7vh@f0gFoicALq<FBUms3Y(h-h} zTMu5hD)o^1%!P#sWYIJlZ)QLB%X0lBC@3w+<Zt;cqecO6Y$hhOM31c_bc8gT6h>=o zr=ag0KD?neCNc?cPgpF#A1K9yK$Pk2$NUpZ;Q8L{FdQkO;oo^GQZhB1JWeZtq>ndh z8Pdpv$nRv7C~YtFk|~bLPzzw(ZvLfA7Vx~PL{D_0%+b1VJLA<m4?sgpPu`lZ8pOm? zEWQ>!5K(`4^m(gO`kEprmfLe;N6)_Mr&is;scx8wu~u2|$Ipe5uLKWM$2uw$$&+Tc z{zfKbUWLR_ziIjlkiyg6?1(*(wvq>F+6me#`}Vv}&Ev=f07&w18gS02QJHBG$CGl4 zIS}h;Tv2BW(5U0_(0{)(((!L^<H+rhpWx#F7^D46`Ii7=OSAc7AcXzh3ATL`9K?CB zbr&zjkJ^5pz#d!-HT;9_GalTzm9we6oPXE;126hCq4544)*)9=c+2g0I`2PqP9l%f zB00+2LGhFT_Upwnxr0Y9u0+hxS@``K6q)jauk>!tyRLth+6jY5v*Ub2R%9;ra4%(( zy=n7ZnIY`xp6(X~DHtH0KnuXbG`pSW#j8OZa0P!cQmm`=0fg&x#qtbX;kD^Lm{)R1 zb#F<Yaq`Y8;oxGj?El<qL4Y<0J*QhVK7kH4^HJ!5tT7lhfLfTsaAI$=|Mbqmgvea! zM9q_eh=aCY6A~2Xixh2xhGh^cTHswCwJ^D?CABp{gJd-DB)2`SelNvN75&IDZ>Fq> zjyv21%N2oJ3`V`<VY;8#Y`XZoOt5@^J<gKe1&m36>3|uQPrW6n_5?JYvM6K@@T@i> z34jv6mEy*hgP)+`qx&>5I14A(7^xn^;4LdvvO@U2x}6hony0tHFR!BLpZWwDlmT_& zcbsBK<kG(ip#kKg?iA=}Y*Zj)d2Dgw7x^6pVRUPcCF-(~;RTUNkA+}t<cWbb2Er6x z4Sqa=#bY*PJuk<M0;rvCh*NeJYBrBdq|<0SQz|~9U_oJqNdykh@pOy(WBux@64}S& zHS5PEuUfL)8`5Y8&)}hl!;HvHgfz`1fD4sr2`PUXnLc8kVI%}&Kvk=Jx;3~7<WYHa zWAK&@2mT0#4IGqo7ePd7ft{PT$|6qM;2Z4aD&lq_n9m@w0V<`Aw#nk9R<nNHzQM(r zEZPZu`rXogRHHfH2Jk5&lg42({Qe8Cq2eaqDVS9@-@R_(fj`+m{@-||O+iOf<4*hE zZlq;Eln*8@9jv&R0%mbnup55aH)FzRQrO(r1i8I!Ld6P8TW?@^2*Dd!wCj5hRb({C z62Qtmk2FLc)!!H2KpS4c1)JF}FW)}I;0cH*S~(w;lY`H;F14^)-Z3%7$>UO0Sw1W! z*BKs+o;q4~9{;^(*)b^d+ZoMm_z&&;Y>C{TWXl@3ax(?mK1s~PgxYe7iMfLPCfT&Y z)))n5qieXsczkVI-Aq5_^AYO~ZJ!GgfsvKyYTOoM$7v9QZvN0!-jM7nV{p)v*{>kV z2xaAHWRBOf4bd{P0)lgfi~;5Gv0IIl0kgW5)DUrdw^K|tl}S-mImX1cj>ePpvSW>C zya!#V=D*<-YT46~;%wr&%d^d4vBPk3$*j+5wNdf8`&EkXhz5M)%(}cN(kC6No$)EI zc&SbI+u$*6s%B4kEl=Kc9$TI;?qkKe-DW<y9J~2Vp;VbbUAR!?a986_J$yg8xS<fj z1ny4-R==N;&Kol%Y0s>4FMpsED1P~L@k~3iP#%XW_3x#n2TcVr3}{02NmNCt@Z#(T zpeoS6(=<3oTFPOZl7M#dyWEUXkDV0LgirL?GR;{S!wcdckLwrs$ocI-{~qm}3Gl#+ z7yvLk7dA<4jxei&We~2c>RRzKNc1u2Dw?Z^;(n=xQDQOWkM99VL!<U{Wn3zfii?4_ zDPDIt<+6c`a>YPTAIe}~_+f6!9vJZF_s106Ph3W|0J0}MI{g7Hi}|Z8ogrR--f_pm z%p@eLp1~%b#Oo2H{<kgLc{%sV^I=<#tXZ>khLubsts?0_wp+123NO(f_X0F{fXeJt zz4Fgl?NT0doAX5=)+Ky-%k?(TOs!ebdKJ#}a6&TeSNT^dZJI4^BR9|1(>3t**96}n zkm8pwbtIEnB=ERs4b!;yvrHHCafab8)eL+6Hitps%uD|ak>~684JZEamupB`Zak=( zdvENATFg!5&QTnN7e>Gf`YhO8K>_M7OBe70#9@l`dFj|yQgPywZ1N7p(3k+XT$85b zUs-mJdj|V8hEI}$G#)uIc7Ffh-S&2HL^<~XE8%9KZ=rU|==$GoQAg0Ar*rT1!e@IG zOAj|GbDL~%)V_gVt3O?tIAe6byPJZEO<6KhV@Fa8dAJQI=9&o=gP~;S%9yN2&bD|3 zD50t1L4c%yG<4E~xVNb+PaeLZ;$XAvaXrmSJqe|~2WKOSaz7C17L$fffP4|hjtnE4 z>-jlLxwGBYIu73tXU`*eGTo3aQ#N9<0#6<q1d|47=b_K1%S4ZgRO_ouR(?m6^l~CW zzpOvAI>gVBBS;yTd@xP7QYgymX8<@fIM9|2X)D+{P}&qaE57{Kyt@z%R5CTnW@#9a zr_rvjFSjJG$&Q&3v=G4wD=1}xvFKg<Gu?z1=+Z4??~_4ud!8j_qht_&`i3|%XoSFB zd4Sz%p$3J8@cXUP7o6?a{C9|Mq}sdtKK-YyNB(+Y0LGo9P%Cx8Ed>;H>srV7Ry3jk zk}PL8w&n4YrNN2a&sx|!KWN3Cw^n5fZlFPb$gb)M3(LDVSS_yk6;?RB<v#wDi^|!k zhFicI=7KvT)h8Q0c%x|?%?9#<PG{y%s#+{2W%UCU7QbL1AhFYo2GvlM55if)4GTFO zTcz1FNXoh4^~?=eAeGgCZ#wT_9o0<6aP7<N5z%A|RO3)iS_lCNrqXaO2Lg}Ew4QLF z!A#>wH2Uo9k)+W7meYnbo<CRfW&XGKk`2zBX=t_VK@$z))_V(o99nxT%jDsF%5zUv z{;FZJyI26j(6+r2bex_b+pZ&g_A1pT`_n9lxAhzzR!QP#z0YOm6&wGa*oUOXS3OAq zjL%68JH~E@1#^Z(JSv+xcs5pZ4KW>tW*gOZKnyUyue_!1z!GZ2#8`WSh&mDs>Nt;b zpa;oTs^ie+0;Sv!s=;k;=@g`utUVNCXZO^J05jFn>=onfy(7wrJe@~m^HoVf#(J2e z$iq-1P(UfE^5%Jisv^M!=o0elp)NF+>T?L%42;G>ImY^6SU1h?*IiZe{ugG1c~y3E zvEy2%4$q8hre9QoN7ujCSrDEf6u($TVwURM#s}(S`UYw!$^une<%K$r3kd{OAIthg z-2*p=E^=Qe`Cn0&d@fp5YZ?<*+S8)Y(yg+uPr809={){fg!20P!Ao!$;5HXc*6J4( zZ7;A9aEbT5Dc58of7}vv33I4hh5DYcDyIH}3baF%Mf;0h#ovrOdBVx2KF&Jq(qzKr z@icfsk5n`zPA?M>hPmssF4MXZrzd9(ES>;WT3WGgoXZHyP$3FsEpC;ZN;zL)4A)mK zMd|W+nN?frG;An|v!9d}qsiB3>6B-WmPBwTyU`@>U@fOMJSr>L5PRFj`7>Qq#`ws} zui##s^<>-|Lz)GCXQpCltH1F*<24cXqfg-CM6be)56*5H-xkWCG1_7IO$l*KGzp!e z$ecjZo#22hp+BjL)`7TQS4CDAL+KjTQ5B?QSa@uae-(B{*KkP{4xn~IhkJ!CR8d&0 zV$mhT?5O$mkah9BDXI#F=7vnPx+1`@lp)WIp+BLFe@p@(e~+)68~#2E+?uTRbe5c2 zX)e9?8bV}HP@1Zb=kgr)$1Lg!*b_NV&@GN|>AxhAGG|(JOEH&E?W;{IYu52~E?wJl z&fI%)WpKO4%WPJfwPrbp4~L-%A~bPK!~2Yz;ni5eCGljk17~oPRn3Hts=MnINQ8xx zJes&5EEC;k9GWbl8O6bz2+VxBV)+AzDKmn96viU&D<TRNVJp#se)dPz{f6BxNAdJo z*g<b)BF&8qW)qq?$&~wV3ABJ)nBv@J4DI1+mfvNn5k{vxn41*FUhkbPOa+tMI!J@e zB0lY$<-Ef5hCv97Sh3iP;YU2AvvO8Q1}((g+1KmCXv$T38?Xp%6G){;LQ1Er=amUt za0jU2XWphSj-j?oOrg69`wqr5X}fWV2pjZ~ND8)N+$UQmZ<9WX@)VQP^YsFk<Coom zsl*nF`5I5(^F&%<|1SVDK+M0Rlcuv0B~!!;tJEyeB%VT0PR$Wk6X{mX*q>l?L?HVV zi@rz|sEeE~wiT9_H9TyFMz0BGG;a|=UR=c^ABK3U!<pK3O9MIbis05J4H?z)g$_F? zUqv<_-S7yqqDkoQfSNr306+jqL_t(QwuS@74REQDY+>q=pUqMHO*pop`GkkT<rzsy z9x+3{jzHUd!O9Zk#Y`;_)OLI3eMI~LEWMt2@=K&TiNji*0ZA4blvs+@n91?^OkfaL z-Vt!eqig(Ox2@WKI9c3kMacb7dpD!4ATPg~@a&VP{v7Tg$OsHL-oCjJ`Zni)i-X2) zOmWKSrl)xD=k=O&r+P}C_EKr$52o?aq)WEF53|wjmOF=Pi^buFknzQg^6o4$EBK;e zm?=Y`QM3_EVSZhz%`1T+w}zT*^yc^ARNqXDQ;QCM@zTXdwO*JznxPp%Ed|=<43k`N zf6rf0a)%-f&Mi*r>3}QK#je*x?FAVe9HPlcAiJ!)P+P0mqUmi<6)99Ss5ZTG5GF6| zIUv&~TH#RJC_);AZw4sShHM8lTLT50vt~Ak3uJ7~`arCMbDXk3Zvlc|mYl2*fIwLn zgmC$YCR}{xMt~#;XHpNx$oY)Xf(HXwvhHJ>Kfk$kkCAtDE(>a#+nFBY`_>0mc^M<1 z!a8a=nx1UzW39ltVqKv4<%L52%MnYU$uw&p-Zq_xsH|oVMYjQ1luE2TI*U@SSsP}N zE;_Uk*7XmCn0U;WvJk|Xu&hPJUlaPY7~~2CoYk%s6Nt$3OgBfA)Eb}}9NmgTz*OgG zI_|X`yK{pK&#fn6dv>3&0$i>jw9ZAuHJWDE48X^&2tEGdj?%=5bRDHluKeyke^Lk( ze7ehb*>Dh1+-wsvA4`yd!<@*wwGr%6p$_4bLzZ;uJpqnb9<kE<!jwwkCsf8{ja6wM z?B=EVNFNuUZ@;(<-nxe;R+Y$6Fo7{S#MZSIV;K0@YRTn2r{+_DR#!7(yeLm<4*V)I zdjubl*dUmQ_G>KK2>5)8`CmV+dLQb?R`+nKw!0X#C|?i6e6_&_$o%%f3p;v{!_->G z8mcPx865^<@!^2a3UVsZItWHyNTTi9<D$VW(2}mYso61TF!1Zv&QPUu-IO(i#KBS6 zv>AOq+YQHs(YUVBM`0_6uF#(KB^QQOP;GLzg>(>6d9)qtym!hMO*p^}IXu~0nlp<p z903ZDGf9LbyRbJotPLmO=wl7i0ndeewmqlAFxlXDv>+dP$Hoab1fu4{B^u*aMChAC z02yHA0Jv#`1M=l<(Tzj4&yK!`mYFd;1c0Tsz|>y{?g(nT3}U)C?Jjp(D5HQY$l%=M zpx~S4>Oc&^49nzvse+JK>X>ken~=AG&+r2IcMf0)OgR1L6zb0<LBUd|6nzgQ-OdZz zT89N~-gZa;Wy>#RI^N}+n;hS?@yE|xBDMSb7heFd9K771<Pwnvygw}F9QbiNe)&SA zqLRg=67>=t<UUlv4WmN03DHhhL!(p(g_(1n8+FQ;Ba-ZPnV7F58gUd@6G;Xb{?OcF z1v;2sN1;>^TgvFSmCA&&ctOU9?VM8MpJVW*xe_$Sp<zgy(0Eac>_~1`-qsT@sXiW4 zv*2(_(m0kY#-m|?NsMLQM}scP6RsiT$r8#7D<usLzq6C18?{|yy<9v4+}PzHN~!RA zB7it5XHG(F(;==9oTG{uSTy~PZJG!rz!?NkI!<FClc%5JaYP&Sed5pjozD+6p2O1a z_pjf-ELI3~x%K9RTOIrn7Mrs7>s$v8kS2Z3X|tD`2ReVxeUJD5lo3xxSr%U2W$z|{ z14d{c^Af=z3c(m47~lI#XNhiO6QUGv3afZA(Pn5uy0n*WB-oOgm%3N|MlKp{qZQdC z?ck$*bAPsH<YB7Arg%-z)hU$Qzcpp3E{c|d&LnSzQ@BLPXnzE#Cbr~<G2}4BBl<K7 zvy@BYtQ;Qg)8+$G9^IG$#I~){A)u&fd<84!=s}<*p+W@EAp}1e)5Y+X4j#Sjxj9ie z1pv_TAgeyb^UTZ{L1Yj-4Mve0CPN$x6E8Rwd&44gh>u5X%C?`QXc9g{iz1zkx^%JW z|KA7}3f$=-A(n%z@j<S<jth&J`&$c`I)ob=wmsgxkJm_6^!1%zdY^96MCrGcaYMc- zd)oe`vR)L9oo6c^Z9fEer({fMKn&|bWF2IoQHB@6kIf|KGv$~-7*akzW#&j6BhLDm z9ly?jLE@)!@%FtW3Vw@&Hc+z_o<802!jN7|NH2_<1gl4_=!=gsZCq3L^pMatS!aSL zApp?;FIKcXh>Bz(VW^35r$>h;j0JVnsLog+Gz4a$0-XuAJQXl>2%yPLb5%|Q8eZsO z$geWQ=oSlPUS$a@r1IS`E%=}eLK05K@+hDV&q5mlU00eKs0zi8R;?x4h&QjC4HQ14 zG#t*1o=@9&tgI2bt^_);WW$h+%)kNn%No_;{<s27zYPkjvgdSQ%XAOn!NI&(m_f*> zgFb9+w;{VVJ0tji|D?FA)jk1OnsQxW7Ws-bOGC1zjg;p3HtL67=!}>Z?6u*uT_I-} zDG`(Z(U;F3Lf3Z}n*7nW$X%X{f}RCZJabmk0>JkUWphb?4bJjBF)wl}UWBI8rrs20 zE&|8qwTW~B*f@)4bCX87gBqGm>X{`{?^KI<0524_PLISr{0&sIZtn2j=;pv1P?R=0 z^IMc?!VnYA#!*{q&}uu>HeV$e$TNTHfL5fK>X*0e*}0Qj_=s8dLfOj1w>srvnUuq} z6@e@}b(XX~yt+r^g@*NkE!U_Jj{Zem+O-C1Ava`D5e9q_G3f}9qr4FOMbbk%K8!J6 z=XRG%*+sx9OHS8R5y-_GZ_&64AhaPHvHPfiu7PQhtpT)Dkm1&wPcyFq+cbl8`H$-d z4lvS8p<iBOVbHFu`KvBGFd&xChPnsS5jt3c2(vs64{dt3wQ}ORc1uCA6!s!dWPz}0 zAYs-S7gXlUlvlbXWwxB_o;>DNOc0u5nv<>m@O@?la9pt5n<vW)B>m;Cm5_+Alz?BP zDj?x5h4dj|YXHQ_Mg`3z>Of%CK)YB3@q){#iOcUiE+Q`=fPjqm?p_3;S?M4Cj_Ald zwAdu$Z9oGc$KfOp9J-5feu2XN$swc3PdydB3!|bmKS+8lG<{`q=rLGm2$xKA+ZO)7 zLJUB57F`sME*FB-=H9aky>y6GGeu}}s8n=)3HVO#(4%pVag!gT{HNtFN*8-&{2rIf z55didTLRjuiLjFmA7fhl$XNm8nQMUeK|(6zU|dd)f)H<-tO~zF!Sw+R{v^ORE8d4g ztWUllq?av4;Mol9TSj4qPS=#2fFoBax1l1&FIN8M`Q}Sd!fL5(rcs04sQ}tin<%`w zkX-vKT^gWGf+9sTw51n$62N_iP{sha_$n^|Eb1s(Si$;02J;Co8E28v{=ERds{^t# zpb%w?7+46563Wh%>%on41%!!wd${@VS7rv_V5Eh^*>=9fPe;IuFiq7M;s+k(Rtc{q zEpA9OA{^e}nSv&P+D%9fX+v1fVHyr!e!)EQNOLX~KZa#R8F<VJ4m(2FBnn%eD>IYo z%!4}soziiyuC%pB-CA=1AC{a_jCerF;a%50F!(MDbUJhL-FT1CcXoK-q~tPic7idP z>cfct^r0I_n<Z|W+8Jr0Bs&&>@n^~z!3s#U%Dv#FeXDtJ;<B)`Gzy%=AUAatmsg6? zoGyv0hQ13Vl{6*Di>p|da@!c*(m%1FyiZ_w#LC$8Xj0}n6RWKfVaNh)F3m`Co<qwD zY(7w;k1*HLq`^0!I4~gt4f%nui8YIT21TwiKbfxr&@~eL0)s-z#}g8Wa{CljW8oEo z+$cohu8<b@pD2_o^9h<cQi7s)r(HCF&<Se1F<@onSNbCCAj39u>bc26vbcVRu8En% z(X29L4@7c4M?n}3@1AkNopjSDE#%yA_=z=4C@&3gkvHSk6$dR%g*Hs2Sh2Bx^-nVY z?b9r?y1h68u@`~=(3N57kx29Y{%@eH;DcH=XzRHDyn*`1SrnD}=%Wtf>bu4B!Ph^T z6pnA($&Bg82|yKrlOfO?_-5GwzwBdC3OJJ~&fv&==c^-KrM))m`vnsce6{3sl%clh zOn*$!8uFqC&9v%8<>F#K%np%u7eRfWT0{cFlt<FC3t?V#i^M7pih|<>6svK&d%wdn zak;~V=_-ncq^6AWHYo2z#iJ~E`9}zKRWstCXm>$7Q5erPkyEn+8t#T1ngh}B0at1W z_*V+iu~;{0LE%^3VG>U5bsgw<x+oKu4_jsupcBmt%W+_gdAE$MMAQ762b7B1i*Ho? z!*kT6>lWX%IJ$`^T$<LHLvyVMt*b{Bxv5_o9NFe$scNK2Qa4UBXHCoaY~D${v8mS4 z*3P6@`2t{pZdEPU;4LKYg#5tp?E)YTWVU_i&^lpf9~Iz8fw#8OF&g0!$+0s&sv*<a zD_vFar*hRsb5=;ay{@p3Z8F@;aRk4<`DA5hYx*-(jY+5w%?D}GK_v(=lrNyH=J(b# z00_NA@M(cv7VoGx4Ro12+&v+9t>}{xAXXKANh@l=;)1ah=<cPc1gg-Xi#!Uu$>>61 zFCEUb0k>gGHBM0S`vp6a7?*C-vG(lxz=Jcy&{?q}eP*Y&ny9^uYPpc3f$PmUPZs%? z6CA|Ea<o(q8uop-r1YqL{8=|rvaZBN-+Jtkj`kiP@I*&Mn&>D-;ecXq^eFl9nD;{w zbkq==1iq!*ilVKM2mDqtNL)O`6&7N+=B|W}W#c0BK4**0$o)_6py;WEAx0h@ikdfn z1C3#3<*w8-;&#?ujVRbApQ&C<!Kn}4&ETCgDM!{YnmmU52fLFa^FAT=vDDC87bL>C zKP@)*x;cd^W2;3p@kJLIbNR5Uq~=<4)O@>|)HKTgWgTn9ytwz%Lx){Ir+!`>NxJ-U zAOiFCI2B>p&sHAyIGV{v%#j5fJ$g=c`fh<%fs*Yqu#+Kji@<{(4=zWNWbQLMaw-V9 z%<1eF#rJ-&Nx?TPCnx{cS8xukR<W{<r(Dt~k1A(UMt>t{O<`r}TTtB3U}EthVNeuA zB+pv3!w$XW0k%UiVGpiZl_R%8{Q8K?#)Zj`zDa;4yAG|mGqc9=*xx;JonZt+XU=6H z6p~s>x17awbx3jE4M0jP0MZ?Ss<|gBR(7@MP%~k5qD~aNRf6uvm?1H=Mg>$jG-IV? zapdLG9$ctDt;~Cm*wXg~1_X54J4wqxWJpxOY7!YU1ITVC2Hsl2k(4On`!(ZubXN@s zcY{QPm%bMO8cdjQ(g+jodbn>XVm(rn+Fs740@s|9xD}EF4rm~O7=FixxsUC}(O;JQ z`Vbz=*s(1Zbqm}^1OVJ#34&BnWBCPV-r|KOhmJU)4nng=vUxn9+B7+@i(fRE=(3Fh zYJM5EQO>}dQUmnvg*sWSuVjb~9S%Xff8eYIFAv_3@S^5;7Y)+>!?XT&jJ#B7mNZHB z_q#M{smApLM|@IKMiG2}_4pw;!t*~7SwTJica6ow0UqagFU5@v!=xQRKt{-_m50mJ z7`*<(m|=9kNmq*jw|o<VEa%A(AVy7SWmFhxhSq{K*5ugl38@5+rcFjuz`I04v<SjS zK^9fv=6JTcL)Z}J3OV;$tusU!Dd&gKc*{f=aAVpzIOCK_y-ufzicWb2>&Fj{3RfBW z?fQD-XKJ`WVQuuQNSJWAMA1ZnqC|o@{e<Mup`F)Q)aWo<<<qoe4kf=PP`dEry5fzB zARHFb5<a=8YN~^zp`CR_ca+5a4}}K1bkn+V4cT5e-GAG{lq+6(6<N^EzeLR`_aH`N zq4MLVE(U5mW*P5%oaLW9P)^olgiV{VVH7?cV?UU@0nCq=3#!|)&82I}>rCICJKh_` zQVRaZJF9IJiVmEOaRx?(^w9CT(aK>Z8_0*BuHbRvX(uac*$su^w!r5`h@4n(jANGC zrf2$=MHA+T&SPlCoIiJ5o4RzAGiXy|i(;>uJc)Ni)2G3KuopKi66VJ@6RG;jkyciC zFN38yYklSJVT(inOwi`Q7kw>V0Lr^}XXhqwszj!YrjN2RH4jFTgAf4~@?t*s@fx{a zk5Ag()3d@`5=bRExVc+gITVVaAp;0VgaM)FRgV2j`seQx42Q$=@tvV_-H;V!BoBY~ zm3vDH9RKjjdkAe|_Nh}dOfFmz9F&u)$ap6;w!Jb~n7hikyI)=1_k%VHXSx8g`02;d zy7#u;j*B3VCWCL~q9>)OHb|R2#p`sAdy7nyCnlBcqHsi}=;zA2CE4Og{R~|IR9@Rp z`sGg+4bxEHH;^=;EnbqTJZ(@Jy~V1SA6<C>;%3jmGT2b@Edirx5-D!}hmFkW&|Wm% zM+0X%EgI0!$$PkZ2KiM^T<-A*ahfuro7~zGyEg`cGkJuzs|3N)2;9HWJ@a&vfJ9Fb zC@4-$(w4U%k$<*){6JutIg!Q~8D#wC1sf9(x$9&#V{YNzGlrvWP!`0`889@Lu?+!> zgrIhxKv#8V5NEb25T1f;X;>??uj5SUe%&uhY!`*3jhv$zTx*C8TI3GmzMh10l^KVX zk1y-mHK2FZ`0=a&ryuWq!;Q`q9B^D)Ttk})>aT<mib9A1(%qlJ5J;KC?LM6tD&B?R z;5Tkh?m!r2Z_34$h@~S<Hzb!t`wDZZ?{V2AaV`yD8ec*qqQR@lP<<wxd0Fy~x|`Ar z)4Lbkap$^Q4jK9Kb7xkv+K!UBm+1-@K3<N3^bImyJw)Rx`@1>;g+e%388}We_YS9q zT(1r*3R?r8&I06&>#hSL%s`u##~Mkk)Iqh*eNqY8vO&WXaR5#T95C5?YTWn|5v={N zjrXV?!Uef_u{im$3dJY+GU3%sfdf4MBK}-c3;e~_-|h}7A@*+ziK_tBp_8QC;q#?E z=0!8c;O_@(>wuuBNh$Y-xQ64Efg<iw;Cc{<Xe^;~plsGTOOO2V8(eg(d%NedW2pSu zyAl@zD7gRl%@ce@09NP>^dIs1shCNJgE#P?^UDNq(FE5Q39I2>0}xDr^7gW2+?xc$ zQxC72@R`KK^Qke%0>Ozn*fg{=$%tbBq+aK~RuB-<Roj(@3`N5>R!KDPNsCpR3r;S! zIbAhOe^VO=M|siCwO&M&8*TSm8lCAoOS2jW+8kk8(_ArNsBtSNO_`8a(^3AzCy-qz zyuvVwW@m}qSgdvY_R`qijVo^6TT@cjMVuc^q9X$U4k60NzP4FKIog84wpAxS0VXl+ zEgMHszHvF|ZOXJPQ#27tBM@-uQ3{oQLkkWZtDmErZS@3!3Yz;~fm?S`C=t|}k~xaz zXV*S}=%e6)^MN|tMYyhimcr&?dHa_Dm9Lt{^2-|?OI29;Sbx1M-`}%PvTW~JWQ5{6 zdf8Q?a;Law>sseOQw9nT8h-KH3^@djSGHklk%`uEb8KOaezc^6+LB1RS6d8C4XC9! zTjQ?0MoY{?@HE_C*?<q}8j+!;U(VMBw)3UjVCAfY7yV7O)1=7`+OSd+$&@1ilvj@= z#u8N<jP@8&^DB3|qQ}oY#CW^|axr;VwL%BnCm4{)3U{{e3mI)veV{D3UaVvu$UqOx zt+W;-T@{LpSh3ZSD8Ag_K#Pf*9R}XG-zH<Yj~0zkuT;_I)D2#{%8K|owBs=gbz&il zVX>QW)9C{8Oq)SXQS!56l`2_Cf-!ouH04V>#AV@OP!Qh4qbH5=7s&4^D1H88ohjg$ zOuyD~=`jhqj%X#(D6yG(e|ue~=uM9^%3hh6JN~P|Kb>nTUF!Vtfa}lS1aqM*;8Q&+ zYb{Q8=1yjW*-b(@Q-{MOiLJ5+JDmOE9Lq)+Bshjl7abDAy-I#@WS+1Bzq6w>I3i$U zt77uI-Mp%)X<V`3T=$sg#ZAuCsN<EB20@ite{masBZdQBc-`kbGjaS<vP{6|#n&b7 zHEn+sukx5gmE;iADs@f>aE7D;cV-WLtk5rYU@A$7iH^FXbAmavIZXTvNS?ztZ=i5{ z;n^yWoCSwqKkc@LXbCy-l$T?u@ZB692a+dA(d1(%?$FIvdPfzSikv8I<xjrI7(&E& z7rAGIz$4N-%3b&5qK>Xvq&|LYSa=WiXDvV|8mTJn1R3h}e1a!tFG)Q5#NJ=faS8k8 zw-kK0g0%o4VOu$J;7d?KvG=nanCoAd0}2=c``2h}OK9=Bx6Xz`CUCvokvStRbW6Pr zqw-b(O=2jG_xI|WHY+I+3Z&WObUGZKb+9TcXojRl+ZcKQu6=E3N(>3+H1)-@i7aIH z5ds}^F;2PGQH2GLXdPN57cdW~d3VH;W+iluHlI-v0UZ()4!)c!hVGf^x2*RB89-j# ze{18*)b)K*1U{ce`04+xZCp${AN09h0WgtMxOO<`gTN4)=g2iCIt3JU&OR%2G27%y z#{Z6v+XHQRK;pXmsc^K|N)ho!J+&0BLo8MN{tb)jjrC*8IGl#npgXbM%9k%u5E0cj zI^NautWrw0oGTJ$5~KzIYAyV(0_gB}xPM$h+>FSa9Bf7E)P!Icm@V?jYUIX=>xB#$ zQJF5kx%q_?bQDAAn|~J9r$2tY=zp-`(5}Ro!LOSrLN#wV;5QF`Quew`rUMZ#upB#W z$Qfl2O#H8b7(;b#eHa>XBM8T`Gc46CcZ!Tg6{AVN6>`0~2^h2bLx2xzGhZVwNSIoA zf=Nugw~FkJIVNc;nO`WN$I+0Gnq@mp48dga^mMX~q|x*R!Cih2HY@qG%mb7-&jp;R zW(KWf43{g5&_4{PM*lEcT?8p9GgJ1ORlDA#HJN}Mme5l-OyZ@5gg0-c+Cuq`8ovDh zynVNZL#0sY{As^5-p_NX=kqMzdQjpjuwqiL9C-ZVrh4u-Tt;{$I28RFf)|TXiHljY z5-G&^I6YA_#^h;u@*s4EEqM-sVSzgw_<3FZ5~c&&?wGPvXmN<V|Kz);q7*R+>q(DX zz17h-@Hj!+=RKB^3%)<DG_-z|;Bpnv;P?AA3X#<7++alNgP^bgbbSCBN>q6aoM85V z!bt)xLR@&INfNlBy*UOjxrQxC34rWW)6`t#N1x_=%?G1wpDb)lc$u}XS4MGH!kLK! zBCHuC2sO!yl}c!a?#8b>Q<y@w@@cN5<BT27&$Ka2o=kAZVVE+8yg2x0FLvw1;xS~B z#%ABIY0-D?FVC$UOsullnl2iU962<deL>K1KAG=%5AgdRs^!MAi874@SE(@y0>6-D zjd=gZ82<_&DEya8P?51?is_%1xO<LDC8NlL8>-Wiran7LP*()H&er1d-4<_rST+5? z-e9%nUnAt}m_d<D?!Me1^X%ozcJtfRcuf_-JYeFd-#oeQy}uX7!$rf{YEW+hj+2hk z07Do!sU=PJ2yWl{$8lW~Q*6KA7E6SaGt?;jGHoSLE_IQ}ui{|Jl`&MpEh0Tu^p%%n z5w@HhqRuEm6gna*l;k9<xdj(K-xP5JCa;`%=fQaj&7aCzWypCTB8y@HA@Opk&ApNk zT@%F+3MMm0vz1%!rgNM|6@>$PnP5(nL7yNoI2;-{WvG7%oo^p_Pvzuzq2MiQQJwx4 zHbZ%Fo}L#?a8?g~fMfe|LxcC<&v(8z=JJ5A6<4`7RW6r5l&_0Gr)=g{AcxKsc^rN6 zjQ{e@l3CD@-`C_+QysIQXy6`y1oGhAGzXgQwbGw~^H-8AhMY6)MA4s)%A~Bw9jY;6 zp2(;kC|In!vBIh402bQ|yA8U!@T`Z9m$;&)j_AB?NX5y@Cs9zE+yk_6QI0C&#K<bX zwB7nxn+Ro<0<gi*bpdo+Xk)>GlbR~j78e=1m~&@Ln%jDjFoWgg+*(+HNbXrG8EQ+L zHP(XMR!zOm97bj(1<;&&dXsS0bC3CW>v`zbt?KE}fD$DAe9w8lkdYQVI^1<*u;oNl zCrf0XBa=Nwt;Vw-?^jyf{huuZg*FJ(7;@_3OoIb2{txp&KuWF_l`h`>WdhTW8~(t_ zqO6p0gb`-m0Pv3?RIKddE)2}ruH`C8`flS?9WX%g6-(^=)QHrLStSF(38stHhKoyZ zcL=LT%!Sy+fIoMqQm_I*afYfvOubM;WF)&e5XW0^>Fds|GDjLUbvosTa|s5>tH<Lb zM~%yIKhax2g0{JW-?F(HndsEq_&*NQx!`>?T6M-+>Fx@sV03ovhTKo4RN%;bHc(!^ z&k%gIoypjvAF@N$jics0Cezuh&_+(1T=J^}DW^j>5RuX0?8J1m63{1$&H!bV31^9+ zNhNTPp6e%-N5>hv<hSJqiHoz_&c_(;TwLTpVwh6UaZ{XxljNI26%xY@3EYN<aKEiv zu9$6b{QKsjXiv5xZXp`UzQE$W3==8J-rC$*3;5*T0<jw=!+yM@)^3t?o`DTCpX#^w z{?~s{P}Z_Fn!0$E0X_PsKy`!)k|C=DQRy42YW$?%nU1z)xdu9<k)>=YI>NE6=m#7Z z6^OxTOGfAnM>}+Y<7FK!q>B@nWcPDiC#|iOuU$sC9<IxV%CR|HajT=K6$(xBYlza2 z&sA2b1D#lThJD?&(KaAfddp@vA=)AAYC}`L4<<!G7hgEaqfU`ro#}J_X`r@HE<@%) z!n2Ab?eT{jXsdba<xU`4OSx<jXQk6#iV{9@;{vDO5DVD-7^5s3{o=m74GmjShjxJW z2v4tm5aVAf!VVMelQtbj?Q^B(9==}oKHh(E!xN_b7;^ixo_wQ@y#$zqc2ymrOW$1_ z?<92lwl)Hw=R2(JOe9rnw1YQPJOH4*$*(7igifsd4uT%&E&6N+*0||B`P~#@Wb!sB zfdjiNGHP0QD+^MzfFjDHDy3TIU>*Tt1`y~u@4m0$=ZtZ7?I{+`KWgh*uhwH)1rw7S z62i7djSBAI9#oKAC%ttF1Vau_ix<P7M8FUsuV{|lWVrAm&XjSgs<v!^K@H&{8OF&P z(v6}&UrCk*Y`{4#rs?!*;zvc_Jq!@ZD5d{@$+{Oc>1|tE*GX(=-v9L`cJ}uUQ0;#` zTV1UpL15rO@F11CI+0e4d^akt(<W8_)bvL9TJ_ijF9j~f5tzH<GD59iaJ`qJe$TK< zWp@LrFEFh5SKsk{ow~ngeHixf@%+{e`X6M@!TB_`CoU_piiUPfPo-*Hd+~nPw%t{1 zdp)e<gVO7ZL9*w{34#i^m|XyF&OT<&ZezR9UhOBgCtkoV>dt4$0(|A)yY1TMbPt0Q zUtEh6cz4m05nLhKMaNVi`Zc`AnN;z~hEfdo{PkW1Pj$dUJINyYyU$V@0KI9gvef?@ zyqoS(#M5kd45|B@q4n$h-e~br=n%S~$O9KL^P}=l(qteb!rX8&XZ6{{{$>}{8Gb=Z zQLk^&JJShrYpc_=%tPuhY{qDJk*Q%mk3MyvI%Y60j=n@UGDjD#(|)Zx-)^3F7yOua zlV^dRkwtNruryA^-WUsDAf;qm4>Qi@vH$(6ugG8H^fel~S5u68y1h?o-0+ERkJ@{d z#asI(y@!|v9J}!CTr9f2IP+NmfDtpf!4?h=`_UC9h&tzPdrBI8FZdH#zx|Zy)9ftF zor}bKAm_OB25G)QIO-YW!fi3m#?&JIpe?y#_x~=!oFIT39+?s6s)j#1Ho<4hX^Aj{ z$1*RbE4IZ0|FKk!^X(2<z;})1-JfhvZXlONT9zL!jCaw-<hfo;$Lr?!1a#ONKY<LL zEdXcrW!?DjH_{Vlc&}2wweY)R7%@wq&vBm8ah?HmS2YzVR?v9H-?)kx_`US=+Z{1J z<a3H;&t+hXeY(VKzQlHA#`MYy4PZ|%;|Fqu#+43X%0q~^Owc;OokV}h>W*KV85py_ z_rFYYHaA@)%l;B9#Tyd9p*2mq&fO*QOYKZgY&LS^{<!-=E(X(2JT8mH3M8JT2axH@ zh1VLtndxL1`HJ3>ow$$`%K;`XuAMi_3B)=__2T!@Z<>uB=80GCb(lZrpV1N&jaxm; zz?TjYr`LI|iDEdLPAi-*%)T6%Vbe)hfGnhRylp`H8nk)2lqK}_cfA0n^K2w)8ls<) zC0Ryosog^j7m4uevlC$?ZJiyZ<3e?^2<^UR{w*Wyn&H^z3Q8KME8+{u0OLGZ_-Eu) z@G_C(nMurEb@Kn{4}j$B^rCEK%ivxnqI7i*U^fNp>W+GN@6Oj_h*eI@7u2eu_LF`) ztbW`m5$X}j-@pECW_DX9zR^p~zU_?GjV}E-h})>T)tF7<S!|AnDU%(s%8nbG*l2r) zK<vAK{r6tRvpN?+%$3p5e<+o?eq9~Z2`*Fdl#XMy2pmW%63M~cG119uxNOs(ti{uD zO_i&$WUy;^Y`qa2=Vf*j2nno<2D7Q-Fvt9ZOuS0p>=-?#HdSV?+9u)(h|6GRH`&~$ zh}eZ1l+o1A#2{5i@xe&tdS?b$KpQ7R7LEzl=+@*(bzX#QGZR_V$&S-`g_{QeFQ|yq zh@N<z5MY#hrToMrEEx2UU;7n4WVO_-!;R#_`0#JQAVyhlV@}sDW@tRU-%ORFUdvL2 zc)3t%2kr0KuK=ay_U3Wgz}#L2hx~7<U!Vt(m-~@42I*`NDy8ASjf~+!N!Sg7{;Q9K zMZ0rNl^b&VM7uJ})<5!oRl_4nrd?WmvT!N8Q@qrM0I(yVA4OHSQ#x|z<C&@0uIJ&$ zIKAiG4=4Tu&++&dKe{R(+Ip4qMI#F6UyfPDytz<=ea4yhaY>LdJi<YJ)9l$c8`evG zd+9ccwg2B{_oNC^GX{I+-@?F<x+vf?YL`NG`6X5nokB*9S(cpkyg8AoheLjQ_f;E3 z2BmbFt_AyegEDQWbPgS!E5!uD{Lr*cOANAhNAE9iwMr|>x!`TDFZQvE{SxvuwRUZ^ zRQ*rd^Lr|8D|mXNR=soM#RRXq|7=aMNySv%<*ZK`McQw@^&fZFw^T>y@cqUu1qORI zglKMB8Z~X?>+g5HS~+>!BOJlDg2WzJ1Up&iy>^_R5<TYYihUy%zy&4-9krghKoOsc zhp?C(pp3bRGoG)b!dY$KPL}qn%gE~irXFD0S6H4uk+f@F9|GUqX)c^q%d*|+Md^3E z!<ooA8^XIXUC83O*&TlqQ3em^e2jAT{LJTB^%+*9@;#)LIZ5kkFq(EK=hGOUQ8%IP zLUUHip`<t(=hBo0+z;of&QYfS#)j*osmsRwowC;3v0$F+H;O`D&*(iUgCEqvj<%Lb z(Q<6Z^EK<exmIgDVl%*3&Yo`o#Cg}gg{6WU*W1=hPM9X_5<Mepw1e*>%W}VhKhjQ% zUSTqPd`(vk4#mp**fp{8e+;vHQ+&)x_tR9dN%9b`@5M%grA2A^#G#-3K%=7<Psa7} z>ZlObTNv`}C%USk>BJ|_zbH+QQ5IBfAm0IJihgByxp0&Ejis^U4DDB(%ID5NrS&=_ z8`2y99Ti~yP(nUfT8DWty&-feiade&SSNIUX?hPcms1etXI(R^MC|&j82sPnyf|tt zfGm-tKZ&xK2w{aaGC`)EfiY)J&7&}3hB(cft16EkW8f2>)D<`JKan`TAU($3uHVk@ zsuj)@W_>tc*yin74IN7&i__j_ShUZiGo~i9`vOp#Y1^zG`JelfCHs)H_GvLIq-Z}C zP53ira8Dv?VQC8Y(M!9%ZglP&>;Bc>e`txVc_(mdPU5#}JTnKqn><207F_+*g_qf} zFl_m{A{Qg-w2F(s<EeaowLvQ^J8IR*$GjOJN`NSbcu?!!gN1z63fmL>&_SN|yD_$G zySymTI(~&sOV}CGIXbpa7nnIB@NwKV#%w+Uo8zClGv+)JiZ8o=G}a`5Q$)m6rP7V% z#(2HGKA@^2>AYW_wWWzG*0jCyLKWAuZZ@iplNW3H`&>!azBGN^^7)PJm$7u-pRDo% zNqeM@8&{DlTpzCOnSI@OF`Gy(XkSp%QdK>exu(QdkvOZ;^5d`Vck}aZNbb@ge<`>F ze27t&^V3-=Rc&H=aJ|`Vll%FWz5O`S--NVh5IoSEA+DRE&X2Y?zugnQX}am(3LXgM zto|<V&g07fvS&b07t3APvk&+?=J`0k$O&-A++aj%uJ;xvA?ZaouGx;k1e}&VjZ@3U z3rCJ8sRdzB7ybtTn~F2FN+5+Vo)d|_#MNCN?AeyR!7^Zgrzm2FsXL@g_ZX(jVmo}| zw;h_x^MlkCoM%AYn?$EuHG}*Z6y{l<(r1F<Hib69XUomjx(itM!rMgMwZUOrV9pO7 z46qX~Cqi+eXXzq=?IRMLD~UY*S&e5l>!k*;dxatjauLncmkSvjNcLV&&10iIgt&JI z@Z4|T&x<`XjDeV>h6>sh?YC($pO>439>%V3acz$tr+iPOFP%wyam3vQKFA)(VKq2p zDrd<q9ya~!G%@h7R>~8Je>bJBH_RtPm@KFVIESt*7>fshbA`%T+j*Q}91i*?Yv*=H zhIhTQunA#-39ACPKAf5HIytGvbFJIV)N;2ru9c$g_}`6^YIVi`YdMBM#_7Gn%Jg=u zW22zgGGkP)DRsg9jD%0*HW2Fo1`s^t6&U+x33N}PXsk7ihD5wYqTIqCI7;t^#J!H0 z3!@CV*s$xz#uZ4N=3fM#6Y(a#;}jj8u9&BSXgxVLw8LEMmq$R-`$Q6`+~(vcg?Y?B zR>9bKo~2|-93WrrLn7Hp8A30oP3n#Gi0G}IHa3+~^jIe4(_Abxcit_}!W8S03D}3( zg>p=r<1h7dLuXN&qBpwcoZZ&hatf8FIo`Rr!E$jzI@x&-3TZ|$^}XI^daz91n2A{u zcPu{hWXYGuhjMyXX2Uag#)#i6ALhj-7(Fny3X^m8nqhU+b<x5z)k!8c5Ik=lnAx?g zW7q!B9;Y{yOE=|H1__<xSFUjWZom}UUVVS}^wdj55ytucrO|8sAfHiS_ZqP5n7ya4 zCOS9L!^5};y?&ebIDar#=Gj>~<db63=x5uB{0N$~rZ>tQGkDFTWE|9vA$qT$At9Ep ze<Y=UKsYt*Ok#!=BZZn(&Fni5M*02Li{0ZK>-(z{irX-h`IF5s;a+)mN&L<6bI!DB z6hCb;U}{%jj)S^s3?Sy$Q9tz5#iUIV(=?+KAzMGx{AC;BF$}a}m%5w&Pup8E$1j}6 z59ulZA$*j@%;)cCHu}=*Vq7(P<<2K^wjmDTxma5-F&}=l@dG)*6;1`YVX|a$ufq>y zly*l2<tCNq*WQQ>uytcQT;@mn)tHsh*OY|4RSIL<)lKg;MZe&k2`oBa!%M^WddMTP zb_IUyIFK8a(Ng-;bUjJ$a+gtc?|pi{@*za5d&B9#vklT225^A~QroveFI_i3<M1Oi zOVN8{l{)Ip;~HP2jD#JYtjx&$_fB$jdq^7NP@M+&W8l%<E`4rpAS`tz1SLb|>v!1f z@=H@fv8i1?fge3<mmlHx8RJ~t9W3oH`wxFe^Y<CI>!79i+=X_piJDdJNL5kn%(PFJ zp)y1oCs<=V^{3srFkF(MR=xwnF^^QaL!$Tke|@idzQiC$Y8|su$6|E^lioTRTESWB zGwp01Q&kCFdD<&?7pUC2h#t<h&v3>}q?6IqrYO$)tQXGW@kw25c>Fm^&knZCA|m9L zuWO2@8(@;P>b!3yp>uqCyVmJChPW=M3NsJA!Z<st?x+<*sQW{$3aX<tFK1@EnCdfa zDc&C49>k`-4Z9BBoE}W;Vn21KZ9Lel+unYZbqIt6Q)UlOYtrB)NYgvHFyeXkhJt@a zg|;z!!HdW|;dq@blc@FT1MBBvX2!826B2QN+-Xhmt6pmqPAz^jXQp7_?8I??CS9II zuXWMDc|EDrv4ne9ThCnhr-kv$*9dQ9&La$;UzN{;XqLlSg>vh6R?W67iI^q#KdH$N zQ;#fpI|t=;n@D2gIRjSp>A|i;hNZQ}M9S%5kN+%O5p~{-vn!hRq%MCK15xTV14qMo zX1=f`4&ZN)7><+`%Rc73V^>&bdxPQppuOfv!Cc{bBl2<-&1=Reto{m5;_tjzJ}$)T z;^y#P%OJCLC@}LX`TTl2aGE83@1?TFU~~rS{Pb!Y?~M?$yjD2QbYwyd$wzZ+Kn!8I zbvK#bUGU?G85;OtsrVK}#ILORx_Q8?z)SGSO;F{C(Yu+L6HANPv?k4ur}x63>9m{; zIX8P^tFSPfU5}oWSHGWahoei^^tn#<)26KT^YlOhie)<&Xc71O3aaBzkQt)>@)*uF zO$9X@w@DsI1MGEbQmwa__?y&M3<>rbm`K+BEQ*N^n~Rlqjve#pQ=ut>*6)mYJOyB> zCb|r6HXZZh&y|g32!$}i)Bfq=<uEbB-mzAz#HiwWmQ3}DV0ph4_}A0GIMF3$@2lQZ zk<rX(V0MQmrR~zs9GoH|vCZ3>I?X4KvUxGVW5(|I&rsL5z;IK_M4T~7h^Uo9>)%*i z<f5-r<0SQj2$EX2{m@?P7k<@JGakSSO?Y*AfZcc{e#l#$stVlfuk4j?K_xqWcI}m| zwI1kopiAcrYQ5TQC_ab4Oft1bNksZYsQg*A5Ur~Rs|m-m<YXBWCHm}H`?%&2u#LcT zGrldw%ckfz-tJ5Y^BM14HNCwQ>7}9vGFQ>PAMay$3)u0tM>?c)=>ItnjA?#%zH;;_ zG@eI^;U@TFi(RPhJbcz&JmMt7-n|d%uCUI71T+82J6A3<W1ktFE)VC}ZS~8=QA1r@ zdFQF#9Z5N!_4TjfnQ{|<)yB-7`Wo+RPMKo68hdU~^}!Niyo<+;v~z;Hy>czvubbg= z@r7#rw7Sz7G*`!e!ASRDyp@Q{1)L+Eiq4r-c1E2&L#w*1qH+AmUIJAGGfh4Ludr^( zQpDuuS4@3<Pz#9d#4RJI^^<+-=7n2a>re7SKJD2Y9qnWn3ii*DRG%f(&RVi7PoX|P z^C+1Yz*2PH?b)tF(0&7ZiZZzOnoh-}A@nH3o*0C(aK4Bh!>s!;p=i~6RDDzUg@=jR za`+Xm99xBAn8&OwL)LVbR-bj5bK<H>??C^WHs?aKZj9c)xa$Ll7K*-i#B#AQP!o*< zXgV(&o03`8MBm>O{_bN})i98=shAM3;qfGc&5S<VBtO==Dsuri8#wvnAzGRuOJ7hf zXd&O*465Rztuw7A9|q4nO5*)x%@gw2rQ3QP(pOjyG?&+Oc_aB&?KDF)Nzla`a%f`? zuvhe2-Q2FF523F&_SuP8#+&$eUjy<yst{g}(_K0QiRW_nyV-_0@yw<$-zWR|m2Hp* zXn&6jWoztUZ}?3Sw)iJoSv8vvcYUk49XwCxV1d1lM_|HUehuUjK|n5~*Yv|bsL=Wd zKH2s^0{~jjG?8((3U5TN!f9AwK1am$n56*oX2Sx1F(cJ$VOZPWp@peNV>x~0<4?1~ zcYfNdhx~EA5Qb2wLYeY%Q(?yts(Oe(9#8PqE>Xw0wo_Mf`s2iw?Ir9V?(&l?1dCoh z5=f+rjEh=1FX0bmgNIC7{ewLXWgVdy2p=N`rAJJGUOy+!#WBwnJg1LHr24GF^}`T^ za7{91{UA9kMmp!1WlTIvg?gi#-keyfof$#5R=`hm%x80?I@x%uVM!^a6z7!UDh;h= z6114d6Vpi@7ps~y=1goILD4n)59WwxlD`(3oAI*tiE@M`Q(Ktz%FxccXZCz~W}Gpz zD#jC>#8aL4-7x#H_hzS*+}N3h;HlcB!t*TiuZeLpYU6=@XVD6r_>^+vdU#D$MK|IS zWZ_z8e&WX;+C}~JRt?j`Gn<9{O=lD+IyU*$cT$0!fUx;bZgCAvq3Th{RtpuVA2x3r zFxkNCx(QD}iEXHrMzhXL+xSwrCB|Ud^U$%yV06^wy2tihK7^BwO%YSD1Y(^ddy28b zu}vxKm@_k%<tWRE&`z}Kd)2fIIC@a3mkr}Nfm%iAjunXGmf0rnsWytHqE^Q3z*+hD zBQGyh<2kcq%j?EuIUUr_dLha*#EAJT15QgZnd6)q6xoB!>oK3q0~taM;~k&oX`H{; zP~>Q?bFX!i|4y>G#*~}~wXc5<w;Geb3X9@gRq@-|j|@2qo*lj8^tLchHLK^4^KstT zI5XbOCL*r`4`CvqBA*Mv)TBHQHjBgF1l%4;AlnDUako6H<-HGerzso-=*{QR!|A_o ztdT*sJUwNv>)+2jq5U*W6>*2RWcuPK*A}}5wMBWe(|tjf^(AR}aN)_9^Jw)WMyPPf z%GvAk=dmoje8oIB0Itl_sJ8-qQga^&mYreZ0u{w;RU92vXFSZ1$Af&FWB%m5OO$NG zEqxZOtuVFiRSS@%KEe>ryX3x}eD*+Ta+`&U35;@5t87Nl15-Nl%+_aEvrbG+hV}D} zvBfQ73`|v8Y!3@>Cchl5EhtuPw_SNwV_kysqmcAEZCNo(!;a!LRMjc0)`vn_bAmL7 zJrii`xE_mMH5CDt{WIWt75=`1R%`s7rEeg+bHD7St|t>9bS`PLq?vbJBOlQJwf_c@ zI*IBE``w!S_9~?R(W9PAVJ!Py=lu4szS%J4dUkByn&SIv$vt!Nn=jMcKe5~WpO^3( zxy-nu0B}yIiRWcd1tOnbq8IF{Z}e&H=<E@7BFpR%;jGD9O{)$#D%zTN8pB;byp9sX zrx)|R4o%BCs%n@s56ZM_8<CXC*Nri+(-YxiId@&kv2k8&$+t<p+Vy<+A#av_Y(74m zc^phOy&K8Xh9_C)qP{0XdwfH6`2Hk&0sJ0Afu#3l&q>m|i`>9#N|5F6E>MK_dL1;A zU1J(%(JWl+s%-x{WW`qnS$!ZefKowGojDQtal~IZ8Jk+);X9Fevs5+|*}hj?0#kR} zWdzj-SY{?AY%}Xi;+ca+&)zpca$RMo3O90&>EmR)u_TCDW+(6E5YZYcM0Njk|779q zbpF=GF5(km+;u7xifv=G@+=|VF8~7=B|2+%@ft(7Ut-?OgGd_Ny_6H5#-oeVV|sKd zXn%Bq<LNBonFmr9WEI&(4{4m<KMQfzx{f+oY;)%+{gD5b&4m&3*B~j0C$0}Gbj-oO z`RB_8=M1|uhJ#97ll^BTn^Q2Uz#0r3)IsxQs%yRD0al$2ZGmLcI^nt@h8qB&$aKQ` z-o`wl#@!Orlb@;|k%O-(%0>S=88}qOE9`BIK9ep&be#vYP0sCmGcanCwK@5C<^g|F z)07*B`~AKaRgmb}l0HE4-{H!@N>M!y&8!_m(gaf*;8HfT*_AIRUcFJu7P~8Qbt=28 zO%*Sb-oNe{LT!W0O6dsQ0JsRP4PMntj>br>Q)^m4ugY)YWb<k`!#Wety}k})3L%*2 z-xP?lZ+57S0gDo;V`kigqEBpB{phNdUFAGq*0S<(Y9=KwR$%Ec$u94sYdsgPDHhZ{ zdAdKv6`%Z#?e*iv&)GIgHk`jxf*I%M2;J1<IrhWn)S5D2m$qZV=#r-xj~VYB=|2PS zEcG4*&}K6X$GkT|J)Z7VwqgYsY&=0VX1bkr%sZbVYuHI~89oG-40>NDwl3OkBx($P zMaNFVywZKUZhn@A5q+%H0EnldAkV3c?YF=BquMZcp-RW)*11*}j;)>}AnARJcjI#H zTQC`xHh<XV57~AvlKu`~HNIBg<W`e&0lhI(*tjLDH>MbKVtJX4IwPCewoafPbv*u8 zoPxh^qJ4pxIFPAyYv7#p{t{)%OqL7eyYY9JRNrf8sk&T0K&g4H>y|9HhY_36OMTnO z?Ub|jh=(_xkWZRZ!{_6fuFkxy@(9j${K2*bO2iRE+NCDs3&H``m{m-j?|WCi!qN5n z%sY`EW`ZibIIAkBJuPiK3*KOS;Pqj%qH4#*;F-58%a2F<eWBGDPiIBJ^XBDZG+&2i zv}{nvfHF4c0B2p^XVk%$$)3r<hDF92+Qt1}eG86&mg&h!_x{<h+f>c^`2YG>H!|B7 zF82n$^l_*E&jM8Twn$LY+ER6<%4c7WPsMM){pnBGQ}ufn03PWaOY_4v_3M~@`;?D< zw`v`Rh;9T{HS4ps4;ae7GPp22uI!rYmzVWGCdPS06`bsvKt386>$czbO{C_EWb5XI zGcKw^SYdF|XoC?OhQyDpbFYHL#FYb^Yz!1l#hKJMEM!N>9StyfbANiDtT+wa8z|}q zJ53B$-6s$3EQC}yWqJ9svCY~Ip(qUon_rDEDOKAs%U@BmrtClO(5D^9X-quY%Fn^2 z{&}J4gN`Mqx(zyj$i2j1k*35iN@v=}P%YQZ<&};nkz>R1F@H_c`kqBA7<JdeHqke_ zo*${0qZzZqUw@bV(&)KTs_h|scKDw@b${P9Rp9z8EL;7D!KTSs5J%b1F4ISen3KMK zC%bj^o_XjFUvVz#bNuG$xbC{u21>3oDg!po=)JjItm)a;TxDTWC9#m8O1$0!7^Ou5 z2)ZNMRmUj?GKt(USHKqEUW>n3@O63qH1*=x80g;*OuSCdq+Ustrf$qfuQ2~;od`u| z{6~?~5wAMEXXgZ#))ChO3ngUCzwig#A$@yApI)<2<-oVaJ}=nNaU(NpE1!8Y{#t!h z7sE8NA<XZ9IpaG07%-heaT<x7)CA9RRt^0}3QtM{bQkC@d?Nha71Ix>aBhjJX;aeR zJI$6D(vW5vIuTg8!VU`8bPSk=dF>bYry@DoK9<d$rV95a34DipKgQaAmzA3V`wq=G zjTn(+27Rz5sz?0Kg(mW-F<1n`jjR`zs3!hCwCxV2?A^*R)wA)`=c{dhLJpdkSP9Q} zRPB45r`j2onS2JNDk*L$<OUm}go$;;K~+X4<ml{E*s-Ax2hZ(Zf0(o6WH!;BRW<&A z>zk9UFdH&4^zuN{8IZ2#6V}n&E6g*vk>)`1Iw?-hk;o^&FqJ`6oD^c^+nLPl$FPet z%ChG=WfO99)dNYqrg}0XFK*MQtIx=<^`{q3SK>zO*g7X0<?NEPGH3&!{}A@AwPi>P z&nby}%vO`}(pHmn*+z2Mv0MlbnbE(xtM29WV`k;A!%x;?5^Oe3dkU17W4_X(=F8$x z{z!Ld?O%TPe?Mj%Z!)gUk_hy;0fl98G^}bBvKnmfKJN9M;%;MlTkHO2U8_BoXCfl+ zOJiCIn(;EU<)iCImV+9$lJQnN!d_;T3L)Ix(6Qo7eUz_%QXqBS0tE`{q%l{1A|6A~ zx;lm{@NwPAE36G>Y&mBLqnNA?aM|-N06w$zP-}>QJRHO3*W);&3@z~K@s=*g^vzU# zv+Z*THDyDuy5;Dn;_Qq+shj&_JQc+u=7`fCY?cV~F`og2bauu=N}oHJbRaV>slBgl z8tb<&<cJ{qz2g?K5cjfi#^CFKdVLxWFt1u4q~^Rid4EARC2vSXJzLg{bKzI3yak(z z9la}~$F<L>s%8;0Fcrb#+?S;ln=-XjKXx;72MfEYf6>5ht5klDNquaFOpC8Q41N=O zc)ypL{cl;Hh4l~DvhjcRO?hqbbP7eIgy|Y!f6m(#Mf9>7bfCXAN9f?PJR$3mMZrHC z(h9Q7o>qk7gUr`ZdW?*=C137#B#=Kkb+MnE@OX**c<`++FTGk}n4CH2aw7XyuF$I^ z2V_$1tNxlCdTfCB;vCz!lx1EFr@a7EK&-!WwN>dd_J_It#5uMKYzWHj00;a}%lUFu z<#~;1unkmWH4#x*qHBR5CdX6rO$v#hG3nZ1`_W5gFVr&2002M$Nkl<Z6UubgJkG!u zpITui+pDIz$;f_p;jsbWky)mUrSk?|&eih*GZ(e84c;4ElR@0!{VRWPU)W~LS;U+Y zqWq4y03e)mOCA&Prb)=_f{C={a1lFRun{wN2#L+KHur6Bu81T}*#ur1Hc#cKMRuli zQSskN`f$y|b73ajdw+OwBSLiupT{?sFWoh{pqq%;RtqK1(${;D>-*06Qow3%el{w& zX4!kl)V*Upgfoq^n~zmiMLen49vOwrL(oz2k1fLCKk-#-T=<?8){ptv_}HthNas(4 zJ1P%wEi_hedDC0UE^c{RYQ8>Gz_YVKc$K3Ts^Y*(Y<K4JG5-*c2l);1dRqWDHINc& zuVo2cju@s3%!gTj=U1n-%$RjbP`})Ri2d-Ayh7(^-Siw?n5$Mv!G{^Ask3=t{}cN- zv1MKMN{%OTelz4@ddp;XUstwEkNL>H7<@7QPo%s0UZmoeyB%)S*i{S#63aAf6;W`E z)PS=Bl*FAqr(;d%UxpOcDtwt>imWRRuuEw9Y_D@TohO97Z0?Sd)y7ZOHj;>|<W_|| zo_`!h$x}{=N39a*%ALRU@}i{`j!^yNv>0X8jgwCtTjc}%^}KAT2?#K+@&-SzpNNpI zd4!dh3%YJ>abA+0$d4lO9ZP*$7663d6YsUEWc_TA{m7%FXfc5QNE$Hj3@Y1xn6G^H z{Dh7qbd7VwDXWe?;*yHjQ$uCFP$qnf@lVS+noIzXSH;Vz^Ljvalq;O(6qdWZQy+il zZ5w|02V0_w@bV$%IYO*Gch^PK*h5^}7#LG)inG42jqwz$wU+4RTQL(yqW1GM!??0* zin@%nT-vLnxazeY6k<_kQRgd_+GfKwp9$S$TB4sl4I~h=^U1N34juu0cJ$8dFDjcR z$?gcHU5(jv{`|}j7qD1ha#P`?Ug1@invFXSIdCq;D?d)LUR#t|@rgb<r8{^&DcYl` zcoE-OVayBC=TXElF8BkQ8A>@-UJjRRg%J^^!gT(Nd5>WfQe*SBgYpf?OVX)bU_Z9# znzp@oW+zyG|GHL;GNrb#Z3y;HZP%N8<Jt7+L`9bBy)PuDuLYR?e$V`feZ$H`qQ$fE zMZ7VV{&pJyIr#O@KYus<UI?U0|FTcBJoX80rKbCBsEGyQk(+=1*0pbK?fDkh*%Uoh zVYdJ2|2?O;i(D778JRo#=UV;i+`tW<#4Q4|jwL$t_V{iDrOHOB?~qQB$A`lCYyXna zf>Pr4KUx}iqQMTHXAt`<_3P(f%r-9QYBX%-iw(5goXnI$@$2x=owuELx-i4<;vuXW z)4<*kaN^DF9SCe#Z&YZxA7>19=XVje0<m%a-KELi9=bvucz0=_XXV4Bjt;aGwUZ1} zKW133;=fV)7;wG6$4^RA;)$fW(O%p7>*3uStiAO4Tpoy1);j%dK(wC^RZgSjB_>fd z0K{q{q-fPz3{S)ObdMVyyZXFPp<@q2;T&4wu#eceB&F=f<Wr})-zV;d(ws&D(gtG9 zxNOAR(C^l9q4@S2L1q=b=2^!?qa@N)Et|5BH*0cIbi|@;kc7h5BkuaR+HhyW+Au={ zR{FA9uYNF1$;;<l?0P`69yBt2a~m+6=Iz>r+%m$m5AY1~CpitD*__l((P@lR)9af{ z#N>M!AT_V4cj(h6g!>Jvf{3eYeMbfA*Sa^;%&M7tpS+W;Y5i6qhH7_It$f-ihYjQH zn=ie7Nt5#Ll*JSIT8eDP;D`Jr<(YcwD4Kas@dPr=v4t^7Fk>TfPDP5R*QH(zA9%#W zJMR)XsiT7(NVi)K1K!-eu1eE)4S=_{ZUQ${`*1+Y2nmOt3>G{!oylv8a=lkx$6br7 zy{3_A3_)Jmx-<t!hca$C=VN`-aTG2%8IX;Y$Sb_T=zirgA$Yo*i`%#-or((n=1Mp= z!|vv$q|T%oW`c5(7{KpqOI)M=mQBmLY)pkK>=?%xV9;#2*}Wjjbj>USleI1jM#^8Z z{d5jmeB$m^G{PP5%^P?q0U{<#`Upo6>?=>>(MyaB&6S$oi>|$9)02ww4WTwvcc9#j z#Sntn@dNTxkBzmgc(B)`I!ijg#5x9UeVi5MRg|BDic|ALI_;mLA995REq5Io19Z)l zOwzN_OUZc7>&@|}u@+jA-AlBWu+Neu&6NwnrZnw!1O=Tp*1lU)^vo4nt4v$xs^PYd zW}{KDI7L6@!C!=edmf*e$+(i;!XqudpB_Ier#8c5sZ#{TUcB?}NEso6w@r^MmQ zut(gAh<#Fe+n(rMX-xN&v@lm`N(SO2f~Ow=Q<HBoOk<p>F!x&x#u$O>W^26f7L$K$ zl;_fzyASWcuot8BR9Fug!0gF3`?U)))g#yRXjL1F=A~p?eu~ECjrogM;h_&X##m14 zhTUtO0m?_bbD1;%=M$m0W7`|BVd^pk8jHv{_x4mD+wr_IrYCe1onPM03rM*juKERM zZ4=RBB{5d5)UK&U+Donob+v|xRNolxJx*a?lMH!~f(Cr}xGpgxL`h4Q4C)Z{$AMoq zbVE@cbRUHMXOqJrZy#Tg+yH(u9<krfkT0{H%hAwlid+-VSB|GbS@X~T`hUyYW$p>T z$Q_CGRmo&a!XhXUN%)>>Gk#@zqxSQ@Cp<n`8=f>Y38Z6}3es6Q>F1NIX2Ryzcre&N zCEMF+p9#v$>c8+N+tWHc45T4qerTbY;<$K`MfLO+oK_3_ALfj1*u|3t<D${3)ki4F zQn*4AS5Hlal`A~rFCo;H%{6okF>F95RnyA#;k)p?D(f}9Bim2*l6&e76HdR3G`aRt z7)WQaUnE&JU3zB5bP}I9?*ia7g|P3tE@}ir=)5`m%r7V=;G1cKxJi9%&(zPO%qVmn z5cX*kgsDdS%4a{uN;@QnW=s$&WWby|X35f$4L6?D>~2aG7r~J_;PtzqI)ao#YvZ!m zh*?(u#Hj+HBzD{%zKlU;bd0|Xzs*K6W#rPKX?(fW49S`j{St=t_QqWCCoEm9_88|s z|9i4!RJK4VUP+L2fD-r7l}_A{kov@^={N8-wg_a(I-1xlLrAvmRx$$dcgxD||7@b? z#K|<AIG+KL`rXA$@#*{fr#4Hn795NEiTci>b6P4b?xmDMiV5KFMHknGGCKQ<CvPz1 zdSyD!pJ+7|rFykOff+NvjskMbpNcJ~G%|EdT)7T7ks4<}#MC+cV%4!Edq?kC(gEsY zs~YAupOF7<tu{8K9oPnS=QHD)qFm~}rm0}+AiXC$3!m2$6HC*p*3%u=vw_3?nh*<B zf&4U|o-_XeuPKIr>haY4YIu#urvD1nx-moxp|(B?5<&U8V`IZ5{DmqS<>q?KmAV~| z)J-2ea!UtHrA(f@sb4J7@t*ab@DXTx`LaKiMpoaxubUaR=!Iz~*DJ7QiBv9~3)Wj7 z3}_jWdajM5+XaRu`_>j6hggJa$JHa&SgN`t7J8;sU9wSqP7ZkA6%lfiA|0++Y)J5! zaF9T7=f$ZY?4=Vfj|;$U|8(V(H6FL|3^fqHuvW&RpeIHtrmpXk%>yC20O8@&_<MU$ z;y1ReI?niZNSUvEvXP;tskgl_{P0xJ{0Lv|u$+$<%ZXfl?Fp7nvSE(=sQ$sPP-Axk zPZV0EscUCJv<|8+&-w>eH8cR8#>PY8Ps0SxJa{scLSmH%dH6?;NI`!-t(|}eGqY{1 zX(H>Ssr8^+ndM2Xx~jtdtJ&|Z3CaQgt2?9_*G<Sxt%*sj`Jgilt!8i*kgnAVUduj< zM`)lh)lMlMSz9TSqD9!sQ18U^2J~`Nx)V;EmG)tiNVr>{Q8KBaWpeMwsH1<(Za(eX zt>7Dzy*IUhjcLJ4SK$sbLmFvgNZb;bJL#BtS?ALLR`Pe9Ew+Jdno2DC#vt8>M`!Hc zIKeu~HqgwuaW$S}d;CnKf|a>(S~xdxK^*L=j3NB)kj5{-NsoTmmy5zPSk0eoTnigl zBCl7lRX+0${$z;{`3lE_HX~KHKpFf87iA*EI!3HNpnHmk3U*4E#e%ZvLjmW%>i*vX zA8ANjX^Jf51sJ;vw4(LyY$D8$F>mJPlh6KdlaRE5<Zr2VpA~mMO~#e7y1(B|L%@Ac z4^x_>mliTCa<<E%l$0kfj^hjmhUbIAXd_;nP2n*o(BluKxPAD4w>R&a0ejCm$XbW- zllm^B!|DQ;AP#oJxwP{|^jijUT+WXrrlyE?+c6*5{%D|KmkqZ~&Cei9VoOx_wOS1> zk_2SC6{*eK%x$dpZ<KN)5-YfTLqY=d>tJdpXKzQD3jw$9=QXJ{eFZ>Kw+52~&+=v5 z0)CW(OvOou_NnW>Kwb0S_V@LC<xkO5!vV!kXI|Kjy$R(k2~7qaZ`6TQul3AvE}q%h zIjSVsYj4EOXlCOCa?08h&oCK%ADCp!9-KZCemxf=d*wfUI`lT#GO2jG@=cYAEIbRK zKU29*%&I%2-_slcd9KNokLLnb>QmL(oJM%<Pf`6$^ho2Bspe)gT=!sp`>VT%tkAEK z8!%Hlg*t`J=Es#%={=5?8TT=9G_<|@3EVbo<<dD;>bHA=D(QnGRL$Von-N>}uYc`1 zHKt^S)kJ*c^7zGWqfH(s=p0VqI(J&k_k(rc$M8eum&*ogAg^iH5=C*H0+1#^ld9e^ z1*Qowayqemj1oz_sX;`T2!@H0T><5hMoGku?-ycG5Vwn-P`ga+3EXK>yo|qo3Caur zO7W2I%)}kF#@sQ?&Szf4wBVY$BdY&YlSt&yBCmUNV8<fAu70pa!utEi7uA7kYN~D~ zN@FlZKG2IAk(;dpot5=tON0S%-lRT&1id<ik;lC%&zK(rD=ZV9)hVf)i|&Qj%uznq zcup7JEX)c#b`CZIROIrKY#orvEjiJjVCf;??vrAc_wLvXRSLq~ORQOD;!~EF5am}B zOlr3q2{xK^%ycYSGO1FYp`8{!2AIxi_wP|aE(0U!mly@cU<=Qq|9K`EHy<Qs9J)oK zIsV?F@iYxAqhDE{Ev)OhzjpCmaf5*?$z9avS0`4TkG+8oWhonRht>{wIJvhjopmVj z&qs8&Y;Q2jw2pd)(%vD%mb+gw<PI5iiGDcKnYzG_0oTm)2W9i3Noh3aXJNe?e{R<A zu}`DM1381HUF&+HrW%vOtluUh@jksN^@ksI`NH0(kPQaK1c!SNuA%~v+>L-;1H9Ob zGym4taHj%Wlp!o~sbH0kAJHTip_tE4Gz^{F5XARa>D?z_VzkXdxPSfmTRWk4FJ|W! z1MKUw5-IvTYMUo%Qoh#CWnP%ldKT(DjW$ISsm@`74KrJ#%S@@SY%m$E!EZ27h9s0$ z%VhKZkAM7`^j#Zel?`S5TTNSA<8M$hH`5tttUjAxcDJQ-&yB-tv^k7@jzly)v`TG5 z$NDaJ1ji(y$H$h9d6%a?0sqtg<7>At5S((C-8aG&n7Hn`g2-H-S2to+9cTE*PjCXA zcPn?Cv6WP7`q&2@;A}7eK3z(8{Foi!4!|2@>yJm2FmZ<4tGTjTLF&|Iga=x|&)i(B zo~Lb2P1b9dd|36OPY@KOSm#8Quc^Lh%i}@Eg{Cu%Z`Mh*?N_~dTOi&Cm++9y=4BC? zO@F)VTkSbCHZdD?UDiLfO*Qx)FP@8x-N(FTOi5sdD>b{-ethkQ`g@P%6npbgxAq5) z#?r>8?NY;I6JObsG5OYA_`OM!{w#QNYBmU?(U?k)6y=)>;`*sdTk;}zw#r^ddfzvl zY-S-H$G<W@j)theXfMWQz+Q{#@;oOUff$A2amxZ1ViWt#J|u8)NfhI{q`@;?6oH;p zC%xP-D8YI}YLT>#nA;3@U)EJgh;APnS_OkFxvZ)ha3g=0E8L-UQ+UI@II+++Olfwq zKDNY0sjQl<g?vQ0ZrVz&`ja}5T#`z?qBqtHV&cbhXxMk`Pk}={<{G<F^f*<=)Ojez z(~mvn)#=S_XG;#kWN5R6&s_?6F_Y`_IZP2c2c%b=1o&O$##5nL=PX881UF(4oIr@X zzrdpTNxZ^1u`Y_Bb5$md?L~yTt3N|y7h+6x@1W*xz4X`bI<l#inq6V)-OAV{)+-lq z`=VVW*lmcgMwqZ;i{f0(eM{HgRV9zSTpv$BB5kT54J5DQ%a<xME1W*7N*8@6BUtuu zKjDo2o2SWo52v>-K|P2^KQrurl-61M<ZDxl;L6QJ8uED7GYCl~pY96%X}|aE%935f z_|o63Ja7tz(>sWM64XB|VAIwyOa!8Ab(<LXtubYPI@W`+j4e)tXG1`1jVp$F*j`Ki zh^cP$6P8v7GiLGXaQq&kcY%&Jm(|D6tX5y8-HbDJHPx7k>Xg)16^gLdsLN62Bw0rI z%cN)@Zj}zXvYwiS`Eg1$h1YNOUHA@mvT<ezJ^`F1T>~saV)Fwq>uy=VyXqA{k3z{v z{+K4px^fs64O~cBm>QS-aqw{3BW>@^68`7L43c{Cwg@a1=KF!9;&M=iOZL!mznJN; zJQ>Kvch)*3vAqbjZS`0!$-HOhO>d{jLh3IrcZBBtLZWAw59;Y*VRMUHC9T&I!Y5yN z4sfegT1&g~XTz9%T<+jLKE;}Me=JxJSQ{!*DkF0|hUH2(z`V0phNj13snX<der6-} z9~;Dx3Cj$FCD}wWayWT0d-HeGWVOKw)_SDcznYR4NrOPNJKc2p31`uOZu@;*naG1C z1y^)Fs=x4y9IZ*>gt4bcW%d<;Bu)ElhG4oy>3uuK<A;NOZ;2E!>6F|xSTW~II|`WN zuCZtv*En-_s$$#s85~^lhuk^D6rAW<gQ=#MfP78*vhpUZOHkk}aiY?*$|cFFw-(P` ztAJfbJhto<O>7hiQCwgY`sKHF$=qA5Dy70-uAe9yWF>a=nkDqNuKH)zTPbkLuHMTl zwa&IgQ%zlsWlYQamXV9BOPEbk6Up=b4}22s`Ru=9uSZ>`PiX*Bw(Z~&gcsLaMC>W+ zsmtxg)ZNUbOV{b1vqj`hfOcszKwB>}x!~{t*bzU5>S;oaLaQql+_PSne?KgWaUt?P ze30nR-u<s)!Vx!l5YEFw5|te<hjnZ}iNeuZ$^ZQ}{z&*Kt{504O3gJUd_TmzVy#P8 zwqP=<3a&dA)d^tsYm5|-vboAE$YJAPnB!>s-%QlQHmAEY1A+UosjfbofB(lH|8bAM zy$}zZ7;A5xzb7Ch+k0hu#rYQB>0Min`SP47O&qj~OB2jYV!}3exkbL_E!^=xw}s_j z{k6cNDciVJVyw#rjYu-SVFo7q{k^XTl~wt+<f-_(*{j+cB&RK@_VtS~f$do{so9~d zqu2c>5!FZSSo&9o3+Bs`8C;7!It!|Y+icvXC4igf8mMrD)qu~W&}hS8DXGEyPP|V3 zKQ6{#A^91E73!QsYFsBxzT(T*xxU5)g<YXm&Yo$wySg+$9uQk%8BYRDu<QF~9r-gm z;}`=@Mf)Dz>|XQ!j-sBbb2mAV2m(H=4282ElIdFnYpl;TxeN`lNE=%BgzC&KOKw;^ zM|L%s=hR!$OOA~;2DRkcf>vrJ1BF<Sn;G%sWju1ODAk8sL!`ZEB~j38oUK!!7;p2- zH2mfM0=<42eV^?#Os8aTY?_YF$GV9}C7&-t1AGV-7*~wa#PO0O^hG2Qv9K|VEq$gv zFn&Zezb?a2SdO`Kwn~DzFDtk**jg)NaTN1iWS7Nmo+uMfHj|oip~;M7Gu@nFg7<mE z8K%Q*QsIGT1EExusR6Mmexuy}g!hf}SZlldVY5sRUvwa)ee*JeDQ-INon;-hD)p0* zCO<8pH->eIdRUNCmNlX#v&Km81>uw(d-EU2+N3Mg^+P0=p!(TD2clu@Z79=(v>C+R z^uL<g(x{;T`o4h5+~U15U(=Mm=%Yl1oS79QzLnUA``8&}Zq{qsV(Nw<9=(LYH7+(z zk+4V62({CtW|72-Khem}^-)Sz8J%p<Ssq%vGqM;Kn(<MTh)+Kp3EmM|nF$Vn1wYqk zS8plP<hNTf>zi<1pY~QhF=*YGh77RN4gpCBB~7W-?7J1Q4%NF^Yt@(<oQkGT+vVvB zSz6|LJ1f~D^_b$lzDcj^wo+J&TTHkwRoc4;*DM}SoB`Ep&WI=J+1y#hLiaSr*Ie0} z9<wPmP5X-mb&_|(zlNbjtxJFNtVgXWiX)`dS*mWbjF_yxATpdhsUm_>Z(ax!&WSmT zA4;L9^LF8oEC7FJ2b1sgjY@$MO(WKD@wbnpt|+ILn9=-8j}8KR#QLgK4LJ?!1xNR1 zs9SJTkZ=Wd#s`n7Ye}I@V|;(9XQ@LLpc0}Bf#KEz%a52+x8!bsTHF7dy#?RTOqy^t zNze9OO2m*}?l1r3#QBX7U+80u`simQF+)1)t^afg2N~sx`{cgnsoPMS2d#`fn37W5 zINMsZ(BSKa-3Hqk>|R38B>b-|-%Y&dhdqLDuCvG+8=VhN5<Fa22!)_FNy+|~XIyPE zYN`&yw&dE^3UD2rA0}re*U+TW6WKnKXaE>h$1_8l2>02<M9+$b+#c*~m2~aq+p@G4 z#DD!El(iPxa9>jK-IVCF4n1UdmF9YSQk$?dGAr8FXK;lZfYD0FkliDInw-sOs)_?? z(RdNN!g{1TMZzNc^<qE9KuHu4c?cO=G>;(742kVa3#=%lCDwkW#wj9cc~b3V5(zhy z^*>Fxv+;kwbq#bY{99-NIy9Q;6s5(!cP6C!wyiOUOYH@@3Gd|otY0Fnzu3Ka+NKSa zf$zveY&g9Po2(jXI_G8x@;jW)=D5_ye1juqQ^2N@iQ2gKJq~d-{_b*=xQ$rI6r`_m zhqI4~(n55^0#slk)q9+1asSOd@^ss4lt^K+#%Y&;b{%zm!Cf9{Ql)c&^N~}`rCpCp zOn2&<L%F4!F>UW6nWp)F|CMU%Y*&}8ygRK_Ms9b$KCQ_v+2AYB$TW?~)!%z=q*;nx z86)GFt2kG6Xtt)V2eXGHCN6Add<e&|_r&H(DPCmb7*8?saFu|7?JVcZA;Zt*vCl|4 z`|E7(E$9m7_2@FT3eS`bONwejEi{ZEDoR(FA$mQ45;(>@PW3h~k^cAF9}OhiKNH2U z63p!l&j0N~D64CV!`pwG2y}sz+A5h?#@}Cwwr<*5bVwYg5y=>y?BPf=lc2V0y1HZV zUtO{3_RE!Go3$KDh9*tD?+s$f$XA8fdM~{5cKQ%@P+}0;hsW4m5J+O(B9mHsxf<d% z;rN3X9nG1iioytIV}1=DJEd$34Nd6`=p8Yok!F02Iu4LFvt`QDWai}ja&mwz0a51> zhGu;*dbFMhte>DVKC?rLPS@}IlvgG_MW;8b5-*o%&wFTixR^_`WCJyraxexL$C*V{ z78(_zh0gHl<AR{n=C3GO?bNh^qkwi|G-J=c2R<3Y{s|TSP%Sw94;lFDnVZEom@$ai zkhi~@|2e{5Hu#-8scW85qrRVbZYrv}-gQb6KmDyU7>xm>%1ehMp>52ER8UIm@$PNB z<)KV6p@bP~Kuu4&Dcbs%D%R6~NpQ0fZ5?D;){cMyn-|knc{nGlh0U%YArFi+`_giW zn_iJv!qElAz@oTc-U7-V5jF)xqh$dv$VQF;VByyVk__aTN%OrPRLt~ua)01Lij+ck zG~4)!XRpA@29e5tTFTvU#k3@D7Re4h6+WjW=zKACBwJHYhu$5YsZ3!rvYqOHTaVM> z7E!Y4gPoexHN%A=9XrsID$M+dL%#jqR<ywnM`twqMh+ysRRI;O=<x$Dm}Ij*{<FB) zrieH}Qo$zJpkm)@fEUFm86oI+wnxJ0Q|PATiD=S#N~ueOz$iNP7SF{n#dy0HTPo+q zC3Hp1Gp77lE>2#*LfCe0Wa%@F1f<5PN!E}gw1)90t_qrlYjmq@$^ndLn2Z?%eFD^B z`uPNr-*rGeWI-yR{S-+?DR<auRjoX1Hf69r+_j>X8pa=^kP-#fsc+G&Kw$<4^}HK@ zC;SqUT{pTW8hzglN_AGJAjw|%Mgi}2n-27f%a#g;*1aV(n?<KiEIXc8X2wwKg|dZo zE&ybDB$o{oU~daH+q0w1^yiykZUay&@fujuZz4^m#nXNQ8(a*_*IFG>8hf@<UF<iC zVyj$$Zor7Ja()LU>F-(U3I%%K+;XtP+Qy}(8yhp`)G;9b(E}^jg~(D9E`DrH!FQ<; zR_XE(g@eUx5c>0ut1~{XL%5hX^#Hoow_}*Cm`yJ1sI@oy(DnAP^l61DGe}B;zq(j& zIK=b-`5X*u%e5yFR>Xmp8<oZX>&wUhX3z1(ko+@&862?b(N*huxyeF2#`e9l!9x+| zly%2pETQp_k-3Rap^Rw&G(6ZtQL}vVKlT7Lf}RQ^tLB(_feI%ci6KiT98$RokVckQ z=oy*%XSp+Squngb5!%{4XR%Jt^t_+Xoq+6M#2I;`L?F6~5-H+#-fx<$o?wyU*mf_S zY_-#CCdk|@*Sy4%we7QwuG1P@JH4uX3n-iXYmQX(y|y_a{B3pe#HD9{i~t5Pj)g#G z>wGcv?LrGm_foro1dOfyjGnzznnP39*OwOQTYp%HS@w6G1D@@NYMMzTV`#oq(*kp9 zlbSmLFF09ZP#K-qEyau%x`jxc*X<I$QDwWc$Qh74r;e?{Gl2TJJM{}J%WD3kOLL~t zg3z(WlK@Xa86HV*<w@3;N;2So|CvhmOcL{qlVuaFRtMNPU<e`unb(m!E8U4E32hZf zxRfel)!eyUkIVQP?zGkkQn&PGW(N~m*u~v|YW{P7Nxqw9Va~uYa-sQAcTD!l&xsH~ z>139Qvq@*)5SSC9UOTH(l!emEU|7U5DH@InI@^LqxA2tS|NX1~ys|OiT2?;H*xnYQ zKnVJq8TpYv(PME7Spv|;bRRgRJz}`w{O)!?t6X?ZuxYylDtFxpt(RSW#%!2vtIJNE znr4>O%HY&3BuwkuDCkqdFk1^nhGnV_i&Cam<HBZw#$hk?i0oc~I3GF+{**qcR8MVV zX?KAJm2iyw?CjGZQaxQ3>lT9CPpChiivp9bic&`>@dSOVE7Jna=%y6mD8tf)WkP36 z8qSUv45oHChik)8S$K^&D(e@wP-q<+Qi#~&&Hh(OOz_WA8}J!dyZt8(PV=l2u<O*R zi($8tvO2PvcRg!?v#_7ygml4%fpli-($~eg!O*!`q<*Ih8xXQCg3XpSFn546g-n!l z=`~DDEdO6BoVE{rR9jrKb8$<7LDOraf6-XC#BI@Vp&C>X#F+wpkTr`%Egg}36<oJ; z!YOQjaA`L{^G*WkN`K6CH+|Bz%U?|<n|?HjvgvuLS!iqo`@p!wE&r!?s)S5Lo?yZ( zC!5y3m%UZ5CiP8$yU&17efyt4tnv`Eqw!Xm1=XcWL3vNis<_kW=!+>a#k2Kv9syxY z=ejcc&xLr=`WH|E8Wf(n2)~)z8Oj1Vo63jx6I&+CtnXZtv?~p3m~~ME>bSnt_}U=P zLhGO)J)D2zhx50nCb>O!oPQr2Zmn9HE!4+Fzn_HUN>xVCl2Y`gTr+DNbQy-nCSoFk ztKTxA#yDh6Z(}|fQ~?p7Ij4e0&>9!_LD-x`sbRK-hbhX_a)m0paTKG3@RMRk+$HtJ zQ6+kda(5dm-Vn_AW-`5nU91`+3ULg|BF;q3pvwMbUAme-(o@HmMy4@Yc>7fAYgpYp zZLhz1`gSrc1@06XL;{sAb{b1vM7qdcj*9qc%o0bz4x~`Uvm2L98%C=ztw(faqmj-G zVqas)-jDyZ6nlnlb>g5yr%a<lQqLVWo$ukB9$KtJD$84{8ykT$T{moWRhhw?H3H4` z4S;+pC|^oL#J4vFTmcQyKrJi@GN$f~$a#wzx~1lFK(m7#&Nx*}w>QBC^*=jzd8n~g zVY*|aBSLiJ0)CD2NXul@cW>eePTgY7`0JPO4F&_1kt$pKWwFrHU-R(19TZ|?C#Q{% z(VEDr_lG*owoOv$y#X+jXDqyeV1`xq7MpHojCpjSQ{EsXS*bjz*Pj-WUe<sbuh<{g z76J)zcl!V658*TIAqOX}^LJBtu-i1270vWnSpY%C6JTb9-1{GO{h!^$Z=ES+wLwhF zpN+Nd0{yY|x+%9|un+J!X2RGesz3kZS9V94rRl1@*p#DoK2l5Y%i0puyq@;i3}*jF z+Xu5IDe35v1zoPh8psr+&aus#L^q^PGne#C8TH3+{XxW_|Mlm;^{P=Jwi{ZMRds0U zU`&UR6NnLl+QOE-jOUv@AJ^8j(*oGk&<9b1x}V-}R|VkVC^~4smwQXW>RBNnmeb4_ z-+0H($XE|r!JNei4_5@GX$PPcF-Q!N*5WiZ-v9BB|L{Z~5L{e01s+eBpIs9*&95K` zDdUc7<v^?UmY%=<edUbbw?iXSVeYEPtaEcF(OA;dO{G4swZ%tboD863PhOUJ(POa- zC{D&qmK<hjwNx0$3D2K)K7CkAfNVBu^W(Ey*$*n>LP=NOf7>Rz9=UIzC>4y%Iz`I5 zRadd+f~*p;w{`Y6E>f-fA`-vHpMP=Uv}3{9jLT(a7b6ZMHk{^sf)QifU)I!=A|DaB z%qWzum7*A>vC+XkE*PbaqUei|o~{zS`K*ETyPNBE1EY5#Ut@4#P|>P_)t-y}0Tx-d zX{uf-#4C=;pYfc<5CAh4S0g#&w#<y)=?y~F9mlKq$yQos4QlyZqm0Rt`B8EFGAQ^2 zC1zn7gb8b%Vb=Q&!3vh;9XF_mtKmv|TOD%{Zx>8W`FYiIji%MdAR32ZY=EkVlAQ>3 zw4y`ZT_7y#cdUFvF$RI|)rl|CIWAL-J}@W(*Y&z_Er(PZ>BX8m*|GFq7sp}$5Nw70 zygZIiPn#xITv&RZdvm|P(K^7+{pk?zZOY%2-;Ej(30==7UKTd>W?cFj?oy;}j64!( z_n#{J2$uB})2n|2F3+o#hz)}b?AX=?Q#jphHat3SN`bueikL>B;GvlKIy+I%DH#l( z_b!uCJsY<;2!hUlb9~B6EFa6&q&$YACZu=7tJh;-S!N(Z#{7oDimM&O-#F}C3XFxs zgJpa*)sf!m;BVWbb&m>%Aa0Jo*)oVp!(bj4M%t*<_3c4^81iBc3e_vjuqu}c)m|b2 zDHPY@Y&a!VQ5;zDK5Ed(#m0O;&2ibbc*12lnZ(ph<b4o!0Qx*0c6Zi|!f|`(G~SaT zi!caEs`RRlb!$5tc2}$;AS!j16plVBD+g>bV$zCO<K8h}2(q{KE_P*M{@Yy~n@vWu z{=fd4ZHC__#o)`Qa$SN&$(RO4E_Ag})ofXydgJO{1DNp2e%H08>v)qYe@#IZ!r4_X zI~sX^G_K-VZuNQJ${{!0c4LN!>3<y@8r<2#qg`kNk5Whi!jM5mY}y!Cx?Z5oB2pal za;p)prT&X)O&rgtqHlchd?{2MGe6kbQW#^hb%K5;-9;l#KY6hPGWA!LMg0KZv6LFU zm;@zf5@;riWdlz#t!Zu$4=3Xt|7Cz2=wPdD83rO5IV-?X*!$YF`i(MW&c$pw0XFh- z+WUA}(XniqMRem~c3o#1e=IW;UUB}>z_>U&?@Dha(Be9LerbNNz1eY`7#0NE{{M9| zvtm<qZy|OrcXfGD)<NQ&vt9n~Z>gF)muKUkZSKn|dW&wcvym?QgD74%mbP8Bl@eKL zbXRign|<QjC9hliXMo*FT1(2HBB2K#k6Bo9M3*f2d#eBNY{K?5Ne!PPZSf^{n~Iv` zjHZUMja+OJW8%FDE`zJ*CoOa`gUOXMriG<bpL)2uK-uN_**5-cI0Nm`jSqdMT`re? zim6@HR`GbqC0d4PO_^co<)k-hSFTnpj_`N)&wh*YI-++!{8Cs3aUj8l<gQ$5v%uDN z_7DxeZanN-cPq~|Ho0p~H&{vUH!>%f_s%S=xJJ!tEN*Us5&ZxN_-z0>pcwFqLC=Ok zO{Q$xtnbNA_&XP$lZryaFBieJ8`WbA>f=M^NtA_xad#|Z>XQfq%g)}fnvhDT0#(K> zhP}nRG-{5oR`Y=8Rk>v^@s~eeH23R%9p3d^wuELkva7w#$97aB#@QzeaxZYReyNN< z!A=r=*@-P>*jf-f*Hs39N91K@XxS-K1!0#E>@l1D^Pm4tfjsQT9D__n5pYhHYe<qK ztT<Hj!ia4mjI&o@nGr+x1aZcYC<(G&bdM*W;dlk6slqy&HdQH>F%VAB-d+|g=Hsru z``<YHfNN6jC|y#uK*^jff;e>iIBlS@ud!22C`SOaaX#Dz!_3C}TzW<^sDr3?{TR>+ zM>tGvO#0=mj6cdCR-W95R2SIv4)~x5V_{71j=J9Qkh87wMoVs3iV}B!C6jWeQO^sq zsvuI4#Ziv7;7nQlduy&BVwwN3?JrT%-z~`NzP2rG8`7{Dq1;M|C8BvybeBn#St>H9 z`yL)#);T^?D>Ylo%46jcXtp$mE|p7Pg#@uRg@?TRE2%5DX2704k|`UdV&8~&@nBRe zHLoBZ<!<)`Y-ajy3E|x!DKq+HdH!Z6LplqU)--L<%DF5F!SfX2hcQy_L;!x5#pgh1 zHFGDPC3h0o3-^WxBFsTO&cN=^uW>pCuTxj9UV#_-bD4^*=7ufm#A3gX)JDiW9<7(X zK{8cgjiHl>IY3Ka`Q7W5jQE3>6(f#=$N`jiEP|47C2#U<HUdS(u@j?}AJml#8wF$B z`LO2QY2R8f#90e-qcNAyfT?J}Wa(5eWHh~mye-hc7$O{z-_ou<He}qr_P!3TT0M_0 zCY9|^4s|<JzBo2gboM6f+X9T!u*n=lt*NU-Y~p{Pnw6vO+50>-M6-U`PpHIeR<?a< zZ*!@yN!x~y=-L#Nh0?Kn%BphkvaL&M-^?QQ<=`^DVz>>VzNf_u-2i|#y%vhtqL{h$ zq<dUZTnSJwk-63tw`F8fI4dj?c?(S3%mO#0vT9x@>$%J8ct*&;SYY?+FhbTfwY6&L zQGMe;!PoERD)vfCg!P`b9av)uXSqaO^&Q^rIpbJP>|F-jRmZ_&_cH+Dtw)*N!I56f ztWtR$s`!kIMsPJ(5X4JRmOPaM0-6CQM8vmQh#RxS?TlAWS8S1FhH3T0hCDN3^+Q*0 zp{%}g?SvLa)*`wYTt|?YOT+j)n13e1=EGL}l<Q)wOX}^?2}JBAOI5^UVcK@ID`V?b zz1m#(AJg<wGu;((G3Goh!}>b|eJ^8NE4QI%JttGYX{LzCGaKmsA}OA_YRTS`;;D4@ z8D`6&TI|#r7jfWC@NW>t*RW|<BUxRv_IF&#pGlUZbzFO1=I*Geb*8zr!Nw*ow%iDg z2)G3lRi6cTO5YZuKW>@;RK3xfu%T;gWhBIfFSF?-)q91->F|q5sWqeeOC?>%0=)@! z3fUH0a7!*WWL+y{vBP6uE=y{-NtmdLe6U6|2gx1bI5K>26jbV$=17@%R+E77ZhnOK z2r&U)t_)*l9jrKO8tNf0wL*%BXI3mcctf&hyV(8XU;p7dbCS=HhJj8`Mo^rJl!V(M zzcrm<=PvJKP&w_p27or3kLd!Xd!QEqU3=yU;tAHI7@)a#tyQ_#R1}w#_{$Cc6eZyU zEzcl4GdK`}-tTq$=70Uw-a{VPyL-9MV*<93VABe^s(YHHk!K@Hqpo8hP;Gb_18t=^ z-N}Zh;k0Zr^|sDzj=j%`V`x+)<Kqi+^&TZ^WT!4mNV(CHRpG}cX)|I$sBAun%w|C! zFgqj9kacAf1H;klF<{cH>l~)07;ue5q6fI1;COmaV$qCweDR15-q}8)Fi;cPtW#eM z2NDbUcQC~GrCI-ZWmUo!@f$H8>l~c=Sn+^OPE_;xRRN{EpE`?SbkF3<jyu27@^86| zaT*vcJc+TX=v^qXQ@-+;4u3{ArI4<`+XI<5GY{l``*3P}hc+Wl(V_>_6J^)RyNR7a zvjZjC8%A&7B*w&S9cNZmGR@~oy~Y|eN#p+OI(XeFcgm;5F=|OuYulA9NcVO@A=d$9 z^ks<-t4+(F_jhQ~FS%1t0(n?~U^30tyMdAg|M<thW6K!%YsF}9W56`cXLJeJaH{j^ zb}r8_`a|9hSG^K(uNxcLB<~%i3AZPkr6!2wnRMK~Yj=tlG>$-znMT|h(ACR2a88}+ z%W5LEr>Zq)vq2o|yt+Tx#bqf6vf8KP`k<Q8K6@(4sSU_uTu#K8{jYB`NRZd#daa;U zg`#gCI*U)w#4uFn|K~LaO<|j9jxLFx5au~PSk$WUABO_(k{&exMZY4RaW_UZA3gH? z0(5MKt}d9$2k$fPNu)q#y=WjNoyjeX<Tvg1X0F74h`4gJ*MV}er}3nfhGo?{Ks5BJ zoLJM<iJltyJs=4eKo`D!sZRDBb=_3v*|E3`S*CuQSc&-cG;x!9_-2<14TdT%7<6r0 z{O*HNdsCYE*!*NUxSf4w+Bk`7uaK!rAmSyoea`lC@ia)zch}-u#Zoq#vk=Aj#4Ku7 zstN@Fa+nvIrMt<X^cT5ansOvIU&9b*k*M#DfoL3B2PLsv=YhjdWC4q)?~k<3a*Ac@ zYn1MhB1E5Q8joUVdX{C!JSUpg`y+G$Nfwy0gJq8qUVKwCQV0b<@6B5=E@5iUu!^4C zWk8L~vJjVLi%(p@{(r|un+b!uG_)pVN6{`pbEKvWUxKHXUxRYqenvau*}hPVxjsp# z2Y;$u*fYFX4Qas&_V<7M{@;DKz_gS_gQ+2JrCdKnFrwH*>^ADA&T4tOywOShcJ5R+ z*^}5_v$~qd{qLXuS;C2t7}xLpH?s29jBBeJR~grIlAyF4dUO2l1ywEM7h@}T_iD`A zm@+^@HwrxJ({`ssFY`ABHuuZR&;xauyGsyaCA#tXyFc^IAo$(gzp`*S7@nzP$Gi(! zN>4M--Uun6hPI0dA|y1q`Ke+k1#0$Ka8O$-uuT{6CJ8!G6bk)yyDE$#<Ku9^R@)$E zn{0T8PwjFwJ8HWFm;tqsEMS90r2%U@gvO&>eIng5&o&x&d~dT2C7I25xy*#u9-=%O z$b(X@bs7B5<ORS8G6TyH-pyk9A~3NKHHvN+Y+E%77#RBHa3(uBry!2wK>X=)shhf4 z_EswX_Db^Hw@Ka~#TSJbA$otWE)z|5)s_LmTat{Wp(&sSQwB{kIy)@k<8ieoOKgyq zgy-xQ@rq+>{^->%*(#wcfEWpIZeVQpQi2BJo?=a|4H)6Z5mm|diB-Ar`v$;vJ2@5( z{#Y6(c0sXX%cbFLoMGs4w#^dNwBx^5T=JNMBW%jA8y1bsxA0gH#DuPbI|S$TRf0jJ zL9Z@ipwzF^o0y*D7M|<HFz>Q5o*01<sj;I!M4{z+p&&3ocXliT2>3lql-5rNl#;1F zEQ^kAWd!rat49T)N(oV>0QQ5z4ztx&j&hY}2)0jq{5#Y!)s}<|l~O3BhwL`VORy3E zU8>Bmaa^1xS)-wYx28a5+p5)P#}s5O{-fOv4ZU>elHddxi6!@20hI124Dds4<e29R zbggFD+%Ca>X0E21{;xz#0a4fD-cRY0OUr{~ON=gp-O)-mbDe-&3QF1L%Pb1nIElMB zJbjIa=6+DSE8kjScE5W#%t$5MdPu=iP?!8WMRKjV*7MSCj7rB0uI$t3pp7VZb;xRE z?#`R|vw!^yL2=!0PQ5V+O>gaiA|c^OGbNLSYmNt5cym*oksbP2+yuEw*>jzIfo4}i zR$$|V$T$n=%u5QHI^%Fvwo%j>576Gr)#-Zl((J@eD^5Y~YZ&<cjv~Iq$}{`s`B<aR z?4~!)zT<?w2UaxgA5w-srL(qt{H!c>4<2%*MQTPS$F{8PtzcH1<#f?DcgCV+V_;)G z2d6ZVT(G}d0Tb5&B@5(y`57du!`)m!7)wkA$^b&~wG_Kw0%iwihnUgI(?WqDY2$Xs zn1=2oze%y;3bDz*`)Z_hQyiM?bh#(pE8M(0Rc7LW$aP<9+H}ju^m=GV);0vf!WN|o z+o-d3abu^))Ec8V_uB=r^!7sQiH1|pK`hJCE(yvQcW2;N%^0~9eZS|?8DRBb;kqsJ zC(izJ0e|K2%@N!#3eY;3vFCY+J^Dgt?m4yFWpXOCr`>$aGnAp2@q*&4Lr<gMQtp$* zrK2&VV`9kEZv_Y??qeXO`E-5B9L-X|WWsY(Kp6b?PA3@BW2wo$YL?)94QZOPKm?O! z^<XMvx16RedukoIU|wt0?Y?!w0*e8}f|y4xG)nXf`$1|?T4eU3%737^{2oAbT=#@> zyUgw*)oQhwntIJnK}`6=>OhKQ-BVBuc@OVfc-~)|EMNY!YlifimRL*)?eb~&jAT`v zpZIPE*}8<Z%dDH)FGsiU-OZx3_9tKN?ex#~bbPaUxAuK7Y#nF?Xaq1c)PMWOW*K7J zRABzt6kd}j*jUaJmjP}8{qOg_b<|AmBi<VTR*%@?$)s)RGCS*?XMe^sFN1O~1NaeI z{>3#n_qFDg;|#}11UDcrf*%FLp30J@Vd})dN`Z!L<*dyEF>1Xtc~KJMWg#PLKN{7w zvFHq|K=*+l=D;khJb6e&LLs`&k{lToqpF3LF%sgupk8DWAk?RK9{FQD#n;*!WBxRm zMYjYO9U?NXNXf7oF-7F3`RvAUibhE^N(T=zw}M;bF*EK4Fv%$2t#f))h6p=0L$~U5 zqd+Mhnc;o)ai7SphMPKLTk9M4?j>UAH?iF%L?}L^)_T)G#LUcG!Rp!|y=}^iOq7dY zT~$+8z$K96`_n#3D+^_tK5DNLd%5^mGxFb;jnnmlVeWTnvJZw$>Pt}hpDF*=fo@k9 zr1X{XM%2>8nLUPef90J6_XlVpY*R!~mJp>P7xddQLRnmTF>-F@O9}%-%;xDNTVY!! zhUG89@hpImKD1QW%^z}XS!mj(WmmKc@ub#*CK1;WOSih9s3q#}C+pQtvx<OfvgZ5& zut;{EIFo2O=z2A}79y;zH4>-ZmFu<~vt{XpJK#ZMY!*vE3WNATeyIr8Uo4+p6ub;O zbAYv^+Y8Wq$7jYz<irfiDt1-J>b-*OQlAu0css)k{<Z8Ok?|6nbQ~*U9n8+eB(4K& ztS%!{yDLLfS&|A}vy9pJPb|8>F)ZmfWa3JYHN8s6lKZzm?d-cfFCt49`<7C_S5sV? zx(nXiu$+$Q@iO^TCoipCq~5gsZ+B<i#9ZR0DDLil2L6x!(Z<W>7Oy>=+!!b`nuF3& zQ&t%>TNcv32bDd0geQZIaic)$_gDnKazQLmHq#qv{ECx>x<K+>x>sCv0n$obrwJDv zh1pPBoZ}bsw)ttGBGOGTm%r;;vx6?_bdcWP$2dBpW@hZrbg4r|F*0C8lnAo(tis(9 z%ibfKmYAUXjjJ_uDlO7Pa3if#KNyH#6f%BdDH~CfBOY>vm3O18uu25){H*2P&I|(S zq<Vm7!&oA|qp2s#LpZjokm9-5&=-;i_0T?^%lJj>Z(TQ%Wm>ZlXQc=={eqQGb?^;^ z)Gbh@e{q&XY>Fr%ow}7Tbd}Al3zL~xvIS6Dw3dGB#%5nx80AvjD0Jbw?Dw3Bm%+0O z<!P#%%81557XS^n%#*$-;UwF-<VGizGCReHhOB+m5E!1>(JNGHLEK=d6MpVS1Pf6U z5L`Efhm<@7bCO{sXzM9eJ2w#!yVTVO2{Xau=~Jyf#hlUwh?wv`{+N@!435bZC31Qq zaVAx+Vd*=)5!|yL!c=7>spO8Db_e1wM=DYqG5b2_VlowH3vrfsRk)^vK~*(E`dMKN z*EF+UI<hg0m}e7&y}-g4edo#!Pwh|LNkv%$!7D&V+=grARWVe^q;$<Hqv%KKM$}~8 z>K#-95%glMsq0+1-F0bJj_QDpg)P`ac3;KfQ0Cp7V!KyAxjAqiidc=(uKh(h*Ki7> zpY3>C6PB5Yxuc)z<)U?93DgeQ-x+R4!l*y?q1Ma1a@}@6x!UDo%S#u#gO~ct#idub zNVVEEQLU+wkceAM>kC<JsB9^S3!n^I++*9+C8cMV%wDFzNU9~ICB@}Ud2jnJr;AZM zHFp%s{ttGo4k+h>Z_i%0R2LcIHB#+$eay9bh^ARlrRe|17{nEG0dMCRe^@f9TxWTn z#mlA|j`MI?RQ(3hFb0E^qD(sGON(f>`PdzEo1R*+>9Z6XIAu&GG1Un@4KbW7J7`?H zCMJzN(vVi2Y$cSVA0@OO{+c&1u-(#IsL!M*9|BD$mHQQX)ijWJwC>RK9cg?$auktn z0OX%8kiVpu<{lpu>Sk)|cTb%8SfHue5j|y_>~9{m5Ns56HuVY{qf52jzh_cI#nit1 zpj1b?f{HVK*W>XtVb35>uq~+NeZww#b3Zd=kxSchqP*y-o($Eh=|+WpbJ=ei24XAy zl;&CGlBr=`Bd_6HKGmty6*GHy<5Qu*=3Q9%+rN;M_2u4zAYR6#*yjtAb6?N|>O@}J zt1MzhS8WhrKpw!Y;x+6OP|q#bLd{)CSF3Fy&1Tp^ICoP0rL5D1TWiNLW6*81A@;kZ zA-cppwrH9@Ht&-X%<&Um`xS7mzGy$%85ZDcytf}@9Zk!PB8_8sT=#b8pW97UGlOLg z@0-Gh^7jC^Hc5ZtqO9yR$}>_&;FWb9LpI0P1@*K!AF(Kx$^A6<?dz0<G}7QQ0H^#; zY!}3)#%wDQ<-CQ$-lk5Tr>+q0>iN>E3EQ1@+0X~ftpjCYHw`y8+mHR>rhd5<t-Xyb z+?Ks_Yt3JObZaQV=*>)Sak?qA37!unAN!YxdE%~-pjp47vB!U;RLaqOY+S__q10dE zQfCyKWapHy@TA|y)kLKywlUf1p&DW1V!;(IJ`00r*6UGfEHcF$e^I?|kg}n6%9VKZ z8`E|daBL8ggPk)r29)TL_$s`E<z7dKxx?uwrzPAS{QEco4gAddwTkydI)`=mofVI8 zw!iI)%=_ry(B>+q5=rBXT*Ybb!8xZM+i5(h&aX$OG6Z2H`;1rt{EzAr|E!JvMU~L( zHULa5fj8K~TSw_3(S$DI0bU2McSg)v&$#Q|q`EeJX<?A`3dU!*LHmvJXGuI&;+<5P zXHPN@{%B*N(*>|~pmW1Oay$O~!vOeiw?pIa>EiBYHe*kGH+{#}{nJIl(kc75K4gY% zH+7|Nq4G$Qp54pHw7sSjL2m9|Q@jloQ;+C&KAy3;-C+=o^9ERGZGbEqH!Z``QDnXD z>LToP)uwMsd_^&?YUNdpdsEj6jk5xK{k=|3XB~#UbKF(8F(9dhQQoHY=Ntj&=qs(9 zF>A0tp0G?Jy4I7)p;b>@c~UbD+qE9ftCB~58h_zO*+FVaN$p16og>$z^-6)H;?-YU zwI748Wx<@_GCRzJ_0}h_@^2ve%eKnUlCsW+;AUf)T|}GYWsYr3e08STy{xKdTl4on zxk3myM48jnzBFBKnL&%8U3j=_2D>@9Pqq4vP7h#m3VweP)&rZyiQMfk(Cuh5wH?kq z$eD$^j~S2$cs9*9@$X7Hid*}eKa8;K+xE;-b6$~(!6SCE!sIijtX+A!)HEhtTui$z z002M$Nkl<ZjYt4gK&!u|b4S46g>Sq0!{CacNJO><<Qm;_!Cq+gKiy6rC+|vOKEj{& z!ewMyz3id&{Uvp>am-SeE5W^^NLB(}0PBlEkHSQJ>sOStElZ-&2XY7V6!dond<h4% z>NJVX!j&)AuOlGF=6pk9%z;VMm3P{O1gx<iscOeBJV*FRVl5wkhFv^=c+Q?#I$y>K z{X2fdBgU++&q86Gk5S^E;8S{(ygueyi_A|n@Fu&9;68+q2DanFx6Ns{V#Yd~GNZ6x z5JucKAaP<X*30InZ}X;ap{QTwL{ep*?zCtx&#LA{x%&@yJk8e5rBNx^C|p9gqHRRk zl&^K>+335TCPHz&>DwrVpEuEIacKdQmL9pA{yblTmKsT3TJJGQJeIE{d9gr*5YMzM zYU|kIOpl1<SL={W^-AP2kWG+%XrSW^ZzeIU3zeX1)gM~dg)Nho3-#J3^qL@ly%j*O zIxpEfgSsVKp+lFK5JhyU-(TVA$Qt@hq<6m`Wg-zs4GO}DBx=}!VE~BN%=j?~g0`wR zJmP^iQ;+xbR=yVyOmp)W($ls)dr+5khw^c%t$bR38viCnny1f_-ju#`O}hALzo}vE zyYjqA=R#Kq>)sgakFAiA7km?21uo0W_4M*vDlFP|$^Y#;nLTo|>}eFaPLR+`>)-$K zd2PeMhQv3H*w)_^+l1Xx&}R(pxBRQ`D^4o2uo*U~v+XH;yTENnbVRKf*6q$)#ctM* zZd+AS7-?CSyC%lGlIT`|-V8z++|eG(V2dHWMqPzk1tOb4;n_$w(@%<Fy;}E#wlG)& zL!pK!jm`jQIZH0w3x6SI#+6gJ9y)O->(m;FS6h|@mdH%eQuVs|-S4~9#65D#mjs;x z{CG97@WTXruq#{>3XXnh>3#Y?YbOiv2NWaQ_BO-5DjUw8IjB6gWce})NqIylWB8Df z*Yx9y>L^dwiEQM!COlo*mh@-0`EG76iFsrVF#yhhD@0%X>)-1?VMuQYasJmTW%ZgY z_g61bl3J)s!6j}fUfxQedzCxb7A+U7|NLt^p9a9vZXe291ZI5&pOrQ}?(?i|_uFQK zR$lf2nLi6$g6>mN7blV>lVtoBmb|)&*~JMHCc!oLA`MP<7DHBfY3gM(#^5H{OpUnw z?@iQrt+}|SG=%h8SQrqs5~<e1#&_&ga75~LT)|a@RMHpy=F>V`LX1nSk7cP$R}AYy zGr_8aqcEJpgVMVLXX*wFm?^?=g3(v$eA)1KQ>7&pd)TcaMt2M+%A6l*O+4N-S#1EC zhz}p}Dd0lkLlGJbLDel4l0;cRWYd67w)5XdJeSHNC6aladpQ=j$}V$f?a7k(|GGLC z1<7&TTG~6i{{Qd2p4q(-aX?j#)oLXP0tXL*6!plgRZ}!@i&ypS8>i4*Mud6M_<J(x zpU-Zc*|gL6|H-=DxAqfq5}mXu#ja0SWXJQTyMXf7vN$pDH>YT$&;u;i`aOXoX}nqF z!)31u{<XI?1yqn<SZK=kdf<&5-=T0ZjnSxZZ<*m5h9s=&FtcpmnX;PVn9Mv4L!+x` z6F0_2F9EPi^xTP3Hrz$rIU68b47hMhE*~{Cma>hIBUBd~Oh>-+J<%5psbE31hcx9S zS=UuYd<uvsCa8(C(@&0~8+erfw<Lgb6a*+J=O&vj>%e)W?QVYVGOntJu~8sJQehGC zx>1L*lUXHM`#O!7VNA9`%$_2YYMPdU$i|~36{rf5xJ?<O!STVP4YXSFi2b$v6kr(h zDX%)BLKDoFF%U^Wh-5twEo<><IGzFYn0C+L7^8rG%51bd4~*E-77h*Mr<>*A=jjmy zi=~cTM(%p6u`X|&6BY|0?rD+`=;aSBGCh;W5YW9h*jjru$S~ODjo0R)@wyOvsDR&v z6Rkd=Fz+3x*z5CSrgOdb7OFkdj?W1?J+l&nsFZA)fs)P)LdkD}vSb1daYXbQ`tIBC z-p&%50qgAGzTt*o^M!U4GMb2N;{-OC=phrzSQ+_Z3(*KlyDj_mhK+--)@+oCX7C|8 zm{6jpPnL2-S`)3Q5-sXN`zaS(qDHtK1Fe&^KI$`L6o^gm2*7BXFbHY-T2VcFI6m_b z`gq1To==FXj<8Ya$j9Qds=6{&OuO>7lbT?9Cp9NT8!`ek%y+*SV-+4w4&(U{y|?Ho z+XfQJ$xJDRrbS;=41~->ggo%pp}?lHUvc!bUP8c)5Q#!^`I^2{HCLi!8#nmPW~KUd z26%3L?~Q!uK(&H`-@%Gvu6@ZN#!O=ge#aTsq}vxaT)-q~TJvh17tp9eiYEgt-+7ia z!~8qU4&?fY=gK&UEEH60JVU^IWj6xmovDXm=CM`#j#ir(Mot#`6#{XE#|Zcgg@Uq- zola{YqolaQhv_p2Zb}B+Qb*u6X@vzF3{G9OQ%1)a6{LhL$m{8F9Ib4LtbB=*Q8Z4w z1?As172~1p6`U`tjnAM@7xn2y>EAa|Gu}`5vGF%VX!2~MG<6idIE*$IP&bCPr*0IN zTWv$bO<h}9uB8D3Jq{0RrL=v<Pp{2nj!bywM2?tCYR=cLUC9ijoW@|<ROkF{68z)O z-4G;wlr>XH4;LIRqWHRFcW6An(8?_C(<?$mZ;s#9v*?1G=+2rD;BgUeI<h%;veM|q z9_?-nIN$p|p6BM&c_zUML$L+7NY3b+=H;j4DqVHmu+k)IXx0FiL#kD;aR`=neu7#i zVJ(4Aa4h)D&M83kh!`2)lkiELG+@5G7tZZsYs4k-bBh)O)s^@~t6ZCrD<+9(X`;k| z1g#gZ`ynGvE8)~yH>}KZkC32WVVrPe$!u5_syYO*?f2WDZ{oC$YHVKHj!30Tk#w(B zjWY$f6^4faamqFkjzLMNAmgtBoa~OM7~sJqK_ndA%FKwTQ+>4JIbE+kM(ByV1ftJL z0~Zy4{^x)H@rS>ePn&O*QS!#9l5qG1C|3pl;d`{sh^Pd{%!9~ya6!)>mv&l(B%QQI z#1%aEM$A{{eik*(@hJAkmRs$Z{a`Wbc;$uipC&Xp#g&8ady<_^y?!4OFc&JMgBby{ z=K-WqM#h`X*ea28R}Ki$Ahh$+i;*QZikwb_GK075;cR0pzyt&raHO#$o98#<U`Eg? z3{`Rw<&GlXaJF%420rNqbrwM5-RvbM_SWkd{=zFBO>98Z*(Ou`_9ao*K}?Z{M|5t3 zg+x)%%(dnQS&;Z;fqCd_ljZ57G2AgLxu)?OdpS+JNyI>-pOG-^2Rp003=I}v^fZ5k z2?Rvr-ADjoP&A2`&1q<mQ^Ej<&-bw@!|X+PQzut~nz}g5q+zN!3A8^pTdMN5K~uHe zk70(wjrEKHGYM4Uk!<kV*FM&7R}uEQH#+w|4^mw+LUb9!SRwPg%L%K~q$dG#64zOW z)MW2F3|dzR<=s`*PG*+io=@<}GZL1dw$%;;i3!;3=jNkHuZ2dGfGCKa60sIjVIB|G zX{|X8?3H&?a=_?dNw?*VqtU9Tk%RITRFbf9RR~hOJF4(H{4U85FnESBeb6<+5y;WL z1c;t^ok2+KA3gNbT#tdOTdx1nPa7BwH~?7_jmeBtBBfeS_P0s2OfUoiLh+D=)#Ulx zW=<W0*w`Gy$2FoXL!*wt_&7O;SL$;-G}*0*{PIPKA_kXo@X#leL5qZK_<Li39vjb$ z|LAcV+bq}RLY+AhwI7~ZLuYkO-#4Y%+nFoF=3uS#T}ZOiDV&MiS&+ALOgQAtLoZKB zTxHD5hD!6UOMF-4J|5n31=;`MC)p4)-T4l???<@80nOpz0;3l*nfo0Bt;1YFS@zAM zT{dWmiS7s?uo~a)7!X0Ur8OO@@%YS0V62+8xZ`~(Q+A`Ji-fZ7?tV6$T<tgwEb4&3 z+^ZC(o#Sd+jAubJ8um$fR<EG;dXDOPqCV!Z_}c-FDh?IvJUEqN<3|mQU_6`kBO^S8 zegv!V5MbU02o$;x=F$irtxIyo!1#?lHNQ-<D<87riNL(fn`xu1{1o~r4&sDj)IN;Q z(>{<HaEjKHPK1cE00Ir7k${`jFous@b5%CB*Ysmv2B<S;B+p+8?IVk5dXrSlc7NI3 zW(rj7ebbqfrE7f?PTN?1*cuN_tu&mTxlG;DS<|j1ijJU{p=CM}X+do=5lu=47?-D@ zk%;UN9SJOtfcGAe>E@z{aY8v9WOW58h#PNahbIseNutTiWlc2d6&RKx_@h*Wj0#ly zl`2qZ&>ord-MPS12Y`IP#~?E-!He0D11dv$4e>=2LIFkcwuCJMOnKEu)H0yFY#Pf? z?;d9i6C5@;fgO}cp<OG6VxxZL_ROGzp})Zc4Odbqj{v+LV@&F;(3c{#nqUJ-i&7PS zeb$bhX&fCEc0l-;^o~+(s40@!|73pj6i*moR-*f)7OFzV7pqE&Z$GSp4Y3IYGulVD zzobwVL~o<iDM4`;h+F$kcC_)^g|twnlkFp>Op(bU#|J2g(B#C#>plM_>6uN*K>m_2 zM1N<TC5=DpY2G;L{*Px5@)dD&h?S2ka@d@_OI*$4Zs(r}?tLN81I*+O46~VTB`LWo z0L^<iInC^7ft3t>r?z-t7LCmI^rfe?Qf5T)g)IG%p&_@kghM(;&kUT(z;SSbOlBGm zgNMO0RRG0R&BsOlIy)nb@^Y@$XqG6jW2>fBIAJJF^H!G-DsWSCQhv~C0=n4&9bG9w zF8K=hfBpM^V}pVju!f+V_MYU|WQst1z~J~)1L9;mlg=U$Vs;*QA^^IN9&ra59<r}< zPxS?^i)qYBSd&8NGdN04#g;#C+31kCryX$&^W4Qp^yW&7BGXqD6tUz!ajzvH=HqOj z>>0psGDde$I<K31;M<qxhm)d&O^q_(kUvk$)X3GM3Fm!%RXM+!lyL9Dm+V83iI#gu z4iCo(Styir*E!I;>n|M~o`2beSj~+Zr}J)BV#cgAqME2%3<=S;>_HYX+Ko0N76$hv zH1^XMx{y2C&wtJgj!bq(LIVTi$BCfsgr+WR1I_rPTdmeoqn<DAw879VT^cc&5>c~Y zPKy4(8p<G1s0$n<cE&FrRZ!&`0E4DGy^}?3cF|N=JkpO4#et}w78IV%)pk&6`1X90 ztBk7YQY6et>&^h-%wms}JsgOIWY7M}psB@s<o1?|tS1%b5Jo{_t$IO)oxr}I+_EZ1 zQR1|Ak~u4$t!8<BL5+6Lesnv>MQ>a*v=<^KIA{7UWJNZIA+=m4!&;?~?n{*Nn@Rak zlfD=A`D2XU{sYk&+CLaWLh}tt@3@!Q4kcG6yYTIz+oW{9_YcQ9@w?^$6pH7Nvdt?2 zn$&^^sKP95&_%=0++Tw7;9+U44W_9H%m_xW;x3lL(Ls|K9sm?+tTq%KANO<9Y6RTT zf-VDfQkGHis5FRE9M?qa1gIi!&#Bs-UJ<Axhp~AqIcPGM5~o$n4lsb=I02_@gMU&p z&T_eKVOvWYThnE>1rnqja!Rs%?ojTmb>JirO&{Wz$3M=n&YsW~2adLkQ}}FH0ivMp zGS@`SB2kJ5V<tW#bov=H1}<abx`wrj#?S&%dkBax&WPv*0gTz+QNfQojj_rZp!+TL zy8SM_%h{J$#@`9j3tlFAbJv@s5?d&lk<iU@l9*|m0=>r7tv7;5LB|F=66Uc>YM@wV z=^bb+SIAcQq2pd@ZTeE6jytV_sDe;->Z3V|Ub3HKewo>MbjHgp#V|NX8g=qq99YSq zg^{q~O(3XZlJPPE9Mj3SvBXu1|BEt5So*t`TCt|mO;4z95<)qgli~<xJQV8`^|Sf= z=_wHDp+UhCkH0Cz0}AqNS>k8-6lfB*@fKcSv!PZPh^9RWPP^SrL7`w*rb1LQ0KD2V zV@^qOQ);T=q(0d^qHk=%n$M03&=kyhMc5Xf7-bnpupa|lOJXgV*Vh(2y5Q(KXJjmg zLYaLQ>g=a$OpGGM7+3%7&-d<c_K2h7Uigw3<|47cV3OP9V6tcFKRScqFP1Le0%{?; zjzIt>XA~38Uv}VBfOc~cAPU}fCA<)MNDK#^;;`X%5W2kU{|8^XZSo!n%cr;aA!U79 z9F$!I8#wh<fJ!l=+pEpu-E8GbngM`aj$xeBJ6$Ug9EU!UU<_cCNzGByTtqpd7-=`( z7#AaEAGo?D9yLMK3Um-<WSh<nL+OGwC%DxuD}H#G4PxF*MtsjSnjG97%@UzIq4Npx z)I-y0w?+tbTG1jJZHdzeyApkP7!MQ*94j32qkPgj64J-13Md7EM(I#W<taQ)BBN-> z4M@u0oIN^Hgc%noj6WG+pI+plNw(vREvjie15B{z@{d0C%7bCW#2|4d3Dc&mwVxG^ z*(Memb~m}MM*6&#p(0MMm_2AFu{;M5bO**CfB7gBi=}%H2OyK)pDSbta9M3)D~lO4 zmEsH-ZR@wlJ2P;paF+q702clRTX%#MBvcL?(D;7YuDN|QN7Jo*Ow)#hsgHT`8(n!U zps=HHA@xxqpyAc9zTLuk3<Lv=FXVl9ave5?+zej0wnV041YQu0VBLm|(QP$c2E{FL zyM?MgHgf6-8oiKMQxljS!^kN{3D-K7dNjP(@rVZqQ47OYC*ml5Ds02ih9!;m+2coA zdpKH>ip^%vFIig**5Ow=e|6IcRfR2(rO&OoKS#h^+Kne8P{ht6K}^b8&<3;mj_5@5 z(tSQ^;fI`rJVT%fiZ&gBzB2}>MZsnjWwxIXnV|n9A-pjjD(&Qfe6DJ`|7bp`)al>M zX1VN-{Fr&n!KS{`C(&!6&!4y>r=k1xt|;X6ezO*2CwoTAU;pyY{kr(^l_Aji-4vS? z#3;obJj50oF%JW9f`W51@neq-V>FDi^Y4jk*sg4F)MPLl;StCD?w*pCG{GZ_4)xB# z#?LypuEjR(s2fNZ*xAZ&qGaE|P~49PQ^tc7(2G8t=+N^n2Wt{0LEVQfa6nXNC#2~7 ztWT#UW58om<4UV?WYlL8L(6Q2hhqs%K}bmnty=0RtxDZ_YV$PWM*;lYA3HI#*Ka!b zF_-`^BWX_E;6E+WjCiI#OnjLMe5eHwf~?FbMGA^8*J=Z0me?HE1&Jt|VKPiVQ!zZr zfdD@?lR%<*ELFrMP&mO(qMRbr0`yIpo(;@LuX-Z_OWM(=CY<>MgTsS7q}<qOCU%|U zfME_V`?qlFi!bCeH+fLbXv?Z>cXh6pTBrf)b@G<m!I?jn-A!Dl6cbkq-5v3zyMw}3 z-r_C-+T-;JoE~9z!|c>yyfYQLq!yD4li?vb7Du#FrA#zAgJUIis%ka36i1OIYaN{| z<pvwuqAwfqX8MP%vU))%5l7%CB3s7N#HT!vn;L16hvV3)4jCe}voJay3!4iz!QlU7 z!L;qr_5r~S;zG4nsQGd>*D6c~aQvCqbJieaFi6^5!~}%+Mej77ubKqxq!zcvO#XDp z23^SPrMPbcvo)365>)3@#K-)-=wVU@_!kgOEqGkF5CWiF!>~}M%UqN@&BL>Wjf{&E z7P970zyHaqZglno{N7u2V0o#EydtOw$~&G817SInXoIgen~PduMzSlbBU_DN8nWoI zn;d2s+<<`w6@y745b$2JaWp{kP1sr67-qVl|4VlP`05#$^6++(Zsn2b!qU_)I9phf zadX-cV?9X9@7|p5@xM-@I}M2r>wr*jB1GX^L2cNG6d%VJa=1iE7tSm-kp*1lFPUV$ zO<lBQvmx?wtxb0>Bc(qPif*)R>TSe|JW3X!QBNmMG9^1LN*UmDQ>L;_huLVzeTkS9 zr^H(PpnXw=@`k;fEEn)GC>70&!?VhU1_I(Nhs}#>bfeUUV4YwE3?HddA4y?%Ls1## z<(jV-m<K>RyO%)DovhuP=?w)lBKJ2-P`~L5a2E}CxyjOnbcd*jOA!!VGO?cFj*u&L zrgrlW+ga6k(VU|7iNt)u0_@XyrpuDD8<Zt92#flX5Kc^Dv@S&-@=_FE8nF&19_`?# z^E%@*d`$NdL1Tawoi4lL7tsE~yC(G#h7Pjpix-Ohtk0lISJ6#ib(!i-*2NbF+L3Zj z(P1YfNJg_JT@qm+D=G>lU;jdv0y6MvF>niY>eA(ij$extktapywJ>r8WJeShfxQX| z1GC&TOb`T!lp9DJ1KP&MF%Z3r01n1rw#eHOr1(S01`(zgbwq}q;H_A)hSprL6^$vH zYSj^`-Bid0Po%IxBv4oa!#vFLLx^%Xpr=>HhL8r&R6B*)wtOOGU1r!_Wq;R{D*5+6 z`39oi@#j|@7#5&2&0~A_I$a1+;NsT%`d;$#`)zPC*?ZON*x(D0UBZg*)iFB{oCT*< z|GB+`pa`1Q`OC3K)su<mrA|0R4RP-a3E)#U;^)(Y#RLCxp+~uX8Lh#Y4Gv(3jkj@k zqVSC%$2?=(C6IA9pcDWj{|t=rl}?!xibJPg=Ab`V{3zrxVHv(n{(71MTj4vl<AGUC zCKCsX$~+=Wp$T3>s5gEh#1uk&D9B&l&?1|f4qoxf1<|tNd@gCIE?*%o=iq-S2%Cl^ zq7cZ11shaR5RAryf+qgccyT}yfw!5aY_owffRtHd05Z!97c>@!sh5|4_fbB~g}MWV z!%M}uwj7;8VG->LVfK(-&Td4HF3(h|b~~$gJKN-Fc64RbPpzq8W!lu!Pyd-KWS&Xc z^ch-CkSdN1Y&IQ9XmU+luCKW&C@=NL1e8J>iRQ_nA>j(Dv{oc$Gfx8?YycW35;{sM zG~Fh&c)}sA(M43fPl}_umL*1dx#*bj%M3Dl<xNbqP^&biZYDI4+D%PLv=nabfih;v zcDZq}q{vUkcP_?malSNKmya2!(5Ma1{HR31%Yy(13;nd<?Q--X3zmiOaK^w9d-ln0 z4B!GR_G+6(at13u9K)Sw5V1cB#~gHcWF)%hh1LNzb2H$Qc!JSSHcDcttV%n()=mqB z0%B(6Z}9S{C1#5=8_Eic;bg;x6$Hl~TTm8YFk_?`=bYuY(Va+Xiq0ntB0+7kBPn(h z#&3a<uVkJ}d^Vf;gK8zBzj*HQT`=>ApY8W6j(zdmq&K;A^mfO@|Dq@;(w%3$zWyMr zDM)hXao4YS=>;ys3<m7)$9a4RkPS}ljA&--8+f|nb%p7%6!BuZ#}`sK^AHtc(`si^ z4WkYkmY?tc{SO{R6G4!m6I_g`GdM3p&`O9Och|eJXlbpwak3N`9!x4O%V1jGJP<|b z2@z{;`aB&agbk^Nhj`_cG&W+gZd-cIB$zj`S;bvsX8iC|q|?YRY4GQ5=0rQu&?@M5 zrfFn3*9I_g<st@`B1sb6l&QF?wZ~r&&+PRP%uCs^QG*guhy`sXMQ)nBUJ!L*qy#5Y z^li+Dvb!z`%_{%5Rgk>J9{f<6WehZ&EK<l?%=3zq+0Y5zi&Pk0W-z0-{()gvma64J zueWsiF++7Vk-NSpp}n`rH0-SJRdu5U9xrFnEC^6u9yp5VA`;aTp%b|Y<kxPv1aFqs z-|TfjGI;Dt2bjp<wG=PU!-Qz^pmgJEtFQPW8h>H!YJ^c}e=qsCJS8JRFOKFhhSJn7 zm57xq1uOz^lZBO-a!h<g?XF`O5BZ=R`W=335SOpPuKQC0%WYJ(jeb+E4NQVTb!km! zgSM#)&fb8yjDOv)J2Z*aK6QaDDTNPRSnrZG+oYvRo}wkyP?S=}qsCNT!SN&G+kQ$X zqA!oAPO5ZBKH5To>gXJ&k-8*x(X}ue=P9B}IWvX|0!x{bh%OjB?D_bVM-20+;|At# zJMvVNnS@l+QiPz>p+2bk%a@bUX@+^x;0m6rTUzJ`Q@O;FP8Ei=`o(sh^|qd2Xpy*# z3da>7jw|O%hDwnjr#tO%J=#0crngHJ`CBG+eWLf?L=uF7&~OS$@y<(Lb7#Cm#qg?B zS+5p+RjF3a6|@q7(i(3~%|-3?>spOIO(7b}0%v$G(o(J`%5~_QN-1nGz<8nG+NP=^ zO4{`85U^MT4)p=Y?o&lK5_Kn1c*)xnxq^914%HzEk!{8RIGl{6j56IRd;VxWa5HF@ zNH?ZR{cNvF6J%{jkp+hI3)hcG4EjK!&Ci^A1gmQuMYFe2k0b>bU>xU&xK>rA<L^A+ zHS7a8n1Wyi!I|vnLpxKSI}RLULPFbRXm$3bz^G^@?tKSfT!EO}rW94*l^5RZH;F0R zAzlIV1$G9^9&C9Apr<%`|A*)!wd)Y4d;}drq%ZnRR;O_4T%3yO-&&PUoO6XGm$t#o zol}cg)e5IU;`u~%AZUyMq$)Uy98Mh-<6OszP%2R63OY#2i6VoQXwei6lao&id_b*W z89j){@RI?nZnR9W;|7v2*wp10+R?RMfex&YB^)6;eyRW(49&Ro-5b=10Hu{~w(^lX zSBH|3G}v?;sd&tuPtePmq_7>^n2}|hRXHV2RXPVw+xh=g$ULejZpX9ovoo;?KMwd3 z!Ry0JWJHf2196x-?8)Hi$9O%3G0uvExc;gwyiWUo$X_4zE!>_-F{zko1lcb7<F8gJ zOzq^QD-bh|Wb%2IcQaY2dzs5p8$(!6V7p~k7c8~;{aSAj(Z5~;yUZfFi1MF|Atz%e zY}V@>X39<WjaiLwjt7?!C}8}#;sS<@!A+Vt0ri%@S#I)Tn6~Lc2JdWO#;c2?W5Ng- zApUQsOgdJ+Y_4por3e8C{NEJo<pK3|XGAlY_AztRCoxTKglQ@%A=*R&Ip0`N!y5y5 zYnE$zY+P@wgRwaN^_Gr`c$|6DVM=MLfa+N|jm~g2IVmddW`V$Ewo>n=mf*kg47~&4 zsCgiOwgHfL9)=mi%3|lEN9b|FIHKy!jI-gy1F>&nHU;E++vpspeX!Xhhdt|tQ@~Po zE=wJN;=Ex@VY81+@X*Ga%dArPw$!F}5Lhqpn^rHIsWgcKX%~@3gx9-}n`<evGry{s zaZ<d<S?6YW*!pQ(`_;8j=UtK!H=N#q$`wy9WvV=>i%#xhFd>wakgz1f9C^~VCunvT z#O#6J0Z>^30CydQIAu=-juS&;$S?^j2u&38tvDLQ@o+?<s?57`N*Zw*5izxrvIAa} zDZ{K_oxxE}5xFXGqp431&B0b1Gj&|q4*@pKigjomN71^>quBzw#y{hc$_A4@0LK_? z;bEHvz{A%<wuw{7BeY0IeDdS(s4UEI5_>#iPn`%Uuqg%#dYoQ9-E0f_9Y>KrFI1N= zuvH9Vtx$%@(t$1jdhrr6saNn5;kM5^YlvsSwc;s%ES=@b-iYQlzi&0W3irMZS+$v! z%VPeH3#sY4hm`U`fy1ER`7!^>b)W27R?3xn+3IP*eEr^oFcD+;^0J#wrivZr8N#?a zBzk&9y8EjqQBdyiDBW?;1=9LZ6}?iCB|!NE057h4<-`Pj=Ebv{|Ip<N2t`9uXGY0H z%^rJUS}Jgr%!t-^McHAw-@0{xs>hDs9=JH{<DSB%e2I{oL2ZoL)T}G1N+1puK!$&b zd(6?->vRAcmZ+z)bmgUUz8$!=QM$EdQ<IkGv3X6W0jnEN9HkH;P@pOB6-Gf35ko={ zgg7?Xtgww3ppdg|$5%aKoW|<!n}ERla@H#Q$GLv_P5GroHmai}GIb+!By{lDLbtNG zG%N7JTll1}Qzp5~&z!i7frVCb&ef*#x^pcnNAlRr#so)#yJ_wG?GrICnxjJ@`83zm zbjX53LURt8Ph>yC1_SL$61~ZrG3H7=T|i=hx&Eeb=KG^)hP#1)c~`m)5{+TxpjKwJ zdw)p_VU;@V9234&@TScY5kw>65u5F7^oXT4ManxE)Ndk$sFO15WwLxyh5!0n$zUOt zSfc|DlquMWMW$_IBknOn3*apA13ImFWLp$Pp-|>HA{1>(MQFxk#$f-NGFT#*A@uRX zru@e<jby;?K7CM+!I(e{c{qwg3tt>ut2b!<F>*ZeC4_AQgmVKdMR9pR(9Va4eN7bv zoUw^pX`3RY>Sc1O)9*jMr-??5ZUI3nCj{qIbIt}6KwCKck9RFeCT}AA54S9xvw4=* zRVl-O?B^PwJ3n+Q-yHJ}oey4^Qp7}sJwP%N0QGvKi5j}h{#4LLz0tyW^As+-E?UTt zzZgO;#uQ-)c~qSVg8_`6&RvEPev~*Y(N}ir5qw6>9c5npSh$U;x{QbDqawrM0H9<v z8jOTcWrm~_2c$&wEb(BHsES}j4WJN*Hc;f4Ah2xuO47jHF2mcGF+X_>L*D<G-Dt6w zz{ILw>806_hm|GbKx8+NYKx<kr4u^id764I4?z)=<j~YY9FVQUq@$t@x?Nb=8H7?z z9E>fNBZO8ECNReXgrpPN<i-pV;~|X5(f0WDYui_-YVAcnZe=uhk*O>)i5GT><uqvz zvnXPfWsDgkJ}X9u^zB}x3jgt6e-a*7BW5<)-(M(FuYq?)JLS#X1jgx^9H|N4T9c4N zr9>mqNW!DZDAGoG)Pb<`w+kxGAUzB0%m{&TsE4rcN}%k=K_ZW-|J~1cX<HY&ga9%} z+al6!WHXy~C$>ou4NF;_z&=A>ip)JMg!1NW1i=`OnE`q#bZJ%8mZQ>T&0r2gqcD70 z@=}BZL14S%h!UJxv^Giv?Kid%=c53(`%zif21s@5oM+WQWK8ckH}WMu^K6ZEN7(~} zn%WG=>$qMh;0fWAQt(%0j>y9Q0X+t|40Ri9pVXX{9FB(yGXF*(CJzdRizLjkd0z)a zZUU5Wxw09Txl=q=NM6hSgN%2q;2HgMYklW_^408N*sy%(E{!*TdK3knK?S$dGYn;w zImQm1%kI)0!7yF1z{bRnq5wfA>l;FxXB25=@8joTiqB}lddHy;7D|0*eWkQ>x&-88 z{nACp&Up<tYonMt6iEmTkZ352skOB}g6?!TlP2PV6$h(iDW0o>a{|#bB>}s1`CSM+ zF;|l@9mv^(D?1ATg5H_L(a4t&ctO*8x^~PnzPYA1udFBz(%Mzkjp|&}LRrsKhsP92 zdV6I;v4H{k(h*Bgkx3qH=r|1Q7uapB0yki281MKU!$ryP%5?a4#1Rst+78;M4F<XJ z1$FT&k`{TO@bS1kV`H*&BA2O4Bj19a7NpEn*_(#FEathdd4?SD90^Yg9vEEFKqPUs zhyOR1cRn$}7GJa!Bv(GX5K%9f-v*u|CN@;C0Z#9icKu)$f)6HaUTk>Ufu9lUa!BJb zhRb)1#0*tIgwuy^_OH;Qza0}Pvvmn5`&zpuCIZTo`h23&auAE4vQ(4RTY=o5e`Tt2 zX{99t!f}F>7hJ07a(ZwhGzHRr`fw(0PHd7MPW)n4y)lr%P$W+;2^5rorShkLwmA%% zY7lV?4Oxq5%)Hrr47P~rVKD)DZ54(uP8&RUCui7?aczYZ5_@bM1+MF&WkZ&z0#h^0 zIDi@5kB_c!+teY#R@p}!v=Zk8B|mEeqF?x3AZN$@K%&3TBfG-OKftK+=X*`x-sbO~ z3`uE=bFe?%{3oXZ)k-wK*@5P+3#kbw2y>1<HwlBQe^?m?+2%Uk435XK<o%_d8!$wd z{hpvM9wexbt~n!V7&<&@Y~?1bQS7CRI!80K#DKU_=pq#z(xVuo3L|cjinlTog-l$H zs@i$2svsgnRjJS{QJ^V`;w$N^MWu{c<%p`qj3pukY#$qZ6mXHPve-ft;T1MVF1c|b zcZjqG!^SZYF{3z;MjHh@9I0o()359Y=Td|v7HG3-mcS4MbhjPR1qf()cPgsFVw1hp z7ym2k4gdWJASnm`=#tMDND_s%V9KP?VB!MqW7X^#1t{hb6HI6$Ksgcf0+^sIu|=`K za{#UpSz~aS=uiC^_4L9`+1oPS{LBWbi#IcLf0JEhj0%_UDupZ#zqFOPEYQ_VtBV%K zkGEoctVL9btmh5ghv1mO(sfY<02SX^#er8k_|`_to-u2cn7|Q2PY7V_62cXiHnHhQ z4p8Va02)p1wA5-3t*S=`*Hgi!=cHuQK10gUeIi2GBtmU#I##-Y9cG-zl{kklovLjI z3Xf3fUAS$BIIfzkw5h$+w1b!Wi947Bw4;V!tL0iQGtfe!kB6&0jNwPMMoFo914Ph1 z9282w!2?`X*(&c~##4GClhqw`4Vfbx*&`eFZ-auVRDqS%<Eg8BBDC9}AQ3?5Ono4m z=^gEK2MG)R)B#d{K~X7wWj~qB*V|i3^8}ZP7W0DS5ph;KB)9~1O=6C+(~~B>Q?;)= zFzBK<6-V@?OZcI~tfl5R1q`=fRn?9<oPdG#m5JV5!9xadEJJj_sKu&)el;vMsDS&F zT4WyN`IUek#=(FJLWzgC`U+GxH>lgN86aasp=`hoFA~@brAHXxEP<BXwiHL92NJ{R zYf^P#(nP$S2<)AU?O=8|5-%=#wjDZCf~i95uT6GC_Ow&jhTt2KrUl#jb*)v;Dj`w} zGO+}S<3a=_bplWb6Eu0EAJMwFm|<8z)3>(hQtCt4CMS7jCnY}`L0%zyhUrKg$OjmY z!4FN1spCL5QIp+DVL==;l8EYHX@w&@j9c4TK6;#yeTlAQHFX-N+DL9$zBEM(Ic+QH zi2a@}94J1E^~kk>-R*M7hfubD=L!shr>KDoGs~^qN-qse-tK63H}V(&4^<1fq|Z(E znu-VB6Oxm%&@k6o8&tSWT11kHBoa{A$#r(x0A!jc3I%Fx%Crebp%G$XajYpdrQLXH z5;l8jH4wQW1PE0rjxh`?P;SSA^ud5E2z2nc#iR@$hX*0fJo?qDYm3Ab5RxLG;DLo; z$f{2TRSIzuSD6V56p*KJ{C2|MHbOtEMom--j$BJ5px{&%gY!|IVV%qmtPW-xpC_`8 zEmr2jyq-zJg!NU;eux-QpE{Qn%ukzsh4A65sk=brvH9nn7UbwMA5K+<E|kj*w0RrU z(}8Mx+KQAX2Np42fR2r-Lxr9<S)G_+CKTbgUPooU0R!ejVYyG)Uler?_qG1)aoA8* zg`RmK4x2_cx>V7{1}ij2rWYX%04K$k^~J|?J@z^`6q<#3;n2mNnA!?=o9Zc$3jFTX z>q0o$Zt!$wpe!GAHaq&8ydg9$1BxNJVu=c;l#-0&rxE8$K{+v-Dy<k?E@~A8X=>BX z7^qCY-3dc<or+Iu8;VjYAOEVSL>^nc4_>h-QJS+9E+$`+Jxj)b=-=#2HAFBq@)@If zvgW4E7S;UmNQ=rL6*p|kGMnI@G2w_YaQL&$TA&CTK%${oVduCGjpBi}_IbxQc$sos zb(r_vHzW@o3FH}z5b}Ft!y%@+9vslhd{2LJ`P*f~Ek#i7Gl7XSDyZrz|4d4%yWu`F zW%iA0&n<{?fX5|1iJpk#hvYW3WYZ+GUs!ou|8zD`I)git<bNpzjZarTz%>L_5rwKo zERHY~cg=vZIr!PC^!d<yR&e12To9TF%*IJ<7jB$mQ-__!r)+wKtsOV6X+wdLQ12wn zT4+#ac~sl8nUUiOFV*DzR6DdF!XI-VHvMg*nwI%!nXRO+U&D+7IHtNi5!lv0BkR=0 zYQ88$12$Ny{9E;Jo*>7s2-@G6*V-ta_X@J%f>PJm^lP(L5Jtx%;{Y!p1>wMpm|7@s z3_)4Sd@Q{DDg=4YO&;RfO?}XMb=kZAUcBCl+yOu?dNY()A;>(W{%8s0yjTGxWGANr z;Jph8CTh`yecq^%?#{mzq--G7Da_wD9!z(~A!*R1uxAOUe=kKiI%c{`ZW;jrSQt>z zT)64KIO=46>-ILTbLM>61I=J@L>Zhzs4t`vxB_Zma|KjA;?S*hW!a6rIDF~}+na<e zBRB@5dG?%D^;`jw(N5@R5`Sh#7}86wt714jx~9;EQizyfA4f3>X4Qz5&qpkr*GLhw zOfB@L?+U62z$5l#;W-86lR9OO^2;9WoYV|xf)EYaA<tA02nekzbW>`u9aqE`<^q>A zV+!+EMXCrv!y^LaKsBo9nGqCb7!2#3L;R;-x^VO&s@>(&re3m;niT)KHC!$3F^Bx? z5!eMkB+n1LK13oqkze+Ay{mHQcXVpA>WKm(F_yr8a@wDq5QnX%+3XnTRuNndJkA?h zJC8hdXyMFG04N-R2R}EL2v|%C%}x9-OxUY&P5j14OYE)5Ec8=`r3?edC2YumQ1?(! zu&lcBFvAcD9c@@8GJ^P>Z9lE$v8^~n6Zmao2VZ(a$i&TPdBK5gW>dm(h*+}8NA!qL zD3JY$jL1>q<FRcUlo2}u#kk#QaXiqt_{?9b>#HvUNagP^goM-7vcM6#s`Bx4MwD$b z05(s_2?qvHN^C(>YB(uX1IV!)8>B~^#@Cz*Gqr0in0OGj7x#@0c9Usa^LUxLiJX-= z>+gS3&G4=Vxa-2KZ}=GuoDlel2idgN;pV!3ZaS&^_9&i!;+o3&+Z+Eb^u2t5HHv!m z%4~M64~*l%L};mF;yD1+D;k_Wn8R!+CX`WI+PLf?zCtv<C{P8bsEuwDly+kD=lew+ z%6NdXY_^&H*03e0H_F7c*V&Nw>{+$mb#ysJoObm>A3w~{L8l4`mRC4Crco?g{a+fb zV5BpiqYUg!S}&`p!VEjov+HD6X)rMN>v%?LQX0@BENs+c`}B&rrY31cP+*|jYPIBJ z@HnCH+0z4<Oj$F*Us$3j8m(}~PR(+FP;8_s)m>+CaODaVLpe@)1Kd%Q3I$Se)H;R< zDnRLP$2szsSEPM6Tw}~s*VCmK;4Uq_!~}Tx+Jge$c;n>Yk_l6}_QlJVPudD6BVi`( zm4sWM``9bb%x@CJ<@?t3o@Dl|OMKoE-~5ivn^BDiEQ!+d3SWHaqL*+pc!)RtmxxT* zJIjY|Jc+WtQv!Sijw>vIa?+;yV}h^(s6ALoW^klhXL!jA7|b|NNy#=CsLL818vN{h zqP<qf67}`5#<|&xA#3EcfRn+rdMe|Xh@}ClxI`fW76akqA&XfRQ)UBA=3KsrmQq+8 zNGVMqw7@%4hw<4tr5g#;m@$YymLiak=3-Rgmz^m!M9>KlIDQsM3ecg9L^1ncEvw?U zG^HD^Gn!-Xo2WTK@9zzvQ|^@)9GKYF3We)fD2~`(b^u6x@(&u*EzP9?zx!a)bB|v- zWxgv6jOoaN$J8tZZF0cS#A7e7E)2M%#dCtlP4k=<nq)nxs6}LS0irsSSk49>#&MF# zBsslb<VbLg#o*NiC}ar+h}Kmv{;$6=H;3)=EQH0d&VQn22;5|VPFrgs{R9Lz_+JW+ zFCP@QDNLd2RJ)LEp=Vx%eupaz93!<oHo@U3DJg20ggcrIzqVO;gjgMfZF1)RiSGmA zOj;9VE*|RaGc^zdbs=WuVmR1+K!upg&Tr#~D0f>;f<WtNN36V@PLa-*#C$rrF!Rcl z>X;?cy+^I-@yoivHv61poThJ^9^@~%=9&#+fP5Nrm8%O27p^ndQ<(8kb3YBhH^lg* z6OybizDPG2XeCEo{gdlX#$b5{SaQn@vP36!+f8d-4vH39(Y$j)lR8?-+_E2#V&huB zHMi<0dp^}zVFma6W7DH}t?c#2Tv1nD#rT;JC$9?jAG6eYj%-lA8GR*=4!Lq2wsk{I z4_Jnupf4MW^~DtAFc;rTFC?HccsRC89}fHzF|rwxI$9YRbe=(2-y`@$EQEPhQH>37 zgR|}9r_h$>>vEK7!)rR-5a^KaOmcF~BdJqwR;uJ_{3*mU{b$(g5Q)pSj9C0N)dV>; z>t2Ch9;=QG208_(O{WkGyVA_1HpUat3a%9~$peol+d@o<9sXQUZV~>2Z-kj%C8!$z z#d4on+_w)@4jb3I|M~|n75hs*W;~+hIKM0H>cw05ut9cqSKIQ~P@D7|UHVgQR4|0~ zOGBKtyX4?pL9T8@nfgq=yrJPb$3X&!bH8K2AS&zW2mmf57^Z6E|9m5TbJfVUEVepg ze8^##;odkYSn{v|DgKHT&`3t2a+5TzUjeQLBWPHU-v_c41^DB9__NZWPgh!Y__S-1 zQM+LPGv(Q1p^_`2GC&Fj%n`f+HhV;SDg@xQ6pmOGzZ$LIahT(m3<4hoK~t4)=a2}E z+pK1r25c!9qagD!gI9%@t<(@t!O#p5%x|8SiKcqlE~|AER~32fLe&0ie75uwDsPe& zf1%K+9H9!P%dj{_FduDKkRLN|0%qDlxi1&!0(nWJUkc+U5eYC2@c6ZK?dP;$=yw5a zfs=z7OpA#Q#l!#;X8$z-r)W)>WUEhwk!2PaRvZmLJ5?zS6&T#_@xlaQgGZ%BP;YRo zeAV)j`$CETWl8Yy;9)^AQpUXK?53|q*CjyN=wk>-Xc2@0R47^GL$udP>qWh-R}j`6 zpeTd@g|gD{)HbBJ#`?n#gD3F?Z3t*wfn#SC7mC>WYtyb7J>B0CBF58o59*9V7M`q9 z9fmz|IO}y=jTJdUcnJV(2?<C&JI{m~)cFbk0>X$rk<raBBcL%efHlaZV4x8d1=edV zcFfDL1q~SlMU*v!3Q&##a-n{0eoka`t;i|mw7?(6a;~N4d_^Ck&5V(M=`RIS0wQ*G z8F26FnCLte=Cp95$(54x3`_q@TqkMPAgx;f;UaiVYM>WgBN>FdY(NXY28RU=_QsE4 z;=Xvf&gF!#4Gc$)su^y%Nuref_DZ3dhq^sFZZ1I01o}hM_M#&v2CB=!;D+k5pQB;& zLmrclBRj|d)jdufFcf!s%P}*<k^<c9X{ZbeiiQ8PO=P;VDl1O#Z@h4}Lj=lB!zc_! zORC4LhR|fDya60-3>y5#FtRb(GX8TsR^tPmV_!=!M+?H}6NyofoQ{8xnW~)>3IYrc zq%a94hFy8q!t*r=tz9ta-RbCKJdwU=&>+<OrJMmC8>pwHjGhXb{<AN|a|7Nt-b_0@ z*lr#(#k&GQy~(HF-S)Xegh6Kho7ogR2^_+LgcRwXptChu+xQBK6bh3ebTLmO%&vS? z!3bfMRUV>9wgwC;tg4aR>O$5)mHl&U*RKWufrXx8)jYYNqmQs&$hwL24I#5!c=v(m zA1d0iLxLvokrv7nC2HjxArz>DwkBKgV06UmTEk~^`cRo+#}j!ivL*Xy8ENk*a{DWT zJV=i#?KAvS_nRIl1e@4@4GDGfA4R{7FH>*S{awYO_VF3Kq<8rdv&Ss)edOLSr@^I~ z8AjV}uz>)>nDw-cu>V91rr9Rw(J7h{(Bp*J(QdOTv8ybkcpH=!65aQWbA}}5B3OP7 zF0~mM<~P&7^5(h}=fcK@+26TJt67w*A9m*ZQnqIWyL*GU7uH(XC9=~9(?23u$=F!N zd;MLYp-Y6{lMH5+^o%0Zba4_JUc%@qeCA=qf<$!;=&Lo~<<xB|S{pNFE&HP`G@Y9Y zB|N19jS}ge;WGw}Mtf&Lg99kK$c*qc72;ASaa!%&Y_=7MF(WWoj?h~bIHYm(SzDJJ zGUaWwx=s8MKPVk9O<^$bY-#+CN)WyI@`=YrO#{ph<;MBxmDEO8*LGDHv=bUfg7cJ0 zhlT%j(}oj-B`Du8H^u9Pc{K53C}_KHrr@?Y4nk9yQ^S<vatuE>D%KaKOw$uR>+Xg8 z-~S}_2>{(12pNwO($4p!{hAB2HBW*a1Dp=}#-lyN81P+*=4p*W*2GYk2;{D4-K?yv zQS#U10;X(sFkl83+*}OwhXv?x7x&0|hsUAiDMJ=c3tbBDnj6U_kV#oT>157BSrc?y zxx~Qvq9QKwf|adoPLoDx2dh<9Y<d#|B-Ps_?U)i@D8X;A%SXTJuth^p2vZFKSRhQY zcx;W+CM?g4XOrTB#A9XE2FU=W)IJ-njyXH8O*xC;d~9kTPOYB`88Bmt)}&<OeVkvo zj3T!DvJjw!w)w8uFK0z=%7^SP3<M%4T0twb7X%_nhdWt7?q2q%SI!rI5nz}l*^CI) z?XXRS@o(f4oQphC3lUIg!G;{+FM2TzckQb}@`^J8?PQxXy{i#^=FLPz%I_J1=rdr) zj9I|)hAej$w<DmJwcq=mUFPmIlFt4rBWT0XqOJr$gXkGR9Lg?!99>vR4r?zUMvx|l zM=_xT*jGmPg0cx<vx4HSzbnAH$Wlm+Co!OlXn=rJbJ)CY)2mD;#1;^sQ_)*DJIX;Q z73>vkeH6&hbJ3zwf8Xr*Z4~NPj_W^9usWSp@PI>iLV1orQ!mxuL`csCO{E+~W;o~d zQzQ{e*`qIrj$g+9uNwb+|9FsshjBgojvt|nnF|tAs!4$0V^!lr4;LsY`L04Rk)d_7 z7*LtSr%-R(`B|Z;BEPZ)bRm_lG1w`O!6qv31a7VwXC8!Y#-*<abJ<p3x^CGU&W@rI zoy^c;$|hoAg`dO?R%jWtnNM=NS-i*suFa*m_k4_>THtj#L@L8Rz+f~+dk~QcPWLV+ zpftyizv)#bs>8E2sJvK585FQ!F!V)JYk<TZHX~})BeHy!g6l2HwatcFm2~I8|HWFs z@AM*QXp`Ma0NzBrN}en{6+Q&fq@@tRQcWbNJ7Rr2GO=MNO|@S6$!4gn#^K|6yq9o+ zjAtqi3bw+<@#tw=6)-x7;9Mxj3>1bH&zYlO+Nb|Rra+U#I#zm4IWf<kdPoaFd*MYt zO2woHBd#iCTdD<5d{82zt6ne;nL2iD(2IZmi`-jIzok5rI&XdU({5y^S%_^d+)?c0 zttcmftyR>}V^b&TEHsDI%1Flt!F{3(iTrThV0LBm#y@DX!Qc^VJi;a>lpYS~Amfcj zU(s`%n9W_#%q7~FSQ`Usfgz#>A@lq<=%VrehLsGI3k{#>x7sFnj=(RSohnWIUjaq9 zGFdQ-_1XtGDJe4mn|u7p8dPGK_9Y0D4VIc3PzrIdaF0p@Qi)p#8JyrI=5a4;%mx*K zvyJF}>VTx24Sw^X)u<E&E<}fnI##cQ9ppp=%U~HYYz5;JU??_eeoB@2%z({Xjsm)r z70xoU#ytJAnG`sCo|Y*TDGEmftre5eP$bUi6zXC=p#b~&XxQcP_OSU_=iJ)EyPsxc z+3x^Q%9T&qzXcE3NkE1{`lgQMwxdE^#aY-eyTit{Z;PJ;gt@_k_3(J{y-VTl^n3Np z7}FAzx)O1u`5hh4LV)Lz{?N?f9rd}f0TD^PBPjB@(JEz?g5^d0nxI#jGqP!z?m;#Y z@uu^}H$@?5XEQ4dRKAjC1&+h)gm6+DMc{CmL+cni4Nki<@DG+|j7&uOHpJTWw8R2M z+0;SNm?c>XzS?*^0v_{{Ca|RI;ICDvwQ(FuR#_e;{MY#<U!hQH_!*-wbr-||Dz~UU z%k_n}F+B66q)Tr=AP)13WYGfKS3>+7U~8}n!#o-%QUo(C9F+|pHpG1pwi&&AMGv!Z zzjfvBNzw8D@Q(od@^cF}l7?I~!Fqlip6{_aRJBFf3zzJthsk;lY!V36qEz)>6? zi9QX0Qdq2#_ko(|8lY9E<EP*phcMX9>L^DI@!NGW`b7si=Yg^1GJzqEP%Z*0YW`?q z1HHP(>^l|YE9xZjfh=b*I{RHA3=8t4Nm<$2_?tysPkTvSt4H;f*`80u*Q6xp2ri%+ zLLS;Z10)#w%kY>PKvCsbzZJoo0ssIOTS-JgRLUQSq<n-RZN!pBo@`UfO50$6iAY`R zWXHB+hAF!^=gL}~2nr?zQ%W6~2lIkP0pc&!;;iWdhqCQ4Ull<(G4(eCZLvwqF!Kcg zAr{{0+O$A}h)599Dnnr+v$h6~7Rc?s)LW`KTRR@ePN#74wMiEdsz@`VV-9dsQ3qTj zPIWWkl)~(Ft*1C{7ttK%rJHe5+(%}zX&v)DJW|Z~Im5dWr7M)qFj_B^t?LF$+v$Kh zMqwJ)A4{4!jG!tN$A2v^O!-o|{=hWpu)=^{zY8`<jN{EoC?y_-#8wN<H|qe*aM6~W z?M@rnU*aAbT8Qf|WjbS^bV?A>)KsXHZ44E4Bwwnfz4e5yP33FVi=aG?JFBRO4?p|7 zSTo1j1OHkx$wvNHhK!UW777-I@BvB(FlI&m&lY9I6KZNmOB*LgdSQ-FaP$H@Hf?S@ z6;)}n;~pKJALcxzP69y3W^##E#Qk9`rg<I*IK{G1F)2xRJg2djJz?WaZH6J}3}ud{ zs+Esp!%lJ`)wLdZ4Ad@#&1Nuh77<SxiNoRWfHET`qL8%^o;V&HhlT+yf69Yp-WL+3 zZ4swP8zLJL#Vt#uxN$Tp_0X!3&9=3msu>-fn*Q}@(cd)xffy5ZL}(>8uNvf!F2MEK zlc$`g-Fcughj}sEsMaO=iWFtoc1&DiKCV?=oC8&GX=gW6hQjuUU>y-_qkAk$7E+{0 zHprQ%dh0+#iXg{Mn05%xrUG7kS7~Wve@)kLpWrYL7vdB=W0X2OY?%upTYF7>Li(uF zKB+am<A(;nvkwTQYe<UMWuk`W5hyr=G81@{O=gmwhfU@szL~OMw4Arqsh*rBJa{iC z2|5`1k6du)GS`@BLRSmMiEuhv!1I-9*YuoTWN78nK=SowXfSvA0S@9zXjVtvZ)Gas z%%B+s3k&BTY}=)1q@i4y0(TOf#W1u?RXA}^Bhfu32%0!}Mw8~qK*4G;g+Xs%{-PO3 zeDZ9Q0XYyTlpl+-RCw5-ztB?<p8sjFT9aVuT0zxgvwpb(1AAaM7*s@r1{K1mTDdPr zhc=wpH~=N{c!oe(a`cWv``dA_96NOk$0J*FzkR{h5y7j5%RzLz)Xy<mE+#G-sO%;o zVI-cJ{ljhZ308c@ySO%__8>ha*E_a!6lc`jq>4svgJKD`poi?_4;=XdoPYX--+NQs zhXBmJzV;(H$-Rq1?G~b?%W@rS9SN-IeN2X|r<5ozY`g|=t?wD;FRl%MbufSs&z&@+ zq=oF)A^kzG!!kdq+MILc*|LqHwq+08?)i^GQ+y&wl)e)^HYyN_II7?*Uizt(2CF`b z0j16s-oY{|>na4&B{K|=>n){Hqn+VkJfnZiObha>O{ZX93x%nME;CyVUNmDHUHcfQ zk`~la2i-`cJ)Xo8ElL~Vvca?CVB>=(ePwik@TvlKHjpx7*0iIYa@MN-@7uhFY~P_Y z0BxX}3K`fqhD?)$1#kt0*I1L>k3U(WJ+Em7Z3Z4I6nN8%kVAqh|4}_U+!L5>zV)1L z*-TK6dp)z5nbS+$tHPoi8e*>dAp;a}EU3wz?Yv=TsE=vhY?jkkB&s@2Fi(EFkqm~@ zZxusyz&P-DDX2nj3@UV`T0CTj>kYRZV}Qa)MLXuZ9u1s!`Dang`T-lm0EVKOdU*^> z+$LOQdE?Pj3P>$<lp)|mW6Z(u1c{I@k<(#azDviXa5Q7FL<+6&lp>hzFncEPEf$gU zg^}YHr<PdEjI0tDT`T9E&2_V^DK!P3_OC#rhM~ekAab=uxAYZaf`1yOZe_W$Q+M7r z%0XwdZ2Zq!=$5w`7u`8C`Y?akj`ULoR)lRxt=nQYT%P@HQ;q^3^}<n#_C)z<#H2|z z^3sKhc`S+ljR%CA{H&Rd7g0LFQ-Pg*NImwlqsW5-po!E`FL5czy%ACpEUzEVI<E&f zKeYHMBRc2sex3l`M+(IBV#5OEN&<DY!=OqUYRZK)Vj`I*(6!2;yq`|Ah!uAVxJ=@? zWTFj0#%auIgcQY3_#Y2Mco=M^Cdd4y;OPi{cA8&j2sTbiGa=X@OnUf5<I<VldOT1L z<P9+0X%h`_=u{c--_Ro3pcO7#s7_=S$}Rs-nl1v7KgCEG+c>phJo9MX`wR9b{~|7^ z;(<~eK5Pl_{I;s{$7ZOJFKFbEwG76X+ZI^53JPp^3ct7-?3Qbm;jHRM6AIu`KDylh z{1-PFea(RcXSPJ0kHDNXS*4H-j0$JvM7>`mE@_Ik6gpL#spfPDWItti%vWE9=!ybY z2BL~ahyhhZC<lFjmc8ZQWrh?P&`9(1Epe-Q$n%s^%GNOeTenHU=Z-q9+W7Hs_bAGn zkrzPQQPld!jM-H5WaUI1;>{%hFRM^-g<+weHk(yQ4|#t9F$g<fLfHf#^29yuu|318 z+yQlnlYhgTE-UFp7flYoAv~)z6cPFXI>tP6DrWG<pKT9aKBZE=f-0W8)?&{9p^h(x zc`bp~G5n$nFy<W-WvFk;xTYyZ`mFr1WQtCdkL`r<D`z3LLHIEbAMvgX61(`vk%2kR zbAW&G^Uge;afq9^_~+El4ST){1I6XLBT*xP(l(RWEcY9_HNi!J@#8gd*S=2hWIy+j z>;ZAm*-QKsTvnN8MrtBx3I9L;?v_71-uX!(vTrgg=@)1-wO6Jbd}Tazf8a$-o!zG$ zW=*?IBOJSis4swVn3s+F?B&N|X?<BjS@9<6;SX06Q861r27z?MT^Z_+<Ii||h`pf& zU3)+t=gd=+s@a5Xy8#1bT|O5$)?G?dZd{2Lj^OEvt2~%5tAkUuPEQAP_?5TCL`eH) z1UNJm7b1R!oq0)dfG$i&$~SZq8A91}$fz;<lNBenul5MiHHLa!srhxcnkY|K+%^Ho zpn6wy<B`ucKgH)ifOG-=I(zz#LUT5`OVMwEX}o6x3gxi|I9roOG`Z=CJ9NX=^v_S3 z+xywTi30G#ycIuS0iv9f!hG_bP)`>PGa3N&rjT~J0^5K*MUe@CHZ&dpEFv^05mBp# zgI##ojUb18Ao4?xUtLWAdWOYF;m2ghP<1>;N)$zy&WBdLvO%r3)yR??sCJtcvJA$g zY>|fdmn+jX^9aDuKyApKqJbo=@tA!L#f=L4a(7{=ImTze$3Ft#P^N3a9C$roHwS28 zfH05vaFYK)5G=GCBx@<QB2bvWgqK1fa;B6fi;17=bRlK0jXpCT&le@3U>;ztcGI*2 zYvOtQwk3dik4TS_!Mvb`ku1A;08Ghy_N1ndqEb&PLHEpXnhsCxnF8--l`WwW+R}&# z-j%6>s<_Kv%_0!}@gfR;=qRLiI1LPf-YEizRK9tra_U_BQm}OqS$AfPu90W0iO*cE zgz7X2wj%N45|%)=0I^+Ic$2)qVNn#Cyq2x$;*hwl)I;M))hYQgpDgVLMNURk?72Yp zcjD4!LP)b5X$jEF31}}BG#K&pULGM1NDS)UrV$DrC5_ai@E=U1th6xD#FR!9pDp58 zYJ8OFlaOt}u_VSgmEu1*&ym`#O2bVIsPz+5Wc=9hE{C+?+hdT8ZSGf45!;X}Ktbr} zd<JW`#%Y1(Pdj%$|Kqo=pte{?mSz?<V6x)hEY4{`oe<5-zFOeo$BEzRAJ)z5*40g4 zn!Xs~3I}vkXZa0X13kB3iB`g6NvT#x3=$PFLqZp-R>raEt67d|j#=X+BZzViB=dom z5JhC+?DABW?vXYaOev}`Z+K9F6JnA%eGCEx+ebW<VnZFDXR0SQuQb4PENisP7z~58 zC<x4j!w82aG_E)U^e#cFILlYKX|^dQKcPHGp-%WW$+FHyvt2Qzus{cAA?4`Q*61<H zeU1(GFFx3Y6gE!)hKzqg<(X=p3j>0O;8eD!;2r`;s@XG4CO&In!fr2r%;`A7GXOKP z@yGsEXVp&|S`tAuKh^v%r+>mgW_SPo0>ufP>rq5WR;}DR$D5U8xf<-?%VvfO*-59X zPd5NI^$7qRrgGl^PAtPo>pw;A-YEWgIpH~^_ktMzt`ERMFY5AKvFwIokAq;lo3ru? z6M=1!uP`1i55_YzD!!Pxb1{w(W&nHb2Q6X-$U~B=C}#8xTm_BDMydqw`X+WY(S%z? z*(!zX9}E==Y%)yOC{%z_I>30AZ4+>P$^N#Vw1a|!Mzl82+O-z%8X?0S-7?!QX;3#K zPH{tm)_J!~t*XeZH7mmdpS`Mf@!t<yXNXC%lASzZ30S2Gg@{i;G*!ut7|a*Ma3SP{ zQ3fRg|FldaSc&Vwc+Aq^SbuytC|xTQB<3352`*o|_wwTB<n2B9Vi3+Z3^D!sNCp2U zgAIN8s)ZQTUjPsjE%I;;Z91s*qA9qS6Ua(eL>{J1&O$s8=mD@Uu@}J=v>3qLfT9Kl zXr621(!E4?AUY&N2uDNc@pVIGF;SQ}$l5n14N4IO&^V?@b<Y?n_%snfqAQeM6eER0 zb`uuD2LB*e4$$bP(bi=EuECGk6M;_a#IJxzo*L@y2QB?vfRaW9arvuK*cQl7`Lk-I zxe55vXrZRcHm@rHEAo^AxY!~KXKnMvxNW15zgMLUa}IiZ_{d(@P#9}Gc2-pj3wR=A z$jQ>$xKd-1`y~FS_67WKX%@K`_36yk{X`5@Rxh+^KOc*!5d2rbzM{?Fl7Kxys@LSg zrnj0&0#cyOZ!=<)!{BybdP^2dT_e%N<uB@D@MJ?qlE1)W30s+=CE3K0qBr<4gH|d? zQ|iKiq7=3{%&7_B5>X-|@BRbY94btN<L7uLYlsGZ_|R!>ABzN9F^mrAEq{zWMY9<& z^ma#r`x2lT)i%|I;(;<7CDetW7a5E=QZ_|7W<at{sW+Z&+SY~mmk`oRqYHLCYRQ0^ zp^Di0I3rnB`6Yn`5DW@yKBiy?f)%A85p&BtZJyKykL<rLpP1dTe6gjs5TT08&v-0U z9p`~*r3o*_FHrX6*AyKEE%yD}#*mTplsl~fQby^Rxebbd>vfhRF4F<`@>gOKxZX=X znt;4_HZ2KewZ7s=h?<Kr{V#C1ZY6~9@sB1MO!O;=>_vj=JVo{QPbH$7WtiFG>WEf1 znXsq*9HdOJpcN@_-H0O^WApHaih^{xh6F<PV=HBXjuiA5tbopO1&C}!jYw(!b$}r3 zHSwv9=#6J)97Ed8_{HgkdsR(R*yE(Yqd(xk>a-{Bph_c_u+rR(hyg%`A5Ra=dI*nb z!;GY*^wR6;&Dq+2<xO?XhMDqPBWWH)ydmH%sH`rGpDDIqUKrk7_%c%0Y#vkK6d?sm zK`%f~P^VA?Gg5_)2rSgbhy8g$S;}-huSXGZ%t#blDFhR}q$f>n{O%i{h?*g;5xfgT z0-K98bmdCHR?3%@rn2d72D5H)YKN023Gr;E=a>#3<@&I!{KlOg9SDv{dg;~oUXWd_ zB4kx@LC<7ur02HdgV9kwB@}+eOS56dNm^s`P=D)kIXwn3q3|CL4#m1sMC9w{m?XR5 zYv2m%f_I(mZ(2GI&4^1Y;WJw(Y?z{j51uhWqyWRVh<XZ{kf=jJLlbPFk@cp`qz-Bb zNWh$Ticd=_EIz%%nc$9DC;!jgUX2iH0Y`cnX|l#WLDi;iy1Y=lE5bUFKm~Ga#ciA( zAC%<<K`U!ddzpCZPzn@mhGMPtQN;_F9=}k70voM(&6<L%>Vt+P9|wLKkzY6jrG6rX z-LeCQ-psKHt%QKoye`lRkh%9yYbOmp*VAjFF+U-C!ewzULkpqOSOefEao=ErV4jw$ zCa(4VS2i@6A(i=wa<i=3s8My%W!ZbC1ZW7Eltp@LETCf=oEU`QqGRzE-?mID4>fWI zq{YxL6wQ$-S61R?MB%L#+%%4<x%2n|OuLd4KjzhU978T+z-klEgHv&#$gkO;o7kKg zmXp+;XO#i`fM!V^DFrj-JUs-$;ko>&%UY_CI5i2we;;u=5m>{`lxj?k@g{D&>@&tr zVFQFduj7KNiY2r@qFu1yE3CR9BySmp$!geG;!*IKAx}~NYr$MD(Pdofb|K3|{)d0! z4tZu@=5l=clji`mD#@GEE>u(xq__Cyw#lI@o*Sik5-^FK#drBorsB+SGLI7iTY~{i ze=*Ky1p;&q@k}4Su0n4|(L$!eVRC$IzSvD_)<jNtx*wq2Cd5l8V=!@pJ|2!pVhDxi z6;okj4E*@dp43hJG-|W`cf>Jt>=Y@iMz(=@J^jlCTe@`NQwL`fm14k=ydu?D`Rtcn z<o`B0<QJxzmVDSxF{gIKykB<2)>+8qI~v)yJzG7ufXwmN_!;=Ei#|J(^@J0}(18pZ zP=uv0YQsZT4<SvRF7hA2rj<2G!)M!ap&mKa(i(VKWiSI`2yL8OT~@rXmz$S4NoyO} z7(8(<f=O+E3gsUxPE2=NX(ZbG)2VH5rpF}^1o1sLn(@#LR@Xwl>;T<0k2pOIc<UdM z93sznb=W}3>^LEN2+;(U^=>S6oB<qWkaCPqw-Rn{P{uH4&{Di;$U=WhYe=Yxh%dxD z+t#|Sk{Mkn;s5I}Ax2pf%B*Kn@s&TCdCylL2a9-U+Jo8j3QCP4mr-#qaC&^31uufE zlI4OT2zBVSsyAd~gyn16vEV>dq^#;>n>bqHS>p!??GOyRf{G7+ZfZ+_a6ZRJlME-J z&Nbv*Da8Y<`IKubX!eX$Wq=5bd0ud0p@3BFn!zThM_*@HsKXQqrL-X8x7ahAddxIB zA5n|qacIP(uK$|gx3N}!jSJ3y|K)drnMgd1nZ#NE51B<mw*rzIincp(-vwnITl(6n zo|pZMA|d)-*kAsYfUWo`xPt0J!mOp>Hy-OKZWDn5#qp!{nm8xbT3YmJxk-gjH!_Sa zK^gkR>R90Fj>#xQtEcJ5hNyYD;7*X%$v~t4XJbZ7z6oY{V}k*kmkJ=v3{>L@O@OF1 z9?i;H4qq9{mKK^Kvr221ISQems4cUE)_6QAq)t(q-l!HFI#iJ1ERC!apN<?hS;%R7 zakCf$XU-CI@eC*?FrmX88(q7|P}OYV3z6+{EvmJudQ?KRm<$T)ew1k<MeI2F0G3`Z z<0(r=3m)aj1!1tYdEjq75{fC!)QQb;gNQU*)>JtXBaQF6@Ff^p{X;P@{@cy*z)gLB zqEm<x+Ht|?BcJJC<LhYPL4ey||7IEEtiRp-Croha8K)H^<?lawh7qqRpM3Q1b8zL2 zs>N9SK1^UvrOjBe3Tv82h_e!Xtr=SBkLi8m9%iRuwsi?r#y5Gu{ne$uY8n9Vyk&|c z;(Ug^bsGV|HPb*##9Ql&n`}_F-sLfw13>OlWF3q;my22sn{#9~!!;FHZa2Vy{gtWO zbI&!6p-f$pQolD_OC}0f;szV0LM)9`It!iNf)Yljh=5)X+)qWFyZGpr3FrPbWE(1s zTbQcO-?<MW_RoejW$!@6fKATlf?Bdfe-X@!Xcwz>hmI6U#sEK8Ox5wvXGbjj&;Ppc zGYIw37akHo$8%@Fp$Rr}V|2Ak5)Ift|NO5%{~w?KAND6vz+1$+^Z)<=07*qoM6N<$ Ef|*P#asU7T literal 0 HcmV?d00001 diff --git a/packaging/icons/app-icon-32.png b/packaging/icons/app-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..7aeb5ee36f49204b4c6a2b33aa5c6ac2a21ec7fe GIT binary patch literal 1742 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 literal 0 HcmV?d00001 diff --git a/packaging/icons/app-icon-512.png b/packaging/icons/app-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..3cf9665b299692121cbf83b8bba7beb84b65a029 GIT binary patch literal 299853 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�q8b`sW8V_QO+ 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 #yqKspF! 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 R7bO4y002ovPDHLkV1nM_v>yNf literal 0 HcmV?d00001 diff --git a/packaging/icons/app-icon-64.png b/packaging/icons/app-icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..69d537c1dc20deed9bc4925bdfc880bb6ecb9788 GIT binary patch literal 5315 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 MP+O%;$ujtV09Byg)Bpeg literal 0 HcmV?d00001 diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop new file mode 100644 index 0000000..4c75bd7 --- /dev/null +++ b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=OmaPhoto +GenericName=Image Editor +Comment=Edit and composite layered images +Exec=omaphoto %F +Icon=io.github.ZacharyZhang_NY.OmaPhoto +Terminal=false +Categories=Graphics;2DGraphics;RasterGraphics; +MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff; +StartupWMClass=OmaPhoto diff --git a/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml new file mode 100644 index 0000000..4d956c5 --- /dev/null +++ b/packaging/io.github.ZacharyZhang_NY.OmaPhoto.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info"> + <mime-type type="application/x-compositor-project"> + <comment>OmaPhoto Project</comment> + <sub-class-of type="inode/directory"/> + <glob pattern="*.comp"/> + </mime-type> +</mime-info> diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..e53652d --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +image=omaphoto-dev +runtime=/run/user/$(id -u) + +docker build -q -t "$image" -f "$repo/Dockerfile.dev" "$repo" >/dev/null + +in_container() { + exec docker run --rm --init -u "$(id -u):$(id -g)" -e HOME=/tmp --tmpfs /tmp:rw,exec,size=4g \ + --tmpfs "$runtime:uid=$(id -u),gid=$(id -g),mode=0700" -e XDG_RUNTIME_DIR="$runtime" \ + -v "$repo:$repo" -w "$repo" "$@" +} + +configure_and_build='cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build build' + +case "${1:-}" in +build) + in_container "$image" sh -c "$configure_and_build" + ;; +test) + in_container "$image" sh -c "$configure_and_build && ctest --test-dir build --output-on-failure --timeout 120" + ;; +run) + socket=$WAYLAND_DISPLAY + [[ $socket = /* ]] || socket=$XDG_RUNTIME_DIR/$socket + socket=$(readlink -f "$socket") + # The desktop's theme, where Omarchy keeps it, read-only. + theme=$HOME/.local/state/omarchy + mounts=() + [[ -d $theme ]] && mounts=(-v "$theme:/tmp/.local/state/omarchy:ro") + in_container "${mounts[@]}" -v "$socket:$runtime/wayland-0" -e WAYLAND_DISPLAY=wayland-0 \ + -e QT_QPA_PLATFORM=wayland "$image" build/omaphoto "${@:2}" + ;; +*) + echo "usage: dev.sh build|test|run [args]" >&2 + exit 2 + ;; +esac diff --git a/scripts/distro-check.sh b/scripts/distro-check.sh new file mode 100755 index 0000000..366022a --- /dev/null +++ b/scripts/distro-check.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Builds and tests the app in one distribution's own container. +set -euo pipefail +distro=${1:?"usage: distro-check.sh arch|fedora|nixos"} +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/.." && pwd) +docker build -t "omaphoto-$distro" "$here/distro/$distro" +steps="cmake -S . -B /tmp/build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build /tmp/build && ctest --test-dir /tmp/build --output-on-failure --timeout 120" +# Not root, as dev.sh: tests refuse what root may do. +user=1000:1000 +# Nix writes its store as root; the tests drop it. +if [ "$distro" = nixos ]; then + steps="HOME=/root nix build path:/src --no-link && HOME=/root nix develop path:/src --command chroot --userspec=1000:1000 --skip-chdir / env HOME=/scratch TMPDIR=/scratch sh -c '$steps'" + user=0:0 +fi +# The tree read-only; home and temporary files in RAM. +docker run --rm -u "$user" -e QT_QPA_PLATFORM=offscreen -e HOME=/scratch -e TMPDIR=/scratch --tmpfs /scratch:rw,exec,size=4g \ + -v "$repo:/src:ro" -w /src "omaphoto-$distro" sh -c "$steps" diff --git a/scripts/distro/arch/Dockerfile b/scripts/distro/arch/Dockerfile new file mode 100644 index 0000000..c861add --- /dev/null +++ b/scripts/distro/arch/Dockerfile @@ -0,0 +1,10 @@ +# Arch and Omarchy: Qt, GCC and ONNX Runtime as shipped. +FROM archlinux:latest +RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 \ + fontconfig onnxruntime-cpu ttf-dejavu curl \ + && pacman -Scc --noconfirm +# U²-Net for Remove Background, pinned by its checksum. +RUN mkdir -p /usr/local/share/omaphoto \ + && curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \ + https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \ + && echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c - diff --git a/scripts/distro/fedora/Dockerfile b/scripts/distro/fedora/Dockerfile new file mode 100644 index 0000000..51ca020 --- /dev/null +++ b/scripts/distro/fedora/Dockerfile @@ -0,0 +1,11 @@ +# Fedora's own Qt and ONNX Runtime; HEVC from RPM Fusion. +FROM fedora:42 +RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-42.noarch.rpm \ + && dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel libheif-freeworld \ + fontconfig-devel onnxruntime-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \ + && dnf clean all +# U²-Net for Remove Background, pinned by its checksum. +RUN mkdir -p /usr/local/share/omaphoto \ + && curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \ + https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \ + && echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c - diff --git a/scripts/distro/nixos/Dockerfile b/scripts/distro/nixos/Dockerfile new file mode 100644 index 0000000..c00b021 --- /dev/null +++ b/scripts/distro/nixos/Dockerfile @@ -0,0 +1,3 @@ +# NixOS's package set, through the repository's own flake. +FROM nixos/nix:latest +RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf diff --git a/scripts/heic-fixtures.sh b/scripts/heic-fixtures.sh new file mode 100755 index 0000000..2a7dc7f --- /dev/null +++ b/scripts/heic-fixtures.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Writes the HEIC fixtures; the dev image has no encoder. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +generator=$repo/scripts/heic-fixtures +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +docker build -q -t omaphoto-heic-fixtures "$generator" >/dev/null +docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen \ + -v "$generator:/generator:ro" -v "$work:/work" -v "$repo/tests/fixtures:/fixtures" -w /work \ + omaphoto-heic-fixtures /generator/generate.sh diff --git a/scripts/heic-fixtures/Dockerfile b/scripts/heic-fixtures/Dockerfile new file mode 100644 index 0000000..914f0fd --- /dev/null +++ b/scripts/heic-fixtures/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ qt6-base-dev libheif-examples libheif-plugin-x265 libheif-plugin-libde265 \ + && rm -rf /var/lib/apt/lists/* diff --git a/scripts/heic-fixtures/generate.sh b/scripts/heic-fixtures/generate.sh new file mode 100755 index 0000000..0910453 --- /dev/null +++ b/scripts/heic-fixtures/generate.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Runs inside the container: /generator read-only, /fixtures to fill. +set -eu + +qt=/usr/include/x86_64-linux-gnu/qt6 +g++ -std=c++20 -fPIC -I$qt -I$qt/QtCore -I$qt/QtGui /generator/sources.cpp -o sources -lQt6Gui -lQt6Core +./sources +encode="heif-enc -q 100 -p chroma=444" +$encode -o /fixtures/red.heic red.png +$encode --premultiplied-alpha -o /fixtures/premultiplied.heic premultiplied.png +$encode -o /fixtures/turned-p3.heic turned-p3.jpg +# Lossless, no ICC profile: the nclx box names Display P3. +heif-enc -L -p chroma=444 --matrix_coefficients=0 --colour_primaries 12 --transfer_characteristic 13 -o /fixtures/p3-nclx.heic warm.png +for name in red premultiplied turned-p3 p3-nclx; do + heif-info /fixtures/$name.heic | grep -E "^image|color profile|angle" +done diff --git a/scripts/heic-fixtures/sources.cpp b/scripts/heic-fixtures/sources.cpp new file mode 100644 index 0000000..c075d0f --- /dev/null +++ b/scripts/heic-fixtures/sources.cpp @@ -0,0 +1,46 @@ +#include <QBuffer> +#include <QColorSpace> +#include <QFile> +#include <QGuiApplication> +#include <QImage> +#include <QImageWriter> + +int main(int argc, char **argv) +{ + QGuiApplication app(argc, argv); + // Red on the left; right: clear over half-clear blue. + QImage red(64, 32, QImage::Format_RGBA8888); + red.fill(Qt::transparent); + // (200, 100, 50) left; right: white over dark gray. + QImage warm(64, 32, QImage::Format_RGB888); + warm.fill(Qt::white); + for (int y = 16; y < 32; ++y) { + for (int x = 32; x < 64; ++x) + warm.setPixel(x, y, qRgb(16, 16, 16)); + } + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 32; ++x) { + red.setPixel(x, y, qRgba(255, 0, 0, 255)); + warm.setPixel(x, y, qRgb(200, 100, 50)); + if (y >= 16) + red.setPixel(x + 32, y, qRgba(0, 0, 255, 128)); + } + } + // The same picture with its colours already multiplied. + const QImage multiplied = red.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const QImage stored(multiplied.constBits(), 64, 32, multiplied.bytesPerLine(), QImage::Format_RGBA8888); + bool written = red.save("red.png") && stored.save("premultiplied.png") && warm.save("warm.png"); + // Display P3 by ICC profile, turned by EXIF orientation 6. + warm.setColorSpace(QColorSpace::DisplayP3); + QByteArray jpeg; + QBuffer buffer(&jpeg); + QImageWriter writer(&buffer, "jpeg"); + writer.setQuality(100); + written = written && writer.write(warm); + QByteArray exif("\xFF\xE1\x00\x22" "Exif\0\0" "II\x2A\x00\x08\x00\x00\x00" "\x01\x00" "\x12\x01\x03\x00\x01\x00\x00\x00", 28); + exif += QByteArray("\x06\x00\x00\x00" "\x00\x00\x00\x00", 8); + jpeg.insert(2, exif); + QFile file("turned-p3.jpg"); + written = written && file.open(QIODevice::WriteOnly) && file.write(jpeg) == jpeg.size(); + return written ? 0 : 1; +} diff --git a/scripts/install/arch.sh b/scripts/install/arch.sh new file mode 100755 index 0000000..720e209 --- /dev/null +++ b/scripts/install/arch.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Installs OmaPhoto's latest package on Arch and Omarchy. +set -euo pipefail +base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download +package=omaphoto-x86_64.pkg.tar.zst +. /etc/os-release +case " ${ID:-} ${ID_LIKE:-} " in +*" arch "*) ;; +*) + echo "This script is for Arch and Omarchy; this system is ${PRETTY_NAME:-unknown}." >&2 + exit 1 + ;; +esac +if [ "$(uname -m)" != x86_64 ]; then + echo "OmaPhoto's packages are built for x86_64 alone." >&2 + exit 1 +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl -fL --proto '=https' -o "$work/$package" "$base/$package" +curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS" +# The package must match the checksum published beside it. +(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -) +# pacman asks which ONNX Runtime build provides onnxruntime. +sudo pacman -U "$work/$package" diff --git a/scripts/install/fedora.sh b/scripts/install/fedora.sh new file mode 100755 index 0000000..8f55cc5 --- /dev/null +++ b/scripts/install/fedora.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Installs OmaPhoto's latest RPM on Fedora. +set -euo pipefail +base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download +package=omaphoto.x86_64.rpm +. /etc/os-release +case " ${ID:-} ${ID_LIKE:-} " in +*" fedora "*) ;; +*) + echo "This script is for Fedora; this system is ${PRETTY_NAME:-unknown}." >&2 + exit 1 + ;; +esac +if [ "$(uname -m)" != x86_64 ]; then + echo "OmaPhoto's packages are built for x86_64 alone." >&2 + exit 1 +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl -fL --proto '=https' -o "$work/$package" "$base/$package" +curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS" +# The package must match the checksum published beside it. +(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -) +sudo dnf install -y "$work/$package" +# Fedora's own libheif decodes no HEVC; RPM Fusion's does. +if ! rpm -q libheif-freeworld >/dev/null 2>&1; then + echo "HEIC import needs RPM Fusion's libheif-freeworld: https://rpmfusion.org/Configuration, then sudo dnf install libheif-freeworld" +fi diff --git a/scripts/install/nixos.sh b/scripts/install/nixos.sh new file mode 100755 index 0000000..e75e9b7 --- /dev/null +++ b/scripts/install/nixos.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Installs OmaPhoto on NixOS from its flake. +set -euo pipefail +if ! command -v nix >/dev/null; then + echo "This script needs Nix; NixOS carries it." >&2 + exit 1 +fi +if [ "$(uname -m)" != x86_64 ]; then + echo "OmaPhoto's flake builds for x86_64 alone." >&2 + exit 1 +fi +nix --extra-experimental-features "nix-command flakes" profile install github:ZacharyZhang-NY/OmaPhoto diff --git a/scripts/install/ubuntu.sh b/scripts/install/ubuntu.sh new file mode 100755 index 0000000..f847117 --- /dev/null +++ b/scripts/install/ubuntu.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Installs OmaPhoto's latest DEB on Ubuntu and its derivatives. +set -euo pipefail +base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download +package=omaphoto_amd64.deb +. /etc/os-release +case " ${ID:-} ${ID_LIKE:-} " in +*" ubuntu "*) ;; +*) + echo "This script is for Ubuntu and its derivatives; this system is ${PRETTY_NAME:-unknown}." >&2 + exit 1 + ;; +esac +if [ "$(uname -m)" != x86_64 ]; then + echo "OmaPhoto's packages are built for x86_64 alone." >&2 + exit 1 +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl -fL --proto '=https' -o "$work/$package" "$base/$package" +curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS" +# The package must match the checksum published beside it. +(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -) +# apt reads the file as its own user. +chmod a+rx "$work" +sudo apt-get install -y "$work/$package" diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100755 index 0000000..7fa2665 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""House rules for OmaPhoto: files under 500 lines, comments of one line and at most ten words. + +Reads C++ sources and tests, everything under scripts/, CMakeLists.txt and Dockerfile.dev, each with a +scanner for its own language. Run from the repository root; exits 1 and lists each violation. +`--self-test` checks the checker. +""" +import glob, io, os, re, sys, tokenize + + +def comments(text): + """Yields (first line, last line, text) for each comment outside literals and numbers.""" + index, line, size = 0, 1, len(text) + while index < size: + char, two = text[index], text[index:index + 2] + if two == '//': + end = text.find('\n', index) + end = size if end < 0 else end + yield line, line, text[index + 2:end] + index = end + elif two == '/*': + end = text.find('*/', index + 2) + end = size if end < 0 else end + body = text[index + 2:end] + yield line, line + body.count('\n'), body + line += body.count('\n') + index = end + 2 + elif char.isdigit() or (char == '.' and text[index + 1:index + 2].isdigit()): + # A number keeps its digit separators: 1'000, 0xFF'FF. + index += 1 + while index < size and (text[index].isalnum() or text[index] in '._' + or (text[index] == "'" and text[index + 1:index + 2].isalnum())): + index += 1 + elif char.isalpha() or char == '_': + start = index + while index < size and (text[index].isalnum() or text[index] == '_'): + index += 1 + if text[start:index].endswith('R') and text[index:index + 1] == '"': + opened = text.index('(', index) + closing = ')' + text[index + 1:opened] + '"' + end = text.index(closing, opened) + len(closing) + line += text.count('\n', index, end) + index = end + elif char in '"\'': + start, index = index, index + 1 + while index < size and text[index] != char: + index += 2 if text[index] == '\\' else 1 + index += 1 + line += text.count('\n', start, index) + else: + line += char == '\n' + index += 1 + + +def python_comments(text): + """Yields (line, line, text) for each comment of a Python file; its tokenizer knows strings.""" + for token in tokenize.generate_tokens(io.StringIO(text).readline): + if token.type == tokenize.COMMENT and not token.string.startswith('#!'): + yield token.start[0], token.start[0], token.string[1:] + + +def docker_comments(text): + """Yields (line, line, text): Docker drops every line that begins with `#`, wherever it stands.""" + for number, row in enumerate(text.splitlines(), 1): + if row.strip().startswith('#'): + yield number, number, row.strip()[1:] + + +class Unreadable(Exception): + """Shell the scanner refuses to read; the file fails at that line.""" + + +def shell_comments(text): + """Returns (line, line, text) for each comment of a shell script. + + Reads the shell our scripts keep to: quotes, escapes, `$( )` and `${ }` to any depth, here-strings, + and `#` where a word begins. What it cannot read exactly it refuses by raising Unreadable, so nothing + passes unread: `<<` (a heredoc or a shift), backticks, `$'…'`, `((`, `case` inside `$( )`, and a + continued line anywhere but between words (it could split `$(`, `<<` or a keyword). + """ + index, line, size, found = 0, 1, len(text), [] + + def refuse(what): + raise Unreadable('%d: lint does not read %s' % (line, what)) + + def single(): + nonlocal index, line + end = text.find("'", index + 1) + if end < 0: + refuse('a quote left open') + line += text.count('\n', index, end) + index = end + 1 + + def expansion(): + """One step inside `"…"` or `${…}`: an escape, a substitution, or a character.""" + nonlocal index, line + if text[index] == '$' and text.startswith('\\\n', index + 1): + refuse('a continued line that splits `$` from what it opens') + elif text[index] == '\\': + line += text[index + 1:index + 2] == '\n' + index += 2 + elif text[index] == '`': + refuse('backticks') + elif text.startswith('$((', index): + refuse('arithmetic') + elif text.startswith('$(', index): + index += 2 + code(')') + elif text.startswith('${', index): + index += 2 + enclosed('}', 'a brace left open') + else: + line += text[index] == '\n' + index += 1 + + def enclosed(closer, otherwise): + """To the `closer` of `"…"` or `${…}`; strings nest inside braces.""" + nonlocal index + if closer == '"': + index += 1 + while index < size and text[index] != closer: + if closer == '}' and text[index] == "'": + single() + elif closer == '}' and text[index] == '"': + enclosed('"', 'a quote left open') + else: + expansion() + if index >= size: + refuse(otherwise) + index += 1 + + def code(closer): + """Shell code to `closer`, or to the end of the text.""" + nonlocal index, line + word, depth = False, 0 + while index < size: + char = text[index] + if char == '\\': + # A continued line is nothing; other escapes are words. + continued = text[index + 1:index + 2] == '\n' + if continued and index > 0 and text[index - 1] not in ' \t\n': + refuse('a continued line that splits a word or an operator') + line, word, index = line + continued, word or not continued, index + 2 + elif char in '\'"': + single() if char == "'" else enclosed('"', 'a quote left open') + word = True + elif char == '`' or text.startswith("$'", index): + refuse('backticks' if char == '`' else "$'…' quoting") + elif text.startswith('${', index) or text.startswith('$(', index): + expansion() + word = True + elif text.startswith('<<<', index): + index, word = index + 3, False + elif text.startswith('<<', index) or text.startswith('((', index): + refuse('`<<`: a heredoc or a shift' if char == '<' else '`((`') + elif char == ')' and closer and depth == 0: + index += 1 + return + elif char in ' \t\n;&|<>()': + depth += (char == '(') - (char == ')') + line, word, index = line + (char == '\n'), False, index + 1 + elif char == '#' and not word: + end = text.find('\n', index) + end = size if end < 0 else end + found.append((line, line, text[index + 1:end])) + index = end + else: + if closer and not word and re.compile(r'case\b').match(text, index): + refuse('`case` inside `$( )`') + index, word = index + 1, True + if closer: + refuse('`$(` left open') + + if text.startswith('#!'): + index = text.find('\n') if '\n' in text else size + code(None) + return found + + +def cmake_comments(text): + """Yields (first line, last line, text) for each comment of a CMake file. + + `"` strings and `\\` escapes, bracket arguments `[=[ ]=]` where an argument begins (inside an unquoted + argument, escapes included, `[[` is text), bracket comments `#[=[ ]=]`, and `#` anywhere else. + """ + index, line, size, argument = 0, 1, len(text), False + while index < size: + char = text[index] + if char == '\\': + line += text[index + 1:index + 2] == '\n' + index, argument = index + 2, True + elif char == '"': + # A quote inside an unquoted argument is part of it. + start, index = index, index + 1 + while index < size and text[index] != '"': + index += 2 if text[index] == '\\' else 1 + index += 1 + line += text.count('\n', start, index) + elif (bracket := re.compile(r'#?\[(=*)\[').match(text, index)) and (char == '#' or not argument): + closer = ']' + bracket.group(1) + ']' + close = text.find(closer, bracket.end()) + close = size if close < 0 else close + lines = text.count('\n', index, close) + if bracket.group(0).startswith('#'): + yield line, line + lines, text[bracket.end():close] + line, index, argument = line + lines, close + len(closer), False + elif char == '#': + end = text.find('\n', index) + end = size if end < 0 else end + yield line, line, text[index + 1:end] + index = end + else: + # An unquoted argument runs to a blank or a parenthesis. + line, index, argument = line + (char == '\n'), index + 1, char not in ' \t\n()' + + +def violations(path, text): + found = [] + if len(text.splitlines()) >= 500: + found.append('%s: %d lines' % (path, len(text.splitlines()))) + rows = text.splitlines() + previous = None + name = path.rsplit('/', 1)[-1] + scanners = [(('.cpp', '.h'), comments), (('.py',), python_comments), (('.sh',), shell_comments), + (('CMakeLists.txt', '.cmake'), cmake_comments)] + scanner = docker_comments if name.startswith('Dockerfile') else next((s for ends, s in scanners if name.endswith(ends)), None) + if scanner is None: + return found + ['%s: lint has no scanner for this kind of file' % path] + try: + found_comments = list(scanner(text)) + except Unreadable as refusal: + return found + ['%s:%s' % (path, refusal)] + for first, last, body in found_comments: + if last > first: + found.append('%s:%d: comment runs over one line' % (path, first)) + alone = rows[first - 1].strip().startswith(('//', '/*', '#')) + if alone and previous == first - 1: + found.append('%s:%d: comment runs over one line' % (path, first)) + previous = last if alone else None + if len(body.split()) > 10: + found.append('%s:%d: comment of %d words' % (path, first, len(body.split()))) + return found + + +def self_test(): + eleven = 'one two three four five six seven eight nine ten eleven' + cases = [ + ('int a; // fine\n', 0), + ('// ' + eleven + '\n', 1), + ('/* ' + eleven + ' */\n', 1), + ('/* two\n lines */\n', 1), + ('// one\n// two\n', 1), + ('int a; // one\nint b; // two\n', 0), + ('const char *url = "http://example.com/a b c d e f g h i j k l";\n', 0), + ("char slash = '/'; char quote = '\"'; // fine\n", 0), + ('auto raw = R"x(a quote " then // ' + eleven + ')x"; // fine\n', 0), + ('const char *escaped = "a \\" // b c d e f g h i j k l m";\n', 0), + ("int value = 1'000; // " + eleven + '\n', 1), + ("int mask = 0xFF'FF; int next = 2'0; // " + eleven + '\n', 1), + ("double big = 1e+5; char c = 'x'; // fine\n", 0), + ("auto wide = u8R\"(a quote \" then // " + eleven + ")\"; // fine\n", 0), + ('const char *value = "first\\\nsecond";\n// first\n// second\n', 1), + ('auto raw = R"(one\ntwo)";\n// first\n// second\n', 1), + ('int a;\n' * 499, 0), + ('int a;\n' * 500, 1), + ('int a;\n' * 498 + 'int a;', 0), + ('int a;\n' * 499 + 'int a;', 1), + ] + for text, expected in cases: + got = violations('fixture.cpp', text) + assert len(got) == expected, (text[:60], expected, got) + ten = eleven[:-7] + scripts = [ + ('fixture.sh', '#!/usr/bin/env bash ' + eleven + '\necho hello\n', 0, ''), + ('fixture.sh', '# ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', '# ' + ten + '\n', 0, ''), + ('fixture.sh', '#' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', '#\t' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo hello # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo hello\t# ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo hello;# ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', '(# ' + eleven + '\necho)\n', 1, 'comment of 11 words'), + ('fixture.sh', 'sleep 1 &# ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'true ||# ' + eleven + '\ntrue\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo >#' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'name() { echo; } # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'case x in\n a) echo;; # ' + eleven + '\nesac\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo "# ' + eleven + '"\n', 0, ''), + ('fixture.sh', 'echo "first\n# ' + eleven + '\nlast"\n', 0, ''), + ('fixture.sh', "sh -c '\n# " + eleven + "\n'\n", 0, ''), + ('fixture.sh', 'echo "a \\" # ' + eleven + '"\n', 0, ''), + ('fixture.sh', "echo 'a \\' # " + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo \\" # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo a#b ' + eleven + '\n', 0, ''), + ('fixture.sh', 'echo a\\ #' + eleven + '\n', 0, ''), + ('fixture.sh', 'echo a\\\n#b ' + eleven + '\n', 1, 'lint does not read a continued line that splits a word'), + ('fixture.sh', 'echo a \\\n# ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo a \\\n b\n# one\n# two\n', 1, ':4: comment runs over one line'), + ('fixture.sh', 'echo $# ${#name} ${name#a b} ${name:-# c} ' + eleven + '\n', 0, ''), + ('fixture.sh', 'echo \\# ' + eleven + '\n', 0, ''), + ('fixture.sh', 'echo $# # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value="$(echo hi # ' + eleven + '\n)"\n', 1, ':1: comment of 11 words'), + ('fixture.sh', 'value="$(echo "in $(echo deep # ' + eleven + '\n)")"\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value="${name:-$(echo # ' + eleven + '\n)}"\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value=$( (cd /tmp; pwd) ) # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value="$(echo \\) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value="$(echo \\))" # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'cat <<< "text" # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'cat <<< text\n# ' + eleven + '\n', 1, ':2: comment of 11 words'), + ('fixture.sh', 'echo\ncat <<EOF\nbody\nEOF\n', 1, ':2: lint does not read `<<`'), + ('fixture.sh', 'cat <<-END-TEXT\nbody\nEND-TEXT\n', 1, 'lint does not read `<<`'), + ('fixture.sh', 'value=$((1 << shift))\n', 1, 'lint does not read arithmetic'), + ('fixture.sh', 'value="$((1 + 2))"\n', 1, 'lint does not read arithmetic'), + ('fixture.sh', '((count += 1))\n', 1, 'lint does not read `((`'), + ('fixture.sh', 'echo `date`\n', 1, 'lint does not read backticks'), + ('fixture.sh', 'echo "`date`"\n', 1, 'lint does not read backticks'), + ('fixture.sh', "echo $'it\\'s'\n", 1, "lint does not read $'"), + ('fixture.sh', 'value=$(case x in a) echo;; esac)\n', 1, 'lint does not read `case` inside'), + ('fixture.sh', 'value=$(echo showcase)\n', 0, ''), + ('fixture.sh', 'echo "open\n', 1, 'a quote left open'), + ('fixture.sh', "echo 'open\n", 1, 'a quote left open'), + ('fixture.sh', 'echo ${open\n', 1, 'a brace left open'), + ('fixture.sh', 'value=$(echo open\n', 1, '`$(` left open'), + ('fixture.sh', "echo 'a\nb'\n# one\n# two\n", 1, ':4: comment runs over one line'), + ('fixture.sh', 'echo "a\nb"\n# one\n# two\n', 1, ':4: comment runs over one line'), + ('fixture.sh', "echo ${name:-'}'} # " + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo ${name:-"}"} # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo "it\'s" # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'echo \\ #' + eleven + '\n', 0, ''), + ('fixture.sh', "echo 'a'#b " + eleven + '\n', 0, ''), + ('fixture.sh', 'echo "a"#b ' + eleven + '\n', 0, ''), + ('fixture.sh', 'echo $(date)#b ${name}#c ' + eleven + '\n', 0, ''), + ('fixture.sh', 'cat <<<#' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value="$( (echo a) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'), + ('fixture.sh', 'value=$(caseless)\n', 0, ''), + ('fixture.sh', 'value="$\\\n(printf x # ' + eleven + '\n)"\n', 1, ':1: lint does not read a continued line that splits `$`'), + ('fixture.sh', 'value=$\\\n(printf x # ' + eleven + '\n)\n', 1, 'lint does not read a continued line'), + ('fixture.sh', 'value=$(\\\n(1 + 2))\n', 1, 'lint does not read a continued line'), + ('fixture.sh', 'cat <\\\n<EOF\n# ' + eleven + '\nEOF\n', 1, 'lint does not read a continued line'), + ('fixture.sh', 'value=$(ca\\\nse x in a) echo;; esac)\n', 1, 'lint does not read a continued line'), + ('fixture.sh', 'echo one \\\n\ttwo \\\n\\\n three # ' + eleven + '\n', 1, ':4: comment of 11 words'), + ('fixture.sh', 'echo "one\\\ntwo" # ' + eleven + '\n', 1, ':2: comment of 11 words'), + ('fixture.sh', 'echo one\t\\\n two # ' + eleven + '\n', 1, ':2: comment of 11 words'), + ('fixture.sh', '# one\n# two\n', 1, 'comment runs over one line'), + ('fixture.sh', '# one\n#\n', 1, 'comment runs over one line'), + ('fixture.sh', '# one\necho\n# two\n', 0, ''), + ('fixture.sh', 'echo one # first\necho two # second\n', 0, ''), + ('CMakeLists.txt', ' # ' + eleven + '\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME value)#' + eleven + '\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME "# ' + eleven + '")\n', 0, ''), + ('CMakeLists.txt', 'set(NAME "a \\" # ' + eleven + '")\n', 0, ''), + ('CMakeLists.txt', 'set(NAME a\\# ' + eleven + ')\n', 0, ''), + ('CMakeLists.txt', "message(it's) # " + eleven + '\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME [[\n# ' + eleven + '\n]])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME [=[ ]] # ' + eleven + ' ]=])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME a[[b # ' + eleven + ' ]])\n', 1, 'comment of 12 words'), + ('CMakeLists.txt', 'set(NAME\t[[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', '[[ # ' + eleven + ' ]]', 0, ''), + ('CMakeLists.txt', 'set(NAME a\\ [[b # ' + eleven + '\n]])\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME "a"[[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME a"b"[[c # ' + eleven + '\n]])\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME a"b # c"[[d # ' + eleven + '\n]])\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME a)[[b # ' + eleven + ' ]]\n', 0, ''), + ('CMakeLists.txt', 'set([[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME\n[[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME [[a]][[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', '# first\nset(NAME\n# ' + ten + '\n[[b # ' + eleven + ' ]])\n', 0, ''), + ('CMakeLists.txt', 'set(NAME a [==[b # ' + eleven + ' ]==] c[[d # ' + eleven + '\n)\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', 'set(NAME a#[[ ' + eleven + ' ]]\n)\n', 1, 'comment of 11 words'), + ('CMakeLists.txt', '#[[ ' + ten + ' ]]\nset(NAME value)\n', 0, ''), + ('CMakeLists.txt', '#[==[ ' + ten + ' ]==]\n', 0, ''), + ('CMakeLists.txt', '#[[ two\nlines ]]\n', 1, 'comment runs over one line'), + ('CMakeLists.txt', '#[=[ ' + eleven + ' ]=]\n', 1, 'comment of 11 words'), + ('helpers.cmake', '# ' + eleven + '\n', 1, 'comment of 11 words'), + ('Dockerfile.dev', '# fine\nFROM ubuntu:24.04\n', 0, ''), + ('Dockerfile.dev', ' # ' + eleven + '\n', 1, 'comment of 11 words'), + ('Dockerfile.dev', 'RUN echo "a # ' + eleven + '"\n', 0, ''), + ('Dockerfile.dev', 'RUN echo "\\\n# ' + eleven + '\\\n"\n', 1, 'comment of 11 words'), + ('Dockerfile', '# ' + ten + '\n', 0, ''), + ('fixture.py', 'value = "# ' + eleven + '" # fine\n', 0, ''), + ('fixture.py', 'value = 1 # ' + eleven + '\n', 1, 'comment of 11 words'), + ('fixture.py', 'value = 1 # ' + ten + '\n', 0, ''), + ('fixture.py', '#!/usr/bin/env python3 ' + eleven + '\nvalue = 1\n', 0, ''), + ('fixture.py', '"""' + eleven + '\n' + eleven + '"""\n', 0, ''), + ('fixture.py', '# one\n# two\n', 1, 'comment runs over one line'), + ('fixture.py', "value = '''it's # " + eleven + "'''\n", 0, ''), + ('notes.txt', 'plain text\n', 1, 'no scanner for this kind of file'), + ('fixture.sh', 'echo\n' * 500, 1, '500 lines'), + ] + for path, text, expected, message in scripts: + got = violations(path, text) + assert len(got) == expected and all(message in line for line in got), (path, text[:60], expected, message, got) + print('lint self-test: %d cases pass' % (len(cases) + len(scripts))) + + +if __name__ == '__main__': + if sys.argv[1:] == ['--self-test']: + self_test() + sys.exit(0) + paths = sorted(p for p in glob.glob('src/**/*', recursive=True) + glob.glob('tests/*') if p.endswith(('.cpp', '.h'))) + paths += sorted(p for p in glob.glob('scripts/**/*', recursive=True) if os.path.isfile(p)) + paths += ['CMakeLists.txt', 'Dockerfile.dev'] + failures = [failure for path in paths for failure in violations(path, open(path).read())] + print('\n'.join(failures) if failures else 'lint: %d files clean' % len(paths)) + sys.exit(1 if failures else 0) diff --git a/scripts/mutate.py b/scripts/mutate.py new file mode 100755 index 0000000..375ff18 --- /dev/null +++ b/scripts/mutate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Mutation runner for OmaPhoto. Run from the repository root. + + mutate.py FILE [--targets T1,T2] <<< 'old ==> new' blocks separated by lines of @@ + mutate.py FILE --delete [--from N --to M] [--targets T1,T2] delete each statement line in turn + +Verdicts: 'caught' means the mutant built and a test that really ran failed; 'UNBUILDABLE' means the +compiler rejected it, which proves nothing; 'SURVIVED' means every test passed. Anything else (Docker +not starting, a missing test executable, a failing baseline) stops the run with an error. +""" +import argparse, os, re, subprocess, sys + +parser = argparse.ArgumentParser() +parser.add_argument('path') +parser.add_argument('--targets', default='') +parser.add_argument('--delete', action='store_true') +parser.add_argument('--from', dest='first', type=int, default=1) +parser.add_argument('--to', dest='last', type=int, default=10**9) +args = parser.parse_args() + +repo = os.getcwd() +if not os.path.isfile(os.path.join(repo, 'CMakeLists.txt')): + sys.exit('run mutate.py from the repository root') +original = open(args.path).read() +targets = [t for t in args.targets.split(',') if t] + + +def docker(command): + uid, gid = os.getuid(), os.getgid() + runtime = '/run/user/%d' % uid + done = subprocess.run( + ['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen', + '--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime, + '-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command], + capture_output=True, text=True) + return done.returncode, done.stdout + done.stderr + + +def build(): + wanted = ' --target ' + ' '.join(targets) if targets else '' + return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted) + + +def test(): + if targets: + missing = [t for t in targets if not os.path.isfile(os.path.join(repo, 'build', t))] + if missing: + sys.exit('test executables are missing after a successful build: %s' % missing) + return docker(' && '.join('./build/%s' % t for t in targets)) + return docker('ctest --test-dir build --output-on-failure --timeout 120') + + +def judge(): + code, out = build() + if code != 0: + if 'error:' in out or 'FAILED:' in out: + return 'UNBUILDABLE', next((l for l in out.split('\n') if 'error:' in l), '') + sys.exit('the build did not run:\n' + out[-2000:]) + code, out = test() + if code == 0: + return 'SURVIVED', '' + if 'Start testing of' not in out and 'Timeout' not in out: + sys.exit('the tests did not run:\n' + out[-2000:]) + return 'caught', next((l for l in out.split('\n') if l.startswith('FAIL!') or 'Timeout' in l or 'Segmentation' in l), 'test process died') + + +mutants = [] +if args.delete: + skipped = ('const ', 'static ', '#', '//', 'using ', 'namespace ', 'struct ', 'class ', '}', 'return', 'break', 'continue') + lines = original.split('\n') + for number, line in enumerate(lines, 1): + stripped = line.strip() + if not (args.first <= number <= args.last) or not stripped.endswith(';') or stripped.startswith(skipped): + continue + mutated = lines[:] + mutated[number - 1] = line[:len(line) - len(line.lstrip())] + '(void)0;' + mutants.append(('L%d %s' % (number, stripped), '\n'.join(mutated))) +else: + for block in sys.stdin.read().split('\n@@\n'): + block = block.strip('\n') + if not block: + continue + old, new = block.split(' ==> ') + if original.count(old) != 1: + sys.exit('the text to mutate must occur exactly once: %r occurs %d times' % (old, original.count(old))) + mutants.append((old.replace('\n', ' '), original.replace(old, new))) + +verdict, reason = judge() +if verdict != 'SURVIVED': + sys.exit('the unmutated baseline is not green (%s): %s' % (verdict, reason)) + +counts = {'caught': 0, 'SURVIVED': 0, 'UNBUILDABLE': 0} +try: + for label, text in mutants: + open(args.path, 'w').write(text) + verdict, reason = judge() + counts[verdict] += 1 + print('%-11s | %-70s | %s' % (verdict, label[:70], reason[:100])) + sys.stdout.flush() +finally: + open(args.path, 'w').write(original) +print('mutants: %d caught: %d survivors: %d unbuildable: %d' % (len(mutants), counts['caught'], counts['SURVIVED'], counts['UNBUILDABLE'])) diff --git a/src/CompositorApp.cpp b/src/CompositorApp.cpp new file mode 100644 index 0000000..33322a6 --- /dev/null +++ b/src/CompositorApp.cpp @@ -0,0 +1,33 @@ +#include "Document/ProjectWorkspace.h" +#include "Logging.h" +#include "UI/OmarchyTheme.h" +#include "UI/ProjectWorkspaceView.h" +#include "UI/SliderSnap.h" +#include <QApplication> +#include <QUrl> + +int main(int argc, char **argv) +{ + qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{type} %{category}: %{message}")); + QApplication application(argc, argv); + QApplication::setApplicationName(QStringLiteral("OmaPhoto")); + QApplication::setApplicationVersion(QStringLiteral(OMAPHOTO_VERSION)); + // The desktop entry's name: icons and windows find each other. + QGuiApplication::setDesktopFileName(QStringLiteral("io.github.ZacharyZhang_NY.OmaPhoto")); + qCInfo(lcApp).noquote() << "OmaPhoto" << OMAPHOTO_VERSION << "on Qt" << qVersion() + << "platform" << QGuiApplication::platformName(); + // The desktop's colours, retinted when the theme switches. + OmarchyTheme theme; + // Slider knobs snap to a click on the track. + SliderSnap::install(); + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + // Files named at launch, as Finder's Open With gives them. + QList<QUrl> files; + for (const QString &argument : QApplication::arguments().mid(1)) + files << QUrl::fromLocalFile(argument); + if (!files.isEmpty()) + workspace.receive(files); + return QApplication::exec(); +} diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp new file mode 100644 index 0000000..e93cdfa --- /dev/null +++ b/src/ContentView+Panels.cpp @@ -0,0 +1,114 @@ +#include "ContentView.h" +#include "UI/EffectsSheet.h" +#include "UI/FilterSheet.h" +#include "UI/HueSaturationSheet.h" +#include "UI/LevelsSheet.h" +#include <QFileDialog> +#include <QMessageBox> + +void ContentView::synchronizePanels() +{ + // A panel opens with its edit; close and Escape cancel. + if (m_levelsShown != m_session.levels().has_value()) { + m_levelsShown = m_session.levels().has_value(); + if (m_levelsShown) { + m_levelsPanel.onClose = [this] { m_session.cancelLevels(); }; + m_levelsPanel.show(QStringLiteral("Levels"), new LevelsSheet(m_session)); + } else { + m_levelsPanel.close(); + } + } + if (m_hueSaturationShown != m_session.hueSaturation().has_value()) { + m_hueSaturationShown = m_session.hueSaturation().has_value(); + if (m_hueSaturationShown) { + m_adjustmentPanel.onClose = [this] { m_session.cancelHueSaturation(); }; + m_adjustmentPanel.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(m_session)); + } else { + m_adjustmentPanel.close(); + } + } + if (m_filterShown != m_session.filterEdit().has_value()) { + m_filterShown = m_session.filterEdit().has_value(); + if (m_filterShown) { + m_filterPanel.onClose = [this] { m_session.cancelFilter(); }; + m_filterPanel.show(rawValue(m_session.filterEdit()->kind), new FilterSheet(m_session)); + } else { + m_filterPanel.close(); + } + } + // Swift's onChange of the layers: a vanished effect's panel goes. + if (const std::optional<LayerEffectSelection> editing = m_session.effectsEditing()) { + const std::vector<ImageLayer> &layers = m_session.document() ? m_session.document()->layers : std::vector<ImageLayer>(); + const int index = indexOf(layers, editing->layerID); + if (index < 0 || !layers[size_t(index)].effects || !layers[size_t(index)].effects->contains(editing->kind)) { + if (m_session.colorPicker() && m_session.colorPicker()->target.kind == ColorPickerTarget::Kind::effect) + m_session.closeColorPicker(false); + m_session.setEffectsEditing(std::nullopt); + m_session.setEffectsEditingOriginal(std::nullopt); + } + } + // Swift's onChange of effectsEditing: a panel for each choice. + if (m_effectsShown != m_session.effectsEditing()) { + m_effectsShown = m_session.effectsEditing(); + if (m_effectsShown) { + m_effectsPanel.onClose = [this] { m_session.finishEffectsEditing(false); }; + m_effectsPanel.show(rawValue(m_effectsShown->kind), new EffectsSheet(m_session, m_effectsShown->kind)); + } else { + m_effectsPanel.close(); + } + } +} + +void ContentView::showImporter() +{ + if (!m_session.showsImporter()) { + // Dismissed from elsewhere, as a cleared binding dismisses Swift's. + if (QFileDialog *panel = std::exchange(m_importer, nullptr)) + panel->reject(); + return; + } + if (m_importer) + return; + m_importer = new QFileDialog(this); + m_importer->setAttribute(Qt::WA_DeleteOnClose); + m_importer->setFileMode(QFileDialog::ExistingFiles); + m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff)")); + connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) { + // Ours no longer: the flag must not reject it again. + if (m_importer != panel) + return; + m_importer = nullptr; + m_session.setShowsImporter(false); + if (result == QDialog::Accepted) + m_session.importImages(panel->selectedUrls()); + }); + m_importer->open(); +} + +void ContentView::showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message, + const std::function<void()> &dismiss) +{ + if (!message) { + if (QMessageBox *shown = std::exchange(alert, nullptr)) + shown->reject(); + return; + } + // SwiftUI's alert shows its message as it stands. + if (alert) { + alert->setInformativeText(*message); + return; + } + alert = new QMessageBox(this); + alert->setAttribute(Qt::WA_DeleteOnClose); + alert->setIcon(QMessageBox::Warning); + alert->setText(title); + alert->setInformativeText(*message); + alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole); + connect(alert, &QDialog::finished, this, [&alert, shown = alert.data(), dismiss] { + if (alert != shown) + return; + alert = nullptr; + dismiss(); + }); + alert->open(); +} diff --git a/src/ContentView+Status.cpp b/src/ContentView+Status.cpp new file mode 100644 index 0000000..8c7028b --- /dev/null +++ b/src/ContentView+Status.cpp @@ -0,0 +1,60 @@ +#include "ContentView.h" +#include <QLocale> +#include <map> + +// The status bar's words: zoom, and each tool's hint. +QString ContentView::percent(double zoom) +{ + QString number = QLocale(QLocale::English, QLocale::UnitedStates).toString(zoom * 100, 'f', 1); + if (number.endsWith(QLatin1String(".0"))) + number.chop(2); + return number + QLatin1Char('%'); +} + +// The kind two tools' icons and hints follow. +ToolIconKind ContentView::iconKind(NavigationTool tool) const +{ + if (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse) + return ToolIconKind::ellipse; + if (tool == NavigationTool::lasso && m_session.lassoKind() == LassoKind::polygonal) + return ToolIconKind::polygonal; + if (tool == NavigationTool::brush && m_session.brushMode() == BrushToolMode::erase) + return ToolIconKind::eraser; + return ToolIconKind::plain; +} + +QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode smear, ShapeKind shape) +{ + if (tool == NavigationTool::marquee && kind == ToolIconKind::ellipse) + return QStringLiteral("Drag an ellipse · Shift add · Alt subtract · Shift again mid-drag circle · Drag inside to move · Delete clears · Ctrl+D deselect"); + if (tool == NavigationTool::lasso && kind == ToolIconKind::polygonal) + return QStringLiteral("Click corners · Click start, double-click or Enter to close · Delete removes corner · Escape cancel"); + if (tool == NavigationTool::brush && kind == ToolIconKind::eraser) + return QStringLiteral("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"); + if (tool == NavigationTool::blur) + return (smear == BlurToolMode::blur ? QStringLiteral("Drag to soften") + : smear == BlurToolMode::smudge ? QStringLiteral("Drag to smudge") + : QStringLiteral("Drag to push pixels")) + + QStringLiteral(" · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan"); + if (tool == NavigationTool::shape) + return QStringLiteral("Drag to draw a shape on a new layer · Shift %1 · Alt from center · Shift-U or Tab for the next shape · Escape cancel · Space to pan") + .arg(shape == ShapeKind::line ? QStringLiteral("45°") : shape == ShapeKind::rectangle ? QStringLiteral("square") : QStringLiteral("circle")); + // Swift's last branch: the Eyedropper shows Zoom's words too. + static const std::map<NavigationTool, const char *> hints = { + {NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"}, + {NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"}, + {NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"}, + {NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"}, + {NavigationTool::type, "Drag a text box · Click text to edit · Drag box handles to resize · Ctrl+Return finish · Escape cancel"}, + {NavigationTool::gradient, "Drag to draw · Drag ends to adjust · Shift 45° · 1–0 opacity · Enter apply · Escape cancel"}, + {NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"}, + {NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"}, + {NavigationTool::hand, "Drag to pan · Pinch to zoom"}, + {NavigationTool::idle, "No tool selected · Press a tool's key to pick one · Space to pan"}, + {NavigationTool::eyedropper, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"}, + {NavigationTool::zoom, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"}, + }; + return QString::fromUtf8(hints.at(tool)); +} diff --git a/src/ContentView.cpp b/src/ContentView.cpp new file mode 100644 index 0000000..c0209e4 --- /dev/null +++ b/src/ContentView.cpp @@ -0,0 +1,453 @@ +#include "ContentView.h" +#include "UI/ColorPaletteControls.h" +#include "Logging.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ImageFileDrop.h" +#include "UI/LayersPanel.h" +#include "UI/BrushControls.h" +#include "UI/CropControls.h" +#include "UI/GradientControls.h" +#include "UI/ShapeControls.h" +#include "UI/TypeControls.h" +#include "UI/LassoControls.h" +#include "UI/NavigationToolHeader.h" +#include "UI/TransformInspector.h" +#include "UI/NewCanvasSheet.h" +#include "UI/ToolHeaderStyle.h" +#include "UI/ToolIcons.h" +#include <QCheckBox> +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QFrame> +#include <QLocale> +#include <QPainter> +#include <QProgressBar> +#include <QMouseEvent> +#include <QScrollArea> +#include <QSettings> +#include <cmath> +#include <map> + +namespace { +const NavigationTool railTools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand, + NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp, + NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type, + NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom}; + +// Swift's Eyedropper bar: its title and the ring's checkbox. +ToolHeaderBar *eyedropperBar(EditorSession &session, QWidget *parent) +{ + auto *bar = new ToolHeaderBar(QStringLiteral("Eyedropper"), parent); + auto *ring = new QCheckBox(QStringLiteral("Sample Ring"), bar); + ring->setObjectName(QStringLiteral("sampleRing")); + ring->setChecked(session.showsSampleRing()); + QObject::connect(ring, &QCheckBox::toggled, &session, &EditorSession::setShowsSampleRing); + // It follows the session, which others may set. + QObject::connect(&session, &EditorSession::changed, ring, [ring, &session] { + const QSignalBlocker quiet(ring); + ring->setChecked(session.showsSampleRing()); + }); + bar->row->setSpacing(16); + bar->row->insertWidget(1, ring); + return bar; +} +} + +// A rail button: its tool's icon; a plate when chosen. +class ToolButton : public QToolButton { +public: + ToolButton(NavigationTool tool, QWidget *parent) : QToolButton(parent), m_tool(tool) + { + setCheckable(true); + setFixedSize(36, 36); + setFocusPolicy(Qt::NoFocus); + setToolTip(label(tool)); + setAccessibleName(label(tool)); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QColor ink = palette().color(QPalette::WindowText); + if (isChecked()) { + QColor plate = ink, edge = ink; + plate.setAlphaF(0.12); + edge.setAlphaF(0.14); + painter.setPen(edge); + painter.setBrush(plate); + painter.drawRoundedRect(QRectF(0.5, 0.5, 35, 35), 7, 7); + } + ToolIcons::paint(painter, m_tool, QPointF(9, 9), ToolIcons::points, ink, m_kind); + } + +public: + // The Marquee's and the Lasso's icons follow their kinds. + void setKind(ToolIconKind kind) + { + if (kind == m_kind) + return; + m_kind = kind; + update(); + } + +private: + const NavigationTool m_tool; + ToolIconKind m_kind = ToolIconKind::plain; +}; + +namespace { +// Swift's PanelResizeEdge: a divider with an 8-point grip. +class PanelResizeEdge : public QWidget { +public: + PanelResizeEdge(LayersPanel &panel, QWidget *parent) : QWidget(parent), m_panel(panel) + { + setObjectName(QStringLiteral("layersPanelEdge")); + setFixedWidth(8); + setCursor(Qt::SplitHCursor); + setToolTip(QStringLiteral("Drag to resize the panel")); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(QRect(width() / 2, 0, 1, height()), palette().color(QPalette::Mid)); + } + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_start = std::pair(event->globalPosition().x(), double(m_panel.width())); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (!m_start) + return; + // Dragged left, the panel widens; whole points, within range. + const double wanted = std::round(m_start->second - (event->globalPosition().x() - m_start->first)); + const double width = std::min(LayersPanel::maximumWidth, std::max(LayersPanel::minimumWidth, wanted)); + if (width == m_panel.width()) + return; + m_panel.setFixedWidth(int(width)); + ContentView::setLayersPanelWidth(width); + } + void mouseReleaseEvent(QMouseEvent *) override { m_start = std::nullopt; } + +private: + LayersPanel &m_panel; + std::optional<std::pair<double, double>> m_start; +}; + +// Swift's overlay: a 3-point accent ring inside the canvas. +class DropRing : public QWidget { +public: + explicit DropRing(QWidget *parent) : QWidget(parent) + { + setObjectName(QStringLiteral("dropRing")); + setAttribute(Qt::WA_TransparentForMouseEvents); + hide(); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(palette().color(QPalette::Highlight), 3)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(QRectF(rect()).adjusted(4.5, 4.5, -4.5, -4.5), 8, 8); + } +}; + +QFrame *divider(QFrame::Shape shape, QWidget *parent) +{ + auto *line = new QFrame(parent); + line->setFrameShape(shape); + line->setFrameShadow(QFrame::Plain); + line->setForegroundRole(QPalette::Mid); + return line; +} + +QLabel *statusText(QLabel *label) +{ + QFont font = label->font(); + font.setPixelSize(11); + label->setFont(font); + label->setForegroundRole(QPalette::PlaceholderText); + return label; +} + +// A row's widest item shrinks first; this ends in dots. +class ElidedLabel : public QLabel { +public: + using QLabel::QLabel; + +protected: + void paintEvent(QPaintEvent *) override + { + // A widget's painter starts with its foreground role's pen. + QPainter painter(this); + painter.drawText(rect(), Qt::AlignRight | Qt::AlignVCenter, fontMetrics().elidedText(text(), Qt::ElideRight, width())); + } +}; +} + +ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent) + : QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout), + m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))), + m_activity(statusText(new ElidedLabel(this))), + m_spinner(new QProgressBar(this)) +{ + setMinimumSize(800, 520); + m_column->setContentsMargins(0, 0, 0, 0); + m_column->setSpacing(0); + + auto *tools = new QWidget(this); + auto *toolColumn = new QVBoxLayout(tools); + // Room for the palette's overhang; the tools stay centred. + toolColumn->setContentsMargins(7, 16, 7, 12); + toolColumn->setSpacing(10); + for (const NavigationTool tool : railTools) { + auto *button = new ToolButton(tool, tools); + // A refused click stays silent: set the buttons again. + connect(button, &QToolButton::clicked, this, [this, tool] { + m_session.selectTool(tool); + synchronize(); + }); + toolColumn->addWidget(button, 0, Qt::AlignHCenter); + m_toolButtons.push_back({tool, button}); + } + // Swift's 8 points above the swatches, less their overhang. + toolColumn->addSpacing(5); + toolColumn->addWidget(new ColorPaletteControls(session, tools), 0, Qt::AlignHCenter); + toolColumn->addStretch(1); + // Too short a window scrolls the rail, not the bars. + auto *rail = new QScrollArea(this); + rail->setObjectName(QStringLiteral("toolRail")); + rail->setWidget(tools); + rail->setWidgetResizable(true); + rail->setFixedWidth(56); + rail->setFrameShape(QFrame::NoFrame); + rail->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + rail->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + auto *canvas = new QWidget(this); + canvas->setLayout(m_canvasSlot); + // Swift's canvas fills its area: no layout gutter. + m_canvasSlot->setContentsMargins(0, 0, 0, 0); + // The welcome sits over the canvas, as Swift's ZStack. + m_canvasSlot->addWidget(m_canvas, 0, 0); + m_canvasSlot->addWidget(m_dropRing, 0, 0); + setAcceptDrops(true); + + auto *middle = new QHBoxLayout; + middle->setSpacing(0); + middle->addWidget(rail); + middle->addWidget(divider(QFrame::VLine, this)); + middle->addWidget(canvas, 1); + m_layersPanel->setFixedWidth(int(layersPanelWidth())); + middle->addWidget(new PanelResizeEdge(*m_layersPanel, this)); + middle->addWidget(m_layersPanel); + + m_zoom->setObjectName(QStringLiteral("zoomStatus")); + m_zoom->setFixedWidth(62); + m_dimensions->setObjectName(QStringLiteral("canvasDimensions")); + m_colour->setObjectName(QStringLiteral("colourStatus")); + m_activity->setObjectName(QStringLiteral("activityStatus")); + m_spinner->setRange(0, 0); + m_spinner->setTextVisible(false); + m_spinner->setFixedSize(36, 6); + auto *status = new QWidget(this); + status->setObjectName(QStringLiteral("statusBar")); + status->setFixedHeight(30); + auto *statusRow = new QHBoxLayout(status); + statusRow->setContentsMargins(18, 0, 18, 0); + statusRow->setSpacing(16); + statusRow->addWidget(m_zoom); + statusRow->addWidget(m_dimensions); + statusRow->addWidget(m_colour); + statusRow->addWidget(m_spinner, 0, Qt::AlignRight); + // The hint takes what is left, and shrinks first. + statusRow->addWidget(m_activity, 1); + + m_column->addWidget(divider(QFrame::HLine, this)); + m_column->addLayout(middle, 1); + m_column->addWidget(divider(QFrame::HLine, this)); + m_column->addWidget(status); + + // The filter panel follows the session by itself. + connect(&m_session, &EditorSession::changed, this, &ContentView::synchronize); + synchronize(); +} + +// ~QWidget closes the window after members go; stop listening first. +ContentView::~ContentView() +{ + disconnect(&m_session, &EditorSession::changed, this, &ContentView::synchronize); +} + +// Another tab's layer, files or a picture, while free. +bool ContentView::acceptsDrop(const QMimeData &data) const +{ + if (!(data.hasFormat(ProjectWorkspace::layerType) || ImageFileDrop::holdsImages(data))) + return false; + // Swift's session guards; a workspace's are canSwitch's own. + if (!m_workspace) + return !m_session.levels() && !m_session.isProjectBusy() && !m_session.showsNewDocument() && !m_session.showsImporter() + && !m_session.renamingLayerID(); + return m_workspace->canSwitch() && m_workspace->canReceiveDrag(data, m_workspace->current().id); +} + +void ContentView::dragEnterEvent(QDragEnterEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + m_dropRing->raise(); + m_dropRing->show(); +} + +void ContentView::dragMoveEvent(QDragMoveEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) { + event->ignore(); + return; + } + event->setDropAction(Qt::CopyAction); + event->accept(); +} + +void ContentView::dragLeaveEvent(QDragLeaveEvent *) +{ + m_dropRing->hide(); +} + +void ContentView::dropEvent(QDropEvent *event) +{ + m_dropRing->hide(); + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + // Over the canvas the drop names a document point. + std::optional<QPointF> point; + const QPointF inCanvas = m_canvas->mapFrom(this, event->position().toPoint()); + if (m_session.document() && m_canvas->rect().contains(inCanvas.toPoint())) + point = m_session.viewport.documentPoint(inCanvas, m_session.document()->size()); + if (m_workspace) + m_workspace->receiveProviders(*event->mimeData(), m_workspace->current().id, point); + else + ImageFileDrop::importProviders(*event->mimeData(), m_session, point); +} + +double ContentView::layersPanelWidth() +{ + const double stored = QSettings().value(QStringLiteral("layersPanelWidth"), LayersPanel::defaultWidth).toDouble(); + // A stored width outside the range is the default, logged. + if (stored >= LayersPanel::minimumWidth && stored <= LayersPanel::maximumWidth) + return stored; + qCWarning(lcApp) << "layersPanelWidth" << stored << "is out of range, using" << LayersPanel::defaultWidth; + return LayersPanel::defaultWidth; +} + +void ContentView::setLayersPanelWidth(double width) +{ + QSettings().setValue(QStringLiteral("layersPanelWidth"), width); +} + +// Shows what the session holds: every line compares, then sets. +void ContentView::synchronize() +{ + m_canvas->consumeFocusRequest(m_session.canvasFocusRequest()); + m_canvas->synchronizeDisplay(); + if (m_session.document() && !m_hadDocument) + QMetaObject::invokeMethod(&m_session, &EditorSession::requestCanvasFocus, Qt::QueuedConnection); + m_hadDocument = m_session.document().has_value(); + showHeader(m_session.tool()); + for (const auto &[tool, button] : m_toolButtons) { + button->setChecked(m_session.tool() == tool); + button->setKind(iconKind(tool)); + } + const std::optional<CanvasDocument> &document = m_session.document(); + showWelcome(!document); + m_zoom->setVisible(document.has_value()); + m_dimensions->setVisible(document.has_value()); + if (document) { + const QLocale english(QLocale::English, QLocale::UnitedStates); + m_zoom->setText(percent(m_session.viewport.zoom())); + m_dimensions->setText(QStringLiteral("%1 × %2 px").arg(english.toString(document->width), english.toString(document->height))); + } + m_colour->setText(document ? QStringLiteral("sRGB · Transparent") : QStringLiteral("Ready when you are")); + m_spinner->setVisible(m_session.showsBusy() || m_session.isImporting()); + m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…") + : hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind())); + showImporter(); + showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); }); + showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); }); + showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); }); + synchronizePanels(); +} + +// Swift's `if document == nil` makes a fresh sheet. +void ContentView::showWelcome(bool shown) +{ + if (shown == (m_welcome != nullptr)) + return; + delete m_welcome; + m_welcome = nullptr; + if (!shown) + return; + m_welcome = new NewCanvasSheet( + m_session, [this](int width, int height) { m_session.createNewProject(width, height); }, + [this] { + if (m_projects) + m_projects->open(); + }, + this); + m_canvasSlot->addWidget(m_welcome, 0, 0, Qt::AlignCenter); + m_welcome->show(); +} + +void ContentView::showHeader(NavigationTool tool) +{ + // Shared bars: Hand and Zoom, selection tools, brush tools. + const auto kind = [](NavigationTool each) { + return each == NavigationTool::hand || each == NavigationTool::zoom ? NavigationTool::hand + : isSelectionTool(each) ? NavigationTool::lasso + : isBrushTool(each) ? NavigationTool::brush + : each; + }; + if (m_shownTool && kind(*m_shownTool) == kind(tool)) + return; + m_shownTool = tool; + delete m_header; + // A tool without a bar yet keeps the bar's height. + if (tool == NavigationTool::idle) + m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this); + else if (tool == NavigationTool::eyedropper) + m_header = eyedropperBar(m_session, this); + else if (kind(tool) == NavigationTool::hand) + m_header = new NavigationToolHeader(m_session, this); + else if (tool == NavigationTool::move) + m_header = new TransformInspector(m_session, this); + else if (isSelectionTool(tool)) + m_header = new LassoControls(m_session, this); + else if (isBrushTool(tool)) + m_header = new BrushControls(m_session, this); + else if (tool == NavigationTool::gradient) + m_header = new GradientControls(m_session, this); + else if (tool == NavigationTool::shape) + m_header = new ShapeControls(m_session, this); + else if (tool == NavigationTool::type) + m_header = new TypeControls(m_session, this); + else if (tool == NavigationTool::crop) + m_header = new CropControls(m_session, this); + else + m_header = new ToolHeaderBar(QString(), this); + m_header->setObjectName(QStringLiteral("toolHeader")); + m_column->insertWidget(0, m_header); + // A layout shows a late child only later: show now. + m_header->show(); +} diff --git a/src/ContentView.h b/src/ContentView.h new file mode 100644 index 0000000..fe093d8 --- /dev/null +++ b/src/ContentView.h @@ -0,0 +1,93 @@ +#pragma once +#include "Document/EditorSession.h" +#include "IO/ProjectController.h" +#include "Rendering/EditorCanvas.h" +#include "UI/FloatingPanel.h" +#include "UI/ToolIcons.h" +#include <QGridLayout> +#include <QLabel> +#include <QPointer> +#include <QStackedLayout> +#include <QToolButton> +#include <QVBoxLayout> +#include <QWidget> + +class LayersPanel; +class ToolButton; +class ProjectWorkspace; +class QFileDialog; +class QMimeData; +class QMessageBox; +class QProgressBar; + +// One project's editor area: bar, rail, canvas, status. +class ContentView : public QWidget { + Q_OBJECT +public: + // Without a controller, Open project has nowhere to go. + explicit ContentView(EditorSession &session, ProjectController *projects = nullptr, QWidget *parent = nullptr); + ~ContentView() override; + + // The status bar's words for a tool, by its kind. + static QString hint(NavigationTool tool, ToolIconKind kind = ToolIconKind::plain, BlurToolMode smear = BlurToolMode::liquify, + ShapeKind shape = ShapeKind::rectangle); + // Zoom as Swift prints it: percent, one decimal at most. + static QString percent(double zoom); + // The Layers panel's width, remembered across launches. + static double layersPanelWidth(); + static void setLayersPanelWidth(double width); + LayersPanel &layersPanel() const { return *m_layersPanel; } + // Drops of layers from other tabs go to the workspace. + void setWorkspace(ProjectWorkspace *workspace) { m_workspace = workspace; } + bool acceptsDrop(const QMimeData &data) const; + +protected: + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dragLeaveEvent(QDragLeaveEvent *event) override; + void dropEvent(QDropEvent *event) override; + +private: + void synchronize(); + // Swift's onChange of levels: its panel opens and closes. + void synchronizePanels(); + ToolIconKind iconKind(NavigationTool tool) const; + void showHeader(NavigationTool tool); + void showWelcome(bool shown); + void showImporter(); + void showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message, + const std::function<void()> &dismiss); + + EditorSession &m_session; + const QPointer<ProjectController> m_projects; + QVBoxLayout *const m_column; + QWidget *m_header = nullptr; + std::optional<NavigationTool> m_shownTool; + std::vector<std::pair<NavigationTool, ToolButton *>> m_toolButtons; + QGridLayout *const m_canvasSlot; + CanvasView *const m_canvas; + LayersPanel *const m_layersPanel; + // An accent ring while a drop may land. + QWidget *const m_dropRing; + ProjectWorkspace *m_workspace = nullptr; + // A document that appears hands the canvas the keys. + bool m_hadDocument = false; + QWidget *m_welcome = nullptr; + QLabel *const m_zoom; + QLabel *const m_dimensions; + QLabel *const m_colour; + QLabel *const m_activity; + QProgressBar *const m_spinner; + QPointer<QFileDialog> m_importer; + QPointer<QMessageBox> m_importAlert; + QPointer<QMessageBox> m_brushAlert; + QPointer<QMessageBox> m_cropAlert; + FloatingPanel m_levelsPanel{QStringLiteral("levelsPanel"), *this}; + bool m_levelsShown = false; + FloatingPanel m_adjustmentPanel{QStringLiteral("adjustmentPanel"), *this}; + bool m_hueSaturationShown = false; + FloatingPanel m_filterPanel{QStringLiteral("filterPanel"), *this}; + bool m_filterShown = false; + FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this}; + std::optional<LayerEffectSelection> m_effectsShown; +}; diff --git a/src/Document/AdjustmentEditing+Session.h b/src/Document/AdjustmentEditing+Session.h new file mode 100644 index 0000000..27a7611 --- /dev/null +++ b/src/Document/AdjustmentEditing+Session.h @@ -0,0 +1,12 @@ +// Swift's AdjustmentEditing extension: the colour editors write layer settings. +public: + // Draws what lies beneath off the UI thread; `done` follows. + void beginAdjustmentEditing(QUuid id, std::function<void()> done = {}); + // Writes the edited settings, or the original with Preview off. + bool previewAdjustmentEditing(bool preview); + // OK keeps the edited settings; Cancel puts the original back. + bool finishAdjustmentEditing(bool commit); + +private: + LayerAdjustment editedAdjustment() const; + void openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made); diff --git a/src/Document/AdjustmentEditing.cpp b/src/Document/AdjustmentEditing.cpp new file mode 100644 index 0000000..a9c99da --- /dev/null +++ b/src/Document/AdjustmentEditing.cpp @@ -0,0 +1,147 @@ +#include "Document/EditorSession.h" +#include "Document/LayerGroups.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QtConcurrent> + +// Swift's AdjustmentEditing.swift: pixels beneath feed histogram and sampling. + +void EditorSession::beginAdjustmentEditing(QUuid id, std::function<void()> done) +{ + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (m_adjustmentEditingID != id || m_adjustmentOriginal || m_levels || m_hueSaturation || m_filterEdit || index < 0 + || !m_document->layers[size_t(index)].adjustment) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const LayerAdjustment original = *m_document->layers[size_t(index)].adjustment; + ProjectSnapshot source = projectSnapshot().value(); + // Records stay for live masks and folders; beneath alone shows. + QSet<QUuid> underneath; + for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(source.manifest.layers)) { + if (entry.layer.id == id) + break; + underneath.insert(entry.layer.id); + } + for (ProjectLayerRecord &layer : source.manifest.layers) { + if (!underneath.contains(layer.id)) + layer.isVisible = false; + } + // A watcher per call, as Swift's calls are tasks. + auto *watcher = new QFutureWatcher<AdjustmentInput>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id, original, done = std::move(done)] { + watcher->deleteLater(); + openAdjustmentEditor(id, original, watcher->result()); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }); + // A painted layer flattens there, where a failure is caught. + watcher->setFuture(QtConcurrent::run([source]() -> AdjustmentInput { + try { + const QImage image = ImageExporter::render(source).image; + return {ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Adjustment input")), std::nullopt}; + } catch (const ExportError &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made) +{ + // Swift's guard after the await; an open editor wins. + if (m_adjustmentEditingID != id || m_adjustmentOriginal) + return; + std::optional<QString> failure = made.failure; + // Beyond Swift: an edit begun meanwhile keeps its editor. + if (!failure && (m_levels || m_hueSaturation || m_filterEdit)) { + qCWarning(lcApp) << "an adjustment's editor gave way to an edit begun meanwhile"; + m_adjustmentEditingID.reset(); + resumeFileRequests(); + notify(); + return; + } + if (!failure) { + try { + const ImageLayer layer(made.asset.value(), QPointF(0, 0)); + switch (original.kind) { + case AdjustmentKind::levels: + m_levels.emplace(layer, std::nullopt); + m_levels->settings = original.levels; + countLevelsHistogram(); + break; + case AdjustmentKind::hsv: + m_hueSaturation.emplace(layer.id, made.asset.value(), std::nullopt, layer.transform); + m_hueSaturation->settings = original.resolvedHSV(); + break; + case AdjustmentKind::curves: + case AdjustmentKind::exposure: + case AdjustmentKind::gradientMap: + case AdjustmentKind::grain: { + FilterSettings settings; + settings.curves = original.curves; + settings.exposure = original.exposure(); + settings.gradientMap = original.gradientMap(); + settings.grain = original.grain(); + m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt); + break; + } + } + } catch (const ExportError &error) { + failure = QString::fromUtf8(error.what()); + } + } + if (failure) { + m_adjustmentEditingID.reset(); + resumeFileRequests(); + setBrushError(failure); + return; + } + m_adjustmentOriginal = original; + beginEdit(QStringLiteral("Edit %1 Adjustment").arg(rawValue(original.kind))); +} + +// The kind's edit lives as long as the original does. +LayerAdjustment EditorSession::editedAdjustment() const +{ + LayerAdjustment value = m_adjustmentOriginal.value(); + switch (value.kind) { + case AdjustmentKind::levels: value.levels = m_levels.value().settings; break; + case AdjustmentKind::hsv: value.hsvSettings = m_hueSaturation.value().settings; break; + case AdjustmentKind::curves: value.curves = m_filterEdit.value().settings.curves; break; + case AdjustmentKind::exposure: value.setExposure(m_filterEdit.value().settings.exposure); break; + case AdjustmentKind::gradientMap: value.setGradientMap(m_filterEdit.value().settings.gradientMap); break; + case AdjustmentKind::grain: value.setGrain(m_filterEdit.value().settings.grain); break; + } + return value; +} + +bool EditorSession::previewAdjustmentEditing(bool preview) +{ + if (!m_adjustmentEditingID || !m_adjustmentOriginal) + return false; + updateAdjustment(*m_adjustmentEditingID, preview ? editedAdjustment() : *m_adjustmentOriginal); + return true; +} + +bool EditorSession::finishAdjustmentEditing(bool commit) +{ + if (!m_adjustmentEditingID || !m_adjustmentOriginal) + return false; + updateAdjustment(*m_adjustmentEditingID, commit ? editedAdjustment() : *m_adjustmentOriginal); + m_hueSampleMode.reset(); + m_hueTargeting = false; + m_hueTargetDrag.reset(); + m_levels.reset(); + m_hueSaturation.reset(); + m_filterEdit.reset(); + endEdit(); + m_adjustmentOriginal.reset(); + m_adjustmentEditingID.reset(); + resumeFileRequests(); + ++m_canvasFocusRequest; + ++m_brushRevision; + notify(); + return true; +} diff --git a/src/Document/BlurTool+Session.h b/src/Document/BlurTool+Session.h new file mode 100644 index 0000000..a9f0ce5 --- /dev/null +++ b/src/Document/BlurTool+Session.h @@ -0,0 +1,4 @@ +// Swift's BlurTool extension: what a Blur stroke paints. +public: + // The active layer, or its mask, softened at document size. + std::optional<QImage> blurSample(const CanvasDocument &document, bool mask = false) const; diff --git a/src/Document/BlurTool.cpp b/src/Document/BlurTool.cpp new file mode 100644 index 0000000..ea62db3 --- /dev/null +++ b/src/Document/BlurTool.cpp @@ -0,0 +1,52 @@ +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <cmath> + +// Swift's BlurTool extension: the layer, or its mask, softened. +std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!layer) + return std::nullopt; + // The softening follows the brush's size. + const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10)); + try { + if (mask) { + if (!layer->mask) + return std::nullopt; + QImage context = BrushRaster::context(document.width, document.height, true); + // Past its pixels a mask keeps its edge tone. + const int tone = int(std::round(LayerMask::background(layer->mask->asset.thumbnail) * 255)); + context.fill(QColor(tone, tone, tone)); + QPainter painter(&context); + // Coverage only adds white: the mask's area starts black. + const LayerTransform placement = layer->maskTransform(); + painter.save(); + QTransform box; + box.translate(placement.center().x(), placement.center().y()); + box.rotateRadians(placement.radians()); + painter.setTransform(box, true); + painter.fillRect(QRectF(-placement.size.width() / 2, -placement.size.height() / 2, placement.size.width(), placement.size.height()), + Qt::black); + painter.restore(); + LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter); + painter.end(); + return PixelAdjust::gaussianBlur(context, sigma, true); + } + if (!layer->asset) + return std::nullopt; + QImage context = BrushRaster::context(document.width, document.height, false); + QPainter painter(&context); + // A painted asset flattens here; the catch covers it too. + const LayerTransform transform = displayedTransform(*layer); + LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {}); + painter.end(); + return PixelAdjust::gaussianBlur(context, sigma, false); + } catch (const ExportError &error) { + qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what(); + return std::nullopt; + } +} diff --git a/src/Document/BrushStroke+Commit.cpp b/src/Document/BrushStroke+Commit.cpp new file mode 100644 index 0000000..1c921bd --- /dev/null +++ b/src/Document/BrushStroke+Commit.cpp @@ -0,0 +1,180 @@ +#include "Document/BrushStroke.h" +#include "Document/LayerMask.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <QRandomGenerator> +#include <cstring> +#include <new> + +extern "C" { +#include "BrushPixels.h" +#include "HealPixels.h" +} + +// The stroke as a layer: bounds, placement, the commit's image. +namespace { +QRectF integral(const QRectF &rect) +{ + return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom()))); +} + +// Half-open bounds of the pixels that are there, or empty. +QRectF alphaBounds(const QImage &pixels) +{ + size_t edges[4] = {0, 0, 0, 0}; + brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges); + if (edges[2] <= edges[0] || edges[3] <= edges[1]) + return QRectF(); + return QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]); +} + +void drawSource(const ImportedImage &source, const QRectF &rect, QPainter &painter) +{ + if (source.raster) + source.raster->draw(rect, painter); + else + BrushRaster::draw(source.image(), rect, painter); +} +} + +QRectF BrushStroke::committedBounds() const +{ + return integral(m_allocatedBounds.value_or(sourceRect)); +} + +LayerTransform BrushStroke::committedTransform() const +{ + return transform(committedBounds()); +} + +LayerTransform BrushStroke::transform(const QRectF &bounds) const +{ + const QPointF center = pixelToDocument.map(bounds.center()); + LayerTransform result = paintTransform; + result.size = {bounds.width() * paintTransform.size.width() / width, bounds.height() * paintTransform.size.height() / height}; + result.origin = {center.x() - result.size.width() / 2, center.y() - result.size.height() / 2}; + return result; +} + +// Swift's heal: the painted spot rebuilt from nearby texture. +void BrushStroke::heal() +{ + if (!settings.healing || isMask) + return; + std::optional<QRectF> painted; + for (const auto &[key, coverage] : m_coverage) { + long edges[4]; + heal_coverage_bounds(coverage.constBits(), size_t(coverage.width()), size_t(coverage.height()), size_t(coverage.bytesPerLine()), edges); + if (edges[2] <= edges[0] || edges[3] <= edges[1]) + continue; + const QRectF rect = QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]).translated(m_tiles.at(key).rect.topLeft()); + painted = painted ? painted->united(rect) : rect; + } + if (!painted) + return; + // Room for the kernel's search, about three spots away. + const double reach = (std::max(painted->width(), painted->height()) + 32) * 3.2; + const QRect region = painted->adjusted(-reach, -reach, reach, reach).intersected(QRectF(0, 0, width, height)).toAlignedRect(); + QImage pixels = BrushRaster::context(region.width(), region.height(), false); + { + QPainter painter(&pixels); + const QRectF placed = sourceRect.translated(-region.topLeft()); + if (m_source && m_source->raster) + m_source->raster->draw(placed, painter); + else if (m_source) + BrushRaster::draw(m_source->image(), placed, painter); + } + // The kernel reads coverage rows packed, as Swift's contexts are. + const std::unique_ptr<uint8_t[]> painting(new (std::nothrow) uint8_t[size_t(region.width()) * size_t(region.height())]()); + if (!painting) + throw ExportError(ExportError::Kind::render); + for (const auto &[key, coverage] : m_coverage) { + const QRect tile = m_tiles.at(key).rect.toRect().translated(-region.topLeft()); + const QRect within = tile.intersected(QRect(QPoint(0, 0), region.size())); + for (int y = within.top(); y <= within.bottom(); ++y) + std::memcpy(painting.get() + size_t(y) * size_t(region.width()) + size_t(within.left()), + coverage.constScanLine(y - tile.top()) + (within.left() - tile.left()), size_t(within.width())); + } + if (spot_heal(pixels.bits(), painting.get(), size_t(region.width()), size_t(region.height()), size_t(pixels.bytesPerLine()), + float(settings.opacity), int(settings.healingMode), QRandomGenerator::global()->generate()) != 0) + throw ProjectError(ProjectError::Kind::tooLarge); + for (const auto &[key, coverage] : m_coverage) { + Tile &tile = m_tiles.at(key); + QImage healed = tile.base; + healed.detach(); + if (healed.isNull()) + throw ExportError(ExportError::Kind::render); + { + QPainter painter(&healed); + BrushRaster::draw(pixels, QRectF(region).translated(-tile.rect.topLeft()), painter); + } + // Swift clips the context; the selection blends here instead. + tile.context = selectionClip ? PixelAdjust::blend(healed, tile.base, *selectionClip, + QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, false) + : healed; + tile.image = tile.context; + } +} + +// Painting only adds alpha: the touched tiles alone are inspected. +PaintSnapshot BrushStroke::paintSnapshot() const +{ + std::optional<QRectF> bounds = m_source ? std::optional(sourceRect) : std::nullopt; + for (const auto &[key, tile] : m_tiles) { + if (isMask) + break; + const QRectF rect = alphaBounds(tile.context); + if (rect.isEmpty()) + continue; + const QRectF placed = rect.translated(tile.rect.topLeft()); + bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed); + } + const QRectF crop = bounds.value_or(committedBounds()); + const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask); + return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop}; +} + +BrushCommit::Input BrushStroke::commitInput() const +{ + const QRectF bounds = committedBounds(); + std::vector<BrushPatch> shifted; + for (const BrushPatch &patch : patches()) + shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image}); + return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())}; +} + +ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop) +{ + if (input.sourceRect == crop) + return asset; + QImage context = BrushRaster::context(int(crop.width()), int(crop.height()), true); + // New canvas area has no mask; old coverage stays aligned. + context.fill(Qt::white); + QPainter painter(&context); + drawSource(asset, input.sourceRect.translated(-crop.topLeft()), painter); + painter.end(); + return LayerMask::assetFrom(context); +} + +BrushCommit::Output BrushCommit::render(const Input &input) +{ + QImage context = BrushRaster::context(input.width, input.height, input.mask); + QPainter painter(&context); + if (input.source) + drawSource(*input.source, input.sourceRect, painter); + for (const BrushPatch &patch : input.patches) + BrushRaster::draw(patch.image, patch.rect, painter); + painter.end(); + const QRectF fullBounds(0, 0, input.width, input.height); + if (input.mask) + return {LayerMask::assetFrom(context), fullBounds}; + // Every pixel with alpha stays, the soft rim included. + const QRectF found = alphaBounds(context); + const QRectF crop = found.isEmpty() ? fullBounds : found; + const QImage image = crop == fullBounds ? context : context.copy(crop.toRect()); + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + return {ImportedImage(image, PixelAdjust::thumbnail(image), input.name), crop}; +} diff --git a/src/Document/BrushStroke+Stroke.cpp b/src/Document/BrushStroke+Stroke.cpp new file mode 100644 index 0000000..13900c5 --- /dev/null +++ b/src/Document/BrushStroke+Stroke.cpp @@ -0,0 +1,466 @@ +#include "Document/BrushStroke.h" +#include "Rendering/PoolMap.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <QtConcurrent> +#include <cmath> +#include <functional> +#include <stdexcept> + +// The stroke's geometry, samples, tiles and coverage. +namespace { +QRectF integral(const QRectF &rect) +{ + return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom()))); +} + +// The clone through coverage and a clip: over, or copied. +void fillClone(const QImage &sample, const QImage &coverage, double alpha, QImage &context, const QImage &clip, bool replaces) +{ + const bool gray = context.format() == QImage::Format_Grayscale8; + if (sample.format() != QImage::Format_RGBA8888_Premultiplied || (gray && replaces)) + throw std::logic_error("a clone samples premultiplied colour, and a mask never copies"); + const int channels = gray ? 1 : 4; + for (int y = 0; y < coverage.height(); ++y) { + const uchar *mask = coverage.constScanLine(y), *source = sample.constScanLine(y); + const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y); + uchar *row = context.scanLine(y); + for (int x = 0; x < coverage.width(); ++x) { + const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0); + // Copying replaces by coverage alone, so clear clears. + const double m = mask[x] / 255.0 * (within ? within[x] / 255.0 : 1.0); + if (replaces ? m <= 0 : k <= 0) + continue; + // A gray sample's edge keeps its coverage, as CoreGraphics draws. + const uchar *from = source + x * 4; + uchar *pixel = row + x * channels; + const double keep = replaces ? 1 - m : 1 - from[3] / 255.0 * k; + for (int c = 0; c < channels; ++c) + pixel[c] = uchar(pixel[c] * keep + from[c] * k + 0.5); + } + } +} + +// The tiles a pixel rect touches, by key. +template <typename Body> void eachTile(const QRectF &affected, int width, const Body &body) +{ + if (affected.isEmpty()) + return; + const qint64 columns = (width + BrushStroke::tileSize - 1) / BrushStroke::tileSize; + const qint64 firstY = qint64(affected.top()) / BrushStroke::tileSize, lastY = (qint64(std::ceil(affected.bottom())) - 1) / BrushStroke::tileSize; + const qint64 firstX = qint64(affected.left()) / BrushStroke::tileSize, lastX = (qint64(std::ceil(affected.right())) - 1) / BrushStroke::tileSize; + for (qint64 y = firstY; y <= lastY; ++y) { + for (qint64 x = firstX; x <= lastX; ++x) + body(y * columns + x, x, y); + } +} +} + +double BrushStroke::spacingFraction(double hardness) +{ + return hardness >= 1 ? 0.015 : 0.025; +} + +BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas) +{ + // A placed mask paints in its grid; else the layer's. + const bool placedMask = mask && layer.mask && layer.mask->placement; + const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform; + const int originalWidth = placedMask ? layer.mask->asset.size().width() + : layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())); + const int originalHeight = placedMask ? layer.mask->asset.size().height() + : layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height())); + if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000) + throw ProjectError(ProjectError::Kind::tooLarge); + const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight); + const QRectF originalBounds(0, 0, originalWidth, originalHeight); + const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas))); + // Judged as a double: past int the conversion is undefined. + if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + Grid grid{int(extent.width()), int(extent.height()), originalBounds.translated(-extent.topLeft()), + QTransform::fromTranslate(extent.left(), extent.top()) * originalMapping, base}; + grid.paintTransform.size = {grid.width * base.size.width() / originalWidth, grid.height * base.size.height() / originalHeight}; + const QPointF center = originalMapping.map(extent.center()); + grid.paintTransform.origin = {center.x() - grid.paintTransform.size.width() / 2, center.y() - grid.paintTransform.size.height() / 2}; + if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > 2000 + || !std::isfinite(settings.hardness) || settings.hardness < 0 || settings.hardness > 1 + || !std::isfinite(settings.opacity) || settings.opacity < 0.01 || settings.opacity > 1) + throw ProjectError(ProjectError::Kind::tooLarge); + return grid; +} + +BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas) + : BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas))) +{ +} + +BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid) + : layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect), + pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform), + m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset), + m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue)) +{ +} + +void BrushStroke::append(QPointF point) +{ + if (!std::isfinite(point.x()) || !std::isfinite(point.y()) || std::abs(point.x()) > 10'000'000 || std::abs(point.y()) > 10'000'000) + return; + if (!m_samples.empty() && m_samples.back() == point) + return; + m_samples.push_back(point); + if (m_samples.size() > 4) + m_samples.erase(m_samples.begin()); + const size_t n = m_samples.size(); + std::vector<BrushSegment> settled; + if (n == 1) + settled = {BrushSegment{float(point.x()), float(point.y()), float(point.x()), float(point.y())}}; + else if (n >= 3) + settled = continuousCurve(m_samples[n - 3], m_samples[n - 2], m_samples[n >= 4 ? n - 4 : 0], point); + std::vector<BrushSegment> tail; + if (n >= 2) + tail = {BrushSegment{float(m_samples[n - 2].x()), float(m_samples[n - 2].y()), float(point.x()), float(point.y())}}; + renderContinuous(settled, tail); +} + +void BrushStroke::flush() +{ + const size_t n = m_samples.size(); + if (n < 2) + return; + const std::vector<BrushSegment> settled = continuousCurve(m_samples[n - 2], m_samples[n - 1], m_samples[n >= 3 ? n - 3 : 0], m_samples[n - 1]); + renderContinuous(settled, {}); + m_samples = {m_samples[n - 1]}; +} + +// Centripetal Catmull–Rom, subdivided within 0.2 document pixels. +std::vector<BrushSegment> BrushStroke::continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const +{ + const auto knot = [](double t, QPointF a, QPointF b) { return t + std::max(0.0001, std::sqrt(std::hypot(b.x() - a.x(), b.y() - a.y()))); }; + const auto mix = [](QPointF a, QPointF b, double ta, double tb, double t) { + const double wa = (tb - t) / (tb - ta), wb = (t - ta) / (tb - ta); + return QPointF(a.x() * wa + b.x() * wb, a.y() * wa + b.y() * wb); + }; + const double t0 = 0, t1 = knot(t0, before, start), t2 = knot(t1, start, end), t3 = knot(t2, end, after); + const auto point = [&](double u) { + if (u == 0) + return start; + if (u == 1) + return end; + const double t = t1 + (t2 - t1) * u; + const QPointF a = mix(before, start, t0, t1, t), b = mix(start, end, t1, t2, t), c = mix(end, after, t2, t3, t); + return mix(mix(a, b, t0, t2, t), mix(b, c, t1, t3, t), t1, t2, t); + }; + std::vector<BrushSegment> result; + const std::function<void(QPointF, QPointF, double, double, int)> subdivide = [&](QPointF a, QPointF b, double lo, double hi, int depth) { + const double dx = b.x() - a.x(), dy = b.y() - a.y(), lengthSquared = dx * dx + dy * dy; + const auto error = [&](QPointF p) { + const double t = lengthSquared > 0 ? std::min(1.0, std::max(0.0, ((p.x() - a.x()) * dx + (p.y() - a.y()) * dy) / lengthSquared)) : 0; + return std::hypot(p.x() - a.x() - t * dx, p.y() - a.y() - t * dy); + }; + const double mid = (lo + hi) / 2; + const QPointF m = point(mid); + const double deviation = std::max({error(m), error(point((lo + mid) / 2)), error(point((mid + hi) / 2))}); + if (deviation <= 0.2 || depth >= 10) { + result.push_back(BrushSegment{float(a.x()), float(a.y()), float(b.x()), float(b.y())}); + return; + } + subdivide(a, m, lo, mid, depth + 1); + subdivide(m, b, mid, hi, depth + 1); + }; + subdivide(start, end, 0, 1, 0); + return result; +} + +std::set<qint64> BrushStroke::continuousKeys(const std::vector<BrushSegment> &segments) const +{ + std::set<qint64> keys; + const double reach = settings.diameter / 2 + 2; + const QTransform inverse = pixelToDocument.inverted(); + for (const BrushSegment &s : segments) { + const QRectF box = QRectF(QPointF(std::min(s.x0, s.x1), std::min(s.y0, s.y1)), QPointF(std::max(s.x0, s.x1), std::max(s.y0, s.y1))) + .adjusted(-reach, -reach, reach, reach).intersected(canvas); + if (box.isEmpty()) + continue; + const QRectF affected = integral(inverse.mapRect(box)).intersected(QRectF(0, 0, width, height)); + eachTile(affected, width, [&](qint64 key, qint64, qint64) { keys.insert(key); }); + } + return keys; +} + +void BrushStroke::renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail) +{ + const std::set<qint64> tailKeys = continuousKeys(tail); + std::set<qint64> changed = continuousKeys(settled); + changed.insert(tailKeys.begin(), tailKeys.end()); + changed.insert(m_tailKeys.begin(), m_tailKeys.end()); + const qint64 columns = (width + tileSize - 1) / tileSize; + std::vector<BrushCoverage::Work> work; + for (qint64 key : changed) { + allocateTile(key, key % columns, key / columns); + const Tile &tile = m_tiles.at(key); + if (!m_coverage.contains(key)) { + m_coverage.emplace(key, BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), true)); + m_coverageTiles.emplace(key, BrushCoverage::tile(int(tile.rect.width()), int(tile.rect.height()))); + } + work.push_back({&m_coverageTiles.at(key), tile.rect, &m_coverage.at(key)}); + } + BrushCoverage::render(work, settled, tail, pixelToDocument, settings, canvas.size()); + m_tailKeys = tailKeys; + publish(changed); +} + +// Each tile anew: original, then colour × coverage × opacity. +void BrushStroke::publish(const std::set<qint64> &changed) +{ + struct Fill { + Tile *tile; + const QImage *coverage; + QImage clip; + QImage sample; + }; + // Copies here, where a failure throws; fills side by side. + std::vector<Fill> work; + m_dirtyDocumentRect = std::nullopt; + for (qint64 key : changed) { + Tile &tile = m_tiles.at(key); + tile.context = tile.base; + tile.context.detach(); + if (tile.context.isNull()) + throw ExportError(ExportError::Kind::render); + work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)}); + const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas); + m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect); + } + // Healing shows Swift's dark wash until heal() rebuilds the spot. + const bool healing = settings.healing && !isMask; + const QPainter::CompositionMode mode = !healing && settings.erasing && !isMask ? QPainter::CompositionMode_DestinationOut : QPainter::CompositionMode_SourceOver; + const QColor colour = healing ? QColor::fromRgbF(0.12, 0.12, 0.12) : m_paintColor; + const double alpha = healing ? 0.45 : settings.opacity; + PoolMap::blocking(work, [&](const Fill &item) { + Tile &tile = *item.tile; + if (clone && (!isMask || isBlur)) + fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone); + else + BrushRaster::fill(colour, *item.coverage, QRectF(QPointF(0, 0), tile.rect.size()), alpha, tile.context, mode, item.clip); + tile.image = tile.context; + }); +} + +// Swift clips the context; the fill takes the selection instead. +QImage BrushStroke::selectionCoverage(const Tile &tile) const +{ + if (!selectionClip) + return QImage(); + return PixelAdjust::coverage(*selectionClip, int(tile.rect.width()), int(tile.rect.height()), + QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument); +} + +// The clone as this tile sees it, before the fill. +QImage BrushStroke::clonePixels(const Tile &tile) const +{ + if (!clone) + return QImage(); + const int width = int(tile.rect.width()), height = int(tile.rect.height()); + // Tile pixels to clone pixels: grid, document, then offset. + const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument + * QTransform::fromTranslate(clone->offset.width(), clone->offset.height()); + // Colour even for gray clones: edges carry their coverage. + const QImage &image = clone->image; + if (toClone.type() > QTransform::TxTranslate) { + // Swift's medium quality: bilinear under a scale or turn. + QImage sample = BrushRaster::context(width, height, false); + QPainter painter(&sample); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setTransform(toClone.inverted()); + painter.drawImage(QRectF(QPointF(0, 0), image.size()), image); + painter.end(); + return sample; + } + // QPainter snaps a translation; a fraction is resampled by hand. + const double left = std::floor(toClone.dx()), top = std::floor(toClone.dy()); + const bool whole = left == toClone.dx() && top == toClone.dy(); + QImage sample = BrushRaster::context(whole ? width : width + 1, whole ? height : height + 1, false); + QPainter painter(&sample); + BrushRaster::draw(image, QRectF(-left, -top, image.width(), image.height()), painter); + painter.end(); + if (whole) + return sample; + const QImage part = BrushRaster::shiftedByFraction(sample, 1 - (toClone.dx() - left), 1 - (toClone.dy() - top)).copy(1, 1, width, height); + if (part.isNull()) + throw ExportError(ExportError::Kind::render); + return part; +} + +void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y) +{ + if (m_tiles.contains(key)) + return; + const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize)); + const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect; + if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit) + throw ProjectError(ProjectError::Kind::tooLarge); + m_allocatedBounds = nextBounds; + QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask); + QPainter painter(&context); + const QRectF initialRect = sourceRect.translated(-rect.topLeft()); + if (m_source && m_source->raster) { + m_source->raster->draw(initialRect, painter); + } else if (m_source) { + // This tile's share of the source; a 1×1 stretches. + const QImage source = m_source->image(); + const QRectF overlap = rect.intersected(sourceRect); + if (source.width() > 2 && source.height() > 2 && !overlap.isEmpty()) { + const double scaleX = source.width() / sourceRect.width(), scaleY = source.height() / sourceRect.height(); + const QRectF crop = integral(QRectF((overlap.left() - sourceRect.left()) * scaleX, (overlap.top() - sourceRect.top()) * scaleY, + overlap.width() * scaleX, overlap.height() * scaleY)); + BrushRaster::draw(source, overlap.translated(-rect.topLeft()), painter, crop); + } else { + BrushRaster::draw(source, initialRect, painter); + } + } + painter.end(); + m_tiles.emplace(key, Tile{rect, context, std::nullopt, context}); +} + +std::vector<BrushPatch> BrushStroke::patches() const +{ + std::vector<BrushPatch> result; + for (const auto &[key, tile] : m_tiles) { + if (tile.image) + result.push_back({tile.rect, *tile.image}); + } + return result; +} + +// Redrawing restarts from each tile's original content. +void BrushStroke::paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw) +{ + QRectF area = canvas; + if (selectionClip) + area = area.intersected(selectionClip->rect); + if (area.isEmpty()) + return; + const QTransform inverse = pixelToDocument.inverted(); + QRectF affected = integral(inverse.mapRect(area)).intersected(QRectF(0, 0, width, height)); + if (withinSource) + affected = affected.intersected(sourceRect); + if (affected.isEmpty()) + return; + eachTile(affected, width, [&](qint64 key, qint64 x, qint64 y) { + allocateTile(key, x, y); + Tile &tile = m_tiles.at(key); + QImage drawn = m_source ? tile.base : BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), isMask); + drawn.detach(); + if (drawn.isNull()) + throw ExportError(ExportError::Kind::render); + QPainter painter(&drawn); + painter.setTransform(inverse * QTransform::fromTranslate(-tile.rect.left(), -tile.rect.top())); + painter.setClipRect(canvas); + draw(painter); + painter.end(); + // Swift clips to the selection; the twin mixes through it. + if (selectionClip) + drawn = PixelAdjust::blend(drawn, tile.base, *selectionClip, QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, isMask); + tile.context = drawn; + tile.image = drawn; + }); + m_dirtyDocumentRect = canvas; +} + +void BrushStroke::fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity) +{ + // Padded both ways, as Swift's before and after locations. + QGradient gradient = shape == GradientShape::linear + ? QGradient(QLinearGradient(start, end)) + : QGradient(QRadialGradient(start, std::hypot(end.x() - start.x(), end.y() - start.y()))); + gradient.setColorAt(0, colors[0]); + gradient.setColorAt(1, colors[1]); + paintCanvas(false, [&](QPainter &painter) { + painter.setOpacity(std::min(1.0, std::max(0.0, opacity))); + painter.fillRect(canvas, gradient); + }); +} + +void BrushStroke::fill(const QColor &color) +{ + paintCanvas(false, [&](QPainter &painter) { painter.fillRect(canvas, color); }); +} + +void BrushStroke::clearPixels() +{ + paintCanvas(true, [&](QPainter &painter) { + painter.setCompositionMode(QPainter::CompositionMode_DestinationOut); + painter.fillRect(canvas, Qt::black); + }); +} + +// Cuts the selected pixels from the source; false with none. +bool BrushStroke::liftSelection() +{ + if (isMask || !m_source || !selectionClip || !selectionClip->coverage) + return false; + const QRectF region = integral(pixelToDocument.inverted().mapRect(selectionClip->rect)).intersected(sourceRect); + if (region.width() < 1 || region.height() < 1) + return false; + QImage lifted = BrushRaster::context(int(region.width()), int(region.height()), false); + QPainter painter(&lifted); + painter.translate(-region.left(), -region.top()); + if (m_source->raster) + m_source->raster->draw(sourceRect, painter); + else + BrushRaster::draw(m_source->image(), sourceRect, painter); + painter.end(); + // Swift clips the context; the selection's coverage multiplies in. + const QImage clip = PixelAdjust::coverage(*selectionClip, lifted.width(), lifted.height(), QTransform::fromTranslate(region.left(), region.top()) * pixelToDocument); + QPainter multiplying(&lifted); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(lifted.rect()), BrushRaster::alphaView(clip)); + multiplying.end(); + m_lifted = Lifted{lifted, region}; + return true; +} + +// Tiles anew: a hole, and the pixels `offset` away. +void BrushStroke::moveLifted(QSizeF offset, bool duplicate) +{ + if (!m_lifted || !selectionClip) + return; + const QTransform inverse = pixelToDocument.inverted(); + const QPointF zero = inverse.map(QPointF(0, 0)), moved = inverse.map(QPointF(offset.width(), offset.height())); + const QRectF target = m_lifted->rect.translated(moved.x() - zero.x(), moved.y() - zero.y()); + const bool whole = target.left() == std::round(target.left()) && target.top() == std::round(target.top()); + // QPainter snaps a fraction; Swift's high quality resamples it. + const QPointF place(std::floor(target.left()), std::floor(target.top())); + const QImage lifted = whole ? m_lifted->image : BrushRaster::shiftedByFraction(m_lifted->image, target.left() - place.x(), target.top() - place.y()); + const QRectF needed = integral(m_lifted->rect.united(target)).intersected(QRectF(0, 0, width, height)); + std::set<qint64> keys = m_moveTiles; + eachTile(needed, width, [&](qint64 key, qint64 x, qint64 y) { + allocateTile(key, x, y); + keys.insert(key); + }); + for (qint64 key : keys) { + Tile &tile = m_tiles.at(key); + QImage drawn = tile.base; + drawn.detach(); + if (drawn.isNull()) + throw ExportError(ExportError::Kind::render); + QPainter painter(&drawn); + if (!duplicate) { + const QImage clip = PixelAdjust::coverage(*selectionClip, drawn.width(), drawn.height(), + QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument); + painter.setCompositionMode(QPainter::CompositionMode_DestinationOut); + painter.drawImage(QRectF(drawn.rect()), BrushRaster::alphaView(clip)); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + } + painter.drawImage(QRectF(place - tile.rect.topLeft(), lifted.size()), lifted); + painter.end(); + tile.context = drawn; + tile.image = drawn; + } + m_moveTiles = keys; + m_dirtyDocumentRect = canvas; +} diff --git a/src/Document/BrushStroke.cpp b/src/Document/BrushStroke.cpp new file mode 100644 index 0000000..a5f3d44 --- /dev/null +++ b/src/Document/BrushStroke.cpp @@ -0,0 +1,131 @@ +#include "Document/BrushStroke.h" +#include "Rendering/PoolMap.h" +#include "IO/ImageExporter.h" +#include <QtConcurrent> +#include <cmath> +#include <numeric> +#include <stdexcept> + +QString rawValue(SpotHealingMode mode) +{ + switch (mode) { + case SpotHealingMode::contentAware: + return QStringLiteral("Content-Aware"); + case SpotHealingMode::createTexture: + return QStringLiteral("Create Texture"); + case SpotHealingMode::proximityMatch: + return QStringLiteral("Proximity Match"); + } + throw std::logic_error("unknown spot healing mode"); +} + +QImage BrushRaster::context(int width, int height, bool mask) +{ + QImage surface(width, height, mask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied); + if (surface.isNull()) + throw ExportError(ExportError::Kind::render); + surface.fill(0); + return surface; +} + +void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source) +{ + context.save(); + context.setRenderHint(QPainter::SmoothPixmapTransform, false); + context.setCompositionMode(QPainter::CompositionMode_Source); + context.drawImage(rect, image, source.isNull() ? QRectF(image.rect()) : source); + context.restore(); +} + +QImage BrushRaster::alphaView(const QImage &mask) +{ + if (mask.format() != QImage::Format_Grayscale8) + throw std::logic_error("a layer mask must be 8-bit gray"); + return QImage(mask.constBits(), mask.width(), mask.height(), mask.bytesPerLine(), QImage::Format_Alpha8); +} + +QRectF BrushRaster::visibleRect(const QPainter &context) +{ + const QPaintDevice &device = *context.device(); + // A widget's size is logical; an image's is device pixels. + QTransform toDevice = context.combinedTransform(); + if (device.devType() == QInternal::Widget) + toDevice *= QTransform::fromScale(1 / device.devicePixelRatio(), 1 / device.devicePixelRatio()); + const QRectF bounds = toDevice.inverted().mapRect(QRectF(0, 0, device.width(), device.height())); + return context.hasClipping() ? bounds.intersected(context.clipBoundingRect()) : bounds; +} + +QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int width, int height) +{ + const QPointF center = transform.center(); + QTransform map; + map.translate(center.x(), center.y()); + map.rotateRadians(transform.radians()); + map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1), + transform.size.height() / height * (transform.flipY ? -1 : 1)); + map.translate(-width / 2.0, -height / 2.0); + return map; +} + +// Source-over of the colour at coverage × alpha, bytewise. +void BrushRaster::fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context, + QPainter::CompositionMode mode, const QImage &clip) +{ + if (coverage.format() != QImage::Format_Grayscale8 || coverage.size() != rect.size().toSize() || rect.topLeft() != rect.topLeft().toPoint()) + throw std::logic_error("coverage must be gray and 1:1 with its rect"); + if (!clip.isNull() && (clip.format() != QImage::Format_Grayscale8 || clip.size() != coverage.size())) + throw std::logic_error("a clip must be gray and sized as the coverage"); + // Erasing takes coverage out; a mask never erases. + const bool erasing = mode == QPainter::CompositionMode_DestinationOut; + if ((!erasing && mode != QPainter::CompositionMode_SourceOver) || (erasing && context.format() == QImage::Format_Grayscale8)) + throw std::logic_error("a brush fills source-over, or erases pixels"); + const int left = int(rect.left()), top = int(rect.top()); + const bool gray = context.format() == QImage::Format_Grayscale8; + if (!gray && context.format() != QImage::Format_RGBA8888_Premultiplied) + throw std::logic_error("a brush context is premultiplied RGBA or gray"); + // What each channel moves towards; erasing moves towards nothing. + const double target[4] = {erasing ? 0 : color.redF() * 255, erasing ? 0 : color.greenF() * 255, erasing ? 0 : color.blueF() * 255, + erasing ? 0 : 255.0}; + const int channels = gray ? 1 : 4; + for (int y = 0; y < coverage.height(); ++y) { + const uchar *mask = coverage.constScanLine(y); + // A clip scales the coverage, rounded once, as CoreGraphics composites. + const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y); + uchar *row = context.scanLine(top + y) + left * channels; + for (int x = 0; x < coverage.width(); ++x) { + const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0); + if (k <= 0) + continue; + // Values are never negative: adding a half rounds them. + uchar *pixel = row + x * channels; + for (int c = 0; c < channels; ++c) + pixel[c] = uchar(pixel[c] * (1 - k) + target[c] * k + 0.5); + } + } +} + +QImage BrushRaster::shiftedByFraction(const QImage &image, double fx, double fy) +{ + QImage shifted = BrushRaster::context(image.width() + 1, image.height() + 1, false); + // Weights in 1/65536: the pixel, left, above, above left. + const qint64 here = std::lround((1 - fx) * (1 - fy) * 65536), left = std::lround(fx * (1 - fy) * 65536); + const qint64 above = std::lround((1 - fx) * fy * 65536), corner = 65536 - here - left - above; + uchar *const out = shifted.bits(); + const uchar *const in = image.constBits(); + const qsizetype outStride = shifted.bytesPerLine(), inStride = image.bytesPerLine(); + const int width = image.width(), height = image.height(); + std::vector<int> rows(size_t(shifted.height())); + std::iota(rows.begin(), rows.end(), 0); + PoolMap::blocking(rows, [&](int y) { + const uchar *row = y < height ? in + y * inStride : nullptr, *up = y > 0 ? in + (y - 1) * inStride : nullptr; + uchar *line = out + y * outStride; + for (int x = 0; x <= width; ++x) { + for (int c = 0; c < 4; ++c) { + const qint64 sum = (row && x < width ? here * row[x * 4 + c] : 0) + (row && x > 0 ? left * row[(x - 1) * 4 + c] : 0) + + (up && x < width ? above * up[x * 4 + c] : 0) + (up && x > 0 ? corner * up[(x - 1) * 4 + c] : 0); + line[x * 4 + c] = uchar((sum + 32768) >> 16); + } + } + }); + return shifted; +} diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h new file mode 100644 index 0000000..4362adf --- /dev/null +++ b/src/Document/BrushStroke.h @@ -0,0 +1,190 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include "Document/Gradient.h" +#include "Document/LayerTransform.h" +#include "Document/Selection.h" +#include "IO/ImageImporter.h" +#include "Rendering/BrushCoverage.h" +#include <QImage> +#include <QPainter> +#include <array> +#include <functional> +#include <map> +#include <set> + +struct BrushPatch { + QRectF rect; + QImage image; +}; + +// Swift's three ways of healing a spot, in its order. +enum class SpotHealingMode { contentAware, createTexture, proximityMatch }; +inline constexpr std::array allSpotHealingModes{SpotHealingMode::contentAware, SpotHealingMode::createTexture, SpotHealingMode::proximityMatch}; +QString rawValue(SpotHealingMode mode); + +// Tip, colour, and what the stroke does with them. +struct BrushSettings { + double diameter = 40; + double hardness = 1; + double red = 0; + double green = 0; + double blue = 0; + // Caps the whole stroke: overlapping dabs never exceed it. + double opacity = 1; + // Erase clears the layer's pixels instead of painting on them. + bool erasing = false; + // Spot healing takes nearby pixels instead of the colour. + bool healing = false; + SpotHealingMode healingMode = SpotHealingMode::contentAware; +}; + +// Shared raster drawing; coverage is copied without color conversion. +namespace BrushRaster { +QImage context(int width, int height, bool mask); +// Copies `image`, or its `source` part, into `rect`. +void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF()); +// A solid colour through gray coverage, and a clip, 1:1. +void fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context, + QPainter::CompositionMode mode = QPainter::CompositionMode_SourceOver, const QImage &clip = QImage()); +// Gray mask bytes read as alpha, without a copy. +QImage alphaView(const QImage &mask); +// CoreGraphics boundingBoxOfClipPath: the clip within the device. +QRectF visibleRect(const QPainter &context); +// Premultiplied pixels shifted right and down by fractions, bilinear. +QImage shiftedByFraction(const QImage &image, double fx, double fy); +QTransform pixelToDocument(const LayerTransform &transform, int width, int height); +} + +// A stroke's pixels as a painted layer, placed and bounded. +struct PaintSnapshot { + ImportedImage asset; + LayerTransform transform; + QRectF bounds; +}; + +// Swift's BrushCommit actor: the stroke's tiles made one image. +namespace BrushCommit { +struct Input { + int width; + int height; + std::optional<ImportedImage> source; + std::vector<BrushPatch> patches; + bool mask; + QString name; + QRectF sourceRect; +}; +struct Output { + ImportedImage asset; + QRectF pixelBounds; +}; +ImportedImage expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop); +Output render(const Input &input); +} + +// Only touched 256 px tiles hold pixels; snapshots copy those. +class BrushStroke { +public: + BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas); + + const ImageLayer layer; + const bool isMask; + const BrushSettings settings; + const QRectF canvas; + const int width; + const int height; + const QRectF sourceRect; + const QTransform pixelToDocument; + const LayerTransform paintTransform; + // Swift's Int: the budget left may run below zero. + qint64 pixelLimit = 100'000'000; + // Limits every edit to the document selection; nil when none. + std::optional<SelectionClip> selectionClip; + // Clone Stamp: a document-sized image, and each point's offset. + struct Clone { + QImage image; + QSizeF offset; + }; + std::optional<Clone> clone; + // Blur: the clone is the layer softened, in place. + bool isBlur = false; + // The clone replaces what is under the tip, clearing too. + bool replacesWithClone = false; + // The undo name when the stroke's kind says none. + std::optional<QString> editName; + static constexpr int tileSize = 256; + // Soft-tip deposition rate, shared with the continuous integral. + static double spacingFraction(double hardness); + + // A sample; the newest piece shows as a straight tail. + void append(QPointF point); + // Replaces the tail with the curve's last piece; repeatable. + void flush(); + std::vector<BrushPatch> patches() const; + const std::optional<QRectF> &dirtyDocumentRect() const { return m_dirtyDocumentRect; } + // The selection, or the canvas, filled with one colour. + void fill(const QColor &color); + // Swift's fillGradient: across the canvas, linear or radial. + void fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity); + // Pixels inside the selection made transparent, where pixels are. + void clearPixels(); + // Cuts the selected pixels from the source; false with none. + bool liftSelection(); + // The tiles anew: a hole, and the pixels `offset` away. + void moveLifted(QSizeF offset, bool duplicate); + // A placed mask as the stroke leaves it (LayerMask.cpp). + std::optional<QImage> placedMaskPreview(const LayerTransform &placement) const; + QRectF committedBounds() const; + LayerTransform committedTransform() const; + LayerTransform transform(const QRectF &bounds) const; + // Spot Healing's end: the painted spot rebuilt from around it. + void heal(); + PaintSnapshot paintSnapshot() const; + BrushCommit::Input commitInput() const; + +private: + struct Tile { + QRectF rect; + QImage context; + std::optional<QImage> image; + QImage base; + }; + struct Lifted { + QImage image; + QRectF rect; + }; + // The grid the stroke paints in, made before the members. + struct Grid { + int width; + int height; + QRectF sourceRect; + QTransform pixelToDocument; + LayerTransform paintTransform; + }; + static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas); + BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid); + std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const; + std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const; + void renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail); + void publish(const std::set<qint64> &changed); + QImage selectionCoverage(const Tile &tile) const; + QImage clonePixels(const Tile &tile) const; + void allocateTile(qint64 key, qint64 x, qint64 y); + // `draw` in document coordinates over every tile the canvas covers. + void paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw); + + // The pixels the stroke starts from: mask's or layer's. + const std::optional<ImportedImage> m_source; + const QColor m_paintColor; + std::optional<QRectF> m_allocatedBounds; + std::vector<QPointF> m_samples; + std::optional<QRectF> m_dirtyDocumentRect; + // Tile keys, row × columns + column, pass int. + std::map<qint64, Tile> m_tiles; + std::map<qint64, BrushCoverage::Tile> m_coverageTiles; + std::set<qint64> m_tailKeys; + // Per-tile gray coverage; the tile is recomposed from it. + std::map<qint64, QImage> m_coverage; + // Selected pixels cut from the source, in layer pixels. + std::optional<Lifted> m_lifted; + std::set<qint64> m_moveTiles; +}; diff --git a/src/Document/CanvasSize.cpp b/src/Document/CanvasSize.cpp new file mode 100644 index 0000000..d21309a --- /dev/null +++ b/src/Document/CanvasSize.cpp @@ -0,0 +1,75 @@ +#include "Document/CanvasSize.h" +#include <cmath> + +QString rawValue(CanvasUnit unit) +{ + switch (unit) { + case CanvasUnit::pixels: + return QStringLiteral("Pixels"); + case CanvasUnit::percent: + return QStringLiteral("Percent"); + case CanvasUnit::inches: + return QStringLiteral("Inches"); + case CanvasUnit::centimeters: + return QStringLiteral("Centimeters"); + } + Q_UNREACHABLE(); +} + +CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution) + : originalWidth(width), originalHeight(height), resolution(resolution), width(double(width)), height(double(height)) +{ +} + +bool CanvasSizeDraft::valid() const +{ + // NaN and infinity fail both range tests. + return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000; +} + +double CanvasSizeDraft::displayed(bool widthAxis) const +{ + const double original = double(widthAxis ? originalWidth : originalHeight); + const double pixels = (widthAxis ? width : height) - (relative ? original : 0); + switch (unit) { + case CanvasUnit::pixels: + return pixels; + case CanvasUnit::percent: + return pixels / original * 100; + case CanvasUnit::inches: + return pixels / resolution; + case CanvasUnit::centimeters: + return pixels / resolution * 2.54; + } + Q_UNREACHABLE(); +} + +void CanvasSizeDraft::set(double value, bool widthAxis) +{ + const double original = double(widthAxis ? originalWidth : originalHeight); + double pixels = value; + if (unit == CanvasUnit::percent) + pixels = value / 100 * original; + else if (unit == CanvasUnit::inches) + pixels = value * resolution; + else if (unit == CanvasUnit::centimeters) + pixels = value / 2.54 * resolution; + const double total = pixels + (relative ? original : 0); + if (widthAxis) { + width = total; + if (locked) + height = total * double(originalHeight) / double(originalWidth); + } else { + height = total; + if (locked) + width = total * double(originalWidth) / double(originalHeight); + } +} + +QPointF CanvasSizeOptions::offset(qint64 fromWidth, qint64 oldHeight) const +{ + if (contentOffset) + return *contentOffset; + // Floor: growth lands right and bottom, cuts left and top. + return QPointF(std::floor(double(width - fromWidth) * double(anchor % 3) / 2), std::floor(double(height - oldHeight) * double(anchor / 3) / 2)); +} diff --git a/src/Document/CanvasSize.h b/src/Document/CanvasSize.h new file mode 100644 index 0000000..f332268 --- /dev/null +++ b/src/Document/CanvasSize.h @@ -0,0 +1,45 @@ +#pragma once +#include <QPointF> +#include <QString> +#include <QtGlobal> +#include <optional> + +enum class CanvasUnit { pixels, percent, inches, centimeters }; +// The name users see. +QString rawValue(CanvasUnit unit); + +// What the canvas size sheet edits, in final pixels. +struct CanvasSizeDraft { + CanvasSizeDraft(qint64 width, qint64 height, double resolution); + + qint64 originalWidth; + qint64 originalHeight; + double resolution; + double width; + double height; + bool relative = false; + bool locked = false; + CanvasUnit unit = CanvasUnit::pixels; + + bool valid() const; + double displayed(bool widthAxis) const; + void set(double value, bool widthAxis); +}; + +struct CanvasExtensionColor { + double red; + double green; + double blue; +}; + +struct CanvasSizeOptions { + qint64 width; + qint64 height; + // Row-major, top-left through bottom-right. + qint64 anchor = 4; + std::optional<CanvasExtensionColor> fill = std::nullopt; + // Crop supplies its own translation of the content. + std::optional<QPointF> contentOffset = std::nullopt; + + QPointF offset(qint64 fromWidth, qint64 oldHeight) const; +}; diff --git a/src/Document/CloneStamp+Session.h b/src/Document/CloneStamp+Session.h new file mode 100644 index 0000000..b7bbaec --- /dev/null +++ b/src/Document/CloneStamp+Session.h @@ -0,0 +1,10 @@ +// Swift's CloneStamp extension: where Clone Stamp copies from. +public: + // Alt-click: the source; a new source starts a new alignment. + void setCloneSource(QPointF point); + // The whole-pixel offset a stroke from `point` copies with. + std::optional<QSizeF> cloneStrokeOffset(QPointF point) const; + // The source for a brush at `point`: the canvas's crosshair. + std::optional<QPointF> cloneSamplePoint(QPointF point) const; + // What a stroke copies from, document-sized, taken as it starts. + std::optional<QImage> cloneSample(const CanvasDocument &document) const; diff --git a/src/Document/CloneStamp.cpp b/src/Document/CloneStamp.cpp new file mode 100644 index 0000000..28c8daa --- /dev/null +++ b/src/Document/CloneStamp.cpp @@ -0,0 +1,61 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <cmath> + +// Swift's CloneStamp extension: the source, its offset, the sample. +void EditorSession::setCloneSource(QPointF point) +{ + if (!std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + m_cloneSource = point; + m_cloneOffset = std::nullopt; + notify(); +} + +void EditorSession::setCloneSettings(const CloneSettings &settings) +{ + m_cloneSettings = settings; + notify(); +} + +// Shared by stroke and preview: what a click stamps. +std::optional<QSizeF> EditorSession::cloneStrokeOffset(QPointF point) const +{ + if (!m_cloneSource) + return std::nullopt; + if (m_cloneSettings.aligned && m_cloneOffset) + return m_cloneOffset; + return QSizeF(std::round(m_cloneSource->x() - point.x()), std::round(m_cloneSource->y() - point.y())); +} + +// The source itself until a stroke fixes the offset. +std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const +{ + if (!m_cloneSource) + return std::nullopt; + if (!m_cloneOffset || (!m_cloneSettings.aligned && !m_brushStroke)) + return m_cloneSource; + return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height()); +} + +std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const +{ + try { + QImage context = BrushRaster::context(document.width, document.height, false); + QPainter painter(&context); + // A painted asset flattens here; the catch covers it too. + if (m_cloneSettings.sampleAllLayers) { + drawLiveComposite(document, painter); + } else if (const std::optional<ImageLayer> layer = activeLayer(); layer && layer->asset) { + const LayerTransform transform = displayedTransform(*layer); + LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {}); + } + painter.end(); + return context; + } catch (const ExportError &error) { + qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what(); + return std::nullopt; + } +} diff --git a/src/Document/CloneStamp.h b/src/Document/CloneStamp.h new file mode 100644 index 0000000..1d2c23d --- /dev/null +++ b/src/Document/CloneStamp.h @@ -0,0 +1,10 @@ +#pragma once + +// Clone Stamp's options-bar settings. +struct CloneSettings { + // The source follows the brush, keeping its offset between strokes. + bool aligned = true; + // Every visible layer as shown, not the active one alone. + bool sampleAllLayers = false; + friend bool operator==(const CloneSettings &, const CloneSettings &) = default; +}; diff --git a/src/Document/ColorPalette+Session.h b/src/Document/ColorPalette+Session.h new file mode 100644 index 0000000..5732773 --- /dev/null +++ b/src/Document/ColorPalette+Session.h @@ -0,0 +1,32 @@ +// Swift's ColorPalette extension: foreground and background colours. +public: + // The brush's own colour, as Swift's computed property. + PaletteColor foregroundColor() const; + void setForegroundColor(const PaletteColor &color); + bool canEditPalette() const; + // A mask's palette is black and white: its paint. + PaletteColor paletteColor(bool background) const; + void setPaletteColor(const PaletteColor &color, bool background); + void swapPaletteColors(); + void resetPaletteColors(); + void openColorPicker(bool background); + // The Type bar's swatch: open text's colour, else the foreground. + PaletteColor typeColor() const; + void openTextColorPicker(); + void closeColorPicker(bool commit); + // The picker on an end of the open Gradient Map. + void openGradientMapColorPicker(bool highlights); + // The Gradient Map follows the picker's working colour. + void previewGradientMapColor(); + // The picker on an effect of the open panel. + void openEffectColorPicker(LayerEffectKind kind); + // The effect follows the picker's working colour. + void previewEffectColor(); + // Swift writes the picker's hsb in place; here, the session. + void setColorPickerHSB(const PickerHSB &hsb); + void sampleIntoColorPicker(QPointF point); + // The visible layers' sRGB at a pixel; none where clear. + std::optional<PaletteColor> sampleCompositeColor(QPointF point) const; + +private: + void setGradientMapColor(const PaletteColor &color, bool highlights); diff --git a/src/Document/ColorPalette.cpp b/src/Document/ColorPalette.cpp new file mode 100644 index 0000000..c97dffa --- /dev/null +++ b/src/Document/ColorPalette.cpp @@ -0,0 +1,338 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QPainter> +#include <algorithm> +#include <cmath> + +PaletteColor PaletteColor::quantized() const +{ + const auto snap = [](double value) { return std::round(value * 255) / 255; }; + return {snap(red), snap(green), snap(blue)}; +} + +QString PaletteColor::hex() const +{ + const auto byte = [](double value) { return int(std::round(value * 255)); }; + return QString::asprintf("%02X%02X%02X", byte(red), byte(green), byte(blue)); +} + +std::optional<PaletteColor> PaletteColor::fromHex(const QString &hex) +{ + // Swift trims spaces and tabs, not line breaks. + const auto blank = [](QChar character) { return character.category() == QChar::Separator_Space || character == u'\t'; }; + qsizetype start = 0, end = hex.size(); + while (start < end && blank(hex.at(start))) + ++start; + while (end > start && blank(hex.at(end - 1))) + --end; + QString text = hex.mid(start, end - start); + if (text.startsWith(u'#')) + text.remove(0, 1); + if (text.size() == 3) + text = QString(text.at(0)) + text.at(0) + text.at(1) + text.at(1) + text.at(2) + text.at(2); + if (text.size() != 6) + return std::nullopt; + // Swift's UInt32(_:radix:): a sign may lead; minus holds zero alone. + const bool negative = text.front() == u'-'; + uint value = 0; + for (const QChar character : QStringView(text).mid(negative || text.front() == u'+' ? 1 : 0)) { + const char16_t unit = character.unicode(); + const int digit = unit >= u'0' && unit <= u'9' ? unit - u'0' : unit >= u'a' && unit <= u'f' ? unit - u'a' + 10 : unit >= u'A' && unit <= u'F' ? unit - u'A' + 10 : -1; + if (digit < 0) + return std::nullopt; + value = value * 16 + uint(digit); + } + if (negative && value != 0) + return std::nullopt; + return PaletteColor{double((value >> 16) & 0xFF) / 255, double((value >> 8) & 0xFF) / 255, double(value & 0xFF) / 255}; +} + +PickerHSB::PickerHSB(const PaletteColor &color) : hue(0), saturation(0), brightness(0) +{ + setRGB(color); +} + +PaletteColor PickerHSB::rgb() const +{ + const double h = std::fmod(std::fmod(hue, 360) + 360, 360) / 60; + const double c = brightness * saturation; + const double x = c * (1 - std::abs(std::fmod(h, 2) - 1)); + const double m = brightness - c; + switch (int(h)) { + case 0: + return {c + m, x + m, m}; + case 1: + return {x + m, c + m, m}; + case 2: + return {m, c + m, x + m}; + case 3: + return {m, x + m, c + m}; + case 4: + return {x + m, m, c + m}; + default: + return {c + m, m, x + m}; + } +} + +void PickerHSB::setRGB(const PaletteColor &color) +{ + const double high = std::max({color.red, color.green, color.blue}); + const double low = std::min({color.red, color.green, color.blue}); + const double delta = high - low; + brightness = high; + if (high > 0) + saturation = delta / high; + if (delta <= 0) + return; + double h; + if (high == color.red) + h = (color.green - color.blue) / delta; + else if (high == color.green) + h = (color.blue - color.red) / delta + 2; + else + h = (color.red - color.green) / delta + 4; + h *= 60; + hue = h < 0 ? h + 360 : h; +} + +QString ColorPickerTarget::title() const +{ + if (kind == Kind::text) + return QStringLiteral("Color Picker (Text Color)"); + if (kind == Kind::gradientMap) + return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)"); + if (kind == Kind::effect) + return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect)); + return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)"); +} + +// Swift's ColorPalette extension: the palette and its picker. +PaletteColor EditorSession::foregroundColor() const +{ + return {m_brushSettings.red, m_brushSettings.green, m_brushSettings.blue}; +} + +void EditorSession::setForegroundColor(const PaletteColor &color) +{ + BrushSettings settings = m_brushSettings; + settings.red = color.red; + settings.green = color.green; + settings.blue = color.blue; + setBrushSettings(settings); +} + +void EditorSession::setBackgroundColor(const PaletteColor &color) +{ + m_backgroundColor = color; + refreshGradient(); + notify(); +} + +bool EditorSession::canEditPalette() const +{ + return !m_isProjectBusy && !m_brushStroke; +} + +PaletteColor EditorSession::paletteColor(bool background) const +{ + if (m_isMaskSelected) + return (background ? !m_maskPaintWhite : m_maskPaintWhite) ? PaletteColor::white() : PaletteColor::black(); + return background ? m_backgroundColor : foregroundColor(); +} + +void EditorSession::setPaletteColor(const PaletteColor &color, bool background) +{ + if (!canEditPalette()) + return; + if (m_isMaskSelected) { + const bool white = color == PaletteColor::white(); + setMaskPaintWhite(background ? !white : white); + } else if (background) { + setBackgroundColor(color); + } else { + setForegroundColor(color); + // Open text follows the swatch; drafts live under Type. + if (m_textDraft) { + changeTextStyle([&color](LayerTextStyle &style) { + style.red = color.red; + style.green = color.green; + style.blue = color.blue; + }); + } + } +} + +void EditorSession::swapPaletteColors() +{ + if (!canEditPalette()) + return; + if (m_isMaskSelected) { + setMaskPaintWhite(!m_maskPaintWhite); + } else { + const PaletteColor old = foregroundColor(); + setPaletteColor(m_backgroundColor, false); + setBackgroundColor(old); + } +} + +void EditorSession::resetPaletteColors() +{ + if (!canEditPalette()) + return; + if (m_isMaskSelected) { + setMaskPaintWhite(false); + } else { + setPaletteColor(PaletteColor::black(), false); + setBackgroundColor(PaletteColor::white()); + } +} + +void EditorSession::openColorPicker(bool background) +{ + if (!canEditPalette() || m_isMaskSelected) + return; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background)); + notify(); +} + +PaletteColor EditorSession::typeColor() const +{ + if (!m_textDraft) + return foregroundColor(); + return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue}; +} + +void EditorSession::openTextColorPicker() +{ + if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type) + return; + const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor()); + notify(); +} + +void EditorSession::closeColorPicker(bool commit) +{ + if (!m_colorPicker) + return; + // Closed first, so each signal sees it gone. + const ColorPickerState picker = *std::exchange(m_colorPicker, std::nullopt); + const PaletteColor color = picker.color(); + const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt; + if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) { + setPaletteColor(color, picker.target.background); + } else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) { + if (draftID) { + changeTextStyle([&color](LayerTextStyle &style) { + style.red = color.red; + style.green = color.green; + style.blue = color.blue; + }); + } else { + m_textDefaults.red = color.red; + m_textDefaults.green = color.green; + m_textDefaults.blue = color.blue; + } + // The text colour is the foreground: the swatch follows. + if (!m_isMaskSelected) + setForegroundColor(color); + } else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) { + // The end previewed the working colour; Cancel puts it back. + setGradientMapColor(commit ? color : picker.original, picker.target.highlights); + } else if (picker.target.kind == ColorPickerTarget::Kind::effect) { + const PaletteColor chosen = commit ? color : picker.original; + changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); }); + } + notify(); +} + +void EditorSession::openGradientMapColorPicker(bool highlights) +{ + // A commit keeps the project busy: no `committing` term. + if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap) + return; + const AdjustmentColor value = highlights ? m_filterEdit->settings.gradientMap.highlights : m_filterEdit->settings.gradientMap.shadows; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::gradientMap, false, std::nullopt, highlights}, PaletteColor{value.red, value.green, value.blue}); + notify(); +} + +void EditorSession::openEffectColorPicker(LayerEffectKind kind) +{ + if (!canEditPalette() || m_colorPicker || !m_effectsEditing) + return; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::effect, false, std::nullopt, false, kind}, editingEffects().color(kind).value_or(PaletteColor::black())); + notify(); +} + +void EditorSession::previewEffectColor() +{ + if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect) + return; + const PaletteColor color = m_colorPicker->color(); + const LayerEffectKind kind = m_colorPicker->target.effect; + changeEffects([&](LayerEffects &effects) { effects.setColor(color, kind); }); +} + +void EditorSession::previewGradientMapColor() +{ + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) + setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights); +} + +void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights) +{ + // An adjustment's editor can close under its picker (9.5c). + if (!m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap) + return; + const FilterEdit &edit = *m_filterEdit; + FilterSettings settings = edit.settings; + (highlights ? settings.gradientMap.highlights : settings.gradientMap.shadows) = AdjustmentColor(color); + if (settings == edit.settings) + return; + updateFilter(settings, edit.preview); +} + +void EditorSession::setColorPickerHSB(const PickerHSB &hsb) +{ + if (!m_colorPicker || m_colorPicker->hsb == hsb) + return; + m_colorPicker->hsb = hsb; + notify(); +} + +void EditorSession::sampleIntoColorPicker(QPointF point) +{ + if (!m_colorPicker) + return; + const std::optional<PaletteColor> color = sampleCompositeColor(point); + if (!color) + return; + PickerHSB hsb = m_colorPicker->hsb; + hsb.setRGB(*color); + setColorPickerHSB(hsb); +} + +std::optional<PaletteColor> EditorSession::sampleCompositeColor(QPointF point) const +{ + if (!m_document || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->width && point.y() < m_document->height)) + return std::nullopt; + try { + // The pixel under the point, drawn as the canvas shows. + QImage pixel = BrushRaster::context(1, 1, false); + QPainter painter(&pixel); + painter.translate(-std::floor(point.x()), -std::floor(point.y())); + drawLiveComposite(*m_document, painter); + painter.end(); + const uchar *bytes = pixel.constBits(); + const int alpha = bytes[3]; + if (alpha == 0) + return std::nullopt; + const auto channel = [alpha](int value) { return std::round(double(std::min(alpha, value)) / alpha * 255) / 255; }; + return PaletteColor{channel(bytes[0]), channel(bytes[1]), channel(bytes[2])}; + } catch (const ExportError &error) { + qCWarning(lcApp) << "the colour under the pointer cannot be read:" << error.what(); + return std::nullopt; + } +} diff --git a/src/Document/ColorPalette.h b/src/Document/ColorPalette.h new file mode 100644 index 0000000..e0ff6a5 --- /dev/null +++ b/src/Document/ColorPalette.h @@ -0,0 +1,61 @@ +#pragma once +#include <QColor> +#include <QString> +#include <QUuid> +#include <optional> + +// Swift's PaletteColor: an sRGB colour without alpha. +struct PaletteColor { + double red; + double green; + double blue; + static constexpr PaletteColor black() { return {0, 0, 0}; } + static constexpr PaletteColor white() { return {1, 1, 1}; } + QColor color(double alpha = 1) const { return QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)); } + // The 8-bit values painting and export store. + PaletteColor quantized() const; + QString hex() const; + // Swift's init?(hex:): RRGGBB or RGB, with or without #. + static std::optional<PaletteColor> fromHex(const QString &text); + friend bool operator==(const PaletteColor &, const PaletteColor &) = default; +}; + +// The picker's own truth, so hue survives grays and black. +struct PickerHSB { + PickerHSB(double hue, double saturation, double brightness) : hue(hue), saturation(saturation), brightness(brightness) {} + explicit PickerHSB(const PaletteColor &color); + double hue; + double saturation; + double brightness; + PaletteColor rgb() const; + // Grays keep the hue, black the saturation, as Photoshop's. + void setRGB(const PaletteColor &color); + friend bool operator==(const PickerHSB &, const PickerHSB &) = default; +}; + +enum class LayerEffectKind; + +// The picker's targets: palette, text, effect, Gradient Map end. +struct ColorPickerTarget { + enum class Kind { palette, text, gradientMap, effect }; + Kind kind; + bool background = false; + std::optional<QUuid> draftID = std::nullopt; + // A Gradient Map's end: highlights, else shadows. + bool highlights = false; + // An effect's colour: which effect. + LayerEffectKind effect{}; + QString title() const; + friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default; +}; + +// The open picker's working colour: nothing is written until OK. +struct ColorPickerState { + ColorPickerState(const ColorPickerTarget &target, const PaletteColor &original) : target(target), original(original), hsb(original) {} + ColorPickerTarget target; + PaletteColor original; + PickerHSB hsb; + // Swift's object identity: a new picker opens its panel anew. + QUuid id = QUuid::createUuid(); + PaletteColor color() const { return hsb.rgb().quantized(); } +}; diff --git a/src/Document/ContentFill.cpp b/src/Document/ContentFill.cpp new file mode 100644 index 0000000..02a973e --- /dev/null +++ b/src/Document/ContentFill.cpp @@ -0,0 +1,33 @@ +#include "Document/ContentFill.h" +#include "Document/BrushStroke.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" + +// The kernel shares this file's name: angle brackets skip here. +extern "C" { +#include <ContentFill.h> +} + +ContentFillError::ContentFillError(Kind kind) + : std::runtime_error("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image."), kind(kind) +{ +} + +QImage ContentFill::run(const FilterJob &job) +{ + if (!job.selection) + throw ContentFillError(ContentFillError::Kind::noSource); + const int width = job.image.width(), height = job.image.height(); + QImage pixels = BrushRaster::context(width, height, false); + QPainter painter(&pixels); + BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter); + painter.end(); + // White through the selection: the pixels to make. + const QImage mask = PixelAdjust::coverage(*job.selection, width, height, job.mapping); + const int result = content_fill(pixels.bits(), size_t(pixels.bytesPerLine()), mask.constBits(), size_t(mask.bytesPerLine()), width, height); + if (result == 0) + throw ContentFillError(ContentFillError::Kind::noSource); + if (result != 1) + throw ExportError(ExportError::Kind::render); + return pixels; +} diff --git a/src/Document/ContentFill.h b/src/Document/ContentFill.h new file mode 100644 index 0000000..f4d0bd7 --- /dev/null +++ b/src/Document/ContentFill.h @@ -0,0 +1,16 @@ +#pragma once +#include "Document/Filters.h" +#include <stdexcept> + +// Swift's ContentFill.Failure. +class ContentFillError : public std::runtime_error { +public: + enum class Kind { noSource }; + explicit ContentFillError(Kind kind); + const Kind kind; +}; + +// Content-Aware Fill: the selection made from the pixels around it. +namespace ContentFill { +QImage run(const FilterJob &job); +} diff --git a/src/Document/Crop+Session.h b/src/Document/Crop+Session.h new file mode 100644 index 0000000..7ea3373 --- /dev/null +++ b/src/Document/Crop+Session.h @@ -0,0 +1,15 @@ +// Swift's Crop extension: snapping, the frame, the crop itself. +public: + SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const; + LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance); + SnapGuides cropSnapTargets() const; + // The Crop tool's frame: the canvas until one is drawn. + std::optional<QRectF> visibleCropRect() const; + std::optional<double> cropRatio() const; + void cancelCrop(); + void changeCropRatio(); + // Off the UI thread; `done` follows the step or refusal. + void commitCrop(std::function<void()> done = {}); + +private: + void finishCropCommit(); diff --git a/src/Document/Crop.cpp b/src/Document/Crop.cpp new file mode 100644 index 0000000..980f3cb --- /dev/null +++ b/src/Document/Crop.cpp @@ -0,0 +1,293 @@ +#include "Document/Crop.h" +#include "Document/Distort.h" +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include <QtConcurrent> +#include <cmath> +#include <new> + +QRectF CropGeometry::snapped(const QRectF &rect) +{ + const QRectF standard = rect.normalized(); + const double x = std::round(standard.left()), y = std::round(standard.top()); + return QRectF(x, y, std::max(1.0, std::round(standard.right()) - x), std::max(1.0, std::round(standard.bottom()) - y)); +} + +// Swift's isFinite terms are the ranges' own: NaN passes none. +bool CropGeometry::valid(const QRectF &rect) +{ + const QRectF standard = rect.normalized(); + return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000 + && std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000; +} + +QRectF CropGeometry::create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric) +{ + double dx = end.x() - start.x(), dy = end.y() - start.y(); + if (ratio) { + if (std::abs(dx) > std::abs(dy) * *ratio) + dy = (dy < 0 ? -1 : 1) * std::abs(dx) / *ratio; + else + dx = (dx < 0 ? -1 : 1) * std::abs(dy) * *ratio; + } + if (symmetric) + return snapped(QRectF(start.x() - std::abs(dx), start.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2)); + return snapped(QRectF(std::min(start.x(), start.x() + dx), std::min(start.y(), start.y() + dy), std::abs(dx), std::abs(dy))); +} + +QRectF CropDrag::updated(QPointF point, std::optional<double> ratio, bool symmetric) const +{ + switch (mode.kind) { + case Kind::create: return CropGeometry::create(start, point, ratio, symmetric); + case Kind::move: return CropGeometry::snapped(original.translated(point - start)); + case Kind::resize: { + const TransformDrag drag{.original = LayerTransform{.origin = original.topLeft(), .size = original.size()}, + .start = start, + .mode = {TransformDrag::Kind::resize, mode.index}, + .originalCorners = std::nullopt}; + const LayerTransform next = drag.updated(point, ratio.has_value(), false, symmetric); + return CropGeometry::snapped(QRectF(next.origin, next.size)); + } + } + throw std::logic_error("unknown crop drag"); +} + +namespace { +std::optional<double> nearest(double value, const std::vector<double> &targets, double tolerance) +{ + std::optional<double> best; + for (const double target : targets) { + if (std::abs(target - value) > tolerance || (best && std::abs(*best - value) <= std::abs(target - value))) + continue; + best = target; + } + return best; +} + +// The smallest shift that lands an edge on a target. +double shift(std::initializer_list<double> edges, const std::vector<double> &targets, double tolerance) +{ + std::optional<double> best; + for (const double edge : edges) { + const std::optional<double> target = nearest(edge, targets, tolerance); + if (target && (!best || std::abs(*target - edge) < std::abs(*best))) + best = *target - edge; + } + return best.value_or(0); +} +} + +// Moves snap their closest edges; the rest, the dragged side's. +QRectF CropSnap::apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric) const +{ + if (!(tolerance > 0)) + return rect; + bool horizontal = true, vertical = true; + switch (drag.mode.kind) { + case CropDrag::Kind::move: return rect.translated(shift({rect.left(), rect.right()}, xs, tolerance), shift({rect.top(), rect.bottom()}, ys, tolerance)); + // A fixed ratio snaps moves alone, so it stays exact. + case CropDrag::Kind::create: + if (ratio) + return rect; + break; + case CropDrag::Kind::resize: + if (ratio) + return rect; + horizontal = LayerTransform::handles[size_t(drag.mode.index)].x() != 0.5; + vertical = LayerTransform::handles[size_t(drag.mode.index)].y() != 0.5; + break; + } + QRectF result = rect; + if (horizontal) { + if (std::abs(point.x() - result.left()) <= std::abs(point.x() - result.right())) { + if (const std::optional<double> x = nearest(result.left(), xs, tolerance); x && *x < result.right()) + result = QRectF(*x, result.top(), result.right() - *x, result.height()); + } else if (const std::optional<double> x = nearest(result.right(), xs, tolerance); x && *x > result.left()) { + result.setWidth(*x - result.left()); + } + } + if (vertical) { + if (std::abs(point.y() - result.top()) <= std::abs(point.y() - result.bottom())) { + if (const std::optional<double> y = nearest(result.top(), ys, tolerance); y && *y < result.bottom()) + result = QRectF(result.left(), *y, result.width(), result.bottom() - *y); + } else if (const std::optional<double> y = nearest(result.bottom(), ys, tolerance); y && *y > result.top()) { + result.setHeight(*y - result.top()); + } + } + if (symmetric) { + // The snapped edge sets the half; its opposite mirrors it. + const QPointF centre = drag.mode.kind == CropDrag::Kind::create ? drag.start : drag.original.center(); + if (horizontal) { + const double half = point.x() >= centre.x() ? result.right() - centre.x() : centre.x() - result.left(); + if (half >= 0.5) + result = QRectF(centre.x() - half, result.top(), half * 2, result.height()); + } + if (vertical) { + const double half = point.y() >= centre.y() ? result.bottom() - centre.y() : centre.y() - result.top(); + if (half >= 0.5) + result = QRectF(result.left(), centre.y() - half, result.width(), half * 2); + } + } + return result; +} + +namespace { +QRectF boxAround(const LayerTransform &transform) +{ + const Corners corners = DistortWarp::corners(transform); + QPointF low = corners[0], high = corners[0]; + for (const QPointF corner : corners) { + low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())}; + high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())}; + } + return QRectF(low, high); +} +} + +// The canvas's edges and middle, and every other visible layer's. +SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const +{ + SnapGuides targets; + if (!m_document) + return targets; + const QSizeF size = m_document->size(); + targets.xs = {0, size.width() / 2, size.width()}; + targets.ys = {0, size.height() / 2, size.height()}; + for (const ImageLayer &layer : m_document->renderLayers()) { + if (!layer.asset || moving.contains(layer.id)) + continue; + const QRectF box = boxAround(displayedTransform(layer)); + targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.center().x()), std::round(box.right())}); + targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.center().y()), std::round(box.bottom())}); + } + return targets; +} + +// `draft` nudged onto a nearby edge or middle, within `tolerance`. +LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance) +{ + const SnapGuides targets = transformSnapTargets(moving); + const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance); + snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}}; + if (snap.offset.isNull()) + return draft; + LayerTransform snapped = draft; + snapped.origin += QPointF(snap.offset.width(), snap.offset.height()); + return snapped; +} + +// The canvas's edges and every visible layer's upright bounds. +SnapGuides EditorSession::cropSnapTargets() const +{ + SnapGuides targets; + if (!m_document) + return targets; + const QSizeF size = m_document->size(); + targets.xs = {0, size.width()}; + targets.ys = {0, size.height()}; + for (const ImageLayer &layer : m_document->renderLayers()) { + if (!layer.asset) + continue; + const QRectF box = boxAround(displayedTransform(layer)); + targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.right())}); + targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.bottom())}); + } + return targets; +} + +std::optional<QRectF> EditorSession::visibleCropRect() const +{ + if (m_tool != NavigationTool::crop || !m_document) + return std::nullopt; + return m_cropRect ? m_cropRect : QRectF(QPointF(0, 0), m_document->size()); +} + +std::optional<double> EditorSession::cropRatio() const +{ + if (m_cropRatioChoice == QStringLiteral("Original")) + return m_document ? std::optional(double(m_document->width) / double(m_document->height)) : std::nullopt; + if (m_cropRatioChoice == QStringLiteral("1:1")) + return 1; + if (m_cropRatioChoice == QStringLiteral("4:3")) + return 4.0 / 3; + if (m_cropRatioChoice == QStringLiteral("16:9")) + return 16.0 / 9; + return std::nullopt; +} + +void EditorSession::cancelCrop() +{ + setCropRect(std::nullopt); +} + +// The frame takes the ratio about its middle, width kept. +void EditorSession::changeCropRatio() +{ + const std::optional<QRectF> rect = visibleCropRect(); + const std::optional<double> ratio = cropRatio(); + if (!rect || !ratio) + return; + const double height = rect->width() / *ratio; + const QRectF next = CropGeometry::snapped(QRectF(rect->left(), rect->center().y() - height / 2, rect->width(), height)); + if (CropGeometry::valid(next)) + setCropRect(next); +} + +// The canvas cut to the frame off the UI thread. +void EditorSession::commitCrop(std::function<void()> done) +{ + bool started = false; + if (canStartProjectOperation() && m_cropRect && CropGeometry::valid(*m_cropRect)) { + // Swift reads a CGRect's width and minX standardised. + const QRectF rect = m_cropRect->normalized(); + const CanvasSizeOptions options{.width = qint64(rect.width()), .height = qint64(rect.height()), .contentOffset = QPointF(-rect.left(), -rect.top())}; + // Taken before anything changes: running out changes nothing. + try { + std::optional<ProjectSnapshot> snapshot = projectSnapshot(); + if (snapshot) { + m_cropCommit.setFuture(QtConcurrent::run([snapshot = std::move(*snapshot), options]() -> Cropped { + try { + return Cropped{CanvasResizer::resize(snapshot, options), std::nullopt}; + } catch (const ProjectError &error) { + return Cropped{std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return Cropped{std::nullopt, QString::fromUtf8(error.what())}; + } + })); + started = true; + } + } catch (const std::bad_alloc &) { + setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what())); + } + } + if (!started) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + m_committingCrop = std::move(done); + setIsProjectBusy(true); +} + +void EditorSession::finishCropCommit() +{ + const std::function<void()> done = std::exchange(m_committingCrop, {}); + try { + // Moved out: a large project's copy could fail. + const Cropped result = m_cropCommit.future().takeResult(); + if (result.snapshot) { + applyDocumentSize(*result.snapshot, QStringLiteral("Crop")); + // Only a crop that landed lets its frame go. + m_cropRect.reset(); + } else { + setCropError(result.failure); + } + } catch (const std::bad_alloc &) { + setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what())); + } + setIsProjectBusy(false); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); +} diff --git a/src/Document/Crop.h b/src/Document/Crop.h new file mode 100644 index 0000000..9117de4 --- /dev/null +++ b/src/Document/Crop.h @@ -0,0 +1,36 @@ +#pragma once +#include "Document/LayerTransform.h" +#include <QRectF> +#include <optional> +#include <vector> + +// Swift's CropGeometry: frames on whole pixels, within Swift's bounds. +namespace CropGeometry { +QRectF snapped(const QRectF &rect); +bool valid(const QRectF &rect); +// From `start` to `end`, or out from `start` when symmetric. +QRectF create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric = false); +} + +// Swift's CropDrag: a frame being made, moved or resized. +struct CropDrag { + enum class Kind { create, move, resize }; + struct Mode { + Kind kind; + int index = 0; + }; + QPointF start; + QRectF original; + Mode mode; + // Symmetric (Alt) keeps the frame's middle still. + QRectF updated(QPointF point, std::optional<double> ratio, bool symmetric = false) const; +}; + +// Swift's CropSnap: dragged edges drawn to nearby edges. +struct CropSnap { + std::vector<double> xs; + std::vector<double> ys; + // In document pixels. + double tolerance; + QRectF apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric = false) const; +}; diff --git a/src/Document/Curves.cpp b/src/Document/Curves.cpp new file mode 100644 index 0000000..75ff067 --- /dev/null +++ b/src/Document/Curves.cpp @@ -0,0 +1,67 @@ +#include "Document/Curves.h" +#include "Document/BrushStroke.h" +#include "IO/ProjectStore.h" +#include <QPainter> +#include <algorithm> +#include <array> +#include <cmath> +extern "C" { +#include "LevelsPixels.h" +} + +bool CurvesSettings::isValid() const +{ + // Fixed ends and rising x keep x in range. + return channels.size() == 4 && std::all_of(channels.begin(), channels.end(), [](const std::vector<CurvePoint> &points) { + return points.size() >= 2 && points.size() <= 32 && points.front().x == 0 && points.back().x == 255 + && std::all_of(points.begin(), points.end(), [](const CurvePoint &point) { return point.y >= 0 && point.y <= 255; }) + && std::adjacent_find(points.begin(), points.end(), [](const CurvePoint &a, const CurvePoint &b) { return !(a.x < b.x); }) == points.end(); + }); +} + +double CurvesSettings::value(double x, size_t channel) const +{ + const std::vector<CurvePoint> &p = channels.at(channel); + // Swift's lastIndex(where:), else the first segment. + size_t last = 0; + for (size_t index = 0; index < p.size(); ++index) { + if (p[index].x <= x) + last = index; + } + const size_t i = std::min(p.size() - 2, last); + std::vector<double> d; + for (size_t index = 0; index + 1 < p.size(); ++index) + d.push_back((p[index + 1].y - p[index].y) / (p[index + 1].x - p[index].x)); + const auto slope = [&](size_t j) { + if (j == 0) + return d.front(); + if (j == p.size() - 1) + return d.back(); + if (d[j - 1] * d[j] <= 0) + return 0.0; + return 2 / (1 / d[j - 1] + 1 / d[j]); + }; + const double h = p[i + 1].x - p[i].x, t = std::min(1.0, std::max(0.0, (x - p[i].x) / h)); + const double y = (2 * t * t * t - 3 * t * t + 1) * p[i].y + (t * t * t - 2 * t * t + t) * h * slope(i) + + (-2 * t * t * t + 3 * t * t) * p[i + 1].y + (t * t * t - t * t) * h * slope(i + 1); + return std::min(255.0, std::max(0.0, y)); +} + +QImage CurvesSettings::apply(const QImage &image) const +{ + if (!isValid()) + throw ProjectError(ProjectError::Kind::invalid); + QImage context = BrushRaster::context(image.width(), image.height(), false); + { + QPainter painter(&context); + BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); + } + // Each colour's curve, then the composite's. + std::array<float, 3 * 256> tables; + for (size_t channel = 1; channel <= 3; ++channel) { + for (size_t x = 0; x < 256; ++x) + tables[(channel - 1) * 256 + x] = float(value(value(double(x), channel), 0) / 255); + } + levels_apply(context.bits(), size_t(image.width()) * size_t(image.height()), tables.data()); + return context; +} diff --git a/src/Document/Curves.h b/src/Document/Curves.h new file mode 100644 index 0000000..6f40e0d --- /dev/null +++ b/src/Document/Curves.h @@ -0,0 +1,22 @@ +#pragma once +#include "Document/Levels.h" +#include <QImage> +#include <vector> + +struct CurvePoint { + double x; + double y; + friend bool operator==(const CurvePoint &, const CurvePoint &) = default; +}; + +// Swift's CurvesSettings: a curve a channel, the composite first. +struct CurvesSettings { + LevelsChannel channel = LevelsChannel::rgb; + std::vector<std::vector<CurvePoint>> channels = std::vector<std::vector<CurvePoint>>(4, {{0, 0}, {255, 255}}); + bool isValid() const; + // Shape-preserving cubic Hermite: no overshoot between handles. + double value(double x, size_t channel) const; + // Throws ProjectError when invalid, ExportError when out of memory. + QImage apply(const QImage &image) const; + friend bool operator==(const CurvesSettings &, const CurvesSettings &) = default; +}; diff --git a/src/Document/Distort+Session.h b/src/Document/Distort+Session.h new file mode 100644 index 0000000..bde91d4 --- /dev/null +++ b/src/Document/Distort+Session.h @@ -0,0 +1,14 @@ +// Swift's Distort extension: four corners dragged apart. +public: + void beginDistort(); + void previewCorners(const Corners &corners); + std::optional<DistortPreview> distortPreview(const ImageLayer &layer) const; + // A layer's effects warped with the pending distortion. + std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const; + // For a known target: the commit's, once the edit ends. + std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset, const DistortTarget &target) const; + +private: + std::optional<DistortTarget> distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const; + void commitDistort(const TransformEdit &edit, const Corners &shape); + void distort(int index, const LayerTransform &transform, const Corners &corners); diff --git a/src/Document/Distort.cpp b/src/Document/Distort.cpp new file mode 100644 index 0000000..e668a9b --- /dev/null +++ b/src/Document/Distort.cpp @@ -0,0 +1,482 @@ +#include "Document/Distort.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/LayerEffects+Renderer.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include <QPainterPath> +#include <QPolygonF> +#include <QRegion> +#include <cmath> +#include <functional> + +extern "C" { +#include "BrushPixels.h" +} + +namespace { +// Swift divides by w whatever its sign; Qt 6.11 clips. +QPointF project(const QTransform &map, QPointF point) +{ + const double w = map.m13() * point.x() + map.m23() * point.y() + map.m33(); + return QPointF((map.m11() * point.x() + map.m21() * point.y() + map.m31()) / w, (map.m12() * point.x() + map.m22() * point.y() + map.m32()) / w); +} + +double area(QPointF a, QPointF b, QPointF c) +{ + return (b.x() - a.x()) * (c.y() - a.y()) - (b.y() - a.y()) * (c.x() - a.x()); +} + +using Triangle = std::array<QPointF, 3>; + +// The affine map taking three points to three others. +std::optional<QTransform> affine(const Triangle &source, const Triangle &target) +{ + const QPointF u = source[1] - source[0], v = source[2] - source[0]; + const QPointF uu = target[1] - target[0], vv = target[2] - target[0]; + const double det = u.x() * v.y() - v.x() * u.y(); + if (std::abs(det) <= 1e-9) + return std::nullopt; + const double a = (uu.x() * v.y() - vv.x() * u.y()) / det, c = (vv.x() * u.x() - uu.x() * v.x()) / det; + const double b = (uu.y() * v.y() - vv.y() * u.y()) / det, d = (vv.y() * u.x() - uu.y() * v.x()) / det; + return QTransform(a, b, c, d, target[0].x() - (a * source[0].x() + c * source[0].y()), target[0].y() - (b * source[0].x() + d * source[0].y())); +} + +struct Target { + QPointF topLeft, topRight, bottomRight, bottomLeft; +}; + +// A flipped layer's pixels go to the opposite corners. +Target imageCorners(const Corners &corners, bool flipX, bool flipY) +{ + const auto corner = [&](int x, int y) { + const int u = flipX ? 1 - x : x, v = flipY ? 1 - y : y; + return corners[size_t(std::array<int, 4>{0, 1, 3, 2}[size_t(v * 2 + u)])]; + }; + return {corner(0, 0), corner(1, 0), corner(1, 1), corner(0, 1)}; +} + +QRectF shapeBounds(const Corners &corners) +{ + QPointF low = corners[0], high = corners[0]; + for (const QPointF corner : corners) { + low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())}; + high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())}; + } + return QRectF(QPointF(std::floor(low.x()), std::floor(low.y())), QPointF(std::ceil(high.x()), std::ceil(high.y()))); +} + +// Pixels whose centres a triangle holds, left bound inclusive. +QRegion triangleRegion(const Triangle &triangle) +{ + QRegion region; + const double top = std::min({triangle[0].y(), triangle[1].y(), triangle[2].y()}); + const double bottom = std::max({triangle[0].y(), triangle[1].y(), triangle[2].y()}); + for (int row = int(std::ceil(top - 0.5)); row + 0.5 < bottom; ++row) { + const double y = row + 0.5; + std::optional<double> left, right; + for (size_t index = 0; index < 3; ++index) { + // Ends ordered by y: a shared edge gives one x. + const QPointF a = std::min(triangle[index], triangle[(index + 1) % 3], [](QPointF p, QPointF q) { return p.y() < q.y(); }); + const QPointF b = a == triangle[index] ? triangle[(index + 1) % 3] : triangle[index]; + // An edge holds its lower end, not its upper one. + if (a.y() == b.y() || y < a.y() || y >= b.y()) + continue; + const double x = a.x() + (b.x() - a.x()) * (y - a.y()) / (b.y() - a.y()); + left = std::min(left.value_or(x), x); + right = std::max(right.value_or(x), x); + } + if (!left) + continue; + const int first = int(std::ceil(*left - 0.5)), last = int(std::ceil(*right - 0.5)) - 1; + if (last >= first) + region += QRect(first, row, last - first + 1, 1); + } + return region; +} + +// A folded shape: each half by its own affine map. +void warpFolded(const QImage &image, const Target &target, const std::function<QPointF(QPointF)> &place, QPainter &painter) +{ + const QRectF source(0, 0, image.width(), image.height()); + const std::array<std::pair<Triangle, Triangle>, 2> halves = { + std::pair{Triangle{source.topLeft(), source.topRight(), source.bottomRight()}, Triangle{place(target.topLeft), place(target.topRight), place(target.bottomRight)}}, + std::pair{Triangle{source.topLeft(), source.bottomRight(), source.bottomLeft()}, Triangle{place(target.topLeft), place(target.bottomRight), place(target.bottomLeft)}}}; + for (const auto &[from, to] : halves) { + const std::optional<QTransform> map = affine(from, to); + if (!map) + continue; + painter.save(); + // A hard region along the diagonal: the halves meet exactly. + painter.setClipRegion(triangleRegion(to)); + painter.setTransform(*map); + BrushRaster::draw(image, source, painter); + painter.restore(); + } +} +} + +Corners DistortWarp::corners(const LayerTransform &transform) +{ + return {transform.point({0, 0}), transform.point({1, 0}), transform.point({1, 1}), transform.point({0, 1})}; +} + +bool DistortWarp::isUsable(const Corners &corners) +{ + for (const QPointF corner : corners) { + if (!std::isfinite(corner.x()) || !std::isfinite(corner.y()) || std::abs(corner.x()) > 1'000'000 || std::abs(corner.y()) > 1'000'000) + return false; + } + // Both halves need area, or one has nothing to draw. + return std::abs(area(corners[0], corners[1], corners[2])) > 0.01 && std::abs(area(corners[0], corners[2], corners[3])) > 0.01; +} + +bool DistortWarp::isConvex(const Corners &corners) +{ + if (!isUsable(corners)) + return false; + double sign = 0; + for (size_t index = 0; index < 4; ++index) { + const QPointF a = corners[index], b = corners[(index + 1) % 4], c = corners[(index + 2) % 4]; + const double cross = (b.x() - a.x()) * (c.y() - b.y()) - (b.y() - a.y()) * (c.x() - b.x()); + if (std::abs(cross) <= 0.01) + return false; + if (sign == 0) + sign = cross < 0 ? -1 : 1; + else if ((cross < 0) != (sign < 0)) + return false; + } + return true; +} + +QTransform DistortWarp::homography(const Corners &c) +{ + const double sx = c[0].x() - c[1].x() + c[2].x() - c[3].x(), sy = c[0].y() - c[1].y() + c[2].y() - c[3].y(); + double g = 0, h = 0; + if (std::abs(sx) > 1e-9 || std::abs(sy) > 1e-9) { + const double dx1 = c[1].x() - c[2].x(), dx2 = c[3].x() - c[2].x(), dy1 = c[1].y() - c[2].y(), dy2 = c[3].y() - c[2].y(); + const double den = dx1 * dy2 - dx2 * dy1; + if (std::abs(den) > 1e-12) { + g = (sx * dy2 - dx2 * sy) / den; + h = (dx1 * sy - sx * dy1) / den; + } + } + const double a = c[1].x() - c[0].x() + g * c[1].x(), b = c[3].x() - c[0].x() + h * c[3].x(); + const double d = c[1].y() - c[0].y() + g * c[1].y(), e = c[3].y() - c[0].y() + h * c[3].y(); + // Qt divides by the third row, as Swift by w. + return QTransform(a, d, g, b, e, h, c[0].x(), c[0].y(), 1); +} + +DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask, + std::optional<double> limit) +{ + if (!isUsable(corners)) + throw ProjectError(ProjectError::Kind::invalid); + const QRectF bounds = shapeBounds(corners); + if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling}; + // A uniform 1 × 1 mask already covers any shape. + if (isMask && image.width() == 1 && image.height() == 1) + return {image, placed}; + const double factor = limit ? std::min(1.0, *limit / std::max(bounds.width(), bounds.height())) : 1; + const int width = std::max(1, int(std::ceil(bounds.width() * factor))), height = std::max(1, int(std::ceil(bounds.height() * factor))); + const Target target = imageCorners(corners, transform.flipX, transform.flipY); + const auto place = [&](QPointF point) { return QPointF((point.x() - bounds.left()) * factor, (point.y() - bounds.top()) * factor); }; + QImage surface = BrushRaster::context(width, height, isMask); + QPainter painter(&surface); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); + if (!isConvex(corners)) { + warpFolded(image, target, place, painter); + return {surface, placed}; + } + const QRectF source(0, 0, image.width(), image.height()); + QTransform map; + if (!QTransform::quadToQuad(QPolygonF({source.topLeft(), source.topRight(), source.bottomRight(), source.bottomLeft()}), + QPolygonF({place(target.topLeft), place(target.topRight), place(target.bottomRight), place(target.bottomLeft)}), map)) + throw ExportError(ExportError::Kind::render); + painter.setTransform(map); + painter.drawImage(source, image); + return {surface, placed}; +} + +DistortWarp::Trimmed DistortWarp::warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners) +{ + const Warped warped = warp(image, transform, corners, false); + const QRect full = warped.image.rect(); + size_t edges[4] = {0, 0, 0, 0}; + brush_alpha_bounds(warped.image.constBits(), size_t(full.width()), size_t(full.height()), size_t(warped.image.bytesPerLine()), edges); + const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1])); + // Nothing visible, or nothing to trim: the warp itself. + if (crop.width() < 1 || crop.height() < 1 || crop == full) + return {warped.image, warped.transform, full}; + LayerTransform placed = warped.transform; + placed.origin += crop.topLeft(); + placed.size = crop.size(); + return {warped.image.copy(crop), placed, crop}; +} + +Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners) +{ + QTransform toDocument; + toDocument.translate(transform.center().x(), transform.center().y()); + toDocument.rotateRadians(transform.radians()); + toDocument.scale(transform.size.width(), transform.size.height()); + toDocument.translate(-0.5, -0.5); + const QTransform toUnit = toDocument.inverted(); + const QTransform map = homography(corners); + const Corners own = DistortWarp::corners(placement); + Corners result; + for (size_t index = 0; index < 4; ++index) + result[index] = project(map, toUnit.map(own[index])); + return result; +} + +DistortWarp::Warped DistortWarp::warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background, + std::optional<double> limit) +{ + const Warped warped = warp(image, transform, corners, true, limit); + if (background <= 0 || warped.image.cacheKey() == image.cacheKey()) + return warped; + const int width = warped.image.width(), height = warped.image.height(); + QImage surface = BrushRaster::context(width, height, true); + surface.fill(qRound(background * 255)); + QPainter painter(&surface); + painter.setRenderHint(QPainter::Antialiasing, true); + const double sx = width / warped.transform.size.width(), sy = height / warped.transform.size.height(); + QPainterPath shape; + for (size_t index = 0; index < 4; ++index) { + const QPointF point((corners[index].x() - warped.transform.origin.x()) * sx, (corners[index].y() - warped.transform.origin.y()) * sy); + if (index == 0) + shape.moveTo(point); + else + shape.lineTo(point); + } + shape.closeSubpath(); + painter.setClipPath(shape); + BrushRaster::draw(warped.image, QRectF(0, 0, width, height), painter); + return {surface, warped.transform}; +} + +// Ctrl-drag on a handle: the corners move freely; Apply resamples. +void EditorSession::beginDistort() +{ + if (!m_transformEdit || m_transformEdit->corners || !m_transformEdit->draft.isValid()) + return; + m_transformEdit->corners = DistortWarp::corners(m_transformEdit->draft); + m_transformEdit->persistent = true; + notify(); +} + +// A twisted or collapsed shape is ignored. +void EditorSession::previewCorners(const Corners &corners) +{ + if (!m_transformEdit || !m_transformEdit->corners || !DistortWarp::isUsable(corners)) + return; + m_transformEdit->corners = corners; + notify(); +} + +// A group's layers take the box's perspective each. +std::optional<DistortTarget> EditorSession::distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const +{ + if (!edit.group) + return edit.layerID == layer.id ? std::optional(DistortTarget{edit.draft, shape}) : std::nullopt; + const auto original = edit.group->originals.constFind(layer.id); + if (original == edit.group->originals.constEnd()) + return std::nullopt; + const LayerTransform transform = original->following(edit.group->box, edit.draft); + const Corners corners = DistortWarp::carried(transform, edit.draft, shape); + return DistortWarp::isUsable(corners) ? std::optional(DistortTarget{transform, corners}) : std::nullopt; +} + +// `image` is the layer with effects round it, mask applied. +std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const +{ + if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners) + return std::nullopt; + const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners); + if (!target) + return std::nullopt; + return distortedEffects(layer, image, inset, *target); +} + +std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset, + const DistortTarget &target) const +{ + // The grown box takes the same perspective as the layer's. + const LayerTransform grown = LayerEffectsRenderer::placed(target.transform, image, inset); + const Corners carried = DistortWarp::carried(grown, target.transform, target.corners); + if (m_distortEffectsCache.contains(layer.id)) { + const DistortEffectsCache &cache = m_distortEffectsCache.at(layer.id); + if (cache.corners == carried && cache.image.cacheKey() == image.cacheKey()) + return cache.result; + } + std::optional<DistortWarp::Warped> result; + // Swift's try?: effects that cannot warp are left off. + try { + result = DistortWarp::warp(image, grown, carried, false, 2048); + } catch (const ProjectError &) { + } catch (const ExportError &) { + } + m_distortEffectsCache.insert_or_assign(layer.id, DistortEffectsCache{carried, image, result}); + return result; +} + +// The layer warped into the pending distortion, at preview size. +std::optional<DistortPreview> EditorSession::distortPreview(const ImageLayer &layer) const +{ + if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners || !layer.asset) + return std::nullopt; + const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners); + if (!target) + return std::nullopt; + const std::optional<QImage> mask = layer.mask ? layer.mask->enabledImage() : std::nullopt; + const std::optional<ImageIdentity> maskID = mask ? std::optional(layer.mask->asset.identity()) : std::nullopt; + const auto cached = m_distortPreviewCache.find(layer.id); + if (cached != m_distortPreviewCache.end() && cached->second.corners == target->corners && cached->second.draft == target->transform + && cached->second.image == layer.asset->identity() && cached->second.mask == maskID) + return cached->second.result; + std::optional<DistortPreview> result; + try { + const DistortWarp::Warped warped = DistortWarp::warp(layer.asset->image(), target->transform, target->corners, false, 2048); + std::optional<QImage> warpedMask; + const std::optional<LayerMask> &owned = layer.mask; + if (owned && !owned->placement && owned->isLinked) { + // Swift's try?: a mask that cannot warp draws none. + try { + if (mask) + warpedMask = DistortWarp::warp(*mask, target->transform, target->corners, true, 2048).image; + } catch (const ProjectError &) { + } catch (const ExportError &) { + } + } else if (owned) { + std::optional<QImage> carriedMask; + if (owned->isLinked && owned->placement) { + // A linked mask placed apart takes the same perspective. + const LayerTransform placement = owned->placement->following(layer.transform, target->transform); + const Corners carried = DistortWarp::carried(placement, target->transform, target->corners); + try { + if (DistortWarp::isConvex(carried)) { + const DistortWarp::Warped moved = DistortWarp::warpMask(owned->asset.image(), placement, carried, LayerMask::background(owned->asset.thumbnail), 2048); + carriedMask = LayerMask(ImportedImage(moved.image, owned->asset.thumbnail, owned->asset.name), owned->isEnabled) + .clipImage(moved.transform, warped.transform, warped.image.width(), warped.image.height(), 2048); + } + } catch (const ProjectError &) { + } catch (const ExportError &) { + } + } + // Otherwise the mask stays where it is on the document. + warpedMask = carriedMask ? carriedMask : owned->clipImage(owned->placement.value_or(layer.transform), warped.transform, warped.image.width(), warped.image.height(), 2048); + } + result = DistortPreview{warped.image, warpedMask, warped.transform}; + } catch (const ProjectError &) { + } catch (const ExportError &) { + } + m_distortPreviewCache.insert_or_assign(layer.id, DistortPreviewCache{target->corners, target->transform, layer.asset->identity(), maskID, result}); + return result; +} + +// Apply: each layer resampled into its shape, one step. +void EditorSession::commitDistort(const TransformEdit &edit, const Corners &shape) +{ + m_distortPreviewCache.clear(); + std::vector<QUuid> ids; + if (edit.group) { + for (auto original = edit.group->originals.constBegin(); original != edit.group->originals.constEnd(); ++original) + ids.push_back(original.key()); + } else { + ids.push_back(edit.layerID); + } + beginEdit(edit.group ? QStringLiteral("Distort Layers") : QStringLiteral("Distort")); + for (const QUuid &id : ids) { + const int index = indexOf(m_document->layers, id); + if (index < 0) + continue; + const std::optional<DistortTarget> target = distortTarget(m_document->layers[index], edit, shape); + if (!target) + continue; + // Warped effects stay on until the new pixels' effects land. + const std::optional<EffectsPreviewCache::Result> effects = effectsPreviews.rendered(id); + const std::optional<DistortWarp::Warped> warpedEffects = + effects ? distortedEffects(m_document->layers[index], effects->image, effects->inset, *target) : std::nullopt; + try { + distort(index, target->transform, target->corners); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + // Placed where warped: the commit also crops the layer. + if (warpedEffects) + effectsPreviews.seed(id, warpedEffects->image, warpedEffects->transform); + } + endEdit(); + m_distortEffectsCache.clear(); +} + +// The layer at `index` resampled onto `corners`. +void EditorSession::distort(int index, const LayerTransform &transform, const Corners &corners) +{ + const ImageLayer layer = m_document->layers[index]; + if (!layer.asset) + return; + const DistortWarp::Trimmed warped = DistortWarp::warpTrimmed(layer.asset->image(), transform, corners); + const ImportedImage asset(warped.image, PixelAdjust::thumbnail(warped.image), layer.name); + std::optional<LayerMask> mask = layer.mask; + if (layer.mask && !layer.mask->placement && layer.mask->isLinked) { + const DistortWarp::Warped warpedMask = DistortWarp::warp(layer.mask->asset.image(), transform, corners, true); + // A uniform mask passes through; others crop with the pixels. + const bool uniform = warpedMask.image.cacheKey() == layer.mask->asset.image().cacheKey(); + mask = layer.mask->replacing(uniform ? layer.mask->asset : LayerMask::assetFrom(warpedMask.image.copy(warped.crop))); + } else if (layer.mask && layer.mask->isLinked && layer.mask->placement + && DistortWarp::isConvex(DistortWarp::carried(layer.mask->placement->following(layer.transform, transform), transform, corners))) { + // A linked mask placed apart takes the same perspective. + const LayerTransform placement = layer.mask->placement->following(layer.transform, transform); + const DistortWarp::Warped moved = DistortWarp::warpMask(layer.mask->asset.image(), placement, DistortWarp::carried(placement, transform, corners), + LayerMask::background(layer.mask->asset.thumbnail)); + const bool uniform = moved.image.cacheKey() == layer.mask->asset.image().cacheKey(); + mask = LayerMask(uniform ? layer.mask->asset : LayerMask::assetFrom(moved.image), layer.mask->isEnabled, moved.transform, true); + } else if (layer.mask) { + // An unlinked mask keeps its place on the document. + mask->placement = layer.mask->placement.value_or(layer.transform); + } + m_document->layers[index].asset = asset; + m_document->layers[index].transform = warped.transform; + m_document->layers[index].mask = mask; +} + +// The selection's outline carried through the distortion. +std::optional<QPainterPath> DistortWarp::mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize, + const LayerTransform &transform, const Corners &corners) +{ + if (!isConvex(corners) || pixelSize.width() <= 0 || pixelSize.height() <= 0) + return std::nullopt; + const QTransform toPixels = pixelToDocument.inverted(); + const QTransform map = homography(corners); + const auto carry = [&](QPointF point) { + const QPointF pixel = toPixels.map(point); + double u = pixel.x() / pixelSize.width(), v = pixel.y() / pixelSize.height(); + if (transform.flipX) + u = 1 - u; + if (transform.flipY) + v = 1 - v; + return project(map, QPointF(u, v)); + }; + QPainterPath result; + result.setFillRule(path.fillRule()); + for (int i = 0; i < path.elementCount(); ++i) { + const QPainterPath::Element element = path.elementAt(i); + if (element.isMoveTo()) { + result.moveTo(carry(element)); + } else if (element.isLineTo()) { + result.lineTo(carry(element)); + } else { + // A curve is its first control, then two data elements. + result.cubicTo(carry(element), carry(path.elementAt(i + 1)), carry(path.elementAt(i + 2))); + i += 2; + } + } + return result; +} diff --git a/src/Document/Distort.h b/src/Document/Distort.h new file mode 100644 index 0000000..e09d4df --- /dev/null +++ b/src/Document/Distort.h @@ -0,0 +1,72 @@ +#pragma once +#include "Document/LayerTransform.h" +#include "IO/ImageImporter.h" +#include <QImage> +#include <QPainterPath> +#include <QRect> +#include <optional> + +// Free distortion: the layer's four corners move on their own. +namespace DistortWarp { +struct Warped { + QImage image; + LayerTransform transform; +}; +struct Trimmed { + QImage image; + LayerTransform transform; + // In the warp's pixels, for cropping a mask to match. + QRect crop; +}; + +// Clockwise from the top left, in document pixels. +Corners corners(const LayerTransform &transform); +// Four finite corners with some area to both halves. +bool isUsable(const Corners &corners); +// A shape a perspective can take, wound either way. +bool isConvex(const Corners &corners); +// The perspective taking the unit square onto `corners`. +QTransform homography(const Corners &corners); +// `image` under `transform`, resampled so its corners land on `corners`. +Warped warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask, + std::optional<double> limit = std::nullopt); +// A full warp cropped to its visible pixels. +Trimmed warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners); +// Where `placement`'s corners land under `transform`'s perspective. +Corners carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners); +// A path through the distortion; nil when the shape folds. +std::optional<QPainterPath> mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize, + const LayerTransform &transform, const Corners &corners); +// A mask warped like `warp`, its background outside the shape. +Warped warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background, + std::optional<double> limit = std::nullopt); +} + +// Where a distortion takes a layer: transform and corners. +struct DistortTarget { + LayerTransform transform; + Corners corners; +}; + +// The layer warped for the canvas, its mask alongside. +struct DistortPreview { + QImage image; + std::optional<QImage> mask; + LayerTransform transform; +}; + +// Effects last warped for a distortion, kept while corners rest. +struct DistortEffectsCache { + Corners corners; + QImage image; + std::optional<DistortWarp::Warped> result; +}; + +// The canvas's last warped preview, kept while nothing changed. +struct DistortPreviewCache { + Corners corners; + LayerTransform draft; + ImageIdentity image; + std::optional<ImageIdentity> mask; + std::optional<DistortPreview> result; +}; diff --git a/src/Document/DocumentHistory.cpp b/src/Document/DocumentHistory.cpp new file mode 100644 index 0000000..aca7587 --- /dev/null +++ b/src/Document/DocumentHistory.cpp @@ -0,0 +1,131 @@ +#include "Document/DocumentHistory.h" +#include <algorithm> +#include <set> +#include <utility> + +DocumentHistory::DocumentHistory(int entryLimit, qint64 retainedByteLimit) + : entryLimit(std::max(0, entryLimit)), retainedByteLimit(std::max<qint64>(0, retainedByteLimit)) +{ +} + +void DocumentHistory::reset() +{ + m_past.clear(); + m_future.clear(); + m_pending.reset(); + m_depth = 0; + m_revision = QUuid::createUuid(); + m_savedRevision = m_revision; +} + +// A closed step changes these three; pending state is spent. +void DocumentHistory::adopt(DocumentHistory &&staged) noexcept +{ + m_past = std::move(staged.m_past); + m_future = std::move(staged.m_future); + m_revision = staged.m_revision; +} + +void DocumentHistory::begin(const QString &name, const std::optional<CanvasDocument> &document, + std::optional<QUuid> selection) +{ + if (m_depth == 0) { + m_pending = Snapshot{document, selection, m_revision}; + m_pendingName = name; + } + m_depth += 1; +} + +void DocumentHistory::end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection) +{ + if (m_depth == 0) + return; + m_depth -= 1; + if (m_depth != 0 || !m_pending) + return; + const Snapshot before = *std::exchange(m_pending, std::nullopt); + // Selecting, navigating and no-op edits keep redo history. + if (before.document == document) + return; + m_revision = QUuid::createUuid(); + m_past.push_back({m_pendingName, before, Snapshot{document, selection, m_revision}}); + m_future.clear(); + trim(document); +} + +std::optional<DocumentHistory::Snapshot> DocumentHistory::undo() +{ + if (!canUndo()) + return std::nullopt; + Entry entry = std::move(m_past.back()); + m_past.pop_back(); + m_future.push_back(entry); + m_revision = entry.before.revision; + trim(entry.before.document); + return entry.before; +} + +std::optional<DocumentHistory::Snapshot> DocumentHistory::redo() +{ + if (!canRedo()) + return std::nullopt; + Entry entry = std::move(m_future.back()); + m_future.pop_back(); + m_past.push_back(entry); + m_revision = entry.after.revision; + trim(entry.after.document); + return entry.after; +} + +qint64 DocumentHistory::retainedBytes(const std::optional<CanvasDocument> ¤t) const +{ + const auto assets = [](const ImageLayer &layer) { + std::vector<const ImportedImage *> result; + if (layer.asset) + result.push_back(&*layer.asset); + if (layer.mask) + result.push_back(&layer.mask->asset); + return result; + }; + std::set<ImageIdentity> seen; + if (current) { + for (const ImageLayer &layer : current->layers) { + for (const ImportedImage *asset : assets(layer)) { + seen.insert(asset->identity()); + seen.insert({nullptr, asset->thumbnail.cacheKey()}); + } + } + } + qint64 bytes = 0; + const auto count = [&](const Snapshot &snapshot) { + if (!snapshot.document) + return; + for (const ImageLayer &layer : snapshot.document->layers) { + for (const ImportedImage *asset : assets(layer)) { + if (seen.insert(asset->identity()).second) + bytes += asset->byteCount(); + if (seen.insert({nullptr, asset->thumbnail.cacheKey()}).second) + bytes += asset->thumbnail.sizeInBytes(); + } + } + }; + for (const std::vector<Entry> *entries : {&m_past, &m_future}) { + for (const Entry &entry : *entries) { + count(entry.before); + count(entry.after); + } + } + return bytes; +} + +void DocumentHistory::trim(const std::optional<CanvasDocument> ¤t) +{ + while (int(m_past.size() + m_future.size()) > entryLimit || retainedBytes(current) > retainedByteLimit) { + if (!m_past.empty()) + m_past.erase(m_past.begin()); + else if (!m_future.empty()) + m_future.erase(m_future.begin()); + else + break; + } +} diff --git a/src/Document/DocumentHistory.h b/src/Document/DocumentHistory.h new file mode 100644 index 0000000..03abc90 --- /dev/null +++ b/src/Document/DocumentHistory.h @@ -0,0 +1,55 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include <QString> +#include <QUuid> +#include <optional> +#include <vector> + +// Snapshots share image data; layer edits copy no pixels. +class DocumentHistory { +public: + struct Snapshot { + std::optional<CanvasDocument> document; + std::optional<QUuid> activeLayerID; + QUuid revision; + }; + + const int entryLimit; + const qint64 retainedByteLimit; + + explicit DocumentHistory(int entryLimit = 100, qint64 retainedByteLimit = 256LL * 1024 * 1024); + + bool canUndo() const { return m_depth == 0 && !m_past.empty(); } + bool canRedo() const { return m_depth == 0 && !m_future.empty(); } + QString undoName() const { return m_past.empty() ? QString() : m_past.back().name; } + QString redoName() const { return m_future.empty() ? QString() : m_future.back().name; } + bool isModified() const { return m_revision != m_savedRevision; } + int undoCount() const { return int(m_past.size()); } + void markSaved() { m_savedRevision = m_revision; } + void reset(); + // Takes the steps a staged copy of this history recorded. + void adopt(DocumentHistory &&staged) noexcept; + + void begin(const QString &name, const std::optional<CanvasDocument> &document, std::optional<QUuid> selection); + void end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection); + std::optional<Snapshot> undo(); + std::optional<Snapshot> redo(); + qint64 retainedBytes(const std::optional<CanvasDocument> ¤t) const; + +private: + struct Entry { + QString name; + Snapshot before; + Snapshot after; + }; + + void trim(const std::optional<CanvasDocument> ¤t); + + std::vector<Entry> m_past; + std::vector<Entry> m_future; + QUuid m_revision = QUuid::createUuid(); + QUuid m_savedRevision = m_revision; + std::optional<Snapshot> m_pending; + QString m_pendingName = QStringLiteral("Edit"); + int m_depth = 0; +}; diff --git a/src/Document/EditorSession+Brush.cpp b/src/Document/EditorSession+Brush.cpp new file mode 100644 index 0000000..c358b12 --- /dev/null +++ b/src/Document/EditorSession+Brush.cpp @@ -0,0 +1,407 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Logging.h" +#include "Rendering/RasterSnapshot.h" +#include <QtConcurrent> +#include <chrono> +#include <cmath> + +// Swift's EditorSession+Brush: the stroke from press to commit. +namespace { +bool sameAsset(const std::optional<ImportedImage> &a, const std::optional<ImportedImage> &b) +{ + return a.has_value() == b.has_value() && (!a || a->identity() == b->identity()); +} + +bool sameMask(const std::optional<LayerMask> &a, const std::optional<LayerMask> &b) +{ + return a.has_value() == b.has_value() && (!a || a->asset.identity() == b->asset.identity()); +} + +double uptime() +{ + return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +} + +bool EditorSession::canPaint() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + const std::optional<DocumentSelection> current = selection(); + // A folder has no pixels: only its mask paints. + return canEditLayers() && m_selectedLayerIDs.size() == 1 && layer && (!layer->isGroup || m_isMaskSelected) && !(current && current->isEmpty()) + && m_document->effectiveVisibleIDs().contains(layer->id) && (!m_isMaskSelected || (layer->mask && layer->mask->isEnabled)) + && (m_isMaskSelected || !layer->adjustment); +} + +// A tiled raster edit of pixels or mask, within budget. +std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const +{ + if (!m_document) + throw ProjectError(ProjectError::Kind::tooLarge); + auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size()); + qint64 used = 0; + for (const ImageLayer &other : m_document->layers) { + if (other.id == layer.id) + continue; + const std::optional<ImportedImage> &image = m_isMaskSelected ? (other.mask ? std::optional(other.mask->asset) : std::nullopt) : other.asset; + if (image) + used += qint64(image->size().width()) * image->size().height(); + } + stroke->pixelLimit = 100'000'000 - used; + const std::optional<DocumentSelection> current = selection(); + if (current) + stroke->selectionClip = current->clip(m_document->size()); + if (!m_isMaskSelected && layer.mask) { + qint64 maskPixels = 0; + for (const ImageLayer &other : m_document->layers) { + if (other.id != layer.id && other.mask) + maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height(); + } + stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels); + } + return stroke; +} + +void EditorSession::beginBrush(QPointF point) +{ + if (m_tool == NavigationTool::blur && m_blurMode != BlurToolMode::blur) { + beginWarp(point); + return; + } + // Spot Healing and Clone Stamp rework pixels, never a mask. + const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur; + if ((!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected)) || !canPaint()) + return; + const ImageLayer layer = activeLayer().value(); + std::optional<BrushStroke::Clone> clone; + if (m_tool == NavigationTool::cloneStamp) { + const std::optional<QSizeF> offset = cloneStrokeOffset(point); + if (!offset) { + setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first.")); + return; + } + const std::optional<QImage> image = cloneSample(m_document.value()); + if (!image) + return; + m_cloneOffset = offset; + clone = BrushStroke::Clone{*image, *offset}; + } + // Blur paints a softened copy of the layer, in place. + if (m_tool == NavigationTool::blur) { + const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected); + if (!image) + return; + clone = BrushStroke::Clone{*image, QSizeF(0, 0)}; + } + finishOpacityEdit(); + try { + BrushSettings settings = m_brushSettings; + settings.healing = m_tool == NavigationTool::spotHealing; + settings.erasing = m_tool == NavigationTool::brush && m_brushMode == BrushToolMode::erase && !m_isMaskSelected; + settings.healingMode = m_spotHealingMode; + if (m_isMaskSelected) { + settings.red = m_maskPaintWhite ? 1 : 0; + settings.green = settings.red; + settings.blue = settings.red; + } + m_brushStroke = makeRasterEdit(layer, settings); + m_brushStroke->clone = clone; + m_brushStroke->isBlur = m_tool == NavigationTool::blur; + m_brushStroke->append(point); + m_lastBrushPoint = LastBrushPoint{point, layer.id, m_isMaskSelected}; + ++m_brushRevision; + resumeFileRequests(); + notify(); + } catch (const ProjectError &error) { + cancelBrush(); + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + cancelBrush(); + setBrushError(QString::fromUtf8(error.what())); + } +} + +void EditorSession::continueBrush(QPointF point) +{ + if (m_warpStroke) { + m_warpStroke->append(point); + m_lastBrushPoint->point = point; + ++m_brushRevision; + notify(); + return; + } + if (!m_brushStroke) + return; + try { + m_brushStroke->append(point); + m_lastBrushPoint->point = point; + ++m_brushRevision; + notify(); + } catch (const ProjectError &error) { + cancelBrush(); + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + cancelBrush(); + setBrushError(QString::fromUtf8(error.what())); + } +} + +// Where a Shift-click's line starts, on the same target. +std::optional<QPointF> EditorSession::shiftLineStart() const +{ + if (!m_lastBrushPoint || m_lastBrushPoint->layerID != m_activeLayerID || m_lastBrushPoint->mask != m_isMaskSelected) + return std::nullopt; + return m_lastBrushPoint->point; +} + +void EditorSession::cancelBrush() +{ + m_warpStroke.reset(); + m_brushStroke.reset(); + ++m_brushRevision; + resumeFileRequests(); + notify(); +} + +// Mouse-up's own call, before the next event arrives. +bool EditorSession::finishBrushImmediately() +{ + if (m_warpStroke) { + if (m_isProjectBusy) + return false; + finishWarp(); + return true; + } + if (!m_brushStroke) + return true; + if (m_isProjectBusy) + return false; + try { + m_brushStroke->flush(); + if (m_brushStroke->settings.healing) + m_brushStroke->heal(); + if (!m_brushStroke->patches().empty()) + commitPaintSnapshot(*m_brushStroke); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + cancelBrush(); + return true; +} + +void EditorSession::finishBrush() +{ + finishBrushImmediately(); +} + +// Immutable tiles land at once, undo entry included. +void EditorSession::commitPaintSnapshot(const BrushStroke &stroke) +{ + const PaintSnapshot result = stroke.paintSnapshot(); + const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1; + if (!result.transform.isValid() || index < 0) + return; + const ImageLayer current = m_document->layers[index]; + if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform) + return; + std::optional<LayerMask> mask = current.mask; + if (!stroke.isMask && mask && !mask->placement && result.bounds != stroke.sourceRect) { + // The mask grows with the layer, revealing past it. + const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(mask->asset, stroke.sourceRect, {}, result.bounds, true); + mask = mask->replacing(ImportedImage(raster, raster->thumbnail(), mask->asset.name)); + } + beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask") + : stroke.settings.erasing ? QStringLiteral("Erase") + : stroke.isBlur ? QStringLiteral("Blur") + : stroke.clone ? QStringLiteral("Clone Stamp") + : stroke.settings.healing ? QStringLiteral("Spot Healing") + : QStringLiteral("Brush Stroke"))); + ImageLayer &layer = m_document->layers[index]; + if (stroke.isMask) { + layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset); + } else { + layer.asset = result.asset; + layer.transform = result.transform; + layer.isGroup = false; + layer.mask = mask; + // Swift rebuilds the layer; effects stay, shape and text go. + layer.shape = std::nullopt; + layer.text = std::nullopt; + } + endEdit(); +} + +// A raster edit assembled off the UI thread; one step. +void EditorSession::commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply, std::function<void()> done) +{ + // The caller resumes from the event loop, like await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (!stroke->committedTransform().isValid()) { + setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what())); + finish(); + return; + } + setIsProjectBusy(true); + const BrushCommit::Input input = stroke->commitInput(); + m_committingRaster = CommittingRaster{std::move(stroke), name, std::move(alsoApply), std::move(done)}; + m_rasterCommit.setFuture(QtConcurrent::run([input]() -> RasterMade { + try { + return RasterMade{BrushCommit::render(input), std::nullopt}; + } catch (const ExportError &error) { + return RasterMade{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishRasterCommit() +{ + const RasterMade made = m_rasterCommit.result(); + const CommittingRaster pending = std::move(m_committingRaster.value()); + m_committingRaster = std::nullopt; + const auto finish = [&] { + setIsProjectBusy(false); + ++m_brushRevision; + notify(); + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); + }; + if (made.failure) { + setBrushError(made.failure); + finish(); + return; + } + const BrushStroke &stroke = *pending.stroke; + const BrushCommit::Output &result = *made.output; + try { + const LayerTransform transform = stroke.transform(result.pixelBounds.translated(stroke.committedBounds().topLeft())); + if (!transform.isValid()) + throw ProjectError(ProjectError::Kind::tooLarge); + std::optional<LayerMask> mask = stroke.layer.mask; + if (!stroke.isMask && mask && !mask->placement) + mask = mask->replacing(BrushCommit::expandMask(mask->asset, stroke.commitInput(), result.pixelBounds)); + // Never over content that changed underneath it. + const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1; + if (index < 0) { + finish(); + return; + } + const ImageLayer current = m_document->layers[index]; + if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform || !sameMask(current.mask, stroke.layer.mask)) { + finish(); + return; + } + beginEdit(pending.name); + ImageLayer &layer = m_document->layers[index]; + if (stroke.isMask) { + layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset); + } else { + layer.asset = result.asset; + layer.transform = transform; + layer.isGroup = false; + // Swift rebuilds the layer; effects stay, shape and text go. + layer.shape = std::nullopt; + layer.text = std::nullopt; + if (mask && current.mask) + mask->isEnabled = current.mask->isEnabled; + layer.mask = mask; + } + if (pending.alsoApply) + pending.alsoApply(); + endEdit(); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + finish(); +} + +bool EditorSession::usesOpacityKeys() const +{ + return isBrushTool(m_tool) || m_tool == NavigationTool::gradient || m_tool == NavigationTool::move; +} + +void EditorSession::typeOpacityDigit(int digit, std::optional<double> time) +{ + if (!usesOpacityKeys() || m_brushStroke || m_isProjectBusy || digit < 0 || digit > 9) + return; + const double now = time.value_or(uptime()); + int percent = digit == 0 ? 100 : digit * 10; + if (m_pendingOpacityDigit && now - m_pendingOpacityDigit->time < 0.6) { + percent = std::max(1, m_pendingOpacityDigit->digit * 10 + digit); + m_pendingOpacityDigit = std::nullopt; + } else { + m_pendingOpacityDigit = PendingOpacityDigit{digit, now}; + } + const double value = percent / 100.0; + if (isBrushTool(m_tool)) { + m_brushSettings.opacity = value; + notify(); + } else if (m_tool == NavigationTool::gradient) { + setGradientSettings(GradientSettings{m_gradientSettings.shape, m_gradientSettings.style, m_gradientSettings.reversed, value}); + } else { + setSelectedLayersOpacity(value); + } +} + +// Shift-[ and Shift-]: hardness in Photoshop's quarters. +void EditorSession::changeBrushHardness(bool increase) +{ + if (m_brushStroke) + return; + const double quarter = m_brushSettings.hardness * 4; + const double step = increase ? std::floor(quarter + 0.001) + 1 : std::ceil(quarter - 0.001) - 1; + m_brushSettings.hardness = std::min(4.0, std::max(0.0, step)) / 4; + notify(); +} + +void EditorSession::changeBrushSize(bool increase) +{ + if (m_brushStroke) + return; + // A fifth, at least a pixel: 2 would stay 2. + const double current = m_brushSettings.diameter; + const double stepped = increase ? std::max(current + 1, std::round(current * 1.2)) : std::min(current - 1, std::round(current / 1.2)); + m_brushSettings.diameter = std::min(2000.0, std::max(1.0, stepped)); + notify(); +} + +void EditorSession::setBrushSettings(const BrushSettings &settings) +{ + m_brushSettings = settings; + refreshGradient(); + notify(); +} + +void EditorSession::setBrushMode(BrushToolMode mode) +{ + m_brushMode = mode; + notify(); +} + +void EditorSession::setBlurMode(BlurToolMode mode) +{ + m_blurMode = mode; + notify(); +} + +void EditorSession::setSpotHealingMode(SpotHealingMode mode) +{ + m_spotHealingMode = mode; + notify(); +} + +void EditorSession::setMaskPaintWhite(bool white) +{ + m_maskPaintWhite = white; + refreshGradient(); + notify(); +} diff --git a/src/Document/EditorSession+Brush.h b/src/Document/EditorSession+Brush.h new file mode 100644 index 0000000..b76b484 --- /dev/null +++ b/src/Document/EditorSession+Brush.h @@ -0,0 +1,24 @@ +// Swift's EditorSession+Brush extension: a stroke from press to commit. +public: + bool canPaint() const; + std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const; + void beginBrush(QPointF point); + void continueBrush(QPointF point); + // Where a Shift-click's line starts; nil on another target. + std::optional<QPointF> shiftLineStart() const; + void cancelBrush(); + // Mouse-up's own call; false while busy. + bool finishBrushImmediately(); + void finishBrush(); + void commitPaintSnapshot(const BrushStroke &stroke); + // Off the UI thread; `done` follows the step or refusal. + void commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply = {}, + std::function<void()> done = {}); + bool usesOpacityKeys() const; + // Digits set 10%…100%; two quick ones, an exact value. + void typeOpacityDigit(int digit, std::optional<double> time = std::nullopt); + void changeBrushHardness(bool increase); + void changeBrushSize(bool increase); + +private: + void finishRasterCommit(); diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp new file mode 100644 index 0000000..7d74a24 --- /dev/null +++ b/src/Document/EditorSession+Imports.cpp @@ -0,0 +1,124 @@ +#include "Document/EditorSession.h" +#include "Logging.h" +#include <QtConcurrent> + +bool EditorSession::canStartProjectOperation() const +{ + return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter + && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID; +} + +void EditorSession::waitForFileRequest(std::function<void()> ready) +{ + if (canStartProjectOperation()) + ready(); + else + m_fileRequestWaiters.push_back(std::move(ready)); +} + +void EditorSession::resumeFileRequests() +{ + if (!canStartProjectOperation()) + return; + // Each waiter looks again when its turn comes. + for (const std::function<void()> &waiter : std::exchange(m_fileRequestWaiters, {})) + QMetaObject::invokeMethod(this, [this, waiter] { waitForFileRequest(waiter); }, Qt::QueuedConnection); +} + +void EditorSession::waitForProjectAccess(std::function<void()> ready) +{ + if (!m_isProjectBusy) + ready(); + else + m_projectWaiters.push_back(std::move(ready)); +} + +void EditorSession::updateBusyIndicator() +{ + // Quick operations never dim the controls. + if (m_isProjectBusy) { + if (!m_busyTimer.isActive() && !m_showsBusy) + m_busyTimer.start(); + } else { + m_busyTimer.stop(); + m_showsBusy = false; + } +} + +void EditorSession::importImages(const QList<QUrl> &urls, std::optional<QPointF> point, std::function<void()> done) +{ + if (urls.isEmpty()) { + if (done) + done(); + return; + } + if (m_brushStroke) + finishBrush(); + cancelCrop(); + commitTransform(); + waitForProjectAccess([this, urls, point, done] { + m_pendingImports.push_back({urls, point, done}); + if (!m_isImporting) { + setIsImporting(true); + drainImports(); + } + }); +} + +void EditorSession::drainImports() +{ + if (m_pendingImports.empty()) { + setIsImporting(false); + if (!m_importFailures.isEmpty()) + setImportError(std::exchange(m_importFailures, {}).join("\n\n")); + return; + } + beginEdit(QStringLiteral("Import Images")); + // Without a canvas the first image makes it. + m_importPoint = m_document ? m_pendingImports.front().point : std::nullopt; + m_importIndex = 0; + decodeNext(); +} + +void EditorSession::decodeNext() +{ + const ImportRequest &request = m_pendingImports.front(); + if (m_importIndex == request.urls.size()) { + endEdit(); + qCInfo(lcApp) << "imported a request of" << request.urls.size() << "files"; + // The caller goes on once the queue's state is settled. + if (request.done) + QMetaObject::invokeMethod(this, request.done, Qt::QueuedConnection); + m_pendingImports.pop_front(); + drainImports(); + return; + } + qint64 usedPixels = 0; + if (m_document) { + for (const ImageLayer &layer : m_document->layers) { + if (layer.asset) + usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height(); + } + } + const QUrl url = request.urls[m_importIndex]; + m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded { + try { + if (!url.isLocalFile()) + throw ImageImportError(ImageImportError::Kind::unsupported); + return {ImageImporter::decode(url.toLocalFile(), remaining), QString()}; + } catch (const ImageImportError &error) { + return {std::nullopt, url.fileName() + ": " + error.what()}; + } + })); +} + +void EditorSession::finishDecode() +{ + const Decoded decoded = m_decoder.result(); + if (decoded.asset) + insert(*decoded.asset, m_importPoint); + else + m_importFailures << decoded.failure; + m_importIndex += 1; + decodeNext(); +} diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h new file mode 100644 index 0000000..91e2550 --- /dev/null +++ b/src/Document/EditorSession+Jobs.h @@ -0,0 +1,136 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/Filters.h" +#include "Document/HueSaturation.h" +#include "Document/Selection.h" +#include "IO/ImageImporter.h" +#include "IO/ProjectStore.h" +#include <QImage> +#include <QPainterPath> +#include <QSet> +#include <QUrl> +#include <functional> +#include <map> +#include <optional> + +// The private structs of EditorSession, its private base of types. +struct SessionJobs { + // A brush family's tip while another family is in use. + struct ParkedTip { + double diameter; + double hardness; + double opacity; + }; + struct ImportRequest { + QList<QUrl> urls; + std::optional<QPointF> point; + std::function<void()> done; + }; + struct Decoded { + std::optional<ImportedImage> asset; + QString failure; + }; + struct Baked { + std::map<QUuid, ImportedImage> images; + std::optional<QString> failure; + }; + // What an invert waits for and writes back. + struct Inverting { + QUuid layerID; + bool mask; + std::optional<ImageIdentity> image; + std::optional<ImageIdentity> maskImage; + std::function<void()> done; + }; + struct Inverted { + std::optional<QImage> image; + std::optional<QString> failure; + }; + // What a wand click waits for and applies. + struct Wanding { + SelectionMode mode; + QUuid documentID; + std::function<void()> done; + }; + struct Wanded { + std::optional<QPainterPath> path; + std::optional<QString> failure; + }; + // The last stroke's end, for a Shift-click's line. + struct LastBrushPoint { + QPointF point; + QUuid layerID; + bool mask; + }; + struct PendingOpacityDigit { + int digit; + double time; + }; + // A raster edit's result on the way to the layer. + struct RasterMade { + std::optional<BrushCommit::Output> output; + std::optional<QString> failure; + }; + struct CommittingRaster { + std::shared_ptr<const BrushStroke> stroke; + QString name; + std::function<void()> alsoApply; + std::function<void()> done; + }; + // A filter's preview, and its result for the layer. + struct Filtered { + std::optional<QImage> image; + std::optional<QString> failure; + // The job's settings, which the preview was made with. + FilterSettings settings; + }; + struct FilterMade { + std::optional<ImportedImage> asset; + std::optional<LayerTransform> transform; + std::optional<QString> failure; + }; + // The crop's resized project, or why it failed. + struct Cropped { + std::optional<ProjectSnapshot> snapshot; + std::optional<QString> failure; + }; + // Levels' result for the layer. + struct Leveled { + std::optional<ImportedImage> asset; + std::optional<QString> failure; + }; + // A commit's caller, waiting for the preview or the result. + struct Committing { + QUuid editID; + std::function<void()> done; + }; + // Hue/Saturation's pixels, or why they failed. + struct Adjusted { + std::optional<AdjustedPixels> pixels; + std::optional<QString> failure; + }; + // The layers beneath an adjustment, drawn, or why not. + struct AdjustmentInput { + std::optional<ImportedImage> asset; + std::optional<QString> failure; + }; + // A Hue/Saturation commit keeps its edit and its caller. + struct CommittingHue { + HueSaturationEdit edit; + std::function<void()> done; + }; + // A merge worked out once: what goes, what it becomes. + struct MergePlan { + std::vector<QUuid> ids; + QSet<QUuid> removed; + QString name; + std::optional<QUuid> parent; + QUuid anchor; + QString action; + }; + struct TransformDuplicate { + std::vector<QUuid> copies; + QSet<QUuid> source; + QUuid primary; + }; +}; diff --git a/src/Document/EditorSession+Layers.cpp b/src/Document/EditorSession+Layers.cpp new file mode 100644 index 0000000..38f87c8 --- /dev/null +++ b/src/Document/EditorSession+Layers.cpp @@ -0,0 +1,181 @@ +#include "Document/EditorSession.h" +#include <algorithm> +#include <map> + +void EditorSession::addBlankLayer() +{ + if (!canEditLayers()) + return; + const std::vector<ImageLayer> &existing = m_document->layers; + int number = 1; + const auto taken = [&](const QString &name) { + return std::any_of(existing.begin(), existing.end(), [&](const ImageLayer &layer) { return layer.name == name; }); + }; + while (taken(QStringLiteral("Layer %1").arg(number))) + ++number; + ImageLayer layer(QStringLiteral("Layer %1").arg(number), m_document->size()); + const std::optional<ImageLayer> active = activeLayer(); + const bool intoFolder = active && active->isGroup; + layer.parentID = intoFolder ? m_activeLayerID : active ? active->parentID : std::nullopt; + if (layer.parentID) + m_collapsedGroupIDs.remove(*layer.parentID); + const int activeIndex = indexOf(existing, m_activeLayerID); + int insertion = activeIndex >= 0 ? activeIndex + 1 : int(existing.size()); + // In a folder the layer goes above its topmost contents. + if (intoFolder) { + std::map<QUuid, std::optional<QUuid>> parents; + for (const ImageLayer &each : existing) + parents[each.id] = each.parentID; + const auto isInside = [&](QUuid id) { + std::optional<QUuid> parent = parents[id]; + for (int steps = 0; parent && steps < 64; ++steps) { + if (*parent == *m_activeLayerID) + return true; + parent = parents.count(*parent) ? parents[*parent] : std::nullopt; + } + return false; + }; + for (int index = int(existing.size()) - 1; index >= 0; --index) { + if (isInside(existing[index].id)) { + insertion = std::max(insertion, index + 1); + break; + } + } + } + beginEdit(QStringLiteral("New Blank Layer")); + m_document->layers.insert(m_document->layers.begin() + insertion, layer); + setActiveLayerID(layer.id); + endEdit(); +} + +void EditorSession::deleteLayer(QUuid id) +{ + if (!canEditLayers() || indexOf(m_document->layers, id) < 0) + return; + if (!deleteWithLiveMaskChoice({id})) + finishDeletingLayer(id, {}); +} + +void EditorSession::deleteActiveLayer() +{ + if (m_activeLayerID) + deleteLayer(*m_activeLayerID); +} + +void EditorSession::deleteSelectedLayers() +{ + if (!canEditLayers()) + return; + // Captured first: deleting moves the active layer. + std::vector<QUuid> ids; + for (const ImageLayer &layer : m_document->layers) { + if (m_selectedLayerIDs.contains(layer.id)) + ids.push_back(layer.id); + } + if (ids.size() <= 1) + deleteActiveLayer(); + else if (!deleteWithLiveMaskChoice(ids)) + finishDeletingLayers(ids, {}); +} + +void EditorSession::renameLayer(QUuid id, const QString &name) +{ + const QString trimmed = name.trimmed(); + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (m_isProjectBusy || m_isImporting || trimmed.isEmpty() || index < 0) + return; + beginEdit(QStringLiteral("Rename Layer")); + m_document->layers[index].name = trimmed; + endEdit(); +} + +void EditorSession::toggleLayerVisibility(QUuid id) +{ + const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1; + if (index < 0) + return; + beginEdit(m_document->layers[index].isVisible ? QStringLiteral("Hide Layer") : QStringLiteral("Show Layer")); + m_document->layers[index].isVisible = !m_document->layers[index].isVisible; + endEdit(); +} + +std::optional<bool> EditorSession::beginVisibilitySwipe(QUuid id) +{ + const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1; + if (index < 0) + return std::nullopt; + const bool visible = !m_document->layers[index].isVisible; + beginEdit(visible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer")); + setVisibilityInSwipe(id, visible); + return visible; +} + +void EditorSession::setVisibilityInSwipe(QUuid id, bool visible) +{ + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (index < 0 || m_document->layers[index].isVisible == visible) + return; + m_document->layers[index].isVisible = visible; + notify(); +} + +void EditorSession::endVisibilitySwipe() +{ + endEdit(); +} + +void EditorSession::reorderLayers(const std::set<int> &offsets, int destination) +{ + if (!canEditLayers()) + return; + std::vector<ImageLayer> listed(m_document->layers.rbegin(), m_document->layers.rend()); + const int count = int(listed.size()); + const bool inRange = std::all_of(offsets.begin(), offsets.end(), [&](int offset) { return offset >= 0 && offset < count; }); + if (!inRange || destination < 0 || destination > count) + return; + // SwiftUI's move: the picked rows land before `destination`. + std::vector<ImageLayer> moved, kept; + int before = 0; + for (int index = 0; index < count; ++index) { + const bool picked = offsets.count(index) > 0; + (picked ? moved : kept).push_back(listed[index]); + before += picked && index < destination; + } + kept.insert(kept.begin() + (destination - before), moved.begin(), moved.end()); + beginEdit(QStringLiteral("Reorder Layers")); + m_document->layers.assign(kept.rbegin(), kept.rend()); + endEdit(); +} + +bool EditorSession::canMoveActiveLayer(int offset) const +{ + const std::optional<ImageLayer> active = canEditLayers() ? activeLayer() : std::nullopt; + if (!active) + return false; + int position = -1, siblings = 0; + for (const ImageLayer &layer : m_document->layers) { + if (layer.parentID != active->parentID) + continue; + if (layer.id == active->id) + position = siblings; + ++siblings; + } + return position >= 0 && position + offset >= 0 && position + offset < siblings; +} + +void EditorSession::moveActiveLayer(int offset) +{ + if (!canMoveActiveLayer(offset)) + return; + const ImageLayer active = *activeLayer(); + std::vector<int> siblings; + for (int index = 0; index < int(m_document->layers.size()); ++index) { + if (m_document->layers[index].parentID == active.parentID) + siblings.push_back(index); + } + const int from = indexOf(m_document->layers, active.id); + const auto position = std::find(siblings.begin(), siblings.end(), from) - siblings.begin(); + beginEdit(QStringLiteral("Reorder Layers")); + std::swap(m_document->layers[from], m_document->layers[siblings[position + offset]]); + endEdit(); +} diff --git a/src/Document/EditorSession+Model.cpp b/src/Document/EditorSession+Model.cpp new file mode 100644 index 0000000..eb1ad7a --- /dev/null +++ b/src/Document/EditorSession+Model.cpp @@ -0,0 +1,126 @@ +#include "Document/EditorSession+Model.h" +#include "IO/ProjectStore.h" +#include <algorithm> + +ImageLayer::ImageLayer(ImportedImage imported, QPointF origin) + : id(QUuid::createUuid()), asset(std::move(imported)), + transform{.origin = origin, .size = QSizeF(asset->size())}, name(asset->name) +{ +} + +int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id) +{ + const auto found = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return id && layer.id == *id; }); + return found == layers.end() ? -1 : int(found - layers.begin()); +} + +ImageLayer::ImageLayer(QString name, QSizeF blankSize) + : id(QUuid::createUuid()), transform{.origin = QPointF(0, 0), .size = blankSize}, name(std::move(name)) +{ +} + +ImageLayer::ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask) + : id(record.id), asset(std::move(asset)), transform(record.transform), name(record.name), isVisible(record.isVisible), + parentID(record.parentID), isGroup(record.isGroup == true), opacity(record.opacity.value_or(1)), + blendMode(record.blendMode.value_or(LayerBlendMode::normal)), maskSourceID(record.maskSourceID), mask(std::move(mask)), + adjustment(record.adjustment), effects(record.effects) +{ +} + +bool operator==(const ImageLayer &lhs, const ImageLayer &rhs) +{ + const bool sameImage = lhs.asset.has_value() == rhs.asset.has_value() + && (!lhs.asset || lhs.asset->identity() == rhs.asset->identity()); + return lhs.id == rhs.id && lhs.name == rhs.name && lhs.isVisible == rhs.isVisible + && lhs.transform == rhs.transform && sameImage && lhs.parentID == rhs.parentID + && lhs.isGroup == rhs.isGroup && lhs.opacity == rhs.opacity && lhs.blendMode == rhs.blendMode + && lhs.mask == rhs.mask && lhs.maskSourceID == rhs.maskSourceID && lhs.adjustment == rhs.adjustment && lhs.shape == rhs.shape + && lhs.text == rhs.text && lhs.effects == rhs.effects; +} + +CanvasDocument::CanvasDocument(int width, int height) + : id(QUuid::createUuid()), width(width), height(height) +{ +} + +CanvasDocument::CanvasDocument(const ProjectSnapshot &snapshot) + : id(snapshot.manifest.documentID), width(int(snapshot.manifest.width)), height(int(snapshot.manifest.height)), + resolution(snapshot.manifest.resolution.value_or(72)) +{ + for (const ProjectLayerRecord &record : snapshot.manifest.layers) { + const auto image = snapshot.images.find(record.id); + layers.push_back(ImageLayer(record, image == snapshot.images.end() ? std::nullopt : std::optional(image->second), + snapshot.mask(record))); + } +} + +namespace { +// Swift's CharacterSet.whitespaces: category Zs and tab. +bool isWhitespace(QChar character) +{ + return character == u'\t' || character.category() == QChar::Separator_Space; +} + +// Swift's Int(String): optional sign, then ASCII digits only. +std::optional<int> wholeNumber(QStringView text) +{ + qsizetype index = text.startsWith(u'+') || text.startsWith(u'-') ? 1 : 0; + if (index == text.size()) + return std::nullopt; + for (; index < text.size(); ++index) { + if (text[index] < u'0' || text[index] > u'9') + return std::nullopt; + } + bool fits = false; + const int number = text.toInt(&fits); + return fits ? std::optional<int>(number) : std::nullopt; +} +} + +std::optional<int> CanvasDocument::validDimension(const QString &value) +{ + qsizetype first = 0, last = value.size(); + while (first < last && isWhitespace(value[first])) + ++first; + while (last > first && isWhitespace(value[last - 1])) + --last; + const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first)); + if (!number || *number < 1 || *number > 30'000) + return std::nullopt; + return number; +} + +QString label(NavigationTool tool) +{ + switch (tool) { + case NavigationTool::move: return QStringLiteral("Move / Transform (V)"); + case NavigationTool::marquee: return QStringLiteral("Marquee (M)"); + case NavigationTool::lasso: return QStringLiteral("Lasso (L)"); + case NavigationTool::wand: return QStringLiteral("Magic Wand (W)"); + case NavigationTool::crop: return QStringLiteral("Crop (C)"); + case NavigationTool::brush: return QStringLiteral("Brush (B) · Eraser (E)"); + case NavigationTool::spotHealing: return QStringLiteral("Spot Healing Brush (J)"); + case NavigationTool::cloneStamp: return QStringLiteral("Clone Stamp (S) · Alt-click sets the source"); + case NavigationTool::blur: return QStringLiteral("Smear (R)"); + case NavigationTool::gradient: return QStringLiteral("Gradient (G)"); + case NavigationTool::shape: return QStringLiteral("Shape (U) · Shift-U switches Rectangle/Ellipse"); + case NavigationTool::type: return QStringLiteral("Type (T)"); + case NavigationTool::eyedropper: return QStringLiteral("Eyedropper (I)"); + case NavigationTool::hand: return QStringLiteral("Hand (H)"); + case NavigationTool::zoom: return QStringLiteral("Zoom (Z)"); + // No tool sits in no rail: nothing to name. + case NavigationTool::idle: break; + } + throw std::logic_error("no tool has no label"); +} + +bool isBrushTool(NavigationTool tool) +{ + return tool == NavigationTool::brush || tool == NavigationTool::spotHealing + || tool == NavigationTool::cloneStamp || tool == NavigationTool::blur; +} + +bool isSelectionTool(NavigationTool tool) +{ + return tool == NavigationTool::marquee || tool == NavigationTool::lasso || tool == NavigationTool::wand; +} diff --git a/src/Document/EditorSession+Model.h b/src/Document/EditorSession+Model.h new file mode 100644 index 0000000..b02358b --- /dev/null +++ b/src/Document/EditorSession+Model.h @@ -0,0 +1,85 @@ +#pragma once +#include "Document/LayerAdjustment.h" +#include "Document/LayerAppearance.h" +#include "Document/LayerGroups.h" +#include "Document/LayerMask.h" +#include "Document/LayerTransform.h" +#include "Document/Selection.h" +#include "Document/ShapeTool.h" +#include "Document/TypeTool.h" +#include "IO/ImageImporter.h" +#include <QString> +#include <QUuid> +#include <optional> +#include <vector> + +struct ImageLayer { + QUuid id; + std::optional<ImportedImage> asset; + LayerTransform transform; + QString name; + bool isVisible = true; + std::optional<QUuid> parentID; + bool isGroup = false; + double opacity = 1; + LayerBlendMode blendMode = LayerBlendMode::normal; + std::optional<QUuid> maskSourceID; + std::optional<LayerMask> mask; + std::optional<LayerAdjustment> adjustment; + // Set on layers the Shape tool made; see `liveShape`. + std::optional<LayerShape> shape; + // A stroke, shadows or overlay drawn round the pixels. + std::optional<LayerEffects> effects; + std::optional<LayerText> text; + + ImageLayer(ImportedImage imported, QPointF origin); + ImageLayer(QString name, QSizeF blankSize); + // A layer as a project records it. + ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask); + + QPointF origin() const { return transform.origin; } + QSizeF size() const { return transform.size; } + ProjectLayerRecord hierarchyRecord() const; + LayerTransform maskTransform() const; + // The shape this layer still is: none once repainted otherwise. + std::optional<LayerShape> liveShape() const; + // The text this layer still is: none once repainted otherwise. + std::optional<LayerText> liveText() const; + friend bool operator==(const ImageLayer &lhs, const ImageLayer &rhs); +}; + +// The layer's place in the list, or -1. +int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id); + +struct ProjectSnapshot; + +struct CanvasDocument { + QUuid id; + int width; + int height; + double resolution = 72; + std::vector<ImageLayer> layers; // Bottom to top. + // Part of the document so undo covers it; never saved. + std::optional<DocumentSelection> selection; + + CanvasDocument(int width, int height); + // The canvas a project holds, each layer as recorded. + explicit CanvasDocument(const ProjectSnapshot &snapshot); + + QSizeF size() const { return QSizeF(width, height); } + static std::optional<int> validDimension(const QString &value); + std::vector<LayerHierarchy::Entry> hierarchyEntries() const; + QSet<QUuid> effectiveVisibleIDs() const; + std::vector<ImageLayer> renderLayers() const; + friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default; +}; + +enum class NavigationTool { + move, marquee, lasso, wand, crop, brush, spotHealing, cloneStamp, blur, + gradient, shape, type, eyedropper, hand, zoom, idle +}; + +// The rail's tooltip: the tool's name and its key. +QString label(NavigationTool tool); +bool isBrushTool(NavigationTool tool); +bool isSelectionTool(NavigationTool tool); diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp new file mode 100644 index 0000000..aed3d85 --- /dev/null +++ b/src/Document/EditorSession+Projects.cpp @@ -0,0 +1,71 @@ +#include "Document/EditorSession.h" +#include "Logging.h" + +std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const +{ + if (!m_document) + return std::nullopt; + ProjectSnapshot snapshot{.manifest = {.resolution = m_document->resolution, .documentID = m_document->id, .width = m_document->width, + .height = m_document->height, .activeLayerID = m_activeLayerID, .layers = {}}, + .images = {}}; + for (const ImageLayer &layer : m_document->layers) { + if (layer.asset) + snapshot.images.insert({layer.id, *layer.asset}); + if (layer.mask) + snapshot.masks.insert({layer.id, layer.mask->asset}); + snapshot.manifest.layers.push_back(layer.hierarchyRecord()); + } + return snapshot; +} + +void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path) +{ + m_collapsedGroupIDs.clear(); + m_isMaskSelected = false; + m_cropRect.reset(); + m_transformEdit = std::nullopt; + m_document = CanvasDocument(snapshot); + // Install loads shapes and text; Swift's resize rebuild does not. + for (size_t index = 0; index < m_document->layers.size(); ++index) { + ImageLayer &layer = m_document->layers[index]; + layer.shape = LayerShape::loaded(snapshot.manifest.layers[index].shape, layer.asset); + layer.text = LayerText::loaded(snapshot.manifest.layers[index].text, layer.asset); + } + setActiveLayerID(snapshot.manifest.activeLayerID); + m_projectPath = path; + m_renamingLayerID = std::nullopt; + history.reset(); + viewport.fit(m_document->size()); + qCInfo(lcApp).noquote() << "installed a project of" << int(m_document->layers.size()) << "layers from" << path; + resumeFileRequests(); + notify(); +} + +void EditorSession::setProjectPath(std::optional<QString> path) +{ + m_projectPath = std::move(path); + notify(); +} + +void EditorSession::clearProject() +{ + m_collapsedGroupIDs.clear(); + m_isMaskSelected = false; + m_cropRect.reset(); + m_transformEdit = std::nullopt; + m_document = std::nullopt; + setActiveLayerID(std::nullopt); + m_renamingLayerID = std::nullopt; + m_projectPath = std::nullopt; + history.reset(); + resumeFileRequests(); + notify(); +} + +void EditorSession::createNewProject(int width, int height) +{ + if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000) + return; + clearProject(); + createDocument(width, height, true); +} diff --git a/src/Document/EditorSession+Projects.h b/src/Document/EditorSession+Projects.h new file mode 100644 index 0000000..ecd4297 --- /dev/null +++ b/src/Document/EditorSession+Projects.h @@ -0,0 +1,7 @@ +// Swift's EditorSession+Projects extension: snapshots in and out. +public: + std::optional<ProjectSnapshot> projectSnapshot() const; + // Only a package that loaded and validated whole is installed. + void installProject(const ProjectSnapshot &snapshot, const QString &path); + void clearProject(); + void createNewProject(int width, int height); diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp new file mode 100644 index 0000000..8b5284f --- /dev/null +++ b/src/Document/EditorSession+Transform.cpp @@ -0,0 +1,296 @@ +#include "Document/Distort.h" +#include "Document/EditorSession.h" +#include <algorithm> + +bool EditorSession::canTransform() const +{ + if (!canEditLayers()) + return false; + if (transformsAsGroup()) + return !groupTransformMembers().empty(); + const std::optional<ImageLayer> active = activeLayer(); + return active && active->asset && m_document->effectiveVisibleIDs().contains(active->id); +} + +bool EditorSession::transformsAsGroup() const +{ + const std::optional<ImageLayer> active = activeLayer(); + return m_selectedLayerIDs.size() > 1 || (m_selectedLayerIDs.size() == 1 && active && active->isGroup); +} + +std::vector<ImageLayer> EditorSession::groupTransformMembers() const +{ + if (!transformsAsGroup() || !m_document) + return {}; + QHash<QUuid, std::optional<QUuid>> parents; + for (const ImageLayer &layer : m_document->layers) + parents.insert(layer.id, layer.parentID); + const QSet<QUuid> visible = m_document->effectiveVisibleIDs(); + std::vector<ImageLayer> members; + for (const ImageLayer &layer : m_document->layers) { + if (!layer.asset || !visible.contains(layer.id)) + continue; + // Selected itself, or inside a selected folder. + std::optional<QUuid> current = layer.id; + for (int step = 0; step < 64 && current; ++step) { + if (m_selectedLayerIDs.contains(*current)) { + members.push_back(layer); + break; + } + current = parents.value(*current); + } + } + return members; +} + +std::optional<LayerTransform> EditorSession::groupTransformBox() const +{ + const std::vector<ImageLayer> members = groupTransformMembers(); + if (members.empty()) + return std::nullopt; + QPointF low = DistortWarp::corners(members.front().transform)[0], high = low; + for (const ImageLayer &member : members) { + for (const QPointF corner : DistortWarp::corners(member.transform)) { + low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())}; + high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())}; + } + } + // Rounding can leave a unit layer's box under one pixel. + return LayerTransform{.origin = low, .size = {std::max(1.0, high.x() - low.x()), std::max(1.0, high.y() - low.y())}}; +} + +void EditorSession::beginTransform(bool persistent) +{ + cancelCrop(); + const std::optional<ImageLayer> layer = activeLayer(); + if (!canTransform() || !layer) + return; + m_tool = NavigationTool::move; + if (transformsAsGroup()) { + const std::optional<LayerTransform> box = groupTransformBox(); + if (!box) + return; + QHash<QUuid, LayerTransform> originals; + for (const ImageLayer &member : groupTransformMembers()) + originals.insert(member.id, member.transform); + m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr}; + } else { + // Linked, layer and mask move together as the layer. + const bool maskAlone = transformTargetsMask(); + m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr}; + } + notify(); +} + +void EditorSession::setTransformAutoSelect(bool picks) +{ + m_transformAutoSelect = picks; + notify(); +} + +void EditorSession::setShowsTransformControls(bool shows) +{ + m_showsTransformControls = shows; + notify(); +} + +void EditorSession::setLocksTransformRatio(bool locks) +{ + m_locksTransformRatio = locks; + notify(); +} + +// A folder has no pixels to copy: it just moves. +void EditorSession::beginDuplicateTransform() +{ + if (m_transformDuplicate || !m_activeLayerID) + return; + const QUuid primary = *m_activeLayerID; + commitTransform(); + if (!canTransform()) + return; + const QSet<QUuid> selection = m_selectedLayerIDs; + // Bottom to top: the copies keep their order. + std::vector<QUuid> targets; + for (const ImageLayer &layer : m_document->layers) { + if (selection.contains(layer.id) && !layer.isGroup) + targets.push_back(layer.id); + } + if (targets.empty()) + return; + beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer")); + std::vector<QUuid> copies; + for (const QUuid &id : targets) { + selectLayer(id); + duplicateActiveLayer(); + copies.push_back(m_activeLayerID.value()); + } + m_transformDuplicate = TransformDuplicate{copies, selection, primary}; + selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back()); + beginTransform(false); +} + +// The duplicate's step closes with its transform. +void EditorSession::endDuplicateTransform() +{ + if (!m_transformDuplicate) + return; + m_transformDuplicate = std::nullopt; + endEdit(); +} + +void EditorSession::previewTransform(const LayerTransform &value) +{ + if (!value.isValid() || !m_transformEdit) + return; + m_transformEdit->draft = value; + notify(); +} + +void EditorSession::commitTransform() +{ + snapGuides = {}; + // Whatever ends a transform ends these two as well. + if (m_blendPreview) { + m_blendPreview = std::nullopt; + notify(); + } + finishOpacityEdit(); + if (!m_transformEdit) + return; + if (m_transformEdit->floating) { + // Unchanged: restored exactly, so soft edges keep no seam. + const TransformEdit edit = *std::exchange(m_transformEdit, std::nullopt); + if (edit.draft == edit.floating->original && !edit.corners) + cancelFloatingTransform(*edit.floating); + else + mergeFloatingTransform(edit, *edit.floating); + return; + } + if (m_transformEdit->mask) { + commitMaskTransform(); + return; + } + if (m_transformEdit->corners) { + const TransformEdit edit = *m_transformEdit; + m_transformEdit = std::nullopt; + commitDistort(edit, *edit.corners); + endDuplicateTransform(); + return; + } + // One transaction: an edit that moves nothing records nothing. + beginEdit(m_transformEdit->group ? QStringLiteral("Transform Layers") : QStringLiteral("Transform Layer")); + for (int index = 0; index < int(m_document->layers.size()); ++index) { + ImageLayer &layer = m_document->layers[size_t(index)]; + // A layer the box squeezes under a pixel stays put. + const std::optional<LayerTransform> moved = pendingTransform(layer); + if (!moved || !moved->isValid()) + continue; + // A linked mask goes along; an unlinked one stays. + if (layer.mask) + layer.mask->placement = layer.mask->placementMovingLayer(layer.transform, *moved); + layer.transform = *moved; + redrawShape(index); + } + m_transformEdit = std::nullopt; + endEdit(); + endDuplicateTransform(); +} + +void EditorSession::cancelTransform() +{ + snapGuides = {}; + if (!m_transformEdit) + return; + const std::shared_ptr<const FloatingTransform> floating = std::exchange(m_transformEdit, std::nullopt)->floating; + if (m_transformDuplicate) { + // The copies go and the old selection returns. + const TransformDuplicate duplicate = *m_transformDuplicate; + std::erase_if(m_document->layers, [&](const ImageLayer &layer) { + return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end(); + }); + selectLayers(duplicate.source, duplicate.primary); + endDuplicateTransform(); + } + if (floating) + cancelFloatingTransform(*floating); + notify(); +} + +std::optional<QSizeF> EditorSession::transformPixelSize() const +{ + if (m_transformEdit && m_transformEdit->group) + return m_transformEdit->group->box.size; + if (!m_transformEdit && transformsAsGroup()) { + const std::optional<LayerTransform> box = groupTransformBox(); + return box ? std::optional(box->size) : std::nullopt; + } + // A mask has no pixel size to scale against. + if (transformTargetsMask()) + return std::nullopt; + if (m_transformEdit && m_transformEdit->floating) + return m_transformEdit->floating->pixelSize; + const std::optional<ImageLayer> active = activeLayer(); + return active && active->asset ? std::optional(QSizeF(active->asset->size())) : std::nullopt; +} + +bool EditorSession::transformTargetsMask() const +{ + if (m_transformEdit) + return m_transformEdit->mask; + const std::optional<ImageLayer> active = activeLayer(); + return m_isMaskSelected && active && active->mask && !active->mask->isLinked; +} + +LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const +{ + if (const std::optional<LayerTransform> pending = pendingTransform(layer)) + return *pending; + // Content-Aware Fill past the edge previews on the grown layer. + if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id)) + return *m_filterEdit->grownTransform; + return layer.transform; +} + +LayerTransform EditorSession::editedTransform(const ImageLayer &layer) const +{ + if (m_transformEdit && m_transformEdit->layerID == layer.id) + return m_transformEdit->draft; + // Before an edit the handles sit on the group's box. + if (!m_transformEdit && layer.id == m_activeLayerID && transformsAsGroup()) + return groupTransformBox().value_or(layer.transform); + return layer.id == m_activeLayerID && transformTargetsMask() ? layer.maskTransform() : layer.transform; +} + +std::optional<LayerTransform> EditorSession::pendingTransform(const ImageLayer &layer) const +{ + // An edit of the mask alone moves no layer. + if (!m_transformEdit || m_transformEdit->mask) + return std::nullopt; + if (m_transformEdit->group) { + const TransformGroup &group = *m_transformEdit->group; + const auto original = group.originals.constFind(layer.id); + return original == group.originals.constEnd() ? std::nullopt : std::optional(original->following(group.box, m_transformEdit->draft)); + } + return m_transformEdit->layerID == layer.id ? std::optional(m_transformEdit->draft) : std::nullopt; +} + +void EditorSession::nudgeLayer(double dx, double dy) +{ + const bool alreadyEditing = m_transformEdit.has_value(); + if (!alreadyEditing) + beginTransform(false); + if (!m_transformEdit) + return; + LayerTransform value = m_transformEdit->draft; + value.origin += QPointF(dx, dy); + previewTransform(value); + if (const std::optional<Corners> corners = m_transformEdit->corners) { + Corners moved = *corners; + for (QPointF &corner : moved) + corner += QPointF(dx, dy); + previewCorners(moved); + } + if (!alreadyEditing) + commitTransform(); +} diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp new file mode 100644 index 0000000..c6419a6 --- /dev/null +++ b/src/Document/EditorSession.cpp @@ -0,0 +1,347 @@ +#include "Document/EditorSession.h" +#include "Logging.h" +#include <cmath> + +EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(this) +{ + // A coarse timer may fire a twentieth early. + m_busyTimer.setTimerType(Qt::PreciseTimer); + m_busyTimer.setSingleShot(true); + m_busyTimer.setInterval(busyIndicatorDelay); + connect(&m_busyTimer, &QTimer::timeout, this, [this] { + m_showsBusy = true; + notify(); + }); + connect(&m_decoder, &QFutureWatcher<Decoded>::finished, this, &EditorSession::finishDecode); + connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake); + connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert); + connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand); + connect(&m_filterPreview, &QFutureWatcher<Filtered>::finished, this, &EditorSession::finishFilterPreview); + connect(&m_filterCommit, &QFutureWatcher<FilterMade>::finished, this, &EditorSession::finishFilterCommit); + connect(&m_cropCommit, &QFutureWatcher<Cropped>::finished, this, &EditorSession::finishCropCommit); + connect(&m_levelsHistogram, &QFutureWatcher<std::optional<LevelsHistogram>>::finished, this, &EditorSession::finishLevelsHistogram); + connect(&m_levelsPreview, &QFutureWatcher<std::optional<QImage>>::finished, this, &EditorSession::finishLevelsPreview); + connect(&m_levelsCommit, &QFutureWatcher<Leveled>::finished, this, &EditorSession::finishLevelsCommit); + connect(&m_hueSaturationTask, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationPreview); + connect(&m_hueSaturationCommit, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationCommit); + connect(&m_rasterCommit, &QFutureWatcher<RasterMade>::finished, this, &EditorSession::finishRasterCommit); +} + +void EditorSession::setActiveLayerID(std::optional<QUuid> id) +{ + // Another layer: its pixels are the target again. + if (id != m_activeLayerID) + m_isMaskSelected = false; + m_activeLayerID = id; + m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>(); + notify(); +} + +std::optional<ImageLayer> EditorSession::activeLayer() const +{ + if (!m_document || !m_activeLayerID) + return std::nullopt; + const auto found = std::find_if(m_document->layers.begin(), m_document->layers.end(), + [&](const ImageLayer &layer) { return layer.id == *m_activeLayerID; }); + return found == m_document->layers.end() ? std::nullopt : std::optional(*found); +} + +void EditorSession::setIsProjectBusy(bool busy) +{ + m_isProjectBusy = busy; + // Freed waiters each look again when their turn comes. + if (!busy) { + for (const std::function<void()> &waiter : std::exchange(m_projectWaiters, {})) + QMetaObject::invokeMethod(this, [this, waiter] { waitForProjectAccess(waiter); }, Qt::QueuedConnection); + } + resumeFileRequests(); + updateBusyIndicator(); + notify(); +} + +void EditorSession::setShowsNewDocument(bool shows) +{ + m_showsNewDocument = shows; + resumeFileRequests(); + notify(); +} + +void EditorSession::setShowsImporter(bool shows) +{ + m_showsImporter = shows; + resumeFileRequests(); + notify(); +} + +void EditorSession::setIsImporting(bool importing) +{ + m_isImporting = importing; + resumeFileRequests(); + notify(); +} + +void EditorSession::setImportError(std::optional<QString> error) +{ + m_importError = std::move(error); + resumeFileRequests(); + notify(); +} + +void EditorSession::setBrushError(std::optional<QString> error) +{ + m_brushError = std::move(error); + notify(); +} + +void EditorSession::setRenamingLayerID(std::optional<QUuid> id) +{ + m_renamingLayerID = id; + resumeFileRequests(); + notify(); +} + +void EditorSession::setAdjustmentEditingID(std::optional<QUuid> id) +{ + m_adjustmentEditingID = id; + resumeFileRequests(); + notify(); +} + +void EditorSession::selectLayer(std::optional<QUuid> id) +{ + dropEffectSelection(); + if (id != m_activeLayerID && !finishText()) + return; + if (m_brushStroke || m_warpStroke || m_levels) + return; + if (id != m_activeLayerID) { + commitTransform(); + resolveGradient(); + } + setActiveLayerID(id); +} + +void EditorSession::selectTool(NavigationTool value) +{ + if (m_tool != value && !finishText()) + return; + if (m_isProjectBusy || m_brushStroke || m_warpStroke || m_levels) + return; + if (m_tool != value) { + commitTransform(); + cancelCrop(); + resolveGradient(); + cancelLasso(); + cancelShape(); + } + // Each family's tip parks on leaving; sessions begin in 0. + const int from = tipFamily(m_tool), to = tipFamily(value); + if (from != to) { + const ParkedTip parked = m_parkedBrushTips.at(to); + m_parkedBrushTips[from] = ParkedTip{m_brushSettings.diameter, m_brushSettings.hardness, m_brushSettings.opacity}; + m_brushSettings.diameter = parked.diameter; + m_brushSettings.hardness = parked.hardness; + m_brushSettings.opacity = parked.opacity; + } + m_tool = value; + // The Crop tool opens on the whole canvas, freely. + if (value == NavigationTool::crop && !m_cropRect && m_document) { + m_cropRatioChoice = QStringLiteral("Free"); + m_cropRect = QRectF(QPointF(0, 0), m_document->size()); + } + notify(); +} + +int EditorSession::tipFamily(NavigationTool tool) +{ + return tool == NavigationTool::cloneStamp ? 1 : tool == NavigationTool::blur ? 2 : 0; +} + +// Tab: the tool's next mode; others arrive with their tools. +void EditorSession::cycleToolMode() +{ + if (m_isProjectBusy || m_brushStroke || m_warpStroke) + return; + if (m_tool == NavigationTool::marquee) + toggleMarqueeKind(); + else if (m_tool == NavigationTool::lasso) + toggleLassoKind(); + else if (m_tool == NavigationTool::shape) + toggleShapeKind(); + else if (m_tool == NavigationTool::brush) + setBrushMode(m_brushMode == BrushToolMode::paint ? BrushToolMode::erase : BrushToolMode::paint); + else if (m_tool == NavigationTool::gradient) + setGradientSettings(GradientSettings{allGradientShapes[(size_t(m_gradientSettings.shape) + 1) % allGradientShapes.size()], m_gradientSettings.style, + m_gradientSettings.reversed, m_gradientSettings.opacity}); + else if (m_tool == NavigationTool::blur) + setBlurMode(allBlurToolModes[(size_t(m_blurMode) + 1) % allBlurToolModes.size()]); + else if (m_tool == NavigationTool::spotHealing) + setSpotHealingMode(allSpotHealingModes[(size_t(m_spotHealingMode) + 1) % allSpotHealingModes.size()]); + else if (m_tool == NavigationTool::cloneStamp) + setCloneSettings(CloneSettings{m_cloneSettings.aligned, !m_cloneSettings.sampleAllLayers}); +} + +bool EditorSession::canUseHistory() const +{ + return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter + && !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels; +} + +void EditorSession::undo() +{ + // As Photoshop, the first Undo discards a pending gradient. + if (m_gradientEdit) { + cancelGradient(); + return; + } + if (!canUndo()) + return; + if (const std::optional<DocumentHistory::Snapshot> snapshot = history.undo()) + restore(*snapshot); +} + +void EditorSession::redo() +{ + if (!canRedo()) + return; + if (const std::optional<DocumentHistory::Snapshot> snapshot = history.redo()) + restore(*snapshot); +} + +void EditorSession::restore(const DocumentHistory::Snapshot &snapshot) +{ + cancelCrop(); + cancelGradient(); + const auto canvas = [](const std::optional<CanvasDocument> &document) { return document ? std::optional(document->id) : std::nullopt; }; + const bool changedCanvas = canvas(m_document) != canvas(snapshot.document); + // The mask stays the target while layer and mask remain. + const bool keepsMaskTarget = m_isMaskSelected && m_activeLayerID == snapshot.activeLayerID; + m_document = snapshot.document; + setActiveLayerID(snapshot.activeLayerID); + const std::optional<ImageLayer> active = activeLayer(); + m_isMaskSelected = keepsMaskTarget && active && active->mask; + if (changedCanvas && m_document) + viewport.fit(m_document->size()); + qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers"; + notify(); +} + +void EditorSession::beginEdit(const QString &name) +{ + history.begin(name, m_document, m_activeLayerID); + // An open transaction closes undo and redo: observers must know. + notify(); +} + +void EditorSession::endEdit() +{ + history.end(m_document, m_activeLayerID); + notify(); +} + +bool EditorSession::canEditLayers() const +{ + return !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_showsImporter + && !m_renamingLayerID && !m_transformEdit && !m_cropRect && !m_gradientEdit && !m_filterEdit && !m_pixelMove && !m_levels + && !m_hueSaturation && !m_adjustmentEditingID; +} + +void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> centeredAt) +{ + beginEdit(QStringLiteral("Import Image")); + if (!m_document) { + m_document = CanvasDocument(asset.size().width(), asset.size().height()); + viewport.fit(m_document->size()); + } + const QPointF center = centeredAt.value_or(QPointF(m_document->width / 2.0, m_document->height / 2.0)); + ImageLayer layer(asset, QPointF(std::floor(center.x() - asset.size().width() / 2.0), std::floor(center.y() - asset.size().height() / 2.0))); + // Beside the active layer, or inside the active folder. + const std::optional<ImageLayer> active = activeLayer(); + layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt; + if (layer.parentID) + m_collapsedGroupIDs.remove(*layer.parentID); + m_document->layers.push_back(layer); + setActiveLayerID(layer.id); + endEdit(); +} + +void EditorSession::createDocument(int width, int height, bool emptyLayer) +{ + if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000) + return; + commitTransform(); + beginEdit(QStringLiteral("New Canvas")); + CanvasDocument document(width, height); + // File > New starts with a selected blank "Layer 1". + std::optional<QUuid> selected; + if (emptyLayer) { + document.layers.push_back(ImageLayer(QStringLiteral("Layer 1"), document.size())); + selected = document.layers.back().id; + } + m_document = document; + setActiveLayerID(selected); + m_renamingLayerID = std::nullopt; + viewport.fit(m_document->size()); + m_showsNewDocument = false; + // A closed sheet or an ended rename frees file requests. + resumeFileRequests(); + qCInfo(lcApp) << "new canvas" << width << "x" << height; + endEdit(); +} + +void EditorSession::fit() +{ + if (!m_document) + return; + const CanvasViewport before = viewport; + viewport.fit(m_document->size()); + if (!(viewport == before)) + notify(); +} + +void EditorSession::zoom(double value, std::optional<QPointF> anchor) +{ + if (!m_document) + return; + // The viewport refuses a zoom that is no number. + const CanvasViewport before = viewport; + viewport.setZoom(value, anchor.value_or(viewport.center()), m_document->size()); + if (!(viewport == before)) + notify(); +} + +void EditorSession::setShowsPixelGrid(bool shows) +{ + m_showsPixelGrid = shows; + notify(); +} + +void EditorSession::setCropRect(std::optional<QRectF> rect) +{ + if (std::exchange(m_cropRect, rect) != rect) + notify(); +} + +void EditorSession::setCropRatioChoice(const QString &choice) +{ + if (std::exchange(m_cropRatioChoice, choice) != choice) + notify(); +} + +void EditorSession::setCropError(std::optional<QString> error) +{ + if (std::exchange(m_cropError, error) != error) + notify(); +} + +void EditorSession::setShowsSampleRing(bool shows) +{ + m_showsSampleRing = shows; + notify(); +} + +void EditorSession::requestCanvasFocus() +{ + ++m_canvasFocusRequest; + notify(); +} diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h new file mode 100644 index 0000000..4cb9009 --- /dev/null +++ b/src/Document/EditorSession.h @@ -0,0 +1,437 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/CloneStamp.h" +#include "Document/ColorPalette.h" +#include "Document/Gradient.h" +#include "Document/Distort.h" +#include "Document/Filters.h" +#include "Document/LevelsAutomatic.h" +#include "Document/FloatingSelection.h" +#include "Document/MagicWand.h" +#include "Document/SelectionClipboard.h" +#include "Document/SelectionEdits.h" +#include "Document/SmudgeLiquify.h" +#include "Document/DocumentHistory.h" +#include "Document/EditorSession+Jobs.h" +#include "Document/EditorSession+Model.h" +#include "IO/ProjectStore.h" +#include "Rendering/CanvasViewport.h" +#include "Rendering/EffectsPreviewCache.h" +#include <QFutureWatcher> +#include <QObject> +#include <QSet> +#include <QTimer> +#include <QUrl> +#include <deque> +#include <map> +#include <functional> +#include <set> + +class QPainter; + +// The one editor state; `changed` stands in for Swift's observation. +class EditorSession : public QObject, private SessionJobs { + Q_OBJECT +public: + explicit EditorSession(QObject *parent = nullptr); + + // The canvas edits this in place, then calls notify(). + CanvasViewport viewport; + DocumentHistory history; + // Swift's @ObservationIgnored tuple: the canvas draws it unannounced. + SnapGuides snapGuides; + // The canvas's effects previews, rendered off the UI thread. + EffectsPreviewCache effectsPreviews; + + const std::optional<CanvasDocument> &document() const { return m_document; } + NavigationTool tool() const { return m_tool; } + const QSet<QUuid> &collapsedGroupIDs() const { return m_collapsedGroupIDs; } + const QSet<QUuid> &selectedLayerIDs() const { return m_selectedLayerIDs; } + std::optional<QUuid> activeLayerID() const { return m_activeLayerID; } + void setActiveLayerID(std::optional<QUuid> id); + std::optional<ImageLayer> activeLayer() const; + + // Blocks overlapping edits at once. + bool isProjectBusy() const { return m_isProjectBusy; } + void setIsProjectBusy(bool busy); + // Busy this long, in milliseconds, before the controls dim. + static constexpr int busyIndicatorDelay = 250; + bool showsBusy() const { return m_showsBusy; } + bool showsNewDocument() const { return m_showsNewDocument; } + void setShowsNewDocument(bool shows); + bool showsImporter() const { return m_showsImporter; } + void setShowsImporter(bool shows); + bool isImporting() const { return m_isImporting; } + void setIsImporting(bool importing); + std::optional<QString> importError() const { return m_importError; } + void setImportError(std::optional<QString> error); + std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; } + void setRenamingLayerID(std::optional<QUuid> id); + + // The first tab's first canvas sheet skips the clipboard. + bool skipsInitialClipboardCanvasSize = false; + // Where the project lives on disk, once opened or saved. + const std::optional<QString> &projectPath() const { return m_projectPath; } + void setProjectPath(std::optional<QString> path); + + bool canStartProjectOperation() const; + // `ready` runs at once, or when the session allows. + void waitForFileRequest(std::function<void()> ready); + void waitForProjectAccess(std::function<void()> ready); + + void selectLayer(std::optional<QUuid> id); + void selectTool(NavigationTool value); + // Tab: the current tool's next mode. + void cycleToolMode(); + + // Whether the mask thumbnail, not the layer's, is the target. + bool isMaskSelected() const { return m_isMaskSelected; } + + // A blend mode tried on by hovering; nothing is saved. + struct BlendPreview { + QUuid layerID; + LayerBlendMode mode; + friend bool operator==(const BlendPreview &, const BlendPreview &) = default; + }; + const std::optional<BlendPreview> &blendPreview() const { return m_blendPreview; } + + // A pending transform; layers change when it is committed. + const std::optional<TransformEdit> &transformEdit() const { return m_transformEdit; } + bool canTransform() const; + // Several layers, or a folder's contents, move in one box. + bool transformsAsGroup() const; + std::vector<ImageLayer> groupTransformMembers() const; + std::optional<LayerTransform> groupTransformBox() const; + void beginTransform(bool persistent = true); + // Alt-drag: the selection's copies move, one step. + void beginDuplicateTransform(); + // The Move tool's settings (TransformInspector, Ctrl+H). + bool transformAutoSelect() const { return m_transformAutoSelect; } + void setTransformAutoSelect(bool picks); + bool showsTransformControls() const { return m_showsTransformControls; } + void setShowsTransformControls(bool shows); + bool locksTransformRatio() const { return m_locksTransformRatio; } + void setLocksTransformRatio(bool locks); + void previewTransform(const LayerTransform &value); + void commitTransform(); + void cancelTransform(); + std::optional<QSizeF> transformPixelSize() const; + LayerTransform displayedTransform(const ImageLayer &layer) const; + LayerTransform editedTransform(const ImageLayer &layer) const; + std::optional<LayerTransform> pendingTransform(const ImageLayer &layer) const; + // An unlinked mask, selected, transforms without its layer. + bool transformTargetsMask() const; + void nudgeLayer(double dx, double dy); + + const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; } + LassoKind lassoKind() const { return m_lassoKind; } + void setLassoKind(LassoKind kind); + LassoKind marqueeKind() const { return m_marqueeKind; } + void setMarqueeKind(LassoKind kind); + // The options bar's mode; Shift adds, Alt subtracts. + SelectionMode selectionModeChoice() const { return m_selectionModeChoice; } + void setSelectionModeChoice(SelectionMode mode); + std::optional<SelectionMode> heldSelectionMode() const { return m_heldSelectionMode; } + bool selectionAntialiased() const { return m_selectionAntialiased; } + void setSelectionAntialiased(bool antialiased); + // Pixels Expand and Contract grow or shrink by. + int selectionExpandAmount() const { return m_selectionExpandAmount; } + void setSelectionExpandAmount(int amount); + int selectionContractAmount() const { return m_selectionContractAmount; } + void setSelectionContractAmount(int amount); + const std::optional<DocumentSelection> &selectionMoveOrigin() const { return m_selectionMoveOrigin; } + const WandSettings &wandSettings() const { return m_wandSettings; } + void setWandSettings(const WandSettings &settings); + const BrushStroke *brushStroke() const { return m_brushStroke.get(); } + const BrushSettings &brushSettings() const { return m_brushSettings; } + void setBrushSettings(const BrushSettings &settings); + BrushToolMode brushMode() const { return m_brushMode; } + void setBrushMode(BrushToolMode mode); + BlurToolMode blurMode() const { return m_blurMode; } + void setBlurMode(BlurToolMode mode); + // Smudge or Liquify under way; the canvas shows its copy. + const WarpStroke *warpStroke() const { return m_warpStroke.get(); } + SpotHealingMode spotHealingMode() const { return m_spotHealingMode; } + void setSpotHealingMode(SpotHealingMode mode); + bool maskPaintWhite() const { return m_maskPaintWhite; } + void setMaskPaintWhite(bool white); + const PaletteColor &backgroundColor() const { return m_backgroundColor; } + void setBackgroundColor(const PaletteColor &color); + // The open colour picker; the session notifies its changes. + const std::optional<ColorPickerState> &colorPicker() const { return m_colorPicker; } + const GradientSettings &gradientSettings() const { return m_gradientSettings; } + void setGradientSettings(const GradientSettings &settings); + // A gradient drawn and not yet applied. + const std::optional<GradientEdit> &gradientEdit() const { return m_gradientEdit; } + // Clone Stamp's Alt-clicked source, in document pixels, and options. + const std::optional<QPointF> &cloneSource() const { return m_cloneSource; } + const CloneSettings &cloneSettings() const { return m_cloneSettings; } + void setCloneSettings(const CloneSettings &settings); + // Counts every change to a stroke, so the canvas redraws. + int brushRevision() const { return m_brushRevision; } + // Selected pixels on the move, from a Ctrl-drag or Ctrl-arrow. + const PixelMove *pixelMove() const { return m_pixelMove.get(); } + + // The one filter open; its sheet shows while it lasts. + const std::optional<FilterEdit> &filterEdit() const { return m_filterEdit; } + // The last committed filter's settings, where the next one starts. + const FilterSettings &filterSettings() const { return m_filterSettings; } + const std::optional<LevelsEdit> &levels() const { return m_levels; } + const std::optional<HueSaturationEdit> &hueSaturation() const { return m_hueSaturation; } + // The newest preview, waiting for the running one. + const std::optional<HueSaturationJob> &hueSaturationPending() const { return m_hueSaturationPending; } + std::optional<HueSampleMode> hueSampleMode() const { return m_hueSampleMode; } + bool hueTargeting() const { return m_hueTargeting; } + // The adjustment layer the colour editors write to. + std::optional<QUuid> adjustmentEditingID() const { return m_adjustmentEditingID; } + void setAdjustmentEditingID(std::optional<QUuid> id); + // Its settings as its editor opened, for Cancel. + const std::optional<LayerAdjustment> &adjustmentOriginal() const { return m_adjustmentOriginal; } + // The effect whose panel is open, bound to its layer. + const std::optional<LayerEffectSelection> &effectsEditing() const { return m_effectsEditing; } + void setEffectsEditing(std::optional<LayerEffectSelection> selection); + // Its layer's effects as the panel opened, for Cancel. + const std::optional<LayerEffects> &effectsEditingOriginal() const { return m_effectsEditingOriginal; } + void setEffectsEditingOriginal(std::optional<LayerEffects> original); + // The effect chosen in the list; see selectedEffect. + const std::optional<LayerEffectSelection> &effectSelection() const { return m_effectSelection; } + + bool isModified() const { return history.isModified(); } + bool canUseHistory() const; + // Undo's first press discards a pending gradient. + bool canUndo() const { return canUseHistory() && (history.canUndo() || m_gradientEdit); } + bool canRedo() const { return canUseHistory() && history.canRedo(); } + void undo(); + void redo(); + // A transaction that nests; a whole gesture is one entry. + void beginEdit(const QString &name); + void endEdit(); + + bool canEditLayers() const; + void addBlankLayer(); + void deleteLayer(QUuid id); + void deleteActiveLayer(); + // Every selected layer, folders with their contents: one step. + void deleteSelectedLayers(); + void renameLayer(QUuid id, const QString &name); + void toggleLayerVisibility(QUuid id); + // Pressing an eye, then dragging over others: one undo step. + std::optional<bool> beginVisibilitySwipe(QUuid id); + void setVisibilityInSwipe(QUuid id, bool visible); + void endVisibilitySwipe(); + // The list runs top down; the document stores bottom up. + void reorderLayers(const std::set<int> &offsets, int destination); + bool canMoveActiveLayer(int offset) const; + void moveActiveLayer(int offset); + + // Files decode off the UI thread; `done` follows this request. + void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); + void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt); + void createDocument(int width, int height, bool emptyLayer = false); + void fit(); + void zoom(double value, std::optional<QPointF> anchor = std::nullopt); + // The View menu's grid, shown from 800%. + bool showsPixelGrid() const { return m_showsPixelGrid; } + void setShowsPixelGrid(bool shows); + // The Eyedropper's ring: the sampled colour over the original. + bool showsSampleRing() const { return m_showsSampleRing; } + void setShowsSampleRing(bool shows); + // The Crop tool's frame in document pixels, once drawn. + const std::optional<QRectF> &cropRect() const { return m_cropRect; } + void setCropRect(std::optional<QRectF> rect); + // Swift's ratio names: Free, Original, 1:1, 4:3 and 16:9. + const QString &cropRatioChoice() const { return m_cropRatioChoice; } + void setCropRatioChoice(const QString &choice); + const std::optional<QString> &cropError() const { return m_cropError; } + void setCropError(std::optional<QString> error); + // Counted requests; the canvas takes focus on each new one. + int canvasFocusRequest() const { return m_canvasFocusRequest; } + void requestCanvasFocus(); + + // What Paste puts back in place, until another copy. + const std::optional<PixelClipboard> &pixelClipboard() const { return m_pixelClipboard; } + // What went wrong in a tool; the window shows it. + const std::optional<QString> &brushError() const { return m_brushError; } + void setBrushError(std::optional<QString> error); + + void notify() { emit changed(); } + + // Swift's extensions, each declared in its twin's header. +#include "Document/EditorSession+Projects.h" +#include "IO/ImageResizer+Session.h" +#include "Document/LayerMask+Session.h" +#include "Document/LayerAppearance+Session.h" +#include "Document/Crop+Session.h" +#include "Document/Distort+Session.h" +#include "Document/LayerMerge+Session.h" +#include "Document/LayerFlip+Session.h" +#include "Document/Selection+Session.h" +#include "Document/MagicWand+Session.h" +#include "Document/EditorSession+Brush.h" +#include "Document/CloneStamp+Session.h" +#include "Document/ColorPalette+Session.h" +#include "Document/Gradient+Session.h" +#include "Document/ShapeTool+Session.h" +#include "Document/TypeTool+Session.h" +#include "Document/SmudgeLiquify+Session.h" +#include "Document/BlurTool+Session.h" +#include "Document/SelectionEdits+Session.h" +#include "Document/SelectionClipboard+Session.h" +#include "Document/FloatingSelection+Session.h" +#include "Document/Filters+Session.h" +#include "Document/Levels+Session.h" +#include "Document/LevelsAutomatic+Session.h" +#include "Document/HueSaturation+Session.h" +#include "Document/MaskTracing+Session.h" +#include "Document/LayerGroups+Session.h" +#include "Document/LiveLayerMask+Session.h" +#include "Document/LayerAdjustment+Session.h" +#include "Document/AdjustmentEditing+Session.h" +#include "Document/LayerEffects+Session.h" +signals: + void changed(); + +private: + // Copies layers into this document in place, as Swift does. + friend class ProjectWorkspace; + void restore(const DocumentHistory::Snapshot &snapshot); + void resumeFileRequests(); + void updateBusyIndicator(); + void drainImports(); + void decodeNext(); + void finishDecode(); + void endDuplicateTransform(); + // Clone Stamp is family 1, the Smear 2, others 0. + static int tipFamily(NavigationTool tool); + + std::optional<CanvasDocument> m_document; + NavigationTool m_tool = NavigationTool::move; + QSet<QUuid> m_collapsedGroupIDs; + QSet<QUuid> m_selectedLayerIDs; + // The canvas reads previews from a const session: mutable caches. + mutable std::map<QUuid, DistortPreviewCache> m_distortPreviewCache; + mutable std::map<QUuid, DistortEffectsCache> m_distortEffectsCache; + mutable std::optional<MaskDistortPreviewCache> m_maskDistortPreviewCache; + + std::optional<TransformEdit> m_transformEdit; + std::optional<TransformDuplicate> m_transformDuplicate; + bool m_transformAutoSelect = false; + bool m_showsTransformControls = true; + bool m_locksTransformRatio = true; + std::optional<BlendPreview> m_blendPreview; + std::optional<QUuid> m_opacityEditLayerID; + bool m_isMaskSelected = false; + std::optional<QUuid> m_activeLayerID; + bool m_isProjectBusy = false; + bool m_showsNewDocument = false; + bool m_showsImporter = false; + bool m_isImporting = false; + std::optional<QString> m_importError; + std::optional<QUuid> m_renamingLayerID; + std::optional<QString> m_projectPath; + bool m_showsPixelGrid = true; + bool m_showsSampleRing = true; + std::optional<QRectF> m_cropRect; + QString m_cropRatioChoice = QStringLiteral("Free"); + std::optional<QString> m_cropError; + QFutureWatcher<Cropped> m_cropCommit; + std::function<void()> m_committingCrop; + int m_canvasFocusRequest = 0; + bool m_showsBusy = false; + QTimer m_busyTimer; + std::vector<std::function<void()>> m_projectWaiters; + std::vector<std::function<void()>> m_fileRequestWaiters; + std::deque<ImportRequest> m_pendingImports; + // Where the request now decoding stands. + qsizetype m_importIndex = 0; + std::optional<QPointF> m_importPoint; + QStringList m_importFailures; + QFutureWatcher<Decoded> m_decoder; + std::optional<QString> m_brushError; + // The layers a bake under way will delete. + std::vector<QUuid> m_bakingIDs; + QFutureWatcher<Baked> m_baker; + std::optional<Inverting> m_inverting; + QFutureWatcher<Inverted> m_inverter; + WandSettings m_wandSettings; + std::optional<Wanding> m_wanding; + QFutureWatcher<Wanded> m_wand; + std::unique_ptr<BrushStroke> m_brushStroke; + std::unique_ptr<PixelMove> m_pixelMove; + BrushSettings m_brushSettings; + BrushToolMode m_brushMode = BrushToolMode::paint; + BlurToolMode m_blurMode = BlurToolMode::liquify; + std::unique_ptr<WarpStroke> m_warpStroke; + SpotHealingMode m_spotHealingMode = SpotHealingMode::contentAware; + bool m_maskPaintWhite = false; + PaletteColor m_backgroundColor = PaletteColor::white(); + std::optional<ColorPickerState> m_colorPicker; + GradientSettings m_gradientSettings; + std::optional<GradientEdit> m_gradientEdit; + ShapeKind m_shapeKind = ShapeKind::rectangle; + double m_shapeCornerRadius = 0; + double m_shapeLineWidth = 4; + std::optional<ShapeDraft> m_shapeDraft; + mutable std::map<QUuid, ShapePreview> m_shapeTransformPreviewCache; + std::optional<TextDraft> m_textDraft; + LayerTextStyle m_textDefaults; + std::optional<QPointF> m_cloneSource; + CloneSettings m_cloneSettings; + // The offset aligned strokes keep, fixed as a stroke begins. + std::optional<QSizeF> m_cloneOffset; + // Clone Stamp and the Smear keep their own soft tips. + std::map<int, ParkedTip> m_parkedBrushTips{{1, {40, 0, 1}}, {2, {40, 0, 1}}}; + std::optional<LastBrushPoint> m_lastBrushPoint; + int m_brushRevision = 0; + std::optional<PendingOpacityDigit> m_pendingOpacityDigit; + std::optional<CommittingRaster> m_committingRaster; + QFutureWatcher<RasterMade> m_rasterCommit; + std::optional<FilterEdit> m_filterEdit; + FilterSettings m_filterSettings; + QFutureWatcher<Filtered> m_filterPreview; + // Out until its finish runs: setFuture drops a pending signal. + bool m_filterRendering = false; + // Which edit's preview is being made; a cancelled edit's drops. + QUuid m_filterPreviewFor; + std::vector<Committing> m_filterCommitWaiting; + std::optional<Committing> m_committingFilter; + QFutureWatcher<FilterMade> m_filterCommit; + std::optional<LevelsEdit> m_levels; + // Which edit a running preview serves; others drop. + QUuid m_levelsPreviewFor; + QFutureWatcher<std::optional<QImage>> m_levelsPreview; + bool m_levelsRendering = false; + QFutureWatcher<std::optional<LevelsHistogram>> m_levelsHistogram; + std::optional<Committing> m_committingLevels; + QFutureWatcher<Leveled> m_levelsCommit; + std::optional<HueSaturationEdit> m_hueSaturation; + std::optional<HueSaturationJob> m_hueSaturationPending; + // Which edit a running preview serves; others drop. + QUuid m_hueSaturationPreviewFor; + QFutureWatcher<Adjusted> m_hueSaturationTask; + bool m_hueSaturationRendering = false; + std::optional<CommittingHue> m_committingHueSaturation; + QFutureWatcher<Adjusted> m_hueSaturationCommit; + std::optional<HueSampleMode> m_hueSampleMode; + bool m_hueTargeting = false; + std::optional<HueTargetDrag> m_hueTargetDrag; + std::optional<QUuid> m_adjustmentEditingID; + std::optional<LayerAdjustment> m_adjustmentOriginal; + std::optional<LayerEffectSelection> m_effectsEditing; + std::optional<LayerEffects> m_effectsEditingOriginal; + std::optional<LayerEffectSelection> m_effectSelection; + std::optional<PixelClipboard> m_pixelClipboard; + // Swift's pasteboard change count, counted from the first copy. + int m_clipboardChanges = 0; + bool m_watchingClipboard = false; + std::optional<LassoDraft> m_lassoDraft; + LassoKind m_lassoKind = LassoKind::freehand; + LassoKind m_marqueeKind = LassoKind::rectangle; + SelectionMode m_selectionModeChoice = SelectionMode::replace; + std::optional<SelectionMode> m_heldSelectionMode; + // The selection as a drag began; one step per drag. + std::optional<DocumentSelection> m_selectionMoveOrigin; + bool m_selectionAntialiased = true; + int m_selectionExpandAmount = 1; + int m_selectionContractAmount = 1; +}; diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp new file mode 100644 index 0000000..ab0ec6e --- /dev/null +++ b/src/Document/Filters+Session.cpp @@ -0,0 +1,335 @@ +#include "Document/BrushStroke.h" +#include "Document/ContentFill.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "Document/SubjectRemoval.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Logging.h" +#include <QtConcurrent> + +// Swift's session extension in Filters.swift. + +// `canAdjustColors` already refuses an empty selection. +bool EditorSession::canContentAwareFill() const +{ + return canAdjustColors() && selection().has_value() && !m_hueSaturation; +} + +void EditorSession::beginFilter(FilterKind kind) +{ + if (kind == FilterKind::contentAwareFill && !canContentAwareFill()) + return; + // Swift beeps; `canAdjustColors` reads the open filter. + if (m_hueSaturation || !canAdjustColors()) { + qCWarning(lcApp) << "a filter cannot open here"; + return; + } + // A pending gradient lands first, then the filter begins. + if (m_gradientEdit) { + commitGradient([this, kind] { beginFilter(kind); }); + return; + } + commitTransform(); + cancelCrop(); + cancelLasso(); + const ImageLayer layer = activeLayer().value(); + const QSizeF size = m_document->size(); + const std::optional<DocumentSelection> current = selection(); + FilterSettings settings = m_filterSettings; + // Gradient Map starts from the palette, as Photoshop's. + if (kind == FilterKind::gradientMap) + settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)}; + try { + // Content-Aware Fill extends the layer over the selection's reach. + std::optional<QRectF> area; + if (kind == FilterKind::contentAwareFill && current) { + const QRectF box = current->path.boundingRect().intersected(QRectF(QPointF(0, 0), size)); + if (!box.isEmpty()) + area = box; + } + m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area); + updateFilter(m_filterEdit->settings, true); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +void EditorSession::updateFilter(const FilterSettings &settings, bool preview) +{ + if (!m_filterEdit || m_filterEdit->committing) + return; + FilterEdit &edit = *m_filterEdit; + edit.settings = settings.normalized(); + edit.preview = preview; + // A bigger blur needs more room round the layer. + if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) { + try { + edit.growForBlur(); + edit.preparedPreview.reset(); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + } + // An adjustment layer takes the settings; its pixels stay. + if (previewAdjustmentEditing(preview)) { + notify(); + return; + } + // Automatic kinds render once. + if (isAutomatic(edit.kind) && edit.preparedPreview && edit.preparedSettings == edit.settings) { + ++m_brushRevision; + notify(); + return; + } + if (!preview) { + // A job behind another edit's run is Swift's running task. + if (m_filterRendering && m_filterPreviewFor == edit.id) + edit.pending.reset(); + edit.preparedPreview.reset(); + ++m_brushRevision; + notify(); + return; + } + edit.pending = edit.previewJob(); + renderFilterPreview(); +} + +// Renders the newest job; one that arrives mid-render waits. +void EditorSession::renderFilterPreview() +{ + if (!m_filterEdit || !m_filterEdit->pending) + return; + FilterEdit &edit = *m_filterEdit; + edit.preparing = true; + edit.previewError = std::nullopt; + // One worker at a time: a job waits its turn. + if (!m_filterRendering) { + const FilterJob job = *std::exchange(edit.pending, std::nullopt); + m_filterPreviewFor = edit.id; + m_filterRendering = true; + m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered { + try { + return Filtered{PixelFilter::run(job), std::nullopt, job.settings}; + } catch (const ContentFillError &error) { + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + } catch (const SubjectRemovalError &error) { + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + } catch (const ExportError &error) { + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + } + })); + } + notify(); +} + +void EditorSession::finishFilterPreview() +{ + m_filterRendering = false; + const Filtered result = m_filterPreview.result(); + // An edit cancelled meanwhile takes nothing; a new one waited. + if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { + FilterEdit &edit = *m_filterEdit; + edit.preparing = false; + edit.previewError = result.failure; + if (edit.preview || isAutomatic(edit.kind)) { + edit.preparedPreview = result.image; + edit.preparedSettings = result.settings; + ++m_brushRevision; + } + notify(); + } + renderFilterPreview(); + // Each waiting commit looks again; a gone edit's just returns. + for (const Committing &waiter : std::exchange(m_filterCommitWaiting, {})) { + if (m_filterEdit && m_filterEdit->id == waiter.editID) + commitFilter(waiter.done); + else + QMetaObject::invokeMethod(this, waiter.done, Qt::QueuedConnection); + } +} + +void EditorSession::cancelFilter() +{ + // A Gradient Map colour being picked goes with the panel. + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) + closeColorPicker(false); + if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing) + return; + m_filterEdit.reset(); + ++m_brushRevision; + notify(); +} + +void EditorSession::commitFilter(std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) + closeColorPicker(true); + if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) { + finish(); + return; + } + FilterEdit &edit = *m_filterEdit; + // An automatic filter commits what its preview made, once delivered. + if (isAutomatic(edit.kind) && edit.preparing) { + m_filterCommitWaiting.push_back(Committing{edit.id, done ? done : [] {}}); + return; + } + if (isAutomatic(edit.kind) && (!edit.preparedPreview || edit.previewError)) { + finish(); + return; + } + // Nothing to change closes as Cancel does, with no step. + if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0) + || (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings()) + || (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)) { + cancelFilter(); + finish(); + return; + } + edit.committing = true; + // Swift cancels the running preview: its result and queue drop. + edit.pending.reset(); + m_filterPreviewFor = QUuid(); + m_filterSettings = edit.settings; + setIsProjectBusy(true); + m_committingFilter = Committing{edit.id, std::move(done)}; + // A mask hides the background, which can come back. + if (edit.kind == FilterKind::removeBackground) { + commitBackgroundMask(edit); + return; + } + const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt; + const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur; + // A painted layer flattens in the worker, which catches failures. + m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = edit.settings, + selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached, + grown = edit.grownTransform, spreads]() -> FilterMade { + try { + QImage image = cached ? *cached : PixelFilter::run(FilterJob{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed}); + std::optional<LayerTransform> placed = grown; + // A blur is cut back to the pixels it left. + if (spreads && grown) { + const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, *grown); + image = trimmed.image; + placed = trimmed.transform; + } + return FilterMade{ImportedImage(image, PixelAdjust::thumbnail(image), rawValue(kind)), placed, std::nullopt}; + } catch (const ContentFillError &error) { + return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::commitBackgroundMask(const FilterEdit &edit) +{ + // A mask in the layer's own grid is multiplied in. + const int index = indexOf(m_document->layers, edit.layerID); + std::optional<ImportedImage> existing; + if (index >= 0 && m_document->layers[index].mask) { + const LayerMask &owned = *m_document->layers[index].mask; + if (!owned.placement && owned.asset.size() == edit.original.size()) + existing = owned.asset; + } + // Painted pixels and masks flatten in the worker. + m_filterCommit.setFuture(QtConcurrent::run([original = edit.original, existing, selection = edit.selection, mapping = edit.mapping, + settings = edit.settings]() -> FilterMade { + try { + const QImage source = original.image(); + const std::optional<QImage> under = existing ? std::optional(existing->image()) : std::nullopt; + QImage mask = SubjectRemoval::subjectMask(source, under, settings); + // With a selection, only its part of the mask changes. + if (selection) { + QImage base = under ? *under : BrushRaster::context(source.width(), source.height(), true); + if (!under) + base.fill(255); + mask = PixelAdjust::blend(mask, base, *selection, mapping, true); + } + return FilterMade{LayerMask::assetFrom(mask), std::nullopt, std::nullopt}; + } catch (const SubjectRemovalError &error) { + return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishFilterCommit() +{ + const FilterMade made = m_filterCommit.result(); + const FilterEdit edit = std::exchange(m_filterEdit, std::nullopt).value(); + const Committing pending = std::exchange(m_committingFilter, std::nullopt).value(); + // The preview stayed up till now: the canvas never flashed. + const auto finish = [&] { + ++m_brushRevision; + setIsProjectBusy(false); + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); + }; + if (made.failure) { + setBrushError(made.failure); + finish(); + return; + } + const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1; + if (index < 0) { + finish(); + return; + } + const ImageLayer current = m_document->layers[index]; + if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) { + finish(); + return; + } + if (edit.kind == FilterKind::removeBackground) { + beginEdit(rawValue(edit.kind)); + // Swift keeps the mask's switches; a placement would misplace it. + LayerMask mask = current.mask ? current.mask->replacing(*made.asset) : LayerMask(*made.asset); + mask.isEnabled = true; + mask.placement = std::nullopt; + m_document->layers[index].mask = mask; + m_isMaskSelected = true; + endEdit(); + finish(); + return; + } + try { + // The mask moves onto the new box, edge tone beyond. + std::optional<LayerMask> mask = current.mask; + if (edit.grownTransform && current.mask && !current.mask->placement + && (current.mask->asset.size().width() > 1 || current.mask->asset.size().height() > 1)) { + LayerMask enabled = *current.mask; + enabled.isEnabled = true; + const QSize size = made.asset->size(); + const std::optional<QImage> carried = enabled.clipImage(current.transform, made.transform.value(), size.width(), size.height()); + if (!carried) + throw ExportError(ExportError::Kind::render); + mask = current.mask->replacing(LayerMask::assetFrom(*carried)); + } + beginEdit(rawValue(edit.kind)); + ImageLayer layer = current; + layer.asset = made.asset; + layer.transform = made.transform.value_or(current.transform); + layer.mask = mask; + // Swift rebuilds the layer without shape, effects and text. + layer.shape = std::nullopt; + layer.effects = std::nullopt; + layer.text = std::nullopt; + m_document->layers[index] = layer; + endEdit(); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + finish(); +} diff --git a/src/Document/Filters+Session.h b/src/Document/Filters+Session.h new file mode 100644 index 0000000..d4df866 --- /dev/null +++ b/src/Document/Filters+Session.h @@ -0,0 +1,16 @@ +// Swift's Filters extension: one filter at a time. +public: + bool canContentAwareFill() const; + void beginFilter(FilterKind kind); + // The settings normalized; Preview off drops the queue and preview. + void updateFilter(const FilterSettings &settings, bool preview); + void cancelFilter(); + // Off the UI thread; `done` follows the step or refusal. + void commitFilter(std::function<void()> done = {}); + +private: + void renderFilterPreview(); + void finishFilterPreview(); + void finishFilterCommit(); + // Swift's commitBackgroundMask: the subject kept by a mask. + void commitBackgroundMask(const FilterEdit &edit); diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp new file mode 100644 index 0000000..784f9fb --- /dev/null +++ b/src/Document/Filters.cpp @@ -0,0 +1,242 @@ +#include "Document/Filters.h" +#include "Document/BrushStroke.h" +#include "Document/ContentFill.h" +#include "Document/EditorSession+Model.h" +#include "Document/PixelAdjust.h" +#include "Document/SubjectRemoval.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <algorithm> +#include <cmath> +#include <numbers> + +extern "C" { +#include "BrushPixels.h" +#include "LensPixels.h" +#include "NoisePixels.h" +} + +QString rawValue(FilterKind kind) +{ + switch (kind) { + case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur"); + case FilterKind::motionBlur: return QStringLiteral("Motion Blur"); + case FilterKind::addNoise: return QStringLiteral("Add Noise"); + case FilterKind::lensCorrection: return QStringLiteral("Lens Correction"); + case FilterKind::removeBackground: return QStringLiteral("Remove Background"); + case FilterKind::contentAwareFill: return QStringLiteral("Content-Aware Fill"); + case FilterKind::curves: return QStringLiteral("Curves"); + case FilterKind::exposure: return QStringLiteral("Exposure"); + case FilterKind::gradientMap: return QStringLiteral("Gradient Map"); + case FilterKind::grain: return QStringLiteral("Grain"); + } + throw std::logic_error("unknown filter kind"); +} + +bool isAutomatic(FilterKind kind) +{ + return kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground; +} + +QString rawValue(BackgroundQuality quality) +{ + return quality == BackgroundQuality::basic ? QStringLiteral("Basic") : QStringLiteral("Advanced"); +} + +bool isImageAdjustment(FilterKind kind) +{ + return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain; +} + +FilterSettings FilterSettings::normalized() const +{ + using ImageAdjustmentPixels::clamp; + FilterSettings result = *this; + result.radius = clamp(radius, 0.1, 250, 1); + result.angle = clamp(angle, -90, 90, 0); + result.distance = clamp(distance, 1, 2000, 10); + result.amount = clamp(amount, 0.1, 400, 10); + result.distortion = clamp(distortion, -100, 100, 0); + result.refineEdges = clamp(refineEdges, 0, 40, 12); + result.matteContrast = clamp(matteContrast, 0, 100, 25); + result.shiftEdge = clamp(shiftEdge, -10, 10, 0); + result.exposure = exposure.normalized(); + result.gradientMap = gradientMap.normalized(); + result.grain = grain.normalized(); + return result; +} + +PixelFilter::Trimmed PixelFilter::trimmed(const QImage &image, const LayerTransform &placed) +{ + const QRectF full(image.rect()); + // Bounds read premultiplied bytes; other formats get drawn. + QImage pixels = image; + if (pixels.format() != QImage::Format_RGBA8888_Premultiplied) { + pixels = BrushRaster::context(image.width(), image.height(), false); + QPainter painter(&pixels); + BrushRaster::draw(image, full, painter); + } + size_t edges[4] = {0, 0, 0, 0}; + brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges); + const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1])); + if (crop.width() < 1 || crop.height() < 1 || QRectF(crop) == full) + return {image, placed}; + LayerTransform result = placed; + result.size = {crop.width() * placed.size.width() / full.width(), crop.height() * placed.size.height() / full.height()}; + const QPointF middle = BrushRaster::pixelToDocument(placed, image.width(), image.height()).map(QRectF(crop).center()); + result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2}; + // Swift's crop shares pixels; a copy may find no memory. + const QImage cropped = image.copy(crop); + if (cropped.isNull()) + throw ExportError(ExportError::Kind::render); + return {cropped, result}; +} + +namespace { +// The image drawn into a fresh premultiplied context. +QImage drawn(const QImage &image) +{ + QImage context = BrushRaster::context(image.width(), image.height(), false); + QPainter painter(&context); + BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); + return context; +} +} + +QImage PixelFilter::run(const FilterJob &job) +{ + const FilterSettings settings = job.settings.normalized(); + const int width = job.image.width(), height = job.image.height(); + QImage image; + switch (job.kind) { + case FilterKind::curves: image = settings.curves.apply(job.image); break; + case FilterKind::exposure: image = settings.exposure.apply(job.image); break; + case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break; + // Grain sits in layer pixels; the job's seed patterns it. + case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break; + case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break; + case FilterKind::contentAwareFill: image = ContentFill::run(job); break; + // Unclamped: a blur spreads into the room made for it. + case FilterKind::gaussianBlur: image = PixelAdjust::gaussianBlur(drawn(job.image), settings.radius * job.scale, false); break; + case FilterKind::motionBlur: + image = PixelAdjust::motionBlur(drawn(job.image), settings.distance * job.scale * motionRadiusPerPixel, settings.angle * std::numbers::pi / 180); + break; + case FilterKind::addNoise: + image = drawn(job.image); + noise_add(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0, + settings.monochromatic ? 1 : 0, job.seed); + break; + case FilterKind::lensCorrection: { + // Relative to the image's size, so previews bend alike. + const QImage source = drawn(job.image); + image = BrushRaster::context(width, height, false); + lens_distort(source.constBits(), image.bits(), size_t(width), size_t(height), size_t(source.bytesPerLine()), settings.distortion / 100 * lensStrength); + break; + } + } + if (!job.selection) + return image; + return PixelAdjust::blend(image, job.image, *job.selection, job.mapping, false); +} + +namespace { +QRectF integral(const QRectF &rect) +{ + return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom()))); +} +} + +FilterEdit::FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings, + std::optional<QRectF> area) + : kind(kind), layerID(layer.id), original(layer.asset ? *layer.asset : throw ProjectError(ProjectError::Kind::invalid)), + transform(layer.transform), selection(std::move(selection)), settings(settings.normalized()) +{ + prepare(original, transform); + if (area) + grow(integral(mapping.inverted().mapRect(*area))); + growForBlur(); +} + +double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings) +{ + switch (kind) { + case FilterKind::gaussianBlur: return settings.radius * 3 + 2; + case FilterKind::motionBlur: return settings.distance / 2 + 2; + default: return 0; + } +} + +void FilterEdit::growForBlur() +{ + const double margin = blurMargin(kind, settings); + if (!(margin > grownMargin)) + return; + const QSize size = original.size(); + grow(QRectF(-margin, -margin, size.width() + 2 * margin, size.height() + 2 * margin)); +} + +// The layer's pixels in a grid covering `extent` (layer pixels). +void FilterEdit::grow(const QRectF &extent) +{ + const QSize size = original.size(); + const QRectF bounds(0, 0, size.width(), size.height()); + const QRectF target = integral(bounds.united(extent)); + if (target == bounds) + return; + if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false); + const QRectF inside = bounds.translated(-target.left(), -target.top()); + QPainter painter(&grown); + if (original.raster) + original.raster->draw(inside, painter); + else + BrushRaster::draw(original.image(), inside, painter); + painter.end(); + const QTransform toDocument = BrushRaster::pixelToDocument(transform, size.width(), size.height()); + LayerTransform expanded = transform; + expanded.size = {target.width() * transform.size.width() / bounds.width(), target.height() * transform.size.height() / bounds.height()}; + const QPointF middle = toDocument.map(target.center()); + expanded.origin = {middle.x() - expanded.size.width() / 2, middle.y() - expanded.size.height() / 2}; + grownImage = grown; + grownTransform = expanded; + grownMargin = std::min({bounds.left() - target.left(), bounds.top() - target.top(), target.right() - bounds.right(), target.bottom() - bounds.bottom()}); + prepare(ImportedImage(grown, QImage(), QString()), expanded); +} + +void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed) +{ + const QSize size = source.size(); + mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height()); + // Noise and grain preview whole: enlarged, grain looks coarse. + const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground; + const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height())); + if (!(factor < 1)) { + previewSource = source.image(); + previewScale = 1; + previewMapping = mapping; + return; + } + const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor)); + previewSource = BrushRaster::context(width, height, false); + QPainter painter(&previewSource); + const QRectF rect(0, 0, width, height); + if (source.raster) + source.raster->draw(rect, painter); + else + BrushRaster::draw(source.image(), rect, painter); + painter.end(); + previewScale = double(width) / size.width(); + previewMapping = BrushRaster::pixelToDocument(placed, width, height); +} + +std::optional<QImage> FilterEdit::previewImage(QUuid id) const +{ + return preview && id == layerID ? preparedPreview : std::nullopt; +} + +FilterJob FilterEdit::previewJob() const +{ + return FilterJob{kind, previewSource, settings, previewScale, selection, previewMapping, seed}; +} diff --git a/src/Document/Filters.h b/src/Document/Filters.h new file mode 100644 index 0000000..c776b77 --- /dev/null +++ b/src/Document/Filters.h @@ -0,0 +1,141 @@ +#pragma once +#include "Document/Curves.h" +#include "Document/ImageAdjustments.h" +#include "Document/LayerTransform.h" +#include "Document/Selection.h" +#include "IO/ImageImporter.h" +#include <QImage> +#include <QRandomGenerator> +#include <QTransform> +#include <QUuid> +#include <array> +#include <cmath> + +struct ImageLayer; + +// The Filter and Image menus' filters. +enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain }; +// The menu's title, Swift's rawValue. +QString rawValue(FilterKind kind); +// Made without settings: its preview is its result. +bool isAutomatic(FilterKind kind); +// The Image menu's colour adjustments, not under Filter. +bool isImageAdjustment(FilterKind kind); +// Swift's allCases, the menus' order. +inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise, + FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill, + FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, + FilterKind::grain}; + +// Remove Background's mask: the model's alone, or refined. +enum class BackgroundQuality { basic, advanced }; +QString rawValue(BackgroundQuality quality); +inline constexpr std::array allBackgroundQualities{BackgroundQuality::basic, BackgroundQuality::advanced}; + +// Every filter's settings; each filter reads only its own. +struct FilterSettings { + // Gaussian Blur's radius in layer pixels: the blur's sigma. + double radius = 1; + // Motion Blur's angle, counterclockwise from horizontal, as Photoshop's. + double angle = 0; + double distance = 10; + // Add Noise's strength, Photoshop's percentage. + double amount = 10; + bool gaussian = false; + // The same noise on every channel: brightness alone. + bool monochromatic = false; + // Positive straightens barrel distortion, negative pincushion. + double distortion = 0; + CurvesSettings curves{}; + ExposureSettings exposure{}; + GradientMapSettings gradientMap{}; + GrainSettings grain{}; + BackgroundQuality backgroundQuality = BackgroundQuality::basic; + // How far, in pixels, the mask is pulled onto edges. + double refineEdges = 12; + // Pushes the mask's grays apart, 0 to 100, clearing haze. + double matteContrast = 25; + // Contracts (negative) or grows the mask's edge, in pixels. + double shiftEdge = 0; + FilterSettings normalized() const; + friend bool operator==(const FilterSettings &, const FilterSettings &) = default; +}; + +// What a filter runs on: grid, settings, selection, placement. +struct FilterJob { + FilterKind kind; + QImage image; + FilterSettings settings; + // Pixels in `image` a layer pixel: smaller previews blur less. + double scale; + std::optional<SelectionClip> selection; + QTransform mapping; + // Add Noise's pattern: the same seed, the same grain. + quint32 seed = 0; +}; + +// Swift's PixelFilter. +namespace PixelFilter { +struct Trimmed { + QImage image; + LayerTransform transform; +}; + +// CIMotionBlur's radius a streak pixel: an even streak's spread. +inline const double motionRadiusPerPixel = 1 / std::sqrt(12.0); +// Remove Distortion at ±100 moves the corners this share. +inline constexpr double lensStrength = 0.35; +// The pixels that are there, placed where they were. +Trimmed trimmed(const QImage &image, const LayerTransform &placed); +// The filter over the job's image, through its selection. +QImage run(const FilterJob &job); +} + +// An open filter: the layer's pixels, preview copy, grown grid. +class FilterEdit { +public: + // Previews render from a copy no longer than this. + static constexpr double previewLimit = 2048; + // `area`: a document area the grid must cover (Content-Aware Fill). + FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings, + std::optional<QRectF> area); + // The room a blur needs round the layer, in pixels. + static double blurMargin(FilterKind kind, const FilterSettings &settings); + // Pads the grid for the blur; it only ever grows. + void growForBlur(); + std::optional<QImage> previewImage(QUuid id) const; + FilterJob previewJob() const; + + // Swift's object identity: a preview lands on its own edit. + const QUuid id = QUuid::createUuid(); + const FilterKind kind; + const QUuid layerID; + const ImportedImage original; + const LayerTransform transform; + const std::optional<SelectionClip> selection; + QTransform mapping; + QImage previewSource; + double previewScale = 1; + QTransform previewMapping; + // The layer padded out, and the transform placing it. + std::optional<QImage> grownImage; + std::optional<LayerTransform> grownTransform; + // How far the padding reaches past every side. + double grownMargin = 0; + FilterSettings settings; + bool preview = true; + bool committing = false; + std::optional<QString> previewError; + bool preparing = false; + // Add Noise's grain, fixed while the panel is open. + const quint32 seed = QRandomGenerator::global()->generate(); + std::optional<QImage> preparedPreview; + // The settings the prepared preview was made with. + std::optional<FilterSettings> preparedSettings; + std::optional<FilterJob> pending; + +private: + void grow(const QRectF &extent); + // Swift's prepared and prepare: mapping and preview copy. + void prepare(const ImportedImage &source, const LayerTransform &placed); +}; diff --git a/src/Document/FloatingSelection+Session.h b/src/Document/FloatingSelection+Session.h new file mode 100644 index 0000000..f120c2d --- /dev/null +++ b/src/Document/FloatingSelection+Session.h @@ -0,0 +1,10 @@ +// Swift's FloatingSelection extension: Ctrl+T on selected pixels. +public: + bool canTransformSelection() const; + void transformCommand(); + void beginSelectionTransform(std::function<void()> done = {}); + std::optional<QTransform> floatingSelectionTransform(const TransformEdit &edit) const; + +private: + void mergeFloatingTransform(const TransformEdit &edit, const FloatingTransform &floating); + void cancelFloatingTransform(const FloatingTransform &floating); diff --git a/src/Document/FloatingSelection.cpp b/src/Document/FloatingSelection.cpp new file mode 100644 index 0000000..97b2ab0 --- /dev/null +++ b/src/Document/FloatingSelection.cpp @@ -0,0 +1,196 @@ +#include "Document/FloatingSelection.h" +#include "Document/BrushStroke.h" +#include "Document/Distort.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <cmath> + +// Swift's FloatingSelection extension: one "Transform Selection" step. +namespace { +QRectF integral(const QRectF &rect) +{ + return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom()))); +} +} + +bool EditorSession::canTransformSelection() const +{ + const std::optional<DocumentSelection> current = selection(); + const std::optional<ImageLayer> layer = activeLayer(); + // `canEditPixels` refuses an open edit and an empty selection. + return canEditPixels() && !m_isMaskSelected && current && layer && layer->asset; +} + +// Ctrl+T: the selected pixels with a selection, else the layer. +void EditorSession::transformCommand() +{ + if (canTransformSelection()) + beginSelectionTransform(); + else + beginTransform(); +} + +void EditorSession::beginSelectionTransform(std::function<void()> done) +{ + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (!canTransformSelection()) { + qCWarning(lcApp) << "no selected pixels to transform"; + finish(); + return; + } + const ImageLayer source = activeLayer().value(); + std::optional<CopiedPixels> lifted; + try { + lifted = renderSelectedPixels(source, false); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + finish(); + return; + } + if (!lifted) { + qCWarning(lcApp) << "no selected pixels to lift"; + finish(); + return; + } + const CanvasDocument before = m_document.value(); + const std::optional<QUuid> beforeActive = m_activeLayerID; + // The outer step: closed by the merge or the restore. + beginEdit(QStringLiteral("Transform Selection")); + clearSelectedPixels([this, source, lifted = *lifted, before, beforeActive, finish] { + const int index = m_document ? indexOf(m_document->layers, source.id) : -1; + std::optional<QImage> thumbnail; + try { + thumbnail = PixelAdjust::thumbnail(lifted.image); + } catch (const ExportError &error) { + qCWarning(lcApp) << "the floating selection could not be made:" << error.what(); + } + if (index < 0 || !thumbnail) { + m_document = before; + endEdit(); + finish(); + return; + } + ImageLayer floating(ImportedImage(lifted.image, *thumbnail, QStringLiteral("Floating Selection")), lifted.region.topLeft()); + floating.name = QStringLiteral("Floating Selection"); + floating.parentID = source.parentID; + floating.opacity = source.opacity; + floating.blendMode = source.blendMode; + m_document->layers.insert(m_document->layers.begin() + index + 1, floating); + setActiveLayerID(floating.id); + m_tool = NavigationTool::move; + const auto state = std::make_shared<const FloatingTransform>(FloatingTransform{source.id, before, beforeActive, floating.transform, lifted.region.size()}); + m_transformEdit = TransformEdit{floating.id, floating.transform, std::nullopt, false, true, std::nullopt, state}; + notify(); + finish(); + }); +} + +// Maps the original selection to where the pixels float now. +std::optional<QTransform> EditorSession::floatingSelectionTransform(const TransformEdit &edit) const +{ + if (!edit.floating) + return std::nullopt; + const int width = int(edit.floating->pixelSize.width()), height = int(edit.floating->pixelSize.height()); + return BrushRaster::pixelToDocument(edit.floating->original, width, height).inverted() * BrushRaster::pixelToDocument(edit.draft, width, height); +} + +// The pixels back into their layer, the selection moved along. +void EditorSession::mergeFloatingTransform(const TransformEdit &edit, const FloatingTransform &floating) +{ + try { + // The open edit holds both layers; checked reads suffice. + const QImage pixels = m_document->layers[indexOf(m_document->layers, edit.layerID)].asset.value().image(); + const ImageLayer source = m_document->layers[indexOf(m_document->layers, floating.sourceID)]; + // A distorted selection is warped into its shape first. + QImage placedImage = pixels; + LayerTransform placedTransform = edit.draft; + if (edit.corners) { + const DistortWarp::Trimmed warped = DistortWarp::warpTrimmed(pixels, edit.draft, *edit.corners); + placedImage = warped.image; + placedTransform = warped.transform; + } + const FloatingMerge::Merged merged = FloatingMerge::merge(placedImage, placedTransform, source); + const std::optional<DocumentSelection> current = selection(); + std::optional<DocumentSelection> moved; + if (current && edit.corners) { + const QTransform placement = BrushRaster::pixelToDocument(floating.original, int(floating.pixelSize.width()), int(floating.pixelSize.height())); + if (const std::optional<QPainterPath> path = DistortWarp::mapPath(current->path, placement, floating.pixelSize, edit.draft, *edit.corners)) + moved = DocumentSelection{*path, current->antialiased}; + } else if (const std::optional<QTransform> shift = floatingSelectionTransform(edit); current && shift) { + moved = DocumentSelection{shift->map(current->path), current->antialiased}; + } + std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return layer.id == edit.layerID; }); + ImageLayer &layer = m_document->layers[indexOf(m_document->layers, source.id)]; + layer.asset = merged.asset; + layer.transform = merged.transform; + layer.isGroup = false; + layer.mask = merged.mask; + // Swift rebuilds the layer without shape, effects and text. + layer.shape = std::nullopt; + layer.effects = std::nullopt; + layer.text = std::nullopt; + m_document->selection = moved; + setActiveLayerID(source.id); + } catch (const ProjectError &error) { + m_document = floating.before; + setActiveLayerID(floating.beforeActive); + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + m_document = floating.before; + setActiveLayerID(floating.beforeActive); + setBrushError(QString::fromUtf8(error.what())); + } + endEdit(); +} + +void EditorSession::cancelFloatingTransform(const FloatingTransform &floating) +{ + m_document = floating.before; + setActiveLayerID(floating.beforeActive); + endEdit(); +} + +FloatingMerge::Merged FloatingMerge::merge(const QImage &pixels, const LayerTransform &transform, const ImageLayer &source) +{ + if (!source.asset) + throw ProjectError(ProjectError::Kind::invalid); + const QImage sourceImage = source.asset->image(); + const int width = sourceImage.width(), height = sourceImage.height(); + const QTransform toDocument = BrushRaster::pixelToDocument(source.transform, width, height); + const QTransform toPixels = toDocument.inverted(); + const QRectF floatingBounds = toPixels.mapRect(BrushRaster::pixelToDocument(transform, pixels.width(), pixels.height()).mapRect(QRectF(pixels.rect()))); + const QRectF original(0, 0, width, height); + const QRectF extent = integral(original.united(floatingBounds)); + if (extent.width() > 30'000 || extent.height() > 30'000 || extent.width() * extent.height() > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + QImage context = BrushRaster::context(int(extent.width()), int(extent.height()), false); + const QRectF placed = original.translated(-extent.topLeft()); + QPainter painter(&context); + BrushRaster::draw(sourceImage, placed, painter); + painter.setTransform(toPixels * QTransform::fromTranslate(-extent.left(), -extent.top())); + LayerRenderer::draw(pixels, transform, transform.center(), painter, {}); + painter.end(); + const ImportedImage asset(context, PixelAdjust::thumbnail(context), source.name); + LayerTransform merged = source.transform; + merged.size = {extent.width() * source.size().width() / width, extent.height() * source.size().height() / height}; + const QPointF center = toDocument.map(extent.center()); + merged.origin = {center.x() - merged.size.width() / 2, center.y() - merged.size.height() / 2}; + std::optional<LayerMask> mask = source.mask; + if (source.mask && !source.mask->placement && extent != original) { + // The mask grows with the layer, revealing the new area. + QImage grown = BrushRaster::context(int(extent.width()), int(extent.height()), true); + grown.fill(Qt::white); + QPainter maskPainter(&grown); + BrushRaster::draw(source.mask->asset.image(), placed, maskPainter); + maskPainter.end(); + mask = source.mask->replacing(LayerMask::assetFrom(grown)); + } + return {asset, merged, mask}; +} diff --git a/src/Document/FloatingSelection.h b/src/Document/FloatingSelection.h new file mode 100644 index 0000000..5c2ea5a --- /dev/null +++ b/src/Document/FloatingSelection.h @@ -0,0 +1,23 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include "Document/LayerTransform.h" +#include <QImage> + +// Ctrl+T with a selection: the pixels float, then merge back. +struct FloatingTransform { + QUuid sourceID; + CanvasDocument before; + std::optional<QUuid> beforeActive; + LayerTransform original; + QSizeF pixelSize; +}; + +// Floating pixels drawn back onto their layer, grown as needed. +namespace FloatingMerge { +struct Merged { + ImportedImage asset; + LayerTransform transform; + std::optional<LayerMask> mask; +}; +Merged merge(const QImage &pixels, const LayerTransform &transform, const ImageLayer &source); +} diff --git a/src/Document/Gradient+Session.h b/src/Document/Gradient+Session.h new file mode 100644 index 0000000..3592a7a --- /dev/null +++ b/src/Document/Gradient+Session.h @@ -0,0 +1,14 @@ +// Swift's Gradient extension: a pending gradient, drawn until applied. +public: + void beginGradient(QPointF point); + void moveGradient(std::optional<QPointF> start, std::optional<QPointF> end); + // Redraws the pending gradient from its ends, settings and palette. + void refreshGradient(); + std::array<QColor, 2> gradientColors(bool mask) const; + // Ends a drag; a click without a line leaves nothing. + void endGradientDrag(); + void cancelGradient(); + // Off the UI thread; `done` follows the step or refusal. + void commitGradient(std::function<void()> done = {}); + // Switching tools, layers or targets applies it, as Photoshop. + void resolveGradient(); diff --git a/src/Document/Gradient.cpp b/src/Document/Gradient.cpp new file mode 100644 index 0000000..efba42f --- /dev/null +++ b/src/Document/Gradient.cpp @@ -0,0 +1,152 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include <stdexcept> + +QString rawValue(GradientStyle style) +{ + switch (style) { + case GradientStyle::foregroundToBackground: + return QStringLiteral("Foreground to Background"); + case GradientStyle::foregroundToTransparent: + return QStringLiteral("Foreground to Transparent"); + } + throw std::logic_error("unknown gradient style"); +} + +QString rawValue(GradientShape shape) +{ + switch (shape) { + case GradientShape::linear: + return QStringLiteral("Linear"); + case GradientShape::radial: + return QStringLiteral("Radial"); + } + throw std::logic_error("unknown gradient shape"); +} + +void EditorSession::setGradientSettings(const GradientSettings &settings) +{ + m_gradientSettings = settings; + refreshGradient(); + notify(); +} + +void EditorSession::beginGradient(QPointF point) +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (m_tool != NavigationTool::gradient || !(canPaint() || m_gradientEdit) || !layer) + return; + // A new line on this target replaces the pending one. + if (m_gradientEdit && m_gradientEdit->raster->layer.id == layer->id && m_gradientEdit->raster->isMask == m_isMaskSelected) { + m_gradientEdit->start = point; + m_gradientEdit->end = point; + refreshGradient(); + return; + } + if (!canPaint()) + return; + finishOpacityEdit(); + try { + m_gradientEdit = GradientEdit{std::shared_ptr<BrushStroke>(makeRasterEdit(*layer)), point, point}; + ++m_brushRevision; + notify(); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +void EditorSession::moveGradient(std::optional<QPointF> start, std::optional<QPointF> end) +{ + if (!m_gradientEdit) + return; + if (start) + m_gradientEdit->start = *start; + if (end) + m_gradientEdit->end = *end; + refreshGradient(); +} + +void EditorSession::refreshGradient() +{ + if (!m_gradientEdit) + return; + if (m_gradientEdit->hasLine()) { + try { + m_gradientEdit->raster->fillGradient(m_gradientSettings.shape, m_gradientEdit->start, m_gradientEdit->end, + gradientColors(m_gradientEdit->raster->isMask), m_gradientSettings.opacity); + } catch (const ProjectError &error) { + cancelGradient(); + setBrushError(QString::fromUtf8(error.what())); + return; + } catch (const ExportError &error) { + cancelGradient(); + setBrushError(QString::fromUtf8(error.what())); + return; + } + } + ++m_brushRevision; + notify(); +} + +std::array<QColor, 2> EditorSession::gradientColors(bool mask) const +{ + // A mask's colours are gray, the palette's red. + const auto color = [mask](const PaletteColor &value, double alpha) { + return mask ? QColor::fromRgbF(float(value.red), float(value.red), float(value.red), float(alpha)) : value.color(alpha); + }; + const PaletteColor foreground = paletteColor(false); + std::array<QColor, 2> colors = m_gradientSettings.style == GradientStyle::foregroundToBackground + ? std::array{color(foreground, 1), color(paletteColor(true), 1)} + : std::array{color(foreground, 1), color(foreground, 0)}; + if (m_gradientSettings.reversed) + std::swap(colors[0], colors[1]); + return colors; +} + +void EditorSession::endGradientDrag() +{ + if (m_gradientEdit && !m_gradientEdit->hasLine()) + cancelGradient(); +} + +void EditorSession::cancelGradient() +{ + if (!m_gradientEdit) + return; + m_gradientEdit = std::nullopt; + ++m_brushRevision; + notify(); +} + +void EditorSession::commitGradient(std::function<void()> done) +{ + if (!m_gradientEdit || m_isProjectBusy) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + if (!m_gradientEdit->hasLine()) { + cancelGradient(); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const std::shared_ptr<BrushStroke> raster = m_gradientEdit->raster; + commitRasterEdit(raster, raster->isMask ? QStringLiteral("Gradient Mask") : QStringLiteral("Gradient"), {}, [this, raster, done] { + // Swift's `gradientEdit === edit`: the same edit ends here. + if (m_gradientEdit && m_gradientEdit->raster == raster) + cancelGradient(); + if (done) + done(); + }); +} + +// Swift's `Task`: the commit starts once the caller returns. +void EditorSession::resolveGradient() +{ + if (m_gradientEdit) + QMetaObject::invokeMethod(this, [this] { commitGradient(); }, Qt::QueuedConnection); +} diff --git a/src/Document/Gradient.h b/src/Document/Gradient.h new file mode 100644 index 0000000..21f52ac --- /dev/null +++ b/src/Document/Gradient.h @@ -0,0 +1,34 @@ +#pragma once +#include <QPointF> +#include <QString> +#include <array> +#include <cmath> +#include <memory> + +class BrushStroke; + +enum class GradientStyle { foregroundToBackground, foregroundToTransparent }; +inline constexpr std::array allGradientStyles{GradientStyle::foregroundToBackground, GradientStyle::foregroundToTransparent}; +QString rawValue(GradientStyle style); + +// Linear runs start to end; radial centres on the start. +enum class GradientShape { linear, radial }; +inline constexpr std::array allGradientShapes{GradientShape::linear, GradientShape::radial}; +QString rawValue(GradientShape shape); + +struct GradientSettings { + GradientShape shape = GradientShape::linear; + GradientStyle style = GradientStyle::foregroundToTransparent; + bool reversed = false; + double opacity = 1; + friend bool operator==(const GradientSettings &, const GradientSettings &) = default; +}; + +// A pending gradient on a layer or mask, document pixels. +struct GradientEdit { + // Shared: the canvas draws it while a commit renders. + std::shared_ptr<BrushStroke> raster; + QPointF start; + QPointF end; + bool hasLine() const { return std::hypot(end.x() - start.x(), end.y() - start.y()) >= 0.5; } +}; diff --git a/src/Document/GuidedMatte.cpp b/src/Document/GuidedMatte.cpp new file mode 100644 index 0000000..8d9febf --- /dev/null +++ b/src/Document/GuidedMatte.cpp @@ -0,0 +1,124 @@ +#include "Document/GuidedMatte.h" +#include "IO/ImageExporter.h" +#include <algorithm> +#include <cmath> +#include <new> + +namespace { +// Floats; a failed allocation is a render error. +std::vector<float> floats(size_t count) +{ + try { + return std::vector<float>(count); + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } +} + +QImage checked(const QImage &image) +{ + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + return image; +} +} + +std::vector<float> GuidedMatte::box(const std::vector<float> &source, int width, int height, int radius) +{ + const float span = float(radius * 2 + 1); + const size_t stride = size_t(width); + std::vector<float> pass = floats(stride * size_t(height)); + for (int y = 0; y < height; ++y) { + const float *row = source.data() + size_t(y) * stride; + float sum = 0; + for (int x = -radius; x <= radius; ++x) + sum += row[std::clamp(x, 0, width - 1)]; + float *out = pass.data() + size_t(y) * stride; + for (int x = 0; x < width; ++x) { + out[x] = sum / span; + sum -= row[std::clamp(x - radius, 0, width - 1)]; + sum += row[std::clamp(x + radius + 1, 0, width - 1)]; + } + } + // Each column sums as Swift's, a row at a time. + std::vector<float> result = floats(stride * size_t(height)), sums = floats(stride); + for (int y = -radius; y <= radius; ++y) { + const float *row = pass.data() + size_t(std::clamp(y, 0, height - 1)) * stride; + for (size_t x = 0; x < stride; ++x) + sums[x] += row[x]; + } + for (int y = 0; y < height; ++y) { + float *out = result.data() + size_t(y) * stride; + const float *leaving = pass.data() + size_t(std::clamp(y - radius, 0, height - 1)) * stride; + const float *entering = pass.data() + size_t(std::clamp(y + radius + 1, 0, height - 1)) * stride; + for (size_t x = 0; x < stride; ++x) { + out[x] = sums[x] / span; + sums[x] -= leaving[x]; + sums[x] += entering[x]; + } + } + return result; +} + +std::vector<float> GuidedMatte::filter(const std::vector<float> &mask, const std::vector<float> &guide, int width, int height, int radius, float epsilon) +{ + const size_t count = size_t(width) * size_t(height); + const std::vector<float> meanGuide = box(guide, width, height, radius), meanMask = box(mask, width, height, radius); + std::vector<float> slope = floats(count), offset = floats(count); + { + std::vector<float> squares = floats(count), products = floats(count); + for (size_t i = 0; i < count; ++i) { + squares[i] = guide[i] * guide[i]; + products[i] = guide[i] * mask[i]; + } + const std::vector<float> meanSquares = box(squares, width, height, radius), meanProducts = box(products, width, height, radius); + for (size_t i = 0; i < count; ++i) { + const float variance = meanSquares[i] - meanGuide[i] * meanGuide[i]; + const float covariance = meanProducts[i] - meanGuide[i] * meanMask[i]; + slope[i] = covariance / (variance + epsilon); + offset[i] = meanMask[i] - slope[i] * meanGuide[i]; + } + } + const std::vector<float> meanSlope = box(slope, width, height, radius), meanOffset = box(offset, width, height, radius); + std::vector<float> result = floats(count); + for (size_t i = 0; i < count; ++i) + result[i] = std::min(1.0f, std::max(0.0f, meanSlope[i] * guide[i] + meanOffset[i])); + return result; +} + +// Premultiplied first: Qt area-filters that, not gray, when shrinking. +std::vector<float> GuidedMatte::levels(const QImage &image, int width, int height) +{ + const QImage source = checked(checked(image.convertToFormat(QImage::Format_RGBA8888_Premultiplied)) + .scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation) + .convertToFormat(QImage::Format_RGBA8888_Premultiplied)); + std::vector<float> result = floats(size_t(width) * size_t(height)); + for (int y = 0; y < height; ++y) { + const uchar *row = source.constScanLine(y); + for (int x = 0; x < width; ++x) + result[size_t(y) * size_t(width) + size_t(x)] = float(qGray(row[x * 4], row[x * 4 + 1], row[x * 4 + 2])) / 255; + } + return result; +} + +QImage GuidedMatte::image(const std::vector<float> &levels, int width, int height) +{ + QImage result = checked(QImage(width, height, QImage::Format_Grayscale8)); + for (int y = 0; y < height; ++y) { + uchar *row = result.scanLine(y); + for (int x = 0; x < width; ++x) + row[x] = uchar(std::min(255.0f, std::max(0.0f, levels[size_t(y) * size_t(width) + size_t(x)] * 255 + 0.5f))); + } + return result; +} + +QImage GuidedMatte::refine(const QImage &mask, const QImage &guide, double radius, double limit) +{ + const QSize full = mask.size(); + const double factor = std::min(1.0, limit / std::max(full.width(), full.height())); + const int width = std::max(1, int(std::round(full.width() * factor))), height = std::max(1, int(std::round(full.height() * factor))); + const int steps = std::max(1, int(std::round(radius * factor))); + const QImage small = image(filter(levels(mask, width, height), levels(guide, width, height), width, height, steps, 1e-4f), width, height); + // Drawn back up, filtered; Qt returns one already that size. + return checked(small.scaled(full, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_Grayscale8)); +} diff --git a/src/Document/GuidedMatte.h b/src/Document/GuidedMatte.h new file mode 100644 index 0000000..126830b --- /dev/null +++ b/src/Document/GuidedMatte.h @@ -0,0 +1,17 @@ +#pragma once +#include <QImage> +#include <vector> + +// Swift's GuidedMatte: a mask pulled onto its image's own edges. +namespace GuidedMatte { +// Mean over a (2r+1)² square, two running-sum passes. +std::vector<float> box(const std::vector<float> &source, int width, int height, int radius); +// `mask` refined by `guide`: both 0 to 1, one size. +std::vector<float> filter(const std::vector<float> &mask, const std::vector<float> &guide, int width, int height, int radius, float epsilon); +// `image`'s gray levels at this size, 0 to 1. +std::vector<float> levels(const QImage &image, int width, int height); +// Levels back to a gray image. +QImage image(const std::vector<float> &levels, int width, int height); +// Refined on a copy `limit` long at most, drawn back. +QImage refine(const QImage &mask, const QImage &guide, double radius, double limit); +} diff --git a/src/Document/HueSaturation+Filter.cpp b/src/Document/HueSaturation+Filter.cpp new file mode 100644 index 0000000..8e357f8 --- /dev/null +++ b/src/Document/HueSaturation+Filter.cpp @@ -0,0 +1,190 @@ +#include "Document/BrushStroke.h" +#include "Rendering/PoolMap.h" +#include "Document/HueSaturation.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <QtConcurrent> +#include <algorithm> +#include <cmath> +#include <numeric> + +namespace { +struct HSL { + double hue; + double saturation; + double lightness; +}; + +HSL toHSL(double red, double green, double blue) +{ + const double high = std::max({red, green, blue}), low = std::min({red, green, blue}); + const double lightness = (high + low) / 2, delta = high - low; + if (!(delta > 0)) + return {0, 0, lightness}; + const double saturation = delta / (1 - std::abs(2 * lightness - 1)); + double hue = high == red ? (green - blue) / delta : high == green ? (blue - red) / delta + 2 : (red - green) / delta + 4; + hue *= 60; + if (hue < 0) + hue += 360; + return {hue, std::min(1.0, saturation), lightness}; +} + +HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness) +{ + if (!(saturation > 0)) + return {lightness, lightness, lightness}; + const double chroma = (1 - std::abs(2 * lightness - 1)) * saturation, sector = hue / 60; + const double second = chroma * (1 - std::abs(std::fmod(sector, 2) - 1)), base = lightness - chroma / 2; + HueSaturationFilter::Color color{chroma, 0, second}; + switch (int(sector)) { + case 0: color = {chroma, second, 0}; break; + case 1: color = {second, chroma, 0}; break; + case 2: color = {0, chroma, second}; break; + case 3: color = {0, second, chroma}; break; + case 4: color = {second, 0, chroma}; break; + } + const auto clamp = [base](double value) { return std::min(1.0, std::max(0.0, value + base)); }; + return {clamp(color.red), clamp(color.green), clamp(color.blue)}; +} + +// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back. +void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows) +{ + constexpr int size = HueSaturationFilter::dimension, last = size - 1; + PoolMap::blocking(rows, [&](int y) { + uchar *pixel = bits + y * stride; + for (int x = 0; x < width; ++x, pixel += 4) { + const float alpha = pixel[3]; + if (alpha == 0) { + std::fill_n(pixel, 3, uchar(0)); + continue; + } + float place[3], fraction[3]; + int low[3]; + for (int channel = 0; channel < 3; ++channel) { + place[channel] = std::min(1.0f, pixel[channel] / alpha) * last; + low[channel] = std::min(int(place[channel]), last - 1); + fraction[channel] = place[channel] - float(low[channel]); + } + float out[3] = {0, 0, 0}; + for (int corner = 0; corner < 8; ++corner) { + const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1); + const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1]) + * ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]); + const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)]; + for (int channel = 0; channel < 3; ++channel) + out[channel] += share * entry[channel]; + } + for (int channel = 0; channel < 3; ++channel) + pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha)); + } + }); +} +} + +std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(const HueSaturationSettings &settings) +{ + std::vector<HueResponse> result(361); + for (int degree = 0; degree <= 360; ++degree) { + HueResponse &response = result[size_t(degree)]; + for (const auto &[colorRange, adjustment] : settings.adjustments) { + if (adjustment == RangeAdjustment()) + continue; + const double weight = settings.weight(colorRange, degree); + if (!(weight > 0)) + continue; + response.shift += adjustment.hue * weight; + response.saturation += adjustment.saturation * weight; + response.lightness += adjustment.lightness * weight; + } + } + return result; +} + +std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings) +{ + const std::vector<HueResponse> response = hueResponse(settings); + std::vector<float> values(size_t(dimension * dimension * dimension * 4)); + size_t index = 0; + const double step = dimension - 1; + for (int blue = 0; blue < dimension; ++blue) { + for (int green = 0; green < dimension; ++green) { + for (int red = 0; red < dimension; ++red) { + const Color color = adjust(red / step, green / step, blue / step, settings, response); + values[index] = float(color.red); + values[index + 1] = float(color.green); + values[index + 2] = float(color.blue); + values[index + 3] = 1; + index += 4; + } + } + } + return values; +} + +HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, double blue, const HueSaturationSettings &settings) +{ + return adjust(red, green, blue, settings, hueResponse(settings)); +} + +HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, double blue, const HueSaturationSettings &settings, + const std::vector<HueResponse> &response) +{ + auto [hue, saturation, lightness] = toHSL(red, green, blue); + double lightnessAmount = 0; + if (settings.colorize) { + hue = std::fmod(settings.hue(), 360); + saturation = std::min(1.0, std::max(0.0, settings.saturation() / 100)); + lightnessAmount = settings.lightness() / 100; + } else { + // Every range adds in, weighted by its claim. + const HueResponse &sampled = response[size_t(std::min(response.size() - 1, size_t(std::max(0.0, std::round(hue)))))]; + lightnessAmount = sampled.lightness / 100; + hue = std::fmod(hue + sampled.shift, 360); + if (hue < 0) + hue += 360; + // Multiplied, so neutral grays stay neutral. + saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100))); + } + // Toward white above zero, black below, reaching either at 100. + const double amount = std::min(1.0, std::max(-1.0, lightnessAmount)); + lightness = amount >= 0 ? lightness + (1 - lightness) * amount : lightness * (1 + amount); + return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness))); +} + +double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings) +{ + double shift = 0; + for (const auto &[colorRange, adjustment] : settings.adjustments) { + if (adjustment.hue != 0) + shift += adjustment.hue * settings.weight(colorRange, hue); + } + const double shifted = std::fmod(hue + shift, 360); + return shifted < 0 ? shifted + 360 : shifted; +} + +AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job) +{ + const int width = job.image.width(), height = job.image.height(); + QImage result = BrushRaster::context(width, height, false); + { + QPainter painter(&result); + BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter); + } + std::vector<float> table; + std::vector<int> rows; + try { + table = cube(job.settings); + rows.resize(size_t(height)); + } catch (const std::bad_alloc &) { + // Out of memory fails as a context would. + throw ExportError(ExportError::Kind::render); + } + std::iota(rows.begin(), rows.end(), 0); + applyCube(result.bits(), result.bytesPerLine(), width, table, rows); + // Swift blends over the image drawn again: the same pixels. + if (job.selection) + result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false); + return {result, job.thumbnail ? std::optional(PixelAdjust::thumbnail(result)) : std::nullopt}; +} diff --git a/src/Document/HueSaturation+Session.cpp b/src/Document/HueSaturation+Session.cpp new file mode 100644 index 0000000..5e12ce5 --- /dev/null +++ b/src/Document/HueSaturation+Session.cpp @@ -0,0 +1,276 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QtConcurrent> + +namespace { +// Swift's adjustedPixels: the render, or why it failed. +SessionJobs::Adjusted adjusted(const HueSaturationJob &job) +{ + try { + return {HueSaturationFilter::run(job), std::nullopt}; + } catch (const ExportError &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } +} +} + +// Swift's session extension in HueSaturation.swift. +bool EditorSession::canAdjustColors() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + const std::optional<DocumentSelection> current = selection(); + return !m_levels && !m_filterEdit && m_document && layer && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_pixelMove && !m_renamingLayerID && !m_showsNewDocument + && !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && layer->asset + && m_document->effectiveVisibleIDs().contains(layer->id) && !(current && current->isEmpty()); +} + +void EditorSession::beginHueSaturation() +{ + // Swift beeps. + if (m_hueSaturation || !canAdjustColors()) { + qCWarning(lcApp) << "Hue/Saturation cannot open here"; + return; + } + // A pending gradient lands first, as beginLevels waits. + if (m_gradientEdit) { + commitGradient([this] { beginHueSaturation(); }); + return; + } + commitTransform(); + const ImageLayer layer = activeLayer().value(); + const std::optional<DocumentSelection> current = selection(); + try { + m_hueSaturation.emplace(layer.id, layer.asset.value(), current ? std::optional(current->clip(m_document->size())) : std::nullopt, + layer.transform); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + return; + } + notify(); +} + +void EditorSession::updateHueSaturation(const HueSaturationSettings &settings, bool preview) +{ + if (!m_hueSaturation) + return; + HueSaturationEdit &edit = *m_hueSaturation; + edit.settings = settings; + edit.preview = preview; + // An adjustment layer takes the settings; its pixels stay. + if (previewAdjustmentEditing(preview)) { + notify(); + return; + } + if (!preview || settings.isIdentity()) { + // Swift cancels the running preview: its result drops. + m_hueSaturationPreviewFor = QUuid(); + m_hueSaturationPending.reset(); + edit.setPreview(std::nullopt); + ++m_brushRevision; + notify(); + return; + } + m_hueSaturationPending = HueSaturationJob{edit.previewSource, settings, edit.selection, edit.previewPixelToDocument, false}; + renderPendingHuePreview(); + notify(); +} + +// Renders the newest job; one that arrives mid-render waits. +void EditorSession::renderPendingHuePreview() +{ + if (!m_hueSaturation || !m_hueSaturationPending || m_hueSaturationRendering) + return; + const HueSaturationJob job = *std::exchange(m_hueSaturationPending, std::nullopt); + m_hueSaturationPreviewFor = m_hueSaturation->id; + m_hueSaturationRendering = true; + m_hueSaturationTask.setFuture(QtConcurrent::run([job] { return adjusted(job); })); +} + +void EditorSession::finishHueSaturationPreview() +{ + m_hueSaturationRendering = false; + const Adjusted made = m_hueSaturationTask.result(); + // Swift's adjustedPixels shows a failure whichever edit asked. + if (made.failure) + setBrushError(made.failure); + if (m_hueSaturation && m_hueSaturation->id == std::exchange(m_hueSaturationPreviewFor, QUuid()) && made.pixels) { + m_hueSaturation->setPreview(made.pixels->image); + ++m_brushRevision; + notify(); + } + renderPendingHuePreview(); +} + +void EditorSession::commitHueSaturation(std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (finishAdjustmentEditing(true) || !m_hueSaturation || m_committingHueSaturation) { + finish(); + return; + } + m_hueSampleMode.reset(); + m_hueTargeting = false; + m_hueTargetDrag.reset(); + m_hueSaturationPending.reset(); + m_hueSaturationPreviewFor = QUuid(); + const HueSaturationEdit &edit = *m_hueSaturation; + if (edit.settings.isIdentity()) { + m_hueSaturation.reset(); + ++m_brushRevision; + notify(); + finish(); + return; + } + // The preview stays on screen until the pixels land. + m_committingHueSaturation.emplace(CommittingHue{edit, std::move(done)}); + setIsProjectBusy(true); + // A painted layer flattens there, where a failure is caught. + m_hueSaturationCommit.setFuture(QtConcurrent::run([original = edit.original, settings = edit.settings, selection = edit.selection, + mapping = edit.pixelToDocument]() -> Adjusted { + try { + return adjusted(HueSaturationJob{original.image(), settings, selection, mapping, true}); + } catch (const ExportError &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishHueSaturationCommit() +{ + const Adjusted made = m_hueSaturationCommit.result(); + const CommittingHue committing = std::exchange(m_committingHueSaturation, std::nullopt).value(); + const HueSaturationEdit &edit = committing.edit; + const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1; + if (made.failure) + setBrushError(made.failure); + else if (index >= 0 && m_document->layers[index].asset && m_document->layers[index].asset->identity() == edit.original.identity()) { + beginEdit(QStringLiteral("Hue/Saturation")); + ImageLayer &layer = m_document->layers[index]; + layer.asset = ImportedImage(made.pixels->image, made.pixels->thumbnail.value_or(made.pixels->image), layer.name); + // Swift rebuilds the layer without shape, effects and text. + layer.shape = std::nullopt; + layer.effects = std::nullopt; + layer.text = std::nullopt; + endEdit(); + } + // Swift's defers: the project frees, then the edit goes. + setIsProjectBusy(false); + m_hueSaturation.reset(); + ++m_brushRevision; + notify(); + if (committing.done) + QMetaObject::invokeMethod(this, committing.done, Qt::QueuedConnection); +} + +std::optional<double> EditorSession::sampledHue(QPointF point) const +{ + const std::optional<PaletteColor> color = sampleCompositeColor(point); + if (!color) + return std::nullopt; + const PickerHSB hsb(*color); + return hsb.saturation > 0.02 ? std::optional(hsb.hue) : std::nullopt; +} + +void EditorSession::sampleHueRange(QPointF point) +{ + if (!m_hueSaturation || !m_hueSampleMode) + return; + HueSaturationSettings settings = m_hueSaturation->settings; + const std::optional<double> hue = settings.range != ColorRange::master && !settings.colorize ? sampledHue(point) : std::nullopt; + // Swift beeps: Master, Colorize, or a gray pixel. + if (!hue) { + qCWarning(lcApp) << "no hue to sample here"; + return; + } + HueBand band = settings.band(); + if (*m_hueSampleMode == HueSampleMode::replace) + band = band.centered(*hue); + else if (*m_hueSampleMode == HueSampleMode::add) + band.include(*hue); + else + band.exclude(*hue); + settings.setBand(band); + updateHueSaturation(settings, m_hueSaturation->preview); +} + +bool EditorSession::beginHueTargeting(QPointF point) +{ + const std::optional<double> hue = m_hueSaturation && m_hueTargeting && !m_hueSaturation->settings.colorize ? sampledHue(point) : std::nullopt; + // Swift beeps. + if (!hue) { + qCWarning(lcApp) << "no colour to target here"; + return false; + } + HueSaturationSettings settings = m_hueSaturation->settings; + // Swift's max(by:): the first of the strongest claims. + ColorRange range = colorRanges.front(); + for (const ColorRange each : colorRanges) { + if (settings.weight(each, *hue) > settings.weight(range, *hue)) + range = each; + } + settings.range = range; + const RangeAdjustment adjustment = settings.adjustments.contains(range) ? settings.adjustments.at(range) : RangeAdjustment(); + m_hueTargetDrag = HueTargetDrag{range, adjustment.hue, adjustment.saturation}; + updateHueSaturation(settings, m_hueSaturation->preview); + return true; +} + +void EditorSession::dragHueTargeting(double viewDelta, bool adjustsHue) +{ + if (!m_hueSaturation || !m_hueTargetDrag) + return; + HueSaturationSettings settings = m_hueSaturation->settings; + const HueTargetDrag drag = *m_hueTargetDrag; + RangeAdjustment &adjustment = settings.adjustments[drag.range]; + if (adjustsHue) + adjustment.hue = std::min(180.0, std::max(-180.0, drag.hue + viewDelta / 2)); + else + adjustment.saturation = std::min(100.0, std::max(-100.0, drag.saturation + viewDelta / 2)); + updateHueSaturation(settings, m_hueSaturation->preview); +} + +void EditorSession::endHueTargeting() +{ + m_hueTargetDrag.reset(); +} + +void EditorSession::cancelHueSaturation() +{ + if (finishAdjustmentEditing(false)) + return; + const bool armed = m_hueSampleMode || m_hueTargeting; + m_hueSampleMode.reset(); + m_hueTargeting = false; + m_hueTargetDrag.reset(); + if (!m_hueSaturation) { + if (armed) + notify(); + return; + } + // A running preview drops: its edit is gone. + m_hueSaturationPending.reset(); + m_hueSaturation.reset(); + ++m_brushRevision; + notify(); +} + +void EditorSession::setHueSampleMode(std::optional<HueSampleMode> mode) +{ + if (m_hueSampleMode == mode) + return; + m_hueSampleMode = mode; + notify(); +} + +void EditorSession::setHueTargeting(bool targeting) +{ + if (m_hueTargeting == targeting) + return; + m_hueTargeting = targeting; + notify(); +} diff --git a/src/Document/HueSaturation+Session.h b/src/Document/HueSaturation+Session.h new file mode 100644 index 0000000..7e61860 --- /dev/null +++ b/src/Document/HueSaturation+Session.h @@ -0,0 +1,26 @@ +// Swift's HueSaturation extension. +public: + bool canAdjustColors() const; + void beginHueSaturation(); + // Previews coalesce: the newest waits for the running one. + void updateHueSaturation(const HueSaturationSettings &settings, bool preview); + // Off the UI thread; `done` follows the step or refusal. + void commitHueSaturation(std::function<void()> done = {}); + // The hue under a document point; near-grays have none. + std::optional<double> sampledHue(QPointF point) const; + // The eyedroppers: re-centre, widen or narrow the band. + void sampleHueRange(QPointF point); + // Targeted adjustment: the range owning the sampled colour. + bool beginHueTargeting(QPointF point); + // A unit every two view points, from the drag's start. + void dragHueTargeting(double viewDelta, bool adjustsHue); + void endHueTargeting(); + void cancelHueSaturation(); + // The sheet's writes, as Swift's sheet sets them. + void setHueSampleMode(std::optional<HueSampleMode> mode); + void setHueTargeting(bool targeting); + +private: + void renderPendingHuePreview(); + void finishHueSaturationPreview(); + void finishHueSaturationCommit(); diff --git a/src/Document/HueSaturation.cpp b/src/Document/HueSaturation.cpp new file mode 100644 index 0000000..16fc419 --- /dev/null +++ b/src/Document/HueSaturation.cpp @@ -0,0 +1,246 @@ +#include "Document/HueSaturation.h" +#include "Document/BrushStroke.h" +#include "Document/LayerTransform.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainter> +#include <cmath> +#include <stdexcept> + +QString rawValue(ColorRange range) +{ + switch (range) { + case ColorRange::master: return QStringLiteral("Master"); + case ColorRange::reds: return QStringLiteral("Reds"); + case ColorRange::yellows: return QStringLiteral("Yellows"); + case ColorRange::greens: return QStringLiteral("Greens"); + case ColorRange::cyans: return QStringLiteral("Cyans"); + case ColorRange::blues: return QStringLiteral("Blues"); + case ColorRange::magentas: return QStringLiteral("Magentas"); + } + throw std::logic_error("no such colour range"); +} + +HueBand defaultBand(ColorRange range) +{ + switch (range) { + case ColorRange::master: return {0, 0, 360, 360}; + case ColorRange::reds: return {315, 345, 15, 45}; + case ColorRange::yellows: return {15, 45, 75, 105}; + case ColorRange::greens: return {75, 105, 135, 165}; + case ColorRange::cyans: return {135, 165, 195, 225}; + case ColorRange::blues: return {195, 225, 255, 285}; + case ColorRange::magentas: return {255, 285, 315, 345}; + } + throw std::logic_error("no such colour range"); +} + +namespace { +// Swift's truncatingRemainder, brought into 0 to 360. +double wrap(double value) +{ + const double remainder = std::fmod(value, 360); + return remainder < 0 ? remainder + 360 : remainder; +} +} + +double HueBand::forward(double from, double to) +{ + const double delta = std::fmod(to - from, 360); + return delta < 0 ? delta + 360 : delta; +} + +double HueBand::weight(double hue) const +{ + const double span = forward(falloffStart, falloffEnd); + // Master covers everything. + if (!(span > 0)) + return 1; + const double position = forward(falloffStart, hue); + if (!(position <= span)) + return 0; + const double rampIn = forward(falloffStart, rangeStart), plateauEnd = forward(falloffStart, rangeEnd); + if (position < rampIn) + return rampIn > 0 ? position / rampIn : 1; + if (position <= plateauEnd) + return 1; + const double rampOut = span - plateauEnd; + return rampOut > 0 ? (span - position) / rampOut : 1; +} + +std::array<double, 4> HueBand::handles() const +{ + return {falloffStart, rangeStart, rangeEnd, falloffEnd}; +} + +HueBand HueBand::centered(double hue) const +{ + const double core = forward(rangeStart, rangeEnd), leading = forward(falloffStart, rangeStart), trailing = forward(rangeEnd, falloffEnd); + const double start = wrap(hue - core / 2); + return {wrap(start - leading), start, wrap(start + core), wrap(start + core + trailing)}; +} + +void HueBand::include(double hue) +{ + if (!(weight(hue) < 1)) + return; + const double shoulderIn = forward(falloffStart, rangeStart), shoulderOut = forward(rangeEnd, falloffEnd); + if (forward(hue, rangeStart) <= forward(rangeEnd, hue)) { + rangeStart = hue; + falloffStart = hue - shoulderIn; + } else { + rangeEnd = hue; + falloffEnd = hue + shoulderOut; + } + normalize(); +} + +void HueBand::exclude(double hue) +{ + if (!(weight(hue) > 0)) + return; + const double shoulderIn = forward(falloffStart, rangeStart), shoulderOut = forward(rangeEnd, falloffEnd); + if (forward(falloffStart, hue) <= forward(hue, falloffEnd)) { + falloffStart = hue + 1; + rangeStart = hue + 1 + shoulderIn; + } else { + falloffEnd = hue - 1; + rangeEnd = hue - 1 - shoulderOut; + } + normalize(); +} + +void HueBand::normalize() +{ + falloffStart = wrap(falloffStart); + rangeStart = wrap(rangeStart); + rangeEnd = wrap(rangeEnd); + falloffEnd = wrap(falloffEnd); + // Under a full circle. + if (forward(falloffStart, falloffEnd) > 350) + falloffEnd = wrap(falloffStart + 350); +} + +void HueBand::setHandle(int index, double degrees) +{ + HueBand updated = *this; + const double value = std::fmod(std::fmod(degrees, 360) + 360, 360); + (index == 0 ? updated.falloffStart : index == 1 ? updated.rangeStart : index == 2 ? updated.rangeEnd : updated.falloffEnd) = value; + const double span = forward(updated.falloffStart, updated.falloffEnd), toStart = forward(updated.falloffStart, updated.rangeStart), + toEnd = forward(updated.falloffStart, updated.rangeEnd); + if (span > 1 && span <= 350 && toStart <= toEnd && toEnd <= span) + *this = updated; +} + +QString rawValue(HueSampleMode mode) +{ + switch (mode) { + case HueSampleMode::replace: return QStringLiteral("Sample"); + case HueSampleMode::add: return QStringLiteral("Add"); + case HueSampleMode::remove: return QStringLiteral("Remove"); + } + throw std::logic_error("no such sample mode"); +} + +QString help(HueSampleMode mode) +{ + switch (mode) { + case HueSampleMode::replace: return QStringLiteral("Click the image to center this range on that color"); + case HueSampleMode::add: return QStringLiteral("Click the image to widen this range to include that color"); + case HueSampleMode::remove: return QStringLiteral("Click the image to narrow this range to exclude that color"); + } + throw std::logic_error("no such sample mode"); +} + +HueSaturationSettings::HueSaturationSettings(double hue, double saturation, double lightness, bool colorize, ColorRange range) + : range(range), colorize(colorize) +{ + for (const ColorRange each : allColorRanges) + bands.emplace(each, defaultBand(each)); + adjustments[range] = RangeAdjustment{hue, saturation, lightness}; +} + +double HueSaturationSettings::hue() const +{ + return adjustments.contains(range) ? adjustments.at(range).hue : 0; +} + +void HueSaturationSettings::setHue(double value) +{ + adjustments[range].hue = value; +} + +double HueSaturationSettings::saturation() const +{ + return adjustments.contains(range) ? adjustments.at(range).saturation : 0; +} + +void HueSaturationSettings::setSaturation(double value) +{ + adjustments[range].saturation = value; +} + +double HueSaturationSettings::lightness() const +{ + return adjustments.contains(range) ? adjustments.at(range).lightness : 0; +} + +void HueSaturationSettings::setLightness(double value) +{ + adjustments[range].lightness = value; +} + +HueBand HueSaturationSettings::band() const +{ + return bands.contains(range) ? bands.at(range) : defaultBand(range); +} + +void HueSaturationSettings::setBand(const HueBand &value) +{ + bands[range] = value; +} + +HueSaturationSettings HueSaturationSettings::colorizeStart() +{ + return HueSaturationSettings(0, 25, 0, true); +} + +bool HueSaturationSettings::isIdentity() const +{ + return !colorize && std::all_of(adjustments.begin(), adjustments.end(), [](const auto &entry) { return entry.second == RangeAdjustment(); }); +} + +double HueSaturationSettings::weight(ColorRange colorRange, double hue) const +{ + if (colorRange == ColorRange::master) + return 1; + const double inBand = (bands.contains(colorRange) ? bands.at(colorRange) : defaultBand(colorRange)).weight(hue); + return invertRange && colorRange == range ? 1 - inBand : inBand; +} + +HueSaturationEdit::HueSaturationEdit(QUuid layerID, const ImportedImage &original, std::optional<SelectionClip> selection, + const LayerTransform &transform) + : layerID(layerID), original(original), selection(std::move(selection)), + pixelToDocument(BrushRaster::pixelToDocument(transform, original.size().width(), original.size().height())) +{ + const QSize size = original.size(); + const double factor = std::min(1.0, double(previewLimit) / std::max(size.width(), size.height())); + if (factor < 1) { + const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor)); + previewSource = BrushRaster::context(width, height, false); + QPainter painter(&previewSource); + const QRectF rect(0, 0, width, height); + if (original.raster) + original.raster->draw(rect, painter); + else + BrushRaster::draw(original.image(), rect, painter); + previewPixelToDocument = BrushRaster::pixelToDocument(transform, width, height); + } else { + previewSource = original.image(); + previewPixelToDocument = pixelToDocument; + } +} + +std::optional<QImage> HueSaturationEdit::previewImage(QUuid layer) const +{ + return layer == layerID ? m_preparedPreview : std::nullopt; +} diff --git a/src/Document/HueSaturation.h b/src/Document/HueSaturation.h new file mode 100644 index 0000000..5ba7cbc --- /dev/null +++ b/src/Document/HueSaturation.h @@ -0,0 +1,160 @@ +#pragma once +#include "Document/Selection.h" +#include "IO/ImageImporter.h" +#include <QImage> +#include <QTransform> +#include <QUuid> +#include <array> +#include <map> +#include <optional> +#include <vector> + +struct LayerTransform; + +// The six colour ranges and Master, as Photoshop's Cmd+U. +enum class ColorRange { master, reds, yellows, greens, cyans, blues, magentas }; +inline constexpr std::array<ColorRange, 7> allColorRanges{ColorRange::master, ColorRange::reds, ColorRange::yellows, ColorRange::greens, + ColorRange::cyans, ColorRange::blues, ColorRange::magentas}; +// Swift's colorRanges: every range but Master. +inline constexpr std::array<ColorRange, 6> colorRanges{ColorRange::reds, ColorRange::yellows, ColorRange::greens, + ColorRange::cyans, ColorRange::blues, ColorRange::magentas}; +QString rawValue(ColorRange range); + +// A hue band in degrees, wrapping at 360. +struct HueBand { + double falloffStart; + double rangeStart; + double rangeEnd; + double falloffEnd; + // Degrees forward from one hue to another, 0 to 360. + static double forward(double from, double to); + // Full inside the range, ramping through each shoulder. + double weight(double hue) const; + std::array<double, 4> handles() const; + // Centred on a hue, keeping the core and shoulders. + HueBand centered(double hue) const; + // Widens to hold the hue, moving the nearer edge. + void include(double hue); + // Narrows to leave the hue out, shoulder included. + void exclude(double hue); + // Moves a handle unless the four would cross. + void setHandle(int index, double degrees); + friend bool operator==(const HueBand &, const HueBand &) = default; + +private: + void normalize(); +}; + +// Photoshop's starting band for a range. +HueBand defaultBand(ColorRange range); + +// Which eyedropper the panel arms. +enum class HueSampleMode { replace, add, remove }; +inline constexpr std::array<HueSampleMode, 3> allHueSampleModes{HueSampleMode::replace, HueSampleMode::add, HueSampleMode::remove}; +QString rawValue(HueSampleMode mode); +QString help(HueSampleMode mode); + +// A targeted-adjustment drag: its range and starting values. +struct HueTargetDrag { + ColorRange range; + double hue; + double saturation; +}; + +struct RangeAdjustment { + double hue = 0; + double saturation = 0; + double lightness = 0; + friend bool operator==(const RangeAdjustment &, const RangeAdjustment &) = default; +}; + +// Each range keeps its own values; Master applies everywhere. +struct HueSaturationSettings { + explicit HueSaturationSettings(double hue = 0, double saturation = 0, double lightness = 0, bool colorize = false, + ColorRange range = ColorRange::master); + ColorRange range; + bool colorize; + // Applies the range outside its band instead. + bool invertRange = false; + std::map<ColorRange, RangeAdjustment> adjustments; + std::map<ColorRange, HueBand> bands; + // Swift's computed properties: the selected range's values. + double hue() const; + void setHue(double value); + double saturation() const; + void setSaturation(double value); + double lightness() const; + void setLightness(double value); + HueBand band() const; + void setBand(const HueBand &value); + // Photoshop's start when Colorize is switched on. + static HueSaturationSettings colorizeStart(); + bool isIdentity() const; + // Master everywhere; the others through their bands. + double weight(ColorRange colorRange, double hue) const; + friend bool operator==(const HueSaturationSettings &, const HueSaturationSettings &) = default; +}; + +struct HueSaturationJob { + QImage image; + HueSaturationSettings settings; + std::optional<SelectionClip> selection; + QTransform pixelToDocument; + // Previews skip the panel's thumbnail. + bool thumbnail = true; +}; + +struct AdjustedPixels { + QImage image; + std::optional<QImage> thumbnail; +}; + +// Swift's HueSaturationFilter: a colour cube built from the settings. +namespace HueSaturationFilter { +inline constexpr int dimension = 33; +// Swift's tuple: how every range moves one hue. +struct HueResponse { + double shift = 0; + double saturation = 0; + double lightness = 0; +}; +struct Color { + double red; + double green; + double blue; +}; +// Throws ExportError when out of memory. +AdjustedPixels run(const HueSaturationJob &job); +// Once a degree, 0 to 360. +std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings); +// RGBA floats, red fastest, then green, then blue. +std::vector<float> cube(const HueSaturationSettings &settings); +Color adjust(double red, double green, double blue, const HueSaturationSettings &settings); +Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response); +// The hue a spectrum swatch becomes. +double shiftedHue(double hue, const HueSaturationSettings &settings); +} + +// One open Hue/Saturation dialog over a layer's pixels. +class HueSaturationEdit { +public: + // Past 8000 pixels a side the preview copy is smaller. + static constexpr int previewLimit = 8000; + HueSaturationEdit(QUuid layerID, const ImportedImage &original, std::optional<SelectionClip> selection, const LayerTransform &transform); + std::optional<QImage> previewImage(QUuid layer) const; + void setPreview(std::optional<QImage> image) { m_preparedPreview = std::move(image); } + + // Swift's object identity: results land on their own edit. + const QUuid id = QUuid::createUuid(); + const QUuid layerID; + const ImportedImage original; + const std::optional<SelectionClip> selection; + const QTransform pixelToDocument; + QImage previewSource; + QTransform previewPixelToDocument; + HueSaturationSettings settings; + bool preview = true; + +private: + std::optional<QImage> m_preparedPreview; +}; diff --git a/src/Document/ImageAdjustments.cpp b/src/Document/ImageAdjustments.cpp new file mode 100644 index 0000000..ceb86c7 --- /dev/null +++ b/src/Document/ImageAdjustments.cpp @@ -0,0 +1,133 @@ +#include "Document/ImageAdjustments.h" +#include "Document/BrushStroke.h" +#include "IO/ProjectStore.h" +#include <QPainter> +#include <array> +#include <cmath> +extern "C" { +#include "AdjustPixels.h" +#include "LevelsPixels.h" +} + +QImage ImageAdjustmentPixels::run(const QImage &image, const std::function<void(uchar *, int, int, qsizetype)> &body) +{ + QImage context = BrushRaster::context(image.width(), image.height(), false); + { + QPainter painter(&context); + BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); + } + body(context.bits(), context.width(), context.height(), context.bytesPerLine()); + return context; +} + +double ImageAdjustmentPixels::clamp(double value, double low, double high, double fallback) +{ + return std::isfinite(value) ? std::min(high, std::max(low, value)) : fallback; +} + +bool AdjustmentColor::isValid() const +{ + return red >= 0 && red <= 1 && green >= 0 && green <= 1 && blue >= 0 && blue <= 1; +} + +AdjustmentColor AdjustmentColor::clamped() const +{ + using ImageAdjustmentPixels::clamp; + return {clamp(red, 0, 1, 0), clamp(green, 0, 1, 0), clamp(blue, 0, 1, 0)}; +} + +bool ExposureSettings::isValid() const +{ + return exposure >= exposureLow && exposure <= exposureHigh && offset >= offsetLow && offset <= offsetHigh && gamma >= gammaLow + && gamma <= gammaHigh; +} + +ExposureSettings ExposureSettings::normalized() const +{ + using ImageAdjustmentPixels::clamp; + return {clamp(exposure, exposureLow, exposureHigh, 0), clamp(offset, offsetLow, offsetHigh, 0), clamp(gamma, gammaLow, gammaHigh, 1)}; +} + +std::array<float, 256> ExposureSettings::table() const +{ + const double scale = std::pow(2, exposure); + std::array<float, 256> result; + for (size_t index = 0; index < 256; ++index) { + const double encoded = double(index) / 255; + double linear = encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4); + linear = std::pow(std::max(0.0, linear * scale + offset), 1 / gamma); + const double output = linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055; + result[index] = float(std::min(1.0, std::max(0.0, output))); + } + return result; +} + +QImage ExposureSettings::apply(const QImage &image) const +{ + if (!isValid()) + throw ProjectError(ProjectError::Kind::invalid); + const std::array<float, 256> curve = table(); + std::array<float, 3 * 256> tables; + for (size_t channel = 0; channel < 3; ++channel) + std::copy(curve.begin(), curve.end(), tables.begin() + qsizetype(channel * 256)); + return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype) { + levels_apply(pixels, size_t(width) * size_t(height), tables.data()); + }); +} + +bool GradientMapSettings::isValid() const +{ + return shadows.isValid() && highlights.isValid(); +} + +GradientMapSettings GradientMapSettings::normalized() const +{ + return {shadows.clamped(), highlights.clamped(), reversed}; +} + +GradientMapSettings::Ends GradientMapSettings::ends() const +{ + return reversed ? Ends{highlights, shadows} : Ends{shadows, highlights}; +} + +QImage GradientMapSettings::apply(const QImage &image) const +{ + if (!isValid()) + throw ProjectError(ProjectError::Kind::invalid); + const auto [dark, light] = ends(); + std::array<uint8_t, 3 * 256> table; + const auto byte = [](double value) { return uint8_t(std::min(255.0, std::max(0.0, std::round(value * 255)))); }; + for (size_t index = 0; index < 256; ++index) { + const double t = double(index) / 255; + table[index * 3] = byte(dark.red + (light.red - dark.red) * t); + table[index * 3 + 1] = byte(dark.green + (light.green - dark.green) * t); + table[index * 3 + 2] = byte(dark.blue + (light.blue - dark.blue) * t); + } + return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) { + adjust_gradient_map(pixels, size_t(width), size_t(height), size_t(stride), table.data()); + }); +} + +bool GrainSettings::isValid() const +{ + return amount >= amountLow && amount <= amountHigh && size >= sizeLow && size <= sizeHigh && roughness >= roughnessLow + && roughness <= roughnessHigh; +} + +GrainSettings GrainSettings::normalized() const +{ + using ImageAdjustmentPixels::clamp; + return {clamp(amount, amountLow, amountHigh, 25), clamp(size, sizeLow, sizeHigh, 1.5), clamp(roughness, roughnessLow, roughnessHigh, 50), seed}; +} + +QImage GrainSettings::apply(const QImage &image, QPointF origin, double unitsPerPixel, std::optional<quint32> seed) const +{ + if (!isValid() || !std::isfinite(unitsPerPixel) || !(unitsPerPixel > 0)) + throw ProjectError(ProjectError::Kind::invalid); + if (!(amount > 0)) + return image; + const quint32 pattern = seed.value_or(this->seed); + return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) { + adjust_grain(pixels, size_t(width), size_t(height), size_t(stride), amount, size, roughness, pattern, origin.x(), origin.y(), unitsPerPixel); + }); +} diff --git a/src/Document/ImageAdjustments.h b/src/Document/ImageAdjustments.h new file mode 100644 index 0000000..4741c79 --- /dev/null +++ b/src/Document/ImageAdjustments.h @@ -0,0 +1,76 @@ +#pragma once +#include "Document/ColorPalette.h" +#include <QImage> +#include <QPointF> +#include <array> +#include <functional> +#include <optional> + +// Swift's ImageAdjustmentPixels: a kernel over premultiplied RGBA. +namespace ImageAdjustmentPixels { +// The body gets pixels, width, height and bytes a row. +QImage run(const QImage &image, const std::function<void(uchar *, int, int, qsizetype)> &body); +// Within range; a value that is no number falls back. +double clamp(double value, double low, double high, double fallback); +} + +// A straight sRGB colour an adjustment stores, 0 to 1. +struct AdjustmentColor { + AdjustmentColor(double red, double green, double blue) : red(red), green(green), blue(blue) {} + explicit AdjustmentColor(const PaletteColor &color) : AdjustmentColor(color.red, color.green, color.blue) {} + double red; + double green; + double blue; + bool isValid() const; + AdjustmentColor clamped() const; + friend bool operator==(const AdjustmentColor &, const AdjustmentColor &) = default; +}; + +// Photoshop's Exposure: stops and offset in linear light, then gamma. +struct ExposureSettings { + static constexpr double exposureLow = -20, exposureHigh = 20; + static constexpr double offsetLow = -0.5, offsetHigh = 0.5; + static constexpr double gammaLow = 0.01, gammaHigh = 9.99; + double exposure = 0; + double offset = 0; + double gamma = 1; + bool isValid() const; + ExposureSettings normalized() const; + // Each input byte's output, 0 to 1, through linear light. + std::array<float, 256> table() const; + QImage apply(const QImage &image) const; + friend bool operator==(const ExposureSettings &, const ExposureSettings &) = default; +}; + +// Gradient Map: brightness picks a colour between the two ends. +struct GradientMapSettings { + AdjustmentColor shadows{0, 0, 0}; + AdjustmentColor highlights{1, 1, 1}; + bool reversed = false; + bool isValid() const; + GradientMapSettings normalized() const; + // Swift's ends: the darkest tone's colour, then the lightest's. + struct Ends { + AdjustmentColor dark; + AdjustmentColor light; + }; + Ends ends() const; + QImage apply(const QImage &image) const; + friend bool operator==(const GradientMapSettings &, const GradientMapSettings &) = default; +}; + +// Film grain, fixed in document space by its seed. +struct GrainSettings { + static constexpr double amountLow = 0, amountHigh = 100; + static constexpr double sizeLow = 0.5, sizeHigh = 20; + static constexpr double roughnessLow = 0, roughnessHigh = 100; + double amount = 25; + double size = 1.5; + double roughness = 50; + quint32 seed = 0; + bool isValid() const; + GrainSettings normalized() const; + // Pixels placed in the document; a given seed wins. + QImage apply(const QImage &image, QPointF origin = QPointF(), double unitsPerPixel = 1, std::optional<quint32> seed = std::nullopt) const; + friend bool operator==(const GrainSettings &, const GrainSettings &) = default; +}; diff --git a/src/Document/LayerAdjustment+Session.h b/src/Document/LayerAdjustment+Session.h new file mode 100644 index 0000000..5ee61b4 --- /dev/null +++ b/src/Document/LayerAdjustment+Session.h @@ -0,0 +1,6 @@ +// Swift's LayerAdjustment extension: adding and updating adjustments. +public: + // Above the active layer, one step; then its editor opens. + void addAdjustment(AdjustmentKind kind); + // Valid settings alone, with no step of their own. + void updateAdjustment(QUuid id, const LayerAdjustment &value); diff --git a/src/Document/LayerAdjustment.cpp b/src/Document/LayerAdjustment.cpp new file mode 100644 index 0000000..3f614b1 --- /dev/null +++ b/src/Document/LayerAdjustment.cpp @@ -0,0 +1,157 @@ +#include "Document/LayerAdjustment.h" +#include "Document/EditorSession.h" +#include <QRandomGenerator> +#include <cmath> + +QString rawValue(AdjustmentKind kind) +{ + switch (kind) { + case AdjustmentKind::hsv: return QStringLiteral("Hue/Saturation"); + case AdjustmentKind::levels: return QStringLiteral("Levels"); + case AdjustmentKind::curves: return QStringLiteral("Curves"); + case AdjustmentKind::exposure: return QStringLiteral("Exposure"); + case AdjustmentKind::gradientMap: return QStringLiteral("Gradient Map"); + case AdjustmentKind::grain: return QStringLiteral("Grain"); + } + throw std::logic_error("unknown adjustment kind"); +} + +std::optional<AdjustmentKind> adjustmentKind(const QString &text) +{ + for (const AdjustmentKind kind : allAdjustmentKinds) { + if (rawValue(kind) == text) + return kind; + } + return std::nullopt; +} + +std::optional<FilterKind> filterKind(AdjustmentKind kind) +{ + switch (kind) { + case AdjustmentKind::curves: return FilterKind::curves; + case AdjustmentKind::exposure: return FilterKind::exposure; + case AdjustmentKind::gradientMap: return FilterKind::gradientMap; + case AdjustmentKind::grain: return FilterKind::grain; + case AdjustmentKind::hsv: + case AdjustmentKind::levels: return std::nullopt; + } + throw std::logic_error("unknown adjustment kind"); +} + +HueSaturationSettings LayerAdjustment::resolvedHSV() const +{ + return hsvSettings.value_or(HueSaturationSettings(hue, saturation, lightness, colorize)); +} + +ExposureSettings LayerAdjustment::exposure() const +{ + return exposureSettings.value_or(ExposureSettings()); +} + +void LayerAdjustment::setExposure(const ExposureSettings &value) +{ + exposureSettings = value; +} + +GradientMapSettings LayerAdjustment::gradientMap() const +{ + return gradientMapSettings.value_or(GradientMapSettings()); +} + +void LayerAdjustment::setGradientMap(const GradientMapSettings &value) +{ + gradientMapSettings = value; +} + +GrainSettings LayerAdjustment::grain() const +{ + return grainSettings.value_or(GrainSettings()); +} + +void LayerAdjustment::setGrain(const GrainSettings &value) +{ + grainSettings = value; +} + +// Bounds refuse what is no number: no isFinite terms. +bool LayerAdjustment::isValid() const +{ + const auto within = [](const RangeAdjustment &values) { + return std::abs(values.hue) <= 360 && std::abs(values.saturation) <= 100 && std::abs(values.lightness) <= 100; + }; + if (!within(RangeAdjustment{hue, saturation, lightness})) + return false; + const HueSaturationSettings hsv = resolvedHSV(); + for (const auto &[range, values] : hsv.adjustments) { + if (!within(values)) + return false; + } + for (const auto &[range, band] : hsv.bands) { + for (const double handle : band.handles()) { + if (!std::isfinite(handle)) + return false; + } + } + for (const LevelRange &range : levels.ranges) { + if (!(range == range.normalized())) + return false; + } + return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid(); +} + +QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region) const +{ + switch (kind) { + case AdjustmentKind::hsv: + return HueSaturationFilter::run(HueSaturationJob{image, resolvedHSV(), std::nullopt, QTransform(), false}).image; + case AdjustmentKind::levels: return LevelsFilter::run(LevelsJob{image, levels, std::nullopt, QTransform()}); + case AdjustmentKind::curves: return curves.apply(image); + case AdjustmentKind::exposure: return exposure().apply(image); + case AdjustmentKind::gradientMap: return gradientMap().apply(image); + case AdjustmentKind::grain: { + // Grain sits in the document, however the image is cut. + const QRectF area = region.value_or(QRectF(0, 0, image.width(), image.height())); + return grain().apply(image, area.topLeft(), area.width() / std::max(1, image.width())); + } + } + throw std::logic_error("unknown adjustment kind"); +} + +void EditorSession::addAdjustment(AdjustmentKind kind) +{ + if (!canEditLayers() || m_document->layers.size() >= 10'000) + return; + ImageLayer layer(rawValue(kind), m_document->size()); + LayerAdjustment adjustment{kind}; + // A Gradient Map runs from foreground to background, as Photoshop's. + if (kind == AdjustmentKind::gradientMap) + adjustment.setGradientMap(GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)}); + // Each Grain layer rolls a pattern of its own. + if (kind == AdjustmentKind::grain) { + GrainSettings grain = adjustment.grain(); + grain.seed = QRandomGenerator::global()->generate(); + adjustment.setGrain(grain); + } + layer.adjustment = adjustment; + const std::optional<ImageLayer> active = activeLayer(); + layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt; + const int activeIndex = indexOf(m_document->layers, m_activeLayerID); + const int index = activeIndex >= 0 ? activeIndex + 1 : int(m_document->layers.size()); + beginEdit(QStringLiteral("New %1 Adjustment").arg(rawValue(kind))); + m_document->layers.insert(m_document->layers.begin() + index, layer); + if (layer.parentID) + m_collapsedGroupIDs.remove(*layer.parentID); + setActiveLayerID(layer.id); + endEdit(); + setAdjustmentEditingID(layer.id); +} + +void EditorSession::updateAdjustment(QUuid id, const LayerAdjustment &value) +{ + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (index < 0 || !value.isValid()) + return; + m_document->layers[size_t(index)].adjustment = value; + ++m_brushRevision; + notify(); +} diff --git a/src/Document/LayerAdjustment.h b/src/Document/LayerAdjustment.h new file mode 100644 index 0000000..93bb7b3 --- /dev/null +++ b/src/Document/LayerAdjustment.h @@ -0,0 +1,50 @@ +#pragma once +#include "Document/Curves.h" +#include "Document/Filters.h" +#include "Document/HueSaturation.h" +#include "Document/ImageAdjustments.h" +#include "Document/Levels.h" +#include <QRectF> +#include <QString> +#include <array> +#include <optional> + +// An adjustment layer's kind, in the Layer menu's order. +enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain }; +inline constexpr std::array allAdjustmentKinds{AdjustmentKind::hsv, AdjustmentKind::levels, AdjustmentKind::curves, + AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain}; +// Swift's rawValue: the new layer's name and the manifest's word. +QString rawValue(AdjustmentKind kind); +// The kind a manifest names, or none. +std::optional<AdjustmentKind> adjustmentKind(const QString &text); +// The filter panel that edits it; Levels and Hue/Saturation: none. +std::optional<FilterKind> filterKind(AdjustmentKind kind); + +// An adjustment layer's settings; its kind reads only its own. +struct LayerAdjustment { + AdjustmentKind kind; + double hue = 0; + double saturation = 0; + double lightness = 0; + bool colorize = false; + // Absent in projects from before range-aware Hue/Saturation. + std::optional<HueSaturationSettings> hsvSettings = std::nullopt; + LevelsSettings levels{}; + CurvesSettings curves{}; + // Absent unless set, so older kinds save exactly as before. + std::optional<ExposureSettings> exposureSettings = std::nullopt; + std::optional<GradientMapSettings> gradientMapSettings = std::nullopt; + std::optional<GrainSettings> grainSettings = std::nullopt; + HueSaturationSettings resolvedHSV() const; + // Swift's computed properties over the optional settings. + ExposureSettings exposure() const; + void setExposure(const ExposureSettings &value); + GradientMapSettings gradientMap() const; + void setGradientMap(const GradientMapSettings &value); + GrainSettings grain() const; + void setGrain(const GrainSettings &value); + bool isValid() const; + // `region`: the document `image` covers, whole pixels by default. + QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt) const; + friend bool operator==(const LayerAdjustment &, const LayerAdjustment &) = default; +}; diff --git a/src/Document/LayerAppearance+Session.h b/src/Document/LayerAppearance+Session.h new file mode 100644 index 0000000..ee0cf96 --- /dev/null +++ b/src/Document/LayerAppearance+Session.h @@ -0,0 +1,12 @@ +// Swift's LayerAppearance extension: opacity and blend modes. +public: + LayerBlendMode displayedBlendMode(const ImageLayer &layer) const; + void previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id); + bool canEditAppearance() const; + // A slider drag: many values, one undo step. + void beginOpacityEdit(); + void finishOpacityEdit(); + void setLayerOpacity(double opacity); + void setSelectedLayersOpacity(double opacity); + void cycleBlendMode(bool forward); + void setLayerBlendMode(LayerBlendMode mode); diff --git a/src/Document/LayerAppearance.cpp b/src/Document/LayerAppearance.cpp new file mode 100644 index 0000000..e1d1207 --- /dev/null +++ b/src/Document/LayerAppearance.cpp @@ -0,0 +1,130 @@ +#include "Document/LayerAppearance.h" +#include "Document/EditorSession.h" +#include <algorithm> +#include <cmath> +#include <array> +#include <utility> + +namespace { +const std::array<std::pair<LayerBlendMode, const char *>, 13> names{{ + {LayerBlendMode::normal, "Normal"}, {LayerBlendMode::multiply, "Multiply"}, {LayerBlendMode::screen, "Screen"}, + {LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"}, + {LayerBlendMode::difference, "Difference"}, {LayerBlendMode::colorDodge, "Color Dodge"}, + {LayerBlendMode::colorBurn, "Color Burn"}, {LayerBlendMode::hue, "Hue"}, {LayerBlendMode::saturation, "Saturation"}, + {LayerBlendMode::color, "Color"}, {LayerBlendMode::luminosity, "Luminosity"}, +}}; +} + +QString rawValue(LayerBlendMode mode) +{ + return QString::fromLatin1(names.at(size_t(mode)).second); +} + +std::optional<LayerBlendMode> layerBlendMode(const QString &rawValue) +{ + for (const auto &[mode, name] : names) { + if (rawValue == QLatin1String(name)) + return mode; + } + return std::nullopt; +} + +LayerBlendMode EditorSession::displayedBlendMode(const ImageLayer &layer) const +{ + if (m_blendPreview && m_blendPreview->layerID == layer.id && m_activeLayerID == layer.id) + return m_blendPreview->mode; + return layer.blendMode; +} + +void EditorSession::previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id) +{ + std::optional<BlendPreview> preview; + if (mode && id && id == m_activeLayerID && canEditAppearance()) + preview = BlendPreview{*id, *mode}; + // Hovering over the same entry again says nothing. + if (preview == m_blendPreview) + return; + m_blendPreview = preview; + notify(); +} + +bool EditorSession::canEditAppearance() const +{ + const std::optional<ImageLayer> active = activeLayer(); + return canEditLayers() && m_selectedLayerIDs.size() == 1 && active && !active->isGroup; +} + +void EditorSession::beginOpacityEdit() +{ + if (!canEditAppearance() || m_opacityEditLayerID) + return; + beginEdit(QStringLiteral("Layer Opacity")); + m_opacityEditLayerID = m_activeLayerID; +} + +void EditorSession::finishOpacityEdit() +{ + if (!m_opacityEditLayerID) + return; + m_opacityEditLayerID = std::nullopt; + endEdit(); +} + +void EditorSession::setLayerOpacity(double opacity) +{ + if (!std::isfinite(opacity) || !canEditAppearance()) + return; + const QUuid id = m_opacityEditLayerID.value_or(*m_activeLayerID); + const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &each) { return each.id == id; }); + if (layer == m_document->layers.end()) + return; + // Inside a drag this nests in the drag's one step. + beginEdit(QStringLiteral("Layer Opacity")); + layer->opacity = std::clamp(opacity, 0.0, 1.0); + endEdit(); +} + +void EditorSession::setSelectedLayersOpacity(double opacity) +{ + if (!std::isfinite(opacity) || !canEditLayers()) + return; + // Folders have no opacity of their own. + const double value = std::clamp(opacity, 0.0, 1.0); + const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && !layer.isGroup && layer.opacity != value; }; + if (std::none_of(m_document->layers.begin(), m_document->layers.end(), changes)) + return; + finishOpacityEdit(); + beginEdit(QStringLiteral("Layer Opacity")); + for (ImageLayer &layer : m_document->layers) { + if (changes(layer)) + layer.opacity = value; + } + endEdit(); +} + +void EditorSession::cycleBlendMode(bool forward) +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!canEditAppearance() || !layer) + return; + const auto found = std::find(allLayerBlendModes.begin(), allLayerBlendModes.end(), layer->blendMode); + const size_t count = allLayerBlendModes.size(), index = size_t(found - allLayerBlendModes.begin()); + setLayerBlendMode(allLayerBlendModes[(index + (forward ? 1 : count - 1)) % count]); +} + +void EditorSession::setLayerBlendMode(LayerBlendMode mode) +{ + if (m_blendPreview) { + m_blendPreview = std::nullopt; + notify(); + } + if (!canEditAppearance()) + return; + finishOpacityEdit(); + beginEdit(QStringLiteral("Layer Blend Mode")); + for (ImageLayer &layer : m_document->layers) { + if (layer.id == m_activeLayerID) + layer.blendMode = mode; + } + endEdit(); +} diff --git a/src/Document/LayerAppearance.h b/src/Document/LayerAppearance.h new file mode 100644 index 0000000..5ac0c16 --- /dev/null +++ b/src/Document/LayerAppearance.h @@ -0,0 +1,20 @@ +#pragma once +#include <QString> +#include <array> +#include <optional> + +enum class LayerBlendMode { + normal, multiply, screen, overlay, darken, lighten, difference, + colorDodge, colorBurn, hue, saturation, color, luminosity +}; + +// Swift's `allCases`: the order of the blend menu. +inline constexpr std::array allLayerBlendModes{ + LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::screen, LayerBlendMode::overlay, + LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge, + LayerBlendMode::colorBurn, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color, + LayerBlendMode::luminosity}; + +// The name users see and the manifest stores. +QString rawValue(LayerBlendMode mode); +std::optional<LayerBlendMode> layerBlendMode(const QString &rawValue); diff --git a/src/Document/LayerEffects+Renderer.cpp b/src/Document/LayerEffects+Renderer.cpp new file mode 100644 index 0000000..4cfcdf6 --- /dev/null +++ b/src/Document/LayerEffects+Renderer.cpp @@ -0,0 +1,126 @@ +#include "Document/LayerEffects+Renderer.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerEffectsKernel.h" +#include <QMutex> +#include <QPainter> +#include <cmath> +#include <vector> + +// Swift's LayerEffectsRenderer, on the kernel alone. +namespace { +struct Entry { + QImage image; + std::optional<QImage> mask; + LayerEffects effects; + LayerEffectsRenderer::Rendered result; +}; + +qint64 bytes(const Entry &entry) +{ + return entry.result.image.sizeInBytes() + entry.image.sizeInBytes() + (entry.mask ? entry.mask->sizeInBytes() : 0); +} + +// Swift's `===`: the same pixels, not equal ones. +bool same(const std::optional<QImage> &lhs, const std::optional<QImage> &rhs) +{ + return lhs.has_value() == rhs.has_value() && (!lhs || lhs->cacheKey() == rhs->cacheKey()); +} + +QMutex lock; +std::vector<Entry> entries; + +// The layer's pixels through its mask, as they show. +QImage masked(const QImage &image, const std::optional<QImage> &mask) +{ + if (!mask) + return image; + const QRectF bounds(0, 0, image.width(), image.height()); + QImage result = BrushRaster::context(image.width(), image.height(), false); + QPainter painter(&result); + BrushRaster::draw(image, bounds, painter); + // CoreGraphics scales a clip mask smoothly to its bounds. + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(bounds, BrushRaster::alphaView(*mask)); + return result; +} +} + +std::optional<LayerEffectsRenderer::Rendered> LayerEffectsRenderer::cached(const QImage &image, const std::optional<QImage> &mask, + const std::optional<LayerEffects> &effects) +{ + const LayerEffects shown = effects.value_or(LayerEffects()).visible(); + if (shown.isEmpty() || !shown.isValid()) + return std::nullopt; + { + const QMutexLocker locked(&lock); + for (const Entry &entry : entries) { + if (entry.image.cacheKey() == image.cacheKey() && same(entry.mask, mask) && entry.effects == shown) + return entry.result; + } + } + try { + const Entry made{image, mask, shown, render(image, mask, shown)}; + // About 64 MiB of results and sources, eight at most. + constexpr qint64 budget = 64 * 1024 * 1024; + const QMutexLocker locked(&lock); + if (bytes(made) <= budget) { + entries.push_back(made); + qint64 total = 0; + for (const Entry &entry : entries) + total += bytes(entry); + while (entries.size() > 8 || total > budget) { + total -= bytes(entries.front()); + entries.erase(entries.begin()); + } + } + return made.result; + } catch (const ProjectError &error) { + qCWarning(lcRendering) << "a layer's effects could not be made:" << error.what(); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "a layer's effects could not be made:" << error.what(); + } + return std::nullopt; +} + +LayerTransform LayerEffectsRenderer::placed(const LayerTransform &transform, const QImage &image, double inset) +{ + const double width = image.width(), height = image.height(); + if (!(width > inset * 2) || !(height > inset * 2)) + return transform; + LayerTransform grown = transform; + grown.size = QSizeF(transform.size.width() * width / (width - inset * 2), transform.size.height() * height / (height - inset * 2)); + grown.origin = transform.center() - QPointF(grown.size.width() / 2, grown.size.height() / 2); + return grown; +} + +double LayerEffectsRenderer::margin(const LayerEffects &all) +{ + const LayerEffects effects = all.visible(); + double margin = 0; + if (effects.stroke && !effects.stroke->inside) + margin = std::max(margin, effects.stroke->size); + if (effects.shadow) + margin = std::max(margin, effects.shadow->distance + effects.shadow->blur * 3); + return std::ceil(margin) + 2; +} + +LayerEffectsRenderer::Rendered LayerEffectsRenderer::render(const QImage &image, const std::optional<QImage> &mask, const LayerEffects &all) +{ + const LayerEffects effects = all.visible(); + if (!effects.isValid()) + throw ProjectError(ProjectError::Kind::invalid); + const double inset = margin(effects); + const qint64 width = image.width() + qint64(inset) * 2, height = image.height() + qint64(inset) * 2; + if (width <= 0 || height <= 0 || width * height > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + // The pixels with room round them, then the effects. + QImage padded = BrushRaster::context(int(width), int(height), false); + { + QPainter painter(&padded); + BrushRaster::draw(masked(image, mask), QRectF(inset, inset, image.width(), image.height()), painter); + } + return {LayerEffectsKernel::render(padded, effects), inset}; +} diff --git a/src/Document/LayerEffects+Renderer.h b/src/Document/LayerEffects+Renderer.h new file mode 100644 index 0000000..6f7c3d9 --- /dev/null +++ b/src/Document/LayerEffects+Renderer.h @@ -0,0 +1,21 @@ +#pragma once +#include "Document/LayerEffects.h" +#include "Document/LayerTransform.h" +#include <QImage> +#include <optional> + +// Swift's LayerEffectsRenderer: a layer with its effects round it. +namespace LayerEffectsRenderer { +// Swift's tuple: the grown image, the room round the pixels. +struct Rendered { + QImage image; + double inset; +}; +// Reused for the same pixels, mask and effects; eight kept. +std::optional<Rendered> cached(const QImage &image, const std::optional<QImage> &mask, const std::optional<LayerEffects> &effects); +// The transform grown by the margin, the image in place. +LayerTransform placed(const LayerTransform &transform, const QImage &image, double inset); +double margin(const LayerEffects &effects); +// `mask` hides pixels first, so effects follow what shows. +Rendered render(const QImage &image, const std::optional<QImage> &mask, const LayerEffects &effects); +} diff --git a/src/Document/LayerEffects+Session.cpp b/src/Document/LayerEffects+Session.cpp new file mode 100644 index 0000000..9c20f99 --- /dev/null +++ b/src/Document/LayerEffects+Session.cpp @@ -0,0 +1,217 @@ +#include "Document/EditorSession.h" + +// Swift's session extension in LayerEffects.swift. +namespace { +// Swift's switch: one kind's effect, present or not. +void take(LayerEffects &into, const LayerEffects &from, LayerEffectKind kind) +{ + switch (kind) { + case LayerEffectKind::stroke: into.stroke = from.stroke; return; + case LayerEffectKind::shadow: into.shadow = from.shadow; return; + case LayerEffectKind::colorOverlay: into.colorOverlay = from.colorOverlay; return; + case LayerEffectKind::innerShadow: into.innerShadow = from.innerShadow; return; + } + throw std::logic_error("unknown effect kind"); +} +} + +// Swift's isGroup terms are dead: folders never hold pixels. +bool EditorSession::canEditEffects() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + return canEditLayers() && layer && layer->asset; +} + +LayerEffects EditorSession::activeEffects() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + return layer && layer->effects ? *layer->effects : LayerEffects(); +} + +LayerEffects EditorSession::editingEffects() const +{ + const int index = m_document && m_effectsEditing ? indexOf(m_document->layers, m_effectsEditing->layerID) : -1; + return index >= 0 ? m_document->layers[size_t(index)].effects.value_or(LayerEffects()) : LayerEffects(); +} + +std::optional<LayerEffectSelection> EditorSession::selectedEffect() const +{ + if (!m_effectSelection || m_effectSelection->layerID != m_activeLayerID || !activeEffects().contains(m_effectSelection->kind)) + return std::nullopt; + return m_effectSelection; +} + +void EditorSession::addEffect(LayerEffectKind kind) +{ + if (!canEditEffects()) + return; + const QUuid id = m_activeLayerID.value(); + if (m_effectsEditing == LayerEffectSelection{id, kind}) + return; + finishEffectsEditing(false); + const LayerEffects original = activeEffects(); + LayerEffects effects = original; + // A new stroke or overlay takes the background colour. + const PaletteColor ground = m_backgroundColor; + switch (kind) { + case LayerEffectKind::stroke: + if (!effects.stroke) + effects.stroke = StrokeEffect{.red = ground.red, .green = ground.green, .blue = ground.blue}; + break; + case LayerEffectKind::shadow: + if (!effects.shadow) + effects.shadow = ShadowEffect(); + break; + case LayerEffectKind::colorOverlay: + if (!effects.colorOverlay) + effects.colorOverlay = ColorOverlayEffect{.red = ground.red, .green = ground.green, .blue = ground.blue}; + break; + case LayerEffectKind::innerShadow: + if (!effects.innerShadow) + effects.innerShadow = InnerShadowEffect(); + break; + } + setEffects(effects, id, QStringLiteral("Add ") + rawValue(kind)); + selectEffect(kind, id, true); + m_effectsEditingOriginal = original; + notify(); +} + +void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing) +{ + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (!canEditLayers() || index < 0 || !m_document->layers[size_t(index)].effects || !m_document->layers[size_t(index)].effects->contains(kind)) + return; + const LayerEffectSelection selection{id, kind}; + if (editing && m_effectsEditing != selection) + finishEffectsEditing(false); + // Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here. + selectLayer(id); + m_isMaskSelected = false; + m_effectSelection = selection; + if (editing && m_effectsEditing != selection) { + m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects()); + m_effectsEditing = selection; + } + notify(); +} + +void EditorSession::finishEffectsEditing(bool commit) +{ + if (!m_effectsEditing) + return; + const LayerEffectSelection editing = *m_effectsEditing; + closeEffectColorPicker(commit); + const int index = m_document ? indexOf(m_document->layers, editing.layerID) : -1; + if (!commit && m_effectsEditingOriginal && index >= 0 && m_document->layers[size_t(index)].effects) { + LayerEffects effects = *m_document->layers[size_t(index)].effects; + take(effects, *m_effectsEditingOriginal, editing.kind); + setEffects(effects, editing.layerID, QStringLiteral("Cancel ") + rawValue(editing.kind)); + } + m_effectsEditing.reset(); + m_effectsEditingOriginal.reset(); + if (!selectedEffect()) + m_effectSelection.reset(); + notify(); +} + +void EditorSession::setEffects(const LayerEffects &effects, std::optional<QUuid> id, const QString &name) +{ + const int index = m_document ? indexOf(m_document->layers, id ? id : m_activeLayerID) : -1; + if (!canEditLayers() || !effects.isValid() || index < 0) + return; + const ImageLayer &layer = m_document->layers[size_t(index)]; + const std::optional<LayerEffects> stored = effects.isEmpty() ? std::nullopt : std::optional(effects); + if (!layer.asset || layer.effects == stored) + return; + finishOpacityEdit(); + beginEdit(name); + m_document->layers[size_t(index)].effects = stored; + endEdit(); +} + +void EditorSession::changeEffects(const std::function<void(LayerEffects &)> &change) +{ + const int index = m_document && m_effectsEditing ? indexOf(m_document->layers, m_effectsEditing->layerID) : -1; + if (index < 0 || !m_document->layers[size_t(index)].effects || !m_document->layers[size_t(index)].effects->contains(m_effectsEditing->kind)) + return; + LayerEffects effects = *m_document->layers[size_t(index)].effects; + change(effects); + setEffects(effects, m_effectsEditing->layerID, QStringLiteral("Edit ") + rawValue(m_effectsEditing->kind)); +} + +bool EditorSession::canCopyEffect(LayerEffectKind kind, QUuid source, QUuid target) const +{ + const int from = m_document ? indexOf(m_document->layers, source) : -1, to = m_document ? indexOf(m_document->layers, target) : -1; + if (!canEditLayers() || source == target || from < 0 || to < 0) + return false; + const ImageLayer &sourceLayer = m_document->layers[size_t(from)], &targetLayer = m_document->layers[size_t(to)]; + return sourceLayer.effects && sourceLayer.effects->contains(kind) && targetLayer.asset; +} + +void EditorSession::copyEffect(LayerEffectKind kind, QUuid source, QUuid target) +{ + if (!canCopyEffect(kind, source, target)) + return; + const LayerEffects original = m_document->layers[size_t(indexOf(m_document->layers, source))].effects.value(); + // Its editor closes first: a later Cancel keeps the copy. + if (m_effectsEditing == LayerEffectSelection{target, kind}) + finishEffectsEditing(true); + LayerEffects effects = m_document->layers[size_t(indexOf(m_document->layers, target))].effects.value_or(LayerEffects()); + take(effects, original, kind); + setEffects(effects, target, QStringLiteral("Copy ") + rawValue(kind)); + selectEffect(kind, target); +} + +void EditorSession::toggleEffect(LayerEffectKind kind, QUuid id) +{ + const int index = m_document ? indexOf(m_document->layers, id) : -1; + if (index < 0 || !m_document->layers[size_t(index)].effects) + return; + LayerEffects effects = *m_document->layers[size_t(index)].effects; + const bool enabled = effects.isEnabled(kind); + effects.setEnabled(!enabled, kind); + setEffects(effects, id, (enabled ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + rawValue(kind)); +} + +void EditorSession::removeSelectedEffect() +{ + const std::optional<LayerEffectSelection> selected = selectedEffect(); + const int index = m_document && selected ? indexOf(m_document->layers, selected->layerID) : -1; + if (!selected || !canEditLayers() || index < 0 || !m_document->layers[size_t(index)].effects) + return; + LayerEffects effects = *m_document->layers[size_t(index)].effects; + if (m_effectsEditing == selected) { + closeEffectColorPicker(false); + m_effectsEditing.reset(); + m_effectsEditingOriginal.reset(); + } + effects.remove(selected->kind); + setEffects(effects, selected->layerID, QStringLiteral("Remove ") + rawValue(selected->kind)); + m_effectSelection.reset(); + notify(); +} + +void EditorSession::setEffectsEditing(std::optional<LayerEffectSelection> selection) +{ + if (std::exchange(m_effectsEditing, selection) != selection) + notify(); +} + +void EditorSession::setEffectsEditingOriginal(std::optional<LayerEffects> original) +{ + if (std::exchange(m_effectsEditingOriginal, original) != original) + notify(); +} + +void EditorSession::dropEffectSelection() +{ + if (std::exchange(m_effectSelection, std::nullopt)) + notify(); +} + +void EditorSession::closeEffectColorPicker(bool commit) +{ + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::effect) + closeColorPicker(commit); +} diff --git a/src/Document/LayerEffects+Session.h b/src/Document/LayerEffects+Session.h new file mode 100644 index 0000000..3a6ad9f --- /dev/null +++ b/src/Document/LayerEffects+Session.h @@ -0,0 +1,27 @@ +// Swift's LayerEffects extension: effects kept with their layer. +public: + bool canEditEffects() const; + LayerEffects activeEffects() const; + // The effects of the layer whose panel is open. + LayerEffects editingEffects() const; + // The chosen effect, if active and still on its layer. + std::optional<LayerEffectSelection> selectedEffect() const; + // Adds the effect with its defaults, then opens its panel. + void addEffect(LayerEffectKind kind); + void selectEffect(LayerEffectKind kind, QUuid id, bool editing = false); + // Cancel puts back this panel's effect alone. + void finishEffectsEditing(bool commit); + // One step, on the active layer unless another is named. + void setEffects(const LayerEffects &effects, std::optional<QUuid> id = std::nullopt, const QString &name = QStringLiteral("Layer Effects")); + // Panel edits stay with the layer that opened the panel. + void changeEffects(const std::function<void(LayerEffects &)> &change); + bool canCopyEffect(LayerEffectKind kind, QUuid source, QUuid target) const; + void copyEffect(LayerEffectKind kind, QUuid source, QUuid target); + void toggleEffect(LayerEffectKind kind, QUuid id); + void removeSelectedEffect(); + // Swift's `effectSelection = nil`, before any guard; announced. + void dropEffectSelection(); + +private: + // An open effect colour picker goes with its panel. + void closeEffectColorPicker(bool commit); diff --git a/src/Document/LayerEffects.cpp b/src/Document/LayerEffects.cpp new file mode 100644 index 0000000..aa9d10d --- /dev/null +++ b/src/Document/LayerEffects.cpp @@ -0,0 +1,178 @@ +#include "Document/LayerEffects.h" +#include <cmath> +#include <stdexcept> + +namespace { +bool within(double value, double low, double high) +{ + return value >= low && value <= high; +} + +bool colourIsValid(double red, double green, double blue) +{ + return within(red, 0, 1) && within(green, 0, 1) && within(blue, 0, 1); +} + +// Away from the light; a layer's pixels count y downward. +QSizeF fallingFrom(double angle, double distance) +{ + const double radians = angle * M_PI / 180; + return QSizeF(-std::cos(radians) * distance, std::sin(radians) * distance); +} + +bool shadowIsValid(double angle, double distance, double blur, double opacity) +{ + return within(angle, -360, 360) && within(distance, 0, 5000) && within(blur, 0, 500) && within(opacity, 0, 1); +} +} + +bool StrokeEffect::isValid() const +{ + return within(size, 0, maxSize) && within(opacity, 0, 1) && colourIsValid(red, green, blue); +} + +QSizeF ShadowEffect::offset() const +{ + return fallingFrom(angle, distance); +} + +bool ShadowEffect::isValid() const +{ + return shadowIsValid(angle, distance, blur, opacity) && colourIsValid(red, green, blue); +} + +bool ColorOverlayEffect::isValid() const +{ + return within(opacity, 0, 1) && colourIsValid(red, green, blue); +} + +QSizeF InnerShadowEffect::offset() const +{ + return fallingFrom(angle, distance); +} + +bool InnerShadowEffect::isValid() const +{ + return shadowIsValid(angle, distance, blur, opacity) && colourIsValid(red, green, blue); +} + +QString rawValue(LayerEffectKind kind) +{ + switch (kind) { + case LayerEffectKind::stroke: return QStringLiteral("Stroke"); + case LayerEffectKind::shadow: return QStringLiteral("Drop Shadow"); + case LayerEffectKind::colorOverlay: return QStringLiteral("Color Overlay"); + case LayerEffectKind::innerShadow: return QStringLiteral("Inner Shadow"); + } + throw std::logic_error("unknown effect kind"); +} + +bool LayerEffects::isValid() const +{ + return (!stroke || stroke->isValid()) && (!shadow || shadow->isValid()) && (!colorOverlay || colorOverlay->isValid()) + && (!innerShadow || innerShadow->isValid()); +} + +std::vector<LayerEffectKind> LayerEffects::kinds() const +{ + std::vector<LayerEffectKind> result; + for (const LayerEffectKind kind : allLayerEffectKinds) { + if (contains(kind)) + result.push_back(kind); + } + return result; +} + +bool LayerEffects::contains(LayerEffectKind kind) const +{ + switch (kind) { + case LayerEffectKind::stroke: return stroke.has_value(); + case LayerEffectKind::shadow: return shadow.has_value(); + case LayerEffectKind::colorOverlay: return colorOverlay.has_value(); + case LayerEffectKind::innerShadow: return innerShadow.has_value(); + } + throw std::logic_error("unknown effect kind"); +} + +bool LayerEffects::isEnabled(LayerEffectKind kind) const +{ + switch (kind) { + case LayerEffectKind::stroke: return stroke && stroke->isEnabled(); + case LayerEffectKind::shadow: return shadow && shadow->isEnabled(); + case LayerEffectKind::colorOverlay: return colorOverlay && colorOverlay->isEnabled(); + case LayerEffectKind::innerShadow: return innerShadow && innerShadow->isEnabled(); + } + throw std::logic_error("unknown effect kind"); +} + +std::optional<PaletteColor> LayerEffects::color(LayerEffectKind kind) const +{ + switch (kind) { + case LayerEffectKind::stroke: return stroke ? std::optional(stroke->color()) : std::nullopt; + case LayerEffectKind::shadow: return shadow ? std::optional(shadow->color()) : std::nullopt; + case LayerEffectKind::colorOverlay: return colorOverlay ? std::optional(colorOverlay->color()) : std::nullopt; + case LayerEffectKind::innerShadow: return innerShadow ? std::optional(innerShadow->color()) : std::nullopt; + } + throw std::logic_error("unknown effect kind"); +} + +namespace { +// Swift's optional chaining: a missing effect takes nothing. +template <typename Effect> void paint(std::optional<Effect> &effect, const PaletteColor &color) +{ + if (!effect) + return; + effect->red = color.red; + effect->green = color.green; + effect->blue = color.blue; +} + +template <typename Effect> void enable(std::optional<Effect> &effect, bool enabled) +{ + if (effect) + effect->enabled = enabled; +} + +template <typename Effect> std::optional<Effect> shown(const std::optional<Effect> &effect) +{ + return effect && effect->isEnabled() ? effect : std::nullopt; +} +} + +void LayerEffects::setColor(const PaletteColor &color, LayerEffectKind kind) +{ + switch (kind) { + case LayerEffectKind::stroke: paint(stroke, color); return; + case LayerEffectKind::shadow: paint(shadow, color); return; + case LayerEffectKind::colorOverlay: paint(colorOverlay, color); return; + case LayerEffectKind::innerShadow: paint(innerShadow, color); return; + } + throw std::logic_error("unknown effect kind"); +} + +void LayerEffects::remove(LayerEffectKind kind) +{ + switch (kind) { + case LayerEffectKind::stroke: stroke.reset(); return; + case LayerEffectKind::shadow: shadow.reset(); return; + case LayerEffectKind::colorOverlay: colorOverlay.reset(); return; + case LayerEffectKind::innerShadow: innerShadow.reset(); return; + } + throw std::logic_error("unknown effect kind"); +} + +void LayerEffects::setEnabled(bool enabled, LayerEffectKind kind) +{ + switch (kind) { + case LayerEffectKind::stroke: enable(stroke, enabled); return; + case LayerEffectKind::shadow: enable(shadow, enabled); return; + case LayerEffectKind::colorOverlay: enable(colorOverlay, enabled); return; + case LayerEffectKind::innerShadow: enable(innerShadow, enabled); return; + } + throw std::logic_error("unknown effect kind"); +} + +LayerEffects LayerEffects::visible() const +{ + return LayerEffects{shown(stroke), shown(shadow), shown(colorOverlay), shown(innerShadow)}; +} diff --git a/src/Document/LayerEffects.h b/src/Document/LayerEffects.h new file mode 100644 index 0000000..37c6429 --- /dev/null +++ b/src/Document/LayerEffects.h @@ -0,0 +1,108 @@ +#pragma once +#include "Document/ColorPalette.h" +#include <QSizeF> +#include <QString> +#include <QUuid> +#include <array> +#include <optional> +#include <vector> + +// A line round what the layer shows, outside or in. +struct StrokeEffect { + // Swift's supported width in document pixels. + static constexpr double maxSize = 500; + // Absent in older projects, which means shown. + std::optional<bool> enabled = std::nullopt; + double size = 4; + double red = 0; + double green = 0; + double blue = 0; + double opacity = 1; + bool inside = false; + bool isEnabled() const { return enabled.value_or(true); } + PaletteColor color() const { return {red, green, blue}; } + bool isValid() const; + friend bool operator==(const StrokeEffect &, const StrokeEffect &) = default; +}; + +// The layer's shape behind it, offset and softened. +struct ShadowEffect { + std::optional<bool> enabled = std::nullopt; + // Where the light comes from, counterclockwise from the right. + double angle = 90; + double distance = 20; + double blur = 20; + double red = 0; + double green = 0; + double blue = 0; + double opacity = 0.5; + bool isEnabled() const { return enabled.value_or(true); } + PaletteColor color() const { return {red, green, blue}; } + // In layer pixels, y down: away from the light. + QSizeF offset() const; + bool isValid() const; + friend bool operator==(const ShadowEffect &, const ShadowEffect &) = default; +}; + +// A flat colour over everything the layer shows. +struct ColorOverlayEffect { + std::optional<bool> enabled = std::nullopt; + double red = 0; + double green = 0; + double blue = 0; + double opacity = 1; + bool isEnabled() const { return enabled.value_or(true); } + PaletteColor color() const { return {red, green, blue}; } + bool isValid() const; + friend bool operator==(const ColorOverlayEffect &, const ColorOverlayEffect &) = default; +}; + +// A shadow inside the layer's own edges. +struct InnerShadowEffect { + std::optional<bool> enabled = std::nullopt; + double angle = 90; + double distance = 10; + double blur = 10; + double red = 0; + double green = 0; + double blue = 0; + double opacity = 0.5; + bool isEnabled() const { return enabled.value_or(true); } + PaletteColor color() const { return {red, green, blue}; } + QSizeF offset() const; + bool isValid() const; + friend bool operator==(const InnerShadowEffect &, const InnerShadowEffect &) = default; +}; + +// Swift's raw values are the panel's and the menu's words. +enum class LayerEffectKind { stroke, shadow, colorOverlay, innerShadow }; +inline constexpr std::array allLayerEffectKinds{LayerEffectKind::stroke, LayerEffectKind::shadow, LayerEffectKind::colorOverlay, + LayerEffectKind::innerShadow}; +QString rawValue(LayerEffectKind kind); + +// What a layer draws round itself; its pixels stay untouched. +struct LayerEffects { + std::optional<StrokeEffect> stroke = std::nullopt; + std::optional<ShadowEffect> shadow = std::nullopt; + std::optional<ColorOverlayEffect> colorOverlay = std::nullopt; + std::optional<InnerShadowEffect> innerShadow = std::nullopt; + bool isEmpty() const { return !stroke && !shadow && !colorOverlay && !innerShadow; } + bool isValid() const; + std::vector<LayerEffectKind> kinds() const; + bool contains(LayerEffectKind kind) const; + bool isEnabled(LayerEffectKind kind) const; + std::optional<PaletteColor> color(LayerEffectKind kind) const; + void setColor(const PaletteColor &color, LayerEffectKind kind); + void remove(LayerEffectKind kind); + void setEnabled(bool enabled, LayerEffectKind kind); + // The effects that are switched on. + LayerEffects visible() const; + friend bool operator==(const LayerEffects &, const LayerEffects &) = default; +}; + +// One effect of one layer: the panel's and the list's. +struct LayerEffectSelection { + QUuid layerID; + LayerEffectKind kind; + friend bool operator==(const LayerEffectSelection &, const LayerEffectSelection &) = default; +}; diff --git a/src/Document/LayerFlip+Session.h b/src/Document/LayerFlip+Session.h new file mode 100644 index 0000000..67e8b8f --- /dev/null +++ b/src/Document/LayerFlip+Session.h @@ -0,0 +1,5 @@ +// Swift's LayerFlip extension. +public: + void flipLayers(bool horizontally); + // Every layer, placed mask and the selection across the middle. + void flipCanvas(bool horizontally); diff --git a/src/Document/LayerFlip.cpp b/src/Document/LayerFlip.cpp new file mode 100644 index 0000000..04390ec --- /dev/null +++ b/src/Document/LayerFlip.cpp @@ -0,0 +1,71 @@ +#include "Document/EditorSession.h" + +LayerTransform LayerTransform::mirrored(bool horizontally, double axis) const +{ + LayerTransform result = *this; + // The middle crosses the line; picture and angle turn over. + if (horizontally) { + result.flipX = !flipX; + result.origin.setX(2 * axis - center().x() - size.width() / 2); + } else { + result.flipY = !flipY; + result.origin.setY(2 * axis - center().y() - size.height() / 2); + } + result.rotation = -rotation; + return result; +} + +// A layer about its middle; a group about its box. +void EditorSession::flipLayers(bool horizontally) +{ + commitTransform(); + if (!canTransform()) + return; + std::vector<ImageLayer> members; + double axis = 0; + if (transformsAsGroup()) { + const LayerTransform box = groupTransformBox().value(); + members = groupTransformMembers(); + axis = horizontally ? box.center().x() : box.center().y(); + } else { + const ImageLayer layer = activeLayer().value(); + members = {layer}; + axis = horizontally ? layer.transform.center().x() : layer.transform.center().y(); + } + QSet<QUuid> ids; + for (const ImageLayer &member : members) + ids.insert(member.id); + beginEdit(horizontally ? QStringLiteral("Flip Horizontal") : QStringLiteral("Flip Vertical")); + for (ImageLayer &layer : m_document->layers) { + if (!ids.contains(layer.id)) + continue; + const LayerTransform flipped = layer.transform.mirrored(horizontally, axis); + // A linked mask flips along; an unlinked one stays. + if (layer.mask) + layer.mask->placement = layer.mask->placementMovingLayer(layer.transform, flipped); + layer.transform = flipped; + } + endEdit(); +} + +// One step; Swift's finishOpacityEdit ran in commitTransform already. +void EditorSession::flipCanvas(bool horizontally) +{ + commitTransform(); + cancelCrop(); + if (!canEditLayers()) + return; + const QSizeF size = m_document->size(); + const double axis = horizontally ? size.width() / 2 : size.height() / 2; + beginEdit(horizontally ? QStringLiteral("Flip Canvas Horizontal") : QStringLiteral("Flip Canvas Vertical")); + for (ImageLayer &layer : m_document->layers) { + layer.transform = layer.transform.mirrored(horizontally, axis); + if (layer.mask && layer.mask->placement) + layer.mask->placement = layer.mask->placement->mirrored(horizontally, axis); + } + if (m_document->selection) { + const QTransform mirror = horizontally ? QTransform(-1, 0, 0, 1, size.width(), 0) : QTransform(1, 0, 0, -1, 0, size.height()); + m_document->selection->path = mirror.map(m_document->selection->path); + } + endEdit(); +} diff --git a/src/Document/LayerGroups+Session.h b/src/Document/LayerGroups+Session.h new file mode 100644 index 0000000..d8847fe --- /dev/null +++ b/src/Document/LayerGroups+Session.h @@ -0,0 +1,13 @@ +// Swift's LayerGroups extension: folders and the multi-selection. +public: + QSet<QUuid> descendantIDs(QUuid id) const; + void addGroup(); + void toggleGroupExpansion(QUuid id); + // The primary layer becomes active when the document holds it. + void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary); + void extendSelection(QUuid id); + void groupSelectedLayers(); + std::vector<LayerHierarchy::Entry> layerRows() const; + bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const; + bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false); + void moveActiveLayerOutOfGroup(); diff --git a/src/Document/LayerGroups.cpp b/src/Document/LayerGroups.cpp new file mode 100644 index 0000000..a0a7442 --- /dev/null +++ b/src/Document/LayerGroups.cpp @@ -0,0 +1,405 @@ +#include "Document/LayerGroups.h" +#include "Document/EditorSession.h" +#include <QHash> +#include <algorithm> +#include <map> +#include <stdexcept> + +namespace { +using Children = std::map<std::optional<QUuid>, std::vector<const ProjectLayerRecord *>>; + +void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool visible, bool topFirst, + const QSet<QUuid> &collapsed, std::vector<LayerHierarchy::Entry> &result) +{ + if (depth > 64) + return; + const auto found = children.find(parent); + if (found == children.end()) + return; + std::vector<const ProjectLayerRecord *> siblings = found->second; + if (topFirst) + std::reverse(siblings.begin(), siblings.end()); + for (const ProjectLayerRecord *layer : siblings) { + const bool effective = visible && layer->isVisible; + result.push_back({*layer, depth, effective}); + if (layer->isGroup == true && !collapsed.contains(layer->id)) + visit(children, layer->id, depth + 1, effective, topFirst, collapsed, result); + } +} + +// Swift's `try?`: a refused hierarchy changes nothing. +bool isValidHierarchy(const std::vector<ImageLayer> &layers) +{ + std::vector<ProjectLayerRecord> records; + for (const ImageLayer &layer : layers) + records.push_back(layer.hierarchyRecord()); + try { + LayerHierarchy::validate(records); + } catch (const ProjectError &) { + return false; + } + return true; +} +} + +std::vector<LayerHierarchy::Entry> LayerHierarchy::entries(const std::vector<ProjectLayerRecord> &layers, + bool topFirst, const QSet<QUuid> &collapsed) +{ + Children children; + for (const ProjectLayerRecord &layer : layers) + children[layer.parentID].push_back(&layer); + std::vector<Entry> result; + visit(children, std::nullopt, 0, true, topFirst, collapsed, result); + return result; +} + +std::vector<ProjectLayerRecord> LayerHierarchy::visibleLayers(const std::vector<ProjectLayerRecord> &layers) +{ + std::vector<ProjectLayerRecord> result; + for (const Entry &entry : entries(layers)) { + if (entry.visible && entry.layer.isGroup != true) + result.push_back(entry.layer); + } + return result; +} + +void LayerHierarchy::validate(const std::vector<ProjectLayerRecord> &layers) +{ + QHash<QUuid, const ProjectLayerRecord *> byID; + for (const ProjectLayerRecord &layer : layers) { + if (byID.contains(layer.id) || (layer.isGroup == true && layer.imageFile)) + throw ProjectError(ProjectError::Kind::invalid); + byID.insert(layer.id, &layer); + } + for (const ProjectLayerRecord &layer : layers) { + QSet<QUuid> seen{layer.id}; + std::optional<QUuid> parent = layer.parentID; + while (parent) { + const ProjectLayerRecord *node = byID.value(*parent); + if (seen.size() > 64 || seen.contains(*parent) || !node || node->isGroup != true) + throw ProjectError(ProjectError::Kind::invalid); + seen.insert(*parent); + parent = node->parentID; + } + if (layer.isGroup == true && seen.size() > 64) + throw ProjectError(ProjectError::Kind::invalid); + } +} + +ProjectLayerRecord ImageLayer::hierarchyRecord() const +{ + const QString file = id.toString(QUuid::WithoutBraces).toUpper(); + const std::optional<LayerShape> live = liveShape(); + const std::optional<LayerText> typed = liveText(); + return {.id = id, + .name = name, + .isVisible = isVisible, + .transform = transform, + .imageFile = asset ? std::optional(file + ".png") : std::nullopt, + .parentID = parentID, + .isGroup = isGroup, + .opacity = opacity, + .blendMode = blendMode, + .maskFile = mask ? std::optional(file + ".mask.png") : std::nullopt, + .maskEnabled = mask ? std::optional(mask->isEnabled) : std::nullopt, + .maskSourceID = maskSourceID, + .adjustment = adjustment, + .maskPlacement = mask ? mask->placement : std::nullopt, + .maskLinked = mask ? std::optional(mask->isLinked) : std::nullopt, + .shape = live ? std::optional(live->style) : std::nullopt, + .effects = effects, + .text = typed ? std::optional(typed->style) : std::nullopt}; +} + +std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const +{ + std::vector<ProjectLayerRecord> records; + for (const ImageLayer &layer : layers) + records.push_back(layer.hierarchyRecord()); + return LayerHierarchy::entries(records); +} + +QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const +{ + QSet<QUuid> result; + for (const LayerHierarchy::Entry &entry : hierarchyEntries()) { + if (entry.visible) + result.insert(entry.layer.id); + } + return result; +} + +std::vector<ImageLayer> CanvasDocument::renderLayers() const +{ + QHash<QUuid, const ImageLayer *> byID; + for (const ImageLayer &layer : layers) { + if (byID.contains(layer.id)) + throw std::logic_error("two layers share an id"); + byID.insert(layer.id, &layer); + } + std::vector<ImageLayer> result; + for (const LayerHierarchy::Entry &entry : hierarchyEntries()) { + if (entry.visible && entry.layer.isGroup != true) + result.push_back(*byID.value(entry.layer.id)); + } + return result; +} + +void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary) +{ + dropEffectSelection(); + if (ids != m_selectedLayerIDs && !finishText()) + return; + if (m_brushStroke) + return; + QSet<QUuid> valid; + if (m_document) { + for (const ImageLayer &layer : m_document->layers) { + if (ids.contains(layer.id)) + valid.insert(layer.id); + } + } + if (valid != m_selectedLayerIDs) { + commitTransform(); + resolveGradient(); + } + // The primary layer is active; any other of them otherwise. + const std::optional<QUuid> active = primary && valid.contains(*primary) ? primary + : valid.isEmpty() ? std::nullopt + : std::optional(*valid.begin()); + setActiveLayerID(active); + m_selectedLayerIDs = valid; + notify(); +} + +void EditorSession::extendSelection(QUuid id) +{ + const auto known = [&](const ImageLayer &layer) { return layer.id == id; }; + // Cmd-Shift-click works while a transform is open too. + if ((!canEditLayers() && !m_transformEdit) || std::none_of(m_document->layers.begin(), m_document->layers.end(), known)) + return; + QSet<QUuid> ids = m_selectedLayerIDs; + if (ids.contains(id) && ids.size() > 1) { + // Taking the active layer out hands its role on. + ids.remove(id); + selectLayers(ids, m_activeLayerID == id ? std::optional(*ids.begin()) : m_activeLayerID); + } else { + ids.insert(id); + selectLayers(ids, id); + } +} + +void EditorSession::groupSelectedLayers() +{ + if (!canEditLayers() || m_document->layers.size() >= 10'000) + return; + const std::vector<ImageLayer> &existing = m_document->layers; + std::map<QUuid, const ImageLayer *> byID; + for (const ImageLayer &layer : existing) + byID[layer.id] = &layer; + // The folders around a layer, nearest first. + using Chain = std::vector<QUuid>; + const auto ancestors = [&](QUuid id) { + Chain result; + for (std::optional<QUuid> parent = byID.at(id)->parentID; parent; parent = byID.at(*parent)->parentID) + result.push_back(*parent); + return result; + }; + // A selected folder carries its contents: they stay inside it. + QSet<QUuid> rootIDs; + for (const QUuid id : m_selectedLayerIDs) { + if (!byID.count(id)) + continue; + const Chain chain = ancestors(id); + if (std::none_of(chain.begin(), chain.end(), [&](QUuid above) { return m_selectedLayerIDs.contains(above); })) + rootIDs.insert(id); + } + std::vector<QUuid> ordered; + std::vector<Chain> chains; + for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) { + if (rootIDs.contains(entry.layer.id)) { + ordered.push_back(entry.layer.id); + chains.push_back(ancestors(entry.layer.id)); + } + } + // The nearest folder they all share; the root when none. + std::optional<QUuid> parent; + for (const QUuid candidate : chains.empty() ? Chain() : chains.front()) { + const auto holds = [&](const Chain &chain) { return std::find(chain.begin(), chain.end(), candidate) != chain.end(); }; + if (std::all_of(chains.begin(), chains.end(), holds)) { + parent = candidate; + break; + } + } + int number = 1; + const auto taken = [&](const QString &name) { + return std::any_of(existing.begin(), existing.end(), [&](const ImageLayer &layer) { return layer.name == name; }); + }; + while (taken(QStringLiteral("Folder %1").arg(number))) + ++number; + ImageLayer group(QStringLiteral("Folder %1").arg(number), m_document->size()); + group.isGroup = true; + group.parentID = parent; + // It sits at the topmost selected branch under that parent. + QSet<QUuid> branches; + for (QUuid branch : ordered) { + while (byID.at(branch)->parentID && byID.at(branch)->parentID != parent) + branch = *byID.at(branch)->parentID; + branches.insert(branch); + } + const auto isKept = [&](const ImageLayer &layer) { return !rootIDs.contains(layer.id); }; + const auto highest = std::find_if(existing.rbegin(), existing.rend(), [&](const ImageLayer &layer) { return branches.contains(layer.id); }); + // With nothing selected the folder goes on top. + const auto insertion = highest == existing.rend() ? std::ptrdiff_t(existing.size()) : std::count_if(existing.begin(), highest.base(), isKept); + std::vector<ImageLayer> layers; + std::copy_if(existing.begin(), existing.end(), std::back_inserter(layers), isKept); + layers.insert(layers.begin() + insertion, group); + for (const QUuid id : ordered) { + ImageLayer child = *byID.at(id); + child.parentID = group.id; + layers.push_back(child); + } + if (!isValidHierarchy(layers)) + return; + beginEdit(QStringLiteral("Group Layers")); + m_document->layers = layers; + setActiveLayerID(group.id); + if (parent) + m_collapsedGroupIDs.remove(*parent); + endEdit(); +} + +std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const +{ + std::vector<ProjectLayerRecord> records; + if (m_document) { + for (const ImageLayer &layer : m_document->layers) + records.push_back(layer.hierarchyRecord()); + } + return LayerHierarchy::entries(records, true, m_collapsedGroupIDs); +} + +QSet<QUuid> EditorSession::descendantIDs(QUuid id) const +{ + QSet<QUuid> result; + if (!m_document) + return result; + // One lookup of children by parent, then the walk. + QMultiHash<QUuid, QUuid> children; + for (const ImageLayer &layer : m_document->layers) { + if (layer.parentID) + children.insert(*layer.parentID, layer.id); + } + std::vector<QUuid> pending{id}; + while (!pending.empty()) { + const QUuid parent = pending.back(); + pending.pop_back(); + for (const QUuid child : children.values(parent)) { + if (!result.contains(child)) { + result.insert(child); + pending.push_back(child); + } + } + } + return result; +} + +void EditorSession::addGroup() +{ + if (!canEditLayers() || m_document->layers.size() >= 10'000) + return; + int number = 1; + const auto taken = [&](const QString &name) { + return std::any_of(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) { return layer.name == name; }); + }; + while (taken(QStringLiteral("Folder %1").arg(number))) + ++number; + ImageLayer group(QStringLiteral("Folder %1").arg(number), m_document->size()); + group.isGroup = true; + const std::optional<ImageLayer> active = activeLayer(); + group.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt; + std::vector<ImageLayer> layers = m_document->layers; + const auto place = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return m_activeLayerID && layer.id == *m_activeLayerID; }); + layers.insert(place == layers.end() ? place : place + 1, group); + // A folder nested too deep is refused. + if (!isValidHierarchy(layers)) + return; + beginEdit(QStringLiteral("New Folder")); + m_document->layers = layers; + setActiveLayerID(group.id); + if (group.parentID) + m_collapsedGroupIDs.remove(*group.parentID); + endEdit(); +} + +void EditorSession::toggleGroupExpansion(QUuid id) +{ + const auto isFolder = [&](const ImageLayer &layer) { return layer.id == id && layer.isGroup; }; + if (m_isProjectBusy || !m_document || std::none_of(m_document->layers.begin(), m_document->layers.end(), isFolder)) + return; + if (m_collapsedGroupIDs.contains(id)) { + m_collapsedGroupIDs.remove(id); + } else { + // A folded folder takes the selection from what it hides. + if (m_activeLayerID && descendantIDs(id).contains(*m_activeLayerID)) + selectLayer(id); + m_collapsedGroupIDs.insert(id); + } + notify(); +} + +bool EditorSession::canPlaceLayer(QUuid id, std::optional<QUuid> parent) const +{ + const auto holds = [&](QUuid wanted, bool folder) { + return std::any_of(m_document->layers.begin(), m_document->layers.end(), + [&](const ImageLayer &layer) { return layer.id == wanted && (!folder || layer.isGroup); }); + }; + if (!canEditLayers() || !holds(id, false)) + return false; + // Into a folder: never itself nor one of its contents. + return !parent || (*parent != id && !descendantIDs(id).contains(*parent) && holds(*parent, true)); +} + +bool EditorSession::placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom) +{ + if (!canPlaceLayer(id, parent)) + return false; + std::vector<ImageLayer> layers = m_document->layers; + const auto from = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return layer.id == id; }); + ImageLayer layer = *from; + layers.erase(from); + layer.parentID = parent; + auto insertion = atBottom ? layers.begin() : layers.end(); + // The layer itself is gone by now: no target. + if (above) { + const auto target = std::find_if(layers.begin(), layers.end(), + [&](const ImageLayer &each) { return each.id == *above && each.parentID == parent; }); + if (target == layers.end()) + return false; + insertion = target + 1; + } + layers.insert(insertion, layer); + // Dropped into a clipping stack it joins; broken ones release. + adoptClipping(id, layers); + releaseDetachedClipping(layers); + if (!isValidHierarchy(layers)) + return false; + beginEdit(QStringLiteral("Move Layer")); + m_document->layers = layers; + setActiveLayerID(id); + if (parent) + m_collapsedGroupIDs.remove(*parent); + endEdit(); + return true; +} + +void EditorSession::moveActiveLayerOutOfGroup() +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!layer || !layer->parentID) + return; + const auto group = std::find_if(m_document->layers.begin(), m_document->layers.end(), + [&](const ImageLayer &each) { return each.id == *layer->parentID; }); + if (group != m_document->layers.end()) + placeLayer(layer->id, group->parentID, group->id); +} diff --git a/src/Document/LayerGroups.h b/src/Document/LayerGroups.h new file mode 100644 index 0000000..8e1c905 --- /dev/null +++ b/src/Document/LayerGroups.h @@ -0,0 +1,17 @@ +#pragma once +#include "IO/ProjectStore.h" +#include <QSet> +#include <vector> + +namespace LayerHierarchy { +struct Entry { + ProjectLayerRecord layer; + int depth; + bool visible; +}; + +std::vector<Entry> entries(const std::vector<ProjectLayerRecord> &layers, bool topFirst = false, + const QSet<QUuid> &collapsed = {}); +std::vector<ProjectLayerRecord> visibleLayers(const std::vector<ProjectLayerRecord> &layers); +void validate(const std::vector<ProjectLayerRecord> &layers); +} diff --git a/src/Document/LayerMask+Session.h b/src/Document/LayerMask+Session.h new file mode 100644 index 0000000..0b26b75 --- /dev/null +++ b/src/Document/LayerMask+Session.h @@ -0,0 +1,20 @@ +// Swift's LayerMask extension: masks on layers and folders. +public: + void selectLayerTarget(QUuid id, bool mask); + // Layers and folders alike take a mask. + bool canEditMask() const; + void addLayerMask(bool revealing = true); + void toggleLayerMask(); + void deleteLayerMask(); + bool canCopyMask(QUuid source, QUuid target) const; + void copyMask(QUuid source, QUuid target); + void toggleMaskLink(QUuid id); + // Where a mask shows now; nil while covering its layer. + std::optional<LayerTransform> displayedMaskPlacement(const ImageLayer &layer) const; + // An unlinked mask distorted alone, in the layer's grid. + std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const; + // The selection made a mask; without one, a solid mask. + void addMask(bool revealing = true); + +private: + void commitMaskTransform(); diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp new file mode 100644 index 0000000..e346374 --- /dev/null +++ b/src/Document/LayerMask.cpp @@ -0,0 +1,493 @@ +#include "Document/Distort.h" +#include "Document/LayerMask.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/DownsampleCache.h" +#include <QHash> +#include "IO/ProjectStore.h" +#include <algorithm> +#include <cmath> +#include <stdexcept> + +LayerMask::LayerMask(ImportedImage asset, bool isEnabled, std::optional<LayerTransform> placement, bool isLinked) + : asset(std::move(asset)), isEnabled(isEnabled), placement(placement), isLinked(isLinked) +{ +} + +std::optional<QImage> LayerMask::enabledImage() const +{ + return isEnabled ? std::optional(asset.image()) : std::nullopt; +} + +std::optional<LayerMask> ProjectSnapshot::mask(const ProjectLayerRecord &layer) const +{ + const auto asset = masks.find(layer.id); + if (!layer.maskFile || asset == masks.end()) + return std::nullopt; + return LayerMask(asset->second, layer.maskEnabled.value_or(true), layer.maskPlacement, layer.maskLinked.value_or(true)); +} + +bool operator==(const LayerMask &lhs, const LayerMask &rhs) +{ + return lhs.asset.identity() == rhs.asset.identity() && lhs.isEnabled == rhs.isEnabled + && lhs.placement == rhs.placement && lhs.isLinked == rhs.isLinked; +} + +LayerMask LayerMask::replacing(ImportedImage replacement) const +{ + return LayerMask(std::move(replacement), isEnabled, placement, isLinked); +} + +bool LayerMask::isValid(const QImage &image) +{ + return image.format() == QImage::Format_Grayscale8; +} + +LayerMask LayerMask::solid(bool revealing) +{ + QImage image = BrushRaster::context(1, 1, true); + image.fill(revealing ? 255 : 0); + return LayerMask(ImportedImage(image, image, QStringLiteral("Layer Mask"))); +} + +ImportedImage LayerMask::assetFrom(const QImage &image) +{ + if (!isValid(image)) + throw ProjectError(ProjectError::Kind::invalid); + const double factor = std::min(1.0, 96.0 / std::max(image.width(), image.height())); + const QSize size(std::max(1, int(image.width() * factor)), std::max(1, int(image.height() * factor))); + const QImage thumbnail = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + throw ExportError(ExportError::Kind::render); + return ImportedImage(image, thumbnail, QStringLiteral("Layer Mask")); +} + +std::optional<LayerTransform> LayerMask::placementMovingLayer(const LayerTransform &old, + const LayerTransform &updated) const +{ + // A uniform mask looks the same wherever it sits. + if (asset.size().width() <= 1 && asset.size().height() <= 1) + return std::nullopt; + std::optional<LayerTransform> moved; + if (!isLinked) + moved = placement.value_or(old); + else if (placement) + moved = placement->following(old, updated); + if (moved && moved->samePlacement(updated)) + return std::nullopt; + return moved; +} + +double LayerMask::background(const QImage &thumbnail) +{ + const int width = thumbnail.width(), height = thumbnail.height(); + if (width <= 0 || height <= 0) + return 1; + if (!isValid(thumbnail)) + throw std::logic_error("a mask thumbnail must be 8-bit gray"); + int total = 0, count = 0; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + if (y != 0 && y != height - 1 && x != 0 && x != width - 1) + continue; + total += thumbnail.constScanLine(y)[x]; + count += 1; + } + } + return total * 2 >= count * 255 ? 1 : 0; +} + +QImage LayerMask::placed(int width, int height, const LayerTransform &layer, const LayerTransform &placement, + int maskWidth, int maskHeight, double background, + const std::function<void(QPainter &)> &compose) +{ + QImage surface = BrushRaster::context(width, height, true); + surface.fill(qRound(background * 255)); + QPainter context(&surface); + context.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); + context.setTransform(BrushRaster::pixelToDocument(placement, maskWidth, maskHeight) + * BrushRaster::pixelToDocument(layer, width, height).inverted()); + compose(context); + context.end(); + return surface; +} + +void LayerMask::drawSmooth(const QImage &image, const QRectF &rect, QPainter &context) +{ + context.save(); + context.setRenderHint(QPainter::SmoothPixmapTransform, true); + context.drawImage(rect, image); + context.restore(); +} + +std::optional<QImage> LayerMask::clipImage(const std::optional<LayerTransform> &placement, const LayerTransform &layer, + int width, int height, std::optional<double> limit) const +{ + const std::optional<QImage> image = enabledImage(); + if (!image) + return std::nullopt; + if (!placement || placement->samePlacement(layer) || width <= 0 || height <= 0) + return image; + const double factor = limit ? std::min(1.0, std::max(1.0, *limit) / std::max(width, height)) : 1; + const int w = std::max(1, int(std::ceil(width * factor))), h = std::max(1, int(std::ceil(height * factor))); + return MaskPlacementCache::shared().image(*image, *placement, layer, w, h, [&]() -> std::optional<QImage> { + // Drawn from a sharp halving near the covered size. + const double covered = placement->size.width() / std::max(1.0, layer.size.width()) * w; + const QImage source = DownsampleCache::shared().imageDrawnAt(*image, covered / image->width()); + try { + return placed(w, h, layer, *placement, image->width(), image->height(), background(asset.thumbnail), + [&](QPainter &context) { drawSmooth(source, QRectF(0, 0, image->width(), image->height()), context); }); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "a placed mask could not be rendered:" << error.what(); + return std::nullopt; + } + }); +} + +MaskPlacementCache &MaskPlacementCache::shared() +{ + static MaskPlacementCache cache; + return cache; +} + +std::optional<QImage> MaskPlacementCache::image(const QImage &mask, const LayerTransform &placement, + const LayerTransform &layer, int width, int height, + const std::function<std::optional<QImage>()> &build) +{ + { + const std::lock_guard<std::mutex> guard(m_lock); + m_clock += 1; + for (Entry &entry : m_entries) { + if (entry.mask.cacheKey() == mask.cacheKey() && entry.placement == placement && entry.layer == layer + && entry.width == width && entry.height == height) { + entry.lastUse = m_clock; + return entry.image; + } + } + } + const std::optional<QImage> image = build(); + if (!image) + return std::nullopt; + constexpr qint64 pixelBudget = 64'000'000; + if (qint64(width) * height > pixelBudget) + return image; + const std::lock_guard<std::mutex> guard(m_lock); + m_entries.push_back({mask, placement, layer, width, height, *image, m_clock}); + const auto pixels = [&] { + qint64 total = 0; + for (const Entry &entry : m_entries) + total += qint64(entry.width) * entry.height; + return total; + }; + while (m_entries.size() > 8 || pixels() > pixelBudget) { + m_entries.erase(std::min_element(m_entries.begin(), m_entries.end(), + [](const Entry &lhs, const Entry &rhs) { return lhs.lastUse < rhs.lastUse; })); + } + return image; +} + +void FolderMaskClip::apply(QPointF center, const QPainter &context, QImage &coverage, double scale) const +{ + const double width = transform.size.width() * scale, height = transform.size.height() * scale; + QImage veil(coverage.size(), QImage::Format_Alpha8); + if (veil.isNull()) + throw ExportError(ExportError::Kind::render); + veil.fill(0); + { + QTransform placement; + placement.translate(center.x(), center.y()); + placement.rotateRadians(transform.radians()); + placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1); + QPainter masking(&veil); + masking.setRenderHint(QPainter::SmoothPixmapTransform, quality(transform.sampling) != InterpolationQuality::none); + masking.setRenderHint(QPainter::Antialiasing, true); + masking.setTransform(placement * context.deviceTransform()); + masking.drawImage(QRectF(-width / 2, -height / 2, width, height), BrushRaster::alphaView(image)); + } + // A failed copy of shared coverage must not pass. + QPainter multiplying(&coverage); + if (!multiplying.isActive()) + throw ExportError(ExportError::Kind::render); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(coverage.rect()), veil); +} + +void FolderMaskClip::draw(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent, + const std::function<std::optional<Applier>(QUuid)> &clip, const QPainter &context, + const std::function<void(QUuid, const QImage &clip)> &drawLayer) +{ + // Coverage through a folder and its ancestors; null for none. + QHash<QUuid, QImage> covered; + std::function<QImage(std::optional<QUuid>, int)> through = [&](std::optional<QUuid> folder, int depth) -> QImage { + if (!folder || depth >= 64) + return QImage(); + if (const auto found = covered.constFind(*folder); found != covered.constEnd()) + return *found; + QImage coverage = through(parent(*folder), depth + 1); + if (const std::optional<Applier> applier = clip(*folder)) { + if (coverage.isNull()) { + coverage = QImage(context.device()->width(), context.device()->height(), QImage::Format_Alpha8); + if (coverage.isNull()) + throw ExportError(ExportError::Kind::render); + coverage.fill(255); + } + (*applier)(context, coverage); + } + covered.insert(*folder, coverage); + return coverage; + }; + for (const QUuid &id : ids) + drawLayer(id, through(parent(id), 0)); +} + +LayerTransform ImageLayer::maskTransform() const +{ + return mask && mask->placement ? *mask->placement : transform; +} + +bool EditorSession::canEditMask() const +{ + return canEditLayers() && m_selectedLayerIDs.size() == 1 && activeLayer().has_value(); +} + +void EditorSession::selectLayerTarget(QUuid id, bool mask) +{ + dropEffectSelection(); + if (m_isProjectBusy || m_isImporting || m_brushStroke) + return; + resolveGradient(); + selectLayer(id); + const std::optional<ImageLayer> active = activeLayer(); + m_isMaskSelected = mask && active && active->mask; + notify(); +} + +void EditorSession::addMask(bool revealing) +{ + const std::optional<DocumentSelection> current = selection(); + if (!current) { + addLayerMask(revealing); + return; + } + const std::optional<ImageLayer> active = activeLayer(); + if (!canEditMask() || active->mask) + return; + const int index = indexOf(m_document->layers, active->id); + // Mask pixels cover the layer's own pixel grid. + const int width = active->asset ? active->asset->size().width() : int(std::round(active->size().width())); + const int height = active->asset ? active->asset->size().height() : int(std::round(active->size().height())); + try { + if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + QImage image = BrushRaster::context(width, height, true); + image.fill(revealing ? Qt::white : Qt::black); + const QTransform toPixels = BrushRaster::pixelToDocument(active->transform, width, height).inverted(); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, current->antialiased); + painter.fillPath(toPixels.map(current->path), revealing ? Qt::black : Qt::white); + const LayerMask mask(LayerMask::assetFrom(image)); + finishOpacityEdit(); + beginEdit(QStringLiteral("Add Mask from Selection")); + m_document->layers[size_t(index)].mask = mask; + m_document->selection = std::nullopt; + m_isMaskSelected = true; + endEdit(); + } catch (const std::runtime_error &error) { + qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what(); + setBrushError(QString::fromUtf8(error.what())); + } +} + +void EditorSession::addLayerMask(bool revealing) +{ + const std::optional<ImageLayer> active = activeLayer(); + if (!canEditMask() || active->mask) + return; + finishOpacityEdit(); + beginEdit(revealing ? QStringLiteral("Add Reveal-All Mask") : QStringLiteral("Add Hide-All Mask")); + for (ImageLayer &layer : m_document->layers) { + if (layer.id == active->id) + layer.mask = LayerMask::solid(revealing); + } + m_isMaskSelected = true; + endEdit(); +} + +void EditorSession::toggleLayerMask() +{ + const std::optional<ImageLayer> active = activeLayer(); + if (!canEditMask() || !active->mask) + return; + finishOpacityEdit(); + beginEdit(active->mask->isEnabled ? QStringLiteral("Disable Layer Mask") : QStringLiteral("Enable Layer Mask")); + for (ImageLayer &layer : m_document->layers) { + if (layer.id == active->id) + layer.mask->isEnabled = !layer.mask->isEnabled; + } + endEdit(); +} + +void EditorSession::deleteLayerMask() +{ + const std::optional<ImageLayer> active = activeLayer(); + if (!canEditMask() || !active->mask) + return; + finishOpacityEdit(); + beginEdit(QStringLiteral("Delete Layer Mask")); + for (ImageLayer &layer : m_document->layers) { + if (layer.id == active->id) + layer.mask = std::nullopt; + } + m_isMaskSelected = false; + endEdit(); +} + +bool EditorSession::canCopyMask(QUuid source, QUuid target) const +{ + if (!canEditLayers() || source == target) + return false; + const auto holds = [&](QUuid id, bool asSource) { + return std::any_of(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) { + return layer.id == id && (asSource ? layer.mask.has_value() : !layer.isGroup); + }); + }; + return holds(source, true) && holds(target, false); +} + +void EditorSession::copyMask(QUuid source, QUuid target) +{ + if (!canCopyMask(source, target)) + return; + commitTransform(); + const auto find = [&](QUuid id) { + return std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) { return layer.id == id; }); + }; + // The copy sits where the mask sits on the document. + LayerMask mask = *find(source)->mask; + mask.placement = find(source)->maskTransform(); + beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask")); + find(target)->mask = mask; + selectLayer(target); + m_isMaskSelected = true; + endEdit(); +} + +void EditorSession::toggleMaskLink(QUuid id) +{ + const auto holds = [&](const ImageLayer &layer) { return layer.id == id && layer.mask; }; + if (!canEditLayers() || std::none_of(m_document->layers.begin(), m_document->layers.end(), holds)) + return; + commitTransform(); + const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), holds); + beginEdit(layer->mask->isLinked ? QStringLiteral("Unlink Layer Mask") : QStringLiteral("Link Layer Mask")); + layer->mask->isLinked = !layer->mask->isLinked; + endEdit(); +} + +std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageLayer &layer) const +{ + if (!layer.mask) + return std::nullopt; + // Previewed on a grown layer, the mask keeps its bounds. + if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id)) + return layer.mask->placement.value_or(layer.transform); + if (m_transformEdit && m_transformEdit->group) { + const TransformGroup &group = *m_transformEdit->group; + const auto original = group.originals.constFind(layer.id); + if (original == group.originals.constEnd()) + return layer.mask->placement; + if (m_transformEdit->corners) + return layer.mask->isLinked && !layer.mask->placement ? std::nullopt : std::optional(layer.mask->placement.value_or(layer.transform)); + return layer.mask->placementMovingLayer(layer.transform, original->following(group.box, m_transformEdit->draft)); + } + if (!m_transformEdit || m_transformEdit->layerID != layer.id) + return layer.mask->placement; + const LayerTransform &draft = m_transformEdit->draft; + if (m_transformEdit->mask) + return draft.samePlacement(layer.transform) ? std::nullopt : std::optional(draft); + // A distortion carries a linked layer's own mask; others stay. + if (m_transformEdit->corners) + return layer.mask->isLinked && !layer.mask->placement ? std::nullopt : std::optional(layer.mask->placement.value_or(layer.transform)); + return layer.mask->placementMovingLayer(layer.transform, draft); +} + +std::optional<QImage> EditorSession::maskDistortPreview(const ImageLayer &layer) const +{ + const std::optional<TransformEdit> &edit = m_transformEdit; + if (!edit || !edit->mask || edit->layerID != layer.id || !edit->corners || !layer.mask || !layer.mask->isEnabled) + return std::nullopt; + const LayerMask &owned = *layer.mask; + if (m_maskDistortPreviewCache && m_maskDistortPreviewCache->corners == *edit->corners && m_maskDistortPreviewCache->draft == edit->draft + && m_maskDistortPreviewCache->mask == owned.asset.identity() && m_maskDistortPreviewCache->layer == layer.transform) + return m_maskDistortPreviewCache->result; + const int width = layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())); + const int height = layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height())); + std::optional<QImage> result; + try { + const DistortWarp::Warped moved = DistortWarp::warpMask(owned.asset.image(), edit->draft, *edit->corners, LayerMask::background(owned.asset.thumbnail), 2048); + result = LayerMask(ImportedImage(moved.image, owned.asset.thumbnail, owned.asset.name)).clipImage(moved.transform, layer.transform, width, height, 2048); + } catch (const ProjectError &) { + } catch (const ExportError &) { + } + m_maskDistortPreviewCache = MaskDistortPreviewCache{*edit->corners, edit->draft, owned.asset.identity(), layer.transform, result}; + return result; +} + +void EditorSession::commitMaskTransform() +{ + const TransformEdit edit = *m_transformEdit; + m_maskDistortPreviewCache.reset(); + const int index = indexOf(m_document->layers, edit.layerID); + if (edit.corners && edit.draft.isValid() && index >= 0 && m_document->layers[index].mask) { + // A distorted mask is resampled; its background fills outside. + m_transformEdit = std::nullopt; + const ImageLayer layer = m_document->layers[index]; + const LayerMask &mask = *layer.mask; + try { + const DistortWarp::Warped moved = DistortWarp::warpMask(mask.asset.image(), edit.draft, *edit.corners, LayerMask::background(mask.asset.thumbnail)); + // Everything that can throw comes before the transaction opens. + const ImportedImage asset = moved.image.cacheKey() == mask.asset.image().cacheKey() ? mask.asset : LayerMask::assetFrom(moved.image); + beginEdit(QStringLiteral("Distort Layer Mask")); + m_document->layers[index].mask = LayerMask(asset, mask.isEnabled, + moved.transform.samePlacement(layer.transform) ? std::nullopt : std::optional(moved.transform), mask.isLinked); + endEdit(); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + return; + } + // The mask takes the placement; its pixels are untouched. + beginEdit(QStringLiteral("Transform Layer Mask")); + for (ImageLayer &layer : m_document->layers) { + if (layer.id == edit.layerID && layer.mask && edit.draft.isValid()) + layer.mask->placement = edit.draft.samePlacement(layer.transform) ? std::nullopt : std::optional(edit.draft); + } + m_transformEdit = std::nullopt; + endEdit(); +} + +// The stroke's mask, in the layer's grid, preview-sized. +std::optional<QImage> BrushStroke::placedMaskPreview(const LayerTransform &placement) const +{ + const int gridWidth = layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())); + const int gridHeight = layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height())); + const double factor = std::min(1.0, 2048.0 / std::max({1, gridWidth, gridHeight})); + const int w = std::max(1, int(std::ceil(gridWidth * factor))), h = std::max(1, int(std::ceil(gridHeight * factor))); + const std::optional<ImportedImage> &old = layer.mask ? std::optional(layer.mask->asset) : std::nullopt; + const double perMaskPixel = placement.size.width() / std::max(1, width) * w / std::max(1.0, layer.transform.size.width()); + try { + return LayerMask::placed(w, h, layer.transform, placement, width, height, old ? LayerMask::background(old->thumbnail) : 1, [&](QPainter &context) { + if (old) + LayerMask::drawSmooth(DownsampleCache::shared().imageDrawnAt(old->image(), perMaskPixel), sourceRect, context); + for (const BrushPatch &patch : patches()) + LayerMask::drawSmooth(patch.image, patch.rect, context); + }); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "the placed mask's preview could not be made:" << error.what(); + return std::nullopt; + } +} diff --git a/src/Document/LayerMask.h b/src/Document/LayerMask.h new file mode 100644 index 0000000..58f5d35 --- /dev/null +++ b/src/Document/LayerMask.h @@ -0,0 +1,84 @@ +#pragma once +#include "Document/LayerTransform.h" +#include "IO/ImageImporter.h" +#include <QPainter> +#include <QUuid> +#include <functional> +#include <mutex> +#include <optional> +#include <vector> + +// Layer-local coverage: white reveals, black hides, gray is soft. +struct LayerMask { + ImportedImage asset; + bool isEnabled = true; + // Set once the mask moves apart from its layer. + std::optional<LayerTransform> placement; + bool isLinked = true; + + explicit LayerMask(ImportedImage asset, bool isEnabled = true, + std::optional<LayerTransform> placement = std::nullopt, bool isLinked = true); + + std::optional<QImage> enabledImage() const; + friend bool operator==(const LayerMask &lhs, const LayerMask &rhs); + LayerMask replacing(ImportedImage asset) const; + static bool isValid(const QImage &image); + static LayerMask solid(bool revealing); + static ImportedImage assetFrom(const QImage &image); + + std::optional<LayerTransform> placementMovingLayer(const LayerTransform &old, const LayerTransform &updated) const; + static double background(const QImage &thumbnail); + static QImage placed(int width, int height, const LayerTransform &layer, const LayerTransform &placement, + int maskWidth, int maskHeight, double background, + const std::function<void(QPainter &)> &compose); + static void drawSmooth(const QImage &image, const QRectF &rect, QPainter &context); + // The mask over a layer's pixel grid; nil while disabled. + std::optional<QImage> clipImage(const std::optional<LayerTransform> &placement, const LayerTransform &layer, + int width, int height, std::optional<double> limit = std::nullopt) const; +}; + +// The canvas's last preview of an unlinked mask distorted alone. +struct MaskDistortPreviewCache { + Corners corners; + LayerTransform draft; + ImageIdentity mask; + LayerTransform layer; + std::optional<QImage> result; +}; + +// Masks resampled into their layers' grids, least recently used dropped. +class MaskPlacementCache { +public: + static MaskPlacementCache &shared(); + std::optional<QImage> image(const QImage &mask, const LayerTransform &placement, const LayerTransform &layer, + int width, int height, const std::function<std::optional<QImage>()> &build); + +private: + struct Entry { + QImage mask; + LayerTransform placement; + LayerTransform layer; + int width; + int height; + QImage image; + quint64 lastUse; + }; + std::vector<Entry> m_entries; + quint64 m_clock = 0; + std::mutex m_lock; +}; + +// A folder's mask, multiplied into every layer inside the folder. +struct FolderMaskClip { + QImage image; + LayerTransform transform; + + using Applier = std::function<void(const QPainter &context, QImage &coverage)>; + + // Multiplies the mask, placed like a layer's own, into coverage. + void apply(QPointF center, const QPainter &context, QImage &coverage, double scale = 1) const; + // Draws ids in order, each through its folders' masks. + static void draw(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent, + const std::function<std::optional<Applier>(QUuid)> &clip, const QPainter &context, + const std::function<void(QUuid, const QImage &clip)> &drawLayer); +}; diff --git a/src/Document/LayerMerge+Session.h b/src/Document/LayerMerge+Session.h new file mode 100644 index 0000000..9a995ee --- /dev/null +++ b/src/Document/LayerMerge+Session.h @@ -0,0 +1,8 @@ +// Swift's LayerMerge extension: selected layers made one. +public: + bool canMergeLayers() const; + QString mergeTitle() const; + void mergeLayers(); + +private: + std::optional<MergePlan> mergePlan() const; diff --git a/src/Document/LayerMerge.cpp b/src/Document/LayerMerge.cpp new file mode 100644 index 0000000..ec72919 --- /dev/null +++ b/src/Document/LayerMerge.cpp @@ -0,0 +1,140 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/Filters.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" + +// What Ctrl+E merges, in stacking order, and its place. +std::optional<EditorSession::MergePlan> EditorSession::mergePlan() const +{ + const std::optional<ImageLayer> active = activeLayer(); + if (!canEditLayers() || !active) + return std::nullopt; + const std::vector<ImageLayer> &layers = m_document->layers; + if (m_selectedLayerIDs.size() > 1) { + // Several selected layers, with anything their folders hold. + QSet<QUuid> picked = m_selectedLayerIDs; + for (const QUuid &id : m_selectedLayerIDs) + picked.unite(descendantIDs(id)); + std::vector<QUuid> ordered; + std::optional<ImageLayer> top; + bool pixels = false; + for (const ImageLayer &layer : layers) { + if (!picked.contains(layer.id)) + continue; + ordered.push_back(layer.id); + pixels = pixels || !layer.isGroup; + if (m_selectedLayerIDs.contains(layer.id)) + top = layer; + } + if (!pixels || !top) + return std::nullopt; + return MergePlan{ordered, picked, top->name, top->parentID, top->id, QStringLiteral("Merge Layers")}; + } + if (active->isGroup) { + // A folder merges its contents, and the folder goes. + const QSet<QUuid> inside = descendantIDs(active->id); + std::vector<QUuid> ids; + bool pixels = false; + for (const ImageLayer &layer : layers) { + if (inside.contains(layer.id) || layer.id == active->id) + ids.push_back(layer.id); + pixels = pixels || (inside.contains(layer.id) && !layer.isGroup); + } + if (!pixels) + return std::nullopt; + return MergePlan{ids, QSet<QUuid>(ids.begin(), ids.end()), active->name, active->parentID, active->id, QStringLiteral("Merge Group")}; + } + // One layer merges with the one beneath it. + for (int index = indexOf(layers, active->id) - 1; index >= 0; --index) { + const ImageLayer &below = layers[index]; + if (below.parentID != active->parentID) + continue; + if (below.isGroup) + return std::nullopt; + return MergePlan{{below.id, active->id}, {below.id, active->id}, below.name, active->parentID, active->id, QStringLiteral("Merge Down")}; + } + return std::nullopt; +} + +bool EditorSession::canMergeLayers() const +{ + return mergePlan().has_value(); +} + +QString EditorSession::mergeTitle() const +{ + const std::optional<MergePlan> plan = mergePlan(); + return plan ? plan->action : QStringLiteral("Merge Down"); +} + +// The layers as shown, baked into one pixel layer. +void EditorSession::mergeLayers() +{ + commitTransform(); + const std::optional<MergePlan> plan = mergePlan(); + if (!plan) + return; + const std::vector<ImageLayer> &layers = m_document->layers; + const QSet<QUuid> kept(plan->ids.begin(), plan->ids.end()); + // Only the merged layers, cut loose from the rest. + CanvasDocument flat(m_document->width, m_document->height); + flat.id = m_document->id; + flat.resolution = m_document->resolution; + for (const ImageLayer &layer : layers) { + if (!kept.contains(layer.id)) + continue; + ImageLayer copy = layer; + if (copy.parentID && !kept.contains(*copy.parentID)) + copy.parentID = std::nullopt; + if (copy.maskSourceID && !kept.contains(*copy.maskSourceID)) + copy.maskSourceID = std::nullopt; + flat.layers.push_back(copy); + } + std::optional<ImageLayer> merged; + try { + QImage full = BrushRaster::context(m_document->width, m_document->height, false); + { + QPainter context(&full); + drawLiveComposite(flat, context); + } + const LayerTransform canvas{.origin = {0, 0}, .size = m_document->size()}; + const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(full, canvas); + // At 1:1 the trimmed size is the crop's: the constructor's. + merged = ImageLayer(ImportedImage(trimmed.image, PixelAdjust::thumbnail(trimmed.image), plan->name), trimmed.transform.origin); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "merge: the layers could not be rendered:" << error.what(); + return; + } + merged->parentID = plan->parent; + std::vector<ImageLayer> next; + int insertion = 0; + for (const ImageLayer &layer : layers) { + if (plan->removed.contains(layer.id)) + continue; + next.push_back(layer); + // Layers clipped to anything merged now clip to the result. + if (next.back().maskSourceID && plan->removed.contains(*next.back().maskSourceID)) + next.back().maskSourceID = merged->id; + } + for (const ImageLayer &layer : layers) { + if (layer.id == plan->anchor) + break; + insertion += plan->removed.contains(layer.id) ? 0 : 1; + } + next.insert(next.begin() + insertion, *merged); + std::vector<ProjectLayerRecord> records; + for (const ImageLayer &layer : next) + records.push_back(layer.hierarchyRecord()); + try { + LayerHierarchy::validate(records); + } catch (const ProjectError &error) { + qCWarning(lcRendering) << "merge: the result is no valid hierarchy:" << error.what(); + return; + } + beginEdit(plan->action); + m_document->layers = std::move(next); + setActiveLayerID(merged->id); + endEdit(); +} diff --git a/src/Document/LayerTransform.cpp b/src/Document/LayerTransform.cpp new file mode 100644 index 0000000..8209440 --- /dev/null +++ b/src/Document/LayerTransform.cpp @@ -0,0 +1,282 @@ +#include "Document/LayerTransform.h" +#include "Document/BrushStroke.h" +#include <algorithm> +#include <cmath> +#include <numbers> +#include <stdexcept> + +namespace { +bool sameSize(QSizeF lhs, QSizeF rhs) +{ + return lhs.width() == rhs.width() && lhs.height() == rhs.height(); +} + +struct Shift { + double move = 0; + std::optional<double> target; +}; + +Shift shift(const std::array<double, 3> &guides, const std::vector<double> &targets, double tolerance) +{ + Shift best; + for (double guide : guides) { + for (double target : targets) { + const double move = target - guide; + if (std::abs(move) > tolerance) + continue; + if (best.target && std::abs(best.move) <= std::abs(move)) + continue; + best = {move, target}; + } + } + return best; +} +} + +QString rawValue(LayerSampling sampling) +{ + switch (sampling) { + case LayerSampling::nearest: + return QStringLiteral("Nearest"); + case LayerSampling::smooth: + return QStringLiteral("Smooth"); + case LayerSampling::high: + return QStringLiteral("High quality"); + } + throw std::logic_error("unknown LayerSampling"); +} + +std::optional<LayerSampling> layerSampling(const QString &rawValue) +{ + for (const LayerSampling sampling : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high}) { + if (rawValue == ::rawValue(sampling)) + return sampling; + } + return std::nullopt; +} + +InterpolationQuality quality(LayerSampling sampling) +{ + switch (sampling) { + case LayerSampling::nearest: + return InterpolationQuality::none; + case LayerSampling::smooth: + return InterpolationQuality::low; + case LayerSampling::high: + return InterpolationQuality::high; + } + throw std::logic_error("unknown LayerSampling"); +} + +const std::array<QPointF, 8> LayerTransform::handles = { + QPointF(0, 0), QPointF(0.5, 0), QPointF(1, 0), QPointF(1, 0.5), + QPointF(1, 1), QPointF(0.5, 1), QPointF(0, 1), QPointF(0, 0.5)}; + +bool operator==(const LayerTransform &lhs, const LayerTransform &rhs) +{ + return lhs.origin.x() == rhs.origin.x() && lhs.origin.y() == rhs.origin.y() + && sameSize(lhs.size, rhs.size) && lhs.rotation == rhs.rotation + && lhs.flipX == rhs.flipX && lhs.flipY == rhs.flipY && lhs.sampling == rhs.sampling; +} + +QPointF LayerTransform::center() const +{ + return {origin.x() + size.width() / 2, origin.y() + size.height() / 2}; +} + +double LayerTransform::radians() const +{ + return std::fmod(rotation, 360) * std::numbers::pi / 180; +} + +bool LayerTransform::isValid() const +{ + const std::array<double, 5> values = {origin.x(), origin.y(), size.width(), size.height(), rotation}; + return std::all_of(values.begin(), values.end(), [](double value) { return std::isfinite(value); }) + && size.width() >= 1 && size.width() <= 300'000 && size.height() >= 1 && size.height() <= 300'000 + && std::abs(origin.x()) <= 1'000'000 && std::abs(origin.y()) <= 1'000'000; +} + +QPointF LayerTransform::point(QPointF unit) const +{ + const double x = (unit.x() - 0.5) * size.width(), y = (unit.y() - 0.5) * size.height(); + const QPointF middle = center(); + return {middle.x() + x * std::cos(radians()) - y * std::sin(radians()), + middle.y() + x * std::sin(radians()) + y * std::cos(radians())}; +} + +bool LayerTransform::contains(QPointF point) const +{ + const double x = point.x() - center().x(), y = point.y() - center().y(); + return std::abs(x * std::cos(radians()) + y * std::sin(radians())) <= size.width() / 2 + && std::abs(-x * std::sin(radians()) + y * std::cos(radians())) <= size.height() / 2; +} + +double LayerTransform::scalePercent(QSizeF pixelSize) const +{ + return size.width() / std::max(1.0, pixelSize.width()) * 100; +} + +LayerTransform LayerTransform::scaled(double toPercent, QSizeF pixelSize) const +{ + LayerTransform result = *this; + result.size = {pixelSize.width() * toPercent / 100, pixelSize.height() * toPercent / 100}; + result.origin = {center().x() - result.size.width() / 2, center().y() - result.size.height() / 2}; + return result; +} + +LayerTransform LayerTransform::rounded() const +{ + LayerTransform result = *this; + result.origin = {std::round(origin.x()), std::round(origin.y())}; + result.size = {std::max(1.0, std::round(size.width())), std::max(1.0, std::round(size.height()))}; + result.rotation = std::round(rotation); + return result; +} + +QTransform LayerTransform::unitToDocument() const +{ + return BrushRaster::pixelToDocument(*this, 1, 1); +} + +LayerTransform LayerTransform::placing(const QTransform &map) const +{ + const double sign = flipX ? -1 : 1; + const double angle = std::atan2(map.m12() * sign, map.m11() * sign); + const double along = -map.m21() * std::sin(angle) + map.m22() * std::cos(angle); + const QPointF middle = map.map(QPointF(0.5, 0.5)); + LayerTransform result = *this; + result.size = {std::hypot(map.m11(), map.m12()), std::abs(along)}; + const double degrees = angle * 180 / std::numbers::pi; + result.rotation = degrees + std::round((rotation - degrees) / 360) * 360; + result.flipY = along < 0; + result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2}; + return result; +} + +LayerTransform LayerTransform::following(const LayerTransform &old, const LayerTransform &updated) const +{ + if (old == updated) + return *this; + if (sameSize(old.size, updated.size) && old.rotation == updated.rotation + && old.flipX == updated.flipX && old.flipY == updated.flipY) { + LayerTransform moved = *this; + moved.origin += updated.origin - old.origin; + return moved; + } + return placing(unitToDocument() * old.unitToDocument().inverted() * updated.unitToDocument()); +} + +bool LayerTransform::samePlacement(const LayerTransform &other) const +{ + LayerTransform copy = *this; + copy.sampling = other.sampling; + return copy == other; +} + +std::optional<Corners> TransformDrag::corners(QPointF to, bool shift) const +{ + if (!originalCorners) + return std::nullopt; + double dx = to.x() - start.x(), dy = to.y() - start.y(); + if (shift) { + if (std::abs(dx) >= std::abs(dy)) + dy = 0; + else + dx = 0; + } + std::vector<int> moved; + if (mode.kind == Kind::distort) { + const int corner = mode.index / 2; + moved = mode.index % 2 == 0 ? std::vector<int>{corner} : std::vector<int>{corner, (corner + 1) % 4}; + } else if (mode.kind == Kind::move) { + moved = {0, 1, 2, 3}; + } else { + return std::nullopt; + } + Corners result = *originalCorners; + for (int corner : moved) + result[corner] += QPointF(dx, dy); + return result; +} + +LayerTransform TransformDrag::updated(QPointF to, bool lockRatio, bool shift, bool option) const +{ + LayerTransform result = original; + const double radians = original.radians(); + switch (mode.kind) { + case Kind::distort: + break; + case Kind::move: { + double dx = to.x() - start.x(), dy = to.y() - start.y(); + if (shift) { + if (std::abs(dx) >= std::abs(dy)) + dy = 0; + else + dx = 0; + } + result.origin += QPointF(dx, dy); + break; + } + case Kind::rotate: { + const QPointF center = original.center(); + const double delta = std::atan2(to.y() - center.y(), to.x() - center.x()) + - std::atan2(start.y() - center.y(), start.x() - center.x()); + result.rotation += delta * 180 / std::numbers::pi; + if (shift) + result.rotation = std::round(result.rotation / 15) * 15; + break; + } + case Kind::resize: { + const QPointF handle = LayerTransform::handles[mode.index]; + const QPointF anchorUnit = option ? QPointF(0.5, 0.5) : QPointF(1 - handle.x(), 1 - handle.y()); + const QPointF anchor = original.point(anchorUnit); + const QPointF initialHandle = original.point(handle); + const double dx = initialHandle.x() + to.x() - start.x() - anchor.x(); + const double dy = initialHandle.y() + to.y() - start.y() - anchor.y(); + const double span = option ? 2 : 1; + const double localX = (dx * std::cos(radians) + dy * std::sin(radians)) * span; + const double localY = (-dx * std::sin(radians) + dy * std::cos(radians)) * span; + const double sx = handle.x() * 2 - 1, sy = handle.y() * 2 - 1; + const double originalWidth = original.size.width(), originalHeight = original.size.height(); + const double rawWidth = sx == 0 ? originalWidth : localX * sx; + const double rawHeight = sy == 0 ? originalHeight : localY * sy; + const bool mirroredX = rawWidth < 0, mirroredY = rawHeight < 0; + double width = std::max(1.0, std::abs(rawWidth)); + double height = std::max(1.0, std::abs(rawHeight)); + if (lockRatio != shift) { + double factor; + if (sx == 0) + factor = height / originalHeight; + else if (sy == 0) + factor = width / originalWidth; + else + factor = std::max(1 / std::min(originalWidth, originalHeight), + (localX * sx * originalWidth + localY * sy * originalHeight) + / (originalWidth * originalWidth + originalHeight * originalHeight)); + width = originalWidth * factor; + height = originalHeight * factor; + } + result.size = {width, height}; + if (mirroredX) + result.flipX = !result.flipX; + if (mirroredY) + result.flipY = !result.flipY; + const double offsetX = (0.5 - anchorUnit.x()) * width * (mirroredX ? -1 : 1); + const double offsetY = (0.5 - anchorUnit.y()) * height * (mirroredY ? -1 : 1); + const QPointF center(anchor.x() + offsetX * std::cos(radians) - offsetY * std::sin(radians), + anchor.y() + offsetX * std::sin(radians) + offsetY * std::cos(radians)); + result.origin = {center.x() - width / 2, center.y() - height / 2}; + break; + } + } + return result.isValid() ? result : original; +} + +TransformSnap::Offset TransformSnap::offset(const QRectF &box, const std::vector<double> &xs, + const std::vector<double> &ys, double tolerance) +{ + const Shift horizontal = shift({box.left(), box.center().x(), box.right()}, xs, tolerance); + const Shift vertical = shift({box.top(), box.center().y(), box.bottom()}, ys, tolerance); + return {QSizeF(horizontal.move, vertical.move), horizontal.target, vertical.target}; +} diff --git a/src/Document/LayerTransform.h b/src/Document/LayerTransform.h new file mode 100644 index 0000000..1f84f09 --- /dev/null +++ b/src/Document/LayerTransform.h @@ -0,0 +1,111 @@ +#pragma once +#include <QHash> +#include <QPointF> +#include <QRectF> +#include <QSizeF> +#include <QString> +#include <QTransform> +#include <QUuid> +#include <array> +#include <memory> +#include <optional> +#include <vector> + +enum class LayerSampling { nearest, smooth, high }; +// The name users see and the manifest stores. +QString rawValue(LayerSampling sampling); +std::optional<LayerSampling> layerSampling(const QString &rawValue); + +// CoreGraphics interpolation qualities the renderer asks for. +enum class InterpolationQuality { none, low, high }; +InterpolationQuality quality(LayerSampling sampling); + +using Corners = std::array<QPointF, 4>; + +// Unrotated document bounds; rotation is clockwise about the center. +struct LayerTransform { + QPointF origin; + QSizeF size; + double rotation = 0; + bool flipX = false; + bool flipY = false; + LayerSampling sampling = LayerSampling::high; + + static const std::array<QPointF, 8> handles; + + friend bool operator==(const LayerTransform &lhs, const LayerTransform &rhs); + QPointF center() const; + double radians() const; + bool isValid() const; + QPointF point(QPointF unit) const; + bool contains(QPointF point) const; + double scalePercent(QSizeF pixelSize) const; + LayerTransform scaled(double toPercent, QSizeF pixelSize) const; + LayerTransform rounded() const; + QTransform unitToDocument() const; + LayerTransform placing(const QTransform &map) const; + LayerTransform following(const LayerTransform &old, const LayerTransform &updated) const; + bool samePlacement(const LayerTransform &other) const; + // Mirrored across a line at `axis`; defined in LayerFlip.cpp. + LayerTransform mirrored(bool horizontally, double axis) const; +}; + +// Several layers under one box; each follows it. +struct TransformGroup { + LayerTransform box; + QHash<QUuid, LayerTransform> originals; +}; + +// A pending transform: the layers change on commit. +struct FloatingTransform; + +struct TransformEdit { + QUuid layerID; + LayerTransform draft; + std::optional<TransformGroup> group; + // An unlinked mask, selected: the edit places it alone. + bool mask = false; + // A drag's edit ends with it; Ctrl+T's waits for Apply. + bool persistent = true; + // Set once a handle is Ctrl-dragged: the corners move freely. + std::optional<Corners> corners; + // Ctrl+T on selected pixels: they float, then merge back. + std::shared_ptr<const FloatingTransform> floating; +}; + +// Where a move has just snapped: guides while it lasts. +struct SnapGuides { + std::vector<double> xs; + std::vector<double> ys; + friend bool operator==(const SnapGuides &, const SnapGuides &) = default; +}; + +struct TransformDrag { + enum class Kind { move, resize, rotate, distort }; + struct Mode { + Kind kind; + int index = 0; + }; + + LayerTransform original; + QPointF start; + Mode mode; + std::optional<Corners> originalCorners; + + std::optional<Corners> corners(QPointF to, bool shift = false) const; + LayerTransform updated(QPointF to, bool lockRatio, bool shift, bool option = false) const; +}; + +namespace TransformSnap { +// Screen points within which a guide snaps. +inline constexpr double distance = 10; + +struct Offset { + QSizeF offset; + std::optional<double> x; + std::optional<double> y; +}; + +Offset offset(const QRectF &box, const std::vector<double> &xs, const std::vector<double> &ys, + double tolerance); +} diff --git a/src/Document/Levels+Session.h b/src/Document/Levels+Session.h new file mode 100644 index 0000000..39c3de5 --- /dev/null +++ b/src/Document/Levels+Session.h @@ -0,0 +1,17 @@ +// Swift's Levels extension: one Levels edit at a time. +public: + void beginLevels(); + void updateLevels(const LevelsSettings &settings, bool preview); + // The sheet's eyedroppers: the point a click sets, or none. + void setLevelsSampleMode(std::optional<LevelsSample> mode); + void cancelLevels(); + // Off the UI thread; `done` follows the step or refusal. + void commitLevels(std::function<void()> done = {}); + +private: + // Off the UI thread, for the open edit. + void countLevelsHistogram(); + void renderLevelsPreview(); + void finishLevelsPreview(); + void finishLevelsHistogram(); + void finishLevelsCommit(); diff --git a/src/Document/Levels.cpp b/src/Document/Levels.cpp new file mode 100644 index 0000000..1da0396 --- /dev/null +++ b/src/Document/Levels.cpp @@ -0,0 +1,369 @@ +#include "Document/Levels.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainter> +#include <QtConcurrent> +#include <algorithm> +#include <cmath> +#include <stdexcept> +#include <vector> +extern "C" { +#include "LevelsPixels.h" +} + +QString rawValue(LevelsChannel channel) +{ + switch (channel) { + case LevelsChannel::rgb: return QStringLiteral("RGB"); + case LevelsChannel::red: return QStringLiteral("Red"); + case LevelsChannel::green: return QStringLiteral("Green"); + case LevelsChannel::blue: return QStringLiteral("Blue"); + } + throw std::logic_error("no such channel"); +} + +namespace { +double clamped(double value, double low, double high, double fallback) +{ + return std::isfinite(value) ? std::min(high, std::max(low, value)) : fallback; +} +} + +LevelRange LevelRange::normalized() const +{ + LevelRange result = *this; + result.black = clamped(black, 0, 254, 0); + result.white = clamped(white, result.black + 1, 255, 255); + result.gamma = clamped(gamma, 0.1, 9.99, 1); + result.outputBlack = clamped(outputBlack, 0, 255, 0); + result.outputWhite = clamped(outputWhite, 0, 255, 255); + return result; +} + +double LevelRange::apply(double value) const +{ + const LevelRange s = normalized(); + const double input = std::min(1.0, std::max(0.0, (value * 255 - s.black) / (s.white - s.black))); + return (s.outputBlack + std::pow(input, 1 / s.gamma) * (s.outputWhite - s.outputBlack)) / 255; +} + +LevelRange LevelsSettings::current() const +{ + return ranges[size_t(channel)]; +} + +void LevelsSettings::setCurrent(const LevelRange &range) +{ + ranges[size_t(channel)] = range.normalized(); +} + +bool LevelsSettings::isIdentity() const +{ + return std::all_of(ranges.begin(), ranges.end(), [](const LevelRange &range) { return range.normalized() == LevelRange(); }); +} + +double LevelsSettings::apply(double value, LevelsChannel channel) const +{ + return ranges[0].apply(ranges[size_t(channel)].apply(value)); +} + +double LevelsHistogramDisplay::scale(const std::array<double, 256> &bins) +{ + const auto counted = [](double bin) { return std::isfinite(bin) && bin > 0; }; + double peak = 0; + for (const double bin : bins) { + if (counted(bin)) + peak = std::max(peak, bin); + } + // The ends and lone spikes cap at four typical peaks. + std::vector<double> interior; + std::copy_if(bins.begin() + 1, bins.end() - 1, std::back_inserter(interior), counted); + if (interior.empty()) + return peak; + std::sort(interior.begin(), interior.end()); + const double typicalPeak = interior[size_t(double(interior.size() - 1) * 0.95)]; + return std::min(peak, typicalPeak * 4); +} + +namespace { +// The job's image in a premultiplied context, rows packed. +QImage drawn(const QImage &image) +{ + QImage context = BrushRaster::context(image.width(), image.height(), false); + QPainter painter(&context); + BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); + return context; +} +} + +QImage LevelsFilter::run(const LevelsJob &job) +{ + if (job.settings.isIdentity()) + return job.image; + QImage context = drawn(job.image); + // On the stack: a heap failure would escape the catch. + std::array<float, 3 * 256> tables; + for (size_t channel = 0; channel < 3; ++channel) { + for (size_t value = 0; value <= 255; ++value) + tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1])); + } + // The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice). + levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data()); + if (job.selection) + return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false); + return context; +} + +LevelsHistogram LevelsFilter::histogram(const LevelsJob &job) +{ + const int width = job.image.width(), height = job.image.height(); + const QImage context = drawn(job.image); + const std::optional<QImage> mask = job.selection ? std::optional(PixelAdjust::coverage(*job.selection, width, height, job.mapping)) : std::nullopt; + // The kernel accumulates: row by row, since Qt pads masks. + std::array<double, 1024> bins{}; + for (int y = 0; y < height; ++y) + levels_histogram(context.constScanLine(y), mask ? mask->constScanLine(y) : nullptr, size_t(width), bins.data()); + LevelsHistogram result{}; + for (size_t channel = 0; channel < 4; ++channel) + std::copy_n(bins.begin() + ptrdiff_t(channel * 256), 256, result[channel].begin()); + return result; +} + +LevelsEdit::LevelsEdit(const ImageLayer &layer, std::optional<SelectionClip> selection) + : layerID(layer.id), original(layer.asset.value()), transform(layer.transform), selection(std::move(selection)), + mapping(BrushRaster::pixelToDocument(transform, original.size().width(), original.size().height())) +{ + const QSize size = original.size(); + // A lookup a pixel is quick; smaller copies looked coarse. + const double factor = std::min(1.0, 8000.0 / std::max(size.width(), size.height())); + if (factor < 1) { + const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor)); + previewSource = BrushRaster::context(width, height, false); + QPainter painter(&previewSource); + const QRectF rect(0, 0, width, height); + if (original.raster) + original.raster->draw(rect, painter); + else + BrushRaster::draw(original.image(), rect, painter); + previewMapping = BrushRaster::pixelToDocument(transform, width, height); + } else { + previewSource = original.image(); + previewMapping = mapping; + } +} + +std::optional<QImage> LevelsEdit::previewImage(QUuid layer) const +{ + return layer == layerID ? preparedPreview : std::nullopt; +} + +LevelsJob LevelsEdit::previewJob() const +{ + return LevelsJob{previewSource, settings, selection, previewMapping}; +} + +// Swift's session extension in Levels.swift. + +void EditorSession::beginLevels() +{ + // `canAdjustColors` reads `levels`, not `hueSaturation`. + if (m_hueSaturation || !canAdjustColors()) + return; + // A pending gradient lands first, then Levels begins. + if (m_gradientEdit) { + commitGradient([this] { beginLevels(); }); + return; + } + commitTransform(); + cancelCrop(); + cancelLasso(); + const std::optional<DocumentSelection> current = selection(); + try { + m_levels.emplace(activeLayer().value(), current ? std::optional(current->clip(m_document->size())) : std::nullopt); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + return; + } + countLevelsHistogram(); + notify(); +} + +void EditorSession::countLevelsHistogram() +{ + const LevelsJob job = m_levels.value().previewJob(); + m_levelsHistogram.setFuture(QtConcurrent::run([job]() -> std::optional<LevelsHistogram> { + try { + return LevelsFilter::histogram(job); + } catch (const ExportError &error) { + qCWarning(lcApp) << "the Levels histogram cannot be made:" << error.what(); + return std::nullopt; + } + })); +} + +void EditorSession::finishLevelsHistogram() +{ + const std::optional<LevelsHistogram> result = m_levelsHistogram.result(); + // A commit cancels it, as Swift's; new edits replace it. + if (!m_levels || m_levels->committing) + return; + // A failed count leaves the bins empty, as Swift's try?. + if (result) + m_levels->histogram = *result; + m_levels->histogramReady = true; + notify(); +} + +void EditorSession::updateLevels(const LevelsSettings &settings, bool preview) +{ + if (!m_levels || m_levels->committing) + return; + LevelsEdit &edit = *m_levels; + edit.settings = settings; + edit.preview = preview; + // An adjustment layer takes the settings; its pixels stay. + if (previewAdjustmentEditing(preview)) { + notify(); + return; + } + if (!preview || settings.isIdentity()) { + // Swift cancels the running preview: its result drops. + m_levelsPreviewFor = QUuid(); + edit.pending = std::nullopt; + edit.preparedPreview = std::nullopt; + ++m_brushRevision; + notify(); + return; + } + edit.pending = edit.previewJob(); + renderLevelsPreview(); + notify(); +} + +// Renders the newest job; one that arrives mid-render waits. +void EditorSession::renderLevelsPreview() +{ + if (!m_levels || !m_levels->pending || m_levelsRendering) + return; + const LevelsJob job = *std::exchange(m_levels->pending, std::nullopt); + m_levelsPreviewFor = m_levels->id; + m_levelsRendering = true; + m_levelsPreview.setFuture(QtConcurrent::run([job]() -> std::optional<QImage> { + try { + return LevelsFilter::run(job); + } catch (const ExportError &error) { + qCWarning(lcApp) << "the Levels preview cannot be made:" << error.what(); + return std::nullopt; + } + })); +} + +void EditorSession::finishLevelsPreview() +{ + m_levelsRendering = false; + const std::optional<QImage> result = m_levelsPreview.result(); + // A cancelled run, or another edit's, lands nowhere. + if (m_levels && m_levels->id == std::exchange(m_levelsPreviewFor, QUuid())) { + m_levels->preparedPreview = result; + ++m_brushRevision; + notify(); + } + renderLevelsPreview(); +} + +void EditorSession::setLevelsSampleMode(std::optional<LevelsSample> mode) +{ + if (!m_levels) + return; + m_levels->sampleMode = mode; + ++m_brushRevision; + notify(); +} + +void EditorSession::cancelLevels() +{ + if (finishAdjustmentEditing(false) || !m_levels || m_levels->committing) + return; + m_levels.reset(); + ++m_brushRevision; + resumeFileRequests(); + notify(); +} + +void EditorSession::commitLevels(std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (finishAdjustmentEditing(true) || !m_levels || m_levels->committing) { + finish(); + return; + } + if (m_levels->settings.isIdentity()) { + cancelLevels(); + finish(); + return; + } + LevelsEdit &edit = *m_levels; + edit.committing = true; + // Swift cancels the preview: its result and queue drop. + m_levelsPreviewFor = QUuid(); + edit.pending = std::nullopt; + m_committingLevels = Committing{edit.id, std::move(done)}; + setIsProjectBusy(true); + // A painted layer flattens there, where a failure is caught. + m_levelsCommit.setFuture(QtConcurrent::run([original = edit.original, settings = edit.settings, selection = edit.selection, + mapping = edit.mapping]() -> Leveled { + try { + const QImage image = LevelsFilter::run(LevelsJob{original.image(), settings, selection, mapping}); + return Leveled{ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Levels")), std::nullopt}; + } catch (const ExportError &error) { + return Leveled{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishLevelsCommit() +{ + const Leveled made = m_levelsCommit.result(); + const LevelsEdit edit = std::exchange(m_levels, std::nullopt).value(); + const Committing pending = std::exchange(m_committingLevels, std::nullopt).value(); + // Swift's defer: the edit goes, the project frees, redraw. + const auto finish = [&] { + ++m_brushRevision; + setIsProjectBusy(false); + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); + }; + if (made.failure) { + setBrushError(made.failure); + finish(); + return; + } + const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1; + if (index < 0) { + finish(); + return; + } + const ImageLayer current = m_document->layers[index]; + if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) { + finish(); + return; + } + beginEdit(QStringLiteral("Levels")); + ImageLayer layer = current; + layer.asset = made.asset; + // Swift rebuilds the layer without shape, effects and text. + layer.shape = std::nullopt; + layer.effects = std::nullopt; + layer.text = std::nullopt; + m_document->layers[index] = layer; + endEdit(); + finish(); +} diff --git a/src/Document/Levels.h b/src/Document/Levels.h new file mode 100644 index 0000000..b8f2cc3 --- /dev/null +++ b/src/Document/Levels.h @@ -0,0 +1,98 @@ +#pragma once +#include "Document/LayerTransform.h" +#include "Document/Selection.h" +#include "IO/ImageImporter.h" +#include <QImage> +#include <QTransform> +#include <QUuid> +#include <array> +#include <optional> + +struct ImageLayer; +// Defined with the automatic levels, as Swift's. +enum class LevelsSample; + +// The composite first, then each colour. +enum class LevelsChannel { rgb, red, green, blue }; +inline constexpr std::array<LevelsChannel, 4> allLevelsChannels{LevelsChannel::rgb, LevelsChannel::red, LevelsChannel::green, LevelsChannel::blue}; +// Swift's rawValue: the channel picker's title. +QString rawValue(LevelsChannel channel); + +// One channel's input range, gamma and output range. +struct LevelRange { + double black = 0; + double gamma = 1; + double white = 255; + double outputBlack = 0; + double outputWhite = 255; + // Clamped into range; a value that is no number resets. + LevelRange normalized() const; + double apply(double value) const; + friend bool operator==(const LevelRange &, const LevelRange &) = default; +}; + +struct LevelsSettings { + LevelsChannel channel = LevelsChannel::rgb; + std::array<LevelRange, 4> ranges{}; + LevelRange current() const; + // Stored normalized, as Swift's setter. + void setCurrent(const LevelRange &range); + bool isIdentity() const; + // The channel's range first, then the composite's. + double apply(double value, LevelsChannel channel) const; + // Swift's extension in LevelsAutomatic: a sample calibrates each channel. + LevelsSettings sampling(const std::array<double, 3> &rgb, LevelsSample mode) const; + friend bool operator==(const LevelsSettings &, const LevelsSettings &) = default; +}; + +// The composite's bins, then red's, green's and blue's. +using LevelsHistogram = std::array<std::array<double, 256>, 4>; + +// Swift's LevelsHistogramDisplay: spikes cannot flatten the graph. +namespace LevelsHistogramDisplay { +double scale(const std::array<double, 256> &bins); +} + +struct LevelsJob { + QImage image; + LevelsSettings settings; + std::optional<SelectionClip> selection; + QTransform mapping; +}; + +// Swift's LevelsFilter. +namespace LevelsFilter { +// The settings over the image, through the selection. +QImage run(const LevelsJob &job); +// Alpha-weighted; RGB is the mean of the channels, not luminance. +LevelsHistogram histogram(const LevelsJob &job); +} + +// An open Levels edit: the layer's pixels, settings and preview. +class LevelsEdit { +public: + LevelsEdit(const ImageLayer &layer, std::optional<SelectionClip> selection); + + // The prepared preview, for this edit's own layer. + std::optional<QImage> previewImage(QUuid layer) const; + LevelsJob previewJob() const; + + // Swift's object identity: results land on their own edit. + const QUuid id = QUuid::createUuid(); + const QUuid layerID; + const ImportedImage original; + const LayerTransform transform; + const std::optional<SelectionClip> selection; + const QTransform mapping; + // Full size to 8000 pixels a side, else scaled down. + QImage previewSource; + QTransform previewMapping; + std::optional<LevelsSample> sampleMode; + LevelsSettings settings; + bool preview = true; + bool committing = false; + LevelsHistogram histogram{}; + bool histogramReady = false; + std::optional<QImage> preparedPreview; + std::optional<LevelsJob> pending; +}; diff --git a/src/Document/LevelsAutomatic+Session.h b/src/Document/LevelsAutomatic+Session.h new file mode 100644 index 0000000..38abc09 --- /dev/null +++ b/src/Document/LevelsAutomatic+Session.h @@ -0,0 +1,5 @@ +// Swift's LevelsAutomatic extension: automatic settings and sampling. +public: + void autoLevels(LevelsAuto mode); + // The chosen eyedropper sets its point from the layer's pixel. + void sampleLevels(QPointF point); diff --git a/src/Document/LevelsAutomatic.cpp b/src/Document/LevelsAutomatic.cpp new file mode 100644 index 0000000..6ca3953 --- /dev/null +++ b/src/Document/LevelsAutomatic.cpp @@ -0,0 +1,153 @@ +#include "Document/LevelsAutomatic.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <algorithm> +#include <cmath> +#include <numeric> +#include <stdexcept> + +QString rawValue(LevelsSample sample) +{ + switch (sample) { + case LevelsSample::black: return QStringLiteral("Black"); + case LevelsSample::gray: return QStringLiteral("Gray"); + case LevelsSample::white: return QStringLiteral("White"); + } + throw std::logic_error("no such sample"); +} + +QString rawValue(LevelsAuto mode) +{ + switch (mode) { + case LevelsAuto::contrast: return QStringLiteral("Contrast"); + case LevelsAuto::color: return QStringLiteral("Color"); + case LevelsAuto::neutral: return QStringLiteral("Color + neutral midtones"); + } + throw std::logic_error("no such mode"); +} + +namespace { +// The bins holding all but a thousandth at either end. +std::optional<std::pair<double, double>> endpoints(const std::array<double, 256> &bins) +{ + const double total = std::accumulate(bins.begin(), bins.end(), 0.0); + if (!(total > 0)) + return std::nullopt; + double sum = 0; + int low = 0, high = 255; + for (int index = 0; index < 256; ++index) { + sum += bins[size_t(index)]; + if (sum > total * 0.001) { + low = index; + break; + } + } + sum = 0; + for (int index = 255; index >= 0; --index) { + sum += bins[size_t(index)]; + if (sum > total * 0.001) { + high = index; + break; + } + } + return low < high ? std::optional(std::pair(double(low), double(high))) : std::nullopt; +} +} + +LevelsSettings settings(LevelsAuto mode, const LevelsHistogram &histogram) +{ + LevelsSettings result; + if (mode == LevelsAuto::contrast) { + // A shared interval preserves channel relationships. + std::optional<double> low, high; + for (size_t channel = 1; channel < 4; ++channel) { + if (const std::optional<std::pair<double, double>> limits = endpoints(histogram[channel])) { + low = std::min(low.value_or(limits->first), limits->first); + high = std::max(high.value_or(limits->second), limits->second); + } + } + // Each channel's low lies below its high: so do theirs. + if (low) + result.ranges[0] = LevelRange{*low, 1, high.value(), 0, 255}; + return result; + } + for (size_t channel = 1; channel < 4; ++channel) { + const std::optional<std::pair<double, double>> limits = endpoints(histogram[channel]); + if (!limits) + continue; + LevelRange range{limits->first, 1, limits->second, 0, 255}; + if (mode == LevelsAuto::neutral) { + const double total = std::accumulate(histogram[channel].begin(), histogram[channel].end(), 0.0); + double weighted = 0; + for (size_t index = 0; index < 256; ++index) + weighted += range.apply(double(index) / 255) * histogram[channel][index]; + // Weight at both ends keeps the mean inside, under 9.99. + range.gamma = std::max(0.1, std::log(weighted / total) / std::log(0.5)); + } + result.ranges[channel] = range; + } + return result; +} + +// Samples are unpremultiplied RGB; the channels calibrate together. +LevelsSettings LevelsSettings::sampling(const std::array<double, 3> &rgb, LevelsSample mode) const +{ + LevelsSettings result = *this; + result.ranges[0] = LevelRange(); + for (size_t channel = 1; channel < 4; ++channel) { + LevelRange range = result.ranges[channel]; + const double value = rgb[channel - 1] * 255; + if (mode == LevelsSample::black) { + range.black = std::min(range.white - 1, value); + } else if (mode == LevelsSample::white) { + range.white = std::max(range.black + 1, value); + } else { + const double fraction = (value - range.black) / (range.white - range.black); + if (!(fraction > 0 && fraction < 1)) + continue; + range.gamma = std::log(fraction) / std::log(0.5); + } + range.outputBlack = 0; + range.outputWhite = 255; + result.ranges[channel] = range.normalized(); + } + return result; +} + +// Swift's session extension in LevelsAutomatic.swift. + +void EditorSession::autoLevels(LevelsAuto mode) +{ + if (!m_levels || !m_levels->histogramReady || m_levels->committing) + return; + m_levels->sampleMode = std::nullopt; + updateLevels(::settings(mode, m_levels->histogram), m_levels->preview); +} + +void EditorSession::sampleLevels(QPointF point) +{ + // Positive bounds: a point that is no number samples nothing. + if (!m_levels || !m_levels->sampleMode || m_levels->committing || !m_document + || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->width && point.y() < m_document->height)) + return; + const LevelsEdit &edit = *m_levels; + const QPointF pixel = edit.mapping.inverted().map(point); + const QSize size = edit.original.size(); + if (!(pixel.x() >= 0 && pixel.y() >= 0 && pixel.x() < size.width() && pixel.y() < size.height())) + return; + try { + QImage context = BrushRaster::context(1, 1, false); + { + QPainter painter(&context); + BrushRaster::draw(edit.original.image(), QRectF(0, 0, 1, 1), painter, QRectF(std::floor(pixel.x()), std::floor(pixel.y()), 1, 1)); + } + const uchar *bytes = context.constBits(); + if (bytes[3] == 0) + return; + const auto channel = [bytes](int index) { return std::min(1.0, double(bytes[index]) / bytes[3]); }; + updateLevels(edit.settings.sampling({channel(0), channel(1), channel(2)}, *edit.sampleMode), edit.preview); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} diff --git a/src/Document/LevelsAutomatic.h b/src/Document/LevelsAutomatic.h new file mode 100644 index 0000000..226cbf2 --- /dev/null +++ b/src/Document/LevelsAutomatic.h @@ -0,0 +1,13 @@ +#pragma once +#include "Document/Levels.h" + +// What a Levels eyedropper sets: black, gray or white point. +enum class LevelsSample { black, gray, white }; +inline constexpr std::array<LevelsSample, 3> allLevelsSamples{LevelsSample::black, LevelsSample::gray, LevelsSample::white}; +QString rawValue(LevelsSample sample); + +// Swift's LevelsAuto: settings read from a histogram. +enum class LevelsAuto { contrast, color, neutral }; +inline constexpr std::array<LevelsAuto, 3> allLevelsAutos{LevelsAuto::contrast, LevelsAuto::color, LevelsAuto::neutral}; +QString rawValue(LevelsAuto mode); +LevelsSettings settings(LevelsAuto mode, const LevelsHistogram &histogram); diff --git a/src/Document/LiveLayerMask+Session.h b/src/Document/LiveLayerMask+Session.h new file mode 100644 index 0000000..907b5c3 --- /dev/null +++ b/src/Document/LiveLayerMask+Session.h @@ -0,0 +1,22 @@ +// Swift's LiveLayerMask extension: clipping and live masks. +public: + // The layers as the export draws them, in document pixels. + void drawLiveComposite(const CanvasDocument &document, QPainter &context, bool onSurface = false) const; + // A layer clipped to `source` shows through that layer's alpha. + bool canLinkMask(QUuid source, QUuid target) const; + bool linkMask(QUuid source, QUuid target); + void removeLiveMask(QUuid target); + // Alt-click clips a layer to its lower sibling, or frees. + bool canToggleClippingMask(QUuid id) const; + void toggleClippingMask(QUuid id); + static void adoptClipping(QUuid id, std::vector<ImageLayer> &layers); + static void releaseDetachedClipping(std::vector<ImageLayer> &layers); + // Asks: bake, unlink or cancel. False when nothing depends. + bool deleteWithLiveMaskChoice(const std::vector<QUuid> &ids); + using BakedImages = std::map<QUuid, ImportedImage>; + void finishDeletingLayer(QUuid id, const BakedImages &baked); + void finishDeletingLayers(const std::vector<QUuid> &ids, const BakedImages &baked); + +private: + std::optional<QUuid> clippingBase(const ImageLayer &layer) const; + void finishBake(); diff --git a/src/Document/LiveLayerMask.cpp b/src/Document/LiveLayerMask.cpp new file mode 100644 index 0000000..99463c1 --- /dev/null +++ b/src/Document/LiveLayerMask.cpp @@ -0,0 +1,384 @@ +#include "Document/LiveLayerMask.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/LayerEffects+Renderer.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/AdjustmentSurface.h" +#include "Rendering/LayerRenderer.h" +#include "Rendering/LiveMaskRenderer.h" +#include <QHash> +#include <QMessageBox> +#include <QPushButton> +#include <QSet> +#include <QtConcurrent> +#include <map> +#include <stdexcept> + +void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &context, bool onSurface) const +{ + // Adjustments read the pixels beneath: a surface of their own. + if (!onSurface && std::any_of(document.layers.begin(), document.layers.end(), [](const ImageLayer &layer) { return layer.adjustment.has_value(); })) { + AdjustmentSurface::draw(context, [&](QPainter &surface) { drawLiveComposite(document, surface, true); }); + return; + } + std::map<QUuid, ImageLayer> records; + for (const ImageLayer &layer : document.layers) { + if (!records.emplace(layer.id, layer).second) + throw std::logic_error("a document repeats a layer id"); + } + const auto parent = [&](QUuid id) { return records.contains(id) ? records.at(id).parentID : std::nullopt; }; + LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id).maskSourceID : std::nullopt; }, + [&](QUuid id, QPainter &target, const QImage &clip) { + if (!records.contains(id) || !records.at(id).asset) + return; + const ImageLayer &layer = records.at(id); + const QImage image = layer.asset->image(); + const LayerTransform transform = displayedTransform(layer); + const std::optional<QImage> mask = + layer.mask ? layer.mask->clipImage(displayedMaskPlacement(layer), transform, image.width(), image.height()) : std::nullopt; + // With effects the mask is in their image already. + if (const auto effects = LayerEffectsRenderer::cached(image, mask, layer.effects)) { + const LayerTransform grown = LayerEffectsRenderer::placed(transform, effects->image, effects->inset); + LayerRenderer::draw(effects->image, grown, grown.center(), target, + {.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .clip = clip}); + return; + } + LayerRenderer::draw(image, transform, transform.center(), target, + {.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip}); + }); + live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; }; + live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity; }; + live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) { + const ImageLayer &layer = records.at(id); + if (const std::optional<QImage> image = layer.mask ? layer.mask->enabledImage() : std::nullopt) + FolderMaskClip{*image, layer.transform}.apply(layer.transform.center(), painter, coverage); + }; + std::vector<QUuid> ids; + for (const ImageLayer &layer : document.renderLayers()) + ids.push_back(layer.id); + live.prepareStacks(ids, parent, [&](QUuid id) { return records.contains(id) ? displayedBlendMode(records.at(id)) : LayerBlendMode::normal; }); + FolderMaskClip::draw(ids, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + if (!records.contains(id) || !records.at(id).mask) + return std::nullopt; + const ImageLayer &folder = records.at(id); + const std::optional<QImage> image = folder.mask->enabledImage(); + if (!image) + return std::nullopt; + const FolderMaskClip clip{*image, displayedTransform(folder)}; + return [clip](const QPainter &painter, QImage &coverage) { clip.apply(clip.transform.center(), painter, coverage); }; + }, context, [&](QUuid id, const QImage &clip) { live.drawComposite(id, context, clip); }); +} + +void LiveMaskGraph::validate(const std::vector<ProjectLayerRecord> &layers) +{ + QHash<QUuid, const ProjectLayerRecord *> records; + for (const ProjectLayerRecord &layer : layers) { + if (records.contains(layer.id)) + throw ProjectError(ProjectError::Kind::invalid); + records.insert(layer.id, &layer); + } + for (const ProjectLayerRecord &layer : layers) { + QSet<QUuid> path; + std::optional<QUuid> current = layer.id; + while (current) { + const ProjectLayerRecord *record = records.value(*current); + if (path.size() >= 256 || path.contains(*current) || !record) + throw ProjectError(ProjectError::Kind::invalid); + path.insert(*current); + if (record->maskSourceID) { + const ProjectLayerRecord *source = records.value(*record->maskSourceID); + // Folders never clip; nothing clips to an adjustment. + if (record->isGroup == true || !source || source->isGroup == true || source->adjustment) + throw ProjectError(ProjectError::Kind::invalid); + } + current = record->maskSourceID; + } + } +} + +std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot, QUuid target) +{ + const std::vector<ProjectLayerRecord> &layers = snapshot.manifest.layers; + const size_t found = std::find_if(layers.begin(), layers.end(), [&](const ProjectLayerRecord &layer) { return layer.id == target; }) - layers.begin(); + if (found == layers.size() || !snapshot.images.contains(target)) + return std::nullopt; + // Checked reads: a lost guard aborts or throws. + const ImportedImage &original = snapshot.images.at(target); + const int width = original.size().width(), height = original.size().height(); + QImage surface = BrushRaster::context(width, height, false); + const QTransform inverse = BrushRaster::pixelToDocument(layers[found].transform, width, height).inverted(); + // Swift's dictionary traps on an id that repeats. + QHash<QUuid, const ProjectLayerRecord *> records; + for (const ProjectLayerRecord &layer : layers) { + if (records.contains(layer.id)) + throw std::logic_error("a snapshot to bake repeats a layer id"); + records.insert(layer.id, &layer); + } + LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; }, + [&](QUuid id, QPainter &context, const QImage &clip) { + // `at` is checked: end() would read wild memory. + if (!records.contains(id) || !snapshot.images.contains(id)) + return; + const QImage pixels = snapshot.images.at(id).image(); + // Only the live dependency bakes: mask and appearance stay. + if (id == target) { + LayerRenderer::draw(pixels, {.origin = {0, 0}, .size = QSizeF(width, height)}, QPointF(width / 2.0, height / 2.0), + context, {.clip = clip}); + return; + } + const ProjectLayerRecord &layer = *records.value(id); + const std::optional<LayerMask> mask = snapshot.mask(layer); + const std::optional<QImage> shown = + mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt; + context.save(); + context.setTransform(inverse, true); + LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), context, + {.opacity = layer.opacity.value_or(1), .mask = shown.value_or(QImage()), .clip = clip}); + context.restore(); + }); + { + QPainter painter(&surface); + live.draw(target, painter); + } + return ImportedImage(surface, PixelAdjust::thumbnail(surface), original.name); +} + +bool EditorSession::canLinkMask(QUuid source, QUuid target) const +{ + if (!canEditLayers()) + return false; + bool found = false; + std::vector<ProjectLayerRecord> records; + for (const ImageLayer &layer : m_document->layers) { + records.push_back(layer.hierarchyRecord()); + if (layer.id == target) { + records.back().maskSourceID = source; + found = true; + } + } + if (!found) + return false; + // The graph refuses itself, folders, strangers and rings. + try { + LiveMaskGraph::validate(records); + } catch (const ProjectError &) { + return false; + } + return true; +} + +bool EditorSession::linkMask(QUuid source, QUuid target) +{ + if (!canLinkMask(source, target)) + return false; + ImageLayer &layer = m_document->layers[indexOf(m_document->layers, target)]; + if (layer.maskSourceID == source) + return true; + beginEdit(QStringLiteral("Create Clipping Mask")); + layer.maskSourceID = source; + endEdit(); + return true; +} + +void EditorSession::adoptClipping(QUuid id, std::vector<ImageLayer> &layers) +{ + const int moved = indexOf(layers, id); + if (moved < 0 || layers[moved].isGroup) + return; + std::vector<const ImageLayer *> siblings; + for (const ImageLayer &layer : layers) { + if (layer.parentID == layers[moved].parentID) + siblings.push_back(&layer); + } + const size_t own = std::find_if(siblings.begin(), siblings.end(), [&](const ImageLayer *layer) { return layer->id == id; }) - siblings.begin(); + // Between a base or its stack and a clipped layer. + if (own == 0 || own + 1 == siblings.size()) + return; + const std::optional<QUuid> source = siblings[own + 1]->maskSourceID; + const ImageLayer &below = *siblings[own - 1]; + if (source && *source != id && (below.id == *source || below.maskSourceID == source)) + layers[moved].maskSourceID = source; +} + +void EditorSession::removeLiveMask(QUuid target) +{ + if (!canEditLayers()) + return; + const int clipped = indexOf(m_document->layers, target); + if (clipped < 0 || !m_document->layers[clipped].maskSourceID) + return; + // Those above it on the same base let go too. + const std::optional<QUuid> source = m_document->layers[clipped].maskSourceID, parent = m_document->layers[clipped].parentID; + beginEdit(QStringLiteral("Release Clipping Mask")); + bool reached = false; + for (ImageLayer &layer : m_document->layers) { + if (layer.parentID != parent) + continue; + reached = reached || layer.id == target; + if (reached && layer.id != target && layer.maskSourceID != source) + break; + if (reached) + layer.maskSourceID = std::nullopt; + } + endEdit(); +} + +bool EditorSession::canToggleClippingMask(QUuid id) const +{ + if (!canEditLayers()) + return false; + const std::vector<ImageLayer> &layers = m_document->layers; + const int layer = indexOf(layers, id); + if (layer < 0) + return false; + if (layers[layer].maskSourceID) + return true; + const std::optional<QUuid> base = clippingBase(layers[layer]); + return base && canLinkMask(*base, id); +} + +std::optional<QUuid> EditorSession::clippingBase(const ImageLayer &layer) const +{ + // The next lower sibling, or the base that one shares. + const ImageLayer *below = nullptr; + for (const ImageLayer &each : m_document->layers) { + if (each.id == layer.id) + break; + if (each.parentID == layer.parentID) + below = &each; + } + return below ? std::optional(below->maskSourceID.value_or(below->id)) : std::nullopt; +} + +void EditorSession::toggleClippingMask(QUuid id) +{ + if (!canEditLayers()) + return; + const std::vector<ImageLayer> &layers = m_document->layers; + const int layer = indexOf(layers, id); + if (layer < 0) + return; + if (layers[layer].maskSourceID) { + removeLiveMask(id); + return; + } + if (const std::optional<QUuid> base = clippingBase(layers[layer])) + linkMask(*base, id); +} + +void EditorSession::releaseDetachedClipping(std::vector<ImageLayer> &layers) +{ + // A clipped layer sits in one run above its base. + std::map<std::optional<QUuid>, std::optional<QUuid>> bases; + for (ImageLayer &layer : layers) { + std::optional<QUuid> &base = bases[layer.parentID]; + if (!layer.maskSourceID) { + base = layer.id; + } else if (layer.maskSourceID != base) { + layer.maskSourceID = std::nullopt; + base = layer.id; + } + } +} + +bool EditorSession::deleteWithLiveMaskChoice(const std::vector<QUuid> &ids) +{ + if (!m_document) + return false; + QSet<QUuid> removed; + for (const QUuid id : ids) { + removed.unite(descendantIDs(id)); + removed.insert(id); + } + std::vector<QUuid> targets; + for (const ImageLayer &layer : m_document->layers) { + if (!removed.contains(layer.id) && layer.maskSourceID && removed.contains(*layer.maskSourceID)) + targets.push_back(layer.id); + } + if (targets.empty()) + return false; + QMessageBox alert; + alert.setIcon(QMessageBox::Warning); + alert.setText(ids.size() == 1 ? QStringLiteral("This layer supplies a live mask") : QStringLiteral("These layers supply live masks")); + alert.setInformativeText(QStringLiteral("Bake keeps the current masked appearance in the dependent layers’ pixels. " + "Remove Links reveals their pixels. You can undo either choice.")); + const QPushButton *bake = alert.addButton(QStringLiteral("Bake and Delete"), QMessageBox::AcceptRole); + alert.addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); + const QPushButton *unlink = alert.addButton(QStringLiteral("Remove Links and Delete"), QMessageBox::DestructiveRole); + alert.exec(); + if (alert.clickedButton() == unlink) { + finishDeletingLayers(ids, {}); + return true; + } + const std::optional<ProjectSnapshot> snapshot = projectSnapshot(); + if (alert.clickedButton() != bake || !snapshot) + return true; + qCInfo(lcApp) << "baking" << int(targets.size()) << "live masks before a deletion"; + setIsProjectBusy(true); + m_bakingIDs = ids; + m_baker.setFuture(QtConcurrent::run([snapshot = *snapshot, targets]() -> Baked { + Baked result; + try { + for (const QUuid target : targets) { + if (const std::optional<ImportedImage> image = LiveMaskBaker::bake(snapshot, target)) + result.images.insert({target, *image}); + } + } catch (const ExportError &error) { + result.failure = QString::fromUtf8(error.what()); + } + return result; + })); + return true; +} + +void EditorSession::finishBake() +{ + const Baked baked = m_baker.result(); + if (baked.failure) { + qCWarning(lcApp).noquote() << "cannot bake the live masks:" << *baked.failure; + setBrushError(baked.failure); + } else { + finishDeletingLayers(m_bakingIDs, baked.images); + } + setIsProjectBusy(false); +} + +void EditorSession::finishDeletingLayer(QUuid id, const BakedImages &baked) +{ + if (!m_document) + return; + std::vector<ImageLayer> &layers = m_document->layers; + const int index = indexOf(layers, id); + if (index < 0) + return; + QSet<QUuid> removed = descendantIDs(id); + removed.insert(id); + beginEdit(QStringLiteral("Delete Layer")); + std::erase_if(layers, [&](const ImageLayer &layer) { return removed.contains(layer.id); }); + // Who was clipped to them is freed, baked where asked. + for (ImageLayer &layer : layers) { + if (!layer.maskSourceID || !removed.contains(*layer.maskSourceID)) + continue; + layer.maskSourceID = std::nullopt; + if (const auto image = baked.find(layer.id); image != baked.end()) + layer.asset = image->second; + } + if (m_activeLayerID && removed.contains(*m_activeLayerID)) + setActiveLayerID(layers.empty() ? std::nullopt : std::optional(layers[std::min(index, int(layers.size()) - 1)].id)); + endEdit(); +} + +void EditorSession::finishDeletingLayers(const std::vector<QUuid> &ids, const BakedImages &baked) +{ + if (ids.size() <= 1) { + if (!ids.empty()) + finishDeletingLayer(ids.front(), baked); + return; + } + beginEdit(QStringLiteral("Delete Layers")); + for (const QUuid id : ids) + finishDeletingLayer(id, baked); + endEdit(); +} diff --git a/src/Document/LiveLayerMask.h b/src/Document/LiveLayerMask.h new file mode 100644 index 0000000..b8a462a --- /dev/null +++ b/src/Document/LiveLayerMask.h @@ -0,0 +1,12 @@ +#pragma once +#include "IO/ProjectStore.h" +#include <vector> + +namespace LiveMaskGraph { +void validate(const std::vector<ProjectLayerRecord> &layers); +} + +namespace LiveMaskBaker { +// The target's pixels through its live mask; nil without pixels. +std::optional<ImportedImage> bake(const ProjectSnapshot &snapshot, QUuid target); +} diff --git a/src/Document/MagicWand+Session.h b/src/Document/MagicWand+Session.h new file mode 100644 index 0000000..613f33a --- /dev/null +++ b/src/Document/MagicWand+Session.h @@ -0,0 +1,8 @@ +// Swift's MagicWand extension. +public: + // Off the UI thread; `done` follows the step or refusal. + void magicWand(QPointF point, SelectionMode mode, std::function<void()> done = {}); + +private: + void finishWand(); + std::optional<QImage> wandSample(const CanvasDocument &document) const; diff --git a/src/Document/MagicWand.cpp b/src/Document/MagicWand.cpp new file mode 100644 index 0000000..70925c8 --- /dev/null +++ b/src/Document/MagicWand.cpp @@ -0,0 +1,174 @@ +#include "Document/MagicWand.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <QtConcurrent> +#include <algorithm> +#include <cmath> +#include <cstdlib> +extern "C" { +#include "WandPixels.h" +} + +QString title(WandSampleSize size) +{ + switch (size) { + case WandSampleSize::point: + return QStringLiteral("Point Sample"); + case WandSampleSize::threeByThree: + return QStringLiteral("3 by 3 Average"); + case WandSampleSize::fiveByFive: + return QStringLiteral("5 by 5 Average"); + } + throw std::logic_error("unknown sample size"); +} + +int radius(WandSampleSize size) +{ + return int(size); +} + +MagicWandError::MagicWandError(Kind kind) + : std::runtime_error(kind == Kind::tooDetailed + ? "That selection is too detailed to outline. Try a different Tolerance, or turn on Contiguous." + : "There isn’t enough memory to make that selection."), + kind(kind) +{ +} + +std::optional<QPainterPath> MagicWand::select(const QImage &image, QPointF point, const WandSettings &settings) +{ + const int width = image.width(), height = image.height(); + if (!std::isfinite(point.x()) || !std::isfinite(point.y())) + return std::nullopt; + const double fx = std::floor(point.x()), fy = std::floor(point.y()); + if (fx < 0 || fy < 0 || fx >= width || fy >= height) + return std::nullopt; + // Premultiplied RGBA, rows top down, as the kernel reads. + QImage rgba = BrushRaster::context(width, height, false); + QPainter painter(&rgba); + BrushRaster::draw(image, QRectF(0, 0, width, height), painter); + painter.end(); + std::vector<uchar> selected(size_t(width) * size_t(height)); + const long count = wand_mask(rgba.constBits(), size_t(width), size_t(height), size_t(rgba.bytesPerLine()), size_t(fx), size_t(fy), + size_t(radius(settings.sampleSize)), std::clamp(settings.tolerance, 0, 255), settings.contiguous ? 1 : 0, selected.data()); + if (count < 0) + throw MagicWandError(MagicWandError::Kind::memory); + if (count == 0) + return std::nullopt; + return outline(selected, width, height); +} + +std::optional<QPainterPath> MagicWand::outline(const std::vector<uchar> &mask, int width, int height) +{ + if (width <= 0 || height <= 0 || mask.size() != size_t(width) * size_t(height)) + return std::nullopt; + int32_t *points = nullptr, *loops = nullptr; + size_t pointCount = 0, loopCount = 0; + const int status = wand_trace(mask.data(), size_t(width), size_t(height), &points, &pointCount, &loops, &loopCount); + const std::unique_ptr<int32_t, decltype(&std::free)> ownedPoints(points, &std::free), ownedLoops(loops, &std::free); + if (status == -2) + throw MagicWandError(MagicWandError::Kind::tooDetailed); + if (status != 0) + throw MagicWandError(MagicWandError::Kind::memory); + if (loopCount == 0) + return std::nullopt; + // Outer loops clockwise, holes the other way: winding fills them. + QPainterPath path; + path.setFillRule(Qt::WindingFill); + size_t index = 0; + for (size_t loop = 0; loop < loopCount; ++loop) { + const size_t length = size_t(loops[loop]); + path.moveTo(points[index * 2], points[index * 2 + 1]); + for (size_t corner = index + 1; corner < index + length; ++corner) + path.lineTo(points[corner * 2], points[corner * 2 + 1]); + path.closeSubpath(); + index += length; + } + return path; +} + +void EditorSession::setWandSettings(const WandSettings &settings) +{ + m_wandSettings = settings; + notify(); +} + +// What the wand reads: composite, or the active layer's pixels. +std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document) const +{ + try { + QImage context = BrushRaster::context(document.width, document.height, false); + QPainter painter(&context); + // A painted asset flattens here; the catch covers it too. + if (m_wandSettings.sampleAllLayers) { + drawLiveComposite(document, painter); + } else if (const std::optional<ImageLayer> layer = activeLayer(); layer && !layer->isGroup && layer->asset) { + const LayerTransform transform = displayedTransform(*layer); + LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {}); + } + painter.end(); + return context; + } catch (const ExportError &error) { + qCWarning(lcApp) << "the wand cannot sample the canvas:" << error.what(); + return std::nullopt; + } +} + +void EditorSession::magicWand(QPointF point, SelectionMode mode, std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + const std::optional<CanvasDocument> &document = m_document; + // Swift's positive bounds refuse a point that is no number. + const bool inside = document && point.x() >= 0 && point.y() >= 0 && point.x() < document->width && point.y() < document->height; + if (!canEditSelection() || m_isProjectBusy || m_selectionMoveOrigin || !inside) { + finish(); + return; + } + const std::optional<QImage> sample = wandSample(*document); + if (!sample) { + finish(); + return; + } + setIsProjectBusy(true); + m_wanding = Wanding{mode, document->id, std::move(done)}; + m_wand.setFuture(QtConcurrent::run([sample = *sample, point, settings = m_wandSettings]() -> Wanded { + try { + return Wanded{MagicWand::select(sample, point, settings), std::nullopt}; + } catch (const MagicWandError &error) { + return Wanded{std::nullopt, QString::fromUtf8(error.what())}; + } catch (const ExportError &error) { + return Wanded{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishWand() +{ + const Wanded result = m_wand.result(); + const Wanding pending = std::exchange(m_wanding, std::nullopt).value(); + setIsProjectBusy(false); + if (result.failure) { + qCWarning(lcApp).noquote() << "the wand could not select:" << *result.failure; + setBrushError(result.failure); + } else if (m_document && m_document->id == pending.documentID) { + if (!result.path) { + // Nothing matched: New clears, as a lasso click enclosing nothing. + if (pending.mode == SelectionMode::replace) + deselect(); + } else if (pending.mode == SelectionMode::replace) { + // A traced outline lies on the canvas: no costly clip. + setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, QStringLiteral("Magic Wand")); + } else { + applySelection(*result.path, pending.mode, QStringLiteral("Magic Wand")); + } + } + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); +} diff --git a/src/Document/MagicWand.h b/src/Document/MagicWand.h new file mode 100644 index 0000000..b497515 --- /dev/null +++ b/src/Document/MagicWand.h @@ -0,0 +1,40 @@ +#pragma once +#include <QImage> +#include <QPainterPath> +#include <QPointF> +#include <QString> +#include <optional> +#include <stdexcept> +#include <vector> + +// Pixels either side of the click, averaged into the reference. +enum class WandSampleSize { point, threeByThree, fiveByFive }; +QString title(WandSampleSize size); +int radius(WandSampleSize size); + +// The Magic Wand's options-bar settings. +struct WandSettings { + // How far each channel may differ, 0–255, and still match. + int tolerance = 32; + WandSampleSize sampleSize = WandSampleSize::point; + // Only similar pixels connected to the clicked one. + bool contiguous = true; + // The visible composite rather than the active layer. + bool sampleAllLayers = false; + friend bool operator==(const WandSettings &, const WandSettings &) = default; +}; + +class MagicWandError : public std::runtime_error { +public: + enum class Kind { tooDetailed, memory }; + explicit MagicWandError(Kind kind); + const Kind kind; +}; + +// Selects pixels like a clicked one; the C kernel matches. +namespace MagicWand { +// The outline of pixels matching the click; nil when none. +std::optional<QPainterPath> select(const QImage &image, QPointF point, const WandSettings &settings); +// The outline of a mask's nonzero pixels along their edges. +std::optional<QPainterPath> outline(const std::vector<uchar> &mask, int width, int height); +} diff --git a/src/Document/MaskTracing+Session.h b/src/Document/MaskTracing+Session.h new file mode 100644 index 0000000..ab09bdb --- /dev/null +++ b/src/Document/MaskTracing+Session.h @@ -0,0 +1,4 @@ +// Swift's MaskTracing extension: thumbnails loaded as selections. +public: + void loadMaskSelection(QUuid layerID, SelectionMode mode = SelectionMode::replace); + void loadLayerSelection(QUuid layerID, SelectionMode mode = SelectionMode::replace); diff --git a/src/Document/MaskTracing.cpp b/src/Document/MaskTracing.cpp new file mode 100644 index 0000000..26a114e --- /dev/null +++ b/src/Document/MaskTracing.cpp @@ -0,0 +1,146 @@ +#include "Document/MaskTracing.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QPainter> +#include <functional> +#include <map> +#include <vector> + +namespace { +// Outer loops run clockwise, holes counterclockwise, for winding fill. +std::optional<QPainterPath> trace(const QImage &image, bool alpha, const std::function<bool(uchar)> &test) +{ + const int width = image.width(), height = image.height(); + if (width <= 0 || height <= 0) + return std::nullopt; + const int channels = alpha ? 4 : 1; + // Drawn over a cleared bitmap, as the Swift tracer does. + QImage pixels(width, height, alpha ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_Grayscale8); + if (pixels.isNull()) + return std::nullopt; + pixels.fill(0); + QPainter(&pixels).drawImage(QRectF(0, 0, width, height), image); + const auto selected = [&](int x, int y) { + return x >= 0 && y >= 0 && x < width && y < height + && test(pixels.constScanLine(y)[x * channels + channels - 1]); + }; + // Directed unit edges, keyed by their start vertex. + const int stride = width + 1; + std::map<int, std::vector<int>> outgoing; + const auto edge = [&](int x0, int y0, int x1, int y1) { outgoing[y0 * stride + x0].push_back(y1 * stride + x1); }; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + if (!selected(x, y)) + continue; + if (!selected(x, y - 1)) + edge(x, y, x + 1, y); + if (!selected(x + 1, y)) + edge(x + 1, y, x + 1, y + 1); + if (!selected(x, y + 1)) + edge(x + 1, y + 1, x, y + 1); + if (!selected(x - 1, y)) + edge(x, y + 1, x, y); + } + } + if (outgoing.empty()) + return std::nullopt; + QPainterPath path; + path.setFillRule(Qt::WindingFill); + while (!outgoing.empty()) { + const int start = outgoing.begin()->first; + std::vector<int> loop; + int current = start; + do { + const auto found = outgoing.find(current); + if (found == outgoing.end()) + break; + const int end = found->second.back(); + found->second.pop_back(); + if (found->second.empty()) + outgoing.erase(found); + loop.push_back(current); + current = end; + } while (current != start); + // Keep corners; drop vertices that continue straight. + std::vector<QPointF> corners; + const int count = int(loop.size()); + for (int index = 0; index < count; ++index) { + const int vertex = loop[index], previous = loop[(index + count - 1) % count], following = loop[(index + 1) % count]; + const int inX = vertex % stride - previous % stride, inY = vertex / stride - previous / stride; + const int outX = following % stride - vertex % stride, outY = following / stride - vertex / stride; + if (inX != outX || inY != outY) + corners.push_back(QPointF(vertex % stride, vertex / stride)); + } + if (corners.size() < 3) + continue; + path.moveTo(corners.front()); + for (std::size_t index = 1; index < corners.size(); ++index) + path.lineTo(corners[index]); + path.closeSubpath(); + } + return path.isEmpty() ? std::nullopt : std::optional(path); +} +} + +std::optional<QPainterPath> MaskTracing::darkPixels(const QImage &image) +{ + return trace(image, false, [](uchar value) { return value < 128; }); +} + +std::optional<QPainterPath> MaskTracing::opaquePixels(const QImage &image) +{ + return trace(image, true, [](uchar value) { return value >= 128; }); +} + +void EditorSession::loadMaskSelection(QUuid layerID, SelectionMode mode) +{ + if (!canEditSelection()) + return; + const int index = indexOf(m_document->layers, layerID); + if (index < 0 || !m_document->layers[size_t(index)].mask) + return; + const ImageLayer &layer = m_document->layers[size_t(index)]; + QImage mask; + // A painted mask flattens here; a failure loads nothing. + try { + mask = layer.mask->asset.image(); + } catch (const ExportError &error) { + qCWarning(lcApp).noquote() << "cannot read the mask of" << layer.name << ":" << error.what(); + return; + } + const std::optional<QPainterPath> traced = MaskTracing::darkPixels(mask); + // Swift beeps: nothing black to select. + if (!traced) { + qCWarning(lcApp).noquote() << "no black to select in the mask of" << layer.name; + return; + } + applySelection(BrushRaster::pixelToDocument(layer.maskTransform(), mask.width(), mask.height()).map(*traced), mode, + QStringLiteral("Load Mask Selection")); +} + +void EditorSession::loadLayerSelection(QUuid layerID, SelectionMode mode) +{ + const int index = canEditSelection() ? indexOf(m_document->layers, layerID) : -1; + // Swift beeps: no pixels, or none opaque. + if (index < 0 || m_document->layers[size_t(index)].isGroup || !m_document->layers[size_t(index)].asset) { + qCWarning(lcApp) << "no pixels to select for layer" << layerID.toString(); + return; + } + const ImageLayer &layer = m_document->layers[size_t(index)]; + QImage image; + try { + image = layer.asset->image(); + } catch (const ExportError &error) { + qCWarning(lcApp).noquote() << "cannot read the pixels of" << layer.name << ":" << error.what(); + return; + } + const std::optional<QPainterPath> traced = MaskTracing::opaquePixels(image); + if (!traced) { + qCWarning(lcApp).noquote() << "no opaque pixels to select in" << layer.name; + return; + } + applySelection(BrushRaster::pixelToDocument(layer.transform, image.width(), image.height()).map(*traced), mode, + QStringLiteral("Load Layer Selection")); +} diff --git a/src/Document/MaskTracing.h b/src/Document/MaskTracing.h new file mode 100644 index 0000000..198def4 --- /dev/null +++ b/src/Document/MaskTracing.h @@ -0,0 +1,10 @@ +#pragma once +#include <QImage> +#include <QPainterPath> +#include <optional> + +// Raster coverage traced into an outline along exact pixel edges. +namespace MaskTracing { +std::optional<QPainterPath> darkPixels(const QImage &image); +std::optional<QPainterPath> opaquePixels(const QImage &image); +} diff --git a/src/Document/PixelAdjust.cpp b/src/Document/PixelAdjust.cpp new file mode 100644 index 0000000..77bf81f --- /dev/null +++ b/src/Document/PixelAdjust.cpp @@ -0,0 +1,236 @@ +#include "Document/PixelAdjust.h" +#include "Rendering/PoolMap.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include <QtConcurrent> +#include <algorithm> +#include <cmath> +#include <memory> +#include <new> +#include <numeric> +#include <stdexcept> + +namespace { +using Float4 = float __attribute__((vector_size(16))); + +// A pixel in floats: gray has one lane, colour four. +template <typename Pixel> Pixel load(const uchar *bytes); +template <> float load<float>(const uchar *bytes) +{ + return float(bytes[0]); +} +template <> Float4 load<Float4>(const uchar *bytes) +{ + return Float4{float(bytes[0]), float(bytes[1]), float(bytes[2]), float(bytes[3])}; +} + +// Rounded once, as a render to eight bits does. +void store(float value, uchar *bytes) +{ + bytes[0] = uchar(std::min(255.0f, std::max(0.0f, value) + 0.5f)); +} +void store(Float4 value, uchar *bytes) +{ + for (int c = 0; c < 4; ++c) + store(value[c], bytes + c); +} + +// Normalised weights from the centre out, three sigmas long. +std::vector<float> gaussian(double sigma) +{ + const int reach = int(std::ceil(3 * sigma)); + std::vector<double> exact(size_t(reach) + 1); + double total = 0; + for (int i = 0; i <= reach; ++i) { + exact[size_t(i)] = std::exp(-double(i) * i / (2 * sigma * sigma)); + total += i == 0 ? exact[0] : 2 * exact[size_t(i)]; + } + std::vector<float> weights; + for (const double weight : exact) + weights.push_back(float(weight / total)); + return weights; +} + +// Rows for a parallel map; no memory: a render error. +std::vector<int> rowNumbers(int height) +{ + try { + std::vector<int> rows(static_cast<size_t>(height)); + std::iota(rows.begin(), rows.end(), 0); + return rows; + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } +} + +// Columns a block at a time keep the taps cached. +constexpr int block = 64; + +template <typename Pixel> void blur(const QImage &image, const std::vector<float> &weights, bool clamped, Pixel *across, QImage &result) +{ + const int width = image.width(), height = image.height(), reach = int(weights.size()) - 1, size = int(sizeof(Pixel) / sizeof(float)); + const uchar *const in = image.constBits(); + uchar *const out = result.bits(); + const qsizetype inStride = image.bytesPerLine(), outStride = result.bytesPerLine(); + // Past the edge: the edge itself, or nothing. + const auto source = [&](int at, int limit) { return clamped ? std::clamp(at, 0, limit - 1) : at; }; + std::vector<int> rows = rowNumbers(height); + PoolMap::blocking(rows, [&](int y) { + const uchar *row = in + y * inStride; + Pixel *line = across + size_t(y) * size_t(width); + for (int x = 0; x < width; ++x) { + Pixel sum = weights[0] * load<Pixel>(row + x * size); + // Away from the edges every tap is inside: no checks. + if (x >= reach && x + reach < width) { + for (int i = 1; i <= reach; ++i) + sum += weights[size_t(i)] * (load<Pixel>(row + (x - i) * size) + load<Pixel>(row + (x + i) * size)); + } else { + for (int i = 1; i <= reach; ++i) { + const int left = source(x - i, width), right = source(x + i, width); + if (left >= 0) + sum += weights[size_t(i)] * load<Pixel>(row + left * size); + if (right < width) + sum += weights[size_t(i)] * load<Pixel>(row + right * size); + } + } + line[x] = sum; + } + }); + PoolMap::blocking(rows, [&](int y) { + uchar *line = out + y * outStride; + for (int start = 0; start < width; start += block) { + const int count = std::min(block, width - start); + Pixel sums[block]; + for (int x = 0; x < count; ++x) + sums[x] = weights[0] * across[size_t(y) * size_t(width) + size_t(start + x)]; + for (int i = -reach; i <= reach; ++i) { + const int from = source(y + i, height); + if (i == 0 || from < 0 || from >= height) + continue; + const Pixel *taps = across + size_t(from) * size_t(width) + size_t(start); + const float weight = weights[size_t(std::abs(i))]; + for (int x = 0; x < count; ++x) + sums[x] += weight * taps[x]; + } + for (int x = 0; x < count; ++x) + store(sums[x], line + (start + x) * size); + } + }); +} +} + +QImage PixelAdjust::thumbnail(const QImage &image) +{ + const double factor = std::min(1.0, 96.0 / std::max(image.width(), image.height())); + const int width = std::max(1, int(image.width() * factor)), height = std::max(1, int(image.height() * factor)); + QImage result = BrushRaster::context(width, height, false); + QPainter painter(&result); + BrushRaster::draw(image, QRectF(0, 0, width, height), painter); + return result; +} + +QImage PixelAdjust::coverage(const SelectionClip &selection, int width, int height, const QTransform &pixelToDocument) +{ + QImage mask = BrushRaster::context(width, height, true); + // An empty selection covers nothing. + if (!selection.coverage) + return mask; + QPainter painter(&mask); + painter.setTransform(pixelToDocument.inverted()); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(selection.rect, *selection.coverage); + return mask; +} + +QImage PixelAdjust::blend(const QImage &adjusted, const QImage &original, const SelectionClip &selection, + const QTransform &pixelToDocument, bool isMask) +{ + const int width = adjusted.width(), height = adjusted.height(); + if (original.size() != adjusted.size() || adjusted.format() != original.format()) + throw std::logic_error("blend needs two images of one size and format"); + const QImage mask = coverage(selection, width, height, pixelToDocument); + QImage result = BrushRaster::context(width, height, isMask); + const int channels = isMask ? 1 : 4; + for (int y = 0; y < height; ++y) { + const uchar *top = adjusted.constScanLine(y), *bottom = original.constScanLine(y), *weight = mask.constScanLine(y); + uchar *target = result.scanLine(y); + for (int x = 0; x < width; ++x) { + const int m = weight[x]; + for (int channel = 0; channel < channels; ++channel) { + const int index = x * channels + channel; + target[index] = uchar((top[index] * m + bottom[index] * (255 - m) + 127) / 255); + } + } + } + return result; +} + +QImage PixelAdjust::gaussianBlur(const QImage &image, double sigma, bool clamped) +{ + const bool gray = image.format() == QImage::Format_Grayscale8; + if (!gray && image.format() != QImage::Format_RGBA8888_Premultiplied) + throw std::logic_error("a blur takes premultiplied RGBA or gray"); + if (!(sigma > 0) || !std::isfinite(sigma)) + throw std::logic_error("a blur needs a positive sigma"); + const std::vector<float> weights = gaussian(sigma); + const size_t pixels = size_t(image.width()) * size_t(image.height()); + QImage result = BrushRaster::context(image.width(), image.height(), gray); + // Allocated here, where a failure throws, before the workers run. + if (gray) { + const std::unique_ptr<float[]> across(new (std::nothrow) float[pixels]); + if (!across) + throw ExportError(ExportError::Kind::render); + blur<float>(image, weights, clamped, across.get(), result); + } else { + const std::unique_ptr<Float4[]> across(new (std::nothrow) Float4[pixels]); + if (!across) + throw ExportError(ExportError::Kind::render); + blur<Float4>(image, weights, clamped, across.get(), result); + } + return result; +} + +QImage PixelAdjust::motionBlur(const QImage &image, double radius, double angle) +{ + if (image.format() != QImage::Format_RGBA8888_Premultiplied) + throw std::logic_error("a motion blur takes premultiplied RGBA"); + if (!(radius > 0) || !std::isfinite(radius) || !std::isfinite(angle)) + throw std::logic_error("a motion blur needs a finite positive radius and angle"); + // Measured on a dot: a Gaussian taper of its radius. + const std::vector<float> weights = gaussian(radius); + const int width = image.width(), height = image.height(), reach = int(weights.size()) - 1; + // Core Image's y points up: counterclockwise climbs the screen. + const double dx = std::cos(angle), dy = -std::sin(angle); + QImage result = BrushRaster::context(width, height, false); + const uchar *const in = image.constBits(); + uchar *const out = result.bits(); + const qsizetype inStride = image.bytesPerLine(), outStride = result.bytesPerLine(); + // Bilinear, clear past the edges, as an unclamped image reads. + const auto sample = [&](double x, double y) { + const double left = std::floor(x), top = std::floor(y); + const float across = float(x - left), down = float(y - top); + Float4 sum{0, 0, 0, 0}; + for (int j = 0; j < 2; ++j) { + const int row = int(top) + j; + if (row < 0 || row >= height) + continue; + for (int i = 0; i < 2; ++i) { + const int column = int(left) + i; + if (column >= 0 && column < width) + sum += (j ? down : 1 - down) * (i ? across : 1 - across) * load<Float4>(in + row * inStride + column * 4); + } + } + return sum; + }; + std::vector<int> rows = rowNumbers(height); + PoolMap::blocking(rows, [&](int y) { + uchar *line = out + y * outStride; + for (int x = 0; x < width; ++x) { + Float4 sum = weights[0] * load<Float4>(in + y * inStride + x * 4); + for (int i = 1; i <= reach; ++i) + sum += weights[size_t(i)] * (sample(x + i * dx, y + i * dy) + sample(x - i * dx, y - i * dy)); + store(sum, line + x * 4); + } + }); + return result; +} diff --git a/src/Document/PixelAdjust.h b/src/Document/PixelAdjust.h new file mode 100644 index 0000000..406e495 --- /dev/null +++ b/src/Document/PixelAdjust.h @@ -0,0 +1,19 @@ +#pragma once +#include "Document/Selection.h" +#include <QImage> +#include <QTransform> + +// Shared plumbing for whole-image adjustments. +namespace PixelAdjust { +// A small preview for the Layers panel, like an import's. +QImage thumbnail(const QImage &image); +// Selection coverage on the image's own pixel grid. +QImage coverage(const SelectionClip &selection, int width, int height, const QTransform &pixelToDocument); +// coverage × adjusted + (1 − coverage) × original. +QImage blend(const QImage &adjusted, const QImage &original, const SelectionClip &selection, + const QTransform &pixelToDocument, bool isMask); +// Core Image's Gaussian on stored values; `clamped` repeats the edge. +QImage gaussianBlur(const QImage &image, double sigma, bool clamped); +// Core Image's motion blur: a Gaussian taper along the angle. +QImage motionBlur(const QImage &image, double radius, double angle); +} diff --git a/src/Document/PixelInvert.cpp b/src/Document/PixelInvert.cpp new file mode 100644 index 0000000..6b2abb2 --- /dev/null +++ b/src/Document/PixelInvert.cpp @@ -0,0 +1,29 @@ +#include "Document/PixelInvert.h" +#include "Document/BrushStroke.h" +#include "Document/PixelAdjust.h" + +QImage PixelInvert::run(const Job &job) +{ + const QImage original = job.image.convertToFormat(job.isMask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied); + QImage inverted = BrushRaster::context(original.width(), original.height(), job.isMask); + for (int y = 0; y < original.height(); ++y) { + const uchar *source = original.constScanLine(y); + uchar *target = inverted.scanLine(y); + if (job.isMask) { + for (int x = 0; x < original.width(); ++x) + target[x] = uchar(255 - source[x]); + continue; + } + // Premultiplied: alpha − colour keeps transparency. + for (int x = 0; x < original.width() * 4; x += 4) { + const uchar alpha = source[x + 3]; + target[x] = uchar(alpha - source[x]); + target[x + 1] = uchar(alpha - source[x + 1]); + target[x + 2] = uchar(alpha - source[x + 2]); + target[x + 3] = alpha; + } + } + if (!job.selection) + return inverted; + return PixelAdjust::blend(inverted, original, *job.selection, job.pixelToDocument, job.isMask); +} diff --git a/src/Document/PixelInvert.h b/src/Document/PixelInvert.h new file mode 100644 index 0000000..a41e049 --- /dev/null +++ b/src/Document/PixelInvert.h @@ -0,0 +1,18 @@ +#pragma once +#include "Document/Selection.h" +#include <QImage> +#include <QTransform> +#include <optional> + +// A whole-image invert in one pass, limited to a selection. +namespace PixelInvert { +struct Job { + QImage image; + bool isMask; + // Maps the image's top-left pixel grid to document pixels. + QTransform pixelToDocument; + std::optional<SelectionClip> selection; +}; + +QImage run(const Job &job); +} diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp new file mode 100644 index 0000000..5a0c1a7 --- /dev/null +++ b/src/Document/ProjectWorkspace.cpp @@ -0,0 +1,499 @@ +#include "Document/ProjectWorkspace.h" +#include "Document/LiveLayerMask.h" +#include "IO/ImageExporter.h" +#include "IO/ImageFileDrop.h" +#include "Logging.h" +#include <QFileDialog> +#include <QFileInfo> +#include <QFutureWatcher> +#include <QTimer> +#include <QtConcurrent> + +const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row"); + +ProjectTab::ProjectTab(QString name) : defaultName(std::move(name)), controller(session) {} + +QString ProjectTab::nameWithoutSuffix(const QString &path) +{ + // As Swift's deletingPathExtension: `.comp` alone is all name. + const QString name = QFileInfo(path).fileName(); + const qsizetype dot = name.lastIndexOf(QLatin1Char('.')); + return dot > 0 && dot < name.size() - 1 ? name.left(dot) : name; +} + +QString ProjectTab::title() const +{ + return session.projectPath() ? nameWithoutSuffix(*session.projectPath()) : defaultName; +} + +ProjectWorkspace::ProjectWorkspace() +{ + const auto first = std::make_shared<ProjectTab>(QStringLiteral("Untitled")); + first->session.skipsInitialClipboardCanvasSize = true; + first->controller.workspace = this; + m_tabs = {first}; + m_selectedID = first->id; +} + +std::shared_ptr<ProjectTab> ProjectWorkspace::tab(QUuid id) const +{ + for (const std::shared_ptr<ProjectTab> &each : m_tabs) { + if (each->id == id) + return each; + } + return nullptr; +} + +ProjectTab &ProjectWorkspace::current() const +{ + const std::shared_ptr<ProjectTab> selected = tab(m_selectedID); + return selected ? *selected : *m_tabs.front(); +} + +bool ProjectWorkspace::canSwitch() const +{ + return !m_isManaging && current().session.canStartProjectOperation() && !current().session.hueSaturation() && !current().session.filterEdit() + && !current().session.gradientEdit() && !current().session.pixelMove() && !current().session.colorPicker(); +} + +void ProjectWorkspace::setManaging(bool managing) +{ + m_isManaging = managing; + emit changed(); +} + +void ProjectWorkspace::finish(const std::function<void()> &done) +{ + // The caller goes on first, like a resumed Swift task. + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); +} + +void ProjectWorkspace::finish(const std::function<void(bool)> &done, bool value) +{ + if (done) + QMetaObject::invokeMethod(this, [done, value] { done(value); }, Qt::QueuedConnection); +} + +ProjectTab &ProjectWorkspace::addTab(bool reuseEmpty) +{ + if (reuseEmpty && m_tabs.size() == 1 && !current().session.document()) + return current(); + const auto added = std::make_shared<ProjectTab>(QStringLiteral("Untitled %1").arg(m_nextNumber)); + m_nextNumber += 1; + added->controller.workspace = this; + added->controller.window = window; + m_tabs.push_back(added); + m_selectedID = added->id; + emit changed(); + return *added; +} + +void ProjectWorkspace::select(QUuid id) +{ + if (id == m_selectedID || !canSwitch() || !tab(id)) + return; + current().session.commitTransform(); + m_selectedID = id; + current().controller.window = window; + emit changed(); +} + +void ProjectWorkspace::newCanvas() +{ + if (!canSwitch()) + return; + current().session.commitTransform(); + addTab(false); +} + +void ProjectWorkspace::open(std::optional<QString> path, std::function<void(bool)> done) +{ + if (!canSwitch()) { + finish(done, false); + return; + } + setManaging(true); + const auto end = [this, done](bool opened) { + setManaging(false); + finish(done, opened); + }; + if (path) { + loadProject(*path, end); + return; + } + // A project is a folder here: one a dialog. + auto *panel = new QFileDialog(window, QStringLiteral("Open Project")); + panel->setAttribute(Qt::WA_DeleteOnClose); + panel->setFileMode(QFileDialog::Directory); + connect(panel, &QDialog::finished, this, [this, panel, end](int result) { + if (result != QDialog::Accepted) { + end(false); + return; + } + loadProject(panel->selectedFiles().value(0), end); + }); + panel->open(); +} + +void ProjectWorkspace::loadProject(const QString &path, std::function<void(bool)> then) +{ + for (const std::shared_ptr<ProjectTab> &existing : m_tabs) { + if (ProjectController::samePlace(existing->session.projectPath(), path)) { + m_selectedID = existing->id; + emit changed(); + then(true); + return; + } + } + // Loaded unattached: a failed open leaves no broken tab. + const auto loading = std::make_shared<ProjectTab>(ProjectTab::nameWithoutSuffix(path)); + loading->controller.window = window; + // That tab's controller answers, perhaps after this is gone. + loading->controller.open(path, [self = QPointer<ProjectWorkspace>(this), loading, then](bool opened) { + if (!self) + return; + if (!opened) { + then(false); + return; + } + if (self->m_tabs.size() == 1 && !self->current().session.document()) + self->m_tabs.clear(); + loading->controller.workspace = self; + self->m_tabs.push_back(loading); + self->m_selectedID = loading->id; + emit self->changed(); + then(true); + }); +} + +void ProjectWorkspace::close(QUuid id, std::function<void()> done) +{ + const std::shared_ptr<ProjectTab> closing = tab(id); + if (!canSwitch() || !closing) { + finish(done); + return; + } + setManaging(true); + closing->controller.window = window; + // Held here, the tab outlives its own controller's answer. + closing->controller.confirmQuit([self = QPointer<ProjectWorkspace>(this), closing, done](bool confirmed) { + if (!self) + return; + if (confirmed) + self->removeTab(closing->id); + self->setManaging(false); + self->finish(done); + }); +} + +void ProjectWorkspace::removeTab(QUuid id) +{ + size_t index = 0; + while (index < m_tabs.size() && m_tabs[index]->id != id) + index += 1; + // Nothing erased is nothing changed: no iterator to trust. + if (std::erase_if(m_tabs, [&](const std::shared_ptr<ProjectTab> &each) { return each->id == id; }) == 0) + return; + if (m_tabs.empty()) + addTab(false); + else if (m_selectedID == id) + m_selectedID = m_tabs[std::min(index, m_tabs.size() - 1)]->id; + emit changed(); +} + +std::vector<std::shared_ptr<ProjectTab>> ProjectWorkspace::quitOrder() const +{ + // The project on screen first, then the rest in order. + std::vector<std::shared_ptr<ProjectTab>> order{tab(current().id)}; + for (const std::shared_ptr<ProjectTab> &each : m_tabs) { + if (each->id != current().id) + order.push_back(each); + } + return order; +} + +void ProjectWorkspace::confirmQuit(std::function<void(bool)> done) +{ + if (!canSwitch()) { + finish(done, false); + return; + } + setManaging(true); + askNext(quitOrder(), 0, std::move(done)); +} + +void ProjectWorkspace::askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done) +{ + if (index == order.size()) { + setManaging(false); + finish(done, true); + return; + } + const std::shared_ptr<ProjectTab> asked = order[index]; + m_selectedID = asked->id; + asked->controller.window = window; + emit changed(); + asked->controller.confirmQuit([self = QPointer<ProjectWorkspace>(this), order, index, done](bool confirmed) { + if (!self) + return; + if (confirmed) { + self->askNext(order, index + 1, done); + return; + } + self->setManaging(false); + self->finish(done, false); + }); +} + +void ProjectWorkspace::closeWindow(QWidget *closing) +{ + confirmQuit([this, closing = QPointer<QWidget>(closing)](bool confirmed) { + if (!confirmed) + return; + m_tabs.clear(); + addTab(false); + // The window's close asks us; this mark lets it through. + if (closing) { + closing->setProperty("closeConfirmed", true); + closing->close(); + } + }); +} + +void ProjectWorkspace::receive(const QList<QUrl> &urls, std::optional<QUuid> destination, std::optional<QPointF> point, + std::function<void()> done) +{ + // Swift sleeps 30 ms between looks; so does this. + if (!canSwitch()) { + QTimer::singleShot(30, this, [=, this] { receive(urls, destination, point, done); }); + return; + } + setManaging(true); + receiveNext(urls, destination, point, std::move(done)); +} + +void ProjectWorkspace::receiveNext(QList<QUrl> urls, std::optional<QUuid> destination, std::optional<QPointF> point, + std::function<void()> done) +{ + if (urls.isEmpty()) { + setManaging(false); + finish(done); + return; + } + const QUrl url = urls.takeFirst(); + const auto next = [=, this] { receiveNext(urls, destination, point, done); }; + // A project opens its own tab; a picture joins one. + if (ProjectController::isProject(url)) { + loadProject(url.toLocalFile(), [next](bool) { next(); }); + return; + } + const std::shared_ptr<ProjectTab> into = destination ? tab(*destination) : tab(addTab().id); + if (!into) { + next(); + return; + } + m_selectedID = into->id; + emit changed(); + // The callback keeps the tab alive, as Swift's await does. + into->session.importImages({url}, point, [self = QPointer<ProjectWorkspace>(this), into, next] { + if (self) + next(); + }); +} + +void ProjectWorkspace::receiveProviders(const QMimeData &data, std::optional<QUuid> destination, std::optional<QPointF> point, + std::function<void()> done) +{ + if (data.hasFormat(layerType)) + copyNext(layerIDs(data), destination, point, std::move(done)); + else + importNext(ImageFileDrop::providers(data), destination, point, std::move(done)); +} + +// One provider after another, as Swift awaits each. +void ProjectWorkspace::importNext(std::vector<std::shared_ptr<QMimeData>> providers, std::optional<QUuid> destination, std::optional<QPointF> point, + std::function<void()> done) +{ + if (providers.empty()) { + finish(done); + return; + } + const std::shared_ptr<QMimeData> provider = providers.front(); + providers.erase(providers.begin()); + // The front tab hears what failed; it stays alive. + const std::shared_ptr<ProjectTab> front = tab(m_selectedID); + ImageFileDrop::importProviders(*provider, front->session, point, this, destination, + [self = QPointer<ProjectWorkspace>(this), front, providers = std::move(providers), destination, point, done]() mutable { + if (self) + self->importNext(std::move(providers), destination, point, std::move(done)); + }); +} + +std::optional<QUuid> ProjectWorkspace::layerID(const QString &text) +{ + const QString trimmed = text.trimmed(); + const QUuid id = QUuid::fromString(trimmed); + if (uuidString(id).compare(trimmed, Qt::CaseInsensitive) != 0) + return std::nullopt; + return id; +} + +std::vector<QUuid> ProjectWorkspace::layerIDs(const QMimeData &data) +{ + std::vector<QUuid> ids; + for (const QString &line : QString::fromUtf8(data.data(layerType)).split(QLatin1Char('\n'))) { + if (const std::optional<QUuid> id = layerID(line)) + ids.push_back(*id); + } + return ids; +} + +// Several rows are copied one after another, as Swift awaits. +void ProjectWorkspace::copyNext(std::vector<QUuid> ids, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done) +{ + if (ids.empty()) { + finish(done); + return; + } + const QUuid first = ids.front(); + ids.erase(ids.begin()); + copyLayer(first, destination, point, [self = QPointer(this), ids = std::move(ids), destination, point, done = std::move(done)]() mutable { + if (self) + self->copyNext(std::move(ids), destination, point, std::move(done)); + }); +} + +std::optional<QUuid> ProjectWorkspace::draggedLayerSource(const QMimeData &data) const +{ + const std::vector<QUuid> ids = layerIDs(data); + if (ids.empty()) + return std::nullopt; + for (const std::shared_ptr<ProjectTab> &each : m_tabs) { + if (each->session.document() && indexOf(each->session.document()->layers, ids.front()) >= 0) + return each->id; + } + return std::nullopt; +} + +bool ProjectWorkspace::canReceiveDrag(const QMimeData &data, std::optional<QUuid> destination) const +{ + const std::optional<QUuid> source = draggedLayerSource(data); + return !destination || !source || *source != *destination; +} + +void ProjectWorkspace::copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done) +{ + // A null pointer aborts when read; an iterator would not. + std::shared_ptr<ProjectTab> from; + for (const std::shared_ptr<ProjectTab> &each : m_tabs) { + if (!from && each->session.document() && indexOf(each->session.document()->layers, id) >= 0) + from = each; + } + if (!canSwitch() || !from || !from->session.canEditLayers() || destination == std::optional(from->id)) { + finish(done); + return; + } + Copy copy{.id = id, .from = from, .target = nullptr, .original = from->session.document().value(), .layers = {}, .included = {}, .point = point}; + const ProjectSnapshot snapshot = copy.from->session.projectSnapshot().value(); + copy.target = destination ? tab(*destination) : tab(addTab(false).id); + if (!copy.target || (destination && !copy.target->session.canStartProjectOperation()) + || (copy.target->session.document() && !copy.target->session.canEditLayers())) { + finish(done); + return; + } + copy.included = copy.from->session.descendantIDs(id); + copy.included.insert(id); + const auto pixels = [](const std::vector<ImageLayer> &layers) { + qint64 total = 0; + for (const ImageLayer &layer : layers) + total += layer.asset ? qint64(layer.asset->size().width()) * layer.asset->size().height() : 0; + return total; + }; + for (const ImageLayer &layer : copy.original.layers) { + if (copy.included.contains(layer.id)) + copy.layers.push_back(layer); + } + const qint64 used = copy.target->session.document() ? pixels(copy.target->session.document()->layers) : 0; + if (used + pixels(copy.layers) > 100'000'000) { + copy.target->session.setImportError(QStringLiteral("The copied layers exceed this project’s 100-megapixel limit.")); + finish(done); + return; + } + setManaging(true); + copy.from->session.setIsProjectBusy(true); + copy.target->session.setIsProjectBusy(true); + // A mask from outside the copy goes into the pixels. + std::vector<QUuid> outside; + for (const ImageLayer &layer : copy.layers) { + if (layer.maskSourceID && !copy.included.contains(*layer.maskSourceID)) + outside.push_back(layer.id); + } + auto *watcher = new QFutureWatcher<Baked>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, copy, done] { + watcher->deleteLater(); + const Baked baked = watcher->result(); + if (baked.failure) { + qCWarning(lcApp).noquote() << "cannot copy the layers:" << *baked.failure; + copy.target->session.setImportError(baked.failure); + } else { + place(copy, baked.images); + } + // Managing ends last: its signal sees both projects free. + copy.from->session.setIsProjectBusy(false); + copy.target->session.setIsProjectBusy(false); + setManaging(false); + finish(done); + }); + watcher->setFuture(QtConcurrent::run([snapshot, outside]() -> Baked { + Baked result; + try { + for (const QUuid layer : outside) { + if (const std::optional<ImportedImage> image = LiveMaskBaker::bake(snapshot, layer)) + result.images.insert({layer, *image}); + } + } catch (const ExportError &error) { + result.failure = QString::fromUtf8(error.what()); + } + return result; + })); +} + +void ProjectWorkspace::place(const Copy ©, const EditorSession::BakedImages &baked) +{ + QHash<QUuid, QUuid> mapping; + for (const ImageLayer &layer : copy.layers) + mapping.insert(layer.id, QUuid::createUuid()); + EditorSession &into = copy.target->session; + const QSizeF size = into.document() ? into.document()->size() : copy.original.size(); + const QPointF anchor = copy.layers[indexOf(copy.layers, copy.id)].transform.center(); + const QPointF shift = copy.point.value_or(QPointF(size.width() / 2, size.height() / 2)) - anchor; + const auto mapped = [&](const std::optional<QUuid> &old) { + return old && mapping.contains(*old) ? std::optional(mapping.value(*old)) : std::nullopt; + }; + std::vector<ImageLayer> layers; + for (ImageLayer layer : copy.layers) { + if (baked.contains(layer.id)) + layer.asset = baked.at(layer.id); + layer.id = mapping.value(layer.id); + layer.transform.origin += shift; + if (layer.mask && layer.mask->placement) + layer.mask->placement->origin += shift; + // A parent or mask source outside the copy lets go. + layer.parentID = mapped(layer.parentID); + layer.maskSourceID = mapped(layer.maskSourceID); + // Swift's copy lists its fields; effects are not among them. + layer.effects = std::nullopt; + layers.push_back(layer); + } + // Busy sessions refuse a new canvas: free it first. + into.setIsProjectBusy(false); + into.beginEdit(QStringLiteral("Copy Layers from Project")); + if (!into.document()) + into.createDocument(int(size.width()), int(size.height())); + into.m_document->layers.insert(into.m_document->layers.end(), layers.begin(), layers.end()); + into.setActiveLayerID(mapping.value(copy.id)); + into.endEdit(); + // Announced by the caller, which stops managing next. + m_selectedID = copy.target->id; +} diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h new file mode 100644 index 0000000..8e485f3 --- /dev/null +++ b/src/Document/ProjectWorkspace.h @@ -0,0 +1,96 @@ +#pragma once +#include "Document/EditorSession.h" +#include "IO/ProjectController.h" +#include <QMimeData> +#include <memory> +#include <vector> + +// One open project: its session and its file operations. +class ProjectTab { +public: + explicit ProjectTab(QString name); + + const QUuid id = QUuid::createUuid(); + const QString defaultName; + // The controller refers to the session: this order. + EditorSession session; + ProjectController controller; + + QString title() const; + static QString nameWithoutSuffix(const QString &path); +}; + +// The projects open in one window, one in front. +class ProjectWorkspace : public QObject { + Q_OBJECT +public: + ProjectWorkspace(); + + const std::vector<std::shared_ptr<ProjectTab>> &tabs() const { return m_tabs; } + QUuid selectedID() const { return m_selectedID; } + bool isManaging() const { return m_isManaging; } + QPointer<QWidget> window; + + ProjectTab ¤t() const; + std::shared_ptr<ProjectTab> tab(QUuid id) const; + bool canSwitch() const; + ProjectTab &addTab(bool reuseEmpty = true); + void select(QUuid id); + void newCanvas(); + // Each `done` runs from the event loop, never inline. + void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {}); + void close(QUuid id, std::function<void()> done = {}); + void removeTab(QUuid id); + std::vector<std::shared_ptr<ProjectTab>> quitOrder() const; + void confirmQuit(std::function<void(bool)> done); + void closeWindow(QWidget *closing); + void receive(const QList<QUrl> &urls, std::optional<QUuid> destination = std::nullopt, + std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); + // A dragged layer row carries its id under this type. + static const QString layerType; + void receiveProviders(const QMimeData &data, std::optional<QUuid> destination = std::nullopt, + std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); + // The tab a dragged layer lives in, read mid-air. + std::optional<QUuid> draggedLayerSource(const QMimeData &data) const; + // A layer's own tab is no target; others are. + bool canReceiveDrag(const QMimeData &data, std::optional<QUuid> destination) const; + // A drag's ids, one a line; other lines skipped. + static std::vector<QUuid> layerIDs(const QMimeData &data); + // The id a text names; all zeros counts. + static std::optional<QUuid> layerID(const QString &text); + void copyLayer(QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt, + std::function<void()> done = {}); + +signals: + void changed(); + +private: + struct Baked { + EditorSession::BakedImages images; + std::optional<QString> failure; + }; + struct Copy { + QUuid id; + std::shared_ptr<ProjectTab> from, target; + CanvasDocument original; + std::vector<ImageLayer> layers; + QSet<QUuid> included; + std::optional<QPointF> point; + }; + + void setManaging(bool managing); + void finish(const std::function<void()> &done); + void finish(const std::function<void(bool)> &done, bool value); + void loadProject(const QString &path, std::function<void(bool)> then); + void askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done); + void receiveNext(QList<QUrl> urls, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done); + void copyNext(std::vector<QUuid> ids, std::optional<QUuid> destination, std::optional<QPointF> point, std::function<void()> done); + void importNext(std::vector<std::shared_ptr<QMimeData>> providers, std::optional<QUuid> destination, std::optional<QPointF> point, + std::function<void()> done); + void place(const Copy ©, const EditorSession::BakedImages &baked); + + std::vector<std::shared_ptr<ProjectTab>> m_tabs; + QUuid m_selectedID; + bool m_isManaging = false; + int m_nextNumber = 2; +}; diff --git a/src/Document/Selection+Session.h b/src/Document/Selection+Session.h new file mode 100644 index 0000000..66bec04 --- /dev/null +++ b/src/Document/Selection+Session.h @@ -0,0 +1,39 @@ +// Swift's Selection extension: outlines drawn, moved and combined. +public: + std::optional<DocumentSelection> selection() const; + bool canEditSelection() const; + SelectionMode selectionMode(bool shift, bool option) const; + // An outline in progress keeps its starting mode. + SelectionMode lassoCursorMode(bool shift, bool option) const; + SelectionMode displayedSelectionMode() const; + void updateHeldSelectionKeys(bool shift, bool option); + void beginLasso(QPointF point, SelectionMode mode); + // Shift squares the box; `fromCenter` grows it around the anchor. + void dragMarquee(QPointF point, bool square, bool fromCenter); + void extendLasso(QPointF point); + void moveLassoCursor(std::optional<QPointF> point); + void removeLastLassoPoint(); + void cancelLasso(); + void pressMarqueeKey(); + void pressLassoKey(); + void toggleMarqueeKind(); + void toggleLassoKind(); + // Closes the outline and combines it with the selection. + void finishLasso(); + void applySelection(const QPainterPath &shape, SelectionMode mode, const QString &name); + void setSelection(std::optional<DocumentSelection> value, const QString &name); + // Dragging in New mode moves the outline, never pixels. + bool canMoveSelection(QPointF point) const; + bool beginSelectionMove(); + void moveSelection(QSizeF offset); + void endSelectionMove(); + void nudgeSelection(double dx, double dy); + bool canModifySelection() const; + void expandSelection(int amount); + void contractSelection(int amount); + void selectAll(); + void deselect(); + void invertSelection(); + +private: + void resizeSelection(double delta, const QString &name); diff --git a/src/Document/Selection.cpp b/src/Document/Selection.cpp new file mode 100644 index 0000000..be4a3b8 --- /dev/null +++ b/src/Document/Selection.cpp @@ -0,0 +1,418 @@ +#include "Document/Selection.h" +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include <QPainterPathStroker> +#include <cmath> +#include <stdexcept> + +namespace { +QPainterPath rectPath(const QRectF &rect) +{ + QPainterPath path; + path.addRect(rect); + return path; +} + +QPainterPath canvasPath(const CanvasDocument &document) +{ + return rectPath(QRectF(QPointF(0, 0), document.size())); +} + +void fill(QPainter &painter, const QPainterPath &path, bool antialiased) +{ + painter.setRenderHint(QPainter::Antialiasing, antialiased); + painter.fillPath(path, Qt::white); +} +} + +QString rawValue(LassoKind kind) +{ + switch (kind) { + case LassoKind::freehand: + return QStringLiteral("Freehand"); + case LassoKind::polygonal: + return QStringLiteral("Polygonal"); + case LassoKind::rectangle: + return QStringLiteral("Rectangle"); + case LassoKind::ellipse: + return QStringLiteral("Ellipse"); + } + throw std::logic_error("unknown lasso kind"); +} + +QString rawValue(SelectionMode mode) +{ + switch (mode) { + case SelectionMode::replace: + return QStringLiteral("New"); + case SelectionMode::add: + return QStringLiteral("Add"); + case SelectionMode::subtract: + return QStringLiteral("Subtract"); + } + throw std::logic_error("unknown selection mode"); +} + +bool DocumentSelection::isEmpty() const +{ + return path.isEmpty() || path.boundingRect().isEmpty(); +} + +QImage DocumentSelection::coverage(int width, int height) const +{ + QImage image = BrushRaster::context(width, height, true); + QPainter painter(&image); + fill(painter, path, antialiased); + return image; +} + +SelectionClip DocumentSelection::clip(QSizeF canvas) const +{ + const QRect region = path.boundingRect().adjusted(-1, -1, 1, 1).toAlignedRect() + .intersected(QRect(0, 0, int(canvas.width()), int(canvas.height()))); + if (isEmpty() || region.isEmpty()) + return SelectionClip{QRectF(), std::nullopt}; + QImage image = BrushRaster::context(region.width(), region.height(), true); + QPainter painter(&image); + painter.translate(-region.x(), -region.y()); + fill(painter, path, antialiased); + return SelectionClip{QRectF(region), image}; +} + +QRectF DragBox::rect(QPointF anchor, QPointF point, bool square, bool fromCenter) +{ + double dx = std::round(point.x()) - anchor.x(), dy = std::round(point.y()) - anchor.y(); + if (square) { + const double side = std::max(std::abs(dx), std::abs(dy)); + dx = dx < 0 ? -side : side; + dy = dy < 0 ? -side : side; + } + if (fromCenter) + return QRectF(anchor.x() - std::abs(dx), anchor.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2); + return QRectF(std::min(anchor.x(), anchor.x() + dx), std::min(anchor.y(), anchor.y() + dy), std::abs(dx), std::abs(dy)); +} + +std::optional<DocumentSelection> EditorSession::selection() const +{ + return m_document ? m_document->selection : std::nullopt; +} + +bool EditorSession::canEditSelection() const +{ + return canEditLayers(); +} + +SelectionMode EditorSession::selectionMode(bool shift, bool option) const +{ + return option ? SelectionMode::subtract : shift ? SelectionMode::add : m_selectionModeChoice; +} + +SelectionMode EditorSession::lassoCursorMode(bool shift, bool option) const +{ + return m_lassoDraft ? m_lassoDraft->mode : selectionMode(shift, option); +} + +SelectionMode EditorSession::displayedSelectionMode() const +{ + return m_lassoDraft ? m_lassoDraft->mode : m_heldSelectionMode.value_or(m_selectionModeChoice); +} + +void EditorSession::updateHeldSelectionKeys(bool shift, bool option) +{ + const std::optional<SelectionMode> held = option ? std::optional(SelectionMode::subtract) : shift ? std::optional(SelectionMode::add) : std::nullopt; + if (m_heldSelectionMode == held) + return; + m_heldSelectionMode = held; + notify(); +} + +void EditorSession::setLassoKind(LassoKind kind) +{ + m_lassoKind = kind; + notify(); +} + +void EditorSession::setMarqueeKind(LassoKind kind) +{ + m_marqueeKind = kind; + notify(); +} + +void EditorSession::setSelectionModeChoice(SelectionMode mode) +{ + m_selectionModeChoice = mode; + notify(); +} + +void EditorSession::setSelectionAntialiased(bool antialiased) +{ + m_selectionAntialiased = antialiased; + notify(); +} + +void EditorSession::setSelectionExpandAmount(int amount) +{ + m_selectionExpandAmount = amount; + notify(); +} + +void EditorSession::setSelectionContractAmount(int amount) +{ + m_selectionContractAmount = amount; + notify(); +} + +void EditorSession::beginLasso(QPointF point, SelectionMode mode) +{ + // The Magic Wand selects with a click, never an outline. + if (!isSelectionTool(m_tool) || m_tool == NavigationTool::wand || !canEditSelection() || m_selectionMoveOrigin) + return; + if (m_tool == NavigationTool::marquee) { + const QPointF anchor(std::round(point.x()), std::round(point.y())); + m_lassoDraft = LassoDraft{{anchor}, std::nullopt, mode, m_marqueeKind, anchor}; + } else { + m_lassoDraft = LassoDraft{{point}, std::nullopt, mode, m_lassoKind, std::nullopt}; + } + notify(); +} + +void EditorSession::dragMarquee(QPointF point, bool square, bool fromCenter) +{ + if (!m_lassoDraft || (m_lassoDraft->kind != LassoKind::rectangle && m_lassoDraft->kind != LassoKind::ellipse) || !m_lassoDraft->anchor + || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + const QRectF rect = DragBox::rect(*m_lassoDraft->anchor, point, square, fromCenter); + m_lassoDraft->points = {rect.topLeft(), rect.topRight(), rect.bottomRight(), rect.bottomLeft()}; + notify(); +} + +void EditorSession::extendLasso(QPointF point) +{ + if (!m_lassoDraft || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + // Points closer than a quarter pixel are skipped. + const QPointF last = m_lassoDraft->points.back(); + if (std::hypot(point.x() - last.x(), point.y() - last.y()) < 0.25) + return; + m_lassoDraft->points.push_back(point); + notify(); +} + +void EditorSession::moveLassoCursor(std::optional<QPointF> point) +{ + if (!m_lassoDraft) + return; + m_lassoDraft->cursor = point; + notify(); +} + +void EditorSession::removeLastLassoPoint() +{ + if (!m_lassoDraft) + return; + m_lassoDraft->points.pop_back(); + if (m_lassoDraft->points.empty()) + m_lassoDraft = std::nullopt; + notify(); +} + +void EditorSession::cancelLasso() +{ + if (!m_lassoDraft) + return; + m_lassoDraft = std::nullopt; + notify(); +} + +// The key chooses the tool; the bar switches its kind. +void EditorSession::pressMarqueeKey() +{ + selectTool(NavigationTool::marquee); +} + +void EditorSession::pressLassoKey() +{ + selectTool(NavigationTool::lasso); +} + +void EditorSession::toggleMarqueeKind() +{ + cancelLasso(); + m_marqueeKind = m_marqueeKind == LassoKind::rectangle ? LassoKind::ellipse : LassoKind::rectangle; + notify(); +} + +void EditorSession::toggleLassoKind() +{ + cancelLasso(); + m_lassoKind = m_lassoKind == LassoKind::freehand ? LassoKind::polygonal : LassoKind::freehand; + notify(); +} + +void EditorSession::finishLasso() +{ + if (!m_lassoDraft) + return; + const LassoDraft draft = *std::exchange(m_lassoDraft, std::nullopt); + QPainterPath outline; + outline.setFillRule(Qt::WindingFill); + if (draft.kind == LassoKind::ellipse && draft.points.size() == 4) { + // The drag's box, whole pixels; the oval fills it. + double left = draft.points[0].x(), top = draft.points[0].y(), right = left, bottom = top; + for (const QPointF &point : draft.points) { + left = std::min(left, point.x()); + right = std::max(right, point.x()); + top = std::min(top, point.y()); + bottom = std::max(bottom, point.y()); + } + outline.addEllipse(QRectF(left, top, right - left, bottom - top)); + } else { + outline.moveTo(draft.points.front()); + for (std::size_t index = 1; index < draft.points.size(); ++index) + outline.lineTo(draft.points[index]); + outline.closeSubpath(); + } + const QRectF bounds = outline.boundingRect(); + if ((draft.points.size() < 3 && draft.kind != LassoKind::ellipse) || bounds.width() <= 0 || bounds.height() <= 0) { + // A click that encloses nothing deselects in New mode. + if (draft.mode == SelectionMode::replace) + deselect(); + notify(); + return; + } + applySelection(outline, draft.mode, + draft.kind == LassoKind::freehand ? QStringLiteral("Lasso") : draft.kind == LassoKind::polygonal ? QStringLiteral("Polygonal Lasso") + : draft.kind == LassoKind::ellipse ? QStringLiteral("Elliptical Marquee") : QStringLiteral("Rectangular Marquee")); + notify(); +} + +void EditorSession::applySelection(const QPainterPath &shape, SelectionMode mode, const QString &name) +{ + if (!m_document || !canEditSelection()) + return; + const QPainterPath clipped = shape.intersected(canvasPath(*m_document)); + const std::optional<DocumentSelection> current = selection(); + QPainterPath result; + switch (mode) { + case SelectionMode::replace: + result = clipped; + break; + case SelectionMode::add: + result = current ? current->path.united(clipped) : clipped; + break; + case SelectionMode::subtract: + // Nothing to subtract from: nothing changes. + if (!current) + return; + result = current->path.subtracted(clipped); + break; + } + setSelection(DocumentSelection{result, m_selectionAntialiased}, name); +} + +void EditorSession::setSelection(std::optional<DocumentSelection> value, const QString &name) +{ + if (!m_document || !canEditSelection() || value == selection()) + return; + beginEdit(name); + m_document->selection = std::move(value); + endEdit(); +} + +bool EditorSession::canMoveSelection(QPointF point) const +{ + const std::optional<DocumentSelection> current = selection(); + if (!current || current->isEmpty() || !canEditSelection() || m_lassoDraft) + return false; + return current->path.contains(point); +} + +bool EditorSession::beginSelectionMove() +{ + const std::optional<DocumentSelection> current = selection(); + if (m_selectionMoveOrigin || !current || current->isEmpty() || !canEditSelection()) + return false; + beginEdit(QStringLiteral("Move Selection")); + m_selectionMoveOrigin = current; + return true; +} + +void EditorSession::moveSelection(QSizeF offset) +{ + // The document may have gone mid-drag: nothing to move. + if (!m_selectionMoveOrigin || !m_document) + return; + // Whole pixels, so edges stay crisp; not re-clipped. + m_document->selection = DocumentSelection{m_selectionMoveOrigin->path.translated(std::round(offset.width()), std::round(offset.height())), + m_selectionMoveOrigin->antialiased}; + notify(); +} + +void EditorSession::endSelectionMove() +{ + if (!m_selectionMoveOrigin) + return; + m_selectionMoveOrigin = std::nullopt; + endEdit(); +} + +void EditorSession::nudgeSelection(double dx, double dy) +{ + if (!beginSelectionMove()) + return; + moveSelection(QSizeF(dx, dy)); + endSelectionMove(); +} + +bool EditorSession::canModifySelection() const +{ + const std::optional<DocumentSelection> current = selection(); + return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft; +} + +void EditorSession::expandSelection(int amount) +{ + resizeSelection(amount, QStringLiteral("Expand Selection")); +} + +void EditorSession::contractSelection(int amount) +{ + resizeSelection(-amount, QStringLiteral("Contract Selection")); +} + +void EditorSession::resizeSelection(double delta, const QString &name) +{ + const std::optional<DocumentSelection> current = selection(); + if (!m_document || !current || !canModifySelection() || delta == 0 || std::abs(delta) > 500) + return; + // A band `|delta|` wide on each side, added or removed. + QPainterPathStroker stroker; + stroker.setWidth(std::abs(delta) * 2); + stroker.setCapStyle(Qt::RoundCap); + stroker.setJoinStyle(Qt::RoundJoin); + const QPainterPath band = stroker.createStroke(current->path); + const QPainterPath result = delta > 0 ? current->path.united(band).intersected(canvasPath(*m_document)) : current->path.subtracted(band); + setSelection(DocumentSelection{result, current->antialiased}, name); +} + +void EditorSession::selectAll() +{ + if (!m_document) + return; + setSelection(DocumentSelection{canvasPath(*m_document)}, QStringLiteral("Select All")); +} + +void EditorSession::deselect() +{ + if (!selection()) + return; + setSelection(std::nullopt, QStringLiteral("Deselect")); +} + +void EditorSession::invertSelection() +{ + const std::optional<DocumentSelection> current = selection(); + if (!m_document || !current) + return; + setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased}, QStringLiteral("Inverse")); +} diff --git a/src/Document/Selection.h b/src/Document/Selection.h new file mode 100644 index 0000000..e679b1f --- /dev/null +++ b/src/Document/Selection.h @@ -0,0 +1,53 @@ +#pragma once +#include <QImage> +#include <QPainterPath> +#include <QPointF> +#include <QRectF> +#include <QString> +#include <optional> +#include <vector> + +// Coverage of one document region, ready to clip edits. +struct SelectionClip { + QRectF rect; + // Nil for an empty selection: it clips everything away. + std::optional<QImage> coverage; +}; + +// A document-space outline, clipped to the canvas. +struct DocumentSelection { + QPainterPath path; + bool antialiased = true; + + // An explicit empty selection: later edits touch nothing. + bool isEmpty() const; + // Gray coverage at document resolution, white where selected. + QImage coverage(int width, int height) const; + SelectionClip clip(QSizeF canvas) const; + friend bool operator==(const DocumentSelection &, const DocumentSelection &) = default; +}; + +// The Marquee's outlines are rectangle and ellipse. +enum class LassoKind { freehand, polygonal, rectangle, ellipse }; +// The name the tool bar shows. +QString rawValue(LassoKind kind); + +enum class SelectionMode { replace, add, subtract }; +QString rawValue(SelectionMode mode); + +// A drag's box in whole pixels; shared with Shape. +namespace DragBox { +QRectF rect(QPointF anchor, QPointF point, bool square, bool fromCenter); +} + +// An outline being drawn, in document pixels. +struct LassoDraft { + std::vector<QPointF> points; + // The polygonal lasso's rubber-band end. + std::optional<QPointF> cursor; + SelectionMode mode; + LassoKind kind; + // The Marquee's starting corner or centre, whole pixels. + std::optional<QPointF> anchor; + friend bool operator==(const LassoDraft &, const LassoDraft &) = default; +}; diff --git a/src/Document/SelectionClipboard+Session.h b/src/Document/SelectionClipboard+Session.h new file mode 100644 index 0000000..fc93eaa --- /dev/null +++ b/src/Document/SelectionClipboard+Session.h @@ -0,0 +1,24 @@ +// Swift's SelectionClipboard extension: Copy, Copy Merged, Paste. +public: + // Ctrl+X: copy, then clear the selected pixels. + void cutSelection(std::function<void()> done = {}); + // A copy above the layer, then placed; never a folder. + void duplicateActiveLayer(); + bool duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false); + std::optional<QRectF> selectionCopyRegion() const; + bool canCopyPixels() const; + std::optional<CopiedPixels> renderSelectedPixels(const ImageLayer &layer, bool mask) const; + bool canCopyMerged() const; + std::optional<CopiedPixels> renderMergedPixels() const; + void copyMergedSelection(); + void copySelection(); + bool canPaste() const; + void paste(); + void layerViaCopy(); + // A shape or text style is tied to its asset. + void addPixelLayer(const QImage &image, QPointF origin, const QString &name, const QString &editName, bool dropsSelection = true, + const std::optional<LayerShapeStyle> &shape = std::nullopt, const std::optional<LayerTextStyle> &text = std::nullopt); + QString nextLayerName() const; + +private: + void store(const CopiedPixels &copied); diff --git a/src/Document/SelectionClipboard.cpp b/src/Document/SelectionClipboard.cpp new file mode 100644 index 0000000..543db77 --- /dev/null +++ b/src/Document/SelectionClipboard.cpp @@ -0,0 +1,344 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <QBuffer> +#include <QClipboard> +#include <QColorSpace> +#include <QGuiApplication> +#include <QImageWriter> +#include <QMimeData> +#include <cmath> + +namespace { +// The working format, whatever another app copied. +QImage sRGBCopy(const QImage &image) +{ + // CoreGraphics converts colour spaces as it draws; Qt does not. + const bool tagged = image.colorSpace().isValid() && image.colorSpace() != QColorSpace::SRgb; + const QImage source = tagged ? image.convertedToColorSpace(QColorSpace::SRgb) : image; + QImage copy = BrushRaster::context(source.width(), source.height(), false); + QPainter painter(©); + BrushRaster::draw(source, QRectF(0, 0, source.width(), source.height()), painter); + copy.setColorSpace(QColorSpace::SRgb); + return copy; +} + +// Swift clips the context; QPainter multiplies the coverage in. +void clipTo(const std::optional<SelectionClip> &clip, QPainter &painter) +{ + if (!clip) + return; + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(clip->rect, BrushRaster::alphaView(*clip->coverage)); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); +} + +const QMimeData *clipboardData() +{ + return QGuiApplication::clipboard()->mimeData(); +} +} + +// Swift's SelectionClipboard extension; Cut arrives with 8.2. +std::optional<QRectF> EditorSession::selectionCopyRegion() const +{ + if (!m_document) + return std::nullopt; + const QRectF canvas(QPointF(0, 0), m_document->size()); + const std::optional<DocumentSelection> current = selection(); + const QRectF bounds = current ? current->path.boundingRect() : canvas; + // Path operations leave float noise: round with a tolerance. + constexpr double tolerance = 0.001; + const double minX = std::floor(bounds.left() + tolerance), minY = std::floor(bounds.top() + tolerance); + const QRectF region = QRectF(minX, minY, std::ceil(bounds.right() - tolerance) - minX, std::ceil(bounds.bottom() - tolerance) - minY).intersected(canvas); + if (region.width() < 1 || region.height() < 1) + return std::nullopt; + return region; +} + +bool EditorSession::canCopyPixels() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + const std::optional<DocumentSelection> current = selection(); + if (!canEditLayers() || !layer || (layer->isGroup && !m_isMaskSelected) || (current && current->isEmpty())) + return false; + return m_isMaskSelected ? layer->mask.has_value() : layer->asset.has_value(); +} + +// The layer's pixels as shown, through the selection. +std::optional<CopiedPixels> EditorSession::renderSelectedPixels(const ImageLayer &layer, bool mask) const +{ + if (!m_document) + return std::nullopt; + const std::optional<DocumentSelection> current = selection(); + const std::optional<SelectionClip> clip = current ? std::optional(current->clip(m_document->size())) : std::nullopt; + if (clip && !clip->coverage) + return std::nullopt; + const std::optional<QRectF> region = selectionCopyRegion(); + if (!region) + return std::nullopt; + QImage image = BrushRaster::context(int(region->width()), int(region->height()), false); + QPainter painter(&image); + painter.translate(-region->left(), -region->top()); + const LayerTransform transform = displayedTransform(layer); + if (mask && layer.mask) { + // A mask as opaque gray: ground, then white through it. + const std::optional<LayerTransform> placement = displayedMaskPlacement(layer); + const double ground = placement ? LayerMask::background(layer.mask->asset.thumbnail) : 0; + painter.fillRect(*region, QColor::fromRgbF(ground, ground, ground)); + clipTo(clip, painter); + // Each pass is clipped alone, so soft edges stack. + QImage white = BrushRaster::context(image.width(), image.height(), false); + QPainter second(&white); + second.translate(-region->left(), -region->top()); + LayerRenderer::drawCoverage(layer.mask->asset.image(), placement.value_or(transform), second); + clipTo(clip, second); + second.end(); + painter.drawImage(*region, white); + } else if (!mask && layer.asset) { + LayerRenderer::draw(layer.asset->image(), transform, transform.center(), painter, {}); + clipTo(clip, painter); + } else { + return std::nullopt; + } + painter.end(); + return CopiedPixels{image, *region}; +} + +bool EditorSession::canCopyMerged() const +{ + const std::optional<DocumentSelection> current = selection(); + if (!canEditLayers() || (current && current->isEmpty())) + return false; + for (const ImageLayer &layer : m_document->renderLayers()) { + if (layer.asset) + return true; + } + return false; +} + +// Every visible layer as shown, through the selection. +std::optional<CopiedPixels> EditorSession::renderMergedPixels() const +{ + if (!m_document) + return std::nullopt; + const std::optional<DocumentSelection> current = selection(); + const std::optional<SelectionClip> clip = current ? std::optional(current->clip(m_document->size())) : std::nullopt; + if (clip && !clip->coverage) + return std::nullopt; + const std::optional<QRectF> region = selectionCopyRegion(); + if (!region) + return std::nullopt; + // Composited first: Color Burn and Dodge read their backdrop. + QImage merged = BrushRaster::context(int(region->width()), int(region->height()), false); + QPainter composite(&merged); + composite.translate(-region->left(), -region->top()); + drawLiveComposite(*m_document, composite); + composite.end(); + if (clip) { + QPainter painter(&merged); + painter.translate(-region->left(), -region->top()); + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(clip->rect, BrushRaster::alphaView(*clip->coverage)); + } + return CopiedPixels{merged, *region}; +} + +void EditorSession::copyMergedSelection() +{ + if (!canCopyMerged()) + return; + try { + const std::optional<CopiedPixels> copied = renderMergedPixels(); + if (!copied) { + qCWarning(lcApp) << "nothing to copy merged"; + return; + } + store(*copied); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +void EditorSession::copySelection() +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!canCopyPixels() || !layer) + return; + try { + const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); + if (!copied) { + qCWarning(lcApp) << "nothing to copy"; + return; + } + store(*copied); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +// Keeps pixels for Paste; the system clipboard gets PNG. +void EditorSession::store(const CopiedPixels &copied) +{ + QClipboard *clipboard = QGuiApplication::clipboard(); + // Counted from the first copy: another since makes Paste external. + if (!m_watchingClipboard) { + m_watchingClipboard = true; + connect(clipboard, &QClipboard::dataChanged, this, [this] { ++m_clipboardChanges; }); + } + auto *data = new QMimeData; + data->setImageData(copied.image); + QByteArray png; + QBuffer buffer(&png); + QImageWriter writer(&buffer, "png"); + if (writer.write(copied.image)) + data->setData(QStringLiteral("image/png"), png); + else + qCWarning(lcApp) << "the copied pixels could not be encoded as PNG:" << writer.errorString(); + clipboard->setMimeData(data); + m_pixelClipboard = PixelClipboard{copied.image, copied.region.topLeft(), m_clipboardChanges}; +} + +void EditorSession::cutSelection(std::function<void()> done) +{ + if (!selection() || !canCopyPixels()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + copySelection(); + clearSelectedPixels(std::move(done)); +} + +bool EditorSession::canPaste() const +{ + if (!m_document || !canEditLayers()) + return false; + if (m_pixelClipboard && m_clipboardChanges == m_pixelClipboard->changeCount) + return true; + // An empty clipboard has no data object at all. + const QMimeData *data = clipboardData(); + return data && data->hasImage(); +} + +// Pixels copied here go back in place; others centre. +void EditorSession::paste() +{ + if (!canPaste()) + return; + if (m_pixelClipboard && m_clipboardChanges == m_pixelClipboard->changeCount) { + addPixelLayer(m_pixelClipboard->image, m_pixelClipboard->origin, nextLayerName(), QStringLiteral("Paste")); + return; + } + const QImage external = qvariant_cast<QImage>(clipboardData()->imageData()); + if (external.isNull()) { + qCWarning(lcApp) << "the clipboard's image could not be read"; + return; + } + try { + const QImage image = sRGBCopy(external); + const QPointF origin(std::floor((m_document->size().width() - image.width()) / 2), std::floor((m_document->size().height() - image.height()) / 2)); + addPixelLayer(image, origin, nextLayerName(), QStringLiteral("Paste")); + } catch (const ExportError &error) { + // Swift's `try?` beeps here, where a layer's copy alerts. + qCWarning(lcApp) << "the clipboard's image could not be copied:" << error.what(); + } +} + +// Ctrl+J: the selection as a layer; without one, a duplicate. +void EditorSession::layerViaCopy() +{ + const std::optional<ImageLayer> layer = activeLayer(); + const std::optional<DocumentSelection> current = selection(); + if (!canEditLayers() || !layer || layer->isGroup || (current && current->isEmpty())) + return; + if (!current) { + duplicateActiveLayer(); + return; + } + try { + const std::optional<CopiedPixels> copied = renderSelectedPixels(*layer, m_isMaskSelected); + if (!copied) { + qCWarning(lcApp) << "nothing to copy into a layer"; + return; + } + addPixelLayer(copied->image, copied->region.topLeft(), nextLayerName(), QStringLiteral("Layer via Copy")); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +// A new layer above the active one, in its folder. +void EditorSession::addPixelLayer(const QImage &image, QPointF origin, const QString &name, const QString &editName, bool dropsSelection, + const std::optional<LayerShapeStyle> &shape, const std::optional<LayerTextStyle> &text) +{ + if (!m_document) + return; + QImage thumbnail; + try { + thumbnail = PixelAdjust::thumbnail(image); + } catch (const ExportError &error) { + qCWarning(lcApp) << "no thumbnail for the new layer:" << error.what(); + return; + } + ImageLayer layer(ImportedImage(image, thumbnail, name), origin); + if (shape) + layer.shape = LayerShape{*shape, layer.asset->identity()}; + if (text) + layer.text = LayerText{*text, layer.asset->identity()}; + const std::optional<ImageLayer> active = activeLayer(); + layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt; + const int activeIndex = indexOf(m_document->layers, m_activeLayerID); + const int index = activeIndex >= 0 ? activeIndex + 1 : int(m_document->layers.size()); + finishOpacityEdit(); + beginEdit(editName); + m_document->layers.insert(m_document->layers.begin() + index, layer); + // Pasting drops the selection, as Photoshop; a shape keeps it. + if (dropsSelection) + m_document->selection = std::nullopt; + setActiveLayerID(layer.id); + endEdit(); +} + +QString EditorSession::nextLayerName() const +{ + QSet<QString> names; + for (const ImageLayer &layer : m_document->layers) + names.insert(layer.name); + int number = 1; + while (names.contains(QStringLiteral("Layer %1").arg(number))) + ++number; + return QStringLiteral("Layer %1").arg(number); +} + +void EditorSession::duplicateActiveLayer() +{ + const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1; + if (index < 0 || m_document->layers[index].isGroup) + return; + ImageLayer copy = m_document->layers[index]; + copy.id = QUuid::createUuid(); + copy.name = copy.name + QStringLiteral(" copy"); + beginEdit(QStringLiteral("Duplicate Layer")); + m_document->layers.insert(m_document->layers.begin() + index + 1, copy); + setActiveLayerID(copy.id); + endEdit(); +} + +// Alt-drag in the Layers panel: a copy placed where dropped. +bool EditorSession::duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom) +{ + const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1; + if (index < 0 || m_document->layers[index].isGroup || !canPlaceLayer(id, parent)) + return false; + beginEdit(QStringLiteral("Duplicate Layer")); + selectLayer(id); + duplicateActiveLayer(); + // The copy is active: nothing above refuses it. + const bool placed = placeLayer(*m_activeLayerID, parent, above, atBottom); + endEdit(); + return placed; +} diff --git a/src/Document/SelectionClipboard.h b/src/Document/SelectionClipboard.h new file mode 100644 index 0000000..4f4da4e --- /dev/null +++ b/src/Document/SelectionClipboard.h @@ -0,0 +1,18 @@ +#pragma once +#include <QImage> +#include <QPointF> +#include <QRectF> + +// Pixels copied from the canvas, with where they came from. +struct PixelClipboard { + QImage image; + QPointF origin; + // The clipboard's changes when written; more means another copy since. + int changeCount; +}; + +// Swift's (image, region) tuple: pixels and their canvas bounds. +struct CopiedPixels { + QImage image; + QRectF region; +}; diff --git a/src/Document/SelectionEdits+Session.h b/src/Document/SelectionEdits+Session.h new file mode 100644 index 0000000..0aa32ca --- /dev/null +++ b/src/Document/SelectionEdits+Session.h @@ -0,0 +1,23 @@ +// Swift's SelectionEdits extension: fills, clears, moves and inverts. +public: + // The outline the canvas draws. + std::optional<DocumentSelection> displayedSelection() const; + enum class FillSource { foreground, background }; + bool canEditPixels() const; + void fillSelection(FillSource source, std::function<void()> done = {}); + void clearSelectedPixels(std::function<void()> done = {}); + void deleteKeyPressed(); + // Swift's PixelMove: Ctrl-drag and Ctrl-arrow move selected pixels. + bool beginPixelMove(bool duplicate = false); + void movePixels(QSizeF offset); + void finishPixelMove(std::function<void()> done = {}); + void cancelPixelMove(); + void nudgePixels(double dx, double dy, std::function<void()> done = {}); + void deleteLayerOrMask(); + bool canInvert() const; + // Off the UI thread; `done` follows the step or refusal. + void invertPixels(std::function<void()> done = {}); + +private: + void finishInvert(); + void applyPixelEdit(const ImageLayer &layer, const QString &name, const std::function<void(BrushStroke &)> &paint, std::function<void()> done); diff --git a/src/Document/SelectionEdits.cpp b/src/Document/SelectionEdits.cpp new file mode 100644 index 0000000..008e346 --- /dev/null +++ b/src/Document/SelectionEdits.cpp @@ -0,0 +1,347 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "Document/PixelInvert.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QtConcurrent> +#include <cmath> + +namespace { +// A uniform 1×1 mask given the layer's pixel grid. +QImage expandedUniformMask(const QImage &image, int width, int height) +{ + if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + QImage expanded = BrushRaster::context(width, height, true); + QPainter painter(&expanded); + BrushRaster::draw(image, QRectF(0, 0, width, height), painter); + return expanded; +} +} + +bool EditorSession::canEditPixels() const +{ + return canPaint(); +} + +// Fills the selection or the layer with one colour. +void EditorSession::fillSelection(FillSource source, std::function<void()> done) +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!canEditPixels() || !layer) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + // A mask takes the palette's red as gray. + const PaletteColor value = paletteColor(source == FillSource::background); + // Live text takes the colour itself and stays text. + if (!m_isMaskSelected && !selection() && recolorText(layer->id, value)) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const QColor color = m_isMaskSelected ? QColor::fromRgbF(float(value.red), float(value.red), float(value.red)) : value.color(); + applyPixelEdit(*layer, m_isMaskSelected ? QStringLiteral("Fill Mask") : QStringLiteral("Fill"), [color](BrushStroke &edit) { edit.fill(color); }, std::move(done)); +} + +// Delete with a selection: pixels clear; a mask takes background. +void EditorSession::clearSelectedPixels(std::function<void()> done) +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!selection() || !canEditPixels() || !layer) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + if (m_isMaskSelected) { + fillSelection(FillSource::background, std::move(done)); + return; + } + if (!layer->asset) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + applyPixelEdit(*layer, QStringLiteral("Clear"), [](BrushStroke &edit) { edit.clearPixels(); }, std::move(done)); +} + +// The Delete key: clears the selection, else mask or layer. +void EditorSession::deleteKeyPressed() +{ + if (selectedEffect()) { + removeSelectedEffect(); + return; + } + if (selection()) + clearSelectedPixels(); + else + deleteLayerOrMask(); +} + +// A raster edit committed off the UI thread, one step. +void EditorSession::applyPixelEdit(const ImageLayer &layer, const QString &name, const std::function<void(BrushStroke &)> &paint, std::function<void()> done) +{ + finishOpacityEdit(); + try { + std::unique_ptr<BrushStroke> edit = makeRasterEdit(layer); + paint(*edit); + if (edit->patches().empty()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + commitRasterEdit(std::move(edit), name, {}, std::move(done)); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + } +} + +void EditorSession::deleteLayerOrMask() +{ + if (selectedEffect()) { + removeSelectedEffect(); + return; + } + const std::optional<ImageLayer> active = activeLayer(); + if (m_isMaskSelected && active && active->mask && m_selectedLayerIDs.size() <= 1) + deleteLayerMask(); + else + deleteSelectedLayers(); +} + +bool EditorSession::canInvert() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!m_document || !layer || m_isProjectBusy || m_isImporting || m_brushStroke || m_pixelMove || m_renamingLayerID || m_showsNewDocument || m_showsImporter + || m_selectedLayerIDs.size() != 1 || (layer->isGroup && !m_isMaskSelected) || !m_document->effectiveVisibleIDs().contains(layer->id)) + return false; + const std::optional<DocumentSelection> current = selection(); + if (current && current->isEmpty()) + return false; + return m_isMaskSelected ? layer->mask && layer->mask->isEnabled : layer->asset.has_value(); +} + +void EditorSession::invertPixels(std::function<void()> done) +{ + // The caller resumes from the event loop, as after await. + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (!canInvert()) { + finish(); + return; + } + commitTransform(); + // A pending gradient lands first, as Swift awaits it. + if (m_gradientEdit) { + commitGradient([this, done] { invertPixels(done); }); + return; + } + if (!canInvert()) { + finish(); + return; + } + const ImageLayer layer = activeLayer().value(); + const bool mask = m_isMaskSelected; + finishOpacityEdit(); + setIsProjectBusy(true); + PixelInvert::Job job{QImage(), mask, QTransform(), std::nullopt}; + try { + // Flattening a painted asset may fail: caught like the rest. + job.image = mask ? layer.mask.value().asset.image() : layer.asset.value().image(); + const std::optional<DocumentSelection> current = selection(); + if (current) + job.selection = current->clip(m_document->size()); + // A uniform 1×1 mask cannot hold a partial selection. + if (mask && job.selection && job.image.size() == QSize(1, 1)) { + job.image = expandedUniformMask(job.image, layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())), + layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()))); + } + job.pixelToDocument = BrushRaster::pixelToDocument(mask ? layer.maskTransform() : layer.transform, job.image.width(), job.image.height()); + } catch (const std::runtime_error &error) { + qCWarning(lcApp).noquote() << "cannot invert:" << error.what(); + setBrushError(QString::fromUtf8(error.what())); + setIsProjectBusy(false); + finish(); + return; + } + m_inverting = Inverting{layer.id, mask, layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, + layer.mask ? std::optional(layer.mask->asset.identity()) : std::nullopt, std::move(done)}; + m_inverter.setFuture(QtConcurrent::run([job]() -> Inverted { + try { + return Inverted{PixelInvert::run(job), std::nullopt}; + } catch (const ExportError &error) { + return Inverted{std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void EditorSession::finishInvert() +{ + const Inverted result = m_inverter.result(); + const Inverting pending = std::exchange(m_inverting, std::nullopt).value(); + // The document may have gone meanwhile: nothing to write. + const int index = m_document ? indexOf(m_document->layers, pending.layerID) : -1; + if (result.failure) { + qCWarning(lcApp).noquote() << "cannot invert:" << *result.failure; + setBrushError(result.failure); + } else if (index >= 0) { + // Only the layer the invert was computed from is written. + const ImageLayer ¤t = m_document->layers[size_t(index)]; + const std::optional<ImageIdentity> image = current.asset ? std::optional(current.asset->identity()) : std::nullopt; + const std::optional<ImageIdentity> maskImage = current.mask ? std::optional(current.mask->asset.identity()) : std::nullopt; + if (image == pending.image && maskImage == pending.maskImage) { + try { + if (pending.mask) { + const ImportedImage asset = LayerMask::assetFrom(*result.image); + beginEdit(QStringLiteral("Invert Mask")); + m_document->layers[size_t(index)].mask = current.mask.value().replacing(asset); + } else { + const ImportedImage asset(*result.image, PixelAdjust::thumbnail(*result.image), current.name); + beginEdit(QStringLiteral("Invert")); + m_document->layers[size_t(index)].asset = asset; + // Swift rebuilds the layer without shape, effects and text. + m_document->layers[size_t(index)].shape = std::nullopt; + m_document->layers[size_t(index)].effects = std::nullopt; + m_document->layers[size_t(index)].text = std::nullopt; + } + endEdit(); + ++m_brushRevision; + } catch (const std::runtime_error &error) { + qCWarning(lcApp).noquote() << "cannot invert:" << error.what(); + setBrushError(QString::fromUtf8(error.what())); + } + } + } + setIsProjectBusy(false); + if (pending.done) + QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); +} + +PixelMove::PixelMove(std::shared_ptr<BrushStroke> raster, DocumentSelection origin, bool duplicate) + : raster(std::move(raster)), origin(std::move(origin)), duplicate(duplicate) +{ +} + +DocumentSelection PixelMove::movedSelection() const +{ + return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased}; +} + +// Ctrl-drag: starts moving the selected pixels; false with none. +bool EditorSession::beginPixelMove(bool duplicate) +{ + const std::optional<DocumentSelection> current = selection(); + const std::optional<ImageLayer> layer = activeLayer(); + // `canPaint` refuses a pixel move and an empty selection. + if (!current || !canPaint() || m_isMaskSelected || !layer || !layer->asset) + return false; + try { + std::shared_ptr<BrushStroke> raster = makeRasterEdit(*layer); + if (!raster->liftSelection()) + return false; + finishOpacityEdit(); + m_pixelMove = std::make_unique<PixelMove>(std::move(raster), *current, duplicate); + notify(); + return true; + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + return false; +} + +// The pixels previewed `offset` whole document pixels away. +void EditorSession::movePixels(QSizeF offset) +{ + if (!m_pixelMove) + return; + const QSizeF rounded(std::round(offset.width()), std::round(offset.height())); + try { + m_pixelMove->raster->moveLifted(rounded, m_pixelMove->duplicate); + } catch (const ProjectError &error) { + cancelPixelMove(); + setBrushError(QString::fromUtf8(error.what())); + return; + } catch (const ExportError &error) { + cancelPixelMove(); + setBrushError(QString::fromUtf8(error.what())); + return; + } + m_pixelMove->offset = rounded; + ++m_brushRevision; + notify(); +} + +// The outline to draw, following a pixel move or transform. +std::optional<DocumentSelection> EditorSession::displayedSelection() const +{ + if (m_pixelMove) + return m_pixelMove->movedSelection(); + // Transforming selected pixels: the outline follows the handles. + const std::optional<DocumentSelection> current = selection(); + if (m_transformEdit && current) { + if (const std::optional<QTransform> matrix = floatingSelectionTransform(*m_transformEdit)) + return DocumentSelection{matrix->map(current->path), current->antialiased}; + } + return current; +} + +// Pixels and moved outline commit together, one step. +void EditorSession::finishPixelMove(std::function<void()> done) +{ + const auto finish = [this, done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + if (!m_pixelMove || m_isProjectBusy) { + finish(); + return; + } + const auto end = [this, finish] { + m_pixelMove.reset(); + ++m_brushRevision; + notify(); + finish(); + }; + if (m_pixelMove->offset.isNull()) { + end(); + return; + } + // The outline stays at its new place while committing. + const DocumentSelection moved = m_pixelMove->movedSelection(); + commitRasterEdit(m_pixelMove->raster, m_pixelMove->duplicate ? QStringLiteral("Duplicate Pixels") : QStringLiteral("Move Pixels"), + [this, moved] { m_document->selection = moved; }, end); +} + +void EditorSession::cancelPixelMove() +{ + if (!m_pixelMove) + return; + m_pixelMove.reset(); + ++m_brushRevision; + notify(); +} + +// Ctrl-arrow: the selected pixels a step, as one undo step. +void EditorSession::nudgePixels(double dx, double dy, std::function<void()> done) +{ + if (!beginPixelMove()) { + qCWarning(lcApp) << "no selected pixels to nudge"; + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + movePixels(QSizeF(dx, dy)); + finishPixelMove(std::move(done)); +} diff --git a/src/Document/SelectionEdits.h b/src/Document/SelectionEdits.h new file mode 100644 index 0000000..2df890d --- /dev/null +++ b/src/Document/SelectionEdits.h @@ -0,0 +1,18 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/Selection.h" +#include <memory> + +// Selected pixels being dragged: the lifted raster and the outline. +class PixelMove { +public: + PixelMove(std::shared_ptr<BrushStroke> raster, DocumentSelection origin, bool duplicate); + // The outline shifted by the drag so far. + DocumentSelection movedSelection() const; + + // Shared: the canvas draws it while the commit runs. + const std::shared_ptr<BrushStroke> raster; + const DocumentSelection origin; + const bool duplicate; + QSizeF offset{0, 0}; +}; diff --git a/src/Document/ShapeTool+Session.h b/src/Document/ShapeTool+Session.h new file mode 100644 index 0000000..8effb01 --- /dev/null +++ b/src/Document/ShapeTool+Session.h @@ -0,0 +1,31 @@ +// Swift's ShapeTool extension: shapes dragged out as new layers. +public: + // Pixels one shape layer may hold, an import's budget. + static constexpr qint64 maxShapePixels = 100'000'000; + ShapeKind shapeKind() const { return m_shapeKind; } + void setShapeKind(ShapeKind kind); + // A rectangle's corner radius, in pixels; 0 keeps corners square. + double shapeCornerRadius() const { return m_shapeCornerRadius; } + void setShapeCornerRadius(double radius); + // A line's thickness, in document pixels. + double shapeLineWidth() const { return m_shapeLineWidth; } + void setShapeLineWidth(double width); + const std::optional<ShapeDraft> &shapeDraft() const { return m_shapeDraft; } + void beginShape(QPointF point); + // The line being dragged, from its start to the pointer. + std::optional<std::pair<QPointF, QPointF>> shapeLineEnds() const; + // Shift squares or snaps a line; Alt grows from centre. + void dragShape(QPointF point, bool square, bool fromCenter); + void cancelShape(); + // Shift-U and Tab: Rectangle, Ellipse, Line. + void toggleShapeKind(); + // The draft becomes a layer above the active one. + void finishShape(); + // "Rectangle 1", "Ellipse 2", skipping names already used. + QString nextShapeName(ShapeKind kind) const; + // A resized shape layer draws its shape again. + void redrawShape(int index); + // A rounded rectangle at its dragged size, 2048 at most. + QImage shapeTransformPreview(const ImageLayer &layer, const LayerTransform &transform) const; + static QImage shapeImage(ShapeKind kind, QSizeF size, const PaletteColor &color, double cornerRadius = 0, double lineWidth = 0, + std::optional<QPointF> start = std::nullopt, std::optional<QPointF> end = std::nullopt); diff --git a/src/Document/ShapeTool.cpp b/src/Document/ShapeTool.cpp new file mode 100644 index 0000000..b89af4b --- /dev/null +++ b/src/Document/ShapeTool.cpp @@ -0,0 +1,280 @@ +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QPainter> +#include <QPainterPathStroker> +#include <cmath> +#include <numbers> +#include <stdexcept> + +QString rawValue(ShapeKind kind) +{ + switch (kind) { + case ShapeKind::rectangle: + return QStringLiteral("Rectangle"); + case ShapeKind::ellipse: + return QStringLiteral("Ellipse"); + case ShapeKind::line: + return QStringLiteral("Line"); + } + throw std::logic_error("unknown shape kind"); +} + +std::optional<ShapeKind> shapeKind(const QString &text) +{ + for (const ShapeKind kind : allShapeKinds) { + if (rawValue(kind) == text) + return kind; + } + return std::nullopt; +} + +QPainterPath path(ShapeKind kind, const QRectF &rect, double cornerRadius) +{ + QPainterPath made; + if (kind == ShapeKind::ellipse) { + made.addEllipse(rect); + return made; + } + // Below zero, like zero, keeps the corners square. + const double radius = std::min({cornerRadius, rect.width() / 2, rect.height() / 2}); + if (radius > 0) + made.addRoundedRect(rect, radius, radius); + else + made.addRect(rect); + return made; +} + +QPainterPath strokedLine(QPointF from, QPointF to, double width) +{ + QPainterPath outline; + if (from == to) { + // CoreGraphics caps a zero-length line as a dot. + outline.addEllipse(from, width / 2, width / 2); + return outline; + } + QPainterPath segment(from); + segment.lineTo(to); + QPainterPathStroker stroker; + stroker.setWidth(width); + stroker.setCapStyle(Qt::RoundCap); + return stroker.createStroke(segment); +} + +std::optional<LayerShape> LayerShape::loaded(const std::optional<LayerShapeStyle> &style, const std::optional<ImportedImage> &image) +{ + if (!style || !image) + return std::nullopt; + return LayerShape{*style, image->identity()}; +} + +std::optional<LayerShape> ImageLayer::liveShape() const +{ + if (!shape || !asset || asset->identity() != shape->image) + return std::nullopt; + return shape; +} + +void EditorSession::setShapeKind(ShapeKind kind) +{ + m_shapeKind = kind; + notify(); +} + +void EditorSession::setShapeCornerRadius(double radius) +{ + m_shapeCornerRadius = radius; + notify(); +} + +void EditorSession::setShapeLineWidth(double width) +{ + m_shapeLineWidth = width; + notify(); +} + +void EditorSession::beginShape(QPointF point) +{ + if (m_tool != NavigationTool::shape || !canEditLayers() || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + const QPointF anchor(std::round(point.x()), std::round(point.y())); + m_shapeDraft = ShapeDraft{.kind = m_shapeKind, .anchor = anchor, .rect = QRectF(anchor, QSizeF(0, 0)), + .cornerRadius = m_shapeKind == ShapeKind::rectangle ? m_shapeCornerRadius : 0}; + notify(); +} + +std::optional<std::pair<QPointF, QPointF>> EditorSession::shapeLineEnds() const +{ + // Only a line's drag sets an end. + if (!m_shapeDraft || !m_shapeDraft->end) + return std::nullopt; + return std::pair(m_shapeDraft->anchor, *m_shapeDraft->end); +} + +void EditorSession::dragShape(QPointF point, bool square, bool fromCenter) +{ + if (!m_shapeDraft || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + ShapeDraft &draft = *m_shapeDraft; + if (draft.kind == ShapeKind::line && square) { + // Shift snaps a line to eighths of a turn. + const double dx = point.x() - draft.anchor.x(), dy = point.y() - draft.anchor.y(); + const double angle = std::round(std::atan2(dy, dx) / (std::numbers::pi / 4)) * (std::numbers::pi / 4); + const double length = std::hypot(dx, dy); + const QPointF snapped(draft.anchor.x() + std::cos(angle) * length, draft.anchor.y() + std::sin(angle) * length); + draft.end = snapped; + draft.rect = DragBox::rect(draft.anchor, snapped, false, fromCenter); + } else { + if (draft.kind == ShapeKind::line) + draft.end = point; + draft.rect = DragBox::rect(draft.anchor, point, square, fromCenter); + } + notify(); +} + +void EditorSession::cancelShape() +{ + if (!m_shapeDraft) + return; + m_shapeDraft = std::nullopt; + notify(); +} + +void EditorSession::toggleShapeKind() +{ + cancelShape(); + setShapeKind(allShapeKinds[(size_t(m_shapeKind) + 1) % allShapeKinds.size()]); +} + +void EditorSession::finishShape() +{ + if (!m_shapeDraft) + return; + const ShapeDraft draft = *std::exchange(m_shapeDraft, std::nullopt); + notify(); + QRectF rect = draft.rect; + const double thickness = m_shapeLineWidth; + // A line's box holds its two points and its thickness. + std::optional<std::pair<QPointF, QPointF>> ends; + if (draft.kind == ShapeKind::line) { + const QPointF from = draft.anchor, to = draft.end.value_or(draft.anchor); + rect = QRectF(std::min(from.x(), to.x()), std::min(from.y(), to.y()), std::abs(to.x() - from.x()), std::abs(to.y() - from.y())) + .adjusted(-thickness / 2, -thickness / 2, thickness / 2, thickness / 2); + ends = std::pair(from, to); + } + if (!canEditLayers() || !m_document || rect.width() < 1 || rect.height() < 1) + return; + // Swift's Int(): each side truncated before multiplying. + if (std::trunc(rect.width()) * std::trunc(rect.height()) > double(maxShapePixels)) { + setBrushError(QStringLiteral("That shape is too large. A shape can cover up to 100 megapixels.")); + return; + } + // The ends as fractions of the box, for resizing. + const auto unit = [&rect](QPointF point) { return QPointF((point.x() - rect.left()) / rect.width(), (point.y() - rect.top()) / rect.height()); }; + const std::optional<QPointF> start = ends ? std::optional(unit(ends->first)) : std::nullopt; + const std::optional<QPointF> finish = ends ? std::optional(unit(ends->second)) : std::nullopt; + const PaletteColor color = foregroundColor(); + try { + const QImage image = shapeImage(draft.kind, rect.size(), color, draft.cornerRadius, thickness, start, finish); + const LayerShapeStyle style{.kind = draft.kind, .red = color.red, .green = color.green, .blue = color.blue, + .cornerRadius = draft.cornerRadius, + .lineWidth = draft.kind == ShapeKind::line ? std::optional(thickness) : std::nullopt, + .start = start, .end = finish}; + addPixelLayer(image, rect.topLeft(), nextShapeName(draft.kind), rawValue(draft.kind), false, style); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +QString EditorSession::nextShapeName(ShapeKind kind) const +{ + QSet<QString> names; + if (m_document) { + for (const ImageLayer &layer : m_document->layers) + names.insert(layer.name); + } + int number = 1; + while (names.contains(QStringLiteral("%1 %2").arg(rawValue(kind)).arg(number))) + ++number; + return QStringLiteral("%1 %2").arg(rawValue(kind)).arg(number); +} + +void EditorSession::redrawShape(int index) +{ + ImageLayer &layer = m_document->layers[size_t(index)]; + const std::optional<LayerShape> shape = layer.liveShape(); + if (!shape) + return; + // A valid transform is a pixel wide at least. + const int width = int(std::round(layer.transform.size.width())), height = int(std::round(layer.transform.size.height())); + if ((width == layer.asset->size().width() && height == layer.asset->size().height()) || qint64(width) * height > maxShapePixels) + return; + QImage image, thumbnail; + try { + image = shapeImage(shape->style.kind, QSizeF(width, height), shape->style.color(), shape->style.cornerRadius, + shape->style.lineWidth.value_or(0), shape->style.start, shape->style.end); + thumbnail = PixelAdjust::thumbnail(image); + } catch (const ExportError &error) { + // Swift's try? keeps the stretched pixels without a word. + qCWarning(lcApp) << "a shape could not be drawn at its new size:" << error.what(); + return; + } + // The mask stays where it shows while the grid changes. + if (layer.mask) + layer.mask->placement = layer.maskTransform(); + layer.asset = ImportedImage(image, thumbnail, layer.asset->name); + layer.shape = LayerShape{shape->style, layer.asset->identity()}; +} + +QImage EditorSession::shapeTransformPreview(const ImageLayer &layer, const LayerTransform &transform) const +{ + const std::optional<LayerShape> shape = layer.liveShape(); + if (!m_transformEdit || !shape || shape->style.kind != ShapeKind::rectangle || shape->style.cornerRadius <= 0) { + if (!m_shapeTransformPreviewCache.empty() && !m_transformEdit) + m_shapeTransformPreviewCache.clear(); + return QImage(); + } + const QSizeF size = transform.size; + const QSize pixels = layer.asset->size(); + if (size.width() < 1 || size.height() < 1 + || (std::abs(size.width() - pixels.width()) < 0.5 && std::abs(size.height() - pixels.height()) < 0.5)) + return QImage(); + const double factor = std::min(1.0, 2048 / std::max(size.width(), size.height())); + const QSizeF drawn(std::max(1.0, std::round(size.width() * factor)), std::max(1.0, std::round(size.height() * factor))); + if (m_shapeTransformPreviewCache.contains(layer.id) && m_shapeTransformPreviewCache.at(layer.id).size == drawn) + return m_shapeTransformPreviewCache.at(layer.id).image; + QImage image; + try { + image = shapeImage(ShapeKind::rectangle, drawn, shape->style.color(), shape->style.cornerRadius * factor); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "no rounded preview for a resized shape:" << error.what(); + return QImage(); + } + m_shapeTransformPreviewCache.insert_or_assign(layer.id, ShapePreview{drawn, image}); + return image; +} + +QImage EditorSession::shapeImage(ShapeKind kind, QSizeF size, const PaletteColor &color, double cornerRadius, double lineWidth, + std::optional<QPointF> start, std::optional<QPointF> end) +{ + QImage context = BrushRaster::context(int(size.width()), int(size.height()), false); + const QRectF bounds(QPointF(0, 0), size); + QPainter painter(&context); + painter.setRenderHint(QPainter::Antialiasing, true); + if (kind != ShapeKind::line) { + painter.fillPath(path(kind, bounds, cornerRadius), color.color()); + painter.end(); + return context; + } + const double thickness = std::max(1.0, lineWidth); + // Lines saved without ends ran corner to corner, inset. + const double dx = std::min(thickness, size.width()) / 2, dy = std::min(thickness, size.height()) / 2; + const QRectF inset = bounds.adjusted(dx, dy, -dx, -dy); + const QPointF from = start ? QPointF(start->x() * size.width(), start->y() * size.height()) : inset.topLeft(); + const QPointF to = end ? QPointF(end->x() * size.width(), end->y() * size.height()) : inset.bottomRight(); + // An outline, not a pen: Qt thins a one-pixel pen. + painter.fillPath(strokedLine(from, to, thickness), color.color()); + painter.end(); + return context; +} diff --git a/src/Document/ShapeTool.h b/src/Document/ShapeTool.h new file mode 100644 index 0000000..fef9fc9 --- /dev/null +++ b/src/Document/ShapeTool.h @@ -0,0 +1,60 @@ +#pragma once +#include "Document/ColorPalette.h" +#include "IO/ImageImporter.h" +#include <QImage> +#include <QPainterPath> +#include <QRectF> +#include <array> +#include <optional> + +// The Shape tool's shapes: lines stroked, the rest filled. +enum class ShapeKind { rectangle, ellipse, line }; +inline constexpr std::array allShapeKinds{ShapeKind::rectangle, ShapeKind::ellipse, ShapeKind::line}; +QString rawValue(ShapeKind kind); +std::optional<ShapeKind> shapeKind(const QString &text); +// The shape filling `rect`; a big radius makes a pill. +QPainterPath path(ShapeKind kind, const QRectF &rect, double cornerRadius = 0); +// A round-capped line's outline, as CoreGraphics strokes it. +QPainterPath strokedLine(QPointF from, QPointF to, double width); + +// What a shape layer draws, kept for drawing it resized. +struct LayerShapeStyle { + ShapeKind kind; + double red; + double green; + double blue; + // Document pixels, whatever size the shape is scaled to. + double cornerRadius; + // A line's thickness and ends, as fractions of its box. + std::optional<double> lineWidth = std::nullopt; + std::optional<QPointF> start = std::nullopt; + std::optional<QPointF> end = std::nullopt; + PaletteColor color() const { return {red, green, blue}; } + friend bool operator==(const LayerShapeStyle &, const LayerShapeStyle &) = default; +}; + +// A shape layer: its style and the image it drew. +struct LayerShape { + LayerShapeStyle style; + ImageIdentity image; + static std::optional<LayerShape> loaded(const std::optional<LayerShapeStyle> &style, const std::optional<ImportedImage> &image); + friend bool operator==(const LayerShape &, const LayerShape &) = default; +}; + +// A shape being dragged out, in document pixels. +struct ShapeDraft { + ShapeKind kind; + QPointF anchor; + QRectF rect; + // Where a line is dragged to; its ends stay put. + std::optional<QPointF> end = std::nullopt; + // Rectangles only, fixed as the drag starts. + double cornerRadius = 0; + friend bool operator==(const ShapeDraft &, const ShapeDraft &) = default; +}; + +// Swift's tuple: a rounded rectangle drawn at its dragged size. +struct ShapePreview { + QSizeF size; + QImage image; +}; diff --git a/src/Document/SmudgeLiquify+Session.h b/src/Document/SmudgeLiquify+Session.h new file mode 100644 index 0000000..69868da --- /dev/null +++ b/src/Document/SmudgeLiquify+Session.h @@ -0,0 +1,5 @@ +// Swift's SmudgeLiquify extension: a warp from press to commit. +public: + void beginWarp(QPointF point); + // The result painted into the layer along the stroke. + void finishWarp(); diff --git a/src/Document/SmudgeLiquify.cpp b/src/Document/SmudgeLiquify.cpp new file mode 100644 index 0000000..73f534e --- /dev/null +++ b/src/Document/SmudgeLiquify.cpp @@ -0,0 +1,254 @@ +#include "Document/SmudgeLiquify.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/LayerRenderer.h" +#include <algorithm> +#include <cmath> +#include <stdexcept> + +QString rawValue(BrushToolMode mode) +{ + switch (mode) { + case BrushToolMode::paint: + return QStringLiteral("Paint"); + case BrushToolMode::erase: + return QStringLiteral("Erase"); + } + throw std::logic_error("unknown brush mode"); +} + +QString rawValue(BlurToolMode mode) +{ + switch (mode) { + case BlurToolMode::liquify: + return QStringLiteral("Liquify"); + case BlurToolMode::blur: + return QStringLiteral("Blur"); + case BlurToolMode::smudge: + return QStringLiteral("Smudge"); + } + throw std::logic_error("unknown smear mode"); +} + +WarpStroke::WarpStroke(const ImageLayer &layer, const QImage &image, const LayerTransform &transform, QSizeF canvas, BlurToolMode mode, + const BrushSettings &settings) + : layer(layer), mode(mode), diameter(std::max(2.0, settings.diameter)), hardness(std::min(0.98, std::max(0.0, settings.hardness))), + strength(std::min(1.0, std::max(0.01, settings.opacity))), width(int(canvas.width())), height(int(canvas.height())), + m_context(BrushRaster::context(width, height, false)) +{ + QPainter painter(&m_context); + LayerRenderer::draw(image, transform, transform.center(), painter, {}); +} + +int WarpStroke::radius() const +{ + return int(std::ceil(diameter / 2)); +} + +float WarpStroke::weight(float u) const +{ + // Swift's positive guards: a number that is none moves nothing. + if (!(u < 1)) + return 0; + const float h = float(hardness); + if (!(u > h)) + return 1; + const float t = (1 - u) / (1 - h); + return t * t * (3 - 2 * t); +} + +void WarpStroke::append(QPointF point) +{ + if (!m_last) { + m_last = point; + if (mode == BlurToolMode::smudge) + pickUp(point); + return; + } + const QPointF from = *m_last; + const double distance = std::hypot(point.x() - from.x(), point.y() - from.y()); + const double spacing = std::max(1.0, diameter * (mode == BlurToolMode::smudge ? 0.08 : 0.025)); + if (distance < spacing) + return; + const int steps = int(std::ceil(distance / spacing)); + QPointF previous = from; + for (int step = 1; step <= steps; ++step) { + const double t = double(step) / double(steps); + const QPointF next(from.x() + (point.x() - from.x()) * t, from.y() + (point.y() - from.y()) * t); + if (mode == BlurToolMode::smudge) + smudge(next); + else + push(previous, next); + m_points.push_back(next); + previous = next; + } + m_last = point; +} + +void WarpStroke::pickUp(QPointF center) +{ + const int r = radius(), side = 2 * r + 1; + m_carried.assign(size_t(side) * size_t(side) * 4, 0); + const int cx = int(std::round(center.x())), cy = int(std::round(center.y())); + const uchar *pixels = m_context.constBits(); + const qsizetype stride = m_context.bytesPerLine(); + for (int dy = -r; dy <= r; ++dy) { + const int y = cy + dy; + if (y < 0 || y >= height) + continue; + for (int dx = -r; dx <= r; ++dx) { + const int x = cx + dx; + if (x < 0 || x >= width) + continue; + const uchar *p = pixels + y * stride + x * 4; + float *c = m_carried.data() + (size_t(dy + r) * size_t(side) + size_t(dx + r)) * 4; + for (int k = 0; k < 4; ++k) + c[k] = float(p[k]); + } + } +} + +void WarpStroke::smudge(QPointF center) +{ + const int r = radius(), side = 2 * r + 1; + const int cx = int(std::round(center.x())), cy = int(std::round(center.y())); + const float keep = float(strength), invR = 1 / float(diameter / 2); + uchar *pixels = m_context.bits(); + const qsizetype stride = m_context.bytesPerLine(); + for (int dy = -r; dy <= r; ++dy) { + const int y = cy + dy; + if (y < 0 || y >= height) + continue; + for (int dx = -r; dx <= r; ++dx) { + const int x = cx + dx; + if (x < 0 || x >= width) + continue; + const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR); + if (!(w > 0)) + continue; + uchar *p = pixels + y * stride + x * 4; + float *c = m_carried.data() + (size_t(dy + r) * size_t(side) + size_t(dx + r)) * 4; + for (int k = 0; k < 4; ++k) { + const float under = float(p[k]); + const float painted = under + (c[k] - under) * w; + p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(painted)))); + // The brush picks up what it left; weaker, more so. + c[k] = painted + (c[k] - painted) * keep; + } + } + } +} + +void WarpStroke::push(QPointF a, QPointF b) +{ + const int r = radius(); + const float moveX = float(b.x() - a.x()) * float(strength), moveY = float(b.y() - a.y()) * float(strength); + const int margin = int(std::ceil(std::max(std::abs(moveX), std::abs(moveY)))) + 2; + const int cx = int(std::round(b.x())), cy = int(std::round(b.y())); + // A copy of the area before this dab, sampled. + const int x0 = std::max(0, cx - r - margin), x1 = std::min(width - 1, cx + r + margin); + const int y0 = std::max(0, cy - r - margin), y1 = std::min(height - 1, cy + r + margin); + if (x0 > x1 || y0 > y1) + return; + const int cw = x1 - x0 + 1, ch = y1 - y0 + 1; + if (m_scratch.size() < size_t(cw) * size_t(ch) * 4) + m_scratch.assign(size_t(cw) * size_t(ch) * 4, 0); + uchar *pixels = m_context.bits(); + const qsizetype stride = m_context.bytesPerLine(); + for (int y = 0; y < ch; ++y) { + const uchar *row = pixels + (y + y0) * stride + x0 * 4; + std::copy(row, row + size_t(cw) * 4, m_scratch.begin() + std::ptrdiff_t(size_t(y) * size_t(cw) * 4)); + } + const float invR = 1 / float(diameter / 2); + const float *s = m_scratch.data(); + for (int dy = -r; dy <= r; ++dy) { + const int y = cy + dy; + if (y < y0 || y > y1) + continue; + for (int dx = -r; dx <= r; ++dx) { + const int x = cx + dx; + if (x < x0 || x > x1) + continue; + const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR); + if (!(w > 0)) + continue; + // Bilinear from the old pixels, behind the brush's travel. + const float sx = std::min(float(cw - 1), std::max(0.0f, float(x - x0) - moveX * w)); + const float sy = std::min(float(ch - 1), std::max(0.0f, float(y - y0) - moveY * w)); + const int ix = std::min(cw - 2, int(sx)), iy = std::min(ch - 2, int(sy)); + if (ix < 0 || iy < 0) + continue; + const float fx = sx - float(ix), fy = sy - float(iy); + uchar *p = pixels + y * stride + x * 4; + const size_t s00 = (size_t(iy) * size_t(cw) + size_t(ix)) * 4, s10 = s00 + 4, s01 = s00 + size_t(cw) * 4, s11 = s01 + 4; + for (int k = 0; k < 4; ++k) { + const float top = s[s00 + k] + (s[s10 + k] - s[s00 + k]) * fx; + const float bottom = s[s01 + k] + (s[s11 + k] - s[s01 + k]) * fx; + p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(top + (bottom - top) * fy)))); + } + } + } +} + +// Swift's EditorSession extension: a warp from press to commit. +void EditorSession::beginWarp(QPointF point) +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!canPaint() || m_isMaskSelected || !layer || !layer->asset || !m_document) { + if (m_isMaskSelected) + setBrushError(QStringLiteral("Smudge and Liquify work on a layer's pixels, not its mask.")); + return; + } + finishOpacityEdit(); + try { + // A painted asset flattens here; the catch covers it too. + auto stroke = std::make_unique<WarpStroke>(*layer, layer->asset->image(), displayedTransform(*layer), m_document->size(), m_blurMode, + m_brushSettings); + stroke->append(point); + m_warpStroke = std::move(stroke); + m_lastBrushPoint = LastBrushPoint{point, layer->id, false}; + ++m_brushRevision; + resumeFileRequests(); + notify(); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } +} + +// The result painted along the stroke, one step. +void EditorSession::finishWarp() +{ + if (!m_warpStroke) + return; + const std::unique_ptr<WarpStroke> warp = std::move(m_warpStroke); + ++m_brushRevision; + resumeFileRequests(); + const int index = m_document ? indexOf(m_document->layers, warp->layer.id) : -1; + if (!warp->points().empty() && index >= 0) { + const ImageLayer current = m_document->layers[index]; + if (current.asset && current.asset->identity() == warp->layer.asset.value().identity() && current.transform == warp->layer.transform) { + try { + BrushSettings settings = m_brushSettings; + // A slightly wider hard tip covers all that moved. + settings.diameter = warp->diameter + 4; + settings.hardness = 1; + settings.opacity = 1; + const std::unique_ptr<BrushStroke> stroke = makeRasterEdit(current, settings); + stroke->clone = BrushStroke::Clone{warp->image(), QSizeF(0, 0)}; + stroke->replacesWithClone = true; + stroke->editName = rawValue(warp->mode); + for (const QPointF step : warp->points()) + stroke->append(step); + stroke->flush(); + if (!stroke->patches().empty()) + commitPaintSnapshot(*stroke); + } catch (const ProjectError &error) { + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + setBrushError(QString::fromUtf8(error.what())); + } + } + } + notify(); +} diff --git a/src/Document/SmudgeLiquify.h b/src/Document/SmudgeLiquify.h new file mode 100644 index 0000000..b1b6b92 --- /dev/null +++ b/src/Document/SmudgeLiquify.h @@ -0,0 +1,53 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/EditorSession+Model.h" +#include <QImage> +#include <array> +#include <optional> +#include <vector> + +// The Brush's two modes. +enum class BrushToolMode { paint, erase }; +QString rawValue(BrushToolMode mode); + +// The Smear's modes: Liquify and Smudge push pixels, Blur softens. +enum class BlurToolMode { liquify, blur, smudge }; +inline constexpr std::array allBlurToolModes{BlurToolMode::liquify, BlurToolMode::blur, BlurToolMode::smudge}; +QString rawValue(BlurToolMode mode); + +// Swift's WarpStroke: Smudge or Liquify on a document-sized copy. +class WarpStroke { +public: + WarpStroke(const ImageLayer &layer, const QImage &image, const LayerTransform &transform, QSizeF canvas, BlurToolMode mode, + const BrushSettings &settings); + + const ImageLayer layer; + const BlurToolMode mode; + const double diameter; + const double hardness; + const double strength; + const int width; + const int height; + // Every dab's centre, for painting the result into the layer. + const std::vector<QPointF> &points() const { return m_points; } + // The layer as the stroke has reshaped it so far. + const QImage &image() const { return m_context; } + // Dabs along the way to `point`. + void append(QPointF point); + +private: + int radius() const; + // How far a dab moves pixels: 0 centre, 1 rim. + float weight(float u) const; + void pickUp(QPointF center); + void smudge(QPointF center); + // Forward warp: pixels follow the brush, fading to the rim. + void push(QPointF from, QPointF to); + + QImage m_context; + std::vector<QPointF> m_points; + std::optional<QPointF> m_last; + // Smudge: the colour the brush carries, a (2r+1)² RGBA square. + std::vector<float> m_carried; + std::vector<float> m_scratch; +}; diff --git a/src/Document/SubjectRemoval.cpp b/src/Document/SubjectRemoval.cpp new file mode 100644 index 0000000..a2cb6b2 --- /dev/null +++ b/src/Document/SubjectRemoval.cpp @@ -0,0 +1,238 @@ +#include "Document/SubjectRemoval.h" +#include "Document/GuidedMatte.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QMutex> +#include <QStandardPaths> +#include <algorithm> +#include <array> +#include <cmath> +#include <limits> +#include <new> +#include <onnxruntime_cxx_api.h> + +namespace { +QString message(SubjectRemovalError::Kind kind, const QString &detail) +{ + switch (kind) { + case SubjectRemovalError::Kind::noSubject: + return QStringLiteral("No foreground subject was detected in this layer. Try an image with a more distinct subject."); + case SubjectRemovalError::Kind::model: return QStringLiteral("Remove Background could not use its model: ") + detail; + } + throw std::logic_error("unknown subject removal failure"); +} +} + +SubjectRemovalError::SubjectRemovalError(Kind kind, const QString &detail) : std::runtime_error(message(kind, detail).toStdString()), kind(kind) +{ +} + +namespace { +using SubjectRemoval::side; + +QImage checked(const QImage &image) +{ + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + return image; +} + +QString modelPath() +{ + const QString name = QStringLiteral("omaphoto/u2net.onnx"); + const QString path = QStandardPaths::locate(QStandardPaths::GenericDataLocation, name); + if (path.isEmpty()) { + const QString folders = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation).join(QStringLiteral(", ")); + qCWarning(lcApp) << "Remove Background finds no model in" << folders; + throw SubjectRemovalError(SubjectRemovalError::Kind::model, name + QStringLiteral(" is in no data folder (") + folders + QStringLiteral(")")); + } + qCInfo(lcApp) << "Remove Background loads its model from" << path; + return path; +} + +// The session and its names, loaded once for the process. +struct Model { + Ort::Env environment{ORT_LOGGING_LEVEL_WARNING, "omaphoto"}; + Ort::Session session; + std::string input; + std::string output; + explicit Model(const QString &path) : session(environment, path.toLocal8Bit().constData(), Ort::SessionOptions()) + { + Ort::AllocatorWithDefaultOptions allocator; + input = session.GetInputNameAllocated(0, allocator).get(); + output = session.GetOutputNameAllocated(0, allocator).get(); + } +}; + +// A failed load stays unset; the next use tries again. +Model &model() +{ + static Model loaded(modelPath()); + return loaded; +} + +// U²-Net's mask at the image's size, white over the subject. +QImage infer(const QImage &image) +{ + std::vector<float> input = SubjectRemoval::modelInput(image); + Model &loaded = model(); + const std::array<int64_t, 4> dimensions{1, 3, side, side}; + const Ort::MemoryInfo memory = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); + const Ort::Value tensor = Ort::Value::CreateTensor<float>(memory, input.data(), input.size(), dimensions.data(), dimensions.size()); + const char *inputs[] = {loaded.input.c_str()}, *outputs[] = {loaded.output.c_str()}; + const std::vector<Ort::Value> result = loaded.session.Run(Ort::RunOptions{nullptr}, inputs, &tensor, 1, outputs, 1); + return SubjectRemoval::modelMask(result.front().GetTensorData<float>(), image.size()); +} + +// Swift's MaskCache: the last image's mask, by identity. +class MaskCache { +public: + QImage mask(const QImage &image) + { + { + const QMutexLocker lock(&m_lock); + if (m_key == image.cacheKey()) + return m_value; + } + const QImage made = infer(image); + const QMutexLocker lock(&m_lock); + m_key = image.cacheKey(); + m_value = made; + return made; + } + +private: + QMutex m_lock; + std::optional<qint64> m_key; + QImage m_value; +}; + +MaskCache cache; + +// The model's mask; slider moves only redo the refining. +QImage vision(const QImage &image) +{ + try { + return cache.mask(image); + } catch (const Ort::Exception &error) { + qCWarning(lcApp) << "Remove Background's model failed:" << error.what(); + throw SubjectRemovalError(SubjectRemovalError::Kind::model, QString::fromUtf8(error.what())); + } catch (const std::bad_alloc &) { + // Its buffers, or ONNX Runtime's wrapper, found no memory. + throw ExportError(ExportError::Kind::render); + } +} + +// Advanced refines, shifts the edge, then clears the haze. +QImage refined(const QImage &mask, const QImage &guide, const FilterSettings &settings, double limit) +{ + // Basic is the model's mask as it comes: quick. + if (settings.backgroundQuality != BackgroundQuality::advanced) + return mask; + QImage image = settings.refineEdges > 0 ? GuidedMatte::refine(mask, guide, settings.refineEdges, limit) : mask; + std::optional<float> level; + if (settings.shiftEdge != 0) { + // A blur, then a hard cut, moves the edge. + image = PixelAdjust::gaussianBlur(image, std::abs(settings.shiftEdge) / 2, true); + level = settings.shiftEdge < 0 ? 0.75f : 0.25f; + } + // 0 leaves the mask; 100 cuts hard at the middle. + const float slope = 1 / std::max(0.02f, 1 - float(settings.matteContrast / 100) * 0.98f); + QImage result = checked(QImage(image.size(), QImage::Format_Grayscale8)); + for (int y = 0; y < image.height(); ++y) { + const uchar *in = image.constScanLine(y); + uchar *out = result.scanLine(y); + for (int x = 0; x < image.width(); ++x) { + float value = float(in[x]) / 255; + // Swift's clamp first is the last one's: Contrast only steepens. + if (level) + value = (value - *level) * 1000; + value = slope * value + (1 - slope) / 2; + out[x] = uchar(std::lround(std::clamp(value, 0.0f, 1.0f) * 255)); + } + } + return result; +} +} + +std::vector<float> SubjectRemoval::modelInput(const QImage &image) +{ + // Scaled premultiplied, so clear pixels read as black. + const QImage small = checked(image.scaled(side, side, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_RGBA8888_Premultiplied)); + uchar brightest = 0; + for (int y = 0; y < side; ++y) { + const uchar *row = small.constScanLine(y); + for (int x = 0; x < side; ++x) + brightest = std::max({brightest, row[x * 4], row[x * 4 + 1], row[x * 4 + 2]}); + } + const float peak = std::max(float(brightest), 1e-6f); + constexpr std::array<float, 3> mean{0.485f, 0.456f, 0.406f}, spread{0.229f, 0.224f, 0.225f}; + std::vector<float> input(size_t(3) * side * side); + for (int y = 0; y < side; ++y) { + const uchar *row = small.constScanLine(y); + for (int x = 0; x < side; ++x) { + for (size_t channel = 0; channel < 3; ++channel) + input[channel * side * side + size_t(y) * side + size_t(x)] = (float(row[x * 4 + int(channel)]) / peak - mean[channel]) / spread[channel]; + } + } + return input; +} + +QImage SubjectRemoval::modelMask(const float *prediction, QSize size) +{ + const auto [low, high] = std::minmax_element(prediction, prediction + side * side); + // Nothing the model takes for a subject: Swift's no instances. + if (!(*high >= 0.5f)) + throw SubjectRemovalError(SubjectRemovalError::Kind::noSubject); + // Stretched from low to high, truncated, as rembg does. + const float range = std::max(*high - *low, std::numeric_limits<float>::epsilon()); + QImage mask = checked(QImage(side, side, QImage::Format_Grayscale8)); + for (int y = 0; y < side; ++y) { + uchar *row = mask.scanLine(y); + for (int x = 0; x < side; ++x) + row[x] = uchar((prediction[size_t(y) * side + size_t(x)] - *low) / range * 255); + } + // Premultiplied, a smaller grid is area-filtered, as in `levels`. + return checked(checked(mask.convertToFormat(QImage::Format_RGBA8888_Premultiplied)) + .scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation) + .convertToFormat(QImage::Format_Grayscale8)); +} + +QImage SubjectRemoval::subjectMask(const QImage &image, const std::optional<QImage> &existing, const FilterSettings &settings) +{ + if (existing && existing->size() != image.size()) + throw std::logic_error("a mask to keep must share the layer's grid"); + const QImage subject = refined(vision(image), image, settings, std::numeric_limits<double>::max()); + if (!existing) + return subject; + // Both masks hide: what either one hides stays hidden. + const QImage base = checked(existing->convertToFormat(QImage::Format_Grayscale8)); + QImage combined = checked(QImage(subject.size(), QImage::Format_Grayscale8)); + for (int y = 0; y < subject.height(); ++y) { + const uchar *top = subject.constScanLine(y), *under = base.constScanLine(y); + uchar *out = combined.scanLine(y); + for (int x = 0; x < subject.width(); ++x) + out[x] = uchar((int(top[x]) * under[x] + 127) / 255); + } + return combined; +} + +QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings) +{ + // The preview refines a copy 1400 long at most: quick. + const QImage mask = refined(vision(image), image, settings, 1400); + // Swift's blend over clear: each premultiplied byte times the mask. + QImage result = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + // Its own pixels now, where a failed copy throws. + result.detach(); + if (result.isNull()) + throw ExportError(ExportError::Kind::render); + for (int y = 0; y < result.height(); ++y) { + const uchar *coverage = mask.constScanLine(y); + uchar *row = result.scanLine(y); + for (int x = 0; x < result.width() * 4; ++x) + row[x] = uchar((int(row[x]) * coverage[x / 4] + 127) / 255); + } + return result; +} diff --git a/src/Document/SubjectRemoval.h b/src/Document/SubjectRemoval.h new file mode 100644 index 0000000..3ef4d9d --- /dev/null +++ b/src/Document/SubjectRemoval.h @@ -0,0 +1,28 @@ +#pragma once +#include "Document/Filters.h" +#include <QImage> +#include <optional> +#include <stdexcept> +#include <vector> + +// Swift's SubjectRemoval.Failure; the model's own failure besides. +class SubjectRemovalError : public std::runtime_error { +public: + enum class Kind { noSubject, model }; + explicit SubjectRemovalError(Kind kind, const QString &detail = QString()); + const Kind kind; +}; + +// Swift's SubjectRemoval: U²-Net's subject mask, refined as asked. +namespace SubjectRemoval { +// U²-Net reads a square this size, as rembg does. +inline constexpr int side = 320; +// Premultiplied, square, by the brightest byte, then ImageNet's spread. +std::vector<float> modelInput(const QImage &image); +// The answer stretched to bytes at `size`; none past half. +QImage modelMask(const float *prediction, QSize size); +// White over the subject in the layer's grid, times `existing`. +QImage subjectMask(const QImage &image, const std::optional<QImage> &existing, const FilterSettings &settings); +// The preview: the layer with its background cleared. +QImage run(const QImage &image, const FilterSettings &settings); +} diff --git a/src/Document/TypeTool+Session.h b/src/Document/TypeTool+Session.h new file mode 100644 index 0000000..5199eb3 --- /dev/null +++ b/src/Document/TypeTool+Session.h @@ -0,0 +1,24 @@ +// Swift's TypeTool extension: text typed onto the canvas. +public: + const std::optional<TextDraft> &textDraft() const { return m_textDraft; } + // Swift writes the draft freely; waiting file requests look again. + void setTextDraft(std::optional<TextDraft> draft); + const LayerTextStyle &textDefaults() const { return m_textDefaults; } + // A click: the text layer under it, or point text. + void beginText(QPointF point, bool newLayer = false); + void editActiveText(); + // By value: finishing passes the very draft this clears. + bool applyText(TextDraft draft); + bool finishText(); + void cancelText(); + // A dragged box: a paragraph that wraps inside it. + void beginText(const QRectF &rect); + // Live text repainted in `color`, one step. + bool recolorText(QUuid id, const PaletteColor &color); + LayerTextStyle currentTextStyle() const; + void changeTextStyle(const std::function<void(LayerTextStyle &)> &change); + // A text layer's name: its first words on one line. + static QString layerName(const QString &content); + // Point text's size: what it measures, plus padding. + static QSizeF textBoxSize(const LayerTextStyle &style); + static QImage textImage(const LayerTextStyle &style); diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp new file mode 100644 index 0000000..7ae0a3d --- /dev/null +++ b/src/Document/TypeTool.cpp @@ -0,0 +1,307 @@ +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/TextLayout.h" +#include <QPainter> +#include <QTextBoundaryFinder> +#include <cmath> +#include <stdexcept> + +QString rawValue(TextAlignment alignment) +{ + switch (alignment) { + case TextAlignment::left: + return QStringLiteral("Left"); + case TextAlignment::center: + return QStringLiteral("Center"); + case TextAlignment::right: + return QStringLiteral("Right"); + } + throw std::logic_error("unknown text alignment"); +} + +std::optional<TextAlignment> textAlignment(const QString &text) +{ + for (const TextAlignment alignment : allTextAlignments) { + if (rawValue(alignment) == text) + return alignment; + } + return std::nullopt; +} + +bool LayerTextStyle::boxIsValid() const +{ + if (!boxSize) + return true; + const double width = boxSize->width(), height = boxSize->height(); + return width >= 16 && width <= 30'000 && height >= 16 && height <= 30'000 && width * height <= 100'000'000; +} + +bool LayerTextStyle::isValid() const +{ + // Ranges refuse NaN and infinities: Swift's isFinite adds nothing. + const auto within = [](double value, double low, double high) { return value >= low && value <= high; }; + return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1) + && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000); +} + +std::optional<LayerText> LayerText::loaded(const std::optional<LayerTextStyle> &style, const std::optional<ImportedImage> &image) +{ + if (!style || !style->isValid() || !image) + return std::nullopt; + return LayerText{*style, image->identity()}; +} + +std::optional<LayerText> ImageLayer::liveText() const +{ + if (!text || !asset || asset->identity() != text->image) + return std::nullopt; + return text; +} + +void EditorSession::setTextDraft(std::optional<TextDraft> draft) +{ + m_textDraft = std::move(draft); + resumeFileRequests(); + notify(); +} + +void EditorSession::beginText(QPointF point, bool newLayer) +{ + if (!canEditLayers() || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return; + // The topmost visible text under the point, if wanted. + std::optional<ImageLayer> target; + const QSet<QUuid> visible = m_document->effectiveVisibleIDs(); + for (auto layer = m_document->layers.rbegin(); !newLayer && !target && layer != m_document->layers.rend(); ++layer) { + if (visible.contains(layer->id) && layer->liveText() && layer->transform.contains(point)) + target = *layer; + } + if (target) + selectLayer(target->id); + LayerTextStyle style = target ? target->liveText()->style : m_textDefaults; + if (!target) { + style.content = QString(); + // New text takes the foreground colour, as painting does. + if (!m_isMaskSelected) { + style.red = foregroundColor().red; + style.green = foregroundColor().green; + style.blue = foregroundColor().blue; + } + // A click makes point text: no box of its own. + style.boxSize = std::nullopt; + } + m_tool = NavigationTool::type; + setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt, + .origin = target ? target->origin() : point, + .transform = target ? std::optional(target->transform) : std::nullopt, .style = style}); +} + +void EditorSession::editActiveText() +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!canEditLayers() || !layer || !layer->liveText()) + return; + m_tool = NavigationTool::type; + setTextDraft(TextDraft{.documentID = m_document->id, .layerID = layer->id, .origin = layer->origin(), .transform = layer->transform, + .style = layer->liveText()->style}); +} + +bool EditorSession::applyText(TextDraft draft) +{ + if (!m_document || m_document->id != draft.documentID || !draft.style.isValid()) + return false; + // Cleared first, as the gate reads it; restored if refused. + const std::optional<TextDraft> pending = std::exchange(m_textDraft, std::nullopt); + const auto refuse = [&] { + m_textDraft = pending; + return false; + }; + if (!canEditLayers()) + return refuse(); + const auto succeed = [&] { + resumeFileRequests(); + notify(); + return true; + }; + if (!draft.layerID && draft.style.content.trimmed().isEmpty()) + return succeed(); + try { + const QImage image = textImage(draft.style); + if (draft.layerID) { + const int index = indexOf(m_document->layers, *draft.layerID); + if (index < 0 || !m_document->layers[size_t(index)].liveText()) + return refuse(); + const ImageLayer layer = m_document->layers[size_t(index)]; + if (layer.liveText()->style == draft.style && (!draft.transform || *draft.transform == layer.transform)) + return succeed(); + const QImage thumbnail = PixelAdjust::thumbnail(image); + LayerTransform transform = draft.transform.value_or(layer.transform); + // The corner stays; so do the scale, turn and flips. + const QPointF anchor = transform.point(QPointF(0, 0)); + if (!draft.transform || !draft.style.boxSize) { + transform.size = QSizeF(image.width() * transform.size.width() / layer.asset->size().width(), + image.height() * transform.size.height() / layer.asset->size().height()); + transform.origin += anchor - transform.point(QPointF(0, 0)); + } + if (!transform.isValid()) + throw ProjectError(ProjectError::Kind::tooLarge); + beginEdit(QStringLiteral("Edit Text")); + ImageLayer &edited = m_document->layers[size_t(index)]; + if (edited.mask && !edited.mask->placement) + edited.mask->placement = layer.maskTransform(); + edited.asset = ImportedImage(image, thumbnail, layer.asset->name); + edited.text = LayerText{draft.style, edited.asset->identity()}; + edited.transform = transform; + endEdit(); + } else { + addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style); + } + m_textDefaults = draft.style; + requestCanvasFocus(); + return succeed(); + } catch (const ProjectError &error) { + // The draft is back before the error is announced. + refuse(); + setBrushError(QString::fromUtf8(error.what())); + } catch (const ExportError &error) { + refuse(); + setBrushError(QString::fromUtf8(error.what())); + } + return false; +} + +bool EditorSession::finishText() +{ + if (!m_textDraft) + return true; + return applyText(*m_textDraft); +} + +void EditorSession::cancelText() +{ + setTextDraft(std::nullopt); + requestCanvasFocus(); +} + +void EditorSession::beginText(const QRectF &rect) +{ + if (!canEditLayers() || !std::isfinite(rect.width()) || !std::isfinite(rect.height())) + return; + LayerTextStyle style = m_textDefaults; + style.boxSize = QSizeF(std::max(16.0, std::round(rect.width())), std::max(16.0, std::round(rect.height()))); + if (!style.boxIsValid()) { + setBrushError(QStringLiteral("That text box exceeds the 30,000-pixel or 100-megapixel limit.")); + return; + } + beginText(rect.topLeft(), true); + if (m_textDraft) { + TextDraft draft = *m_textDraft; + draft.style.boxSize = style.boxSize; + setTextDraft(draft); + } +} + +bool EditorSession::recolorText(QUuid id, const PaletteColor &color) +{ + if (!canEditLayers()) + return false; + const int index = indexOf(m_document->layers, id); + if (index < 0 || !m_document->layers[size_t(index)].liveText()) + return false; + const ImageLayer layer = m_document->layers[size_t(index)]; + LayerTextStyle style = layer.liveText()->style; + if (style.red == color.red && style.green == color.green && style.blue == color.blue) + return true; + style.red = color.red; + style.green = color.green; + style.blue = color.blue; + QImage image, thumbnail; + try { + image = textImage(style); + thumbnail = PixelAdjust::thumbnail(image); + } catch (const std::runtime_error &error) { + // An invalid colour too; Swift's try? fills without a word. + qCWarning(lcApp) << "text could not be repainted:" << error.what(); + return false; + } + finishOpacityEdit(); + beginEdit(QStringLiteral("Fill Text")); + ImageLayer &edited = m_document->layers[size_t(index)]; + edited.asset = ImportedImage(image, thumbnail, layer.asset->name); + edited.text = LayerText{style, edited.asset->identity()}; + endEdit(); + return true; +} + +LayerTextStyle EditorSession::currentTextStyle() const +{ + if (m_textDraft) + return m_textDraft->style; + const std::optional<ImageLayer> active = activeLayer(); + return active && active->liveText() ? active->liveText()->style : m_textDefaults; +} + +void EditorSession::changeTextStyle(const std::function<void(LayerTextStyle &)> &change) +{ + const std::optional<ImageLayer> active = activeLayer(); + if (!m_textDraft && active && active->liveText()) + editActiveText(); + if (m_textDraft) { + TextDraft draft = *m_textDraft; + change(draft.style); + if (draft.style.isValid()) + setTextDraft(draft); + return; + } + LayerTextStyle style = m_textDefaults; + change(style); + if (!style.isValid()) + return; + m_textDefaults = style; + notify(); +} + +QString EditorSession::layerName(const QString &content) +{ + // Whitespace runs become single spaces: the row stays one line. + const QString flattened = content.simplified(); + if (flattened.isEmpty()) + return QStringLiteral("Text"); + QTextBoundaryFinder characters(QTextBoundaryFinder::Grapheme, flattened); + for (int count = 0; count < 40 && characters.toNextBoundary() >= 0;) + ++count; + // Past the end the position is -1: `left` keeps everything. + return flattened.left(characters.position()); +} + +QSizeF EditorSession::textBoxSize(const LayerTextStyle &style) +{ + if (style.boxSize) + return *style.boxSize; + const TextLines lines(style, QSizeF(100'000, 100'000)); + const QSizeF measured = lines.usedSize(); + const double padding = LayerTextStyle::padding; + // A caret's width more; an empty paragraph lays a line. + return QSizeF(std::ceil(measured.width() + padding * 2 + style.fontSize * 0.1), std::ceil(measured.height() + padding * 2)); +} + +QImage EditorSession::textImage(const LayerTextStyle &style) +{ + if (!style.isValid()) + throw ProjectError(ProjectError::Kind::invalid); + const QSizeF size = textBoxSize(style); + const double width = std::ceil(size.width()), height = std::ceil(size.height()); + // A valid style measures sixteen pixels at least. + if (!(width <= 30'000 && height <= 30'000 && width * height <= 100'000'000)) + throw ProjectError(ProjectError::Kind::tooLarge); + QImage context = BrushRaster::context(int(width), int(height), false); + const double padding = LayerTextStyle::padding; + const TextLines lines(style, QSizeF(std::max(1.0, width - 2 * padding), std::max(1.0, height - 2 * padding))); + QPainter painter(&context); + painter.setPen(QColor::fromRgbF(float(style.red), float(style.green), float(style.blue))); + lines.draw(painter, QPointF(padding, padding)); + painter.end(); + return context; +} diff --git a/src/Document/TypeTool.h b/src/Document/TypeTool.h new file mode 100644 index 0000000..0857ab6 --- /dev/null +++ b/src/Document/TypeTool.h @@ -0,0 +1,56 @@ +#pragma once +#include "Document/LayerTransform.h" +#include "IO/ImageImporter.h" +#include <QSizeF> +#include <QString> +#include <QUuid> +#include <array> +#include <optional> + +enum class TextAlignment { left, center, right }; +inline constexpr std::array allTextAlignments{TextAlignment::left, TextAlignment::center, TextAlignment::right}; +QString rawValue(TextAlignment alignment); +std::optional<TextAlignment> textAlignment(const QString &text); + +// What a text layer says and how, in layer pixels. +struct LayerTextStyle { + QString content = QStringLiteral("Text"); + // A PostScript name, as macOS names faces. + QString fontName = QStringLiteral("Helvetica"); + double fontSize = 72; + double red = 0; + double green = 0; + double blue = 0; + TextAlignment alignment = TextAlignment::left; + double tracking = 0; + // Baseline to baseline; 0 is Auto, 120% of the size. + double leading = 0; + // A paragraph's fixed box; none makes point text. + std::optional<QSizeF> boxSize = std::nullopt; + // The gap between the text and its box, either kind. + static constexpr double padding = 12; + double autoLeading() const { return fontSize * 1.2; } + double lineHeight() const { return leading > 0 ? leading : autoLeading(); } + bool boxIsValid() const; + bool isValid() const; + friend bool operator==(const LayerTextStyle &, const LayerTextStyle &) = default; +}; + +// A text layer: its style and the image it drew. +struct LayerText { + LayerTextStyle style; + ImageIdentity image; + static std::optional<LayerText> loaded(const std::optional<LayerTextStyle> &style, const std::optional<ImportedImage> &image); + friend bool operator==(const LayerText &, const LayerText &) = default; +}; + +// Text being typed, before it becomes or changes a layer. +struct TextDraft { + QUuid id = QUuid::createUuid(); + QUuid documentID; + std::optional<QUuid> layerID; + QPointF origin; + std::optional<LayerTransform> transform = std::nullopt; + LayerTextStyle style; + friend bool operator==(const TextDraft &, const TextDraft &) = default; +}; diff --git a/src/IO/CanvasResizer.cpp b/src/IO/CanvasResizer.cpp new file mode 100644 index 0000000..1c1934e --- /dev/null +++ b/src/IO/CanvasResizer.cpp @@ -0,0 +1,89 @@ +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QPainter> +#include <algorithm> +#include <cmath> +#include <new> + +namespace { +ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options) +{ + if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 || options.anchor < 0 + || options.anchor > 8) + throw ProjectError(ProjectError::Kind::tooLarge); + const ProjectManifest &old = snapshot.manifest; + const QPointF offset = options.offset(old.width, old.height); + // NaN and infinity fail the comparison. + if (!(std::abs(offset.x()) <= 1'000'000 && std::abs(offset.y()) <= 1'000'000)) + throw ProjectError(ProjectError::Kind::invalid); + if (options.width == old.width && options.height == old.height && offset == QPointF(0, 0)) + return snapshot; + ProjectManifest manifest{.resolution = old.resolution, .documentID = old.documentID, .width = options.width, + .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}}; + for (const ProjectLayerRecord &layer : old.layers) { + LayerTransform transform = layer.transform; + transform.origin += offset; + if (!transform.isValid()) + throw ProjectError(ProjectError::Kind::tooLarge); + std::optional<LayerTransform> placement = layer.maskPlacement; + if (placement) + placement->origin += offset; + // Field by field, as Swift: what it omits is dropped. + manifest.layers.push_back({.id = layer.id, .name = layer.name, .isVisible = layer.isVisible, .transform = transform, + .imageFile = layer.imageFile, .parentID = layer.parentID, .isGroup = layer.isGroup, + .opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile, + .maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment, + .maskPlacement = placement, + .maskLinked = layer.maskLinked, .shape = layer.shape, + .text = layer.text}); + } + std::map<QUuid, ImportedImage> images = snapshot.images; + // The fill: a bottom layer, clear over the old canvas. + if (options.fill && (options.width > old.width || options.height > old.height)) { + qint64 used = 0; + for (const auto &[id, image] : images) + used += qint64(image.size().width()) * image.size().height(); + if (options.width * options.height > 100'000'000 - used || manifest.layers.size() >= 10'000) + throw ProjectError(ProjectError::Kind::tooLarge); + const CanvasExtensionColor &color = *options.fill; + for (const double part : {color.red, color.green, color.blue}) { + if (!(part >= 0 && part <= 1)) + throw ProjectError(ProjectError::Kind::invalid); + } + QImage image(int(options.width), int(options.height), QImage::Format_RGBA8888_Premultiplied); + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + image.fill(QColor::fromRgbF(float(color.red), float(color.green), float(color.blue))); + { + QPainter context(&image); + context.setCompositionMode(QPainter::CompositionMode_Clear); + context.fillRect(QRectF(offset, QSizeF(double(old.width), double(old.height))), Qt::transparent); + } + const double factor = std::min(1.0, 96 / double(std::max(options.width, options.height))); + const QSize small(std::max(1, int(double(options.width) * factor)), std::max(1, int(double(options.height) * factor))); + const QImage thumbnail = image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + throw ExportError(ExportError::Kind::render); + const QUuid id = QUuid::createUuid(); + const QString name = QStringLiteral("Canvas Extension"); + images.insert({id, ImportedImage(image, thumbnail, name)}); + manifest.layers.insert(manifest.layers.begin(), + {.id = id, .name = name, .isVisible = true, + .transform = {.origin = {0, 0}, .size = {double(options.width), double(options.height)}}, + .imageFile = uuidString(id) + QStringLiteral(".png")}); + } + qCInfo(lcIO) << "canvas" << old.width << "x" << old.height << "becomes" << options.width << "x" << options.height; + return {.manifest = manifest, .images = images, .masks = snapshot.masks}; +} +} + +ProjectSnapshot CanvasResizer::resize(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options) +{ + // Records and maps without memory fail as a render. + try { + return resized(snapshot, options); + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } +} diff --git a/src/IO/CanvasResizer.h b/src/IO/CanvasResizer.h new file mode 100644 index 0000000..69d2edc --- /dev/null +++ b/src/IO/CanvasResizer.h @@ -0,0 +1,8 @@ +#pragma once +#include "Document/CanvasSize.h" +#include "IO/ProjectStore.h" + +namespace CanvasResizer { +// Moves the layers; a fill becomes a bottom layer. +ProjectSnapshot resize(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options); +} diff --git a/src/IO/ImageExporter.cpp b/src/IO/ImageExporter.cpp new file mode 100644 index 0000000..fe8a1de --- /dev/null +++ b/src/IO/ImageExporter.cpp @@ -0,0 +1,173 @@ +#include "IO/ImageExporter.h" +#include "Document/LayerEffects+Renderer.h" +#include "Document/LayerGroups.h" +#include "Document/LayerMask.h" +#include "Document/LiveLayerMask.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include "Rendering/LiveMaskRenderer.h" +#include <QBuffer> +#include <QColorSpace> +#include <QImageReader> +#include <QImageWriter> +#include <QSaveFile> +#include <algorithm> +#include <cmath> + +namespace { +const char *description(ExportError::Kind kind) +{ + switch (kind) { + case ExportError::Kind::tooLarge: + return "Image export supports canvases up to 100 megapixels and 30,000 pixels per side."; + case ExportError::Kind::render: + return "The canvas could not be rendered. Try a smaller canvas."; + case ExportError::Kind::encode: + return "The image could not be encoded."; + } + throw std::logic_error("unknown ExportError kind"); +} + +// Quality runs 0 to 100; -1 means the format's own. +QByteArray encode(QImage image, const char *format, int quality, double resolution) +{ + const int dotsPerMeter = qRound(resolution / 0.0254); + image.setDotsPerMeterX(dotsPerMeter); + image.setDotsPerMeterY(dotsPerMeter); + image.setColorSpace(QColorSpace::SRgb); + QByteArray data; + QBuffer buffer(&data); + QImageWriter writer(&buffer, format); + writer.setQuality(quality); + if (!writer.write(image)) + throw ExportError(ExportError::Kind::encode); + return data; +} +} + +ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {} + +ExportRaster ImageExporter::render(const ProjectSnapshot &snapshot) +try { + const qint64 width = snapshot.manifest.width, height = snapshot.manifest.height; + if (width < 1 || width > 30'000 || height < 1 || height > 30'000 || width * height > 100'000'000) + throw ExportError(ExportError::Kind::tooLarge); + QImage image(int(width), int(height), QImage::Format_RGBA8888_Premultiplied); + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + image.fill(0); + const std::vector<ProjectLayerRecord> &layers = snapshot.manifest.layers; + std::map<QUuid, ProjectLayerRecord> records; + for (const ProjectLayerRecord &layer : layers) { + if ((layer.imageFile && !snapshot.images.contains(layer.id)) || (layer.maskFile && !snapshot.masks.contains(layer.id))) + throw ProjectError(ProjectError::Kind::missingImage); + records.insert({layer.id, layer}); + } + LiveMaskGraph::validate(layers); + const auto parent = [&](QUuid id) { return records.contains(id) ? records.at(id).parentID : std::nullopt; }; + LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id).maskSourceID : std::nullopt; }, + [&](QUuid id, QPainter &target, const QImage &clip) { + const auto asset = snapshot.images.find(id); + if (!records.contains(id) || asset == snapshot.images.end()) + return; + const ProjectLayerRecord &layer = records.at(id); + const QImage pixels = asset->second.image(); + const std::optional<LayerMask> mask = snapshot.mask(layer); + const std::optional<QImage> shown = + mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt; + const double opacity = layer.opacity.value_or(1); + const LayerBlendMode mode = layer.blendMode.value_or(LayerBlendMode::normal); + // With effects the mask is in their image already. + if (const auto effects = LayerEffectsRenderer::cached(pixels, shown, layer.effects)) { + const LayerTransform grown = LayerEffectsRenderer::placed(layer.transform, effects->image, effects->inset); + LayerRenderer::draw(effects->image, grown, grown.center(), target, {.opacity = opacity, .blendMode = mode, .clip = clip}); + return; + } + LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), target, + {.opacity = opacity, .blendMode = mode, .mask = shown.value_or(QImage()), .clip = clip}); + }); + live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; }; + live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity.value_or(1); }; + live.adjustmentClip = [&](QUuid id, const QPainter &context, QImage &coverage) { + const ProjectLayerRecord &layer = records.at(id); + const std::optional<LayerMask> mask = snapshot.mask(layer); + if (const std::optional<QImage> image = mask ? mask->enabledImage() : std::nullopt) + FolderMaskClip{*image, layer.transform}.apply(layer.transform.center(), context, coverage); + }; + std::vector<QUuid> visible; + for (const ProjectLayerRecord &layer : LayerHierarchy::visibleLayers(layers)) + visible.push_back(layer.id); + live.prepareStacks(visible, parent, [&](QUuid id) { return records.at(id).blendMode.value_or(LayerBlendMode::normal); }); + { + QPainter painter(&image); + FolderMaskClip::draw(visible, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + const std::optional<LayerMask> mask = records.contains(id) ? snapshot.mask(records.at(id)) : std::nullopt; + const std::optional<QImage> enabled = mask ? mask->enabledImage() : std::nullopt; + if (!enabled) + return std::nullopt; + const FolderMaskClip clip{*enabled, records.at(id).transform}; + return [clip](const QPainter &context, QImage &coverage) { clip.apply(clip.transform.center(), context, coverage); }; + }, painter, [&](QUuid id, const QImage &clip) { live.drawComposite(id, painter, clip); }); + } + qCInfo(lcIO) << "rendered" << width << "x" << height << "from" << visible.size() << "visible layers"; + return {image, snapshot.manifest.resolution.value_or(72)}; +} catch (const std::bad_alloc &) { + // Memory that runs out fails the render, as contexts do. + throw ExportError(ExportError::Kind::render); +} + +QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot) +{ + const ExportRaster raster = render(snapshot); + return encode(raster.image, "png", -1, raster.resolution); +} + +JPEGResult ImageExporter::jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled) +try { + const auto check = [&cancelled] { + if (cancelled && cancelled()) + throw CancellationError(); + }; + check(); + // JPEG has no alpha: the image lies over the matte. + QImage flattened(raster.image.size(), QImage::Format_RGB888); + if (flattened.isNull()) + throw ExportError(ExportError::Kind::render); + flattened.fill(QColor::fromRgbF(float(options.red), float(options.green), float(options.blue))); + { + QPainter painter(&flattened); + painter.drawImage(QRectF(flattened.rect()), raster.image); + } + check(); + QByteArray data = encode(flattened, "jpeg", qRound(std::clamp(options.quality, 0.0, 1.0) * 100), raster.resolution); + check(); + // The preview shows what the file holds, compression and all. + QBuffer buffer(&data); + QImageReader reader(&buffer, "jpeg"); + const QSize size = reader.size(); + // A thin image keeps one pixel across. + if (std::max(size.width(), size.height()) > 1000) + reader.setScaledSize(size.scaled(1000, 1000, Qt::KeepAspectRatio).expandedTo(QSize(1, 1))); + const QImage preview = reader.read(); + if (preview.isNull()) + throw ExportError(ExportError::Kind::encode); + return {data, preview}; +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} + +void ImageExporter::exportPNG(const ProjectSnapshot &snapshot, const QString &path) +{ + write(pngData(snapshot), path); +} + +void ImageExporter::write(const QByteArray &data, const QString &path) +{ + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + qCWarning(lcIO) << "could not write" << path << ":" << file.errorString(); + throw std::runtime_error(file.errorString().toStdString()); + } + qCInfo(lcIO) << "wrote" << data.size() << "bytes to" << path; +} + diff --git a/src/IO/ImageExporter.h b/src/IO/ImageExporter.h new file mode 100644 index 0000000..98f52df --- /dev/null +++ b/src/IO/ImageExporter.h @@ -0,0 +1,47 @@ +#pragma once +#include "IO/ProjectStore.h" +#include <QByteArray> +#include <QImage> +#include <functional> +#include <stdexcept> + +class ExportError : public std::runtime_error { +public: + enum class Kind { tooLarge, render, encode }; + explicit ExportError(Kind kind); + const Kind kind; +}; + +// Swift's CancellationError: a newer request superseded this one. +class CancellationError : public std::exception {}; + +struct ExportRaster { + QImage image; + // Pixels per inch. + double resolution = 72; +}; + +struct JPEGOptions { + double quality = 0.85; + // The matte behind transparent pixels. + double red = 1; + double green = 1; + double blue = 1; + bool operator==(const JPEGOptions &) const = default; +}; + +struct JPEGResult { + QByteArray data; + // Decoded from `data`, at most 1000 pixels a side. + QImage preview; +}; + +namespace ImageExporter { +ExportRaster render(const ProjectSnapshot &snapshot); +QByteArray pngData(const ProjectSnapshot &snapshot); +// Stops at Swift's three points once `cancelled` says so. +JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {}); +void exportPNG(const ProjectSnapshot &snapshot, const QString &path); +// Replaces the file in one step, or not at all. +void write(const QByteArray &data, const QString &path); +} diff --git a/src/IO/ImageFileDrop.cpp b/src/IO/ImageFileDrop.cpp new file mode 100644 index 0000000..00a3ec2 --- /dev/null +++ b/src/IO/ImageFileDrop.cpp @@ -0,0 +1,104 @@ +#include "IO/ImageFileDrop.h" +#include "Document/ProjectWorkspace.h" +#include "Logging.h" +#include <QDir> +#include <QFile> +#include <QMimeDatabase> +#include <algorithm> + +namespace { +// Swift's order: PNG, JPEG, HEIC, TIFF, then any picture. +QStringList pictureFormats(const QMimeData &data) +{ + QStringList formats; + for (const QString &format : {QStringLiteral("image/png"), QStringLiteral("image/jpeg"), QStringLiteral("image/heic"), QStringLiteral("image/tiff")}) + if (data.hasFormat(format)) + formats << format; + for (const QString &format : data.formats()) + if (format.startsWith(QLatin1String("image/")) && !formats.contains(format)) + formats << format; + return formats; +} + +// A dropped picture copied to a file for the importer. +std::optional<QUrl> temporaryFile(const QMimeData &data) +{ + const QStringList formats = pictureFormats(data); + if (formats.isEmpty()) + return std::nullopt; + const QString suffix = QMimeDatabase().mimeTypeForName(formats.first()).preferredSuffix(); + const QString name = QStringLiteral("Dropped-%1.%2").arg(QUuid::createUuid().toString(QUuid::WithoutBraces).toUpper(), suffix.isEmpty() ? QStringLiteral("png") : suffix); + QFile file(QDir::temp().filePath(name)); + const QByteArray bytes = data.data(formats.first()); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.flush()) { + qCWarning(lcIO) << "could not keep a dropped picture:" << file.errorString(); + // A failed flush fails `remove()` too: close, then unlink. + file.close(); + QFile::remove(file.fileName()); + return std::nullopt; + } + return QUrl::fromLocalFile(file.fileName()); +} +} + +bool ImageFileDrop::holdsImages(const QMimeData &data) +{ + for (const QUrl &url : data.urls()) + if (url.isLocalFile()) + return true; + return !pictureFormats(data).isEmpty(); +} + +std::vector<std::shared_ptr<QMimeData>> ImageFileDrop::providers(const QMimeData &data) +{ + std::vector<std::shared_ptr<QMimeData>> each; + const QList<QUrl> urls = data.urls(); + if (std::any_of(urls.begin(), urls.end(), [](const QUrl &url) { return url.isLocalFile(); })) { + for (const QUrl &url : urls) { + auto provider = std::make_shared<QMimeData>(); + provider->setUrls({url}); + each.push_back(provider); + } + return each; + } + // No file: a picture with its address is one item. + auto whole = std::make_shared<QMimeData>(); + // Swift asks for the preferred picture alone. + const QStringList formats = pictureFormats(data); + if (!formats.isEmpty()) + whole->setData(formats.first(), data.data(formats.first())); + each.push_back(whole); + return each; +} + +void ImageFileDrop::importProviders(const QMimeData &data, EditorSession &session, std::optional<QPointF> point, ProjectWorkspace *workspace, + std::optional<QUuid> destination, std::function<void()> done) +{ + QList<QUrl> urls; + bool unreadable = false; + for (const QUrl &url : data.urls()) { + if (url.isLocalFile()) + urls << url; + else + unreadable = true; + } + // Not a file on disk: a browser's or screenshot's picture. + if (urls.isEmpty()) { + const std::optional<QUrl> copy = temporaryFile(data); + unreadable = !copy; + if (copy) + urls << *copy; + } + const auto finished = [&session, unreadable, done] { + if (unreadable) { + const QString message = QStringLiteral("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager."); + session.setImportError(session.importError() ? *session.importError() + QStringLiteral("\n\n") + message : message); + } + if (done) + done(); + }; + if (workspace) + workspace->receive(urls, destination, point, finished); + else + session.importImages(urls, point, finished); +} diff --git a/src/IO/ImageFileDrop.h b/src/IO/ImageFileDrop.h new file mode 100644 index 0000000..42f0439 --- /dev/null +++ b/src/IO/ImageFileDrop.h @@ -0,0 +1,21 @@ +#pragma once +#include <QMimeData> +#include <QPointF> +#include <QUuid> +#include <functional> +#include <memory> +#include <vector> +#include <optional> + +class EditorSession; +class ProjectWorkspace; + +// Swift's ImageFileDrop: dropped files and pictures, in order, imported. +namespace ImageFileDrop { +void importProviders(const QMimeData &data, EditorSession &session, std::optional<QPointF> point, ProjectWorkspace *workspace = nullptr, + std::optional<QUuid> destination = std::nullopt, std::function<void()> done = {}); +// Swift's drop types: a file on disk or a picture. +bool holdsImages(const QMimeData &data); +// A drag as Swift's providers: a URL each, else whole. +std::vector<std::shared_ptr<QMimeData>> providers(const QMimeData &data); +} diff --git a/src/IO/ImageImporter.cpp b/src/IO/ImageImporter.cpp new file mode 100644 index 0000000..8cebe66 --- /dev/null +++ b/src/IO/ImageImporter.cpp @@ -0,0 +1,243 @@ +#include "IO/ImageImporter.h" +#include "Logging.h" +#include "Rendering/RasterSnapshot.h" +#include <QColorSpace> +#include <QFile> +#include <QFileInfo> +#include <QImageReader> +#include <QTransform> +#include <cmath> +#include <libheif/heif.h> + +namespace { +QString description(ImageImportError::Kind kind) +{ + switch (kind) { + case ImageImportError::Kind::unreadable: + return QStringLiteral("The image could not be read. It may be damaged or unavailable."); + case ImageImportError::Kind::unsupported: + return QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image."); + case ImageImportError::Kind::tooLarge: + return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."); + } + Q_UNREACHABLE(); +} + +[[noreturn]] void refuse(ImageImportError::Kind kind, const QString &path, const QString &reason) +{ + qCWarning(lcIO).noquote() << "cannot import" << path + ":" << reason; + throw ImageImportError(kind); +} + +void checkSize(const QSize &size, qint64 remainingPixels, const QString &path) +{ + if (size.width() <= 0 || size.height() <= 0) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("it states no size")); + if (size.width() > 30'000 || size.height() > 30'000 || qint64(size.width()) * size.height() > remainingPixels) + refuse(ImageImportError::Kind::tooLarge, path, QStringLiteral("%1 x %2 pixels").arg(size.width()).arg(size.height())); +} + +// HEVC in HEIF, by libheif's own reading of the brand. +bool isHEIC(const QByteArray &head) +{ + const char *type = heif_get_file_mime_type(reinterpret_cast<const uint8_t *>(head.constData()), int(head.size())); + return qstrcmp(type, "image/heic") == 0; +} + +// BT.709's curve, shared by BT.601 and BT.2020, as a table. +QList<uint16_t> videoCurve() +{ + QList<uint16_t> table(4096); + for (int index = 0; index < table.size(); ++index) { + const double value = index / double(table.size() - 1); + table[index] = uint16_t(std::lround(65535 * (value < 0.081 ? value / 4.5 : std::pow((value + 0.099) / 1.099, 1 / 0.45)))); + } + return table; +} + +// A HEIC's colours: its ICC profile, else its nclx box. +QColorSpace statedColorSpace(const heif_image_handle *handle, const QString &path) +{ + QByteArray profile(qsizetype(heif_image_handle_get_raw_color_profile_size(handle)), Qt::Uninitialized); + if (heif_image_handle_get_raw_color_profile(handle, profile.data()).code == heif_error_Ok) + return QColorSpace::fromIccProfile(profile); + heif_color_profile_nclx *box = nullptr; + const heif_error error = heif_image_handle_get_nclx_color_profile(handle, &box); + // No profile at all: untagged pixels count as sRGB. + if (error.code == heif_error_Color_profile_does_not_exist) + return QColorSpace(); + if (error.code != heif_error_Ok) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("its nclx colour box: ") + QString::fromUtf8(error.message)); + const std::unique_ptr<heif_color_profile_nclx, void (*)(heif_color_profile_nclx *)> nclx(box, heif_nclx_color_profile_free); + // Unspecified primaries or curve count as sRGB's. + QColorSpace stated(QColorSpace::SRgb); + if (nclx->color_primaries != heif_color_primaries_unspecified) + stated = QColorSpace(QPointF(nclx->color_primary_white_x, nclx->color_primary_white_y), + QPointF(nclx->color_primary_red_x, nclx->color_primary_red_y), + QPointF(nclx->color_primary_green_x, nclx->color_primary_green_y), + QPointF(nclx->color_primary_blue_x, nclx->color_primary_blue_y), QColorSpace::TransferFunction::SRgb); + if (!stated.isValid()) + refuse(ImageImportError::Kind::unreadable, path, + QStringLiteral("colour primaries %1 have no conversion to sRGB here").arg(int(nclx->color_primaries))); + switch (nclx->transfer_characteristics) { + case heif_transfer_characteristic_IEC_61966_2_1: + case heif_transfer_characteristic_unspecified: + return stated; + case heif_transfer_characteristic_linear: + stated.setTransferFunction(QColorSpace::TransferFunction::Linear); + return stated; + case heif_transfer_characteristic_ITU_R_BT_470_6_System_M: + stated.setTransferFunction(QColorSpace::TransferFunction::Gamma, 2.2f); + return stated; + case heif_transfer_characteristic_ITU_R_BT_470_6_System_B_G: + stated.setTransferFunction(QColorSpace::TransferFunction::Gamma, 2.8f); + return stated; + // xvYCC and BT.1361 are BT.709's curve between zero and one. + case heif_transfer_characteristic_IEC_61966_2_4: + case heif_transfer_characteristic_ITU_R_BT_1361: + case heif_transfer_characteristic_ITU_R_BT_709_5: + case heif_transfer_characteristic_ITU_R_BT_601_6: + case heif_transfer_characteristic_ITU_R_BT_2020_2_10bit: + case heif_transfer_characteristic_ITU_R_BT_2020_2_12bit: + // Qt 6.4's constructor from points and a table is broken. + stated.setTransferFunction(videoCurve()); + return stated; + default: + refuse(ImageImportError::Kind::unreadable, path, + QStringLiteral("transfer characteristics %1 have no conversion to sRGB here").arg(int(nclx->transfer_characteristics))); + } +} + +// libheif turns and crops the picture as the file says. +QImage decodeHEIC(const QString &path, qint64 remainingPixels) +{ + // libheif's documented frame: deinit frees what init loaded. + struct Library { + Library() { heif_init(nullptr); } + ~Library() { heif_deinit(); } + } library; + const std::unique_ptr<heif_context, void (*)(heif_context *)> context(heif_context_alloc(), heif_context_free); + heif_error error = heif_context_read_from_file(context.get(), QFile::encodeName(path).constData(), nullptr); + if (error.code != heif_error_Ok) + refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message)); + heif_image_handle *primary = nullptr; + error = heif_context_get_primary_image_handle(context.get(), &primary); + if (error.code != heif_error_Ok) + refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message)); + const std::unique_ptr<heif_image_handle, void (*)(const heif_image_handle *)> handle(primary, heif_image_handle_release); + checkSize(QSize(heif_image_handle_get_width(primary), heif_image_handle_get_height(primary)), remainingPixels, path); + const QColorSpace stated = statedColorSpace(primary, path); + heif_image *decoded = nullptr; + error = heif_decode_image(primary, &decoded, heif_colorspace_RGB, heif_chroma_interleaved_RGBA, nullptr); + if (error.code != heif_error_Ok) + refuse(ImageImportError::Kind::unreadable, path, QString::fromUtf8(error.message)); + const std::unique_ptr<heif_image, void (*)(const heif_image *)> picture(decoded, heif_image_release); + int stride = 0; + const uint8_t *pixels = heif_image_get_plane_readonly(decoded, heif_channel_interleaved, &stride); + if (!pixels) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("libheif gave no pixels")); + const bool premultiplied = heif_image_handle_is_premultiplied_alpha(primary); + // The copy outlives libheif's buffer. + QImage image = QImage(pixels, heif_image_get_width(decoded, heif_channel_interleaved), heif_image_get_height(decoded, heif_channel_interleaved), + stride, premultiplied ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_RGBA8888).copy(); + image.setColorSpace(stated); + return image; +} +} + +ImportedImage::ImportedImage(QImage image, QImage thumbnail, QString name) + : thumbnail(std::move(thumbnail)), name(std::move(name)), m_image(std::move(image)) +{ +} + +ImportedImage::ImportedImage(std::shared_ptr<const RasterSnapshot> raster, QImage thumbnail, QString name) + : thumbnail(std::move(thumbnail)), name(std::move(name)), raster(std::move(raster)) +{ +} + +QSize ImportedImage::size() const +{ + return raster ? QSize(raster->width, raster->height) : m_image.size(); +} + +QImage ImportedImage::image() const +{ + return raster ? raster->makeImage() : m_image; +} + +ImageIdentity ImportedImage::identity() const +{ + return raster ? ImageIdentity{raster.get(), 0} : ImageIdentity{nullptr, m_image.cacheKey()}; +} + +qint64 ImportedImage::byteCount() const +{ + if (!raster) + return m_image.sizeInBytes(); + return qint64(raster->width) * raster->height * (raster->isMask ? 1 : 4); +} + +ImageImportError::ImageImportError(Kind kind) : std::runtime_error(description(kind).toStdString()), kind(kind) {} + +void ImageImporter::liftAllocationLimit() +{ + static const bool lifted = (QImageReader::setAllocationLimit(0), true); + Q_UNUSED(lifted) +} + +ImportedImage ImageImporter::decode(const QString &path, qint64 remainingPixels) +{ + liftAllocationLimit(); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + refuse(ImageImportError::Kind::unreadable, path, file.errorString()); + // The type comes from the bytes: Qt checks them too. + const QByteArray head = file.peek(64); + QImageReader reader(&file); + const QByteArray format = reader.format(); + const bool heic = isHEIC(head); + // Other ISO media is unwanted: AVIF, plain HEIF, video. + if (!heic && head.mid(4, 4) == "ftyp") + refuse(ImageImportError::Kind::unsupported, path, QStringLiteral("ISO media that is not HEIC")); + if (!heic && format.isEmpty()) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("no image format is recognised")); + if (!heic && format != "jpeg" && format != "png" && format != "tiff") + refuse(ImageImportError::Kind::unsupported, path, QString::fromLatin1(format)); + // Qt's own turning drops the colour space: turn afterwards. + QImageIOHandler::Transformations orientation = QImageIOHandler::TransformationNone; + QImage image; + if (heic) { + image = decodeHEIC(path, remainingPixels); + } else { + checkSize(reader.size(), remainingPixels, path); + orientation = reader.transformation(); + if (!reader.read(&image)) + refuse(ImageImportError::Kind::unreadable, path, reader.errorString()); + } + image.convertToColorSpace(QColorSpace::SRgb); + image = std::move(image).convertToFormat(QImage::Format_RGBA8888_Premultiplied); + // Qt 6.9 renamed mirrored as flipped; the floor stays 6.4. +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + Qt::Orientations flips; + flips.setFlag(Qt::Horizontal, orientation.testFlag(QImageIOHandler::TransformationMirror)); + flips.setFlag(Qt::Vertical, orientation.testFlag(QImageIOHandler::TransformationFlip)); + image = std::move(image).flipped(flips); +#else + image = std::move(image).mirrored(orientation.testFlag(QImageIOHandler::TransformationMirror), + orientation.testFlag(QImageIOHandler::TransformationFlip)); +#endif + if (orientation.testFlag(QImageIOHandler::TransformationRotate90)) + image = image.transformed(QTransform().rotate(90)); + if (image.isNull()) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); + image.setColorSpace(QColorSpace::SRgb); + // Swift: scale = min(1, 96 / longest), extent rounded outward. + const qint64 longest = std::max(image.width(), image.height()); + const QSize small(int((image.width() * 96 + longest - 1) / longest), int((image.height() * 96 + longest - 1) / longest)); + const QImage thumbnail = longest <= 96 ? image : image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + refuse(ImageImportError::Kind::unreadable, path, QStringLiteral("out of memory")); + const QString name = QFileInfo(path).completeBaseName(); + qCInfo(lcIO).noquote() << "imported" << path << image.width() << "x" << image.height(); + return ImportedImage(image, thumbnail, name.isEmpty() ? QFileInfo(path).fileName() : name); +} diff --git a/src/IO/ImageImporter.h b/src/IO/ImageImporter.h new file mode 100644 index 0000000..9318f83 --- /dev/null +++ b/src/IO/ImageImporter.h @@ -0,0 +1,48 @@ +#pragma once +#include <QImage> +#include <QString> +#include <compare> +#include <memory> +#include <stdexcept> + +class RasterSnapshot; + +// Which pixels an asset holds, as CGImage identity did. +struct ImageIdentity { + const RasterSnapshot *raster = nullptr; + qint64 cacheKey = 0; + auto operator<=>(const ImageIdentity &) const = default; +}; + +class ImportedImage { +public: + ImportedImage(QImage image, QImage thumbnail, QString name); + ImportedImage(std::shared_ptr<const RasterSnapshot> raster, QImage thumbnail, QString name); + + QSize size() const; + // Painted tiles flatten on first use. + QImage image() const; + ImageIdentity identity() const; + qint64 byteCount() const; + + QImage thumbnail; + QString name; + std::shared_ptr<const RasterSnapshot> raster; + +private: + QImage m_image; +}; + +class ImageImportError : public std::runtime_error { +public: + enum class Kind { unreadable, unsupported, tooLarge }; + explicit ImageImportError(Kind kind); + Kind kind; +}; + +namespace ImageImporter { +// Qt refuses decodes over 128 MB; the app's budgets rule. +void liftAllocationLimit(); +// JPEG, PNG, TIFF or HEIC: upright, sRGB, premultiplied. +ImportedImage decode(const QString &path, qint64 remainingPixels = 100'000'000); +} diff --git a/src/IO/ImageResizer+Session.h b/src/IO/ImageResizer+Session.h new file mode 100644 index 0000000..54c11ce --- /dev/null +++ b/src/IO/ImageResizer+Session.h @@ -0,0 +1,4 @@ +// Swift's ImageResizer extension: resized projects applied. +public: + void applyImageSize(const ProjectSnapshot &snapshot); + void applyDocumentSize(const ProjectSnapshot &snapshot, const QString &actionName); diff --git a/src/IO/ImageResizer.cpp b/src/IO/ImageResizer.cpp new file mode 100644 index 0000000..1585a55 --- /dev/null +++ b/src/IO/ImageResizer.cpp @@ -0,0 +1,140 @@ +#include "IO/ImageResizer.h" +#include "Document/EditorSession.h" +#include "Document/LayerMask.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <QPainter> +#include <algorithm> +#include <cmath> +#include <new> + +namespace { +// A surface over the scaled layer, in document coordinates. +QImage drawn(QImage::Format format, qint64 width, qint64 height, QPointF topLeft, double sx, double sy, + const std::function<void(QPainter &)> &body) +{ + QImage image(int(width), int(height), format); + if (image.isNull()) + throw ExportError(ExportError::Kind::render); + image.fill(0); + QPainter context(&image); + context.translate(-topLeft.x(), -topLeft.y()); + context.scale(sx, sy); + body(context); + return image; +} + +ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions &options) +{ + // NaN fails both tests of a range. + if (options.width < 1 || options.width > 30'000 || options.height < 1 || options.height > 30'000 + || !(options.resolution >= 1 && options.resolution <= 9600)) + throw ProjectError(ProjectError::Kind::tooLarge); + const ProjectManifest &old = snapshot.manifest; + ProjectManifest manifest{.resolution = options.resolution, .documentID = old.documentID, .width = options.width, + .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}}; + if (old.width == options.width && old.height == options.height) { + manifest.layers = old.layers; + return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks}; + } + if (options.width * options.height > 100'000'000) + throw ProjectError(ProjectError::Kind::tooLarge); + const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height); + std::map<QUuid, ImportedImage> images, masks; + qint64 usedPixels = 0, usedMaskPixels = 0; + for (const ProjectLayerRecord &layer : old.layers) { + // Each layer is drawn again: unequal scaling shears turned ones. + double left = INFINITY, top = INFINITY, right = -INFINITY, bottom = -INFINITY; + for (const QPointF unit : {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}) { + const QPointF corner = layer.transform.point(unit); + left = std::min(left, corner.x() * sx), right = std::max(right, corner.x() * sx); + top = std::min(top, corner.y() * sy), bottom = std::max(bottom, corner.y() * sy); + } + left = std::floor(left), top = std::floor(top); + const LayerTransform transform{.origin = {left, top}, .size = {std::ceil(right) - left, std::ceil(bottom) - top}, + .sampling = options.sampling}; + // Judged before any number becomes an integer. + if (!transform.isValid()) + throw ProjectError(ProjectError::Kind::tooLarge); + const qint64 width = qint64(transform.size.width()), height = qint64(transform.size.height()); + LayerTransform sourceTransform = layer.transform; + sourceTransform.sampling = options.sampling; + if (layer.imageFile) { + if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedPixels) + throw ProjectError(ProjectError::Kind::tooLarge); + usedPixels += width * height; + const auto source = snapshot.images.find(layer.id); + if (source == snapshot.images.end()) + throw ProjectError(ProjectError::Kind::missingImage); + const QImage image = drawn(QImage::Format_RGBA8888_Premultiplied, width, height, {left, top}, sx, sy, [&](QPainter &context) { + LayerRenderer::draw(source->second.image(), sourceTransform, sourceTransform.center(), context); + }); + const double factor = std::min(1.0, 96 / double(std::max(width, height))); + const QSize small(std::max(1, int(double(width) * factor)), std::max(1, int(double(height) * factor))); + const QImage thumbnail = image.scaled(small, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + throw ExportError(ExportError::Kind::render); + images.insert({layer.id, ImportedImage(image, thumbnail, source->second.name)}); + } + if (layer.maskFile) { + const auto source = snapshot.masks.find(layer.id); + if (source == snapshot.masks.end()) + throw ProjectError(ProjectError::Kind::missingImage); + // Solid masks have no resolution; placed ones keep their pixels. + if (source->second.size() == QSize(1, 1) || layer.maskPlacement) { + masks.insert({layer.id, source->second}); + } else { + if (width > 30'000 || height > 30'000 || width * height > 100'000'000 - usedMaskPixels) + throw ProjectError(ProjectError::Kind::tooLarge); + usedMaskPixels += width * height; + const QImage image = drawn(QImage::Format_Grayscale8, width, height, {left, top}, sx, sy, [&](QPainter &context) { + LayerRenderer::drawCoverage(source->second.image(), sourceTransform, context); + }); + masks.insert({layer.id, LayerMask::assetFrom(image)}); + } + } + std::optional<LayerTransform> placement; + if (layer.maskPlacement) + placement = layer.maskPlacement->placing(layer.maskPlacement->unitToDocument() * QTransform::fromScale(sx, sy)); + manifest.layers.push_back({.id = layer.id, .name = layer.name, .isVisible = layer.isVisible, .transform = transform, + .imageFile = layer.imageFile, .parentID = layer.parentID, .isGroup = layer.isGroup, + .opacity = layer.opacity, .blendMode = layer.blendMode, .maskFile = layer.maskFile, + .maskEnabled = layer.maskEnabled, .maskSourceID = layer.maskSourceID, .adjustment = layer.adjustment, + .maskPlacement = placement, + .maskLinked = layer.maskLinked}); + } + qCInfo(lcIO) << "resized" << old.width << "x" << old.height << "to" << options.width << "x" << options.height; + return {.manifest = manifest, .images = images, .masks = masks}; +} +} + +ProjectSnapshot ImageResizer::resize(const ProjectSnapshot &snapshot, const ImageSizeOptions &options) +{ + // Records, maps and tiles without memory fail as a render. + try { + return resized(snapshot, options); + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } +} + +void EditorSession::applyImageSize(const ProjectSnapshot &snapshot) +{ + applyDocumentSize(snapshot, QStringLiteral("Image Size")); +} + +// Staged aside, then published: running out of memory changes nothing. +void EditorSession::applyDocumentSize(const ProjectSnapshot &snapshot, const QString &actionName) +{ + if (!m_document || m_document->id != snapshot.manifest.documentID) + return; + std::optional<CanvasDocument> next = CanvasDocument(snapshot); + DocumentHistory recorded = history; + recorded.begin(actionName, m_document, m_activeLayerID); + recorded.end(next, m_activeLayerID); + m_document = std::move(next); + history.adopt(std::move(recorded)); + viewport.fit(m_document->size()); + notify(); +} diff --git a/src/IO/ImageResizer.h b/src/IO/ImageResizer.h new file mode 100644 index 0000000..351504c --- /dev/null +++ b/src/IO/ImageResizer.h @@ -0,0 +1,15 @@ +#pragma once +#include "Document/LayerTransform.h" +#include "IO/ProjectStore.h" + +struct ImageSizeOptions { + qint64 width; + qint64 height; + double resolution; + LayerSampling sampling = LayerSampling::high; +}; + +namespace ImageResizer { +// Scales the document; every layer is drawn again upright. +ProjectSnapshot resize(const ProjectSnapshot &snapshot, const ImageSizeOptions &options); +} diff --git a/src/IO/ProjectController+Export.cpp b/src/IO/ProjectController+Export.cpp new file mode 100644 index 0000000..c065dd6 --- /dev/null +++ b/src/IO/ProjectController+Export.cpp @@ -0,0 +1,171 @@ +#include "Document/ProjectWorkspace.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectController.h" +#include "UI/JPEGExportSheet.h" +#include <QDialog> +#include <QFileDialog> +#include <QFileInfo> +#include <QFutureWatcher> +#include <QLayout> +#include <QtConcurrent> + +namespace { +// Swift's suggestion: the project's name, else Untitled. +QString suggested(const std::optional<QString> &project, const QString &suffix) +{ + return (project ? ProjectTab::nameWithoutSuffix(*project) : QStringLiteral("Untitled")) + suffix; +} +} + +// Swift's NSSavePanel for one type, whose suffix a name needs. +void ProjectController::exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure, + std::function<void(std::optional<QString>)> then) +{ + auto *panel = new QFileDialog(window, title); + panel->setAttribute(Qt::WA_DeleteOnClose); + panel->setAcceptMode(QFileDialog::AcceptSave); + panel->setNameFilter(filter); + panel->setDefaultSuffix(suffixes.first()); + panel->selectFile(name); + // Moved, never copied: `then` may hold a whole snapshot. + connect(panel, &QDialog::finished, this, [this, panel, suffixes, failure, then = std::move(then)](int result) mutable { + if (result != QDialog::Accepted) { + then(std::nullopt); + return; + } + const QString chosen = panel->selectedFiles().value(0); + if (suffixes.contains(QFileInfo(chosen).suffix().toLower())) { + then(chosen); + return; + } + // The panel never saw this name: nothing there is replaced. + const QFileInfo renamed(chosen + QLatin1Char('.') + suffixes.first()); + if (renamed.exists() || renamed.isSymLink()) { + showError(failure, QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), + [then = std::move(then)]() mutable { then(std::nullopt); }); + return; + } + then(renamed.filePath()); + }); + panel->open(); +} + +// Encoding and the disk block: off the UI thread. +void ProjectController::exportWork(std::function<void()> work, const QString &failure, std::function<void()> end) +try { + auto *watcher = new QFutureWatcher<std::optional<QString>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] { + watcher->deleteLater(); + if (const std::optional<QString> message = watcher->result()) { + showError(failure, *message, end); + return; + } + end(); + }); + watcher->setFuture(QtConcurrent::run([work = std::move(work)]() -> std::optional<QString> { + try { + work(); + return std::nullopt; + } catch (const std::runtime_error &error) { + return QString::fromUtf8(error.what()); + } + })); +} catch (const std::bad_alloc &) { + showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); +} + +void ProjectController::exportPNG(std::function<void()> done) +{ + if (!session.document() || !begin()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const auto end = [this, done] { + session.setIsProjectBusy(false); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + const QString failure = QStringLiteral("Couldn’t export PNG"); + std::optional<ProjectSnapshot> snapshot; + try { + snapshot = session.projectSnapshot(); + } catch (const std::bad_alloc &) { + showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); + return; + } + exportPanel(QStringLiteral("Export PNG"), QStringLiteral("PNG image (*.png)"), {QStringLiteral("png")}, suggested(session.projectPath(), QStringLiteral(".png")), + failure, [this, snapshot = std::move(snapshot.value()), failure, end](std::optional<QString> path) { + if (!path) { + end(); + return; + } + // The snapshot's copy may find no memory. + try { + exportWork([snapshot, path = *path] { ImageExporter::exportPNG(snapshot, path); }, failure, end); + } catch (const std::bad_alloc &) { + showError(failure, QString::fromUtf8(ExportError(ExportError::Kind::render).what()), end); + } + }); +} + +void ProjectController::exportJPEG(std::function<void()> done) +{ + if (!window || !session.document() || !begin()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const auto end = [this, done] { + session.setIsProjectBusy(false); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + const QString failure = QStringLiteral("Couldn’t export JPEG"); + const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what()); + std::optional<ProjectSnapshot> snapshot; + try { + snapshot = session.projectSnapshot(); + } catch (const std::bad_alloc &) { + showError(failure, render, end); + return; + } + auto *watcher = new QFutureWatcher<Rendered>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, failure, end] { + watcher->deleteLater(); + Rendered rendered = watcher->future().takeResult(); + if (!rendered.raster) { + showError(failure, rendered.failure, end); + return; + } + QDialog *dialog = sheet(QStringLiteral("Export JPEG")); + auto chosen = std::make_shared<std::optional<QByteArray>>(); + dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), [dialog, chosen](std::optional<QByteArray> data) { + *chosen = std::move(data); + dialog->done(*chosen ? QDialog::Accepted : QDialog::Rejected); + }, dialog)); + connect(dialog, &QDialog::finished, this, [this, chosen, failure, end] { + if (!*chosen) { + end(); + return; + } + exportPanel(QStringLiteral("Export JPEG"), QStringLiteral("JPEG image (*.jpeg *.jpg *.jpe)"), + {QStringLiteral("jpeg"), QStringLiteral("jpg"), QStringLiteral("jpe")}, suggested(session.projectPath(), QStringLiteral(".jpg")), failure, + [this, data = **chosen, failure, end](std::optional<QString> path) { + if (!path) { + end(); + return; + } + exportWork([data, path = *path] { ImageExporter::write(data, path); }, failure, end); + }); + }); + dialog->open(); + }); + watcher->setFuture(QtConcurrent::run([snapshot = std::move(snapshot.value())]() -> Rendered { + try { + return {ImageExporter::render(snapshot), QString()}; + } catch (const ExportError &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} diff --git a/src/IO/ProjectController+Resize.cpp b/src/IO/ProjectController+Resize.cpp new file mode 100644 index 0000000..5b8d5a2 --- /dev/null +++ b/src/IO/ProjectController+Resize.cpp @@ -0,0 +1,118 @@ +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include "IO/ProjectController.h" +#include "UI/CanvasSizeSheet.h" +#include "UI/ImageSizeSheet.h" +#include <QDialog> +#include <QFutureWatcher> +#include <QVBoxLayout> +#include <QtConcurrent> + +QDialog *ProjectController::sheet(const QString &title) +{ + auto *dialog = new QDialog(window); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(title); + auto *layout = new QVBoxLayout(dialog); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSizeConstraint(QLayout::SetFixedSize); + return dialog; +} + +void ProjectController::canvasSize(std::function<void()> done) +{ + if (!window || !session.document() || !begin()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + QDialog *dialog = sheet(QStringLiteral("Canvas Size")); + auto chosen = std::make_shared<std::optional<CanvasSizeOptions>>(); + dialog->layout()->addWidget(new CanvasSizeSheet(session.document().value(), session.foregroundColor(), session.backgroundColor(), + [dialog, chosen](std::optional<CanvasSizeOptions> options) { + *chosen = options; + dialog->done(options ? QDialog::Accepted : QDialog::Rejected); + }, dialog)); + connect(dialog, &QDialog::finished, this, [this, chosen, done] { + const std::optional<CanvasSizeOptions> options = *chosen; + resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return CanvasResizer::resize(snapshot, options); } + : std::function<ProjectSnapshot(const ProjectSnapshot &)>(), + [this](const ProjectSnapshot &resized) { session.applyDocumentSize(resized, QStringLiteral("Canvas Size")); }, + QStringLiteral("Couldn’t change canvas size"), done); + }); + dialog->open(); +} + +void ProjectController::imageSize(std::function<void()> done) +{ + if (!window || !session.document() || !begin()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + QDialog *dialog = sheet(QStringLiteral("Image Size")); + auto chosen = std::make_shared<std::optional<ImageSizeOptions>>(); + dialog->layout()->addWidget(new ImageSizeSheet(session.document().value(), [dialog, chosen](std::optional<ImageSizeOptions> options) { + *chosen = options; + dialog->done(options ? QDialog::Accepted : QDialog::Rejected); + }, dialog)); + connect(dialog, &QDialog::finished, this, [this, chosen, done] { + const std::optional<ImageSizeOptions> options = *chosen; + resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return ImageResizer::resize(snapshot, options); } + : std::function<ProjectSnapshot(const ProjectSnapshot &)>(), + [this](const ProjectSnapshot &resized) { session.applyImageSize(resized); }, QStringLiteral("Couldn’t resize the image"), done); + }); + dialog->open(); +} + +// Cancelled, nothing runs; running out of memory fails a render. +void ProjectController::resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land, + const QString &failure, std::function<void()> done) +{ + const auto end = [this, done] { + session.setIsProjectBusy(false); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }; + const auto explain = [this, failure, end](const QString &message) { showError(failure, message, end); }; + const QString render = QString::fromUtf8(ExportError(ExportError::Kind::render).what()); + // Taken and started before anything changes; running out explains. + std::optional<ProjectSnapshot> snapshot; + QFutureWatcher<Resized> *watcher = nullptr; + try { + if (resize) + snapshot = session.projectSnapshot(); + if (snapshot) { + watcher = new QFutureWatcher<Resized>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [watcher, land, explain, end, render] { + watcher->deleteLater(); + try { + const Resized result = watcher->future().takeResult(); + if (!result.snapshot) { + explain(result.failure); + return; + } + land(*result.snapshot); + } catch (const std::bad_alloc &) { + explain(render); + return; + } + end(); + }); + watcher->setFuture(QtConcurrent::run([snapshot = std::move(*snapshot), resize]() -> Resized { + try { + return {resize(snapshot), QString()}; + } catch (const std::runtime_error &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); + } + } catch (const std::bad_alloc &) { + delete watcher; + explain(render); + return; + } + if (!snapshot) + end(); +} diff --git a/src/IO/ProjectController.cpp b/src/IO/ProjectController.cpp new file mode 100644 index 0000000..531317e --- /dev/null +++ b/src/IO/ProjectController.cpp @@ -0,0 +1,368 @@ +#include "IO/ProjectController.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectStore.h" +#include "Logging.h" +#include <QFileDialog> +#include <QFileInfo> +#include <QFutureWatcher> +#include <QMessageBox> +#include <QPushButton> +#include <QtConcurrent> + +bool ProjectController::isProject(const QUrl &url) +{ + return QFileInfo(url.adjusted(QUrl::StripTrailingSlash).path()).suffix().toLower() == QLatin1String("comp"); +} + +bool ProjectController::samePlace(const std::optional<QString> &path, const QString &other) +{ + // Nothing there resolves to nothing: compare the names then. + const auto resolved = [](const QString &place) { + const QString canonical = QFileInfo(place).canonicalFilePath(); + return canonical.isEmpty() ? QFileInfo(place).absoluteFilePath() : canonical; + }; + return path && resolved(*path) == resolved(other); +} + +ProjectController::ProjectController(EditorSession &session) : session(session) {} + +bool ProjectController::canStart() const +{ + return session.canStartProjectOperation() && !(workspace && workspace->isManaging()); +} + +std::optional<QUuid> ProjectController::ownTab() const +{ + if (!workspace) + return std::nullopt; + for (const std::shared_ptr<ProjectTab> &tab : workspace->tabs()) { + if (&tab->controller == this) + return tab->id; + } + return std::nullopt; +} + +bool ProjectController::begin() +{ + if (!session.canStartProjectOperation()) + return false; + session.cancelCrop(); + session.commitTransform(); + session.setIsProjectBusy(true); + return true; +} + +void ProjectController::finish(const std::function<void(bool)> &done, bool value) +{ + // The caller goes on first, like a resumed Swift task. + if (done) + QMetaObject::invokeMethod(this, [done, value] { done(value); }, Qt::QueuedConnection); +} + +void ProjectController::save(bool asNew, std::function<void(bool)> done) +{ + if (!session.document() || !begin()) { + finish(done, false); + return; + } + saveCurrent(asNew, [this, done](bool saved) { + session.setIsProjectBusy(false); + finish(done, saved); + }); +} + +void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then) +{ + const std::optional<ProjectSnapshot> snapshot = session.projectSnapshot(); + if (!snapshot) { + then(true); + return; + } + if (!asNew && session.projectPath()) { + write(*snapshot, *session.projectPath(), then); + return; + } + auto *panel = new QFileDialog(window, asNew ? QStringLiteral("Save Project As") : QStringLiteral("Save Project")); + panel->setAttribute(Qt::WA_DeleteOnClose); + panel->setAcceptMode(QFileDialog::AcceptSave); + panel->setDefaultSuffix(QStringLiteral("comp")); + panel->selectFile(session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled.comp")); + connect(panel, &QDialog::finished, this, [this, panel, snapshot = *snapshot, then](int result) { + if (result != QDialog::Accepted) { + then(false); + return; + } + // The panel only suggests the suffix; a project needs it. + const QString chosen = panel->selectedFiles().value(0); + if (isProject(QUrl::fromLocalFile(chosen))) { + write(snapshot, chosen, then); + return; + } + // The panel never saw this name: nothing there is replaced. + const QFileInfo renamed(chosen + QStringLiteral(".comp")); + // A link that leads nowhere is still in the way. + if (renamed.exists() || renamed.isSymLink()) { + showError(QStringLiteral("Couldn’t save the project"), + QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(false); }); + return; + } + write(snapshot, renamed.filePath(), then); + }); + panel->open(); +} + +void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then) +{ + auto *watcher = new QFutureWatcher<std::optional<QString>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] { + watcher->deleteLater(); + if (const std::optional<QString> failure = watcher->result()) { + showError(QStringLiteral("Couldn’t save the project"), *failure, [then] { then(false); }); + return; + } + session.setProjectPath(destination); + session.history.markSaved(); + m_saveGeneration += 1; + then(true); + }); + // The store blocks on the disk: off the UI thread. + watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> { + try { + ProjectStore::save(snapshot, destination); + return std::nullopt; + } catch (const std::runtime_error &error) { + return QString::fromUtf8(error.what()); + } + })); +} + +void ProjectController::load(const QString &path, std::function<void(Loaded)> then) +{ + auto *watcher = new QFutureWatcher<Loaded>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [watcher, then] { + watcher->deleteLater(); + then(watcher->result()); + }); + watcher->setFuture(QtConcurrent::run([path]() -> Loaded { + try { + return {ProjectStore::load(path), QString()}; + } catch (const std::runtime_error &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +void ProjectController::open(std::optional<QString> path, std::function<void(bool)> done) +{ + if (workspace) { + workspace->open(path, done); + return; + } + if (!begin()) { + finish(done, false); + return; + } + const auto end = [this, done](bool opened) { + session.setIsProjectBusy(false); + finish(done, opened); + }; + const auto refuse = [this, end](const QString &failure) { + showError(QStringLiteral("Couldn’t open the project"), failure, [end] { end(false); }); + }; + const auto read = [this, end, refuse](QString source) { + // A folder's trailing slash would leave it without a name. + while (source.endsWith(QLatin1Char('/'))) + source.chop(1); + // Validated first: a corrupt project never discards the live document. + load(source, [this, end, refuse, source](const Loaded &first) { + if (!first.snapshot) { + refuse(first.failure); + return; + } + const int previousSave = m_saveGeneration; + confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) { + if (!proceed) { + end(false); + return; + } + // Saving in the confirmation can replace the opened file. + if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) { + session.installProject(*first.snapshot, source); + end(true); + return; + } + load(source, [this, end, refuse, source](const Loaded &again) { + if (!again.snapshot) { + refuse(again.failure); + return; + } + session.installProject(*again.snapshot, source); + end(true); + }); + }); + }); + }; + if (path) { + read(*path); + return; + } + // A project is a folder here: one a dialog. + auto *panel = new QFileDialog(window, QStringLiteral("Open Project")); + panel->setAttribute(Qt::WA_DeleteOnClose); + panel->setFileMode(QFileDialog::Directory); + connect(panel, &QDialog::finished, this, [panel, read, end](int result) { + if (result != QDialog::Accepted) { + end(false); + return; + } + read(panel->selectedFiles().value(0)); + }); + panel->open(); +} + +void ProjectController::newCanvas() +{ + if (workspace) { + workspace->newCanvas(); + return; + } + if (!begin()) + return; + confirmReplacement([this](bool proceed) { + session.setIsProjectBusy(false); + if (proceed) + session.clearProject(); + }); +} + +void ProjectController::close(QWidget *closing) +{ + if (const std::optional<QUuid> tab = ownTab()) { + workspace->close(*tab); + return; + } + if (!begin()) + return; + confirmReplacement([this, closing = QPointer<QWidget>(closing)](bool proceed) { + session.setIsProjectBusy(false); + if (!proceed) + return; + session.clearProject(); + if (closing) + closing->close(); + }); +} + +void ProjectController::confirmQuit(std::function<void(bool)> done) +{ + if (!begin()) { + finish(done, false); + return; + } + confirmReplacement([this, done](bool proceed) { + session.setIsProjectBusy(false); + finish(done, proceed); + }); +} + +void ProjectController::confirmReplacement(std::function<void(bool)> then) +{ + if (!session.isModified() || !session.document()) { + then(true); + return; + } + const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled"); + auto *alert = new QMessageBox(window); + alert->setAttribute(Qt::WA_DeleteOnClose); + alert->setIcon(QMessageBox::Warning); + alert->setText(QStringLiteral("Save changes to %1?").arg(name)); + alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them.")); + const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole); + alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); + const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole); + connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] { + if (alert->clickedButton() == save) + saveCurrent(false, then); + else + then(alert->clickedButton() == discard); + }); + alert->open(); +} + +void ProjectController::showError(const QString &title, const QString &message, std::function<void()> then) +{ + qCWarning(lcIO).noquote() << title + QStringLiteral(": ") + message; + auto *alert = new QMessageBox(window); + alert->setAttribute(Qt::WA_DeleteOnClose); + alert->setIcon(QMessageBox::Warning); + alert->setText(title); + alert->setInformativeText(message); + alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole); + connect(alert, &QDialog::finished, this, std::move(then)); + alert->open(); +} + +void ProjectController::receive(const QList<QUrl> &urls, std::optional<QPointF> point, std::function<void()> done) +{ + if (const std::optional<QUuid> tab = ownTab()) { + workspace->receive(urls, tab, point, done); + return; + } + if (urls.isEmpty()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + m_incoming.push_back({urls, point, std::move(done)}); + if (m_processing) + return; + m_processing = true; + QMetaObject::invokeMethod(this, &ProjectController::drainIncoming, Qt::QueuedConnection); +} + +void ProjectController::drainIncoming() +{ + if (m_incoming.empty()) { + m_processing = false; + return; + } + const Incoming request = m_incoming.front(); + m_incoming.pop_front(); + // The session keeps this waiter, perhaps longer than the controller. + session.waitForFileRequest([self = QPointer<ProjectController>(this), request] { + if (self) + self->handle(request); + }); +} + +void ProjectController::handle(const Incoming &request) +{ + QList<QUrl> projects, images; + for (const QUrl &url : request.urls) + (isProject(url) ? projects : images) << url; + const auto next = [this, done = request.done] { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + drainIncoming(); + }; + if (projects.size() > 1) { + showError(QStringLiteral("Open one project at a time"), QString::fromUtf8(ProjectError(ProjectError::Kind::invalid).what()), next); + return; + } + // Beside a project the images go to the centre. + const auto bringIn = [this, images, next, point = projects.isEmpty() ? request.point : std::nullopt](bool proceed) { + if (!proceed) { + next(); + return; + } + // The session keeps this callback as well. + session.importImages(images, point, [self = QPointer<ProjectController>(this), next] { + if (self) + next(); + }); + }; + if (projects.isEmpty()) + bringIn(true); + else + open(projects.constFirst().toLocalFile(), bringIn); +} diff --git a/src/IO/ProjectController.h b/src/IO/ProjectController.h new file mode 100644 index 0000000..b42a95c --- /dev/null +++ b/src/IO/ProjectController.h @@ -0,0 +1,84 @@ +#pragma once +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QList> +#include <QPointer> +#include <QUrl> +#include <QWidget> +#include <deque> +#include <functional> +#include <optional> + +class ProjectWorkspace; +class QDialog; + +// One project's file operations: save, open, close, drops. +class ProjectController : public QObject { + Q_OBJECT +public: + explicit ProjectController(EditorSession &session); + + // Its owner declares the session first: it outlives this. + EditorSession &session; + // Dialogs open over it; without one they stand alone. + QPointer<QWidget> window; + // With one, opening and closing are the workspace's to do. + QPointer<ProjectWorkspace> workspace; + + static bool isProject(const QUrl &url); + // One place on disk, whatever links lead to it. + static bool samePlace(const std::optional<QString> &path, const QString &other); + + bool canStart() const; + // Each `done` runs from the event loop, never inline. + void save(bool asNew = false, std::function<void(bool)> done = {}); + void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {}); + void newCanvas(); + void close(QWidget *closing); + void confirmQuit(std::function<void(bool)> done); + void receive(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); + // Swift's sheets over the window; resizes off the UI thread. + void canvasSize(std::function<void()> done = {}); + void imageSize(std::function<void()> done = {}); + void exportPNG(std::function<void()> done = {}); + void exportJPEG(std::function<void()> done = {}); + +private: + struct Incoming { + QList<QUrl> urls; + std::optional<QPointF> point; + std::function<void()> done; + }; + struct Loaded { + std::optional<ProjectSnapshot> snapshot; + QString failure; + }; + using Resized = Loaded; + struct Rendered { + std::optional<ExportRaster> raster; + QString failure; + }; + + bool begin(); + void finish(const std::function<void(bool)> &done, bool value); + void saveCurrent(bool asNew, std::function<void(bool)> then); + void write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then); + void load(const QString &path, std::function<void(Loaded)> then); + void confirmReplacement(std::function<void(bool)> then); + void showError(const QString &title, const QString &message, std::function<void()> then); + // Swift's beginSheet: window-modal over the window; Escape cancels. + QDialog *sheet(const QString &title); + // Off the UI thread, then landed or explained; freed after. + void resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land, + const QString &failure, std::function<void()> done); + void exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure, + std::function<void(std::optional<QString>)> then); + void exportWork(std::function<void()> work, const QString &failure, std::function<void()> end); + void drainIncoming(); + void handle(const Incoming &request); + std::optional<QUuid> ownTab() const; + + int m_saveGeneration = 0; + std::deque<Incoming> m_incoming; + bool m_processing = false; +}; diff --git a/src/IO/ProjectStore+Adjustment.cpp b/src/IO/ProjectStore+Adjustment.cpp new file mode 100644 index 0000000..4592645 --- /dev/null +++ b/src/IO/ProjectStore+Adjustment.cpp @@ -0,0 +1,216 @@ +#include "IO/ProjectStore+Json.h" +#include <QJsonArray> +#include <limits> + +// LayerAdjustment's synthesized Codable: every key but the optionals. +namespace { +using namespace ManifestJson; + +QJsonArray array(const QJsonValue &value) +{ + if (!value.isArray()) + refuse(); + return value.toArray(); +} + +template <typename Kind, size_t count> Kind kindNamed(const QJsonValue &value, const std::array<Kind, count> &kinds) +{ + const QString text = string(value); + for (const Kind kind : kinds) { + if (rawValue(kind) == text) + return kind; + } + refuse(); +} + +// ColorRange-keyed: key, value in turn; a missing value refuses. +template <typename Value, typename Decode> std::map<ColorRange, Value> keyed(const QJsonValue &value, Decode decode) +{ + const QJsonArray pairs = array(value); + std::map<ColorRange, Value> result; + for (qsizetype index = 0; index < pairs.size(); index += 2) + result.insert_or_assign(kindNamed(pairs.at(index), allColorRanges), decode(pairs.at(index + 1))); + return result; +} + +template <typename Value, typename Encode> QJsonArray keyed(const std::map<ColorRange, Value> &values, Encode encode) +{ + QJsonArray pairs; + for (const auto &[range, value] : values) { + pairs.append(rawValue(range)); + pairs.append(encode(value)); + } + return pairs; +} + +RangeAdjustment rangeAdjustment(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {number(object.value("hue")), number(object.value("saturation")), number(object.value("lightness"))}; +} + +HueBand band(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {number(object.value("falloffStart")), number(object.value("rangeStart")), number(object.value("rangeEnd")), + number(object.value("falloffEnd"))}; +} + +HueSaturationSettings hueSaturation(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + HueSaturationSettings settings; + settings.range = kindNamed(object.value("range"), allColorRanges); + settings.colorize = boolean(object.value("colorize")); + settings.invertRange = boolean(object.value("invertRange")); + settings.adjustments = keyed<RangeAdjustment>(object.value("adjustments"), rangeAdjustment); + settings.bands = keyed<HueBand>(object.value("bands"), band); + return settings; +} + +LevelsSettings levels(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + const QJsonArray ranges = array(object.value("ranges")); + // Swift decodes any count; isValid refuses all but four. + if (ranges.size() != 4) + refuse(); + LevelsSettings settings; + settings.channel = kindNamed(object.value("channel"), allLevelsChannels); + for (qsizetype index = 0; index < 4; ++index) { + const QJsonObject range = ranges.at(index).toObject(); + settings.ranges[size_t(index)] = {number(range.value("black")), number(range.value("gamma")), number(range.value("white")), + number(range.value("outputBlack")), number(range.value("outputWhite"))}; + } + return settings; +} + +CurvesSettings curves(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + CurvesSettings settings; + settings.channel = kindNamed(object.value("channel"), allLevelsChannels); + settings.channels.clear(); + for (const QJsonValue &channel : array(object.value("channels"))) { + std::vector<CurvePoint> points; + for (const QJsonValue &point : array(channel)) + points.push_back({number(point.toObject().value("x")), number(point.toObject().value("y"))}); + settings.channels.push_back(points); + } + return settings; +} + +AdjustmentColor colour(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {number(object.value("red")), number(object.value("green")), number(object.value("blue"))}; +} + +ExposureSettings exposure(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {.exposure = number(object.value("exposure")), .offset = number(object.value("offset")), .gamma = number(object.value("gamma"))}; +} + +GradientMapSettings gradientMap(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {colour(object.value("shadows")), colour(object.value("highlights")), boolean(object.value("reversed"))}; +} + +GrainSettings grain(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + // Swift's UInt32 refuses what it cannot hold. + const qint64 seed = integer(object.value("seed")); + if (seed < 0 || seed > std::numeric_limits<quint32>::max()) + refuse(); + return {.amount = number(object.value("amount")), .size = number(object.value("size")), .roughness = number(object.value("roughness")), + .seed = quint32(seed)}; +} + +QJsonObject encoded(const AdjustmentColor &colour) +{ + return {{"red", colour.red}, {"green", colour.green}, {"blue", colour.blue}}; +} + +QJsonObject encoded(const HueSaturationSettings &settings) +{ + return {{"range", rawValue(settings.range)}, + {"colorize", settings.colorize}, + {"invertRange", settings.invertRange}, + {"adjustments", keyed(settings.adjustments, + [](const RangeAdjustment &values) { + return QJsonObject{{"hue", values.hue}, {"saturation", values.saturation}, {"lightness", values.lightness}}; + })}, + {"bands", keyed(settings.bands, [](const HueBand &band) { + return QJsonObject{{"falloffStart", band.falloffStart}, {"rangeStart", band.rangeStart}, {"rangeEnd", band.rangeEnd}, + {"falloffEnd", band.falloffEnd}}; + })}}; +} + +QJsonObject encoded(const LevelsSettings &settings) +{ + QJsonArray ranges; + for (const LevelRange &range : settings.ranges) + ranges.append(QJsonObject{{"black", range.black}, {"gamma", range.gamma}, {"white", range.white}, {"outputBlack", range.outputBlack}, + {"outputWhite", range.outputWhite}}); + return {{"channel", rawValue(settings.channel)}, {"ranges", ranges}}; +} + +QJsonObject encoded(const CurvesSettings &settings) +{ + QJsonArray channels; + for (const std::vector<CurvePoint> &points : settings.channels) { + QJsonArray channel; + for (const CurvePoint &point : points) + channel.append(QJsonObject{{"x", point.x}, {"y", point.y}}); + channels.append(channel); + } + return {{"channel", rawValue(settings.channel)}, {"channels", channels}}; +} +} + +LayerAdjustment ManifestJson::adjustment(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {.kind = kindNamed(object.value("kind"), allAdjustmentKinds), + .hue = number(object.value("hue")), + .saturation = number(object.value("saturation")), + .lightness = number(object.value("lightness")), + .colorize = boolean(object.value("colorize")), + .hsvSettings = optional(object, "hsvSettings", hueSaturation), + .levels = levels(object.value("levels")), + .curves = curves(object.value("curves")), + .exposureSettings = optional(object, "exposureSettings", exposure), + .gradientMapSettings = optional(object, "gradientMapSettings", gradientMap), + .grainSettings = optional(object, "grainSettings", grain)}; +} + +QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment) +{ + QJsonObject object{{"kind", rawValue(adjustment.kind)}, + {"hue", adjustment.hue}, + {"saturation", adjustment.saturation}, + {"lightness", adjustment.lightness}, + {"colorize", adjustment.colorize}, + {"levels", ::encoded(adjustment.levels)}, + {"curves", ::encoded(adjustment.curves)}}; + if (adjustment.hsvSettings) + object.insert("hsvSettings", ::encoded(*adjustment.hsvSettings)); + if (adjustment.exposureSettings) { + const ExposureSettings &exposure = *adjustment.exposureSettings; + object.insert("exposureSettings", QJsonObject{{"exposure", exposure.exposure}, {"offset", exposure.offset}, {"gamma", exposure.gamma}}); + } + if (adjustment.gradientMapSettings) { + const GradientMapSettings &map = *adjustment.gradientMapSettings; + object.insert("gradientMapSettings", QJsonObject{{"shadows", ::encoded(map.shadows)}, {"highlights", ::encoded(map.highlights)}, + {"reversed", map.reversed}}); + } + if (adjustment.grainSettings) { + const GrainSettings &grain = *adjustment.grainSettings; + object.insert("grainSettings", QJsonObject{{"amount", grain.amount}, {"size", grain.size}, {"roughness", grain.roughness}, + {"seed", qint64(grain.seed)}}); + } + return object; +} diff --git a/src/IO/ProjectStore+Effects.cpp b/src/IO/ProjectStore+Effects.cpp new file mode 100644 index 0000000..7344030 --- /dev/null +++ b/src/IO/ProjectStore+Effects.cpp @@ -0,0 +1,93 @@ +#include "IO/ProjectStore+Json.h" + +// LayerEffects' synthesized Codable: every key but `enabled`. +namespace { +using namespace ManifestJson; + +QJsonObject object(const QJsonValue &value) +{ + if (!value.isObject()) + refuse(); + return value.toObject(); +} + +StrokeEffect stroke(const QJsonValue &value) +{ + const QJsonObject keys = object(value); + return {optional(keys, "enabled", boolean), number(keys.value("size")), number(keys.value("red")), number(keys.value("green")), + number(keys.value("blue")), number(keys.value("opacity")), boolean(keys.value("inside"))}; +} + +ShadowEffect shadow(const QJsonValue &value) +{ + const QJsonObject keys = object(value); + return {optional(keys, "enabled", boolean), number(keys.value("angle")), number(keys.value("distance")), number(keys.value("blur")), + number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")), number(keys.value("opacity"))}; +} + +ColorOverlayEffect colorOverlay(const QJsonValue &value) +{ + const QJsonObject keys = object(value); + return {optional(keys, "enabled", boolean), number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")), + number(keys.value("opacity"))}; +} + +InnerShadowEffect innerShadow(const QJsonValue &value) +{ + const QJsonObject keys = object(value); + return {optional(keys, "enabled", boolean), number(keys.value("angle")), number(keys.value("distance")), number(keys.value("blur")), + number(keys.value("red")), number(keys.value("green")), number(keys.value("blue")), number(keys.value("opacity"))}; +} + +void colour(QJsonObject &keys, double red, double green, double blue) +{ + keys.insert("red", red); + keys.insert("green", green); + keys.insert("blue", blue); +} +} + +LayerEffects ManifestJson::effects(const QJsonValue &value) +{ + const QJsonObject keys = object(value); + return {optional(keys, "stroke", stroke), optional(keys, "shadow", shadow), optional(keys, "colorOverlay", colorOverlay), + optional(keys, "innerShadow", innerShadow)}; +} + +QJsonObject ManifestJson::encoded(const LayerEffects &effects) +{ + QJsonObject keys; + if (effects.stroke) { + const StrokeEffect &stroke = *effects.stroke; + QJsonObject object{{"size", stroke.size}, {"opacity", stroke.opacity}, {"inside", stroke.inside}}; + colour(object, stroke.red, stroke.green, stroke.blue); + if (stroke.enabled) + object.insert("enabled", *stroke.enabled); + keys.insert("stroke", object); + } + if (effects.shadow) { + const ShadowEffect &shadow = *effects.shadow; + QJsonObject object{{"angle", shadow.angle}, {"distance", shadow.distance}, {"blur", shadow.blur}, {"opacity", shadow.opacity}}; + colour(object, shadow.red, shadow.green, shadow.blue); + if (shadow.enabled) + object.insert("enabled", *shadow.enabled); + keys.insert("shadow", object); + } + if (effects.colorOverlay) { + const ColorOverlayEffect &overlay = *effects.colorOverlay; + QJsonObject object{{"opacity", overlay.opacity}}; + colour(object, overlay.red, overlay.green, overlay.blue); + if (overlay.enabled) + object.insert("enabled", *overlay.enabled); + keys.insert("colorOverlay", object); + } + if (effects.innerShadow) { + const InnerShadowEffect &inner = *effects.innerShadow; + QJsonObject object{{"angle", inner.angle}, {"distance", inner.distance}, {"blur", inner.blur}, {"opacity", inner.opacity}}; + colour(object, inner.red, inner.green, inner.blue); + if (inner.enabled) + object.insert("enabled", *inner.enabled); + keys.insert("innerShadow", object); + } + return keys; +} diff --git a/src/IO/ProjectStore+Json.h b/src/IO/ProjectStore+Json.h new file mode 100644 index 0000000..5714700 --- /dev/null +++ b/src/IO/ProjectStore+Json.h @@ -0,0 +1,33 @@ +#pragma once +#include "IO/ProjectStore.h" +#include <QJsonObject> +#include <QJsonValue> + +// Swift's synthesized Codable by hand: the readers the manifest shares. +namespace ManifestJson { +[[noreturn]] void refuse(); +QString string(const QJsonValue &value); +bool boolean(const QJsonValue &value); +double number(const QJsonValue &value); +// Swift's Int: Qt holds a whole 64-bit token exactly. +qint64 integer(const QJsonValue &value); +QUuid uuid(const QJsonValue &value); + +// Absent and null both mean nil. +template <typename Decode> +auto optional(const QJsonObject &object, const char *key, Decode decode) -> std::optional<decltype(decode(QJsonValue()))> +{ + const QJsonValue value = object.value(QLatin1String(key)); + if (value.isUndefined() || value.isNull()) + return std::nullopt; + return decode(value); +} + +// LayerAdjustment's keys, in ProjectStore+Adjustment.cpp. +LayerAdjustment adjustment(const QJsonValue &value); +QJsonObject encoded(const LayerAdjustment &adjustment); + +// LayerEffects' keys, in ProjectStore+Effects.cpp. +LayerEffects effects(const QJsonValue &value); +QJsonObject encoded(const LayerEffects &effects); +} diff --git a/src/IO/ProjectStore+Manifest.cpp b/src/IO/ProjectStore+Manifest.cpp new file mode 100644 index 0000000..5ced57c --- /dev/null +++ b/src/IO/ProjectStore+Manifest.cpp @@ -0,0 +1,242 @@ +#include "IO/ProjectStore+Json.h" +#include <QJsonArray> +#include <QJsonDocument> +#include <QRegularExpression> +#include <QVariant> + +// Swift's synthesized Codable by hand: same keys, same refusals. +void ManifestJson::refuse() +{ + throw ProjectError(ProjectError::Kind::invalid); +} + +QString ManifestJson::string(const QJsonValue &value) +{ + if (!value.isString()) + refuse(); + return value.toString(); +} + +bool ManifestJson::boolean(const QJsonValue &value) +{ + if (!value.isBool()) + refuse(); + return value.toBool(); +} + +double ManifestJson::number(const QJsonValue &value) +{ + if (!value.isDouble()) + refuse(); + return value.toDouble(); +} + +qint64 ManifestJson::integer(const QJsonValue &value) +{ + const QVariant exact = value.toVariant(); + if (exact.typeId() != QMetaType::LongLong) + refuse(); + return exact.toLongLong(); +} + +QUuid ManifestJson::uuid(const QJsonValue &value) +{ + // Anchored at both ends: `$` would pass a trailing newline. + static const QRegularExpression canonical( + QRegularExpression::anchoredPattern("[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")); + const QString text = string(value); + if (!canonical.match(text).hasMatch()) + refuse(); + return QUuid::fromString(text); +} + +namespace { +using namespace ManifestJson; + +// CGPoint and CGSize: two numbers; what is missing is refused. +std::pair<double, double> pair(const QJsonValue &value) +{ + return {number(value.toArray().at(0)), number(value.toArray().at(1))}; +} + +LayerTransform transform(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + const auto [x, y] = pair(object.value("origin")); + const auto [width, height] = pair(object.value("size")); + const std::optional<LayerSampling> sampling = layerSampling(string(object.value("sampling"))); + if (!sampling) + refuse(); + return {.origin = {x, y}, .size = {width, height}, .rotation = number(object.value("rotation")), + .flipX = boolean(object.value("flipX")), .flipY = boolean(object.value("flipY")), .sampling = *sampling}; +} + +LayerBlendMode blendMode(const QJsonValue &value) +{ + const std::optional<LayerBlendMode> mode = layerBlendMode(string(value)); + if (!mode) + refuse(); + return *mode; +} + +QPointF point(const QJsonValue &value) +{ + const auto [x, y] = pair(value); + return {x, y}; +} + +LayerShapeStyle shape(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + const std::optional<ShapeKind> kind = shapeKind(string(object.value("kind"))); + if (!kind) + refuse(); + return {.kind = *kind, .red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")), + .cornerRadius = number(object.value("cornerRadius")), .lineWidth = optional(object, "lineWidth", number), + .start = optional(object, "start", point), .end = optional(object, "end", point)}; +} + +QSizeF size(const QJsonValue &value) +{ + const auto [width, height] = pair(value); + return {width, height}; +} + +// Synthesized Codable skips defaults: all but the box required. +LayerTextStyle textStyle(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + const std::optional<TextAlignment> alignment = textAlignment(string(object.value("alignment"))); + if (!alignment) + refuse(); + return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")), + .red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")), + .alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")), + .boxSize = optional(object, "boxSize", size)}; +} + +ProjectLayerRecord layer(const QJsonValue &value) +{ + const QJsonObject object = value.toObject(); + return {.id = uuid(object.value("id")), .name = string(object.value("name")), .isVisible = boolean(object.value("isVisible")), + .transform = transform(object.value("transform")), .imageFile = optional(object, "imageFile", string), + .parentID = optional(object, "parentID", uuid), .isGroup = optional(object, "isGroup", boolean), + .opacity = optional(object, "opacity", number), .blendMode = optional(object, "blendMode", blendMode), + .maskFile = optional(object, "maskFile", string), .maskEnabled = optional(object, "maskEnabled", boolean), + .maskSourceID = optional(object, "maskSourceID", uuid), .adjustment = optional(object, "adjustment", ManifestJson::adjustment), + .maskPlacement = optional(object, "maskPlacement", transform), + .maskLinked = optional(object, "maskLinked", boolean), .shape = optional(object, "shape", shape), + .effects = optional(object, "effects", ManifestJson::effects), .text = optional(object, "text", textStyle)}; +} + +QJsonObject encoded(const LayerTransform &transform) +{ + return {{"origin", QJsonArray{transform.origin.x(), transform.origin.y()}}, + {"size", QJsonArray{transform.size.width(), transform.size.height()}}, + {"rotation", transform.rotation}, {"flipX", transform.flipX}, {"flipY", transform.flipY}, + {"sampling", rawValue(transform.sampling)}}; +} + +QJsonObject encoded(const LayerShapeStyle &shape) +{ + QJsonObject object{{"kind", rawValue(shape.kind)}, {"red", shape.red}, {"green", shape.green}, {"blue", shape.blue}, + {"cornerRadius", shape.cornerRadius}}; + if (shape.lineWidth) + object.insert("lineWidth", *shape.lineWidth); + if (shape.start) + object.insert("start", QJsonArray{shape.start->x(), shape.start->y()}); + if (shape.end) + object.insert("end", QJsonArray{shape.end->x(), shape.end->y()}); + return object; +} + +QJsonObject encoded(const LayerTextStyle &text) +{ + QJsonObject object{{"content", text.content}, {"fontName", text.fontName}, {"fontSize", text.fontSize}, {"red", text.red}, {"green", text.green}, + {"blue", text.blue}, {"alignment", rawValue(text.alignment)}, {"tracking", text.tracking}, {"leading", text.leading}}; + if (text.boxSize) + object.insert("boxSize", QJsonArray{text.boxSize->width(), text.boxSize->height()}); + return object; +} + +QJsonObject encoded(const ProjectLayerRecord &layer) +{ + QJsonObject object{{"id", uuidString(layer.id)}, {"name", layer.name}, {"isVisible", layer.isVisible}, + {"transform", encoded(layer.transform)}}; + if (layer.imageFile) + object.insert("imageFile", *layer.imageFile); + if (layer.parentID) + object.insert("parentID", uuidString(*layer.parentID)); + if (layer.isGroup) + object.insert("isGroup", *layer.isGroup); + if (layer.opacity) + object.insert("opacity", *layer.opacity); + if (layer.blendMode) + object.insert("blendMode", rawValue(*layer.blendMode)); + if (layer.maskFile) + object.insert("maskFile", *layer.maskFile); + if (layer.maskEnabled) + object.insert("maskEnabled", *layer.maskEnabled); + if (layer.maskSourceID) + object.insert("maskSourceID", uuidString(*layer.maskSourceID)); + if (layer.adjustment) + object.insert("adjustment", ManifestJson::encoded(*layer.adjustment)); + if (layer.maskPlacement) + object.insert("maskPlacement", encoded(*layer.maskPlacement)); + if (layer.maskLinked) + object.insert("maskLinked", *layer.maskLinked); + if (layer.shape) + object.insert("shape", encoded(*layer.shape)); + if (layer.effects) + object.insert("effects", ManifestJson::encoded(*layer.effects)); + if (layer.text) + object.insert("text", encoded(*layer.text)); + return object; +} +} + +QString uuidString(const QUuid &id) +{ + return id.toString(QUuid::WithoutBraces).toUpper(); +} + +QByteArray ProjectManifest::encoded() const +{ + QJsonArray records; + for (const ProjectLayerRecord &layer : layers) + records.append(::encoded(layer)); + QJsonObject object{{"format", format}, {"version", version}, {"colorSpace", colorSpace}, {"documentID", uuidString(documentID)}, + {"width", width}, {"height", height}, {"layers", records}}; + if (resolution) + object.insert("resolution", *resolution); + if (activeLayerID) + object.insert("activeLayerID", uuidString(*activeLayerID)); + // Swift indents by two; four would outgrow 4 MiB sooner. + QByteArray json; + for (const QByteArray &line : QJsonDocument(object).toJson(QJsonDocument::Indented).split('\n')) { + const qsizetype indent = line.size() - line.trimmed().size(); + json += line.mid(indent / 2) + '\n'; + } + json.chop(1); + return json; +} + +ProjectManifest ProjectManifest::decoded(const QByteArray &data) +{ + const QJsonObject object = QJsonDocument::fromJson(data).object(); + if (!object.value("layers").isArray()) + refuse(); + std::vector<ProjectLayerRecord> records; + for (const QJsonValue &value : object.value("layers").toArray()) + records.push_back(layer(value)); + return {.format = string(object.value("format")), .version = integer(object.value("version")), + .colorSpace = string(object.value("colorSpace")), .resolution = optional(object, "resolution", number), + .documentID = uuid(object.value("documentID")), .width = integer(object.value("width")), + .height = integer(object.value("height")), .activeLayerID = optional(object, "activeLayerID", uuid), .layers = records}; +} + +std::pair<QString, qint64> ProjectManifest::header(const QByteArray &data) +{ + const QJsonObject object = QJsonDocument::fromJson(data).object(); + return {string(object.value("format")), integer(object.value("version"))}; +} diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp new file mode 100644 index 0000000..17cb74d --- /dev/null +++ b/src/IO/ProjectStore.cpp @@ -0,0 +1,326 @@ +#include "IO/ProjectStore.h" +#include "Document/LayerGroups.h" +#include "Document/LayerMask.h" +#include "Document/LiveLayerMask.h" +#include "Logging.h" +#include <QBuffer> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QImageReader> +#include <QImageWriter> +#include <QSet> +#include <cerrno> +#include <cmath> +#include <cstring> +#include <functional> +#include <fcntl.h> +#include <stdio.h> +#include <unistd.h> + +namespace { +QString description(ProjectError::Kind kind) +{ + switch (kind) { + case ProjectError::Kind::invalid: + return QStringLiteral("This is not a valid OmaPhoto project, or its metadata is damaged."); + case ProjectError::Kind::version: + throw std::logic_error("a version error needs its version number"); + case ProjectError::Kind::missingImage: + return QStringLiteral("An image inside the project is missing or damaged. The current document has not been replaced."); + case ProjectError::Kind::tooLarge: + return QStringLiteral("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit."); + case ProjectError::Kind::encode: + return QStringLiteral("An image could not be saved. The previous project has not been replaced."); + } + throw std::logic_error("unknown ProjectError kind"); +} + +constexpr qint64 manifestBytes = 4 * 1024 * 1024, assetBytes = 512 * 1024 * 1024; + +void validate(const ProjectManifest &manifest) +{ + if (manifest.format != QLatin1String("com.compositor.project")) + throw ProjectError(ProjectError::Kind::invalid); + if (manifest.version < 1 || manifest.version > 7) + throw ProjectError::unsupportedVersion(manifest.version); + if (manifest.colorSpace != QLatin1String("sRGB")) + throw ProjectError(ProjectError::Kind::invalid); + // The range refuses NaN and infinity as well. + if (manifest.resolution && !(*manifest.resolution >= 1 && *manifest.resolution <= 9600)) + throw ProjectError(ProjectError::Kind::invalid); + if (manifest.width < 1 || manifest.width > 30'000 || manifest.height < 1 || manifest.height > 30'000 || manifest.layers.size() > 10'000) + throw ProjectError(ProjectError::Kind::tooLarge); + for (const ProjectLayerRecord &layer : manifest.layers) { + // Text needs a valid style and pixels to show it. + if (layer.text && (!layer.text->isValid() || !layer.imageFile)) + throw ProjectError(ProjectError::Kind::invalid); + // Version 7's adjustments hold settings, never pixels or children. + if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid())) + throw ProjectError(ProjectError::Kind::invalid); + const bool group = layer.isGroup == true; + // Layer masks came with version 4, folder masks with 6. + const bool maskAllowed = !layer.maskFile + || (manifest.version >= (group ? 6 : 4) && *layer.maskFile == uuidString(layer.id) + QLatin1String(".mask.png")); + const bool placementAllowed = !layer.maskPlacement || (layer.maskPlacement->isValid() && layer.maskFile); + const double opacity = layer.opacity.value_or(1); + const bool plain = opacity == 1 && layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal; + if (!maskAllowed || (layer.maskEnabled && !layer.maskFile) || !placementAllowed || !std::isfinite(opacity) || opacity < 0 + || opacity > 1 || (manifest.version < 3 && !plain) || (group && !plain)) + throw ProjectError(ProjectError::Kind::invalid); + } + LayerHierarchy::validate(manifest.layers); + LiveMaskGraph::validate(manifest.layers); + QSet<QUuid> ids; + for (const ProjectLayerRecord &layer : manifest.layers) { + // A child needs a folder, so folders alone mark nesting. + if ((manifest.version < 5 && layer.maskSourceID) || (manifest.version == 1 && layer.isGroup == true)) + throw ProjectError(ProjectError::Kind::invalid); + } + for (const ProjectLayerRecord &layer : manifest.layers) { + const bool named = !layer.name.trimmed().isEmpty() && layer.name.toUtf8().size() <= 16'384; + const bool filed = !layer.imageFile || *layer.imageFile == uuidString(layer.id) + QLatin1String(".png"); + if (!layer.transform.isValid() || !named || !filed) + throw ProjectError(ProjectError::Kind::invalid); + ids.insert(layer.id); + } + if (manifest.activeLayerID && !ids.contains(*manifest.activeLayerID)) + throw ProjectError(ProjectError::Kind::invalid); +} + +// Images share 100 million pixels; masks share another. +void checkSize(QSize size, qint64 &used) +{ + const qint64 pixels = qint64(size.width()) * size.height(); + if (size.width() < 1 || size.width() > 30'000 || size.height() < 1 || size.height() > 30'000 || pixels > 100'000'000 - used) + throw ProjectError(ProjectError::Kind::tooLarge); + used += pixels; +} + +// A project's files are plain files inside it, never links. +void checkFile(const QString &file, const QString &package, qint64 maximumBytes, ProjectError::Kind missing) +{ + const QFileInfo info(file); + if (!info.exists() && !info.isSymLink()) + throw ProjectError(missing); + const QString root = QFileInfo(package).canonicalFilePath() + QLatin1Char('/'); + if (!info.canonicalFilePath().startsWith(root)) + throw ProjectError(ProjectError::Kind::invalid); + if (!info.isFile() || info.isSymLink() || info.size() > maximumBytes) + throw ProjectError(ProjectError::Kind::tooLarge); +} + +QByteArray png(const QImage &image) +{ + QByteArray data; + QBuffer buffer(&data); + QImageWriter writer(&buffer, "png"); + if (!writer.write(image)) + throw ProjectError(ProjectError::Kind::encode); + return data; +} + +[[noreturn]] void fail(const QString &what, const QString &path, const QString &reason) +{ + const QString message = QStringLiteral("%1 %2: %3").arg(what, path, reason); + qCWarning(lcIO).noquote() << message; + throw std::runtime_error(message.toStdString()); +} + +// Synced to disk: a swap must never expose empty files. +void writeFile(const QString &path, const QByteArray &data) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush() || ::fsync(file.handle()) != 0) + fail(QStringLiteral("could not write"), path, file.error() == QFile::NoError ? QString::fromLocal8Bit(std::strerror(errno)) : file.errorString()); +} + +void syncDirectory(const QString &path) +{ + const int descriptor = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_DIRECTORY); + const bool synced = descriptor >= 0 && ::fsync(descriptor) == 0; + const QString reason = QString::fromLocal8Bit(std::strerror(errno)); + if (descriptor >= 0) + ::close(descriptor); + if (!synced) + fail(QStringLiteral("could not sync"), path, reason); +} + +// Puts `staged` where `destination` is; a failed sync undoes it. +void replace(const QString &staged, const QString &destination, const QString &parent, bool &keepSibling) +{ + const QString besides = staged + QStringLiteral(".old"); + const QByteArray from = QFile::encodeName(staged), to = QFile::encodeName(destination), aside = QFile::encodeName(besides); + const auto moved = [](const QByteArray &source, const QByteArray &target) { return ::rename(source.constData(), target.constData()) == 0; }; + const auto exchanged = [&] { return ::renameat2(AT_FDCWD, from.constData(), AT_FDCWD, to.constData(), RENAME_EXCHANGE) == 0; }; + // Each way in has its way back. + std::function<bool()> undo; + // Where the old project sits once the new is in. + QString previous; + if (!QFileInfo::exists(destination) && !QFileInfo(destination).isSymLink()) { + if (!moved(from, to)) + fail(QStringLiteral("could not move the project to"), destination, QString::fromLocal8Bit(std::strerror(errno))); + undo = [&] { return moved(to, from); }; + } else if (exchanged()) { + undo = exchanged; + previous = staged; + } else { + if (errno != EINVAL && errno != ENOSYS && errno != ENOTSUP) + fail(QStringLiteral("could not replace"), destination, QString::fromLocal8Bit(std::strerror(errno))); + // No exchange here: two moves, the old kept until then. + qCWarning(lcIO) << "this file system cannot exchange paths; replacing" << destination << "in two steps"; + if (!moved(to, aside)) + fail(QStringLiteral("could not replace"), destination, QString::fromLocal8Bit(std::strerror(errno))); + if (!moved(from, to)) { + const QString reason = QString::fromLocal8Bit(std::strerror(errno)); + if (!moved(aside, to)) + qCCritical(lcIO) << "the previous project is left at" << besides; + fail(QStringLiteral("could not replace"), destination, reason); + } + undo = [&] { return moved(to, from) && moved(aside, to); }; + previous = besides; + } + try { + syncDirectory(parent); + } catch (const std::runtime_error &) { + // A way back that fails must delete nothing. + keepSibling = !undo(); + const QString stays = previous.isEmpty() ? QStringLiteral("the unsynced project could not be taken from ") + destination + : QStringLiteral("the previous project could not be put back; it stays at ") + previous; + if (keepSibling) + qCCritical(lcIO).noquote() << stays; + throw; + } + // The old project waits at the sibling's path for removal. + if (previous == besides && !moved(aside, from)) + qCWarning(lcIO) << "the previous project is left at" << besides; +} + +// The pixels and a thumbnail of at most 96 pixels. +ImportedImage asset(const QString &file, bool isMask, const QString &name, qint64 &used) +{ + ImageImporter::liftAllocationLimit(); + QImageReader reader(file); + const QImage::Format stored = reader.imageFormat(); + const bool deep = stored == QImage::Format_RGBA64 || stored == QImage::Format_RGBA64_Premultiplied || stored == QImage::Format_RGBX64 + || stored == QImage::Format_Grayscale16; + if (reader.format() != "png" || !reader.size().isValid() || deep) + throw ProjectError(ProjectError::Kind::missingImage); + checkSize(reader.size(), used); + QImage image = reader.read(); + if (!isMask) + image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + if (image.isNull()) + throw ProjectError(ProjectError::Kind::missingImage); + if (isMask && !LayerMask::isValid(image)) + throw ProjectError(ProjectError::Kind::invalid); + const int longest = std::max(image.width(), image.height()); + const QImage thumbnail = longest <= 96 ? image : image.scaled(image.size().scaled(96, 96, Qt::KeepAspectRatio).expandedTo(QSize(1, 1)), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (thumbnail.isNull()) + throw ProjectError(ProjectError::Kind::missingImage); + return ImportedImage(image, thumbnail, name); +} +} + +ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, description(kind)) {} + +ProjectError ProjectError::unsupportedVersion(qint64 version) +{ + return ProjectError(Kind::version, version, + QStringLiteral("This project uses format version %1. This app supports versions 1–7.").arg(version)); +} + +ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description) + : std::runtime_error(description.toStdString()), kind(kind), version(version) +{ +} + +void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path) +{ + validate(snapshot.manifest); + std::vector<std::pair<QString, QByteArray>> files; + qint64 pixels = 0, maskPixels = 0; + for (const ProjectLayerRecord &layer : snapshot.manifest.layers) { + for (const bool isMask : {false, true}) { + const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile; + if (!filename) + continue; + const std::map<QUuid, ImportedImage> &assets = isMask ? snapshot.masks : snapshot.images; + const auto found = assets.find(layer.id); + if (found == assets.end()) + throw ProjectError(ProjectError::Kind::missingImage); + const QImage image = found->second.image(); + if (isMask && !LayerMask::isValid(image)) + throw ProjectError(ProjectError::Kind::invalid); + checkSize(image.size(), isMask ? maskPixels : pixels); + files.push_back({*filename, png(image)}); + } + } + const QByteArray metadata = snapshot.manifest.encoded(); + if (metadata.size() > manifestBytes) + throw ProjectError(ProjectError::Kind::tooLarge); + // A sibling is written whole, then takes the project's place. + const QFileInfo destination(QDir::cleanPath(path)); + const QString staged = destination.absolutePath() + QLatin1String("/.") + destination.fileName() + QLatin1Char('.') + + uuidString(QUuid::createUuid()) + QLatin1String(".tmp"); + // Whatever lies at the sibling's path goes. + struct Staging { + QString path; + bool keep = false; + ~Staging() + { + const QFileInfo info(path); + if (!keep && (info.exists() || info.isSymLink()) + && !(info.isDir() && !info.isSymLink() ? QDir(path).removeRecursively() : QFile::remove(path))) + qCWarning(lcIO) << "could not remove" << path; + } + } staging{staged}; + if (!QDir().mkdir(staged) || !QDir().mkdir(staged + QLatin1String("/images"))) + fail(QStringLiteral("could not create"), staged, QStringLiteral("the folder cannot be made")); + writeFile(staged + QLatin1String("/manifest.json"), metadata); + for (const auto &[filename, data] : files) + writeFile(staged + QLatin1String("/images/") + filename, data); + syncDirectory(staged + QLatin1String("/images")); + syncDirectory(staged); + replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep); + qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images"; +} + +ProjectSnapshot ProjectStore::load(const QString &path) +{ + const QString metadataPath = path + QLatin1String("/manifest.json"); + checkFile(metadataPath, path, manifestBytes, ProjectError::Kind::invalid); + QFile metadataFile(metadataPath); + if (!metadataFile.open(QIODevice::ReadOnly)) + throw ProjectError(ProjectError::Kind::invalid); + const QByteArray metadata = metadataFile.readAll(); + // Qt hides errors after partial data: the byte count tells. + if (metadataFile.error() != QFile::NoError || metadata.size() != metadataFile.size()) { + qCWarning(lcIO) << "could not read" << metadataPath << ":" << metadataFile.errorString(); + throw ProjectError(ProjectError::Kind::invalid); + } + // The version comes first: a newer file may not decode. + const auto [format, version] = ProjectManifest::header(metadata); + if (format != QLatin1String("com.compositor.project")) + throw ProjectError(ProjectError::Kind::invalid); + if (version < 1 || version > 7) + throw ProjectError::unsupportedVersion(version); + const ProjectManifest manifest = ProjectManifest::decoded(metadata); + validate(manifest); + ProjectSnapshot snapshot{.manifest = manifest, .images = {}, .masks = {}}; + qint64 pixels = 0, maskPixels = 0; + for (const ProjectLayerRecord &layer : manifest.layers) { + for (const bool isMask : {false, true}) { + const std::optional<QString> &filename = isMask ? layer.maskFile : layer.imageFile; + if (!filename) + continue; + const QString file = path + QLatin1String("/images/") + *filename; + checkFile(file, path, assetBytes, ProjectError::Kind::missingImage); + (isMask ? snapshot.masks : snapshot.images).insert({layer.id, asset(file, isMask, layer.name, isMask ? maskPixels : pixels)}); + } + } + qCInfo(lcIO) << "loaded" << path << "with" << manifest.layers.size() << "layers and" << snapshot.images.size() + snapshot.masks.size() << "images"; + return snapshot; +} + diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h new file mode 100644 index 0000000..1e121ba --- /dev/null +++ b/src/IO/ProjectStore.h @@ -0,0 +1,95 @@ +#pragma once +#include "Document/LayerAdjustment.h" +#include "Document/LayerAppearance.h" +#include "Document/LayerEffects.h" +#include "Document/LayerTransform.h" +#include "Document/ShapeTool.h" +#include "Document/TypeTool.h" +#include "IO/ImageImporter.h" +#include <QByteArray> +#include <QString> +#include <QUuid> +#include <map> +#include <optional> +#include <stdexcept> +#include <utility> +#include <vector> + +struct LayerMask; + +// Absent optionals stay out of the manifest. +struct ProjectLayerRecord { + QUuid id; + QString name; + bool isVisible; + LayerTransform transform; + std::optional<QString> imageFile; + std::optional<QUuid> parentID = std::nullopt; + std::optional<bool> isGroup = std::nullopt; + std::optional<double> opacity = std::nullopt; + std::optional<LayerBlendMode> blendMode = std::nullopt; + std::optional<QString> maskFile = std::nullopt; + std::optional<bool> maskEnabled = std::nullopt; + std::optional<QUuid> maskSourceID = std::nullopt; + std::optional<LayerAdjustment> adjustment = std::nullopt; + std::optional<LayerTransform> maskPlacement = std::nullopt; + // Absent in older projects, which means linked. + std::optional<bool> maskLinked = std::nullopt; + // A shape layer's shape; older versions keep the pixels alone. + std::optional<LayerShapeStyle> shape = std::nullopt; + // What the layer draws round itself; its pixels stay. + std::optional<LayerEffects> effects = std::nullopt; + std::optional<LayerTextStyle> text = std::nullopt; +}; + +// A UUID as Swift writes it: upper case, no braces. +QString uuidString(const QUuid &id); + +struct ProjectManifest { + QString format = QStringLiteral("com.compositor.project"); + // Swift's Int: a wild number must reach validation whole. + qint64 version = 7; + QString colorSpace = QStringLiteral("sRGB"); + // Version-1 projects carry none: 72 pixels per inch. + std::optional<double> resolution = std::nullopt; + QUuid documentID; + qint64 width; + qint64 height; + std::optional<QUuid> activeLayerID; + std::vector<ProjectLayerRecord> layers; + + // Pretty JSON with sorted keys; absent optionals are left out. + QByteArray encoded() const; + // Throws ProjectError(invalid) on anything Swift's decoder refuses. + static ProjectManifest decoded(const QByteArray &data); + // Format and version alone: newer files may not decode. + static std::pair<QString, qint64> header(const QByteArray &data); +}; + +struct ProjectSnapshot { + ProjectManifest manifest; + std::map<QUuid, ImportedImage> images; + std::map<QUuid, ImportedImage> masks = {}; + // A record's mask, from its mask file and fields. + std::optional<LayerMask> mask(const ProjectLayerRecord &layer) const; +}; + +class ProjectError : public std::runtime_error { +public: + enum class Kind { invalid, version, missingImage, tooLarge, encode }; + explicit ProjectError(Kind kind); + static ProjectError unsupportedVersion(qint64 version); + const Kind kind; + // Set only for Kind::version. + const std::optional<qint64> version; + +private: + ProjectError(Kind kind, std::optional<qint64> version, const QString &description); +}; + +// A .comp project: a directory holding manifest.json and images/. +namespace ProjectStore { +// Replaces the project in one step or leaves it untouched. +void save(const ProjectSnapshot &snapshot, const QString &path); +ProjectSnapshot load(const QString &path); +} diff --git a/src/Logging.cpp b/src/Logging.cpp new file mode 100644 index 0000000..df3bdc4 --- /dev/null +++ b/src/Logging.cpp @@ -0,0 +1,5 @@ +#include "Logging.h" + +Q_LOGGING_CATEGORY(lcApp, "omaphoto.app") +Q_LOGGING_CATEGORY(lcRendering, "omaphoto.rendering") +Q_LOGGING_CATEGORY(lcIO, "omaphoto.io") diff --git a/src/Logging.h b/src/Logging.h new file mode 100644 index 0000000..b206935 --- /dev/null +++ b/src/Logging.h @@ -0,0 +1,6 @@ +#pragma once +#include <QLoggingCategory> + +Q_DECLARE_LOGGING_CATEGORY(lcApp) +Q_DECLARE_LOGGING_CATEGORY(lcRendering) +Q_DECLARE_LOGGING_CATEGORY(lcIO) diff --git a/src/Rendering/AdjustmentSurface.cpp b/src/Rendering/AdjustmentSurface.cpp new file mode 100644 index 0000000..49b7d49 --- /dev/null +++ b/src/Rendering/AdjustmentSurface.cpp @@ -0,0 +1,35 @@ +#include "Rendering/AdjustmentSurface.h" +#include "Document/BrushStroke.h" +#include "Logging.h" +#include <QPaintEngine> + +void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, qint64 pixelBudget) +{ + // The engine's device holds the pixels: a widget's window store. + const QPaintDevice &target = *context.paintEngine()->paintDevice(); + const QRect device(0, 0, target.width(), target.height()); + const QRect area = context.deviceTransform().mapRect(BrushRaster::visibleRect(context)).toAlignedRect().intersected(device); + if (area.isEmpty()) + return; + if (qint64(area.width()) * area.height() > pixelBudget) { + qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size(); + return; + } + QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied); + if (surface.isNull()) { + qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size(); + return; + } + surface.fill(0); + { + // The body draws in the painter's own coordinates. + QPainter painter(&surface); + painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top())); + body(painter); + } + // Drawn back under the painter's own opacity, mode and clip. + context.save(); + context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform()); + context.drawImage(QRectF(area), surface); + context.restore(); +} diff --git a/src/Rendering/AdjustmentSurface.h b/src/Rendering/AdjustmentSurface.h new file mode 100644 index 0000000..fd7b488 --- /dev/null +++ b/src/Rendering/AdjustmentSurface.h @@ -0,0 +1,9 @@ +#pragma once +#include <QPainter> +#include <functional> + +// Draws through an image surface over what the painter shows. +namespace AdjustmentSurface { +inline constexpr qint64 pixelBudget = 100'000'000; +void draw(QPainter &context, const std::function<void(QPainter &)> &body, qint64 pixelBudget = AdjustmentSurface::pixelBudget); +} diff --git a/src/Rendering/BrushCoverage.cpp b/src/Rendering/BrushCoverage.cpp new file mode 100644 index 0000000..654ee0d --- /dev/null +++ b/src/Rendering/BrushCoverage.cpp @@ -0,0 +1,238 @@ +#include "Rendering/BrushCoverage.h" +#include "Rendering/PoolMap.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include <QtConcurrent> +#include <cmath> +#include <limits> +#include <memory> +#include <mutex> + +namespace { +// The shader's uniforms. +struct Uniforms { + float a, b, c, d; + float originX, originY, radius, hardness; + float canvasWidth, canvasHeight, antialias, spacing; +}; + +float segmentDistanceSquared(float px, float py, const BrushSegment &s) +{ + const float vx = s.x1 - s.x0, vy = s.y1 - s.y0; + const float t = std::clamp(((px - s.x0) * vx + (py - s.y0) * vy) / std::max(vx * vx + vy * vy, 1e-12f), 0.0f, 1.0f); + const float dx = px - (s.x0 + t * vx), dy = py - (s.y0 + t * vy); + return dx * dx + dy * dy; +} + +// The hard tip's antialiased silhouette, as the shader draws it. +float hardCoverage(float distanceSquared, const Uniforms &u) +{ + return std::clamp((u.radius - std::sqrt(distanceSquared)) / u.antialias + 0.5f, 0.0f, 1.0f); +} + +// The optical density at the solid core: coverage capped. +const float solidDensity = -std::log(0.001f); + +// The shader's soft falloff as optical density, float for float. +float tipDensity(float distanceSquared, const Uniforms &u) +{ + const float t = std::clamp((std::sqrt(distanceSquared) / u.radius - u.hardness) / (1.0f - u.hardness), 0.0f, 1.0f); + // The solid and empty ends, as the formula yields them. + if (t == 0.0f) + return solidDensity; + if (t == 1.0f) + return 0.0f; + const float coverage = std::max(0.0f, (std::exp(-2.5f * t * t) - std::exp(-2.5f)) / (1.0f - std::exp(-2.5f))); + // Optical density adds; coverage is 1 − exp(−density). + return -std::log(std::max(1.0f - coverage, 0.001f)); +} + +// Table steps over d²/r², from hardness² out to the rim. +constexpr int densitySteps = 8192; + +// A soft tip's density, in radii; solid within its hardness. +std::shared_ptr<const std::vector<float>> densityTable(float hardness) +{ + static std::mutex lock; + static float cached = -1; + static std::shared_ptr<const std::vector<float>> table; + const std::lock_guard<std::mutex> guard(lock); + if (table && cached == hardness) + return table; + Uniforms unit{}; + unit.radius = 1; + unit.hardness = hardness; + const float start = hardness * hardness; + auto values = std::make_shared<std::vector<float>>(size_t(densitySteps) + 1); + for (int step = 0; step <= densitySteps; ++step) + (*values)[size_t(step)] = tipDensity(start + (1 - start) * float(step) / densitySteps, unit); + cached = hardness; + table = values; + return table; +} + +// Linear between steps; `core` clamps for the solid centre. +template <bool core> float tableDensity(const std::vector<float> &table, float beyondStart, float scale) +{ + float position = std::min(beyondStart * scale, float(densitySteps)); + if constexpr (core) + position = std::max(position, 0.0f); + const size_t index = size_t(std::min(int(position), densitySteps - 1)); + // One checked read: the upper step; the lower sits below. + const float *const upper = &table[index + 1]; + return upper[-1] + (position - float(index)) * (upper[0] - upper[-1]); +} + +// A soft tip's density: from the table, or by formula. +struct Fade { + const std::vector<float> &table; + // Where the table begins, h²r², and its steps per pixel². + float start; + float scale; + // The table's steps finer than float can tell apart. + bool exact; +}; + +// Eight-point Gauss–Legendre quadrature, clipped to the tip. +template <typename Density> float quadrature(float midpoint, float halfLength, float projection, const Density &density) +{ + constexpr float nodes[4] = {0.1834346425f, 0.5255324099f, 0.7966664774f, 0.9602898565f}; + constexpr float weights[4] = {0.3626837834f, 0.3137066459f, 0.2223810345f, 0.1012285363f}; + float integral = 0.0f; + for (int i = 0; i < 4; ++i) { + // The shader's order: far along a stroke, float rounds differently. + const float a = midpoint - halfLength * nodes[i] - projection, b = midpoint + halfLength * nodes[i] - projection; + integral += weights[i] * (density(a * a) + density(b * b)); + } + return integral; +} + +float segmentDensity(float px, float py, const BrushSegment &s, const Uniforms &u, const Fade &fade) +{ + const float vx = s.x1 - s.x0, vy = s.y1 - s.y0; + const float length = std::sqrt(vx * vx + vy * vy); + if (length < 1e-6f) { + const float distanceSquared = (px - s.x0) * (px - s.x0) + (py - s.y0) * (py - s.y0); + return fade.exact ? tipDensity(distanceSquared, u) : tableDensity<true>(fade.table, distanceSquared - fade.start, fade.scale); + } + const float dx = vx / length, dy = vy / length; + const float projection = (px - s.x0) * dx + (py - s.y0) * dy; + const float perpX = px - s.x0 - projection * dx, perpY = py - s.y0 - projection * dy; + const float perpendicularSquared = perpX * perpX + perpY * perpY; + const float radiusSquared = u.radius * u.radius; + if (perpendicularSquared >= radiusSquared) + return 0.0f; + const float reach = std::sqrt(radiusSquared - perpendicularSquared); + const float lo = std::max(0.0f, projection - reach), hi = std::min(length, projection + reach); + if (hi <= lo) + return 0.0f; + const float midpoint = (lo + hi) * 0.5f, halfLength = (hi - lo) * 0.5f; + // The table counts from h²r², so shift once per segment. + const float shifted = perpendicularSquared - fade.start; + float integral; + if (fade.exact) + integral = quadrature(midpoint, halfLength, projection, [&](float along) { return tipDensity(perpendicularSquared + along, u); }); + else if (shifted < 0) + // Only a segment through the solid core needs the clamp. + integral = quadrature(midpoint, halfLength, projection, [&](float along) { return tableDensity<true>(fade.table, shifted + along, fade.scale); }); + else + integral = quadrature(midpoint, halfLength, projection, [&](float along) { return tableDensity<false>(fade.table, shifted + along, fade.scale); }); + return integral * halfLength / u.spacing; +} + +// A tile's rows, from bits fetched before the threads. +struct Band { + const BrushCoverage::Work *work; + uchar *bits; + qsizetype stride; + int top; + int bottom; +}; + +// Permanent paint and the replaceable tail stay apart. +void renderBand(const Band &band, const Uniforms &u, const std::vector<BrushSegment> &segments, size_t settledCount, const std::vector<float> &table) +{ + const BrushCoverage::Work &work = *band.work; + const int width = int(work.rect.width()); + // The table's span, h²r² to r², in pixels squared. + const float inner = u.hardness * u.hardness; + const Fade fade{table, inner * u.radius * u.radius, densitySteps / ((1 - inner) * u.radius * u.radius), + (1 - inner) / densitySteps < std::numeric_limits<float>::epsilon()}; + for (int y = band.top; y < band.bottom; ++y) { + uchar *row = band.bits + y * band.stride; + for (int x = 0; x < width; ++x) { + float &permanent = work.tile->permanent[size_t(y) * width + x]; + const float lx = x + 0.5f, ly = y + 0.5f; + const float px = u.originX + lx * u.a + ly * u.c, py = u.originY + lx * u.b + ly * u.d; + if (px < 0 || py < 0 || px >= u.canvasWidth || py >= u.canvasHeight) { + row[x] = 0; + continue; + } + if (u.hardness >= 1.0f) { + // Hard tips keep their antialiased silhouette. + float settled = INFINITY, tail = INFINITY; + for (size_t i = 0; i < settledCount; ++i) + settled = std::min(settled, segmentDistanceSquared(px, py, segments[i])); + for (size_t i = settledCount; i < segments.size(); ++i) + tail = std::min(tail, segmentDistanceSquared(px, py, segments[i])); + const float value = std::max(permanent, hardCoverage(settled, u)); + permanent = value; + row[x] = uchar(std::round(255.0f * std::max(value, hardCoverage(tail, u)))); + } else { + float value = permanent, tail = 0.0f; + for (size_t i = 0; i < settledCount; ++i) + value += segmentDensity(px, py, segments[i], u, fade); + for (size_t i = settledCount; i < segments.size(); ++i) + tail += segmentDensity(px, py, segments[i], u, fade); + permanent = std::min(value, 20.0f); + row[x] = uchar(std::round(255.0f * (1.0f - std::exp(-std::min(value + tail, 20.0f))))); + } + } + } +} +} + +BrushCoverage::Tile BrushCoverage::tile(int width, int height) +{ + try { + return Tile{std::vector<float>(size_t(width) * size_t(height), 0.0f)}; + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } +} + +void BrushCoverage::render(const std::vector<Work> &tiles, const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail, + const QTransform &mapping, const BrushSettings &settings, QSizeF canvas) +{ + if (tiles.empty()) + return; + std::vector<BrushSegment> segments = settled; + segments.insert(segments.end(), tail.begin(), tail.end()); + Uniforms base{}; + base.a = float(mapping.m11()); + base.b = float(mapping.m12()); + base.c = float(mapping.m21()); + base.d = float(mapping.m22()); + base.radius = float(settings.diameter / 2); + base.hardness = float(settings.hardness); + base.canvasWidth = float(canvas.width()); + base.canvasHeight = float(canvas.height()); + base.antialias = float(std::max(0.001, std::min(std::hypot(mapping.m11(), mapping.m12()), std::hypot(mapping.m21(), mapping.m22())))); + base.spacing = float(std::max(0.25, settings.diameter * BrushStroke::spacingFraction(settings.hardness))); + const std::shared_ptr<const std::vector<float>> table = densityTable(base.hardness); + // Row bands keep every core busy, however few tiles changed. + std::vector<Band> bands; + for (const Work &item : tiles) { + uchar *const bits = item.context->bits(); + const int height = int(item.rect.height()); + for (int top = 0; top < height; top += 32) + bands.push_back({&item, bits, item.context->bytesPerLine(), top, std::min(height, top + 32)}); + } + PoolMap::blocking(bands, [&](const Band &band) { + Uniforms u = base; + const QPointF origin = mapping.map(band.work->rect.topLeft()); + u.originX = float(origin.x()); + u.originY = float(origin.y()); + renderBand(band, u, segments, settled.size(), *table); + }); +} diff --git a/src/Rendering/BrushCoverage.h b/src/Rendering/BrushCoverage.h new file mode 100644 index 0000000..24d4976 --- /dev/null +++ b/src/Rendering/BrushCoverage.h @@ -0,0 +1,33 @@ +#pragma once +#include <QImage> +#include <QRectF> +#include <QSizeF> +#include <QTransform> +#include <vector> + +struct BrushSettings; + +// A piece of the centreline, in document points (Swift's SIMD4). +struct BrushSegment { + float x0; + float y0; + float x1; + float y1; +}; + +// Twin of MetalBrushCoverage: the continuous brush on the CPU. +namespace BrushCoverage { +// Density laid so far, a float a pixel. +struct Tile { + std::vector<float> permanent; +}; +// One tile's density, rect, and the gray context previewed. +struct Work { + Tile *tile; + QRectF rect; + QImage *context; +}; +Tile tile(int width, int height); +void render(const std::vector<Work> &tiles, const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail, + const QTransform &mapping, const BrushSettings &settings, QSizeF canvas); +} diff --git a/src/Rendering/BrushCursorOverlay.cpp b/src/Rendering/BrushCursorOverlay.cpp new file mode 100644 index 0000000..d65edb9 --- /dev/null +++ b/src/Rendering/BrushCursorOverlay.cpp @@ -0,0 +1,92 @@ +#include "Rendering/BrushCursorOverlay.h" +#include "Rendering/AdjustmentSurface.h" +#include <QPainterPath> + +namespace { +// A white line under a black one, as Swift's. +void stroked(QPainter &painter, const QRectF &ellipse, bool dashed) +{ + for (const auto &[colour, width] : {std::pair(QColor(Qt::white), 2.5), std::pair(QColor(Qt::black), 1.0)}) { + QPen pen(colour, width, Qt::SolidLine, Qt::FlatCap); + // Four on, three off, in points: Qt counts in widths. + if (dashed) + pen.setDashPattern({4 / width, 3 / width}); + painter.setPen(pen); + painter.drawEllipse(ellipse); + } +} + +QRect around(QPointF point, double reach) +{ + return QRectF(point.x() - reach, point.y() - reach, reach * 2, reach * 2).toAlignedRect(); +} +} + +QRegion BrushCursorOverlay::update(std::optional<QPointF> point, double diameter, std::optional<double> hardness, std::optional<QPointF> sample, + const QImage &preview, double previewOpacity, const QImage &tip) +{ + const std::optional<QRectF> next = point ? std::optional(QRectF(point->x() - diameter / 2, point->y() - diameter / 2, diameter, diameter)) : std::nullopt; + QRegion dirty; + // Images compare by identity, as Swift's `!==`. + if (m_circle != next || m_preview.cacheKey() != preview.cacheKey() || m_previewOpacity != previewOpacity || m_tip.cacheKey() != tip.cacheKey() + || m_hardness != hardness) { + if (m_circle) + dirty += m_circle->adjusted(-3, -3, 3, 3).toAlignedRect(); + m_circle = next; + m_preview = preview; + m_previewOpacity = previewOpacity; + m_tip = tip; + m_hardness = hardness; + if (m_circle) + dirty += m_circle->adjusted(-3, -3, 3, 3).toAlignedRect(); + } + if (m_marker != sample) { + if (m_marker) + dirty += around(*m_marker, markerReach + 3); + m_marker = sample; + if (m_marker) + dirty += around(*m_marker, markerReach + 3); + } + return dirty; +} + +void BrushCursorOverlay::draw(QPainter &painter) const +{ + painter.save(); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setBrush(Qt::NoBrush); + if (m_circle) { + if (!m_preview.isNull()) { + painter.save(); + QPainterPath ellipse; + ellipse.addEllipse(*m_circle); + painter.setClipPath(ellipse, Qt::IntersectClip); + painter.setOpacity(painter.opacity() * m_previewOpacity); + // Swift's transparency layer: the tip cuts at drawing resolution. + AdjustmentSurface::draw(painter, [this](QPainter &layer) { + layer.setRenderHint(QPainter::SmoothPixmapTransform); + layer.drawImage(*m_circle, m_preview); + // Only what one click lays down: soft tips preview softly. + layer.setCompositionMode(QPainter::CompositionMode_DestinationIn); + layer.drawImage(*m_circle, m_tip); + }); + painter.restore(); + } + stroked(painter, *m_circle, false); + // At zero hardness the inner ellipse has no size. + if (m_hardness) { + const double inset = m_circle->width() * (1 - *m_hardness) / 2; + stroked(painter, m_circle->adjusted(inset, inset, -inset, -inset), true); + } + } + if (m_marker) { + QPainterPath arms; + arms.moveTo(m_marker->x() - markerReach, m_marker->y()); + arms.lineTo(m_marker->x() + markerReach, m_marker->y()); + arms.moveTo(m_marker->x(), m_marker->y() - markerReach); + arms.lineTo(m_marker->x(), m_marker->y() + markerReach); + for (const auto &[colour, width] : {std::pair(QColor(Qt::white), 3.0), std::pair(QColor(Qt::black), 1.0)}) + painter.strokePath(arms, QPen(colour, width, Qt::SolidLine, Qt::RoundCap)); + } + painter.restore(); +} diff --git a/src/Rendering/BrushCursorOverlay.h b/src/Rendering/BrushCursorOverlay.h new file mode 100644 index 0000000..2d2e197 --- /dev/null +++ b/src/Rendering/BrushCursorOverlay.h @@ -0,0 +1,33 @@ +#pragma once +#include <QImage> +#include <QPainter> +#include <QRegion> +#include <optional> + +// Swift's brush circle; the canvas draws it over its pixels. +class BrushCursorOverlay { +public: + // Moves circle and marker; returns the old and new areas. + QRegion update(std::optional<QPointF> point, double diameter, std::optional<double> hardness, + std::optional<QPointF> sample = std::nullopt, const QImage &preview = QImage(), double previewOpacity = 1, + const QImage &tip = QImage()); + void draw(QPainter &painter) const; + std::optional<QRectF> circle() const { return m_circle; } + std::optional<double> hardness() const { return m_hardness; } + std::optional<QPointF> marker() const { return m_marker; } + const QImage &preview() const { return m_preview; } + const QImage &tip() const { return m_tip; } + +private: + static constexpr double markerReach = 7; + std::optional<QRectF> m_circle; + // Clone Stamp's source crosshair, in view points. + std::optional<QPointF> m_marker; + // What a Clone Stamp click would stamp, inside the circle. + QImage m_preview; + double m_previewOpacity = 1; + // One click's coverage, shaping the preview's edge. + QImage m_tip; + // While hardness is dragged: the full-strength share, as a ring. + std::optional<double> m_hardness; +}; diff --git a/src/Rendering/CanvasViewport.cpp b/src/Rendering/CanvasViewport.cpp new file mode 100644 index 0000000..1813bf8 --- /dev/null +++ b/src/Rendering/CanvasViewport.cpp @@ -0,0 +1,71 @@ +#include "Rendering/CanvasViewport.h" +#include <algorithm> +#include <cmath> + +QRectF CanvasViewport::documentRect(QSizeF size) const +{ + const QSizeF scaled(size.width() * pointsPerPixel(), size.height() * pointsPerPixel()); + return {center().x() - scaled.width() / 2 + pan.width(), + center().y() - scaled.height() / 2 + pan.height(), scaled.width(), scaled.height()}; +} + +QPointF CanvasViewport::documentPoint(QPointF from, QSizeF documentSize) const +{ + const QPointF origin = documentRect(documentSize).topLeft(); + return {(from.x() - origin.x()) / pointsPerPixel(), (from.y() - origin.y()) / pointsPerPixel()}; +} + +QPointF CanvasViewport::viewPoint(QPointF from, QSizeF documentSize) const +{ + const QPointF origin = documentRect(documentSize).topLeft(); + return {origin.x() + from.x() * pointsPerPixel(), origin.y() + from.y() * pointsPerPixel()}; +} + +void CanvasViewport::fit(QSizeF documentSize) +{ + m_followsFit = true; + if (viewSize.width() <= 0 || viewSize.height() <= 0) + return; + const double fitted = std::min(std::max(1.0, viewSize.width() - 96) / documentSize.width(), + std::max(1.0, viewSize.height() - 96) / documentSize.height()); + m_zoom = std::clamp(fitted * backingScale, minimumZoom, maximumZoom); + pan = {0, 0}; +} + +void CanvasViewport::resize(QSizeF to, double newScale, std::optional<QSizeF> documentSize) +{ + const double oldScale = pointsPerPixel(); + viewSize = to; + backingScale = std::max(1.0, newScale); + if (m_followsFit && documentSize) { + fit(*documentSize); + return; + } + const double ratio = pointsPerPixel() / oldScale; + pan = {pan.width() * ratio, pan.height() * ratio}; +} + +void CanvasViewport::setZoom(double value, QPointF anchoredAt, QSizeF documentSize) +{ + if (!std::isfinite(value)) + return; + const QPointF pixel = documentPoint(anchoredAt, documentSize); + m_zoom = std::clamp(value, minimumZoom, maximumZoom); + const QPointF moved = viewPoint(pixel, documentSize); + pan += QSizeF(anchoredAt.x() - moved.x(), anchoredAt.y() - moved.y()); + m_followsFit = false; +} + +void CanvasViewport::translate(QSizeF by) +{ + pan += by; + m_followsFit = false; +} + +bool operator==(const CanvasViewport &lhs, const CanvasViewport &rhs) +{ + return lhs.viewSize.width() == rhs.viewSize.width() && lhs.viewSize.height() == rhs.viewSize.height() + && lhs.backingScale == rhs.backingScale && lhs.m_zoom == rhs.m_zoom + && lhs.pan.width() == rhs.pan.width() && lhs.pan.height() == rhs.pan.height() + && lhs.m_followsFit == rhs.m_followsFit; +} diff --git a/src/Rendering/CanvasViewport.h b/src/Rendering/CanvasViewport.h new file mode 100644 index 0000000..4bddb7d --- /dev/null +++ b/src/Rendering/CanvasViewport.h @@ -0,0 +1,34 @@ +#pragma once +#include <QPointF> +#include <QRectF> +#include <QSizeF> +#include <optional> + +// Document: top-left pixels. View: widget points. Zoom 1: device pixels. +class CanvasViewport { +public: + static constexpr double minimumZoom = 0.001; + static constexpr double maximumZoom = 32; + + QSizeF viewSize{0, 0}; + double backingScale = 1; + QSizeF pan{0, 0}; + + double zoom() const { return m_zoom; } + bool followsFit() const { return m_followsFit; } + double pointsPerPixel() const { return m_zoom / backingScale; } + QPointF center() const { return {viewSize.width() / 2, viewSize.height() / 2}; } + + QRectF documentRect(QSizeF size) const; + QPointF documentPoint(QPointF from, QSizeF documentSize) const; + QPointF viewPoint(QPointF from, QSizeF documentSize) const; + void fit(QSizeF documentSize); + void resize(QSizeF to, double backingScale, std::optional<QSizeF> documentSize); + void setZoom(double value, QPointF anchoredAt, QSizeF documentSize); + void translate(QSizeF by); + friend bool operator==(const CanvasViewport &lhs, const CanvasViewport &rhs); + +private: + double m_zoom = 1; + bool m_followsFit = true; +}; diff --git a/src/Rendering/DownsampleCache.cpp b/src/Rendering/DownsampleCache.cpp new file mode 100644 index 0000000..cf56a93 --- /dev/null +++ b/src/Rendering/DownsampleCache.cpp @@ -0,0 +1,268 @@ +#include "Rendering/DownsampleCache.h" +#include "Rendering/PoolMap.h" +#include "Document/BrushStroke.h" +#include <QThreadPool> +#include <QtConcurrent> +#include <algorithm> +#include <array> +#include <cmath> +#include <cstring> +#include <numbers> + +extern "C" { +#include "AdjustPixels.h" +} + +namespace { +constexpr int taps = 12; + +// Lanczos-3 at half scale, sampled between source pixels. +const std::array<float, taps> &kernel() +{ + static const std::array<float, taps> weights = [] { + std::array<double, taps> lobes{}; + double sum = 0; + for (int tap = 0; tap < taps; ++tap) { + const double x = std::numbers::pi * (tap - 5.5) / 2; + lobes[tap] = std::sin(x) / x * std::sin(x / 3) / (x / 3); + sum += lobes[tap]; + } + std::array<float, taps> result{}; + for (int tap = 0; tap < taps; ++tap) + result[tap] = float(lobes[tap] / sum); + return result; + }(); + return weights; +} + +// A mask's row, one channel: scalar, clamped at the edges. +void filterMaskRow(const uchar *row, int width, float *line, int outWidth) +{ + const float *const weights = kernel().data(); + for (int x = 0; x < outWidth; ++x) { + float sum = 0; + for (int tap = 0; tap < taps; ++tap) + sum += weights[tap] * float(row[std::clamp(2 * x - 5 + tap, 0, width - 1)]); + line[x] = sum; + } +} + +// Four channels in one vector: each lane sums as before. +using Float4 = float __attribute__((vector_size(16))); + +void filterColorRow(const uchar *row, int width, float *line, int outWidth, float *floats) +{ + for (int index = 0; index < width * 4; ++index) + floats[index] = float(row[index]); + const float *const weights = kernel().data(); + for (int x = 0; x < outWidth; ++x) { + Float4 sum = {0, 0, 0, 0}; + for (int tap = 0; tap < taps; ++tap) { + Float4 pixel; + std::memcpy(&pixel, floats + std::clamp(2 * x - 5 + tap, 0, width - 1) * 4, sizeof pixel); + sum += weights[tap] * pixel; + } + std::memcpy(line + x * 4, &sum, sizeof sum); + } +} + +// One band's scratch: twelve rows, sums, a source row. +struct Band { + int first; + int last; + float *lines; + float *sums; + float *floats; +}; + +struct Target { + uchar *bits; + qsizetype stride; + int width; +}; + +template <int channels> void filterBand(const QImage &source, const Target &target, const Band &band) +{ + const float *const weights = kernel().data(); + const int samples = target.width * channels; + std::array<int, taps> held; + held.fill(-1); + for (int y = band.first; y < band.last; ++y) { + std::array<const float *, taps> rows; + for (int tap = 0; tap < taps; ++tap) { + const int sourceRow = std::clamp(2 * y - 5 + tap, 0, source.height() - 1); + const int slot = sourceRow % taps; + float *line = band.lines + size_t(slot) * samples; + if (held[slot] != sourceRow) { + if (channels == 4) + filterColorRow(source.constScanLine(sourceRow), source.width(), line, target.width, band.floats); + else + filterMaskRow(source.constScanLine(sourceRow), source.width(), line, target.width); + held[slot] = sourceRow; + } + rows[tap] = line; + } + // Tap by tap over whole rows: the same order, contiguous. + float *const sum = band.sums; + std::fill(sum, sum + samples, 0.0f); + for (int tap = 0; tap < taps; ++tap) { + const float weight = weights[tap]; + const float *const row = rows.data()[tap]; + for (int sample = 0; sample < samples; ++sample) + sum[sample] += weight * row[sample]; + } + uchar *out = target.bits + y * target.stride; + for (int sample = 0; sample < samples; ++sample) + out[sample] = uchar(std::clamp(int(sum[sample] + 0.5f), 0, 255)); + } +} + +void filterBand(const QImage &source, const Target &target, int channels, const Band &band) +{ + if (channels == 4) + filterBand<4>(source, target, band); + else + filterBand<1>(source, target, band); +} + +// False when the scratch rows cannot be allocated. +bool lanczosHalve(const QImage &source, QImage &destination, int channels, bool threaded) +{ + const int height = destination.height(); + const int count = threaded ? std::clamp(height / 32, 1, QThreadPool::globalInstance()->maxThreadCount()) : 1; + const size_t samples = size_t(destination.width()) * channels; + // Workers allocate nothing: a failure throws here, caught below. + const size_t bandFloats = size_t(taps) * samples + samples + size_t(source.width()) * channels; + // Writable bits are fetched once: scanLine() is not thread-safe. + const Target target{destination.bits(), destination.bytesPerLine(), destination.width()}; + try { + std::vector<float> scratch(bandFloats * count); + std::vector<Band> bands; + for (int index = 0; index < count; ++index) { + float *const lines = scratch.data() + bandFloats * index; + bands.push_back({height * index / count, height * (index + 1) / count, lines, lines + taps * samples, lines + taps * samples + samples}); + } + if (threaded) + PoolMap::blocking(bands, [&](const Band &band) { filterBand(source, target, channels, band); }); + else + filterBand(source, target, channels, bands.front()); + } catch (const std::bad_alloc &) { + return false; + } + return true; +} +} + +DownsampleCache::DownsampleCache(qint64 pixelBudget) : m_pixelBudget(pixelBudget) {} + +DownsampleCache &DownsampleCache::shared() +{ + static DownsampleCache cache; + return cache; +} + +qint64 DownsampleCache::Entry::pixels() const +{ + qint64 total = 0; + for (const QImage &level : levels) + total += qint64(level.width()) * level.height(); + return total; +} + +int DownsampleCache::level(double factor) +{ + if (!std::isfinite(factor) || factor <= 0 || factor >= 0.5) + return 0; + return std::min(maxLevel, int(std::floor(std::log2(1 / factor)))); +} + +QImage DownsampleCache::imageDrawnAt(const QImage &image, double factor) +{ + return imageAtLevel(image, level(factor)).image; +} + +DownsampleCache::Reduced DownsampleCache::imageAtLevel(const QImage &image, int wanted) +{ + if (wanted < 1 || (image.width() <= 1 && image.height() <= 1)) + return {image, 0}; + const qint64 key = image.cacheKey(); + std::vector<QImage> levels; + { + const std::lock_guard<std::mutex> guard(m_lock); + m_clock += 1; + if (const auto found = m_entries.find(key); found != m_entries.end()) + levels = found->second.levels; + } + while (int(levels.size()) < wanted) { + const QImage &previous = levels.empty() ? image : levels.back(); + if (previous.width() <= 1 && previous.height() <= 1) + break; + const std::optional<QImage> next = halve(previous); + if (!next) + break; + levels.push_back(*next); + } + if (levels.empty()) + return {image, 0}; + { + const std::lock_guard<std::mutex> guard(m_lock); + const auto found = m_entries.find(key); + if (found != m_entries.end() && found->second.levels.size() >= levels.size()) + found->second.lastUse = m_clock; + else + m_entries.insert_or_assign(key, Entry{image, levels, m_clock}); + evict(key); + } + const int applied = std::min(wanted, int(levels.size())); + return {levels[applied - 1], applied}; +} + +void DownsampleCache::evict(qint64 keeping) +{ + qint64 total = 0; + for (const auto &[key, entry] : m_entries) + total += entry.pixels(); + while (total > m_pixelBudget) { + auto oldest = m_entries.end(); + for (auto entry = m_entries.begin(); entry != m_entries.end(); ++entry) { + if (entry->first != keeping && (oldest == m_entries.end() || entry->second.lastUse < oldest->second.lastUse)) + oldest = entry; + } + if (oldest == m_entries.end()) + return; + total -= oldest->second.pixels(); + m_entries.erase(oldest); + } +} + +std::optional<QImage> DownsampleCache::halve(const QImage &image, bool threaded) +{ + const bool mask = image.format() == QImage::Format_Grayscale8; + const int width = (image.width() + 1) / 2, height = (image.height() + 1) / 2; + // Color fades into transparent padding wherever an image is cut. + const int pad = mask ? 0 : 8; + const int paddedWidth = width * 2 + pad * 2, paddedHeight = height * 2 + pad * 2; + const QImage::Format format = mask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied; + QImage source(paddedWidth, paddedHeight, format), destination(paddedWidth / 2, paddedHeight / 2, format); + if (source.isNull() || destination.isNull()) + return std::nullopt; + source.fill(0); + { + QPainter painter(&source); + BrushRaster::draw(image, QRectF(pad, pad, image.width(), image.height()), painter); + } + if (mask) { + // An odd last column and row repeat. + for (int y = 0; y < image.height() && paddedWidth > image.width(); ++y) + source.scanLine(y)[image.width()] = source.constScanLine(y)[image.width() - 1]; + if (paddedHeight > image.height()) + std::copy_n(source.constScanLine(image.height() - 1), paddedWidth, source.scanLine(image.height())); + } + if (!lanczosHalve(source, destination, mask ? 1 : 4, threaded)) + return std::nullopt; + if (mask) + return destination; + rgba_clamp_premultiplied(destination.bits(), size_t(destination.width()) * destination.height()); + const QImage cropped = destination.copy(pad / 2, pad / 2, width, height); + return cropped.isNull() ? std::nullopt : std::optional(cropped); +} diff --git a/src/Rendering/DownsampleCache.h b/src/Rendering/DownsampleCache.h new file mode 100644 index 0000000..027c172 --- /dev/null +++ b/src/Rendering/DownsampleCache.h @@ -0,0 +1,44 @@ +#pragma once +#include <QImage> +#include <mutex> +#include <optional> +#include <unordered_map> +#include <vector> + +// Sharp halved copies of layer images, least recently used dropped. +class DownsampleCache { +public: + // Pixels of halved copies kept at once. + static constexpr qint64 pixelBudget = 100'000'000; + // Most halvings ever used; QPainter does the rest. + static constexpr int maxLevel = 6; + + struct Reduced { + QImage image; + int level; + }; + + explicit DownsampleCache(qint64 pixelBudget = DownsampleCache::pixelBudget); + static DownsampleCache &shared(); + + static int level(double factor); + QImage imageDrawnAt(const QImage &image, double factor); + Reduced imageAtLevel(const QImage &image, int wanted); + // Unthreaded when its caller already works side by side. + static std::optional<QImage> halve(const QImage &image, bool threaded = true); + +private: + struct Entry { + QImage source; + std::vector<QImage> levels; + quint64 lastUse; + qint64 pixels() const; + }; + + void evict(qint64 keeping); + + const qint64 m_pixelBudget; + std::unordered_map<qint64, Entry> m_entries; + quint64 m_clock = 0; + std::mutex m_lock; +}; diff --git a/src/Rendering/EditorCanvas+Brush.cpp b/src/Rendering/EditorCanvas+Brush.cpp new file mode 100644 index 0000000..29ade5d --- /dev/null +++ b/src/Rendering/EditorCanvas+Brush.cpp @@ -0,0 +1,231 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Logging.h" +#include <QKeyEvent> +#include <cmath> + +// Swift's brush on the canvas: strokes, tip drag, circle. +void CanvasView::brushMouseDown(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document().value().size()); + // Alt-click with Clone Stamp sets where it copies from. + if (m_session.tool() == NavigationTool::cloneStamp && modifiers.testFlag(Qt::AltModifier)) { + m_session.setCloneSource(pixel); + updateBrushCursor(); + return; + } + m_brushPointer = point; + // Shift paints a line on from the last stroke's end. + const bool shift = modifiers.testFlag(Qt::ShiftModifier); + const std::optional<QPointF> from = shift ? m_session.shiftLineStart() : std::nullopt; + if (from) { + m_session.beginBrush(*from); + m_session.continueBrush(pixel); + } else { + m_session.beginBrush(pixel); + } + m_brushAxisAnchor = shift ? std::optional(pixel) : std::nullopt; + m_brushAxisHorizontal = std::nullopt; + m_brushLastPixel = pixel; + synchronizeDisplay(); +} + +// The circle follows; a drag goes on, Shift keeping axis. +bool CanvasView::brushMouseMove(QPointF point, Qt::KeyboardModifiers modifiers, bool dragging) +{ + m_brushPointer = point; + updateBrushCursor(); + // Swift's mouseMoved paints nothing; mouseDragged does. + if (!dragging || (!m_session.brushStroke() && !m_session.warpStroke()) || m_session.isProjectBusy() || !m_session.document()) + return false; + QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + if (modifiers.testFlag(Qt::ShiftModifier)) { + // The first three pixels settle the axis; it never flips. + const QPointF anchor = m_brushAxisAnchor ? *m_brushAxisAnchor : m_brushLastPixel.value_or(pixel); + if (!m_brushAxisAnchor) { + m_brushAxisAnchor = anchor; + m_brushAxisHorizontal = std::nullopt; + } + if (!m_brushAxisHorizontal && std::hypot(pixel.x() - anchor.x(), pixel.y() - anchor.y()) >= 3) + m_brushAxisHorizontal = std::abs(pixel.x() - anchor.x()) >= std::abs(pixel.y() - anchor.y()); + if (m_brushAxisHorizontal) + pixel = *m_brushAxisHorizontal ? QPointF(pixel.x(), anchor.y()) : QPointF(anchor.x(), pixel.y()); + else + pixel = anchor; + } else { + // Re-anchoring resets the axis, so the anchor alone goes. + m_brushAxisAnchor = std::nullopt; + } + m_brushLastPixel = pixel; + m_session.continueBrush(pixel); + synchronizeDisplay(); + return true; +} + +// The release reaches its own point, then the stroke lands. +void CanvasView::brushMouseUp(QPointF point) +{ + if ((!m_session.brushStroke() && !m_session.warpStroke()) || m_session.isProjectBusy()) + return; + if (m_session.document()) + m_session.continueBrush(m_session.viewport.documentPoint(point, m_session.document()->size())); + m_session.finishBrushImmediately(); + synchronizeDisplay(); +} + +// Right-drag: size across, hardness with Shift; the circle stays. +void CanvasView::beginBrushTipDrag(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const BrushSettings &settings = m_session.brushSettings(); + m_brushTipDrag = BrushTipDrag{point, settings.diameter, settings.hardness, modifiers.testFlag(Qt::ShiftModifier)}; + m_brushPointer = point; + updateBrushCursor(); +} + +void CanvasView::dragBrushTip(QPointF point, Qt::KeyboardModifiers modifiers) +{ + BrushTipDrag &drag = m_brushTipDrag.value(); + drag.hardnessShown = modifiers.testFlag(Qt::ShiftModifier); + const double dx = point.x() - drag.start.x(); + BrushSettings settings = m_session.brushSettings(); + if (drag.hardnessShown) { + // The full range across 200 points. + settings.hardness = std::clamp(drag.hardness + dx / 200, 0.0, 1.0); + settings.diameter = drag.diameter; + } else { + // The circle's edge follows the pointer on screen. + const double perPixel = std::max(0.0001, m_session.viewport.pointsPerPixel()); + settings.diameter = std::clamp(std::round(drag.diameter + 2 * dx / perPixel), 1.0, 2000.0); + settings.hardness = drag.hardness; + } + m_session.setBrushSettings(settings); + m_brushPointer = drag.start; + updateBrushCursor(); +} + +void CanvasView::endBrushTipDrag(QPointF point) +{ + m_brushTipDrag = std::nullopt; + m_brushPointer = point; + updateBrushCursor(); +} + +// The circle at the pointer, a stroke's own size. +void CanvasView::updateBrushCursor() +{ + const bool shows = isBrushTool(m_session.tool()) && !m_spaceHeld && !picking(); + const double diameter = m_session.brushStroke() ? m_session.brushStroke()->settings.diameter : m_session.brushSettings().diameter; + // Clone Stamp marks its source; between strokes, previews a click. + std::optional<QPointF> sample; + QImage preview; + const std::optional<CanvasDocument> &document = m_session.document(); + if (shows && m_session.tool() == NavigationTool::cloneStamp && m_brushPointer && document) { + const QPointF point = m_session.viewport.documentPoint(*m_brushPointer, document->size()); + if (const std::optional<QPointF> source = m_session.cloneSamplePoint(point)) + sample = m_session.viewport.viewPoint(*source, document->size()); + const std::optional<QSizeF> offset = m_session.cloneStrokeOffset(point); + if (!m_session.brushStroke() && !m_optionHeld && offset) + preview = clonePreview(point + QPointF(offset->width(), offset->height()), diameter, *document); + } + const std::optional<double> hardness = m_brushTipDrag && m_brushTipDrag->hardnessShown ? std::optional(m_session.brushSettings().hardness) : std::nullopt; + const QImage tip = preview.isNull() ? QImage() : cloneTip(diameter, m_session.brushSettings().hardness); + update(m_brushCursor.update(shows ? m_brushPointer : std::nullopt, std::max(1.0, diameter * m_session.viewport.pointsPerPixel()), hardness, sample, + preview, m_session.brushSettings().opacity, tip)); +} + +// The brush engine paints one click; the preview softens alike. +QImage CanvasView::cloneTip(double diameter, double hardness) +{ + if (m_cloneTip && m_cloneTip->diameter == diameter && m_cloneTip->hardness == hardness) + return m_cloneTip->image; + const int side = std::max(1, int(std::ceil(diameter))); + const QSizeF size(side, side); + BrushSettings settings{.diameter = diameter, .hardness = hardness, .red = 1, .green = 1, .blue = 1}; + QImage image; + try { + BrushStroke stroke(ImageLayer(QStringLiteral("Tip"), size), false, settings, size); + stroke.append(QPointF(side / 2.0, side / 2.0)); + stroke.flush(); + const PaintSnapshot painted = stroke.paintSnapshot(); + QImage context = BrushRaster::context(side, side, false); + QPainter painter(&context); + BrushRaster::draw(painted.asset.image(), painted.bounds, painter); + painter.end(); + image = context; + } catch (const ProjectError &error) { + qCWarning(lcRendering) << "the clone tip cannot be painted:" << error.what(); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "the clone tip cannot be painted:" << error.what(); + } + m_cloneTip = CloneTip{diameter, hardness, image}; + return image; +} + +// The source around `center` at screen resolution, kept until changed. +QImage CanvasView::clonePreview(QPointF center, double diameter, const CanvasDocument &document) +{ + const double scale = m_session.viewport.pointsPerPixel() * m_session.viewport.backingScale; + const ClonePreviewKey key{center, diameter, scale, m_session.brushRevision(), m_session.history.undoCount(), + m_session.cloneSettings().sampleAllLayers, m_session.activeLayerID()}; + if (m_clonePreview && m_clonePreview->key == key) + return m_clonePreview->image; + const int side = std::min(1024, std::max(1, int(std::ceil(diameter * scale)))); + QImage image; + try { + QImage context = BrushRaster::context(side, side, false); + QPainter painter(&context); + const double perPixel = side / diameter; + painter.scale(perPixel, perPixel); + painter.translate(diameter / 2 - center.x(), diameter / 2 - center.y()); + // A painted asset flattens here; the catch covers it too. + if (m_session.cloneSettings().sampleAllLayers) { + m_session.drawLiveComposite(document, painter); + } else if (const std::optional<ImageLayer> layer = m_session.activeLayer(); layer && layer->asset) { + const LayerTransform transform = m_session.displayedTransform(*layer); + LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {}); + } + painter.end(); + image = context; + } catch (const ExportError &error) { + qCWarning(lcRendering) << "the clone preview cannot be drawn:" << error.what(); + } + m_clonePreview = ClonePreview{key, image}; + return image; +} + +// Swift's brush keys: Tab, B, E, digits, brackets. +bool CanvasView::brushKey(const QKeyEvent &key) +{ + if (key.modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)) + return false; + if (key.key() == Qt::Key_Tab && !key.modifiers().testFlag(Qt::ShiftModifier)) { + m_session.cycleToolMode(); + updateCursor(); + updateBrushCursor(); + return true; + } + if (key.key() == Qt::Key_B || key.key() == Qt::Key_E) { + m_session.selectTool(NavigationTool::brush); + m_session.setBrushMode(key.key() == Qt::Key_E ? BrushToolMode::erase : BrushToolMode::paint); + return true; + } + const QString text = key.text(); + if (text.size() == 1 && text.front().isDigit() && m_session.usesOpacityKeys()) { + m_session.typeOpacityDigit(text.front().digitValue()); + return true; + } + return brushBracket(text); +} + +// Brackets: size, and hardness with Shift, for brushes. +bool CanvasView::brushBracket(const QString &text) +{ + if (!isBrushTool(m_session.tool()) || (text != u"[" && text != u"]" && text != u"{" && text != u"}")) + return false; + if (text == u"[" || text == u"]") + m_session.changeBrushSize(text == u"]"); + else + m_session.changeBrushHardness(text == u"}"); + return true; +} diff --git a/src/Rendering/EditorCanvas+Crop.cpp b/src/Rendering/EditorCanvas+Crop.cpp new file mode 100644 index 0000000..a8a2a6a --- /dev/null +++ b/src/Rendering/EditorCanvas+Crop.cpp @@ -0,0 +1,112 @@ +#include "Rendering/EditorCanvas.h" +#include <QKeyEvent> +#include <array> + +namespace { +// CGRect's contains: the far edges lie outside. +bool contains(const QRectF &rect, QPointF point) +{ + return point.x() >= rect.left() && point.x() < rect.right() && point.y() >= rect.top() && point.y() < rect.bottom(); +} + +// Swift's frame positions, top left first, as Qt's shapes. +Qt::CursorShape resizeShape(int index) +{ + constexpr std::array<Qt::CursorShape, 8> shapes = {Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor, + Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor}; + return shapes[size_t(index)]; +} +} + +// The grip under the pointer: the first region holding it. +std::optional<int> CanvasView::cropRegion(QPointF point) const +{ + for (const TransformOverlay::CropRegion ®ion : m_overlay.cropResizeRegions()) { + if (contains(region.rect, point)) + return region.index; + } + return std::nullopt; +} + +QCursor CanvasView::cropCursor(QPointF point) const +{ + const std::optional<int> index = cropRegion(point); + return index ? QCursor(resizeShape(*index)) : QCursor(Qt::CrossCursor); +} + +// A grip resizes, a drawn frame moves, elsewhere draws anew. +void CanvasView::beginCropDrag(QPointF point) +{ + const QSizeF size = m_session.document().value().size(); + const QPointF pixel = m_session.viewport.documentPoint(point, size); + // Swift's current cursor: the old frame's, kept until the release. + const QCursor cursor = cropCursor(point); + // Swift's fallback is dead: the Crop tool has a frame. + const QRectF rect = m_session.visibleCropRect().value(); + CropDrag::Mode mode{CropDrag::Kind::create}; + if (const std::optional<int> index = cropRegion(point)) + mode = {CropDrag::Kind::resize, *index}; + else if (m_session.cropRect() && contains(*m_session.cropRect(), pixel) && rect != QRectF(QPointF(0, 0), size)) + mode = {CropDrag::Kind::move}; + else + m_session.setCropRect(std::nullopt); + m_cropDrag = CropDrag{pixel, rect, mode}; + const SnapGuides targets = m_session.cropSnapTargets(); + m_cropSnap = CropSnap{targets.xs, targets.ys, cropSnapDistance / std::max(m_session.viewport.pointsPerPixel(), 0.0001)}; + m_dragCursor = cursor; + updateCursor(); +} + +// Without the left button the release was lost: drag ends. +bool CanvasView::cropMove(QPointF point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers) +{ + if (!m_cropDrag) + return false; + if (!buttons.testFlag(Qt::LeftButton)) { + endCropDrag(); + return false; + } + if (m_session.tool() != NavigationTool::crop || m_session.isProjectBusy() || !m_session.document()) + return false; + dragCrop(point, modifiers); + synchronizeDisplay(); + return true; +} + +// Alt keeps the middle still; Ctrl leaves the edges unsnapped. +void CanvasView::dragCrop(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document().value().size()); + const bool symmetric = modifiers.testFlag(Qt::AltModifier); + const std::optional<double> ratio = m_session.cropRatio(); + const CropDrag &drag = m_cropDrag.value(); + QRectF next = drag.updated(pixel, ratio, symmetric); + if (!modifiers.testFlag(Qt::ControlModifier)) + next = m_cropSnap.value().apply(next, drag, pixel, ratio, symmetric); + if (CropGeometry::valid(next)) + m_session.setCropRect(next); +} + +// Swift's cropDrag = nil: its didSet lets the cursor go. +void CanvasView::endCropDrag() +{ + if (!m_cropDrag) + return; + m_cropDrag.reset(); + m_dragCursor.reset(); + updateCursor(); +} + +// Escape drops the frame; Return and Enter crop to it. +bool CanvasView::cropKey(const QKeyEvent &key) +{ + const bool enter = key.key() == Qt::Key_Return || key.key() == Qt::Key_Enter; + if (m_session.tool() != NavigationTool::crop || !(enter || key.key() == Qt::Key_Escape)) + return false; + endCropDrag(); + if (enter) + m_session.commitCrop(); + else + m_session.cancelCrop(); + return true; +} diff --git a/src/Rendering/EditorCanvas+Cursors.cpp b/src/Rendering/EditorCanvas+Cursors.cpp new file mode 100644 index 0000000..fa18517 --- /dev/null +++ b/src/Rendering/EditorCanvas+Cursors.cpp @@ -0,0 +1,373 @@ +#include "Rendering/EditorCanvas.h" +#include "Rendering/EyedropperIcon.h" +#include <QApplication> +#include <QKeyEvent> +#include <QLineEdit> +#include <QPainter> +#include <QPainterPath> +#include <cmath> + +namespace { +// A haloed magnifier with a plus or a minus. +QCursor zoomCursor(bool out, double ratio) +{ + QPixmap pixmap(QSize(24, 24) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + const QRectF glyph(2, 2, 20, 20); + // The lens sits toward the top left, as the symbol's. + const QPointF center(glyph.left() + glyph.width() * 0.41, glyph.top() + glyph.height() * 0.40); + const double radius = glyph.width() * 0.27; + QPainterPath shape; + shape.addEllipse(center, radius, radius); + const QPointF grip = center + QPointF(radius, radius) * std::sqrt(0.5); + shape.moveTo(grip); + shape.lineTo(glyph.bottomRight() - QPointF(1, 1)); + shape.moveTo(center - QPointF(radius * 0.55, 0)); + shape.lineTo(center + QPointF(radius * 0.55, 0)); + if (!out) { + shape.moveTo(center - QPointF(0, radius * 0.55)); + shape.lineTo(center + QPointF(0, radius * 0.55)); + } + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::white, 4, Qt::SolidLine, Qt::RoundCap)); + painter.drawPath(shape); + painter.setPen(Qt::NoPen); + painter.setBrush(Qt::white); + painter.drawEllipse(center, radius, radius); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::black, 1.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawPath(shape); + return QCursor(pixmap, 10, 10); +} + +QPixmap cursorPixmap(QSize size, double ratio) +{ + QPixmap pixmap(size * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + return pixmap; +} + +// A black stroke over a white one, like the crosshair. +void strokeHaloed(QPainter &painter, const QPainterPath &path, double width) +{ + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::white, width + 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(path); + painter.setPen(QPen(Qt::black, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(path); +} + +// The crosshair: two 15-point lines through the hot spot. +void drawCrosshair(QPainter &painter, QPointF hotSpot) +{ + QPainterPath cross; + cross.moveTo(hotSpot - QPointF(7.5, 0)); + cross.lineTo(hotSpot + QPointF(7.5, 0)); + cross.moveTo(hotSpot - QPointF(0, 7.5)); + cross.lineTo(hotSpot + QPointF(0, 7.5)); + strokeHaloed(painter, cross, 1.2); +} + +// A plus to add or a minus to subtract, beside. +void drawBadge(QPainter &painter, QPointF center, SelectionMode mode) +{ + if (mode == SelectionMode::replace) + return; + QPainterPath badge; + badge.moveTo(center - QPointF(3, 0)); + badge.lineTo(center + QPointF(3, 0)); + if (mode == SelectionMode::add) { + badge.moveTo(center - QPointF(0, 3)); + badge.lineTo(center + QPointF(0, 3)); + } + strokeHaloed(painter, badge, 1.2); +} + +// An icon on its 18-unit grid fitted to `box`. +void drawIcon(QPainter &painter, SelectionIcon icon, const QRectF &box, const QColor &colour) +{ + painter.save(); + painter.translate(box.topLeft()); + painter.scale(box.width() / 18, box.height() / 18); + painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + SelectionIcons::paint(painter, icon); + painter.restore(); +} + +// Black over eight white offsets: the icon's outline. +void drawHaloedIcon(QPainter &painter, SelectionIcon icon, const QRectF &box) +{ + for (const QPointF offset : {QPointF(-1, 0), QPointF(1, 0), QPointF(0, -1), QPointF(0, 1), QPointF(-0.7, -0.7), QPointF(0.7, 0.7), QPointF(-0.7, 0.7), QPointF(0.7, -0.7)}) + drawIcon(painter, icon, box.translated(offset), Qt::white); + drawIcon(painter, icon, box, Qt::black); +} + +// Scissors on an 11-point grid: handles below, blades crossed. +QPainterPath scissorsPath(const QRectF &box) +{ + const double unit = box.width() / 11; + const auto at = [&](double x, double y) { return box.topLeft() + QPointF(x * unit, y * unit); }; + QPainterPath path; + path.addEllipse(at(2.8, 8.4), 2 * unit, 2 * unit); + path.addEllipse(at(8.2, 8.4), 2 * unit, 2 * unit); + path.moveTo(at(3.9, 6.7)); + path.lineTo(at(8.8, 0.6)); + path.moveTo(at(7.1, 6.7)); + path.lineTo(at(2.2, 0.6)); + return path; +} + +// A dashed selection box, white under black, as Swift badges. +void drawSelectionBox(QPainter &painter, const QRectF &box) +{ + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::white, 2.5)); + painter.drawRect(box); + QPen dashed(Qt::black, 1); + dashed.setCapStyle(Qt::FlatCap); + dashed.setDashPattern({2, 1.5}); + painter.setPen(dashed); + painter.drawRect(box); +} +} + +QCursor CanvasView::moveSelectionCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(28, 32), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF tip(4, 3); + const QPainterPath arrow = arrowPath().translated(tip); + painter.setPen(QPen(Qt::white, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(arrow); + painter.fillPath(arrow, Qt::black); + drawSelectionBox(painter, QRectF(tip + QPointF(9.5, 13.5), QSizeF(8, 6))); + return QCursor(pixmap, 4, 3); +} + +QCursor CanvasView::selectionCursor(SelectionIcon icon, SelectionMode mode, double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(44, 36), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF hotSpot(8, 8); + drawCrosshair(painter, hotSpot); + // The icon below right of the cross, then its badge. + const QRectF box(hotSpot + QPointF(7, 7), QSizeF(12, 12)); + drawHaloedIcon(painter, icon, box); + drawBadge(painter, QPointF(box.right() + 5, box.center().y()), mode); + return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y())); +} + +// Swift's wand: sparkle at the hot spot, stick down right. +QCursor CanvasView::wandCursor(SelectionMode mode, double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(34, 34), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF hotSpot(7, 7); + QPainterPath stick(hotSpot + QPointF(6, 6)); + stick.lineTo(hotSpot + QPointF(20, 20)); + QPainterPath marks; + for (const QPointF direction : {QPointF(0, -1), QPointF(0, 1), QPointF(-1, 0), QPointF(1, 0)}) { + marks.moveTo(hotSpot + direction * 2.5); + marks.lineTo(hotSpot + direction * 6); + } + if (mode != SelectionMode::replace) { + const QPointF center = hotSpot + QPointF(17, 5); + marks.moveTo(center - QPointF(3, 0)); + marks.lineTo(center + QPointF(3, 0)); + if (mode == SelectionMode::add) { + marks.moveTo(center - QPointF(0, 3)); + marks.lineTo(center + QPointF(0, 3)); + } + } + // White outlines first, so neither covers the other's black. + painter.setBrush(Qt::NoBrush); + for (const auto &[colour, stickWidth, markWidth] : {std::tuple(Qt::white, 5.0, 3.2), std::tuple(Qt::black, 2.4, 1.2)}) { + painter.setPen(QPen(colour, stickWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(stick); + painter.setPen(QPen(colour, markWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(marks); + } + return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y())); +} + +QCursor CanvasView::eyedropperCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(24, 24), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + // Swift's box: sixteen white offsets first, then black. + const QRectF glyph(2, 2, 20, 20); + const double scale = glyph.width() / 18; + const auto draw = [&painter, &glyph, scale](QPointF offset, const QColor &colour) { + painter.save(); + painter.translate(glyph.topLeft() + offset); + painter.scale(scale, scale); + painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + EyedropperIcon::paint(painter); + painter.restore(); + }; + for (int step = 0; step < 16; ++step) + draw(QPointF(std::cos(step * M_PI / 8), std::sin(step * M_PI / 8)) * 1.25, Qt::white); + draw(QPointF(), Qt::black); + // The dropper's tip is the hot spot, as Swift's. + const QPointF tip = glyph.topLeft() + EyedropperIcon::tip() * scale; + return QCursor(pixmap, int(std::round(tip.x())), int(std::round(tip.y()))); +} + +// Swift's arrow with the scissors symbol beside, haloed white. +QCursor CanvasView::movePixelsCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(36, 36), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF tip(4, 3); + const QPainterPath arrow = arrowPath().translated(tip); + painter.setPen(QPen(Qt::white, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(arrow); + painter.fillPath(arrow, Qt::black); + strokeHaloed(painter, scissorsPath(QRectF(tip + QPointF(9, 12), QSizeF(11, 11))), 1.2); + return QCursor(pixmap, 4, 3); +} + +// Ctrl-drag cuts and moves; with Alt it copies. +QCursor CanvasView::pixelDragCursor(bool duplicate, double ratio) +{ + return duplicate ? duplicateCursor(ratio) : movePixelsCursor(ratio); +} + +void CanvasView::updateCursor() +{ + const NavigationTool tool = m_session.tool(); + // A transform, outline or pixel drag keeps its cursor. + if (m_dragCursor) + setCursor(*m_dragCursor); + else if (m_selectionDragStart) + setCursor(moveSelectionCursor(devicePixelRatio())); + else if (m_pixelDragStart) + setCursor(pixelDragCursor(m_session.pixelMove() && m_session.pixelMove()->duplicate, devicePixelRatio())); + else if (m_lastDragPoint) + setCursor(Qt::ClosedHandCursor); + // Picking outranks Space and the tools, as Swift's cursor rects. + else if (picking()) + setCursor(eyedropperCursor(devicePixelRatio())); + else if (m_session.hueTargeting()) + setCursor(Qt::SizeHorCursor); + else if (m_spaceHeld || tool == NavigationTool::hand) + setCursor(Qt::OpenHandCursor); + else if (tool == NavigationTool::crop) + setCursor(m_hover ? cropCursor(*m_hover) : QCursor(Qt::CrossCursor)); + else if (tool == NavigationTool::zoom) + setCursor(zoomCursor(m_optionHeld, devicePixelRatio())); + else if (tool == NavigationTool::type) + setCursor(m_inlineTextEditor && m_hover ? m_inlineTextEditor->cursorAt(*m_hover).value_or(QCursor(Qt::IBeamCursor)) : QCursor(Qt::IBeamCursor)); + else if (tool == NavigationTool::move) + setCursor(m_hover ? transformCursor(*m_hover, heldModifiers()) : QCursor(Qt::ArrowCursor)); + else if (isSelectionTool(tool)) + setCursor(lassoCursor(heldModifiers(), m_hover)); + // The circle, preview and crosshair stand in; Alt picks anew. + else if (tool == NavigationTool::cloneStamp && m_session.cloneSource() && !m_optionHeld) + setCursor(Qt::BlankCursor); + else if (tool == NavigationTool::idle) + setCursor(Qt::ArrowCursor); + else + setCursor(Qt::CrossCursor); +} + +Qt::KeyboardModifiers CanvasView::heldModifiers() const +{ + return (m_optionHeld ? Qt::AltModifier : Qt::NoModifier) | (m_controlHeld ? Qt::ControlModifier : Qt::NoModifier) + | (m_shiftHeld ? Qt::ShiftModifier : Qt::NoModifier); +} + +void CanvasView::readModifiers(Qt::KeyboardModifiers modifiers) +{ + m_optionHeld = modifiers.testFlag(Qt::AltModifier); + m_controlHeld = modifiers.testFlag(Qt::ControlModifier); + m_shiftHeld = modifiers.testFlag(Qt::ShiftModifier); +} + +// Swift's monitors: the window's keys, and modifiers from anywhere. +bool CanvasView::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() != QEvent::KeyPress && event->type() != QEvent::KeyRelease) + return false; + const auto *key = static_cast<QKeyEvent *>(event); + // Alt, Ctrl and Shift from any window, as Swift's monitor. + if (key->key() == Qt::Key_Alt || key->key() == Qt::Key_Control || key->key() == Qt::Key_Shift) { + if (!key->isAutoRepeat()) { + (key->key() == Qt::Key_Alt ? m_optionHeld : key->key() == Qt::Key_Control ? m_controlHeld : m_shiftHeld) = event->type() == QEvent::KeyPress; + modifiersChanged(); + } + return false; + } + const auto *widget = qobject_cast<QWidget *>(watched); + if (!widget || widget->window() != window()) + return false; + // Shift-+ and Shift-− step the blend mode, except while typing. + const bool typing = qobject_cast<QLineEdit *>(QApplication::focusWidget()) || (m_inlineTextEditor && hasFocus()); + if (event->type() != QEvent::KeyPress || typing || key->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)) + return false; + const bool forward = key->key() == Qt::Key_Plus || key->key() == Qt::Key_Equal; + if (key->modifiers().testFlag(Qt::ShiftModifier) && (forward || key->key() == Qt::Key_Underscore || key->key() == Qt::Key_Minus)) { + m_session.cycleBlendMode(forward); + return true; + } + // Brush brackets too; the canvas's own keys take its own. + return QApplication::focusWidget() != this && !m_session.levels() && brushBracket(key->text()); +} + +// A key changed: badge, box and cursor follow at once. +void CanvasView::modifiersChanged() +{ + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (m_marqueeDragPixel && draft && (draft->kind == LassoKind::rectangle || draft->kind == LassoKind::ellipse)) + dragMarqueeDraft(*m_marqueeDragPixel, heldModifiers()); + // So does a crop drag, as Alt and Ctrl change. + if (m_cropDrag && m_session.tool() == NavigationTool::crop && m_session.document()) + dragCrop(m_hover.value(), heldModifiers()); + // As Swift's monitor: Alt may start or end picking. + synchronizeDisplay(); + m_session.updateHeldSelectionKeys(m_shiftHeld, m_optionHeld); + updateCursor(); + updateBrushCursor(); +} + +// The pointer arrow as a path, tip at the origin. +QPainterPath CanvasView::arrowPath() +{ + QPainterPath arrow(QPointF(0, 0)); + for (const QPointF point : {QPointF(0, 16.5), QPointF(3.9, 12.8), QPointF(6.6, 19), QPointF(9.2, 17.9), QPointF(6.6, 11.8), QPointF(11.8, 11.8)}) + arrow.lineTo(point); + arrow.closeSubpath(); + return arrow; +} + +// Arrows back to front, each outlined under its fill. +QCursor CanvasView::duplicateCursor(double ratio) +{ + QPixmap pixmap(QSize(28, 32) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF tip(4, 3); + for (const auto &[offset, fill, outline] : {std::tuple(5.0, Qt::white, Qt::black), std::tuple(0.0, Qt::black, Qt::white)}) { + const QPainterPath arrow = arrowPath().translated(tip + QPointF(offset, offset)); + painter.setPen(QPen(outline, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(arrow); + painter.fillPath(arrow, fill); + } + return QCursor(pixmap, 4, 3); +} diff --git a/src/Rendering/EditorCanvas+Effects.cpp b/src/Rendering/EditorCanvas+Effects.cpp new file mode 100644 index 0000000..b86c1fb --- /dev/null +++ b/src/Rendering/EditorCanvas+Effects.cpp @@ -0,0 +1,72 @@ +#include "Document/LayerEffects+Renderer.h" +#include "Rendering/EditorCanvas.h" +#include "Rendering/RasterSnapshot.h" +#include "Rendering/TiledLayerRenderer.h" + +// Swift's effects surface: a painted layer keeps its effects on. + +// A stroke previews as the finished layer will look (TiledLayerRenderer). +void CanvasView::drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask, + const LayerRenderer::Options &options, double scale, const Center ¢er, QPainter &target) +{ + const std::optional<ImportedImage> &previous = stroke.layer.asset; + const QImage image = previous && !previous->raster ? previous->image() : QImage(); + const std::shared_ptr<const RasterSnapshot> raster = previous ? previous->raster : nullptr; + const LayerRenderer::Options bare{.scale = scale, .opacity = options.opacity, .blendMode = options.blendMode, .clip = options.clip}; + if (!stroke.isMask) { + // The surface holds the wet pixels under their effects. + if (LayerEffectsSurface *surface = strokeSurface(layer, stroke, mask); surface && surface->image()) { + const LayerTransform grown = LayerEffectsRenderer::placed(transform, *surface->image(), surface->margin); + surface->placement = grown; + LayerRenderer::draw(*surface->image(), grown, center(grown.center()), target, bare); + return; + } + if (const std::optional<EffectsPreviewCache::Result> effects = m_session.effectsPreviews.rendered(layer.id)) { + const LayerTransform grown = effects->placement.value_or(LayerEffectsRenderer::placed(layer.transform, effects->image, effects->inset)); + LayerRenderer::draw(effects->image, grown, center(grown.center()), target, bare); + } + TiledLayerRenderer::drawStroke(stroke.width, stroke.height, stroke.sourceRect, stroke.patches(), image, raster, transform, center(transform.center()), + target, options); + return; + } + if (stroke.layer.mask && stroke.layer.mask->placement) { + // A placed mask: the layer draws through its own grid. + if (!layer.asset) + return; + LayerRenderer::Options through = options; + through.mask = stroke.placedMaskPreview(*stroke.layer.mask->placement).value_or(QImage()); + if (layer.asset->raster) + TiledLayerRenderer::drawRaster(layer.asset->raster, transform, center(transform.center()), target, through); + else + LayerRenderer::draw(layer.asset->image(), transform, center(transform.center()), target, through); + return; + } + TiledLayerRenderer::drawMaskStroke(stroke.width, stroke.height, stroke.sourceRect, stroke.patches(), + stroke.layer.mask ? std::optional(stroke.layer.mask->asset) : std::nullopt, image, raster, transform, + center(transform.center()), target, bare); +} + +// Made as the stroke starts, updated as it goes. +LayerEffectsSurface *CanvasView::strokeSurface(const ImageLayer &layer, const BrushStroke &stroke, const std::optional<QImage> &mask) +{ + const LayerEffects effects = layer.effects.value_or(LayerEffects()).visible(); + if (effects.isEmpty() || !effects.isValid()) + return nullptr; + const QSizeF grid(stroke.width, stroke.height); + if (!m_strokeSurface || !m_strokeSurface->matches(layer.id, effects, grid, stroke.sourceRect)) + m_strokeSurface = LayerEffectsSurface::make(layer.id, effects, grid, stroke.sourceRect); + if (!m_strokeSurface) + return nullptr; + m_strokeSurface->update(stroke.layer.asset, stroke.patches(), mask); + return m_strokeSurface.get(); +} + +// The surface shows until the new effects land: nothing blinks. +void CanvasView::handOnStrokeSurface() +{ + if (m_session.brushStroke() || !m_strokeSurface) + return; + if (m_strokeSurface->image() && m_strokeSurface->placement) + m_session.effectsPreviews.seed(m_strokeSurface->layerID, *m_strokeSurface->image(), *m_strokeSurface->placement); + m_strokeSurface.reset(); +} diff --git a/src/Rendering/EditorCanvas+Gradient.cpp b/src/Rendering/EditorCanvas+Gradient.cpp new file mode 100644 index 0000000..5413abb --- /dev/null +++ b/src/Rendering/EditorCanvas+Gradient.cpp @@ -0,0 +1,45 @@ +#include "Rendering/EditorCanvas.h" +#include <cmath> +#include <numbers> + +// Grabs a pending end, else starts a line there. +void CanvasView::beginGradientDrag(QPointF point) +{ + if (!m_session.document()) + return; + if (const std::optional<std::pair<QPointF, QPointF>> line = m_overlay.gradientLine()) { + if (std::hypot(point.x() - line->second.x(), point.y() - line->second.y()) <= 10) { + m_gradientDrag = GradientHandle::end; + return; + } + if (std::hypot(point.x() - line->first.x(), point.y() - line->first.y()) <= 10) { + m_gradientDrag = GradientHandle::start; + return; + } + } + m_session.beginGradient(m_session.viewport.documentPoint(point, m_session.document()->size())); + m_gradientDrag = m_session.gradientEdit() ? std::optional(GradientHandle::end) : std::nullopt; + synchronizeDisplay(); +} + +bool CanvasView::dragGradient(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const std::optional<GradientEdit> &edit = m_session.gradientEdit(); + if (!m_gradientDrag || !edit || !m_session.document()) + return false; + const bool start = *m_gradientDrag == GradientHandle::start; + QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + if (modifiers.testFlag(Qt::ShiftModifier)) + pixel = snapped(pixel, start ? edit->end : edit->start); + m_session.moveGradient(start ? std::optional(pixel) : std::nullopt, start ? std::nullopt : std::optional(pixel)); + synchronizeDisplay(); + return true; +} + +QPointF CanvasView::snapped(QPointF point, QPointF anchor) +{ + const double dx = point.x() - anchor.x(), dy = point.y() - anchor.y(); + const double length = std::hypot(dx, dy); + const double angle = std::round(std::atan2(dy, dx) / (std::numbers::pi / 4)) * (std::numbers::pi / 4); + return {anchor.x() + std::cos(angle) * length, anchor.y() + std::sin(angle) * length}; +} diff --git a/src/Rendering/EditorCanvas+Navigation.cpp b/src/Rendering/EditorCanvas+Navigation.cpp new file mode 100644 index 0000000..406276f --- /dev/null +++ b/src/Rendering/EditorCanvas+Navigation.cpp @@ -0,0 +1,498 @@ +#include "Rendering/EditorCanvas.h" +#include "UI/FloatingPanel.h" +#include <QApplication> +#include <QKeyEvent> +#include <QMouseEvent> +#include <QNativeGestureEvent> +#include <QPainter> +#include <QPainterPath> +#include <QStyleHints> +#include <QWheelEvent> +#include <QWindow> +#include <cmath> + +namespace { +bool isArrow(int key) +{ + return key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down; +} + +std::optional<NavigationTool> toolForKey(int key) +{ + switch (key) { + case Qt::Key_B: + case Qt::Key_E: + return NavigationTool::brush; + case Qt::Key_J: + return NavigationTool::spotHealing; + case Qt::Key_S: + return NavigationTool::cloneStamp; + case Qt::Key_T: + return NavigationTool::type; + case Qt::Key_G: + return NavigationTool::gradient; + case Qt::Key_U: + return NavigationTool::shape; + case Qt::Key_I: + return NavigationTool::eyedropper; + case Qt::Key_W: + return NavigationTool::wand; + case Qt::Key_A: + return NavigationTool::idle; + case Qt::Key_R: + return NavigationTool::blur; + case Qt::Key_C: + return NavigationTool::crop; + case Qt::Key_V: + return NavigationTool::move; + case Qt::Key_H: + return NavigationTool::hand; + case Qt::Key_Z: + return NavigationTool::zoom; + default: + return std::nullopt; + } +} +} + +bool CanvasView::event(QEvent *event) +{ + // Open text takes its editing keys ahead of the menus. + if (event->type() == QEvent::ShortcutOverride && m_inlineTextEditor && InlineTextEditor::claims(*static_cast<QKeyEvent *>(event))) { + event->accept(); + return true; + } + // A text view keeps Shift-Tab: the focus stays put. + if (event->type() == QEvent::KeyPress && static_cast<QKeyEvent *>(event)->key() == Qt::Key_Backtab && m_inlineTextEditor) + return true; + // Tab cycles a tool's mode, as Swift's canvas takes it. + if (event->type() == QEvent::KeyPress && static_cast<QKeyEvent *>(event)->key() == Qt::Key_Tab) { + keyPressEvent(static_cast<QKeyEvent *>(event)); + return true; + } + if (event->type() == QEvent::NativeGesture) { + const auto *gesture = static_cast<QNativeGestureEvent *>(event); + // A pinch, as Swift's magnify; not mid-drag or mid-stroke. + if (gesture->gestureType() == Qt::ZoomNativeGesture && !m_transformDrag && !m_cropDrag && !m_session.brushStroke() && !m_session.warpStroke()) { + m_session.zoom(m_session.viewport.zoom() * (1 + gesture->value()), gesture->position()); + event->accept(); + return true; + } + } + return QWidget::event(event); +} + +void CanvasView::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + syncGeometry(); +} + +void CanvasView::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + syncGeometry(); + // The window's keys and Alt, as Swift's monitors watch them. + qApp->installEventFilter(this); + // Alt, Ctrl and Shift may be down already. + readModifiers(QApplication::keyboardModifiers()); + updateCursor(); + // Another screen may scale differently, as Swift's backing changes. + connect(window()->windowHandle(), &QWindow::screenChanged, this, &CanvasView::syncGeometry, Qt::UniqueConnection); + // A mounted canvas takes keys; dialogs and pickers keep theirs. + QMetaObject::invokeMethod(this, [this] { + if (m_session.document() && !QApplication::activeModalWidget() && !m_session.showsNewDocument() && !m_session.showsImporter() + && !m_session.colorPicker()) + setFocus(Qt::OtherFocusReason); + }, Qt::QueuedConnection); +} + +void CanvasView::hideEvent(QHideEvent *event) +{ + qApp->removeEventFilter(this); + QWidget::hideEvent(event); +} + +void CanvasView::syncGeometry() +{ + // After the layout pass, as Swift waits out SwiftUI's. + QMetaObject::invokeMethod(this, [this] { + const double scale = devicePixelRatio(); + const QSizeF bounds(size()); + if (m_session.viewport.viewSize == bounds && m_session.viewport.backingScale == scale) + return; + m_session.viewport.resize(bounds, scale, m_session.document() ? std::optional(m_session.document()->size()) : std::nullopt); + update(); + m_session.notify(); + }, Qt::QueuedConnection); +} + +void CanvasView::consumeFocusRequest(int request) +{ + if (request == m_lastFocusRequest) + return; + m_lastFocusRequest = request; + // An open draft leaves the keys where they are. + QMetaObject::invokeMethod(this, [this] { + if (!QApplication::activeModalWidget() && !m_session.textDraft()) + setFocus(Qt::OtherFocusReason); + }, Qt::QueuedConnection); +} + +void CanvasView::wheelEvent(QWheelEvent *event) +{ + if (!m_session.document() || m_transformDrag || m_cropDrag || m_session.brushStroke() || m_session.warpStroke()) + return; + // Trackpads report pixels; a wheel reports notches, lines each. + const bool precise = !event->pixelDelta().isNull(); + const QPointF delta = precise ? QPointF(event->pixelDelta()) : QPointF(event->angleDelta()) / 120.0 * QApplication::wheelScrollLines(); + if (event->modifiers() & (Qt::ControlModifier | Qt::AltModifier)) { + m_session.zoom(m_session.viewport.zoom() * std::exp(-delta.y() * 0.015), event->position()); + return; + } + const double multiplier = precise ? 1 : 12; + m_session.viewport.translate(QSizeF(delta.x() * multiplier, delta.y() * multiplier)); + m_session.notify(); +} + +void CanvasView::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::RightButton && isBrushTool(m_session.tool()) && !m_session.brushStroke() && !m_session.warpStroke() && !m_spaceHeld) { + beginBrushTipDrag(event->position(), event->modifiers()); + return; + } + if (event->button() != Qt::LeftButton) { + QWidget::mousePressEvent(event); + return; + } + // Swift's mouseDown lets go of a chosen effect first. + m_session.dropEffectSelection(); + readModifiers(event->modifiers()); + setFocus(Qt::MouseFocusReason); + // A press starts afresh: Qt can lose a release. + endSampling(); + if (!m_session.document() || m_session.isProjectBusy() || m_session.isImporting()) + return; + const QPointF point = event->position(); + m_hover = point; + // A Levels eyedropper sets its point; the panel keeps keys. + if (m_session.levels() && m_session.levels()->sampleMode && !m_spaceHeld) { + m_session.sampleLevels(m_session.viewport.documentPoint(point, m_session.document()->size())); + FloatingPanel::refocus(QStringLiteral("levelsPanel")); + return; + } + // Open Levels leaves the canvas to panning and zooming. + if (m_session.levels() && !m_spaceHeld && m_session.tool() != NavigationTool::hand && m_session.tool() != NavigationTool::zoom) + return; + if (!m_spaceHeld && pickingPress(point)) + return; + if (m_spaceHeld || m_session.tool() == NavigationTool::hand) { + m_lastDragPoint = point; + updateCursor(); + } else if (m_session.tool() == NavigationTool::move) { + beginTransformDrag(point, event->modifiers()); + } else if (isSelectionTool(m_session.tool())) { + lassoMouseDown(point, event->modifiers()); + updateCursor(); + } else if (m_session.tool() == NavigationTool::zoom) { + m_zoomDrag = ZoomDrag{point, m_session.viewport.zoom(), false}; + } else if (isBrushTool(m_session.tool())) { + brushMouseDown(point, event->modifiers()); + } else if (m_session.tool() == NavigationTool::gradient) { + beginGradientDrag(point); + } else if (m_session.tool() == NavigationTool::shape) { + m_session.beginShape(m_session.viewport.documentPoint(point, m_session.document()->size())); + } else if (m_session.tool() == NavigationTool::crop) { + beginCropDrag(point); + } else if (m_session.tool() == NavigationTool::type) { + const bool triple = m_textDoubleClick.isValid() && m_textDoubleClick.elapsed() < QGuiApplication::styleHints()->mouseDoubleClickInterval() + && (point - m_textDoubleClickPoint).manhattanLength() < QGuiApplication::styleHints()->startDragDistance(); + m_textDoubleClick.invalidate(); + pressTextTool(point, event->modifiers(), triple ? 3 : 1); + } +} + +// A double click's second press closes a polygonal draft. +void CanvasView::mouseDoubleClickEvent(QMouseEvent *event) +{ + // The Type tool's second click selects a word. + if (event->button() == Qt::LeftButton && m_session.tool() == NavigationTool::type && !m_spaceHeld && m_session.document() + && !m_session.isProjectBusy() && !m_session.isImporting() && !picking() && !m_session.hueTargeting()) { + setFocus(Qt::MouseFocusReason); + pressTextTool(event->position(), event->modifiers(), 2); + m_textDoubleClick.start(); + m_textDoubleClickPoint = event->position(); + return; + } + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (event->button() == Qt::LeftButton && isSelectionTool(m_session.tool()) && draft && draft->kind == LassoKind::polygonal + && !m_spaceHeld && !m_session.isProjectBusy() && !m_session.isImporting() && !picking() && !m_session.hueTargeting()) { + m_session.finishLasso(); + synchronizeDisplay(); + updateCursor(); + return; + } + // Else, picking included, it is a press, as Swift's mouseDown. + QWidget::mouseDoubleClickEvent(event); +} + +void CanvasView::mouseMoveEvent(QMouseEvent *event) +{ + const QPointF point = event->position(); + m_hover = point; + readModifiers(event->modifiers()); + // Without the left button a text gesture's release was lost. + const bool held = event->buttons().testFlag(Qt::LeftButton); + if (m_textBoxAnchor) { + if (held) + dragTextGesture(point); + else + endTextGesture(); + return; + } + if (m_inlineTextEditor && held && m_inlineTextEditor->drag(point)) + return; + if (m_inlineTextEditor && !held) + m_inlineTextEditor->release(); + if (pickingMove(point, event->buttons()) || targetingMove(point, event->buttons(), event->modifiers()) + || cropMove(point, event->buttons(), event->modifiers())) + return; + if (m_transformDrag) { + dragTransform(point, event->modifiers()); + return; + } + if (m_brushTipDrag) { + dragBrushTip(point, event->modifiers()); + return; + } + if (!m_lastDragPoint && dragGradient(point, event->modifiers())) + return; + if (!m_lastDragPoint && brushMouseMove(point, event->modifiers(), event->buttons().testFlag(Qt::LeftButton))) + return; + // A pan with Space comes first, as Swift's lastDragPoint. + if (!m_lastDragPoint && moveSelectionTool(point, event->modifiers())) + return; + if (m_session.shapeDraft() && m_session.document()) { + // Alt has no other job here: it grows from centre. + m_session.dragShape(m_session.viewport.documentPoint(point, m_session.document()->size()), + event->modifiers().testFlag(Qt::ShiftModifier), event->modifiers().testFlag(Qt::AltModifier)); + synchronizeDisplay(); + return; + } + if (m_zoomDrag) { + const double dx = point.x() - m_zoomDrag->start.x(); + if (std::abs(dx) >= 3) + m_zoomDrag->moved = true; + // Right zooms in, left out: doubling every 100 points. + if (m_zoomDrag->moved) + m_session.zoom(m_zoomDrag->zoom * std::pow(2, dx / 100), m_zoomDrag->start); + return; + } + if (!m_lastDragPoint) { + // Hover: the Move tool's cursor follows the handles. + updateCursor(); + return; + } + m_session.viewport.translate(QSizeF(point.x() - m_lastDragPoint->x(), point.y() - m_lastDragPoint->y())); + m_lastDragPoint = point; + m_session.notify(); +} + +void CanvasView::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::RightButton && m_brushTipDrag) { + endBrushTipDrag(event->position()); + return; + } + if (event->button() != Qt::LeftButton) { + QWidget::mouseReleaseEvent(event); + return; + } + if (m_textBoxAnchor) { + finishTextGesture(); + return; + } + // Open text's press ends; the cursor follows below. + if (m_inlineTextEditor) + m_inlineTextEditor->release(); + if (m_zoomDrag) { + const ZoomDrag drag = *m_zoomDrag; + m_zoomDrag.reset(); + // A press that never moved zooms a step on release. + if (!drag.moved) + m_session.zoom(m_session.viewport.zoom() * (event->modifiers().testFlag(Qt::AltModifier) ? 0.5 : 2), drag.start); + return; + } + m_session.snapGuides = {}; + pickingRelease(); + targetingRelease(); + brushMouseUp(event->position()); + if (m_gradientDrag) { + m_gradientDrag.reset(); + m_session.endGradientDrag(); + } + if (m_session.shapeDraft()) + m_session.finishShape(); + endCropDrag(); + if (m_transformDrag) + endTransformDrag(); + releaseSelectionTool(); + m_lastDragPoint.reset(); + updateCursor(); + // A Ctrl+T edit commits nothing here: the guides still go. + synchronizeDisplay(); +} + +void CanvasView::keyPressEvent(QKeyEvent *event) +{ + // A drag swallows Alt's release: every key re-reads it. + m_optionHeld = event->modifiers().testFlag(Qt::AltModifier) || event->key() == Qt::Key_Alt; + m_controlHeld = event->modifiers().testFlag(Qt::ControlModifier) || event->key() == Qt::Key_Control; + m_shiftHeld = event->modifiers().testFlag(Qt::ShiftModifier) || event->key() == Qt::Key_Shift; + // Escape drops a text box being drawn, as Swift's first. + if (event->key() == Qt::Key_Escape && m_textBoxAnchor) { + endTextGesture(); + return; + } + // Open text is the text view: it takes every key. + if (m_inlineTextEditor) { + if (!m_inlineTextEditor->keyPress(*event)) + QWidget::keyPressEvent(event); + return; + } + const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)); + const bool enter = event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter; + const std::optional<NavigationTool> tool = plain ? toolForKey(event->key()) : std::nullopt; + const bool erase = event->key() == Qt::Key_Backspace || event->key() == Qt::Key_Delete; + if (erase && event->modifiers() == Qt::ShiftModifier) { + // Shift-Delete: Content-Aware Fill, taken either way. + if (m_session.canContentAwareFill()) + m_session.beginFilter(FilterKind::contentAwareFill); + } else if (m_session.levels() && event->key() != Qt::Key_Space) { + // Open Levels takes its keys and passes the rest on. + if (event->key() == Qt::Key_Escape) + m_session.cancelLevels(); + else if (enter) + m_session.commitLevels(); + else if (event->key() == Qt::Key_P && event->modifiers().testFlag(Qt::AltModifier)) + m_session.updateLevels(m_session.levels()->settings, !m_session.levels()->preview); + else + QWidget::keyPressEvent(event); + } else if (m_session.brushStroke() || m_session.warpStroke()) { + // A stroke takes every key; Escape cancels it. + if (event->key() == Qt::Key_Escape && !m_session.isProjectBusy()) { + m_session.cancelBrush(); + synchronizeDisplay(); + } + } else if (selectionKey(*event)) { + return; + } else if (event->key() == Qt::Key_Escape && m_session.shapeDraft()) { + m_session.cancelShape(); + synchronizeDisplay(); + } else if ((event->key() == Qt::Key_Escape || enter) && m_session.gradientEdit()) { + m_gradientDrag.reset(); + if (enter) + m_session.commitGradient(); + else + m_session.cancelGradient(); + } else if (cropKey(*event)) { + return; + } else if ((event->key() == Qt::Key_Escape || enter) && m_session.transformEdit()) { + // The keys end a drag with its edit, cursor included. + m_transformDrag.reset(); + m_dragCursor.reset(); + if (enter) + m_session.commitTransform(); + else + m_session.cancelTransform(); + updateCursor(); + } else if (isArrow(event->key()) && event->modifiers().testFlag(Qt::ControlModifier) && !(event->modifiers() & (Qt::AltModifier | Qt::MetaModifier)) + && !m_session.lassoDraft() && m_session.selection() && !m_session.selection()->isEmpty()) { + // Ctrl-arrow moves the selected pixels in any tool. + const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1; + m_session.nudgePixels(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0, + event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0, [canvas = QPointer<CanvasView>(this)] { + if (canvas) + canvas->synchronizeDisplay(); + }); + } else if (m_session.tool() == NavigationTool::move && plain && isArrow(event->key())) { + const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1; + m_session.nudgeLayer(event->key() == Qt::Key_Left ? -step : event->key() == Qt::Key_Right ? step : 0, + event->key() == Qt::Key_Up ? -step : event->key() == Qt::Key_Down ? step : 0); + } else if (erase && !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) { + m_session.deleteKeyPressed(); + } else if (event->key() == Qt::Key_Space) { + m_spaceHeld = true; + updateCursor(); + updateBrushCursor(); + } else if (brushKey(*event)) { + return; + } else if (plain && (event->key() == Qt::Key_X || event->key() == Qt::Key_D)) { + // X swaps the palette; D brings back black and white. + if (event->key() == Qt::Key_X) + m_session.swapPaletteColors(); + else + m_session.resetPaletteColors(); + } else if (tool == NavigationTool::shape && event->modifiers().testFlag(Qt::ShiftModifier) && m_session.tool() == NavigationTool::shape) { + m_session.toggleShapeKind(); + } else if (tool) { + m_session.selectTool(*tool); + } else { + QWidget::keyPressEvent(event); + } +} + +void CanvasView::keyReleaseEvent(QKeyEvent *event) +{ + // Auto-repeat sends release and press pairs: not a release. + if (event->isAutoRepeat()) { + QWidget::keyReleaseEvent(event); + } else if (event->key() == Qt::Key_Space) { + m_spaceHeld = false; + updateCursor(); + updateBrushCursor(); + } else { + QWidget::keyReleaseEvent(event); + } +} + +void CanvasView::focusInEvent(QFocusEvent *event) +{ + if (m_inlineTextEditor) + m_inlineTextEditor->setFocused(true); + QWidget::focusInEvent(event); +} + +void CanvasView::focusOutEvent(QFocusEvent *event) +{ + if (m_inlineTextEditor) { + m_inlineTextEditor->setFocused(false); + m_inlineTextEditor->release(); + } + // Qt can lose the release: box and sampling go. + if (m_textBoxAnchor) + endTextGesture(); + endSampling(); + // Swift's resignFirstResponder: a stroke cancels, the circle goes. + if (!m_session.isProjectBusy()) + m_session.cancelBrush(); + m_brushPointer = std::nullopt; + updateBrushCursor(); + cancelTransformDrag(); + endCropDrag(); + m_gradientDrag.reset(); + m_session.cancelShape(); + endSelectionGestures(); + m_spaceHeld = false; + m_lastDragPoint.reset(); + updateCursor(); + QWidget::focusOutEvent(event); +} + +// Swift's mouseExited: the circle leaves with the pointer. +void CanvasView::leaveEvent(QEvent *event) +{ + m_brushPointer = std::nullopt; + updateBrushCursor(); + QWidget::leaveEvent(event); +} diff --git a/src/Rendering/EditorCanvas+Picking.cpp b/src/Rendering/EditorCanvas+Picking.cpp new file mode 100644 index 0000000..b8e2125 --- /dev/null +++ b/src/Rendering/EditorCanvas+Picking.cpp @@ -0,0 +1,131 @@ +#include "Rendering/EditorCanvas.h" +#include "UI/ColorPickerSheet.h" +#include "UI/FloatingPanel.h" + +// The Eyedropper, or Alt with Brush, Spot Healing, Gradient. +bool CanvasView::palettePicking() const +{ + const NavigationTool tool = m_session.tool(); + return tool == NavigationTool::eyedropper + || (m_optionHeld && (tool == NavigationTool::brush || tool == NavigationTool::spotHealing || tool == NavigationTool::gradient) + && !m_session.brushStroke() && !m_gradientDrag); +} + +bool CanvasView::picking() const +{ + return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode); +} + +// Picking or targeting begins or ends: a sampling goes. +void CanvasView::syncPicking() +{ + if (m_displayedPicking == picking() && m_displayedTargeting == m_session.hueTargeting()) + return; + m_displayedPicking = picking(); + m_displayedTargeting = m_session.hueTargeting(); + endSampling(); + updateCursor(); +} + +bool CanvasView::pickingPress(QPointF point) +{ + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + if (picking()) { + // A colour, unless a range's eyedropper is armed. + if (m_session.colorPicker() || (palettePicking() && !m_session.hueSampleMode())) { + beginSampling(point); + } else { + m_session.sampleHueRange(pixel); + FloatingPanel::refocus(QStringLiteral("adjustmentPanel")); + } + return true; + } + if (!m_session.hueTargeting()) + return false; + if (m_session.beginHueTargeting(pixel)) + m_hueTargetStart = point; + return true; +} + +bool CanvasView::targetingMove(QPointF point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers) +{ + if (!m_hueTargetStart) + return false; + // Without the left button its release was lost. + if (!buttons.testFlag(Qt::LeftButton)) { + targetingRelease(); + return true; + } + m_session.dragHueTargeting(point.x() - m_hueTargetStart->x(), modifiers.testFlag(Qt::ControlModifier)); + // Every drag restores the cursor Alt may have changed. + setCursor(Qt::SizeHorCursor); + return true; +} + +void CanvasView::targetingRelease() +{ + if (!m_hueTargetStart) + return; + m_hueTargetStart.reset(); + m_session.endHueTargeting(); +} + +void CanvasView::beginSampling(QPointF point) +{ + // Alt may come with the press: take its change first. + syncPicking(); + m_samplingOriginal = m_session.colorPicker() ? m_session.colorPicker()->color() : m_session.foregroundColor(); + m_samplingColor = true; + sampleColor(point); +} + +// Swift's sampleColor: into the open picker, else the foreground. +void CanvasView::sampleColor(QPointF point) +{ + const std::optional<CanvasDocument> &document = m_session.document(); + if (!document) + return; + const QPointF pixel = m_session.viewport.documentPoint(point, document->size()); + if (m_session.colorPicker()) { + m_session.sampleIntoColorPicker(pixel); + } else if (m_session.canEditPalette()) { + if (const std::optional<PaletteColor> color = m_session.sampleCompositeColor(pixel)) + m_session.setForegroundColor(*color); + } + const PaletteColor sampled = m_session.colorPicker() ? m_session.colorPicker()->color() : m_session.foregroundColor(); + update(m_sampleRing.update(m_session.showsSampleRing() ? std::optional(point) : std::nullopt, m_samplingOriginal, sampled)); +} + +void CanvasView::endSampling() +{ + m_samplingColor = false; + update(m_sampleRing.update(std::nullopt, m_samplingOriginal, m_sampleRing.sampled())); +} + +// A drag samples on; a buttonless hover shows the eyedropper. +bool CanvasView::pickingMove(QPointF point, Qt::MouseButtons buttons) +{ + syncPicking(); + if (m_samplingColor) { + // Without the left button its release was lost. + if (buttons.testFlag(Qt::LeftButton)) + sampleColor(point); + else + endSampling(); + return true; + } + if (buttons != Qt::NoButton || !picking()) + return false; + updateCursor(); + return true; +} + +// The release ends sampling; the picker takes the keys back. +void CanvasView::pickingRelease() +{ + if (!m_samplingColor) + return; + endSampling(); + if (m_session.colorPicker()) + ColorPickerPanelController::refocus(); +} diff --git a/src/Rendering/EditorCanvas+Selection.cpp b/src/Rendering/EditorCanvas+Selection.cpp new file mode 100644 index 0000000..ccba9a4 --- /dev/null +++ b/src/Rendering/EditorCanvas+Selection.cpp @@ -0,0 +1,289 @@ +#include "Rendering/EditorCanvas.h" +#include "Logging.h" +#include <QKeyEvent> +#include <QPointer> +#include <cmath> + +// The Marquee and the Lasso on the canvas. +namespace { +bool isMarquee(const std::optional<LassoDraft> &draft) +{ + return draft && (draft->kind == LassoKind::rectangle || draft->kind == LassoKind::ellipse); +} +} + +// Over the selection in New mode, the move cursor. +QCursor CanvasView::lassoCursor(Qt::KeyboardModifiers modifiers, std::optional<QPointF> location) const +{ + const double ratio = devicePixelRatio(); + const SelectionMode mode = m_session.lassoCursorMode(modifiers.testFlag(Qt::ShiftModifier), modifiers.testFlag(Qt::AltModifier)); + // Ctrl over the selection: the scissors, or Ctrl-Alt's copy. + const std::optional<CanvasDocument> &document = m_session.document(); + const bool command = modifiers.testFlag(Qt::ControlModifier); + if ((command || mode == SelectionMode::replace) && document && location + && m_session.canMoveSelection(m_session.viewport.documentPoint(*location, document->size()))) + return command ? pixelDragCursor(modifiers.testFlag(Qt::AltModifier), ratio) : moveSelectionCursor(ratio); + if (m_session.tool() == NavigationTool::wand) + return wandCursor(mode, ratio); + const SelectionIcon icon = m_session.tool() == NavigationTool::marquee + ? (m_session.marqueeKind() == LassoKind::ellipse ? SelectionIcon::ellipseMarquee : SelectionIcon::rectangleMarquee) + : (m_session.lassoKind() == LassoKind::polygonal ? SelectionIcon::polygonalLasso : SelectionIcon::freehandLasso); + return selectionCursor(icon, mode, ratio); +} + +// Freehand drags an outline; Polygonal adds a corner per click. +void CanvasView::lassoMouseDown(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const QSizeF size = m_session.document().value().size(); + // Shift at the press means Add, never a square yet. + m_marqueeConstrainArmed = !modifiers.testFlag(Qt::ShiftModifier); + m_marqueeDragPixel = std::nullopt; + const QPointF pixel = m_session.viewport.documentPoint(point, size); + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (!draft || draft->kind != LassoKind::polygonal) { + // Ctrl-drag inside the selection cuts and moves its pixels. + if (modifiers.testFlag(Qt::ControlModifier) && m_session.canMoveSelection(pixel)) { + if (m_session.beginPixelMove(modifiers.testFlag(Qt::AltModifier))) + m_pixelDragStart = pixel; + else + qCWarning(lcApp) << "the selection holds no pixels to move"; + updateCursor(); + return; + } + const SelectionMode mode = m_session.selectionMode(modifiers.testFlag(Qt::ShiftModifier), modifiers.testFlag(Qt::AltModifier)); + if (mode == SelectionMode::replace && m_session.canMoveSelection(pixel) && m_session.beginSelectionMove()) { + m_selectionDragStart = pixel; + updateCursor(); + return; + } + if (m_session.tool() == NavigationTool::wand) { + m_session.magicWand(pixel, mode); + return; + } + m_session.beginLasso(pixel, mode); + synchronizeDisplay(); + return; + } + // Near the first corner, with three, the outline closes. + const QPointF first = m_session.viewport.viewPoint(draft->points.front(), size); + if (draft->points.size() >= 3 && std::hypot(point.x() - first.x(), point.y() - first.y()) <= 8) + m_session.finishLasso(); + else + m_session.extendLasso(pixel); + synchronizeDisplay(); +} + +// A drag or a hover; true when taken. +bool CanvasView::moveSelectionTool(QPointF point, Qt::KeyboardModifiers modifiers) +{ + if (m_pixelDragStart && m_session.document()) { + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + m_session.movePixels(QSizeF(pixel.x() - m_pixelDragStart->x(), pixel.y() - m_pixelDragStart->y())); + updateCursor(); + synchronizeDisplay(); + return true; + } + if (m_selectionDragStart) { + dragSelection(point, modifiers); + updateMarqueeAutoscroll(point); + updateCursor(); + synchronizeDisplay(); + return true; + } + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (!isSelectionTool(m_session.tool()) || !m_session.document()) + return false; + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + if (!draft || draft->kind == LassoKind::polygonal) { + // Keys may have changed while the app was away. + m_session.updateHeldSelectionKeys(modifiers.testFlag(Qt::ShiftModifier), modifiers.testFlag(Qt::AltModifier)); + updateCursor(); + if (draft) + m_session.moveLassoCursor(pixel); + synchronizeDisplay(); + return true; + } + if (draft->kind == LassoKind::freehand) { + m_session.extendLasso(pixel); + } else { + dragMarqueeDraft(pixel, modifiers); + updateMarqueeAutoscroll(point); + } + synchronizeDisplay(); + return true; +} + +void CanvasView::releaseSelectionTool() +{ + stopMarqueeAutoscroll(); + if (m_pixelDragStart) { + m_pixelDragStart = std::nullopt; + m_session.finishPixelMove([canvas = QPointer<CanvasView>(this)] { + if (!canvas) + return; + canvas->synchronizeDisplay(); + canvas->updateCursor(); + }); + } + if (m_selectionDragStart) { + const QPointF start = *std::exchange(m_selectionDragStart, std::nullopt); + const bool moved = m_session.selectionMoveOrigin() != m_session.selection(); + m_session.endSelectionMove(); + // The wand's click inside selects afresh; another's deselects. + if (!moved && m_session.tool() == NavigationTool::wand) + m_session.magicWand(start, SelectionMode::replace); + else if (!moved) + m_session.deselect(); + synchronizeDisplay(); + updateCursor(); + } + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (isSelectionTool(m_session.tool()) && draft && draft->kind != LassoKind::polygonal) { + m_session.finishLasso(); + synchronizeDisplay(); + updateCursor(); + } +} + +// Escape, Return, Backspace and Delete on a draft; arrows nudge. +bool CanvasView::selectionKey(const QKeyEvent &key) +{ + const bool enter = key.key() == Qt::Key_Return || key.key() == Qt::Key_Enter; + const bool plain = !(key.modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)); + if (m_session.lassoDraft() && (key.key() == Qt::Key_Escape || enter || key.key() == Qt::Key_Backspace || key.key() == Qt::Key_Delete)) { + if (key.key() == Qt::Key_Escape) + m_session.cancelLasso(); + else if (enter) + m_session.finishLasso(); + else + m_session.removeLastLassoPoint(); + synchronizeDisplay(); + updateCursor(); + return true; + } + const std::optional<DocumentSelection> selection = m_session.selection(); + const bool arrow = key.key() == Qt::Key_Left || key.key() == Qt::Key_Right || key.key() == Qt::Key_Up || key.key() == Qt::Key_Down; + if (isSelectionTool(m_session.tool()) && !m_session.lassoDraft() && selection && !selection->isEmpty() && arrow && plain) { + const double step = key.modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1; + m_session.nudgeSelection(key.key() == Qt::Key_Left ? -step : key.key() == Qt::Key_Right ? step : 0, + key.key() == Qt::Key_Up ? -step : key.key() == Qt::Key_Down ? step : 0); + return true; + } + // M and L choose their tools; a repeat does nothing. + if (plain && (key.key() == Qt::Key_M || key.key() == Qt::Key_L)) { + if (key.isAutoRepeat()) + return true; + if (key.key() == Qt::Key_M) + m_session.pressMarqueeKey(); + else + m_session.pressLassoKey(); + updateCursor(); + return true; + } + return false; +} + +// Losing the keys ends a drag and an open outline. +void CanvasView::endSelectionGestures() +{ + stopMarqueeAutoscroll(); + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (draft && draft->kind != LassoKind::polygonal) + m_session.cancelLasso(); + if (m_selectionDragStart) { + m_selectionDragStart = std::nullopt; + m_session.endSelectionMove(); + } + if (m_pixelDragStart) { + m_pixelDragStart = std::nullopt; + m_session.cancelPixelMove(); + } +} + +// The grabbed pixel follows the pointer; Shift keeps one axis. +void CanvasView::dragSelection(QPointF point, Qt::KeyboardModifiers modifiers) +{ + if (!m_selectionDragStart || !m_session.document()) + return; + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); + QSizeF offset(pixel.x() - m_selectionDragStart->x(), pixel.y() - m_selectionDragStart->y()); + if (modifiers.testFlag(Qt::ShiftModifier)) { + if (std::abs(offset.width()) >= std::abs(offset.height())) + offset.setHeight(0); + else + offset.setWidth(0); + } + m_session.moveSelection(offset); +} + +// Alt subtracts, never from the centre; Shift squares once armed. +void CanvasView::dragMarqueeDraft(QPointF pixel, Qt::KeyboardModifiers modifiers) +{ + if (!modifiers.testFlag(Qt::ShiftModifier)) + m_marqueeConstrainArmed = true; + m_marqueeDragPixel = pixel; + m_session.dragMarquee(pixel, m_marqueeConstrainArmed && modifiers.testFlag(Qt::ShiftModifier), false); +} + +// A frame's pan toward a pointer at the edge. +QSizeF CanvasView::marqueeAutoscrollDelta(QPointF point) const +{ + const QRectF bounds(QPointF(0, 0), QSizeF(size())); + constexpr double margin = 12; + const auto speed = [](double past) { return past <= 0 ? 0.0 : std::min(40.0, 2 + past * 0.4); }; + const double left = speed(bounds.left() + margin - point.x()), right = speed(point.x() - (bounds.right() - margin)); + const double top = speed(bounds.top() + margin - point.y()), bottom = speed(point.y() - (bounds.bottom() - margin)); + // Past the right edge the document slides left. + return QSizeF(left - right, top - bottom); +} + +void CanvasView::updateMarqueeAutoscroll(QPointF point) +{ + m_marqueeAutoscrollPoint = point; + if (marqueeAutoscrollDelta(point) == QSizeF(0, 0)) { + stopMarqueeAutoscroll(); + return; + } + if (!m_marqueeAutoscroll.isActive()) + m_marqueeAutoscroll.start(); +} + +void CanvasView::stepMarqueeAutoscroll() +{ + const bool marquee = isMarquee(m_session.lassoDraft()); + if (!m_marqueeAutoscrollPoint || !m_session.document() || !(marquee || m_selectionDragStart)) { + stopMarqueeAutoscroll(); + return; + } + const QPointF point = *m_marqueeAutoscrollPoint; + const QSizeF delta = marqueeAutoscrollDelta(point); + if (delta == QSizeF(0, 0)) { + stopMarqueeAutoscroll(); + return; + } + m_session.viewport.translate(delta); + // The document moved under a still pointer: the box follows. + if (m_selectionDragStart) + dragSelection(point, heldModifiers()); + else + dragMarqueeDraft(m_session.viewport.documentPoint(point, m_session.document()->size()), heldModifiers()); + m_session.notify(); + synchronizeDisplay(); +} + +void CanvasView::stopMarqueeAutoscroll() +{ + m_marqueeAutoscroll.stop(); + m_marqueeAutoscrollPoint = std::nullopt; +} + +// Marching ants animate only while a visible selection exists. +void CanvasView::updateAntsTimer() +{ + const std::optional<DocumentSelection> selection = m_session.displayedSelection(); + const bool active = selection && !selection->isEmpty() && isVisible(); + if (active && !m_antsTimer.isActive()) + m_antsTimer.start(); + else if (!active && m_antsTimer.isActive()) + m_antsTimer.stop(); +} diff --git a/src/Rendering/EditorCanvas+Shape.cpp b/src/Rendering/EditorCanvas+Shape.cpp new file mode 100644 index 0000000..b76c3d5 --- /dev/null +++ b/src/Rendering/EditorCanvas+Shape.cpp @@ -0,0 +1,40 @@ +#include "Rendering/EditorCanvas.h" +#include <QPainterPath> +#include <cmath> + +void CanvasView::drawShapeDraft(double scale, const Center ¢er, QPainter &target, const QImage &clip) const +{ + const std::optional<ShapeDraft> &draft = m_session.shapeDraft(); + // A flat or upright line has a box worth drawing. + if (!draft || (draft->kind == ShapeKind::line ? draft->rect.width() <= 0 && draft->rect.height() <= 0 : draft->rect.isEmpty())) + return; + const QColor color = m_session.foregroundColor().color(); + const QPointF middle = center(draft->rect.center()); + const QRectF rect(middle.x() - draft->rect.width() * scale / 2, middle.y() - draft->rect.height() * scale / 2, draft->rect.width() * scale, + draft->rect.height() * scale); + std::function<void(QPainter &)> body; + QRectF extent = rect; + if (draft->kind == ShapeKind::line) { + // A line's box is set only with its end. + const std::pair<QPointF, QPointF> ends = m_session.shapeLineEnds().value(); + const double thickness = std::max(1.0, m_session.shapeLineWidth() * scale); + // Exactly the two points dragged between, so the start holds. + const QPointF from = center(ends.first), to = center(ends.second); + extent = QRectF(from, to).normalized().adjusted(-thickness, -thickness, thickness, thickness); + body = [color, thickness, from, to](QPainter &aside) { aside.fillPath(strokedLine(from, to, thickness), color); }; + } else { + const QPainterPath shape = path(draft->kind, rect, draft->cornerRadius * scale); + body = [color, shape](QPainter &aside) { aside.fillPath(shape, color); }; + } + // Composed aside, so folder masks clip it as its layer. + LayerRenderer::composite(target, QTransform(), extent, LayerSampling::smooth, InterpolationQuality::high, {.scale = scale, .clip = clip}, + extent, body); +} + +std::optional<CanvasView::ShapeShown> CanvasView::shownShape() const +{ + const std::optional<ShapeDraft> &draft = m_session.shapeDraft(); + if (!draft) + return std::nullopt; + return ShapeShown{*draft, m_session.foregroundColor(), m_session.shapeLineWidth(), m_session.activeLayerID()}; +} diff --git a/src/Rendering/EditorCanvas+Transform.cpp b/src/Rendering/EditorCanvas+Transform.cpp new file mode 100644 index 0000000..542a06e --- /dev/null +++ b/src/Rendering/EditorCanvas+Transform.cpp @@ -0,0 +1,290 @@ +#include "Rendering/EditorCanvas.h" +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <cmath> +#include <numbers> + +namespace { +// Four arrows about `center`, each `reach` long, traced clockwise. +QPainterPath fourArrowPath(QPointF center, double reach, double shaft, double head, double headLength) +{ + const std::array<QPointF, 6> arm = {QPointF(-shaft, -reach + headLength), QPointF(-head, -reach + headLength), QPointF(0, -reach), + QPointF(head, -reach + headLength), QPointF(shaft, -reach + headLength), QPointF(shaft, -shaft)}; + QPainterPath path; + for (int turn = 0; turn < 4; ++turn) { + for (QPointF point : arm) { + for (int step = 0; step < turn; ++step) + point = QPointF(-point.y(), point.x()); + const QPointF location = center + point; + if (path.isEmpty()) + path.moveTo(location); + else + path.lineTo(location); + } + } + path.closeSubpath(); + return path; +} + +QPixmap cursorPixmap(QSize size, double ratio) +{ + QPixmap pixmap(size * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + return pixmap; +} +} + +// Photoshop's Move pointer: the arrow with a four-way badge. +QCursor CanvasView::moveCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(36, 36), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPointF tip(4, 3); + const QPainterPath arrow = arrowPath().translated(tip); + painter.setPen(QPen(Qt::white, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(arrow); + painter.fillPath(arrow, Qt::black); + // Heads narrow and apart: four distinct arrows at badge size. + const QPainterPath badge = fourArrowPath(tip + QPointF(14.5, 17.5), 6.5, 0.75, 2, 2.5); + painter.setPen(QPen(Qt::white, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(badge); + painter.fillPath(badge, Qt::black); + return QCursor(pixmap, 4, 3); +} + +// The arrow in white: a Ctrl-drag moves the corner alone. +QCursor CanvasView::distortCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(28, 32), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QPainterPath arrow = arrowPath().translated(QPointF(4, 3)); + painter.setPen(QPen(Qt::black, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(arrow); + painter.fillPath(arrow, Qt::white); + return QCursor(pixmap, 4, 3); +} + +// A pointing hand with Swift's dashed box beside it. +QCursor CanvasView::loadSelectionCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(28, 32), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath hand; + hand.setFillRule(Qt::WindingFill); + hand.addRoundedRect(QRectF(8.5, 1.5, 4.5, 14), 2.2, 2.2); + hand.addRoundedRect(QRectF(4.5, 11, 14.5, 12), 4, 4); + painter.setPen(QPen(Qt::black, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(hand); + painter.fillPath(hand, Qt::white); + const QPointF tip(11, 2); + const QRectF box(tip + QPointF(6.5, 16.5), QSizeF(8, 6)); + painter.setPen(QPen(Qt::white, 2.5)); + painter.drawRect(box); + QPen dashed(Qt::black, 1); + dashed.setCapStyle(Qt::FlatCap); + dashed.setDashPattern({2, 1.5}); + painter.setPen(dashed); + painter.drawRect(box); + return QCursor(pixmap, int(tip.x()), int(tip.y())); +} + +// Two arrows chasing round a circle, haloed like the symbol. +QCursor CanvasView::rotationCursor(double ratio) +{ + QPixmap pixmap = cursorPixmap(QSize(24, 24), ratio); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + const QRectF ring(5, 5, 14, 14); + QPainterPath shape; + for (const double start : {20.0, 200.0}) { + shape.arcMoveTo(ring, start); + shape.arcTo(ring, start, 130); + // The head at the arc's end, turned along it. + const QPointF end = shape.currentPosition(); + const double tangent = (start + 130 + 90) * std::numbers::pi / 180; + const QPointF along(std::cos(tangent), -std::sin(tangent)); + const QPointF across(-along.y(), along.x()); + shape.moveTo(end + across * 3 - along * 1.5); + shape.lineTo(end + along * 2); + shape.lineTo(end - across * 3 - along * 1.5); + } + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::white, 4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(shape); + painter.setPen(QPen(Qt::black, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(shape); + return QCursor(pixmap, 12, 12); +} + +// The layer a press off the handles drags; Ctrl picks. +std::optional<CanvasView::PressTarget> CanvasView::transformPressLayer(QPointF pixel, Qt::KeyboardModifiers modifiers) const +{ + const std::optional<CanvasDocument> &document = m_session.document(); + if (!(m_session.canEditLayers() || m_session.transformEdit()) || !document) + return std::nullopt; + std::optional<QUuid> underPointer; + const std::vector<ImageLayer> rendered = document->renderLayers(); + for (auto layer = rendered.rbegin(); layer != rendered.rend() && !underPointer; ++layer) { + if (layer->asset && layer->transform.contains(pixel)) + underPointer = layer->id; + } + std::optional<ImageLayer> active = m_session.activeLayer(); + if (active && !(active->asset && !active->isGroup && document->effectiveVisibleIDs().contains(active->id))) + active = std::nullopt; + const bool picks = !m_session.transformEdit(); + const bool control = modifiers.testFlag(Qt::ControlModifier); + if (control && picks && underPointer) + return PressTarget{*underPointer, true}; + // A group's box drags them all; outside, unless auto-select picks. + if (m_session.transformsAsGroup() && m_session.activeLayerID()) { + const std::optional<LayerTransform> box = m_session.transformEdit() ? std::optional(m_session.transformEdit()->draft) : m_session.groupTransformBox(); + if ((box && box->contains(pixel)) || !(picks && m_session.transformAutoSelect()) || !underPointer) + return PressTarget{*m_session.activeLayerID(), false}; + } + if (active && m_session.editedTransform(*active).contains(pixel)) + return PressTarget{active->id, false}; + if (picks && (m_session.transformAutoSelect() || control) && underPointer) + return PressTarget{*underPointer, true}; + if (active) + return PressTarget{active->id, false}; + return std::nullopt; +} + +bool CanvasView::pressMovesLayer(QPointF point, Qt::KeyboardModifiers modifiers) const +{ + const std::optional<CanvasDocument> &document = m_session.document(); + return document && transformPressLayer(m_session.viewport.documentPoint(point, document->size()), modifiers).has_value(); +} + +// The Move tool's cursor; Alt offers a copy. +QCursor CanvasView::transformCursor(QPointF point, Qt::KeyboardModifiers modifiers) const +{ + if (m_dragCursor) + return *m_dragCursor; + if (m_spaceHeld) + return QCursor(Qt::OpenHandCursor); + if (m_session.isProjectBusy() || m_session.isImporting()) + return QCursor(Qt::ArrowCursor); + const bool duplicate = modifiers.testFlag(Qt::AltModifier); + const double ratio = devicePixelRatio(); + const std::optional<TransformOverlayGeometry> geometry = m_overlay.geometry(); + const std::optional<TransformDrag::Mode> hit = geometry ? geometry->hit(point) : std::nullopt; + if (!hit) { + if (!pressMovesLayer(point, modifiers)) + return QCursor(Qt::ArrowCursor); + return duplicate ? duplicateCursor(ratio) : moveCursor(ratio); + } + if (hit->kind == TransformDrag::Kind::rotate) + return rotationCursor(ratio); + // Distorting (Ctrl held, or already distorted) moves corners freely. + const std::optional<TransformEdit> &edit = m_session.transformEdit(); + const bool distorting = (edit && edit->corners) || modifiers.testFlag(Qt::ControlModifier); + return distorting ? distortCursor(ratio) : QCursor(geometry->resizeCursor(hit->index)); +} + +void CanvasView::beginTransformDrag(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const std::optional<CanvasDocument> &document = m_session.document(); + if (!(m_session.canEditLayers() || m_session.transformEdit()) || !document) + return; + const QPointF pixel = m_session.viewport.documentPoint(point, document->size()); + const std::optional<TransformOverlayGeometry> geometry = m_overlay.geometry(); + std::optional<TransformDrag::Mode> mode = geometry ? geometry->hit(point) : std::nullopt; + if (!mode) { + const std::optional<PressTarget> target = transformPressLayer(pixel, modifiers); + if (!target) + return; + // Ctrl+Shift adds the picked layer; Ctrl alone selects it. + if (target->picked && modifiers.testFlag(Qt::ControlModifier) && modifiers.testFlag(Qt::ShiftModifier)) + m_session.extendSelection(target->id); + else if (target->picked) + m_session.selectLayer(target->id); + mode = TransformDrag::Mode{TransformDrag::Kind::move}; + } + m_duplicatesTransformOnDrag = mode->kind == TransformDrag::Kind::move && modifiers.testFlag(Qt::AltModifier); + if (!m_session.transformEdit()) + m_session.beginTransform(false); + if (!m_session.transformEdit()) + return; + // Ctrl-dragging a handle distorts; once distorted, handles keep distorting. + if (mode->kind == TransformDrag::Kind::resize && (modifiers.testFlag(Qt::ControlModifier) || m_session.transformEdit()->corners)) { + m_session.beginDistort(); + if (m_session.transformEdit()->corners) + mode = TransformDrag::Mode{TransformDrag::Kind::distort, mode->index}; + } + m_transformDrag = TransformDrag{m_session.transformEdit()->draft, pixel, *mode, m_session.transformEdit()->corners}; + const double ratio = devicePixelRatio(); + if (mode->kind == TransformDrag::Kind::resize) + m_dragCursor = QCursor(geometry->resizeCursor(mode->index)); + else if (mode->kind == TransformDrag::Kind::rotate) + m_dragCursor = rotationCursor(ratio); + else if (mode->kind == TransformDrag::Kind::distort) + m_dragCursor = distortCursor(ratio); + else + m_dragCursor = m_duplicatesTransformOnDrag ? duplicateCursor(ratio) : moveCursor(ratio); + updateCursor(); +} + +void CanvasView::dragTransform(QPointF point, Qt::KeyboardModifiers modifiers) +{ + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document().value().size()); + if (m_duplicatesTransformOnDrag) { + m_duplicatesTransformOnDrag = false; + m_session.beginDuplicateTransform(); + } + if (const std::optional<Corners> corners = m_transformDrag->corners(pixel, modifiers.testFlag(Qt::ShiftModifier))) { + m_session.previewCorners(*corners); + synchronizeDisplay(); + return; + } + // Drags land on whole pixels and degrees; typing stays exact. + LayerTransform draft = m_transformDrag->updated(pixel, m_session.locksTransformRatio(), modifiers.testFlag(Qt::ShiftModifier), + modifiers.testFlag(Qt::AltModifier)).rounded(); + const std::optional<TransformEdit> &edit = m_session.transformEdit(); + // A move snaps to canvas and layers; Ctrl frees it. + if (m_transformDrag->mode.kind == TransformDrag::Kind::move && !modifiers.testFlag(Qt::ControlModifier) && edit) { + QSet<QUuid> moving; + if (edit->group) { + for (auto original = edit->group->originals.constBegin(); original != edit->group->originals.constEnd(); ++original) + moving.insert(original.key()); + } else { + moving.insert(edit->layerID); + } + draft = m_session.snappedMove(draft, moving, TransformSnap::distance / std::max(m_session.viewport.pointsPerPixel(), 0.0001)); + } + m_session.previewTransform(draft); + synchronizeDisplay(); +} + +// The release: a drag's edit commits, Ctrl+T's waits. +void CanvasView::endTransformDrag() +{ + m_duplicatesTransformOnDrag = false; + m_transformDrag.reset(); + m_dragCursor.reset(); + if (m_session.transformEdit() && !m_session.transformEdit()->persistent) + m_session.commitTransform(); + updateCursor(); +} + +// Losing the keys mid-drag puts the layer back. +void CanvasView::cancelTransformDrag() +{ + if (!m_transformDrag) + return; + m_duplicatesTransformOnDrag = false; + m_session.previewTransform(m_transformDrag->original); + if (m_session.transformEdit() && !m_session.transformEdit()->persistent) + m_session.cancelTransform(); + m_transformDrag.reset(); + m_dragCursor.reset(); + updateCursor(); +} diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp new file mode 100644 index 0000000..9467007 --- /dev/null +++ b/src/Rendering/EditorCanvas.cpp @@ -0,0 +1,499 @@ +#include "Rendering/EditorCanvas.h" +#include "Document/BrushStroke.h" +#include "Document/LayerEffects+Renderer.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/AdjustmentSurface.h" +#include "Rendering/DownsampleCache.h" +#include "Rendering/LayerRenderer.h" +#include "Rendering/LiveMaskRenderer.h" +#include "Rendering/RasterSnapshot.h" +#include "Rendering/TiledLayerRenderer.h" +#include <QPaintEvent> +#include <QPointer> +#include <QPainter> +#include <QPainterPath> +#include <algorithm> +#include <cmath> +#include <map> +#include <stdexcept> + +namespace { +// Swift's shadow: 35% black, blurred over fourteen points. +void drawShadow(const QRectF &rect, QPainter &context) +{ + constexpr int blur = 14; + const QRectF shadow = rect.translated(0, 3); + // Fourteen rings stack to 35% at the edge, fading out. + const double ring = 1 - std::pow(0.65, 1.0 / blur); + context.save(); + context.setPen(Qt::NoPen); + context.setBrush(QColor::fromRgbF(0, 0, 0, ring)); + for (int step = blur; step >= 1; --step) + context.drawRect(shadow.adjusted(-step, -step, step, step)); + context.restore(); +} + +std::optional<ImageIdentity> enabledMaskID(const std::optional<LayerMask> &mask) +{ + return mask && mask->isEnabled ? std::optional(mask->asset.identity()) : std::nullopt; +} +} + +CanvasView::CanvasView(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_overlay(session), m_marqueeAutoscroll(this), m_antsTimer(this) +{ + setObjectName(QStringLiteral("editorCanvas")); + setAccessibleName(QStringLiteral("Canvas")); + setFocusPolicy(Qt::StrongFocus); + setAttribute(Qt::WA_OpaquePaintEvent); + // The Move tool's cursor follows the pointer between presses. + setMouseTracking(true); + // Sixty frames a second, as Swift's; named for tests. + m_marqueeAutoscroll.setObjectName(QStringLiteral("marqueeAutoscroll")); + m_marqueeAutoscroll.setInterval(16); + connect(&m_marqueeAutoscroll, &QTimer::timeout, this, &CanvasView::stepMarqueeAutoscroll); + m_antsTimer.setObjectName(QStringLiteral("antsTimer")); + m_antsTimer.setInterval(120); + connect(&m_antsTimer, &QTimer::timeout, this, [this] { + m_overlay.antsPhase = std::fmod(m_overlay.antsPhase + 1, 8.0); + update(m_overlay.selectionRect(rect())); + }); +} + +CanvasView::DisplayState CanvasView::displayState() const +{ + const std::optional<CanvasDocument> &document = m_session.document(); + const std::optional<TransformEdit> &edit = m_session.transformEdit(); + DisplayState state{.brushRevision = m_session.brushRevision(), + .pixelGrid = m_session.showsPixelGrid(), + .corners = edit ? edit->corners : std::nullopt, + .documentID = document ? std::optional(document->id) : std::nullopt, + .size = document ? std::optional(document->size()) : std::nullopt, + .renderBounds = renderBounds(), + .viewport = m_session.viewport, + .layers = {}, + .folderMasks = {}}; + if (!document) + return state; + // A hidden source still clips: live masks count every layer. + const bool liveMasks = std::any_of(document->layers.begin(), document->layers.end(), + [](const ImageLayer &layer) { return layer.maskSourceID.has_value(); }); + const std::vector<ImageLayer> rendered = liveMasks ? std::vector<ImageLayer>() : document->renderLayers(); + const QSet<QUuid> visible = document->effectiveVisibleIDs(); + for (const ImageLayer &layer : liveMasks ? document->layers : rendered) { + // A blank layer counts while a draft sits on it. + if (!layer.asset && !layer.adjustment && !(m_session.shapeDraft() && layer.id == m_session.activeLayerID())) + continue; + state.layers.push_back({.id = layer.id, .transform = m_session.displayedTransform(layer), + .imageID = layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, + .maskID = enabledMaskID(layer.mask), .maskSourceID = layer.maskSourceID, .parentID = layer.parentID, + .visible = visible.contains(layer.id), .opacity = layer.opacity, + .blendMode = m_session.displayedBlendMode(layer), .maskPlacement = m_session.displayedMaskPlacement(layer), + .adjustment = layer.adjustment, .effects = layer.effects}); + } + for (const ImageLayer &layer : document->layers) { + if (layer.isGroup && layer.mask) + state.folderMasks.push_back({.id = layer.id, .maskID = enabledMaskID(layer.mask), .transform = m_session.displayedTransform(layer)}); + } + return state; +} + +// The stroke's dirty rect in view points, two points out. +std::optional<QRectF> CanvasView::strokeDirtyRect() const +{ + const BrushStroke *stroke = m_session.brushStroke(); + const std::optional<CanvasDocument> &document = m_session.document(); + if (!stroke || !document || !stroke->dirtyDocumentRect()) + return std::nullopt; + const QRectF &dirty = *stroke->dirtyDocumentRect(); + const QPointF origin = m_session.viewport.viewPoint(dirty.topLeft(), document->size()); + const double scale = m_session.viewport.pointsPerPixel(); + return QRectF(origin, QSizeF(dirty.width() * scale, dirty.height() * scale)).adjusted(-2, -2, 2, 2); +} + +bool CanvasView::synchronizeDisplay() +{ + handOnStrokeSurface(); + synchronizeInlineText(); + // A text box being drawn goes with its tool. + if (m_session.tool() != NavigationTool::type && m_textBoxRect) + endTextGesture(); + const DisplayState state = displayState(); + bool changed = false; + // Where the overlay draws now; a pan moves it. + const QRect drawn = m_overlay.drawnRect(rect()); + if (m_displayedState != state) { + // A stroke's step alone repaints where the stroke went. + DisplayState still = state; + still.brushRevision = m_displayedState ? m_displayedState->brushRevision : state.brushRevision; + const std::optional<QRectF> dirty = strokeDirtyRect(); + if (m_displayedState && still == *m_displayedState && dirty) + update(dirty->toAlignedRect()); + else + update(); + m_displayedState = state; + m_overlayRect = drawn; + changed = true; + } + // The handles and guides repaint only where they draw. + const std::optional<TransformOverlayGeometry> geometry = m_overlay.geometry(); + const bool blocked = m_session.isProjectBusy() || m_session.isImporting(); + const std::optional<DocumentSelection> outline = m_session.displayedSelection(); + const SelectionCursorState cursorState{m_session.marqueeKind(), m_session.lassoKind(), m_session.displayedSelectionMode()}; + const std::optional<QRectF> crop = m_overlay.cropViewRect(); + if (m_displayedGeometry != geometry || m_displayedGuides != m_session.snapGuides || m_displayedOutline != outline + || m_displayedDraft != m_session.lassoDraft() || m_displayedCrop != crop) { + // Crop dimming spans the canvas: it comes and goes whole. + if (m_displayedCrop.has_value() != crop.has_value()) + update(); + else + update(QRegion(m_overlayRect) | QRegion(drawn)); + m_displayedCrop = crop; + m_displayedGeometry = geometry; + m_displayedGuides = m_session.snapGuides; + m_displayedOutline = outline; + m_displayedDraft = m_session.lassoDraft(); + m_overlayRect = drawn; + updateCursor(); + } else if (m_displayedTool != m_session.tool() || m_displayedBlocked != blocked || m_displayedSelectionCursor != cursorState) { + updateCursor(); + } + // A shape draft redraws the view, as Swift's observation does. + if (const std::optional<ShapeShown> shape = shownShape(); m_displayedShape != shape) { + update(); + m_displayedShape = shape; + } + syncPicking(); + m_displayedTool = m_session.tool(); + m_displayedBlocked = blocked; + m_displayedSelectionCursor = cursorState; + updateBrushCursor(); + updateAntsTimer(); + return changed; +} + +// Crop shows what a frame past the canvas takes in. +std::optional<QRectF> CanvasView::renderBounds() const +{ + const std::optional<CanvasDocument> &document = m_session.document(); + if (!document) + return std::nullopt; + const QRectF original(QPointF(0, 0), document->size()); + // Swift's tool test is dead: frames live under Crop alone. + return m_session.cropRect() ? original.united(*m_session.cropRect()) : original; +} + +void CanvasView::paintEvent(QPaintEvent *event) +{ + // The dirty rect, image-backed: hand blends and coverage read pixels. + QPainter painter(this); + painter.setClipRect(event->rect()); + AdjustmentSurface::draw(painter, [&](QPainter &surface) { draw(surface, QRectF(event->rect())); }); + // Swift's overlay view: handles and guides over the pixels. + m_overlay.draw(painter, palette()); + // Swift's stack: the ring over the overlays, the editor above. + m_sampleRing.draw(painter); + if (m_inlineTextEditor) + m_inlineTextEditor->draw(painter); + m_brushCursor.draw(painter); + drawTextBoxDraft(painter); +} + +void CanvasView::draw(QPainter &context, const QRectF &dirty) +{ + // The surround is the palette's Base: Swift's 0.105 when dark. + context.fillRect(dirty, palette().color(QPalette::Base)); + const std::optional<CanvasDocument> &document = m_session.document(); + if (!document) + return; + const CanvasViewport &viewport = m_session.viewport; + const QRectF pixels = renderBounds().value(); + const double points = viewport.pointsPerPixel(); + const QRectF rect(viewport.viewPoint(pixels.topLeft(), document->size()), QSizeF(pixels.width() * points, pixels.height() * points)); + const QRectF bounds(QPointF(0, 0), QSizeF(size())); + if (!rect.intersects(bounds) || !rect.intersects(dirty)) + return; + // Inside the document its opaque fill covers the shadow. + if (!rect.adjusted(1, 1, -1, -1).contains(dirty)) + drawShadow(rect, context); + context.save(); + context.setClipRect(rect.intersected(dirty)); + context.fillRect(rect, QColor::fromRgbF(0.30, 0.30, 0.30)); + // Work scales with the visible viewport, not the document. + constexpr double tile = 10; + const QRectF visible = rect.intersected(bounds).intersected(dirty); + if (!visible.isEmpty()) { + const int minX = int(std::floor((visible.left() - rect.left()) / tile)), maxX = int(std::ceil((visible.right() - rect.left()) / tile)); + const int minY = int(std::floor((visible.top() - rect.top()) / tile)), maxY = int(std::ceil((visible.bottom() - rect.top()) / tile)); + for (int row = minY; row < maxY; ++row) { + for (int column = minX; column < maxX; ++column) { + if ((row + column) % 2 == 0) + context.fillRect(QRectF(rect.left() + column * tile, rect.top() + row * tile, tile, tile), QColor::fromRgbF(0.35, 0.35, 0.35)); + } + } + } + if (viewport.zoom() >= crispZoom && !visible.isEmpty()) { + drawDocumentPixels(visible, pixels, *document, context); + } else { + // Its own group: blend modes never meet the checkerboard. + AdjustmentSurface::draw(context, [&](QPainter &group) { + drawLayers(*document, points, [&](QPointF point) { return viewport.viewPoint(point, document->size()); }, group); + }); + } + if (m_session.showsPixelGrid() && viewport.zoom() >= pixelGridZoom && !visible.isEmpty()) + drawPixelGrid(visible, *document, context); + context.restore(); + // A hairline astride the edge, soft as CoreGraphics strokes it. + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.13), 1 / viewport.backingScale)); + context.setBrush(Qt::NoBrush); + context.drawRect(rect); + context.restore(); +} + +// The folder's live mask, multiplied into the coverage. +FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center ¢er) const +{ + return [&edit, scale, center](const QPainter &painter, QImage &coverage) { + QImage live(coverage.size(), QImage::Format_Grayscale8); + if (live.isNull()) + throw ExportError(ExportError::Kind::render); + // Outside the mask's bounds stays hidden, as when committed. + live.fill(0); + LayerTransform transform = edit.paintTransform; + transform.sampling = LayerSampling::nearest; + const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt; + { + QPainter drawing(&live); + drawing.setTransform(painter.deviceTransform()); + // Swift's displayImage: halved near the size drawn. + const double device = LayerRenderer::deviceScale(drawing); + const auto shown = [device](const QImage &image, double width) { + return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width())); + }; + const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr; + const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage(); + const QImage rasterBase = raster && !raster->base.isNull() + ? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width)) + : QImage(); + LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale}, + {.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false, + .sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase}); + } + QPainter multiplying(&coverage); + if (!multiplying.isActive()) + throw ExportError(ExportError::Kind::render); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live)); + }; +} + +void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) +{ + m_session.effectsPreviews.prepare(document.layers); + std::map<QUuid, ImageLayer> byID; + for (const ImageLayer &layer : document.layers) { + if (!byID.emplace(layer.id, layer).second) + throw std::logic_error("a document repeats a layer id"); + } + // A preview that lands repaints the view. + const std::function<void()> repaint = [canvas = QPointer<CanvasView>(this)] { + if (canvas) + canvas->update(); + }; + const auto drawOwn = [&](QUuid id, QPainter &target, const QImage &clip) { + // Open text shows in its editor instead. + if (!byID.contains(id) || (m_session.textDraft() && m_session.textDraft().value().layerID == id)) + return; + const ImageLayer &layer = byID.at(id); + // A stroke or pixel move stands in for the pixels. + const PixelMove *move = m_session.pixelMove(); + const std::optional<GradientEdit> &gradient = m_session.gradientEdit(); + const BrushStroke *stroke = m_session.brushStroke() && m_session.brushStroke()->layer.id == id ? m_session.brushStroke() + : gradient && gradient->raster->layer.id == id ? gradient->raster.get() + : move && move->raster->layer.id == id ? move->raster.get() + : nullptr; + if (!layer.asset && !stroke) + return; + // Smudge or Liquify under way: the reshaped copy, canvas-wide. + if (const WarpStroke *warp = m_session.warpStroke(); warp && warp->layer.id == id) { + const LayerTransform canvas{.origin = {0, 0}, .size = document.size()}; + const std::optional<QImage> mask = layer.mask ? layer.mask->clipImage(layer.maskTransform(), canvas, warp->width, warp->height, 2048) + : std::nullopt; + LayerRenderer::draw(warp->image(), canvas, center(canvas.center()), target, + {.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer), + .mask = mask.value_or(QImage()), .clip = clip}); + return; + } + // A pixel stroke draws its grid by its own transform. + const LayerTransform transform = stroke && !stroke->isMask ? stroke->paintTransform : m_session.displayedTransform(layer); + const std::optional<LayerTransform> placement = m_session.displayedMaskPlacement(layer); + std::optional<QImage> mask; + if (const std::optional<QImage> distorted = m_session.maskDistortPreview(layer)) { + mask = distorted; + } else if (layer.mask && !placement) { + mask = layer.mask->enabledImage(); + } else if (layer.mask) { + // A placed mask in the layer's grid; 2048 while moving. + const ImageLayer &owner = stroke ? stroke->layer : layer; + const LayerTransform base = stroke ? owner.transform : transform; + const double drawn = std::max(base.size.width(), base.size.height()) * scale * LayerRenderer::deviceScale(context); + const double steady = std::pow(2, std::ceil(std::log2(std::max(64.0, drawn)))); + const QSize size = owner.asset ? owner.asset->size() : QSize(int(std::round(base.size.width())), int(std::round(base.size.height()))); + mask = layer.mask->clipImage(placement, base, size.width(), size.height(), m_session.transformEdit() ? std::min(2048.0, steady) : steady); + } + const LayerRenderer::Options options{.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer), + .mask = mask.value_or(QImage()), .clip = clip}; + // Swift asks twice; once, with what shows, is one request. + const std::optional<EffectsPreviewCache::Result> effects = + stroke ? std::nullopt : m_session.effectsPreviews.preview(layer, mask, transform, placement, repaint); + const LayerRenderer::Options bare{.scale = scale, .opacity = layer.opacity, .blendMode = options.blendMode, .clip = clip}; + // A pending distortion warps the effects along with the layer. + if (const auto warped = effects ? m_session.distortedEffects(layer, effects->image, effects->inset) : std::nullopt) { + LayerRenderer::draw(warped->image, warped->transform, center(warped->transform.center()), target, bare); + return; + } + // A pending distortion shows the layer warped into its shape. + if (const std::optional<DistortPreview> distorted = m_session.distortPreview(layer)) { + LayerRenderer::Options through = bare; + through.mask = distorted->mask.value_or(QImage()); + LayerRenderer::draw(distorted->image, distorted->transform, center(distorted->transform.center()), target, through); + return; + } + // Effects round the pixels, on a grown canvas. + if (effects) { + const LayerTransform grown = effects->placement.value_or(LayerEffectsRenderer::placed(transform, effects->image, effects->inset)); + LayerRenderer::draw(effects->image, grown, center(grown.center()), target, bare); + return; + } + if (stroke) { + drawStroke(*stroke, layer, transform, mask, options, scale, center, target); + return; + } + // A rounded rectangle keeps its corners while it is resized. + if (const QImage shaped = m_session.shapeTransformPreview(layer, transform); !shaped.isNull()) { + LayerRenderer::draw(shaped, transform, center(transform.center()), target, options); + return; + } + // An adjustment's or filter's preview stands in for the pixels. + std::optional<QImage> preview = m_session.filterEdit() ? m_session.filterEdit()->previewImage(layer.id) : std::nullopt; + if (!preview && m_session.levels()) + preview = m_session.levels()->previewImage(layer.id); + if (!preview && m_session.hueSaturation()) + preview = m_session.hueSaturation()->previewImage(layer.id); + if (preview) + LayerRenderer::draw(*preview, transform, center(transform.center()), target, options); + else if (layer.asset->raster) + TiledLayerRenderer::drawRaster(layer.asset->raster, transform, center(transform.center()), target, options); + else + LayerRenderer::draw(layer.asset->image(), transform, center(transform.center()), target, options); + }; + // Above the active layer, where the new layer will go. + const auto drawOwnWithDraft = [&](QUuid id, QPainter &target, const QImage &clip) { + drawOwn(id, target, clip); + if (id == m_session.activeLayerID()) + drawShapeDraft(scale, center, target, clip); + }; + const auto parent = [&](QUuid id) { return byID.contains(id) ? byID.at(id).parentID : std::nullopt; }; + // A mask being painted exists as the edit's tiles alone. + const auto maskEdit = [&](QUuid id) -> const BrushStroke * { + const BrushStroke *brush = m_session.brushStroke(); + const BrushStroke *gradient = m_session.gradientEdit() ? m_session.gradientEdit()->raster.get() : nullptr; + for (const BrushStroke *edit : {brush, gradient}) { + if (edit && edit->isMask && edit->layer.id == id) + return edit; + } + return nullptr; + }; + LiveMaskRenderer live([&](QUuid id) { return byID.contains(id) ? byID.at(id).maskSourceID : std::nullopt; }, drawOwnWithDraft); + live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; }; + live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).opacity; }; + live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) { + const ImageLayer &layer = byID.at(id); + if (!layer.mask || !layer.mask->isEnabled) + return; + if (const BrushStroke *edit = maskEdit(id)) { + liveFolderMaskClip(*edit, scale, center)(painter, coverage); + return; + } + FolderMaskClip{layer.mask->asset.image(), layer.transform}.apply(center(layer.transform.center()), painter, coverage, scale); + }; + std::vector<QUuid> ids; + for (const ImageLayer &layer : document.renderLayers()) + ids.push_back(layer.id); + live.prepareStacks(ids, parent, [&](QUuid id) { return byID.contains(id) ? m_session.displayedBlendMode(byID.at(id)) : LayerBlendMode::normal; }); + FolderMaskClip::draw(ids, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + if (!byID.contains(id) || !byID.at(id).mask || !byID.at(id).mask->isEnabled) + return std::nullopt; + const ImageLayer &folder = byID.at(id); + if (const BrushStroke *edit = maskEdit(id)) + return liveFolderMaskClip(*edit, scale, center); + const LayerTransform transform = m_session.displayedTransform(folder); + // A sharp halving near the mask's drawn size. + const QImage image = folder.mask->asset.image(); + const double factor = transform.size.width() * scale * LayerRenderer::deviceScale(context) / std::max(1, image.width()); + const FolderMaskClip clip{DownsampleCache::shared().imageDrawnAt(image, factor), transform}; + const QPointF origin = center(transform.center()); + return [clip, origin, scale](const QPainter &painter, QImage &coverage) { clip.apply(origin, painter, coverage, scale); }; + }, context, [&](QUuid id, const QImage &clip) { live.drawComposite(id, context, clip); }); +} + +void CanvasView::drawDocumentPixels(const QRectF &view, const QRectF &pixels, const CanvasDocument &document, QPainter &context) +{ + const CanvasViewport &viewport = m_session.viewport; + const QPointF topLeft = viewport.documentPoint(view.topLeft(), document.size()); + const QPointF bottomRight = viewport.documentPoint(view.bottomRight(), document.size()); + const QRectF region = QRectF(std::floor(topLeft.x()), std::floor(topLeft.y()), std::ceil(bottomRight.x()) - std::floor(topLeft.x()), + std::ceil(bottomRight.y()) - std::floor(topLeft.y())) + .intersected(QRectF(pixels.toAlignedRect())); + if (region.isEmpty() || region.width() < 1 || region.height() < 1) + return; + QImage raster; + try { + raster = BrushRaster::context(int(region.width()), int(region.height()), false); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "document pixels could not be drawn:" << error.what(); + return; + } + { + QPainter painter(&raster); + drawLayers(document, 1, [&](QPointF point) { return QPointF(point.x() - region.left(), point.y() - region.top()); }, painter); + } + const QPointF origin = viewport.viewPoint(region.topLeft(), document.size()); + const QRectF target(origin, QSizeF(region.width() * viewport.pointsPerPixel(), region.height() * viewport.pointsPerPixel())); + context.save(); + context.setRenderHint(QPainter::SmoothPixmapTransform, false); + context.drawImage(target, raster); + context.restore(); +} + +// One-screen-pixel lines on document pixel boundaries, over the image only. +void CanvasView::drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const +{ + const CanvasViewport &viewport = m_session.viewport; + const double points = viewport.pointsPerPixel(); + const QRectF canvas(viewport.viewPoint(QPointF(0, 0), document.size()), QSizeF(document.width * points, document.height * points)); + const QRectF area = view.intersected(canvas); + if (area.isEmpty()) + return; + const QPointF first = viewport.documentPoint(area.topLeft(), document.size()); + const QPointF last = viewport.documentPoint(area.bottomRight(), document.size()); + const double hairline = 1 / viewport.backingScale; + // Crossings fill once, as CoreGraphics fills the union. + QPainterPath path; + path.setFillRule(Qt::WindingFill); + for (int column = int(std::ceil(first.x())); column <= int(std::floor(last.x())); ++column) { + const double x = viewport.viewPoint(QPointF(column, 0), document.size()).x(); + path.addRect(QRectF(x - hairline / 2, area.top(), hairline, area.height())); + } + for (int row = int(std::ceil(first.y())); row <= int(std::floor(last.y())); ++row) { + const double y = viewport.viewPoint(QPointF(0, row), document.size()).y(); + path.addRect(QRectF(area.left(), y - hairline / 2, area.width(), hairline)); + } + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.fillPath(path, QColor::fromRgbF(0.55, 0.55, 0.55, 0.45)); + context.restore(); +} diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h new file mode 100644 index 0000000..ce2d7fc --- /dev/null +++ b/src/Rendering/EditorCanvas.h @@ -0,0 +1,347 @@ +#pragma once +#include "Document/Crop.h" +#include "Document/EditorSession.h" +#include "Document/LayerMask.h" +#include "Rendering/BrushCursorOverlay.h" +#include "Rendering/InlineTextEditor.h" +#include "Rendering/LayerEffectsSurface.h" +#include "Rendering/SelectionIcons.h" +#include "Rendering/LayerRenderer.h" +#include "Rendering/SampleRingOverlay.h" +#include "Rendering/TransformOverlay.h" +#include <QElapsedTimer> +#include <QPainterPath> +#include <QTimer> +#include <QWidget> +#include <functional> +#include <optional> +#include <vector> + +// The document on screen: layers over a checkerboard. +class CanvasView : public QWidget { + Q_OBJECT +public: + explicit CanvasView(EditorSession &session, QWidget *parent = nullptr); + + // From 200% document pixels show hard-edged, as Photoshop does. + static constexpr double crispZoom = 2; + // The pixel grid appears from 800%. + static constexpr double pixelGridZoom = 8; + // Crop edges snap within this many view points. + static constexpr double cropSnapDistance = 8; + + // Repaints what the session changed; true when something did. + bool synchronizeDisplay(); + // Each request the session counts up takes focus once. + void consumeFocusRequest(int request); + // Photoshop's duplicate pointer: a black arrow over a white one. + static QCursor duplicateCursor(double ratio); + // The Move pointer and the rotation grip's, drawn as paths. + static QCursor moveCursor(double ratio); + static QCursor rotationCursor(double ratio); + // A white arrow: the handle under it distorts. + static QCursor distortCursor(double ratio); + // A badged pointing hand: Ctrl-click loads a selection. + static QCursor loadSelectionCursor(double ratio); + // An arrow with a dashed box: it drags the outline. + static QCursor moveSelectionCursor(double ratio); + // The arrow with scissors: a Ctrl-drag cuts and moves pixels. + static QCursor movePixelsCursor(double ratio); + // A crosshair with the tool's icon, badged + or −. + static QCursor selectionCursor(SelectionIcon icon, SelectionMode mode, double ratio); + // The wand, its sparkle at the hot spot, badged alike. + static QCursor wandCursor(SelectionMode mode, double ratio); + // Swift's eyedropper: haloed white, its tip the hot spot. + static QCursor eyedropperCursor(double ratio); + // The brush circle as last drawn, for tests. + const BrushCursorOverlay &brushCursor() const { return m_brushCursor; } + const SampleRingOverlay &sampleRing() const { return m_sampleRing; } + // The text typed on the canvas, while one is open. + InlineTextEditor *inlineTextEditor() const { return m_inlineTextEditor.get(); } + +protected: + bool event(QEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + void leaveEvent(QEvent *event) override; + void focusInEvent(QFocusEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + void inputMethodEvent(QInputMethodEvent *event) override; + QVariant inputMethodQuery(Qt::InputMethodQuery query) const override; + +private: + struct DisplayState { + struct Layer { + QUuid id; + LayerTransform transform; + std::optional<ImageIdentity> imageID; + std::optional<ImageIdentity> maskID; + std::optional<QUuid> maskSourceID; + // Entering or leaving a masked folder changes the clip. + std::optional<QUuid> parentID; + bool visible; + double opacity; + LayerBlendMode blendMode; + // Where the mask shows when placed apart from the layer. + std::optional<LayerTransform> maskPlacement; + std::optional<LayerAdjustment> adjustment; + std::optional<LayerEffects> effects; + friend bool operator==(const Layer &, const Layer &) = default; + }; + // Folders have no pixels, so their masks are tracked apart. + struct FolderMask { + QUuid id; + std::optional<ImageIdentity> maskID; + LayerTransform transform; + friend bool operator==(const FolderMask &, const FolderMask &) = default; + }; + // Every change to a stroke, so a press repaints. + int brushRevision; + bool pixelGrid; + // A distortion's corners: the warp follows them. + std::optional<Corners> corners; + std::optional<QUuid> documentID; + std::optional<QSizeF> size; + std::optional<QRectF> renderBounds; + CanvasViewport viewport; + std::vector<Layer> layers; + std::vector<FolderMask> folderMasks; + friend bool operator==(const DisplayState &, const DisplayState &) = default; + }; + // A Zoom press: drags zoom about it, clicks step. + struct ZoomDrag { + QPointF start; + double zoom; + bool moved; + }; + using Center = std::function<QPointF(QPointF)>; + // What a press off the handles drags, picked or not. + struct PressTarget { + QUuid id; + bool picked; + }; + + DisplayState displayState() const; + std::optional<QRectF> strokeDirtyRect() const; + FolderMaskClip::Applier liveFolderMaskClip(const BrushStroke &edit, double scale, const Center ¢er) const; + std::optional<QRectF> renderBounds() const; + void syncGeometry(); + void draw(QPainter &context, const QRectF &dirty); + void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask, + const LayerRenderer::Options &options, double scale, const Center ¢er, QPainter &target); + void drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context); + // Swift's effects surface on the canvas (EditorCanvas+Effects.cpp). + LayerEffectsSurface *strokeSurface(const ImageLayer &layer, const BrushStroke &stroke, const std::optional<QImage> &mask); + // The stroke is over: its surface stands in meanwhile. + void handOnStrokeSurface(); + // The dragged shape, in the colour it will have. + void drawShapeDraft(double scale, const Center ¢er, QPainter &target, const QImage &clip) const; + // What the draft draws with, so a change repaints it. + struct ShapeShown { + ShapeDraft draft; + PaletteColor color; + double lineWidth; + // The layer the draft sits above. + std::optional<QUuid> target; + friend bool operator==(const ShapeShown &, const ShapeShown &) = default; + }; + std::optional<ShapeShown> shownShape() const; + // Swift's synchronizeInlineText: the editor follows the draft. + void synchronizeInlineText(); + // The Type tool's press: the editor's, else Swift's gesture. + void pressTextTool(QPointF point, Qt::KeyboardModifiers modifiers, int clicks); + void beginTextGesture(QPointF point, Qt::KeyboardModifiers modifiers, int clicks); + void dragTextGesture(QPointF point); + void finishTextGesture(); + void endTextGesture(); + QRectF textBoxDraftRect() const; + void drawTextBoxDraft(QPainter &painter) const; + void drawDocumentPixels(const QRectF &view, const QRectF &pixels, const CanvasDocument &document, QPainter &context); + void drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const; + void updateCursor(); + static QPainterPath arrowPath(); + Qt::KeyboardModifiers heldModifiers() const; + void readModifiers(Qt::KeyboardModifiers modifiers); + void modifiersChanged(); + std::optional<PressTarget> transformPressLayer(QPointF pixel, Qt::KeyboardModifiers modifiers) const; + bool pressMovesLayer(QPointF point, Qt::KeyboardModifiers modifiers) const; + QCursor transformCursor(QPointF point, Qt::KeyboardModifiers modifiers) const; + void beginTransformDrag(QPointF point, Qt::KeyboardModifiers modifiers); + void dragTransform(QPointF point, Qt::KeyboardModifiers modifiers); + void endTransformDrag(); + void cancelTransformDrag(); + // Swift's lasso gestures (EditorCanvas+Selection.cpp). + QCursor lassoCursor(Qt::KeyboardModifiers modifiers, std::optional<QPointF> location) const; + static QCursor pixelDragCursor(bool duplicate, double ratio); + void lassoMouseDown(QPointF point, Qt::KeyboardModifiers modifiers); + bool moveSelectionTool(QPointF point, Qt::KeyboardModifiers modifiers); + void releaseSelectionTool(); + bool selectionKey(const QKeyEvent &key); + void endSelectionGestures(); + void dragSelection(QPointF point, Qt::KeyboardModifiers modifiers); + void dragMarqueeDraft(QPointF pixel, Qt::KeyboardModifiers modifiers); + QSizeF marqueeAutoscrollDelta(QPointF point) const; + void updateMarqueeAutoscroll(QPointF point); + void stepMarqueeAutoscroll(); + void stopMarqueeAutoscroll(); + void updateAntsTimer(); + // Swift's brush gestures (EditorCanvas+Brush.cpp). + void brushMouseDown(QPointF point, Qt::KeyboardModifiers modifiers); + bool brushMouseMove(QPointF point, Qt::KeyboardModifiers modifiers, bool dragging); + void brushMouseUp(QPointF point); + void beginBrushTipDrag(QPointF point, Qt::KeyboardModifiers modifiers); + void dragBrushTip(QPointF point, Qt::KeyboardModifiers modifiers); + void endBrushTipDrag(QPointF point); + void updateBrushCursor(); + // Clone Stamp's preview: one click's coverage, and the source there. + QImage cloneTip(double diameter, double hardness); + QImage clonePreview(QPointF center, double diameter, const CanvasDocument &document); + bool brushKey(const QKeyEvent &key); + // Swift's gradient gestures (EditorCanvas+Gradient.cpp). + void beginGradientDrag(QPointF point); + bool dragGradient(QPointF point, Qt::KeyboardModifiers modifiers); + // Shift keeps a line to 45° steps, as Photoshop. + static QPointF snapped(QPointF point, QPointF anchor); + bool brushBracket(const QString &text); + // Swift's colour sampling (EditorCanvas+Picking.cpp). + bool palettePicking() const; + bool picking() const; + void syncPicking(); + void beginSampling(QPointF point); + void sampleColor(QPointF point); + void endSampling(); + bool pickingMove(QPointF point, Qt::MouseButtons buttons); + void pickingRelease(); + // A sampling or targeting press, with Space up. + bool pickingPress(QPointF point); + // Swift's crop gestures (EditorCanvas+Crop.cpp). + std::optional<int> cropRegion(QPointF point) const; + QCursor cropCursor(QPointF point) const; + void beginCropDrag(QPointF point); + bool cropMove(QPointF point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers); + void dragCrop(QPointF point, Qt::KeyboardModifiers modifiers); + void endCropDrag(); + bool cropKey(const QKeyEvent &key); + // Swift's targeted drag: right raises; Ctrl drags the hue. + bool targetingMove(QPointF point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers); + void targetingRelease(); + + EditorSession &m_session; + TransformOverlay m_overlay; + std::optional<TransformOverlayGeometry> m_displayedGeometry; + SnapGuides m_displayedGuides; + // Where the overlay last drew, repainted with the next drawing. + QRect m_overlayRect; + std::optional<TransformDrag> m_transformDrag; + // The drag's cursor, kept until the release. + std::optional<QCursor> m_dragCursor; + bool m_duplicatesTransformOnDrag = false; + // The last pointer position over the canvas. + std::optional<QPointF> m_hover; + bool m_controlHeld = false; + // Busy or importing shows the arrow; tracked for refreshes. + bool m_displayedBlocked = false; + std::optional<DisplayState> m_displayedState; + std::optional<NavigationTool> m_displayedTool; + int m_lastFocusRequest = 0; + bool m_spaceHeld = false; + bool m_optionHeld = false; + std::optional<QPointF> m_lastDragPoint; + std::optional<ZoomDrag> m_zoomDrag; + bool m_shiftHeld = false; + // Document point where a drag of the outline began. + std::optional<QPointF> m_selectionDragStart; + // Where a Ctrl-drag of selected pixels began, in document pixels. + std::optional<QPointF> m_pixelDragStart; + // Shift at the press chose Add; pressed afresh it squares. + bool m_marqueeConstrainArmed = true; + std::optional<QPointF> m_marqueeDragPixel; + // Against the canvas edge the view pans toward the pointer. + QTimer m_marqueeAutoscroll; + std::optional<QPointF> m_marqueeAutoscrollPoint; + QTimer m_antsTimer; + std::optional<DocumentSelection> m_displayedOutline; + std::optional<LassoDraft> m_displayedDraft; + std::optional<ShapeShown> m_displayedShape; + // What the lasso cursor last showed: kinds and mode. + struct SelectionCursorState { + LassoKind marquee; + LassoKind lasso; + SelectionMode mode; + friend bool operator==(const SelectionCursorState &, const SelectionCursorState &) = default; + }; + std::optional<SelectionCursorState> m_displayedSelectionCursor; + BrushCursorOverlay m_brushCursor; + std::unique_ptr<InlineTextEditor> m_inlineTextEditor; + // A text box being drawn, in document pixels. + std::optional<QPointF> m_textBoxAnchor; + std::optional<QRectF> m_textBoxRect; + // Qt lacks triple clicks: a press soon after a double. + QElapsedTimer m_textDoubleClick; + QPointF m_textDoubleClickPoint; + // The circle's centre in view points; none off the canvas. + std::optional<QPointF> m_brushPointer; + // Shift's line keeps to the axis its first pixels chose. + std::optional<QPointF> m_brushAxisAnchor; + std::optional<bool> m_brushAxisHorizontal; + std::optional<QPointF> m_brushLastPixel; + // A right-drag: size or hardness from the press's values. + struct BrushTipDrag { + QPointF start; + double diameter; + double hardness; + bool hardnessShown; + }; + std::optional<BrushTipDrag> m_brushTipDrag; + // Swift's cloneTipCache: rebuilt when size or hardness change. + struct CloneTip { + double diameter; + double hardness; + QImage image; + }; + std::optional<CloneTip> m_cloneTip; + // Which end of the pending gradient a drag moves. + enum class GradientHandle { start, end }; + std::optional<GradientHandle> m_gradientDrag; + // Swift's ClonePreviewKey: what the preview depends on. + struct ClonePreviewKey { + QPointF center; + double diameter; + double scale; + int revision; + int undoCount; + bool allLayers; + std::optional<QUuid> layerID; + friend bool operator==(const ClonePreviewKey &, const ClonePreviewKey &) = default; + }; + struct ClonePreview { + ClonePreviewKey key; + QImage image; + }; + std::optional<ClonePreview> m_clonePreview; + SampleRingOverlay m_sampleRing; + // The painted layer's effects, made as its stroke starts. + std::unique_ptr<LayerEffectsSurface> m_strokeSurface; + // The colour a sampling press began from, for the ring. + PaletteColor m_samplingOriginal = PaletteColor::black(); + bool m_samplingColor = false; + bool m_displayedPicking = false; + bool m_displayedTargeting = false; + // Where a targeted drag began, in view points. + std::optional<QPointF> m_hueTargetStart; + std::optional<CropDrag> m_cropDrag; + // Built at the press: the zoom holds still mid-drag. + std::optional<CropSnap> m_cropSnap; + // The crop frame as last shown, in view points. + std::optional<QRectF> m_displayedCrop; +}; diff --git a/src/Rendering/EffectsPreviewCache.cpp b/src/Rendering/EffectsPreviewCache.cpp new file mode 100644 index 0000000..d1f9dc9 --- /dev/null +++ b/src/Rendering/EffectsPreviewCache.cpp @@ -0,0 +1,165 @@ +#include "Rendering/EffectsPreviewCache.h" +#include "Document/BrushStroke.h" +#include "Document/LayerEffects+Renderer.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QTimer> +#include <cmath> +#include <set> + +EffectsPreviewCache::EffectsPreviewCache(QObject *parent) : QObject(parent) +{ + m_worker.setMaxThreadCount(1); +} + +EffectsPreviewCache::~EffectsPreviewCache() +{ + // Queued renders go; the pool waits for the one running. + m_worker.clear(); +} + +bool EffectsPreviewCache::Request::matches(const Request &other) const +{ + // A move only moves the image; a placed mask resamples. + const bool sameMaskGeometry = (!maskSource && !other.maskSource) || (!placement && !other.placement) + || (placement == other.placement && transform == other.transform); + return image.identity() == other.image.identity() && maskSource == other.maskSource && sameMaskGeometry && effects == other.effects + && sideLimit == other.sideLimit; +} + +void EffectsPreviewCache::seed(QUuid id, const QImage &image, const LayerTransform &placement) +{ + // A render under way is for the pixels this replaces. + cancel(id); + m_seeds.insert_or_assign(id, Result{image, 0, placement}); +} + +std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::rendered(QUuid id) const +{ + if (m_entries.contains(id) && m_entries.at(id).result) + return m_entries.at(id).result; + return m_seeds.contains(id) ? std::optional(m_seeds.at(id)) : std::nullopt; +} + +void EffectsPreviewCache::prepare(const std::vector<ImageLayer> &layers) +{ + std::set<QUuid> ids; + for (const ImageLayer &layer : layers) { + if (layer.effects && !layer.effects->visible().isEmpty()) + ids.insert(layer.id); + } + for (auto entry = m_entries.begin(); entry != m_entries.end();) { + if (ids.contains(entry->first)) { + ++entry; + continue; + } + entry->second.request.cancelled->store(true); + entry = m_entries.erase(entry); + } + std::erase_if(m_seeds, [&](const auto &seed) { return !ids.contains(seed.first); }); + // About 64 MiB of output shared by every effect layer. + m_sideLimit = std::min(1536, std::max(32, int(std::sqrt(16'777'216.0 / double(std::max<size_t>(1, ids.size())))))); +} + +std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::preview(const ImageLayer &layer, const std::optional<QImage> &mask, + const LayerTransform &transform, + const std::optional<LayerTransform> &maskPlacement, + const std::function<void()> &completion) +{ + const LayerEffects effects = layer.effects.value_or(LayerEffects()).visible(); + if (!layer.asset || effects.isEmpty() || !effects.isValid()) { + cancel(layer.id); + return std::nullopt; + } + const std::optional<ImageIdentity> maskSource = layer.mask && layer.mask->isEnabled ? std::optional(layer.mask->asset.identity()) : std::nullopt; + const Request request{QUuid::createUuid(), *layer.asset, mask, maskSource, maskPlacement, transform, effects, m_sideLimit, + std::make_shared<std::atomic<bool>>(false)}; + if (m_entries.contains(layer.id) && m_entries.at(layer.id).request.matches(request)) + return m_entries.at(layer.id).result; + std::optional<Result> previous; + if (m_entries.contains(layer.id)) { + const Entry &old = m_entries.at(layer.id); + old.request.cancelled->store(true); + // Kept while the settings change on the same pixels. + if (old.request.image.identity() == request.image.identity() && old.request.maskSource == maskSource + && old.request.effects.kinds() == effects.kinds()) + previous = old.result; + } + if (!previous && m_seeds.contains(layer.id)) + previous = m_seeds.at(layer.id); + m_entries.insert_or_assign(layer.id, Entry{request, previous}); + const QUuid layerID = layer.id; + QTimer::singleShot(60, this, [this, request, layerID, completion] { + m_worker.start([this, request, layerID, completion] { + if (request.cancelled->load()) + return; + const std::optional<Result> result = render(request); + if (request.cancelled->load()) + return; + QMetaObject::invokeMethod(this, [this, layerID, id = request.id, result, completion] { land(layerID, id, result, completion); }, + Qt::QueuedConnection); + }); + }); + return previous; +} + +void EffectsPreviewCache::land(QUuid layerID, QUuid requestID, const std::optional<Result> &result, const std::function<void()> &completion) +{ + if (!m_entries.contains(layerID) || m_entries.at(layerID).request.id != requestID) + return; + m_entries.at(layerID).result = result; + if (result) + m_seeds.erase(layerID); + completion(); +} + +void EffectsPreviewCache::cancel(QUuid id) +{ + if (!m_entries.contains(id)) + return; + m_entries.at(id).request.cancelled->store(true); + m_entries.erase(id); +} + +std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::render(const Request &request) +{ + try { + // A painted layer flattens here, off the UI thread. + const QImage image = request.image.image(); + const double margin = LayerEffectsRenderer::margin(request.effects); + // The margins count too; even a 500px stroke stays bounded. + const double factor = std::min(1.0, double(request.sideLimit - 8) / (double(std::max(image.width(), image.height())) + 2 * margin)); + const int width = std::max(1, int(std::round(image.width() * factor))); + const int height = std::max(1, int(std::round(image.height() * factor))); + // Swift's high quality: each axis filtered by its own measure. + const auto resized = [&](const QImage &source, bool mask) { + // A mask travels as alpha, so its values pass exactly. + const QImage scaled = (mask ? BrushRaster::alphaView(source) : source) + .scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation) + .convertToFormat(mask ? QImage::Format_Alpha8 : QImage::Format_RGBA8888_Premultiplied); + const QImage result = mask ? QImage(scaled.constBits(), width, height, scaled.bytesPerLine(), QImage::Format_Grayscale8).copy() : scaled; + if (result.isNull()) + throw ExportError(ExportError::Kind::render); + return result; + }; + const QImage pixels = factor == 1 ? image : resized(image, false); + const std::optional<QImage> mask = request.mask ? std::optional(factor == 1 ? *request.mask : resized(*request.mask, true)) : std::nullopt; + LayerEffects effects = request.effects; + if (effects.stroke) + effects.stroke->size *= factor; + if (effects.shadow) { + effects.shadow->distance *= factor; + effects.shadow->blur *= factor; + } + // Swift forgets the inner shadow, which then shows too wide. + if (effects.innerShadow) { + effects.innerShadow->distance *= factor; + effects.innerShadow->blur *= factor; + } + const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(pixels, mask, effects); + return Result{rendered.image, rendered.inset, std::nullopt}; + } catch (const ExportError &error) { + qCWarning(lcRendering) << "an effects preview could not be made:" << error.what(); + } + return std::nullopt; +} diff --git a/src/Rendering/EffectsPreviewCache.h b/src/Rendering/EffectsPreviewCache.h new file mode 100644 index 0000000..9ab89cc --- /dev/null +++ b/src/Rendering/EffectsPreviewCache.h @@ -0,0 +1,60 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include <QObject> +#include <QThreadPool> +#include <atomic> +#include <functional> +#include <map> +#include <memory> + +// Swift's EffectsPreviewCache: canvas previews off the UI thread. +class EffectsPreviewCache : public QObject { + Q_OBJECT +public: + explicit EffectsPreviewCache(QObject *parent = nullptr); + ~EffectsPreviewCache() override; + // Swift's tuple; a seed alone carries where it belongs. + struct Result { + QImage image; + double inset; + std::optional<LayerTransform> placement; + }; + // Shown at `placement` until a fresh preview lands. + void seed(QUuid id, const QImage &image, const LayerTransform &placement); + // What is already rendered, asking for nothing new. + std::optional<Result> rendered(QUuid id) const; + // Drops layers without effects; they share the pixel budget. + void prepare(const std::vector<ImageLayer> &layers); + // The last result; `completion` runs once a new one lands. + std::optional<Result> preview(const ImageLayer &layer, const std::optional<QImage> &mask, const LayerTransform &transform, + const std::optional<LayerTransform> &maskPlacement, const std::function<void()> &completion); + +private: + struct Request { + QUuid id; + ImportedImage image; + std::optional<QImage> mask; + // Swift's `===` on the mask's enabled image. + std::optional<ImageIdentity> maskSource; + std::optional<LayerTransform> placement; + LayerTransform transform; + LayerEffects effects; + int sideLimit; + std::shared_ptr<std::atomic<bool>> cancelled; + bool matches(const Request &other) const; + }; + struct Entry { + Request request; + std::optional<Result> result; + }; + static std::optional<Result> render(const Request &request); + void cancel(QUuid id); + void land(QUuid layerID, QUuid requestID, const std::optional<Result> &result, const std::function<void()> &completion); + + std::map<QUuid, Entry> m_entries; + // Warped effects handed in, shown until the worker catches up. + std::map<QUuid, Result> m_seeds; + int m_sideLimit = 1536; + // Swift's serial queue: one render at a time. + QThreadPool m_worker; +}; diff --git a/src/Rendering/EyedropperIcon.cpp b/src/Rendering/EyedropperIcon.cpp new file mode 100644 index 0000000..7186259 --- /dev/null +++ b/src/Rendering/EyedropperIcon.cpp @@ -0,0 +1,33 @@ +#include "Rendering/EyedropperIcon.h" +#include <QPainterPath> + +namespace { +// The glyph stands turned 45 degrees about the grid's middle. +QTransform turned() +{ + return QTransform().translate(9, 9).rotate(45); +} + +constexpr QPointF tubeTip(0, 8.4); +} + +QPointF EyedropperIcon::tip() +{ + return turned().map(tubeTip); +} + +void EyedropperIcon::paint(QPainter &painter) +{ + // A bulb at the top right; a tube below. + painter.save(); + painter.setTransform(turned(), true); + painter.fillRect(QRectF(-2.4, -8.6, 4.8, 4.4), painter.pen().color()); + painter.drawLine(QPointF(-3.6, -3.6), QPointF(3.6, -3.6)); + QPainterPath tube(QPointF(-1.7, -3.6)); + tube.lineTo(QPointF(-1.7, 5.2)); + tube.lineTo(tubeTip); + tube.lineTo(QPointF(1.7, 5.2)); + tube.lineTo(QPointF(1.7, -3.6)); + painter.drawPath(tube); + painter.restore(); +} diff --git a/src/Rendering/EyedropperIcon.h b/src/Rendering/EyedropperIcon.h new file mode 100644 index 0000000..5775722 --- /dev/null +++ b/src/Rendering/EyedropperIcon.h @@ -0,0 +1,11 @@ +#pragma once +#include <QPainter> +#include <QPointF> + +// SF Symbols' eyedropper, which the rail and the cursor share. +namespace EyedropperIcon { +// The tube's tip, bottom left, on the 18-point grid. +QPointF tip(); +// Draws with the painter's pen, as the rail's icons. +void paint(QPainter &painter); +} diff --git a/src/Rendering/HslBlend.cpp b/src/Rendering/HslBlend.cpp new file mode 100644 index 0000000..fb58d15 --- /dev/null +++ b/src/Rendering/HslBlend.cpp @@ -0,0 +1,130 @@ +#include "Rendering/HslBlend.h" +#include "Rendering/PoolMap.h" +#include "IO/ImageExporter.h" +#include <QtConcurrent> +#include <algorithm> +#include <array> +#include <numeric> +#include <stdexcept> + +namespace { +using Color = std::array<float, 3>; + +float lum(const Color &color) +{ + return 0.3f * color[0] + 0.59f * color[1] + 0.11f * color[2]; +} + +Color clipColor(Color color) +{ + const float l = lum(color); + const float low = std::min({color[0], color[1], color[2]}), high = std::max({color[0], color[1], color[2]}); + for (float &channel : color) { + if (low < 0) + channel = l + (channel - l) * l / (l - low); + if (high > 1) + channel = l + (channel - l) * (1 - l) / (high - l); + } + return color; +} + +Color setLum(Color color, float l) +{ + const float shift = l - lum(color); + for (float &channel : color) + channel += shift; + return clipColor(color); +} + +float sat(const Color &color) +{ + return std::max({color[0], color[1], color[2]}) - std::min({color[0], color[1], color[2]}); +} + +Color setSat(const Color &color, float s) +{ + std::array<int, 3> order{0, 1, 2}; + std::sort(order.begin(), order.end(), [&](int lhs, int rhs) { return color[lhs] < color[rhs]; }); + const int low = order[0], middle = order[1], high = order[2]; + Color result{0, 0, 0}; + if (color[high] > color[low]) { + result[middle] = (color[middle] - color[low]) * s / (color[high] - color[low]); + result[high] = s; + } + return result; +} + +Color mixed(LayerBlendMode mode, const Color &backdrop, const Color &source) +{ + switch (mode) { + case LayerBlendMode::hue: + return setLum(setSat(source, sat(backdrop)), lum(backdrop)); + case LayerBlendMode::saturation: + return setLum(setSat(backdrop, sat(source)), lum(backdrop)); + case LayerBlendMode::color: + return setLum(source, lum(backdrop)); + case LayerBlendMode::luminosity: + return setLum(backdrop, lum(source)); + default: + throw std::logic_error("HslBlend handles only the four non-separable modes"); + } +} +} + +bool HslBlend::handles(LayerBlendMode mode) +{ + return mode == LayerBlendMode::hue || mode == LayerBlendMode::saturation || mode == LayerBlendMode::color + || mode == LayerBlendMode::luminosity; +} + +QImage HslBlend::blend(const QImage &backdrop, const QImage &source, LayerBlendMode mode, double opacity) +{ + if (backdrop.size() != source.size() || backdrop.format() != QImage::Format_RGBA8888_Premultiplied + || source.format() != QImage::Format_RGBA8888_Premultiplied) + throw std::logic_error("HslBlend needs two RGBA8888 premultiplied images of one size"); + // Checked here: a worker's exception would come back wrapped. + if (!handles(mode)) + throw std::logic_error("HslBlend blends hue, saturation, color and luminosity alone"); + QImage result(backdrop.size(), QImage::Format_RGBA8888_Premultiplied); + if (result.isNull()) + throw ExportError(ExportError::Kind::render); + // Pointers taken first: `scanLine` detaches, which would race. + const uchar *const lower = backdrop.constBits(), *const upper = source.constBits(); + uchar *const target = result.bits(); + const qsizetype lowerStride = backdrop.bytesPerLine(), upperStride = source.bytesPerLine(), targetStride = result.bytesPerLine(); + std::vector<int> rows; + try { + rows.resize(size_t(result.height())); + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } + std::iota(rows.begin(), rows.end(), 0); + // Each pixel stands alone: rows blend side by side. + PoolMap::blocking(rows, [&](int y) { + const uchar *below = lower + y * lowerStride, *above = upper + y * upperStride; + uchar *out = target + y * targetStride; + for (int x = 0; x < result.width(); ++x, below += 4, above += 4, out += 4) { + const float sourceAlpha = above[3] / 255.0f * float(opacity), backdropAlpha = below[3] / 255.0f; + if (sourceAlpha <= 0) { + std::copy_n(below, 4, out); + continue; + } + Color sourceColor{0, 0, 0}, backdropColor{0, 0, 0}; + for (int channel = 0; channel < 3; ++channel) { + sourceColor[channel] = above[channel] / float(above[3]); + backdropColor[channel] = below[3] ? below[channel] / float(below[3]) : 0; + } + const Color both = mixed(mode, backdropColor, sourceColor); + const float alpha = sourceAlpha + backdropAlpha - sourceAlpha * backdropAlpha; + out[3] = uchar(std::clamp(int(alpha * 255 + 0.5f), 0, 255)); + for (int channel = 0; channel < 3; ++channel) { + const float value = sourceColor[channel] * sourceAlpha * (1 - backdropAlpha) + + backdropColor[channel] * backdropAlpha * (1 - sourceAlpha) + + both[channel] * sourceAlpha * backdropAlpha; + // Premultiplied color never exceeds its alpha. + out[channel] = uchar(std::clamp(int(value * 255 + 0.5f), 0, int(out[3]))); + } + } + }); + return result; +} diff --git a/src/Rendering/HslBlend.h b/src/Rendering/HslBlend.h new file mode 100644 index 0000000..5463723 --- /dev/null +++ b/src/Rendering/HslBlend.h @@ -0,0 +1,10 @@ +#pragma once +#include "Document/LayerAppearance.h" +#include <QImage> + +// Hue, Saturation, Color, Luminosity: PDF blend modes QPainter lacks. +namespace HslBlend { +bool handles(LayerBlendMode mode); +// Source over backdrop, same size, both RGBA8888 premultiplied. +QImage blend(const QImage &backdrop, const QImage &source, LayerBlendMode mode, double opacity); +} diff --git a/src/Rendering/InlineTextEditor+Editing.cpp b/src/Rendering/InlineTextEditor+Editing.cpp new file mode 100644 index 0000000..1993a24 --- /dev/null +++ b/src/Rendering/InlineTextEditor+Editing.cpp @@ -0,0 +1,305 @@ +#include "Document/EditorSession.h" +#include "Rendering/InlineTextEditor.h" +#include "Rendering/TextLayout.h" +#include <QKeyEvent> +#include <QTextBoundaryFinder> +#include <algorithm> +#include <array> + +namespace { +bool isArrow(int key) +{ + return key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down; +} + +// What a text view types, as Qt's text fields judge. +bool isTyping(const QKeyEvent &event) +{ + const QString text = event.text(); + if (text.isEmpty()) + return false; + const QChar first = text.at(0); + if (first.category() == QChar::Other_Format) + return true; + // Ctrl alone is a command; AltGr (Ctrl with Alt) types. + if (event.modifiers() == Qt::ControlModifier || event.modifiers() == (Qt::ShiftModifier | Qt::ControlModifier)) + return false; + return first.isPrint() || first.category() == QChar::Other_PrivateUse || (first.isHighSurrogate() && text.size() > 1 && text.at(1).isLowSurrogate()); +} +} + +bool InlineTextEditor::claims(const QKeyEvent &event) +{ + // The text view's shortcuts, and the fills' text meanings. + constexpr std::array standard{QKeySequence::SelectAll, QKeySequence::Copy, QKeySequence::Cut, QKeySequence::Paste, QKeySequence::Undo, QKeySequence::Redo}; + if (std::any_of(standard.begin(), standard.end(), [&](QKeySequence::StandardKey key) { return event.matches(key); })) + return true; + return event.key() == Qt::Key_Backspace; +} + +bool InlineTextEditor::keyPress(const QKeyEvent &event) +{ + const Qt::KeyboardModifiers modifiers = event.modifiers(); + const int key = event.key(); + const bool enter = key == Qt::Key_Return || key == Qt::Key_Enter; + if (key == Qt::Key_Escape) { + m_session.cancelText(); + } else if (modifiers.testFlag(Qt::AltModifier) && isArrow(key)) { + // Alt with the arrows sets spacing, as Swift's Option. + changeSpacing(key, modifiers.testFlag(Qt::ShiftModifier) ? 10 : 1); + } else if (enter && modifiers.testFlag(Qt::ControlModifier)) { + m_session.finishText(); + } else if (erase(event)) { + // Deletions come first: X11 binds Alt+Backspace to Undo. + } else if (event.matches(QKeySequence::Undo)) { + undo(); + } else if (event.matches(QKeySequence::Redo)) { + redo(); + } else if (event.matches(QKeySequence::Cut)) { + cut(); + } else if (event.matches(QKeySequence::Copy)) { + copy(); + } else if (event.matches(QKeySequence::Paste)) { + paste(); + } else if (event.matches(QKeySequence::SelectAll)) { + selectAll(); + } else if (move(event)) { + return true; + } else if (enter) { + replace(selection(), QStringLiteral("\n"), Edit::typing); + } else if (key == Qt::Key_Tab && !(modifiers & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) { + replace(selection(), QStringLiteral("\t"), Edit::typing); + } else if (isTyping(event)) { + replace(selection(), event.text(), Edit::typing); + } else { + return false; + } + return true; +} + +// Deletions: a selection, else what the key reaches. +bool InlineTextEditor::erase(const QKeyEvent &event) +{ + const Qt::KeyboardModifiers modifiers = event.modifiers() & ~(Qt::KeypadModifier | Qt::ShiftModifier); + const bool back = event.key() == Qt::Key_Backspace, forward = event.key() == Qt::Key_Delete; + if ((!back && !forward) || (modifiers != Qt::NoModifier && modifiers != Qt::AltModifier && modifiers != Qt::ControlModifier)) + return false; + // Shift+Delete is X11's Cut. + if (forward && event.matches(QKeySequence::Cut)) + return false; + TextRange range = selection(); + if (range.isEmpty()) { + // Alt reaches a word, Ctrl the line, as Swift's fills. + const int reach = modifiers == Qt::AltModifier ? (back ? previousWordStart(m_position) : nextWordEnd(m_position)) + : modifiers == Qt::ControlModifier ? lineBound(forward) + : back ? previousCharacter(m_position) + : nextCharacter(m_position); + range = {std::min(reach, m_position), std::max(reach, m_position)}; + } + if (!range.isEmpty()) + replace(range, QString(), Edit::deleting); + return true; +} + +// Qt's movement keys, as the app's text fields take them. +bool InlineTextEditor::move(const QKeyEvent &event) +{ + const TextRange range = selection(); + const auto matches = [&](QKeySequence::StandardKey move, QKeySequence::StandardKey select) { + return event.matches(move) ? std::optional(false) : event.matches(select) ? std::optional(true) : std::nullopt; + }; + // Right goes back in right-to-left text, as Qt's fields. + const bool rtl = rightToLeft(); + const int back = rtl ? nextCharacter(m_position) : previousCharacter(m_position); + const int on = rtl ? previousCharacter(m_position) : nextCharacter(m_position); + if (const std::optional<bool> extend = matches(QKeySequence::MoveToNextChar, QKeySequence::SelectNextChar)) + moveTo(!*extend && !range.isEmpty() ? (rtl ? range.start : range.end) : on, *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToPreviousChar, QKeySequence::SelectPreviousChar)) + moveTo(!*extend && !range.isEmpty() ? (rtl ? range.end : range.start) : back, *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToNextWord, QKeySequence::SelectNextWord)) + moveTo(rtl ? previousWordStart(m_position) : nextWordEnd(m_position), *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToPreviousWord, QKeySequence::SelectPreviousWord)) + moveTo(rtl ? nextWordEnd(m_position) : previousWordStart(m_position), *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToNextLine, QKeySequence::SelectNextLine)) + moveTo(verticalTarget(1), *extend, true); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToPreviousLine, QKeySequence::SelectPreviousLine)) + moveTo(verticalTarget(-1), *extend, true); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToStartOfLine, QKeySequence::SelectStartOfLine)) + moveTo(lineBound(false), *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToEndOfLine, QKeySequence::SelectEndOfLine)) + moveTo(lineBound(true), *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToStartOfDocument, QKeySequence::SelectStartOfDocument)) + moveTo(0, *extend); + else if (const std::optional<bool> extend = matches(QKeySequence::MoveToEndOfDocument, QKeySequence::SelectEndOfDocument)) + moveTo(int(style().content.size()), *extend); + else + return false; + return true; +} + +// Whether the caret's paragraph reads right to left. +bool InlineTextEditor::rightToLeft() const +{ + const QString &content = style().content; + int start = m_position, end = m_position; + while (start > 0 && !separates(content.at(start - 1))) + --start; + while (end < content.size() && !separates(content.at(end))) + ++end; + return QStringView(content).mid(start, end - start).isRightToLeft(); +} + +// Cocoa's paragraph separators, as `TextLines` parts paragraphs. +bool InlineTextEditor::separates(QChar character) +{ + return character == u'\n' || character == u'\r' || character == QChar::ParagraphSeparator; +} + +TextRange InlineTextEditor::spanning(int anchor, int position) +{ + return {std::min(anchor, position), std::max(anchor, position)}; +} + +TextRange InlineTextEditor::selection() const +{ + return spanning(m_anchor, m_position); +} + +void InlineTextEditor::replace(TextRange range, const QString &text, std::optional<Edit> edit) +{ + const QString content = style().content; + // Swift's shouldChangeTextIn: 100,000 UTF-16 units at most. + if (content.size() - (range.end - range.start) + text.size() > 100'000) + return; + if (!edit || edit != m_lastEdit) + m_undo.push_back(m_composing ? m_composing->before : Snapshot{content, m_anchor, m_position}); + m_redo.clear(); + m_lastEdit = edit; + m_composing.reset(); + m_anchor = m_position = range.start + int(text.size()); + publish(QString(content).replace(range.start, range.end - range.start, text)); +} + +// Swift's textDidChange: the draft takes the text view's string. +void InlineTextEditor::publish(const QString &content) +{ + // An edit ends a drag or resize, like a press. + release(); + TextDraft draft = m_session.textDraft().value(); + draft.style.content = content; + m_goalX.reset(); + showCaret(); + m_session.setTextDraft(draft); +} + +void InlineTextEditor::moveTo(int position, bool extend, bool keepGoal) +{ + m_position = position; + if (!extend) + m_anchor = position; + if (!keepGoal) + m_goalX.reset(); + m_lastEdit.reset(); + showCaret(); + updateInputMethod(); +} + +void InlineTextEditor::restore(std::vector<Snapshot> &from, std::vector<Snapshot> &to) +{ + if (from.empty()) + return; + to.push_back(m_composing ? m_composing->before : Snapshot{style().content, m_anchor, m_position}); + const Snapshot snapshot = from.back(); + from.pop_back(); + m_lastEdit.reset(); + m_composing.reset(); + m_anchor = snapshot.anchor; + m_position = snapshot.position; + publish(snapshot.content); +} + +void InlineTextEditor::undo() +{ + restore(m_undo, m_redo); +} + +void InlineTextEditor::redo() +{ + restore(m_redo, m_undo); +} + +void InlineTextEditor::changeSpacing(int key, double step) +{ + // A resize would write back the style it began with. + release(); + m_session.changeTextStyle([key, step](LayerTextStyle &style) { + if (key == Qt::Key_Left || key == Qt::Key_Right) + style.tracking += key == Qt::Key_Left ? -step : step; + // Up closes the lines, counting from Auto's height. + else + style.leading = key == Qt::Key_Up ? std::max(1.0, style.lineHeight() - step) : style.lineHeight() + step; + }); +} + +int InlineTextEditor::nextCharacter(int position) const +{ + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, style().content); + finder.setPosition(position); + const qsizetype next = finder.toNextBoundary(); + return next < 0 ? int(style().content.size()) : int(next); +} + +int InlineTextEditor::previousCharacter(int position) const +{ + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, style().content); + finder.setPosition(position); + return int(std::max<qsizetype>(finder.toPreviousBoundary(), 0)); +} + +// NSTextView's word moves: to a word's end, or its start. +int InlineTextEditor::nextWordEnd(int position) const +{ + QTextBoundaryFinder finder(QTextBoundaryFinder::Word, style().content); + finder.setPosition(position); + for (qsizetype at = finder.toNextBoundary(); at >= 0; at = finder.toNextBoundary()) { + if (finder.boundaryReasons().testFlag(QTextBoundaryFinder::EndOfItem)) + return int(at); + } + return int(style().content.size()); +} + +int InlineTextEditor::previousWordStart(int position) const +{ + QTextBoundaryFinder finder(QTextBoundaryFinder::Word, style().content); + finder.setPosition(position); + for (qsizetype at = finder.toPreviousBoundary(); at >= 0; at = finder.toPreviousBoundary()) { + if (finder.boundaryReasons().testFlag(QTextBoundaryFinder::StartOfItem)) + return int(at); + } + return 0; +} + +// The caret's line's start or end, else the text's. +int InlineTextEditor::lineBound(bool end) const +{ + const std::optional<int> line = m_lines->lineOf(m_position); + if (!line) + return end ? int(style().content.size()) : 0; + return end ? m_lines->lineEnd(*line) : m_lines->lineStart(*line); +} + +// Above line one: the start; below the last: the end. +int InlineTextEditor::verticalTarget(int direction) +{ + const std::optional<int> line = m_lines->lineOf(m_position); + if (!line) + return m_position; + const double x = m_goalX.value_or(m_lines->caret(m_position).value().x()); + m_goalX = x; + const int target = *line + direction; + if (target < 0) + return 0; + if (target >= m_lines->lineCount()) + return int(style().content.size()); + return m_lines->position(target, x); +} diff --git a/src/Rendering/InlineTextEditor+Input.cpp b/src/Rendering/InlineTextEditor+Input.cpp new file mode 100644 index 0000000..0e08a90 --- /dev/null +++ b/src/Rendering/InlineTextEditor+Input.cpp @@ -0,0 +1,219 @@ +#include "Document/EditorSession.h" +#include "Rendering/InlineTextEditor.h" +#include "Rendering/TextLayout.h" +#include <QClipboard> +#include <QGuiApplication> +#include <QInputMethodEvent> +#include <algorithm> +#include <cstdlib> + +void InlineTextEditor::cut() +{ + const Snapshot around = plain(); + if (spanning(around.anchor, around.position).isEmpty()) + return; + copy(); + change(QString()); +} + +void InlineTextEditor::copy() const +{ + const Snapshot around = plain(); + const TextRange range = spanning(around.anchor, around.position); + if (!range.isEmpty()) + QGuiApplication::clipboard()->setText(around.content.mid(range.start, range.end - range.start)); +} + +// Swift's paste is pasteAsPlainText. +void InlineTextEditor::paste() +{ + const QString text = QGuiApplication::clipboard()->text(); + if (!text.isEmpty()) + change(text); +} + +void InlineTextEditor::selectAll() +{ + // Around a preedit, which follows the cursor to the end. + if (m_composing) { + m_lastEdit.reset(); + const QString text = plain().content; + settle(text, 0, int(text.size())); + return; + } + m_anchor = 0; + moveTo(int(style().content.size()), true); +} + +// The text around a preedit, which Qt's fields keep apart. +InlineTextEditor::Snapshot InlineTextEditor::plain() const +{ + if (!m_composing) + return {style().content, m_anchor, m_position}; + const TextRange marked = m_composing->marked; + return {QString(style().content).remove(marked.start, marked.end - marked.start), m_composing->anchor, marked.start}; +} + +// A clipboard command replaces the selection around a preedit. +void InlineTextEditor::change(const QString &text) +{ + const Snapshot around = plain(); + const TextRange range = spanning(around.anchor, around.position); + if (style().content.size() - (range.end - range.start) + text.size() > 100'000) + return; + m_undo.push_back(around); + m_redo.clear(); + m_lastEdit.reset(); + const int cursor = range.start + int(text.size()); + settle(QString(around.content).replace(range.start, range.end - range.start, text), cursor, cursor); +} + +// The preedit returns to the cursor, as Qt shows it. +void InlineTextEditor::settle(QString text, int anchor, int cursor) +{ + if (m_composing) { + const TextRange marked = m_composing->marked; + const QString preedit = style().content.mid(marked.start, marked.end - marked.start); + const int caret = m_position - marked.start; + m_composing->before = {text, anchor, cursor}; + m_composing->anchor = anchor; + m_composing->marked = {cursor, cursor + int(preedit.size())}; + text.insert(cursor, preedit); + anchor = cursor = cursor + caret; + } + m_anchor = anchor; + m_position = cursor; + publish(text); +} + +// Marked text sits in the content, as in NSTextView's storage. +void InlineTextEditor::inputMethod(const QInputMethodEvent &event) +{ + const QString commit = event.commitString(), preedit = event.preeditString(); + const QList<QInputMethodEvent::Attribute> attributes = event.attributes(); + const auto selecting = std::find_if(attributes.begin(), attributes.end(), [](const auto &attribute) { return attribute.type == QInputMethodEvent::Selection; }); + // Qt's steps, on the text without the preedit. + QString text = style().content, shown; + int cursor = m_position, anchor = m_anchor; + if (m_composing) { + const TextRange marked = m_composing->marked; + shown = text.mid(marked.start, marked.end - marked.start); + text.remove(marked.start, marked.end - marked.start); + cursor = marked.start; + anchor = m_composing->anchor; + } + const bool input = !commit.isEmpty() || preedit != shown || event.replacementLength() > 0; + if (!input && !m_composing && selecting == attributes.end()) + return; + // Input takes the selection first, as Qt's fields. + const TextRange selected{std::min(anchor, cursor), std::max(anchor, cursor)}; + const bool takes = input && !selected.isEmpty(); + if (takes) { + text.remove(selected.start, selected.end - selected.start); + cursor = selected.start; + } + // Where Qt leaves the cursor when nothing commits. + int after = cursor; + if (event.replacementStart() <= 0) + after += int(commit.size()) - std::min(-event.replacementStart(), event.replacementLength()); + const int start = std::clamp(cursor + event.replacementStart(), 0, int(text.size())); + const int end = event.replacementLength() > 0 && start + event.replacementLength() <= text.size() ? start + event.replacementLength() : start; + if (text.size() - (end - start) + commit.size() + preedit.size() > 100'000) + return; + // A preedit records nothing; what lands makes a typing step. + const Snapshot before = m_composing ? m_composing->before : Snapshot{style().content, m_anchor, m_position}; + const bool lands = takes || end > start || !commit.isEmpty(); + if (lands) { + if (m_lastEdit != Edit::typing) + m_undo.push_back(before); + m_redo.clear(); + m_lastEdit = Edit::typing; + } + text.replace(start, end - start, commit); + cursor = commit.isEmpty() ? std::clamp(after, 0, int(text.size())) : start + int(commit.size()); + if (input) + anchor = cursor; + // The input method's own move ends a typing run. + if (selecting != attributes.end()) { + cursor = std::clamp(selecting->start + selecting->length, 0, int(text.size())); + anchor = selecting->length ? std::clamp(selecting->start, 0, int(text.size())) : cursor; + m_lastEdit.reset(); + } + m_composing.reset(); + if (preedit.isEmpty()) { + m_anchor = anchor; + m_position = cursor; + } else { + compose(preedit, attributes, lands ? Snapshot{text, anchor, cursor} : before, anchor, text, cursor); + } + publish(text); +} + +// The preedit goes in at the cursor, with Qt's attributes. +void InlineTextEditor::compose(const QString &preedit, const QList<QInputMethodEvent::Attribute> &attributes, const Snapshot &before, int anchor, + QString &text, int cursor) +{ + const int length = int(preedit.size()); + Composing composing{.marked = {cursor, cursor + length}, .anchor = anchor, .before = before}; + int caret = length; + for (const QInputMethodEvent::Attribute &attribute : attributes) { + const int start = std::clamp(attribute.start, 0, length); + if (attribute.type == QInputMethodEvent::Cursor) { + caret = start; + composing.caretHidden = attribute.length == 0; + if (attribute.value.typeId() == QMetaType::QColor) + composing.caretColour = attribute.value.value<QColor>(); + } else if (attribute.type == QInputMethodEvent::TextFormat) { + // Qt's fields pass over a format that is not one. + const QTextCharFormat format = qvariant_cast<QTextFormat>(attribute.value).toCharFormat(); + const int end = std::clamp(attribute.start + attribute.length, start, length); + if (format.isValid()) + composing.formats << QTextLayout::FormatRange{start, end - start, format}; + } + } + text.insert(cursor, preedit); + m_anchor = m_position = cursor + caret; + m_composing = composing; +} + +// The selection shown: the input method's keeps beside the preedit. +TextRange InlineTextEditor::shownSelection() const +{ + if (!m_composing) + return selection(); + const TextRange marked = m_composing->marked; + const int length = marked.end - marked.start; + const TextRange range = spanning(m_composing->anchor, marked.start); + return {range.start < marked.start ? range.start : range.start + length, range.end <= marked.start ? range.end : range.end + length}; +} + +QVariant InlineTextEditor::inputMethodQuery(Qt::InputMethodQuery query) const +{ + const QString &content = style().content; + const std::optional<TextRange> marked = this->marked(); + const int cursor = marked ? marked->start : m_position, anchor = m_composing ? m_composing->anchor : m_anchor; + const QString surrounding = marked ? QString(content).remove(marked->start, marked->end - marked->start) : content; + switch (query) { + case Qt::ImEnabled: + return true; + case Qt::ImHints: + return int(Qt::ImhMultiLine); + case Qt::ImFont: + return TextLayout::font(style()); + case Qt::ImCursorRectangle: { + const double padding = LayerTextStyle::padding; + const QRectF caret = m_lines->caret(m_position).value_or(QRectF(0, 0, 1, style().lineHeight())).translated(padding, padding); + return textTransform().mapRect(caret); + } + case Qt::ImSurroundingText: + return surrounding; + case Qt::ImCursorPosition: + return cursor; + case Qt::ImAnchorPosition: + return anchor; + case Qt::ImCurrentSelection: + return surrounding.mid(spanning(anchor, cursor).start, std::abs(anchor - cursor)); + default: + return QVariant(); + } +} diff --git a/src/Rendering/InlineTextEditor+Pointer.cpp b/src/Rendering/InlineTextEditor+Pointer.cpp new file mode 100644 index 0000000..dc0451f --- /dev/null +++ b/src/Rendering/InlineTextEditor+Pointer.cpp @@ -0,0 +1,315 @@ +#include "Document/EditorSession.h" +#include "Document/Selection.h" +#include "Rendering/EditorCanvas.h" +#include "Rendering/InlineTextEditor.h" +#include <QGuiApplication> +#include <QInputMethod> +#include <QPainter> +#include <QTextBoundaryFinder> +#include <algorithm> +#include <array> +#include <cmath> + +// Swift's hitTest: the handles' bands, then the box. +bool InlineTextEditor::press(QPointF point, Qt::KeyboardModifiers modifiers, int clicks) +{ + // A press starts afresh: Qt can lose a release. + release(); + const std::optional<int> handle = handleAt(point); + const QRectF box(QPointF(0, 0), m_logicalSize); + const QPointF local = boxTransform().inverted().map(point); + if (!handle && !box.contains(local)) + return false; + if (handle) { + land(); + beginResize(*handle, point); + return true; + } + // A click in the padding, the editor's own, does nothing. + const double padding = LayerTextStyle::padding; + if (box.adjusted(padding, padding, -padding, -padding).contains(local)) + pressText(point, modifiers, clicks); + return true; +} + +// NSTextView's clicks: a caret, a word, a paragraph; Shift extends. +void InlineTextEditor::pressText(QPointF point, Qt::KeyboardModifiers modifiers, int clicks) +{ + land(); + m_selecting = true; + // Shift extends by the last click's unit while that stands. + if (clicks == 1 && modifiers.testFlag(Qt::ShiftModifier)) { + if (!m_tracking || m_tracking->anchor != m_anchor || m_tracking->position != m_position) + m_tracking = Tracking{{m_anchor, m_anchor}, 1, m_anchor, m_position}; + drag(point); + return; + } + const int position = positionAt(point, false); + const TextRange unit = clicks == 1 ? TextRange{position, position} : unitAt(positionAt(point, true), clicks); + m_anchor = unit.start; + moveTo(unit.end, true); + m_tracking = Tracking{unit, clicks, m_anchor, m_position}; +} + +// A drag selects by its click's unit from its start. +bool InlineTextEditor::drag(QPointF point) +{ + if (m_resize) { + resizeTo(point); + return true; + } + if (!m_selecting) + return false; + Tracking &tracking = m_tracking.value(); + const int position = positionAt(point, false); + const TextRange unit = tracking.clicks == 1 ? TextRange{position, position} : unitAt(positionAt(point, true), tracking.clicks); + if (unit.start < tracking.origin.start) { + m_anchor = tracking.origin.end; + moveTo(unit.start, true); + } else { + m_anchor = tracking.origin.start; + moveTo(unit.end, true); + } + tracking.anchor = m_anchor; + tracking.position = m_position; + return true; +} + +// The unit stays for Shift; the press itself ends. +void InlineTextEditor::release() +{ + m_resize.reset(); + m_selecting = false; +} + +std::optional<QCursor> InlineTextEditor::cursorAt(QPointF point) const +{ + const std::optional<int> handle = m_resize ? std::optional(m_resize->handle) : handleAt(point); + if (!handle) + return std::nullopt; + // Swift's frame positions, turned with the box by eighths. + static constexpr std::array<int, 8> positions{0, 1, 2, 3, 0, 1, 2, 3}; + static constexpr std::array shapes{Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor}; + const std::optional<TextDraft> &draft = m_session.textDraft(); + const double rotation = draft && draft->transform ? draft->transform->rotation : 0; + // Reduced first: Swift's Int holds turns an int cannot. + const int turns = (int(std::round(std::fmod(rotation, 360) / 45)) % 8 + 8) % 8; + return QCursor(shapes[size_t((positions[size_t(*handle)] + turns) % 4)]); +} + +// Swift's handle(at:): a band along each edge, in box units. +std::optional<int> InlineTextEditor::handleAt(QPointF point) const +{ + const QPointF local = boxTransform().inverted().map(point); + const double width = m_logicalSize.width(), height = m_logicalSize.height(); + // Ten points to a six-point handle, as the Move box. + const double reach = std::min(m_handleSize * 10 / 6, std::min(width, height) / 3); + if (local.x() < -reach || local.x() > width + reach || local.y() < -reach || local.y() > height + reach) + return std::nullopt; + const bool left = local.x() <= reach, right = local.x() >= width - reach; + const bool top = local.y() <= reach, bottom = local.y() >= height - reach; + if (left && top) + return 0; + if (right && top) + return 2; + if (right && bottom) + return 4; + if (left && bottom) + return 6; + if (top) + return 1; + if (right) + return 3; + if (bottom) + return 5; + if (left) + return 7; + return std::nullopt; +} + +// The text position under a point, through the text's mirror. +int InlineTextEditor::positionAt(QPointF point, bool onCharacter) const +{ + if (m_lines->lineCount() == 0) + return 0; + const double padding = LayerTextStyle::padding; + const QPointF text = textTransform().inverted().map(point) - QPointF(padding, padding); + // Above the lines is the start, below them the end. + if (text.y() < 0) + return 0; + // A double first: a far drag passes int's range. + const double line = std::floor(text.y() / style().lineHeight()); + if (line >= m_lines->lineCount()) + return m_lines->lineEnd(m_lines->lineCount() - 1); + return onCharacter ? m_lines->character(int(line), text.x()) : m_lines->position(int(line), text.x()); +} + +// A double click's word; a triple click's paragraph and break. +TextRange InlineTextEditor::unitAt(int position, int clicks) const +{ + const QString &content = style().content; + if (clicks == 2) { + // Past the text, its last word; after a break, none. + const QChar last = content.isEmpty() ? QChar() : content.back(); + if (content.isEmpty() || (position == content.size() && (separates(last) || last == QChar::LineSeparator))) + return {position, position}; + QTextBoundaryFinder finder(QTextBoundaryFinder::Word, content); + finder.setPosition(std::min(position, int(content.size()) - 1)); + if (!finder.isAtBoundary()) + finder.toPreviousBoundary(); + const int start = int(finder.position()); + return {start, int(finder.toNextBoundary())}; + } + int start = position, end = position; + while (start > 0 && !separates(content.at(start - 1))) + --start; + while (end < content.size() && !separates(content.at(end))) + ++end; + if (end < content.size()) + end += content.mid(end, 2) == QStringLiteral("\r\n") ? 2 : 1; + return {start, end}; +} + +// Clicks commit a preedit, as Qt asks, or land it. +void InlineTextEditor::land() +{ + if (!m_composing) + return; + QGuiApplication::inputMethod()->commit(); + if (!m_composing) + return; + QInputMethodEvent commit; + commit.setCommitString(style().content.mid(m_composing->marked.start, m_composing->marked.end - m_composing->marked.start)); + inputMethod(commit); +} + +// Swift's handle press: point text becomes a box, as sized. +void InlineTextEditor::beginResize(int handle, QPointF point) +{ + TextDraft draft = m_session.textDraft().value(); + if (!draft.style.boxSize) { + draft.style.boxSize = m_logicalSize; + draft.transform = m_shownTransform; + m_session.setTextDraft(draft); + } + m_resize = Resize{handle, draft, m_shownTransform, m_session.viewport.documentPoint(point, m_session.document().value().size())}; +} + +// Swift's mouseDragged: the box in its frame, 16 pixels least. +void InlineTextEditor::resizeTo(QPointF point) +{ + const Resize &resize = m_resize.value(); + const LayerTransform &old = resize.transform; + const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document().value().size()); + const double dx = pixel.x() - resize.start.x(), dy = pixel.y() - resize.start.y(); + const double localX = dx * std::cos(old.radians()) + dy * std::sin(old.radians()); + const double localY = -dx * std::sin(old.radians()) + dy * std::cos(old.radians()); + const QPointF unit = LayerTransform::handles[size_t(resize.handle)]; + const QSizeF source = resize.draft.style.boxSize.value(); + const double minWidth = 16 * old.size.width() / source.width(), minHeight = 16 * old.size.height() / source.height(); + double left = 0, top = 0, right = old.size.width(), bottom = old.size.height(); + if (unit.x() == 0) + left = std::min(localX, right - minWidth); + if (unit.x() == 1) + right = std::max(left + minWidth, right + localX); + if (unit.y() == 0) + top = std::min(localY, bottom - minHeight); + if (unit.y() == 1) + bottom = std::max(top + minHeight, bottom + localY); + TextDraft draft = resize.draft; + draft.style.boxSize = QSizeF(std::round((right - left) * source.width() / old.size.width()), std::round((bottom - top) * source.height() / old.size.height())); + if (!draft.style.boxIsValid()) + return; + LayerTransform transform = old; + transform.size = QSizeF(draft.style.boxSize->width() * old.size.width() / source.width(), draft.style.boxSize->height() * old.size.height() / source.height()); + // The edge not dragged stays put, however the box turns. + transform.origin += old.point(QPointF(left / old.size.width(), top / old.size.height())) - transform.point(QPointF(0, 0)); + if (!transform.isValid()) + return; + draft.origin = transform.origin; + draft.transform = transform; + m_session.setTextDraft(draft); +} + +void CanvasView::pressTextTool(QPointF point, Qt::KeyboardModifiers modifiers, int clicks) +{ + // A box whose release was lost goes first. + if (m_textBoxAnchor) + endTextGesture(); + if (m_inlineTextEditor && m_inlineTextEditor->press(point, modifiers, clicks)) { + updateCursor(); + return; + } + beginTextGesture(point, modifiers, clicks); +} + +// Swift's beginTextGesture: open the text there, else draw a box. +void CanvasView::beginTextGesture(QPointF point, Qt::KeyboardModifiers modifiers, int clicks) +{ + if (!m_session.finishText()) + return; + const CanvasDocument &document = m_session.document().value(); + const QPointF pixel = m_session.viewport.documentPoint(point, document.size()); + const QSet<QUuid> visible = document.effectiveVisibleIDs(); + for (auto layer = document.layers.rbegin(); layer != document.layers.rend(); ++layer) { + if (!visible.contains(layer->id) || !layer->liveText() || !layer->transform.contains(pixel)) + continue; + m_session.selectLayer(QUuid(layer->id)); + m_session.editActiveText(); + if (m_inlineTextEditor) + m_inlineTextEditor->pressText(point, modifiers, clicks); + return; + } + m_textBoxAnchor = pixel; + m_textBoxRect = QRectF(pixel, QSizeF(0, 0)); +} + +void CanvasView::dragTextGesture(QPointF point) +{ + // Swift's guard: a document undone mid-drag takes nothing. + if (!m_session.document()) + return; + const QRectF before = textBoxDraftRect(); + m_textBoxRect = DragBox::rect(m_textBoxAnchor.value(), m_session.viewport.documentPoint(point, m_session.document().value().size()), false, false); + update((before | textBoxDraftRect()).adjusted(-2, -2, 2, 2).toAlignedRect()); +} + +// Under four pixels a drag is a click: point text. +void CanvasView::finishTextGesture() +{ + const QRectF rect = m_textBoxRect.value(); + endTextGesture(); + if (rect.width() < 4 && rect.height() < 4) + m_session.beginText(rect.topLeft(), true); + else + m_session.beginText(rect); +} + +// The box being drawn goes, repainted away. +void CanvasView::endTextGesture() +{ + update(textBoxDraftRect().adjusted(-2, -2, 2, 2).toAlignedRect()); + m_textBoxAnchor.reset(); + m_textBoxRect.reset(); +} + +// Where the box being drawn shows, in view points. +QRectF CanvasView::textBoxDraftRect() const +{ + if (!m_textBoxRect || !m_session.document()) + return QRectF(); + const QPointF origin = m_session.viewport.viewPoint(m_textBoxRect->topLeft(), m_session.document().value().size()); + return QRectF(origin, m_textBoxRect->size() * m_session.viewport.pointsPerPixel()); +} + +void CanvasView::drawTextBoxDraft(QPainter &painter) const +{ + if (!m_textBoxRect) + return; + painter.save(); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(palette().color(QPalette::Highlight), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(textBoxDraftRect()); + painter.restore(); +} diff --git a/src/Rendering/InlineTextEditor.cpp b/src/Rendering/InlineTextEditor.cpp new file mode 100644 index 0000000..5032d33 --- /dev/null +++ b/src/Rendering/InlineTextEditor.cpp @@ -0,0 +1,237 @@ +#include "Rendering/InlineTextEditor.h" +#include "Document/EditorSession.h" +#include "Rendering/EditorCanvas.h" +#include <QApplication> +#include <QGuiApplication> +#include <QInputMethod> +#include <QLineEdit> +#include <QPainter> +#include <QPointer> +#include <QStyleHints> +#include <cmath> + +InlineTextEditor::InlineTextEditor(EditorSession &session, QWidget &canvas) : m_session(session), m_canvas(canvas) +{ + // NSTextView's insertion point blinks at the system's pace. + m_blink.setObjectName(QStringLiteral("caretBlink")); + m_blink.setParent(&canvas); + m_blink.setInterval(QGuiApplication::styleHints()->cursorFlashTime() / 2); + QObject::connect(&m_blink, &QTimer::timeout, &m_blink, [this] { + m_caretShown = !m_caretShown; + repaint(); + }); +} + +void InlineTextEditor::synchronize(const TextDraft &draft) +{ + // A draft needs a document, and holds it open. + const CanvasDocument &document = m_session.document().value(); + const bool fresh = m_draftID != draft.id; + m_draftID = draft.id; + // Another draft starts afresh: no steps, nothing composing. + if (fresh) { + m_undo.clear(); + m_redo.clear(); + m_lastEdit.reset(); + m_composing.reset(); + } + const LayerTextStyle &style = draft.style; + // Point text has no box: it is what is typed. + m_logicalSize = EditorSession::textBoxSize(style); + LayerTransform transform = draft.transform.value_or(LayerTransform{.origin = draft.origin, .size = m_logicalSize}); + // Point text grows at its layer's scale, corner kept. + const int index = draft.layerID ? indexOf(document.layers, *draft.layerID) : -1; + if (!style.boxSize && draft.transform && index >= 0 && document.layers[size_t(index)].asset) { + const double factor = transform.size.width() / document.layers[size_t(index)].asset->size().width(); + const QPointF anchor = transform.point(QPointF(0, 0)); + transform.size = m_logicalSize * factor; + transform.origin += anchor - transform.point(QPointF(0, 0)); + } + m_shownTransform = transform; + const double scale = m_session.viewport.pointsPerPixel(); + m_handleSize = std::max(2.0, 6 / std::max(0.01, scale * transform.size.width() / m_logicalSize.width())); + // Content from elsewhere clamps the selection, as Swift's. + if (m_shownStyle != style && !m_composing) { + m_anchor = std::min(m_anchor, int(style.content.size())); + m_position = std::min(m_position, int(style.content.size())); + // Swift sets the selection anew: a click's unit goes. + m_tracking.reset(); + m_selecting = false; + } + m_shownStyle = style; + const double padding = LayerTextStyle::padding; + m_lines = std::make_unique<TextLines>( + style, QSizeF(std::max(1.0, m_logicalSize.width() - 2 * padding), std::max(1.0, m_logicalSize.height() - 2 * padding))); + updateInputMethod(); + if (!fresh) + return; + // The text view takes the keys, sparing a text field. + QMetaObject::invokeMethod( + &m_canvas, + [canvas = QPointer<QWidget>(&m_canvas), session = &m_session, id = draft.id] { + if (!canvas || !session->textDraft() || session->textDraft().value().id != id || qobject_cast<QLineEdit *>(QApplication::focusWidget())) + return; + canvas->setFocus(Qt::OtherFocusReason); + }, + Qt::QueuedConnection); +} + +QTransform InlineTextEditor::boxTransform() const +{ + const QPointF origin = m_session.viewport.documentRect(m_session.document().value().size()).topLeft(); + const double scale = m_session.viewport.pointsPerPixel(); + const QPointF centre = m_shownTransform.center(); + const QSizeF size = m_shownTransform.size; + // Unit square to document as LayerTransform::point, then view. + const QTransform layer = QTransform::fromTranslate(centre.x(), centre.y()) + .rotate(m_shownTransform.rotation) + .translate(-size.width() / 2, -size.height() / 2) + .scale(size.width() / m_logicalSize.width(), size.height() / m_logicalSize.height()); + return layer * QTransform::fromTranslate(origin.x(), origin.y()).scale(scale, scale); +} + +QTransform InlineTextEditor::textTransform() const +{ + // Swift mirrors the text surface about its middle. + const QPointF middle(m_logicalSize.width() / 2, m_logicalSize.height() / 2); + const QTransform mirror = QTransform::fromTranslate(middle.x(), middle.y()) + .scale(m_shownTransform.flipX ? -1 : 1, m_shownTransform.flipY ? -1 : 1) + .translate(-middle.x(), -middle.y()); + return mirror * boxTransform(); +} + +void InlineTextEditor::draw(QPainter &painter) const +{ + if (!m_lines) + return; + painter.save(); + painter.setRenderHint(QPainter::Antialiasing); + const QPalette palette = m_canvas.palette(); + // The frame and handles first: Swift's text view draws above. + painter.setTransform(boxTransform()); + const QColor accent = palette.color(QPalette::Highlight); + const double width = m_logicalSize.width(), height = m_logicalSize.height(), inset = m_handleSize / 12; + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(accent, m_handleSize / 6, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin)); + painter.drawRect(QRectF(0, 0, width, height).adjusted(inset, inset, -inset, -inset)); + painter.setPen(QPen(accent, 1, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin)); + for (const QPointF &unit : LayerTransform::handles) { + const QRectF handle(unit.x() * width - m_handleSize / 2, unit.y() * height - m_handleSize / 2, m_handleSize, m_handleSize); + painter.fillRect(handle, Qt::white); + painter.drawRect(handle); + } + // Text that does not fit: a plus in a corner. + if (m_lines->overflows()) { + const QPointF centre(LayerTransform::handles[4].x() * width, LayerTransform::handles[4].y() * height); + const double arm = m_handleSize * 0.42; + painter.setPen(QPen(Qt::black, m_handleSize / 6, Qt::SolidLine, Qt::FlatCap)); + painter.drawLine(centre - QPointF(arm, 0), centre + QPointF(arm, 0)); + painter.drawLine(centre - QPointF(0, arm), centre + QPointF(0, arm)); + } + const double padding = LayerTextStyle::padding; + painter.setTransform(textTransform()); + const QColor ink = QColor::fromRgbF(float(style().red), float(style().green), float(style().blue)); + painter.setPen(ink); + const QColor highlight = palette.color(m_focused ? QPalette::Active : QPalette::Inactive, QPalette::Highlight); + // The input method's formats, else NSTextView's underline. + QList<QTextLayout::FormatRange> formats; + if (m_composing) { + for (QTextLayout::FormatRange format : m_composing->formats) { + format.start += m_composing->marked.start; + formats << format; + } + if (formats.isEmpty()) { + QTextCharFormat underline; + underline.setFontUnderline(true); + formats << QTextLayout::FormatRange{m_composing->marked.start, m_composing->marked.end - m_composing->marked.start, underline}; + } + } + m_lines->draw(painter, QPointF(padding, padding), shownSelection(), formats, highlight); + // The insertion point shows where nothing is selected. + const bool hidden = m_composing && m_composing->caretHidden; + if (const std::optional<QRectF> caret = m_lines->caret(m_position); caret && caretShown() && selection().isEmpty() && !hidden) + painter.fillRect(caret->translated(padding, padding), m_composing ? m_composing->caretColour.value_or(ink) : ink); + painter.restore(); +} + +QRectF InlineTextEditor::drawnRect() const +{ + if (!m_lines) + return QRectF(); + // Handles overhang; tight lines rise, the caret's line falls. + const double margin = m_handleSize + std::max(style().fontSize, style().lineHeight()); + const QRectF box = QRectF(QPointF(0, 0), m_logicalSize).adjusted(-margin, -margin, margin, margin); + return boxTransform().mapRect(box).adjusted(-2, -2, 2, 2); +} + +void InlineTextEditor::setFocused(bool focused) +{ + m_focused = focused; + showCaret(); +} + +void InlineTextEditor::showCaret() +{ + m_caretShown = true; + if (m_focused && m_blink.interval() > 0) + m_blink.start(); + else + m_blink.stop(); + repaint(); +} + +void InlineTextEditor::repaint() const +{ + m_canvas.update(drawnRect().toAlignedRect()); +} + +// Qt's updateMicroFocus: input methods follow the caret. +void InlineTextEditor::updateInputMethod() const +{ + if (QGuiApplication::focusObject() == &m_canvas) + QGuiApplication::inputMethod()->update(Qt::ImQueryInput); +} + +// Swift's synchronizeInlineText: the editor follows the draft. +void CanvasView::synchronizeInlineText() +{ + const std::optional<TextDraft> &draft = m_session.textDraft(); + if (!draft) { + // Its layer shows whole again, as Swift's needsDisplay. + if (m_inlineTextEditor) { + update(); + m_inlineTextEditor.reset(); + setAttribute(Qt::WA_InputMethodEnabled, false); + } + return; + } + // A new draft repaints the canvas, as Swift's needsDisplay. + if (!m_inlineTextEditor || m_inlineTextEditor->draftID() != draft->id) + update(); + const bool fresh = !m_inlineTextEditor; + if (fresh) + m_inlineTextEditor = std::make_unique<InlineTextEditor>(m_session, *this); + const QRectF before = m_inlineTextEditor->drawnRect(); + m_inlineTextEditor->synchronize(*draft); + update((before | m_inlineTextEditor->drawnRect()).toAlignedRect()); + if (!fresh) + return; + m_inlineTextEditor->setFocused(hasFocus()); + // Input methods compose into the draft, as a text view. + setAttribute(Qt::WA_InputMethodEnabled, true); +} + +void CanvasView::inputMethodEvent(QInputMethodEvent *event) +{ + if (!m_inlineTextEditor) { + QWidget::inputMethodEvent(event); + return; + } + m_inlineTextEditor->inputMethod(*event); + event->accept(); +} + +QVariant CanvasView::inputMethodQuery(Qt::InputMethodQuery query) const +{ + return m_inlineTextEditor ? m_inlineTextEditor->inputMethodQuery(query) : QWidget::inputMethodQuery(query); +} diff --git a/src/Rendering/InlineTextEditor.h b/src/Rendering/InlineTextEditor.h new file mode 100644 index 0000000..7ef1919 --- /dev/null +++ b/src/Rendering/InlineTextEditor.h @@ -0,0 +1,157 @@ +#pragma once +#include "Document/TypeTool.h" +#include "Rendering/TextLayout.h" +#include <QInputMethodEvent> +#include <QCursor> +#include <QTimer> +#include <QTransform> +#include <QVariant> +#include <memory> +#include <optional> +#include <vector> + +class EditorSession; +class QKeyEvent; +class QPainter; +class QWidget; + +// Swift's InlineTextEditor and its text view, painted by the canvas. +class InlineTextEditor { +public: + InlineTextEditor(EditorSession &session, QWidget &canvas); + // Swift's synchronize: size, placement, content and style. + void synchronize(const TextDraft &draft); + QUuid draftID() const { return m_draftID; } + // The text view's bounds, in layer pixels. + QSizeF logicalSize() const { return m_logicalSize; } + // Point text grows as typed, so this is what shows. + LayerTransform shownTransform() const { return m_shownTransform; } + double handleSize() const { return m_handleSize; } + // Layer pixels to view points, without the layer's flips. + QTransform boxTransform() const; + // The text surface's own mapping, flipped with its layer. + QTransform textTransform() const; + void draw(QPainter &painter) const; + // Where the editor draws, in view points. + QRectF drawnRect() const; + // The text view's keys; false leaves a key unused. + bool keyPress(const QKeyEvent &event); + // Keys the editor takes ahead of the menus' shortcuts. + static bool claims(const QKeyEvent &event); + void inputMethod(const QInputMethodEvent &event); + QVariant inputMethodQuery(Qt::InputMethodQuery query) const; + // The caret shows, blinking, while the canvas has focus. + void setFocused(bool focused); + // The pointer: handles resize, the text view selects. + bool press(QPointF point, Qt::KeyboardModifiers modifiers, int clicks); + // NSTextView's click, wherever it lands, as the canvas forwards it. + void pressText(QPointF point, Qt::KeyboardModifiers modifiers, int clicks); + bool drag(QPointF point); + void release(); + // A handle's cursor under the point, turned with the box. + std::optional<QCursor> cursorAt(QPointF point) const; + // NSText's actions, as the Edit menu sends them. + void undo(); + void redo(); + void cut(); + void copy() const; + void paste(); + void selectAll(); + // The text view's selection: an anchor and the caret. + int anchor() const { return m_anchor; } + int caretPosition() const { return m_position; } + std::optional<TextRange> marked() const { return m_composing ? std::optional(m_composing->marked) : std::nullopt; } + bool caretShown() const { return m_focused && m_caretShown; } + +private: + struct Snapshot { + QString content; + int anchor; + int position; + }; + // An input method's preedit, which Qt keeps out of history. + struct Composing { + TextRange marked; + // Qt keeps a selection beside the preedit, without it. + int anchor; + // Formats count from the preedit's start. + QList<QTextLayout::FormatRange> formats = {}; + bool caretHidden = false; + std::optional<QColor> caretColour = std::nullopt; + // The text before the preedit, for its landing step. + Snapshot before; + }; + // A handle's drag: the draft and box it began from. + struct Resize { + int handle; + TextDraft draft; + LayerTransform transform; + QPointF start; + }; + // A click's unit, kept for Shift while its selection stands. + struct Tracking { + TextRange origin; + int clicks; + int anchor; + int position; + }; + // Runs of these coalesce into one undo step, as NSTextView's. + enum class Edit { typing, deleting }; + const LayerTextStyle &style() const { return m_shownStyle.value(); } + static TextRange spanning(int anchor, int position); + TextRange selection() const; + void replace(TextRange range, const QString &text, std::optional<Edit> edit); + void publish(const QString &content); + void moveTo(int position, bool extend, bool keepGoal = false); + void restore(std::vector<Snapshot> &from, std::vector<Snapshot> &to); + void changeSpacing(int key, double step); + bool move(const QKeyEvent &event); + bool rightToLeft() const; + static bool separates(QChar character); + std::optional<int> handleAt(QPointF point) const; + int positionAt(QPointF point, bool onCharacter) const; + TextRange unitAt(int position, int clicks) const; + void land(); + void beginResize(int handle, QPointF point); + void resizeTo(QPointF point); + void compose(const QString &preedit, const QList<QInputMethodEvent::Attribute> &attributes, const Snapshot &before, int anchor, QString &text, + int cursor); + TextRange shownSelection() const; + Snapshot plain() const; + void change(const QString &text); + void settle(QString text, int anchor, int cursor); + bool erase(const QKeyEvent &event); + int nextCharacter(int position) const; + int previousCharacter(int position) const; + int nextWordEnd(int position) const; + int previousWordStart(int position) const; + int lineBound(bool end) const; + int verticalTarget(int direction); + void showCaret(); + void repaint() const; + void updateInputMethod() const; + + EditorSession &m_session; + QWidget &m_canvas; + QUuid m_draftID; + std::optional<LayerTextStyle> m_shownStyle; + QSizeF m_logicalSize{360, 160}; + LayerTransform m_shownTransform; + double m_handleSize = 6; + std::unique_ptr<TextLines> m_lines; + int m_anchor = 0; + int m_position = 0; + std::optional<Composing> m_composing; + // Up and Down keep the caret's first x, as NSTextView. + std::optional<double> m_goalX; + std::vector<Snapshot> m_undo; + std::vector<Snapshot> m_redo; + std::optional<Edit> m_lastEdit; + std::optional<Resize> m_resize; + std::optional<Tracking> m_tracking; + // The button that began a selection is still down. + bool m_selecting = false; + QTimer m_blink; + bool m_focused = false; + bool m_caretShown = true; +}; diff --git a/src/Rendering/LayerEffectsKernel.cpp b/src/Rendering/LayerEffectsKernel.cpp new file mode 100644 index 0000000..9fc0126 --- /dev/null +++ b/src/Rendering/LayerEffectsKernel.cpp @@ -0,0 +1,235 @@ +#include "Rendering/LayerEffectsKernel.h" +#include "Rendering/PoolMap.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include <QtConcurrent> +#include <algorithm> +#include <array> +#include <cmath> +#include <memory> + +// Metal's kernels, pass after pass, in float as the shader. +namespace { +using Buffer = std::unique_ptr<float[]>; + +// Allocated by the caller, where a failure throws. +template <typename Value> std::unique_ptr<Value[]> allocated(qint64 count) +{ + std::unique_ptr<Value[]> made(new (std::nothrow) Value[size_t(count)]); + if (!made) + throw ExportError(ExportError::Kind::render); + return made; +} + +Buffer buffer(qint64 count) +{ + return allocated<float>(count); +} + +// Bands of 32 lines side by side: Metal's thread groups. +void bands(int count, const std::function<void(int band, int line)> &line) +{ + std::vector<int> firsts; + for (int first = 0; first < count; first += 32) + firsts.push_back(first); + PoolMap::blocking(firsts, [&](int first) { + for (int index = first; index < std::min(count, first + 32); ++index) + line(first / 32, index); + }); +} + +float mixed(float from, float to, float amount) +{ + return from + (to - from) * amount; +} + +// Swift's extreme: a sliding window, each index in once. +void spreadLine(const float *input, float *output, int count, qsizetype step, int reach, bool smallest, int *queue) +{ + int head = 0, tail = 0, next = 0; + for (int center = 0; center < count; ++center) { + while (next <= std::min(count - 1, center + reach)) { + const float value = input[next * step]; + while (tail > head) { + const float previous = input[queue[tail - 1] * step]; + if (smallest ? previous < value : previous > value) + break; + --tail; + } + queue[tail++] = next++; + } + while (head < tail && queue[head] < center - reach) + ++head; + // Past the edges Metal reads nothing: the smallest is none. + const bool outside = center < reach || center + reach >= count; + output[center * step] = smallest && outside ? 0.f : input[queue[head] * step]; + } +} + +// Metal's spread, rows then columns: its loops' maxima. +void spread(const float *source, float *across, float *result, int width, int height, int reach, bool smallest) +{ + // A queue a band, as long as that pass's lines. + const qsizetype rows = qsizetype(height + 31) / 32 * width, columns = qsizetype(width + 31) / 32 * height; + const std::unique_ptr<int[]> queues = allocated<int>(std::max(rows, columns)); + bands(height, [&](int band, int y) { + spreadLine(source + qsizetype(y) * width, across + qsizetype(y) * width, width, 1, reach, smallest, queues.get() + qsizetype(band) * width); + }); + bands(width, [&](int band, int x) { spreadLine(across + x, result + x, height, width, reach, smallest, queues.get() + qsizetype(band) * height); }); +} + +void shifted(const float *source, float *result, int width, int height, float dx, float dy) +{ + bands(height, [&](int, int y) { + for (int x = 0; x < width; ++x) { + const float sx = float(x) - dx, sy = float(y) - dy; + float value = 0.f; + // Between pixels, so a shadow moves smoothly. + if (sx >= 0.f && sy >= 0.f && sx <= float(width - 1) && sy <= float(height - 1)) { + const int x0 = int(std::floor(sx)), y0 = int(std::floor(sy)); + const int x1 = std::min(x0 + 1, width - 1), y1 = std::min(y0 + 1, height - 1); + const float fx = sx - float(x0), fy = sy - float(y0); + const float top = mixed(source[qsizetype(y0) * width + x0], source[qsizetype(y0) * width + x1], fx); + const float bottom = mixed(source[qsizetype(y1) * width + x0], source[qsizetype(y1) * width + x1], fx); + value = mixed(top, bottom, fy); + } + result[qsizetype(y) * width + x] = value; + } + }); +} + +// Metal's blur, rows then columns, taps added in order. +void blurred(float *values, float *scratch, int width, int height, float sigma) +{ + if (!(sigma > 0.01f)) + return; + const int radius = std::max(1, int(std::lround(sigma * 3))); + // Valid effects blur at most 500, a radius of 750. + std::array<float, 1501> weights; + if (radius > 750) + throw std::logic_error("an effect blurs past its bounds"); + float total = 0; + for (int offset = -radius; offset <= radius; ++offset) { + weights[size_t(offset + radius)] = std::exp(-float(offset * offset) / (2.f * sigma * sigma)); + total += weights[size_t(offset + radius)]; + } + bands(height, [&](int, int y) { + const float *row = values + qsizetype(y) * width; + for (int x = 0; x < width; ++x) { + float sum = 0; + for (int offset = -radius; offset <= radius; ++offset) + sum += weights[size_t(offset + radius)] * row[std::clamp(x + offset, 0, width - 1)]; + scratch[qsizetype(y) * width + x] = sum / total; + } + }); + bands(height, [&](int, int y) { + float *out = values + qsizetype(y) * width; + std::fill(out, out + width, 0.f); + for (int offset = -radius; offset <= radius; ++offset) { + const float weight = weights[size_t(offset + radius)]; + const float *in = scratch + qsizetype(std::clamp(y + offset, 0, height - 1)) * width; + for (int x = 0; x < width; ++x) + out[x] += weight * in[x]; + } + for (int x = 0; x < width; ++x) + out[x] /= total; + }); +} + +// Source over: a colour, at a coverage, onto the rest. +void over(float (&colour)[3], float &alpha, const PaletteColor &paint, float coverage) +{ + colour[0] = float(paint.red) * coverage + colour[0] * (1.f - coverage); + colour[1] = float(paint.green) * coverage + colour[1] * (1.f - coverage); + colour[2] = float(paint.blue) * coverage + colour[2] * (1.f - coverage); + alpha = coverage + alpha * (1.f - coverage); +} + +uchar byte(float value) +{ + return uchar(std::clamp(value, 0.f, 1.f) * 255.f + 0.5f); +} +} + +QImage LayerEffectsKernel::render(const QImage &pixels, const LayerEffects &effects) +{ + const int width = pixels.width(), height = pixels.height(); + const qint64 count = qint64(width) * height; + // Past Metal's 80 million Swift draws on its CPU. + if (count <= 0 || count > 100'000'000) + throw ExportError(ExportError::Kind::tooLarge); + const QImage source = pixels.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + if (source.isNull()) + throw ExportError(ExportError::Kind::render); + const Buffer shape = buffer(count); + bands(height, [&](int, int y) { + const uchar *row = source.constScanLine(y); + for (int x = 0; x < width; ++x) + shape[qsizetype(y) * width + x] = float(row[x * 4 + 3]) / 255.f; + }); + const std::optional<StrokeEffect> stroke = effects.stroke && effects.stroke->isEnabled() && effects.stroke->size > 0 && effects.stroke->opacity > 0 + ? effects.stroke : std::nullopt; + Buffer ring; + if (stroke) { + const Buffer across = buffer(count), moved = buffer(count); + ring = buffer(count); + spread(shape.get(), across.get(), moved.get(), width, height, std::max(1, int(std::lround(stroke->size))), stroke->inside); + for (qint64 index = 0; index < count; ++index) + ring[index] = std::clamp(stroke->inside ? shape[index] - moved[index] : moved[index] - shape[index], 0.f, 1.f); + } + const std::optional<ShadowEffect> shadow = effects.shadow && effects.shadow->isEnabled() && effects.shadow->opacity > 0 ? effects.shadow : std::nullopt; + Buffer cast; + if (shadow) { + cast = buffer(count); + const QSizeF offset = shadow->offset(); + shifted(shape.get(), cast.get(), width, height, float(offset.width()), float(offset.height())); + blurred(cast.get(), buffer(count).get(), width, height, float(shadow->blur / 2)); + } + const std::optional<InnerShadowEffect> inner = effects.innerShadow && effects.innerShadow->isEnabled() && effects.innerShadow->opacity > 0 + ? effects.innerShadow : std::nullopt; + Buffer inside; + if (inner) { + inside = buffer(count); + const QSizeF offset = inner->offset(); + shifted(shape.get(), inside.get(), width, height, float(offset.width()), float(offset.height())); + blurred(inside.get(), buffer(count).get(), width, height, float(inner->blur / 2)); + // What lies outside the layer, softened, kept to its shape. + for (qint64 index = 0; index < count; ++index) + inside[index] = std::clamp(shape[index] * (1.f - inside[index]), 0.f, 1.f); + } + const std::optional<ColorOverlayEffect> overlay = effects.colorOverlay && effects.colorOverlay->isEnabled() && effects.colorOverlay->opacity > 0 + ? effects.colorOverlay : std::nullopt; + QImage result = BrushRaster::context(width, height, false); + // Taken once: scanLine() detaches, which workers must not race. + uchar *const bits = result.bits(); + const qsizetype stride = result.bytesPerLine(); + // Shadow, outside stroke, pixels, overlay, inner shadow, inside stroke. + bands(height, [&](int, int y) { + const uchar *in = source.constScanLine(y); + uchar *out = bits + y * stride; + for (int x = 0; x < width; ++x) { + const qsizetype index = qsizetype(y) * width + x; + float colour[3] = {0, 0, 0}, alpha = 0; + if (shadow) + over(colour, alpha, shadow->color(), std::clamp(cast[index] * float(shadow->opacity), 0.f, 1.f)); + const float strokeCoverage = stroke ? std::clamp(ring[index] * float(stroke->opacity), 0.f, 1.f) : 0.f; + if (stroke && !stroke->inside) + over(colour, alpha, stroke->color(), strokeCoverage); + const float sourceAlpha = float(in[x * 4 + 3]) / 255.f; + for (int channel = 0; channel < 3; ++channel) + colour[channel] = float(in[x * 4 + channel]) / 255.f + colour[channel] * (1.f - sourceAlpha); + alpha = sourceAlpha + alpha * (1.f - sourceAlpha); + if (overlay) + over(colour, alpha, overlay->color(), std::clamp(shape[index] * float(overlay->opacity), 0.f, 1.f)); + if (inner) + over(colour, alpha, inner->color(), std::clamp(inside[index] * float(inner->opacity), 0.f, 1.f)); + if (stroke && stroke->inside) + over(colour, alpha, stroke->color(), strokeCoverage); + out[x * 4] = byte(colour[0]); + out[x * 4 + 1] = byte(colour[1]); + out[x * 4 + 2] = byte(colour[2]); + out[x * 4 + 3] = byte(alpha); + } + }); + return result; +} diff --git a/src/Rendering/LayerEffectsKernel.h b/src/Rendering/LayerEffectsKernel.h new file mode 100644 index 0000000..f0d283a --- /dev/null +++ b/src/Rendering/LayerEffectsKernel.h @@ -0,0 +1,9 @@ +#pragma once +#include "Document/LayerEffects.h" +#include <QImage> + +// Swift's MetalLayerEffects: its passes on the CPU, in float. +namespace LayerEffectsKernel { +// The shown layer with room round it; same size back. +QImage render(const QImage &pixels, const LayerEffects &effects); +} diff --git a/src/Rendering/LayerEffectsSurface.cpp b/src/Rendering/LayerEffectsSurface.cpp new file mode 100644 index 0000000..aa61e97 --- /dev/null +++ b/src/Rendering/LayerEffectsSurface.cpp @@ -0,0 +1,126 @@ +#include "Rendering/LayerEffectsSurface.h" +#include "Document/LayerEffects+Renderer.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerEffectsKernel.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainter> +#include <cmath> + +namespace { +QRectF integral(const QRectF &rect) +{ + return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom()))); +} +} + +LayerEffectsSurface::LayerEffectsSurface(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect, double margin, QImage context) + : layerID(layerID), grid(grid), sourceRect(sourceRect), margin(margin), m_effects(effects), m_context(std::move(context)) +{ +} + +std::unique_ptr<LayerEffectsSurface> LayerEffectsSurface::make(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect) +{ + const double margin = LayerEffectsRenderer::margin(effects); + const qint64 width = qint64(grid.width() + margin * 2), height = qint64(grid.height() + margin * 2); + if (width * height > 80'000'000) { + qCWarning(lcRendering) << "an effects surface passes its pixel budget:" << width << "x" << height; + return nullptr; + } + try { + QImage context = BrushRaster::context(int(width), int(height), false); + return std::unique_ptr<LayerEffectsSurface>(new LayerEffectsSurface(layerID, effects, grid, sourceRect, margin, std::move(context))); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "an effects surface could not be allocated:" << error.what(); + } + return nullptr; +} + +bool LayerEffectsSurface::matches(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect) const +{ + return this->layerID == layerID && m_effects == effects && this->grid == grid && this->sourceRect == sourceRect; +} + +// How far a change reaches: all within may need redoing. +double LayerEffectsSurface::reach() const +{ + double reach = 1; + if (m_effects.stroke) + reach = std::max(reach, m_effects.stroke->size + 2); + if (m_effects.shadow) + reach = std::max(reach, m_effects.shadow->distance + m_effects.shadow->blur * 3 + 2); + // Swift forgets the inner shadow, whose blur reaches as far. + if (m_effects.innerShadow) + reach = std::max(reach, m_effects.innerShadow->distance + m_effects.innerShadow->blur * 3 + 2); + return reach; +} + +void LayerEffectsSurface::update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask) +{ + std::optional<QRectF> dirty; + std::map<std::pair<qint64, qint64>, qint64> seen; + for (const BrushPatch &patch : patches) { + const std::pair<qint64, qint64> key(qint64(patch.rect.left()), qint64(patch.rect.top())); + seen.insert_or_assign(key, patch.image.cacheKey()); + if (!m_taken.contains(key) || m_taken.at(key) != patch.image.cacheKey()) + dirty = dirty ? dirty->united(patch.rect) : patch.rect; + } + const bool first = !m_image; + const std::optional<QRectF> region = first ? std::optional(QRectF(QPointF(0, 0), grid).adjusted(-margin, -margin, margin, margin)) : dirty; + if (!region) + return; + try { + compose(*region, base, patches, mask); + // Taken once redone: a failed pass is tried again. + m_taken = seen; + m_image = m_context; + } catch (const ExportError &error) { + qCWarning(lcRendering) << "a painted layer's effects could not be redone:" << error.what(); + } +} + +// One region anew: its effects, with the pixels in them. +void LayerEffectsSurface::compose(const QRectF ®ion, const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, + const std::optional<QImage> &mask) +{ + const QRectF bounds = QRectF(QPointF(0, 0), grid).adjusted(-margin, -margin, margin, margin); + // Widened by the reach: Swift's margin misses inside effects. + const double reach = this->reach(); + const QRectF inner = integral(region.adjusted(-reach, -reach, reach, reach)).intersected(bounds); + // What reaches `inner`, cut where the export's padding ends. + const QRectF outer = integral(inner.adjusted(-reach, -reach, reach, reach)).intersected(bounds); + const QImage built = LayerEffectsKernel::render(window(outer, base, patches, mask), m_effects); + // Painted on a copy: one without memory changes nothing. + QImage next = m_context; + QPainter painter(&next); + if (!painter.isActive()) + throw ExportError(ExportError::Kind::render); + // In the surface the grid starts at the margin. + BrushRaster::draw(built, inner.translated(margin, margin), painter, inner.translated(-outer.left(), -outer.top())); + painter.end(); + m_context = next; +} + +// The layer as painted so far: pixels, tiles, mask. +QImage LayerEffectsSurface::window(const QRectF ®ion, const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, + const std::optional<QImage> &mask) const +{ + QImage window = BrushRaster::context(int(region.width()), int(region.height()), false); + QPainter painter(&window); + painter.translate(-region.left(), -region.top()); + if (base && base->raster) + base->raster->draw(sourceRect, painter); + else if (base) + BrushRaster::draw(base->image(), sourceRect, painter); + for (const BrushPatch &patch : patches) { + if (patch.rect.intersects(region)) + BrushRaster::draw(patch.image, patch.rect, painter); + } + if (mask) { + // Only the layer's own pixels show through the mask. + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(sourceRect, BrushRaster::alphaView(*mask)); + } + return window; +} diff --git a/src/Rendering/LayerEffectsSurface.h b/src/Rendering/LayerEffectsSurface.h new file mode 100644 index 0000000..36a05d7 --- /dev/null +++ b/src/Rendering/LayerEffectsSurface.h @@ -0,0 +1,43 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/LayerEffects.h" +#include <QImage> +#include <QUuid> +#include <map> +#include <memory> +#include <optional> + +// Swift's LayerEffectsSurface: effects redone only where paint lands. +class LayerEffectsSurface { +public: + // Swift's init?: none past 80 million pixels, logged. Effects shown. + static std::unique_ptr<LayerEffectsSurface> make(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect); + // Whether it still fits the stroke and settings. + bool matches(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect) const; + // Everything at first, then only where the paint changed. + void update(const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, const std::optional<QImage> &mask); + const std::optional<QImage> &image() const { return m_image; } + + const QUuid layerID; + // The stroke's grid in layer pixels, the layer's own inside. + const QSizeF grid; + const QRectF sourceRect; + // The room the effects need round the pixels. + const double margin; + // Where it was last drawn, handed on at the end. + std::optional<LayerTransform> placement; + +private: + LayerEffectsSurface(QUuid layerID, const LayerEffects &effects, QSizeF grid, QRectF sourceRect, double margin, QImage context); + double reach() const; + void compose(const QRectF ®ion, const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, + const std::optional<QImage> &mask); + QImage window(const QRectF ®ion, const std::optional<ImportedImage> &base, const std::vector<BrushPatch> &patches, + const std::optional<QImage> &mask) const; + + const LayerEffects m_effects; + QImage m_context; + // Each tile's origin and the pixels last taken from it. + std::map<std::pair<qint64, qint64>, qint64> m_taken; + std::optional<QImage> m_image; +}; diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp new file mode 100644 index 0000000..66e47b2 --- /dev/null +++ b/src/Rendering/LayerRenderer.cpp @@ -0,0 +1,318 @@ +#include "Rendering/LayerRenderer.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "Rendering/DownsampleCache.h" +#include "Rendering/HslBlend.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainterPath> +#include <cmath> +#include <stdexcept> + +namespace { +QPainter::CompositionMode compositionMode(LayerBlendMode mode) +{ + switch (mode) { + case LayerBlendMode::normal: + return QPainter::CompositionMode_SourceOver; + case LayerBlendMode::multiply: + return QPainter::CompositionMode_Multiply; + case LayerBlendMode::screen: + return QPainter::CompositionMode_Screen; + case LayerBlendMode::overlay: + return QPainter::CompositionMode_Overlay; + case LayerBlendMode::darken: + return QPainter::CompositionMode_Darken; + case LayerBlendMode::lighten: + return QPainter::CompositionMode_Lighten; + case LayerBlendMode::difference: + return QPainter::CompositionMode_Difference; + case LayerBlendMode::colorDodge: + return QPainter::CompositionMode_ColorDodge; + case LayerBlendMode::colorBurn: + return QPainter::CompositionMode_ColorBurn; + default: + throw std::logic_error("QPainter has no composition mode for this blend mode"); + } +} + +void setSampling(QPainter &painter, InterpolationQuality quality, LayerSampling sampling) +{ + painter.setRenderHint(QPainter::SmoothPixmapTransform, quality != InterpolationQuality::none); + painter.setRenderHint(QPainter::Antialiasing, sampling != LayerSampling::nearest); +} +} + +void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, QPointF center, QPainter &context, + const Options &options) +{ + const double width = transform.size.width() * options.scale, height = transform.size.height() * options.scale; + // Large reductions draw from sharp halvings; QPainter does the rest. + const double device = deviceScale(context); + const Reduced source = reduced(image, width, device, transform.sampling); + const InterpolationQuality quality = interpolation( + transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level)); + const QRectF bounds(-width / 2, -height / 2, width, height); + QTransform placement; + placement.translate(center.x(), center.y()); + placement.rotateRadians(transform.radians()); + placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1); + + if (options.mask.isNull() && options.clip.isNull() && !HslBlend::handles(options.blendMode)) { + const QPainter::CompositionMode mode = compositionMode(options.blendMode); + context.save(); + context.setOpacity(options.opacity); + context.setCompositionMode(mode); + setSampling(context, quality, transform.sampling); + context.setTransform(placement, true); + context.drawImage(coverage(source, bounds), source.image); + context.restore(); + return; + } + + composite(context, placement, coverage(source, bounds), transform.sampling, quality, options, bounds, + [&](QPainter &aside) { aside.drawImage(coverage(source, bounds), source.image); }); +} + +void LayerRenderer::drawBrushPreview(const QImage &image, const LayerTransform &transform, QPointF center, QPainter &context, + const Options &options, const BrushPreview &preview) +{ + if (preview.pixelWidth <= 0 || preview.pixelHeight <= 0) + throw std::logic_error("a brush preview needs a pixel grid"); + const double width = transform.size.width() * options.scale, height = transform.size.height() * options.scale; + const QRectF bounds(-width / 2, -height / 2, width, height); + const auto mapped = [](const QRectF &source, const QRectF &into, double columns, double rows) { + return QRectF(into.left() + source.left() / columns * into.width(), into.top() + source.top() / rows * into.height(), + source.width() / columns * into.width(), source.height() / rows * into.height()); + }; + const auto tile = [&](const BrushPatch &patch) { return mapped(patch.rect, bounds, preview.pixelWidth, preview.pixelHeight); }; + const QRectF originalBounds = preview.sourceRect ? mapped(*preview.sourceRect, bounds, preview.pixelWidth, preview.pixelHeight) : bounds; + // A raster is a source: naming one never flattens it. + const bool hasSource = !image.isNull() || preview.raster; + const auto drawSource = [&](QPainter &aside) { + if (!preview.raster) { + aside.drawImage(originalBounds, image); + return; + } + const RasterSnapshot &raster = *preview.raster; + const QImage &base = preview.rasterBase.isNull() ? raster.base : preview.rasterBase; + if (!base.isNull()) { + // A cropped raster's base shows only inside the source bounds. + aside.save(); + aside.setClipRect(originalBounds, Qt::IntersectClip); + aside.drawImage(mapped(raster.baseRect, originalBounds, raster.width, raster.height), base); + aside.restore(); + } + const QRectF visible = BrushRaster::visibleRect(aside); + for (const BrushPatch &patch : raster.patches) { + const QRectF target = mapped(patch.rect, originalBounds, raster.width, raster.height); + if (target.intersects(visible)) + aside.drawImage(target, patch.image); + } + }; + QTransform placement; + placement.translate(center.x(), center.y()); + placement.rotateRadians(transform.radians()); + placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1); + QRectF extent = bounds; + for (const BrushPatch &patch : preview.patches) + extent = extent.united(tile(patch)); + // Tiles replace what lies under them, with hard edges. + const auto body = [&](QPainter &aside) { + aside.setCompositionMode(QPainter::CompositionMode_Source); + if (hasSource) { + // The source shows inside the grid only. + aside.save(); + aside.setClipRect(bounds, Qt::IntersectClip); + drawSource(aside); + aside.restore(); + } + for (const BrushPatch &patch : preview.patches) { + if (!preview.paintingMask) + aside.drawImage(tile(patch), patch.image); + } + }; + // A tile's part outside the old raster; paths fill odd-even. + const auto outside = [&](const QRectF &rect) { + QPainterPath path; + path.addRect(rect); + if (const QRectF overlap = rect.intersected(originalBounds); !overlap.isEmpty()) + path.addRect(overlap); + return path; + }; + const auto veil = [&](QPainter &masking) { + masking.setCompositionMode(QPainter::CompositionMode_Source); + for (const BrushPatch &patch : preview.patches) { + // Painted coverage replaces the mask; paint outside is revealed. + if (preview.paintingMask) + masking.drawImage(tile(patch), BrushRaster::alphaView(patch.image)); + else + masking.fillPath(outside(tile(patch)), Qt::black); + } + }; + const bool veiled = preview.paintingMask || !options.mask.isNull(); + // Nearest keeps every edge hard; the quality is the layer's. + composite(context, placement, extent, LayerSampling::nearest, quality(transform.sampling), options, originalBounds, body, + veiled ? std::function<void(QPainter &)>(veil) : std::function<void(QPainter &)>()); +} + +void LayerRenderer::composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling, + InterpolationQuality quality, const Options &options, const QRectF &maskBounds, + const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil) +{ + // Qt has no mask clip: the layer composes aside first. + const QTransform toDevice = placement * context.deviceTransform(); + const QRect deviceBounds(0, 0, context.device()->width(), context.device()->height()); + const QRect area = toDevice.mapRect(extent).toAlignedRect() + .intersected(context.deviceTransform().mapRect(BrushRaster::visibleRect(context)).toAlignedRect()) + .intersected(deviceBounds); + if (area.isEmpty()) + return; + const QTransform toSurface = toDevice * QTransform::fromTranslate(-area.left(), -area.top()); + QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied); + if (surface.isNull()) + throw ExportError(ExportError::Kind::render); + surface.fill(0); + { + QPainter aside(&surface); + setSampling(aside, quality, sampling); + aside.setTransform(toSurface); + body(aside); + } + if (!options.mask.isNull() || veil) { + QImage shown(area.size(), QImage::Format_Alpha8); + if (shown.isNull()) + throw ExportError(ExportError::Kind::render); + // Nothing outside a mask survives; without one, all shows. + shown.fill(options.mask.isNull() ? 255 : 0); + { + QPainter masking(&shown); + setSampling(masking, quality, sampling); + masking.setTransform(toSurface); + if (!options.mask.isNull()) { + const Reduced clip = reduced(options.mask, maskBounds.width(), deviceScale(context), sampling); + masking.drawImage(coverage(clip, maskBounds), BrushRaster::alphaView(clip.image)); + } + if (veil) + veil(masking); + } + QPainter multiplying(&surface); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(surface.rect()), shown); + } + if (!options.clip.isNull()) { + if (options.clip.format() != QImage::Format_Alpha8 || options.clip.size() != deviceBounds.size()) + throw std::logic_error("a folder clip must be device-sized alpha coverage"); + QPainter clipping(&surface); + clipping.setCompositionMode(QPainter::CompositionMode_DestinationIn); + clipping.drawImage(QRectF(surface.rect()), options.clip, QRectF(area)); + } + // Everything that can throw happens before the painter changes. + const bool byHand = HslBlend::handles(options.blendMode); + QImage composed = surface; + if (byHand) { + const QImage *target = dynamic_cast<const QImage *>(context.device()); + if (!target) + throw std::logic_error("hue, saturation, color and luminosity need an image-backed painter"); + const QImage backdrop = target->copy(area).convertToFormat(QImage::Format_RGBA8888_Premultiplied); + if (backdrop.isNull()) + throw ExportError(ExportError::Kind::render); + composed = HslBlend::blend(backdrop, surface, options.blendMode, options.opacity); + } + const QPainter::CompositionMode mode = byHand ? QPainter::CompositionMode_Source : compositionMode(options.blendMode); + context.save(); + context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform()); + context.setOpacity(byHand ? 1 : options.opacity); + context.setCompositionMode(mode); + context.drawImage(QRectF(area), composed); + context.restore(); +} + +namespace { +// The mode where alpha-white equals a clip of that coverage. +QPainter::CompositionMode underCoverage(QPainter::CompositionMode mode) +{ + switch (mode) { + // These erase where the source is clear; clips do not. + case QPainter::CompositionMode_Source: + case QPainter::CompositionMode_Plus: + return QPainter::CompositionMode_SourceOver; + case QPainter::CompositionMode_Clear: + return QPainter::CompositionMode_DestinationOut; + case QPainter::CompositionMode_SourceIn: + return QPainter::CompositionMode_SourceAtop; + case QPainter::CompositionMode_SourceOut: + return QPainter::CompositionMode_Xor; + case QPainter::CompositionMode_DestinationIn: + return QPainter::CompositionMode_Destination; + case QPainter::CompositionMode_DestinationAtop: + return QPainter::CompositionMode_DestinationOver; + default: + // The rest are linear in the source, clear pixels kept. + if (mode >= QPainter::RasterOp_SourceOrDestination) + throw std::logic_error("coverage has no meaning under a raster operation"); + return mode; + } +} +} + +void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &transform, QPainter &context) +{ + if (image.format() != QImage::Format_Grayscale8) + throw std::logic_error("coverage is drawn from a gray mask"); + const QPainter::CompositionMode mode = underCoverage(context.compositionMode()); + // Opacity is coverage too only where the mode is linear. + if (mode != context.compositionMode() && context.opacity() < 1) + throw std::logic_error("this composition mode takes no opacity under coverage"); + const QRectF bounds(-transform.size.width() / 2, -transform.size.height() / 2, transform.size.width(), transform.size.height()); + // CoreGraphics filters a shrinking mask; QPainter needs the halvings. + const Reduced source = reduced(image, transform.size.width(), deviceScale(context), transform.sampling); + // White, the mask as its alpha: the painter's mode blends. + QImage white(source.image.size(), QImage::Format_ARGB32_Premultiplied); + if (white.isNull()) + throw ExportError(ExportError::Kind::render); + for (int y = 0; y < white.height(); ++y) { + const uchar *mask = source.image.constScanLine(y); + QRgb *pixels = reinterpret_cast<QRgb *>(white.scanLine(y)); + for (int x = 0; x < white.width(); ++x) + pixels[x] = mask[x] * 0x01010101u; + } + QTransform placement; + placement.translate(transform.center().x(), transform.center().y()); + placement.rotateRadians(transform.radians()); + placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1); + context.save(); + setSampling(context, quality(transform.sampling), transform.sampling); + context.setCompositionMode(mode); + context.setTransform(placement, true); + context.drawImage(coverage(source, bounds), white); + context.restore(); +} + +InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor) +{ + if (sampling == LayerSampling::nearest) + return InterpolationQuality::none; + return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling); +} + +LayerRenderer::Reduced LayerRenderer::reduced(const QImage &image, double width, double device, LayerSampling sampling) +{ + if (sampling == LayerSampling::nearest) + return {image, 0, 1, 1}; + const int level = DownsampleCache::level(width * device / std::max(1, image.width())); + const DownsampleCache::Reduced result = DownsampleCache::shared().imageAtLevel(image, level); + return {result.image, result.level, + double(result.image.width() << result.level) / std::max(1, image.width()), + double(result.image.height() << result.level) / std::max(1, image.height())}; +} + +QRectF LayerRenderer::coverage(const Reduced &reduced, const QRectF &bounds) +{ + return {bounds.left(), bounds.top(), bounds.width() * reduced.widthScale, bounds.height() * reduced.heightScale}; +} + +double LayerRenderer::deviceScale(const QPainter &context) +{ + const QTransform device = context.deviceTransform(); + return std::hypot(device.m11(), device.m12()); +} diff --git a/src/Rendering/LayerRenderer.h b/src/Rendering/LayerRenderer.h new file mode 100644 index 0000000..1bcc29e --- /dev/null +++ b/src/Rendering/LayerRenderer.h @@ -0,0 +1,64 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/LayerAppearance.h" +#include "Document/LayerTransform.h" +#include <QImage> +#include <QPainter> +#include <functional> +#include <memory> +#include <optional> +#include <vector> + +class RasterSnapshot; + +// Draws layers top-left, shared by the canvas and export. +namespace LayerRenderer { +struct Options { + double scale = 1; + double opacity = 1; + LayerBlendMode blendMode = LayerBlendMode::normal; + // A null image means no mask. + QImage mask = QImage(); + // Folder masks as device-sized alpha coverage; null means none. + QImage clip = QImage(); +}; + +// An image halved for a large reduction, and its overhang. +struct Reduced { + QImage image; + int level; + double widthScale; + double heightScale; +}; + +// Replacement tiles over a source, in a pixel grid. +struct BrushPreview { + std::vector<BrushPatch> patches; + int pixelWidth; + int pixelHeight; + // Patches are coverage the source shows through. + bool paintingMask; + // Where the source sits in the grid; absent means everywhere. + std::optional<QRectF> sourceRect = std::nullopt; + // A painted source, drawn from its tiles. + std::shared_ptr<const RasterSnapshot> raster = nullptr; + // Stands in for the raster's base when not null. + QImage rasterBase = QImage(); +}; + +void draw(const QImage &image, const LayerTransform &transform, QPointF center, QPainter &context, + const Options &options = {}); +// Fills white through a gray mask, as the painter blends. +void drawCoverage(const QImage &image, const LayerTransform &transform, QPainter &context); +// Previews tiles without a full-size raster; blends exactly once. +void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPointF center, QPainter &context, + const Options &options, const BrushPreview &preview); +// Composes a layer aside, composites it once; `veil` repaints coverage. +void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling, + InterpolationQuality quality, const Options &options, const QRectF &maskBounds, + const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {}); +InterpolationQuality interpolation(LayerSampling sampling, double finalFactor); +Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling); +QRectF coverage(const Reduced &reduced, const QRectF &bounds); +double deviceScale(const QPainter &context); +} diff --git a/src/Rendering/LiveMaskRenderer.cpp b/src/Rendering/LiveMaskRenderer.cpp new file mode 100644 index 0000000..28c50b2 --- /dev/null +++ b/src/Rendering/LiveMaskRenderer.cpp @@ -0,0 +1,238 @@ +#include "Rendering/LiveMaskRenderer.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" + +extern "C" { +#include "BrushPixels.h" +} + +LiveMaskRenderer::LiveMaskRenderer(Source source, DrawOwn drawOwn, qint64 pixelBudget) + : m_source(std::move(source)), m_drawOwn(std::move(drawOwn)), m_pixelBudget(pixelBudget) +{ +} + +void LiveMaskRenderer::prepareStacks(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent, + const std::function<LayerBlendMode(QUuid)> &blend) +{ + for (const QUuid &id : ids) + m_modes.insert(id, blend(id)); + for (size_t index = 0; index < ids.size(); ++index) { + const QUuid base = ids[index]; + if (m_source(base)) + continue; + std::vector<QUuid> children; + for (size_t next = index + 1; next < ids.size(); ++next) { + if (m_source(ids[next]) != base || parent(ids[next]) != parent(base)) + break; + children.push_back(ids[next]); + } + if (children.empty()) + continue; + m_stackModes.insert(base, blend(base)); + m_stacked.unite(QSet<QUuid>(children.begin(), children.end())); + m_stacks.insert(base, std::move(children)); + } +} + +void LiveMaskRenderer::drawComposite(QUuid id, QPainter &context, const QImage &clip) +{ + if (m_stacked.contains(id)) + return; + // Clipped, an adjustment draws inside its base's stack alone. + if (adjustment(id)) { + if (!m_source(id)) + adjust(id, context, clip); + return; + } + const auto children = m_stacks.constFind(id); + const QSize device(context.device()->width(), context.device()->height()); + QImage group, alpha; + if (children != m_stacks.constEnd() && fits(context)) { + group = QImage(device, QImage::Format_RGBA8888_Premultiplied); + alpha = QImage(device, QImage::Format_Grayscale8); + if (group.isNull() || alpha.isNull()) + qCWarning(lcRendering) << "a clipping stack could not be allocated:" << device; + } + if (group.isNull() || alpha.isNull()) { + // Without a group each child draws alone, through the base. + if (children != m_stacks.constEnd()) { + for (const QUuid &child : *children) + m_stacked.remove(child); + } + draw(id, context, clip); + return; + } + group.fill(0); + { + QPainter painter(&group); + painter.setTransform(context.deviceTransform()); + m_drawOwn(id, painter, QImage()); + } + // Children blend at the base's full coverage; its alpha returns. + layer_extract_alpha(group.constBits(), size_t(group.bytesPerLine()), alpha.bits(), size_t(alpha.bytesPerLine()), + size_t(device.width()), size_t(device.height())); + layer_unpremultiply_opaque(group.bits(), size_t(group.bytesPerLine()), size_t(device.width()), size_t(device.height())); + { + QPainter painter(&group); + painter.setTransform(context.deviceTransform()); + for (const QUuid &child : *children) { + if (adjustment(child)) + adjust(child, painter, QImage()); + else + m_drawOwn(child, painter, QImage()); + } + } + layer_restore_alpha(group.bits(), size_t(group.bytesPerLine()), alpha.constBits(), size_t(alpha.bytesPerLine()), + size_t(device.width()), size_t(device.height())); + // Device pixels already: one to one, at the painter's opacity. + LayerRenderer::composite(context, context.deviceTransform().inverted(), QRectF(group.rect()), LayerSampling::nearest, + InterpolationQuality::none, {.opacity = context.opacity(), .blendMode = m_stackModes.value(id), .clip = clip}, QRectF(), + [&](QPainter &aside) { aside.drawImage(QRectF(group.rect()), group); }); +} + +void LiveMaskRenderer::draw(QUuid id, QPainter &context, const QImage &clip) +{ + // drawOwn's painter changes end here, even when it throws. + struct Saved { + QPainter &painter; + explicit Saved(QPainter &context) : painter(context) { painter.save(); } + ~Saved() { painter.restore(); } + } saved(context); + const std::optional<QUuid> sourceID = m_source(id); + if (!sourceID) { + m_drawOwn(id, context, clip); + return; + } + const std::optional<QImage> shown = coverage(*sourceID, context); + if (!shown) + return; + QImage combined = *shown; + if (!clip.isNull()) { + // A failed copy of the cached coverage must not pass. + QPainter multiplying(&combined); + if (!multiplying.isActive()) + throw ExportError(ExportError::Kind::render); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(combined.rect()), clip); + } + m_drawOwn(id, context, combined); +} + +bool LiveMaskRenderer::fits(const QPainter &context) const +{ + const qint64 width = context.device()->width(), height = context.device()->height(); + return width > 0 && height > 0 && width * height <= m_pixelBudget; +} + +std::optional<QImage> LiveMaskRenderer::coverage(QUuid id, const QPainter &context) +{ + if (const auto found = m_cache.constFind(id); found != m_cache.constEnd()) + return *found; + if (!fits(context)) + qCWarning(lcRendering) << "clip coverage passes its pixel budget:" << context.device()->width() << "x" << context.device()->height(); + if (m_visiting.contains(id) || m_visiting.size() >= 256 || !fits(context)) + return std::nullopt; + struct Visit { + QSet<QUuid> &visiting; + QUuid id; + ~Visit() { visiting.remove(id); } + } visit{m_visiting, id}; + m_visiting.insert(id); + const QSize device(context.device()->width(), context.device()->height()); + QImage pixels(device, QImage::Format_RGBA8888_Premultiplied), gray(device, QImage::Format_Alpha8); + if (pixels.isNull() || gray.isNull()) { + qCWarning(lcRendering) << "clip coverage could not be allocated:" << device; + return std::nullopt; + } + pixels.fill(0); + { + QPainter painter(&pixels); + painter.setTransform(context.deviceTransform()); + draw(id, painter); + } + layer_extract_alpha(pixels.constBits(), size_t(pixels.bytesPerLine()), gray.bits(), size_t(gray.bytesPerLine()), + size_t(device.width()), size_t(device.height())); + m_cache.insert(id, gray); + return gray; +} + +namespace { +// Colours blend at full coverage; the original alpha then returns. +QImage blended(const QImage &original, const QImage &adjusted, LayerBlendMode mode) +{ + const int width = original.width(), height = original.height(); + QImage base = original.copy(), top = adjusted.copy(), alpha(original.size(), QImage::Format_Grayscale8); + if (base.isNull() || top.isNull() || alpha.isNull()) + throw ExportError(ExportError::Kind::render); + layer_extract_alpha(base.constBits(), size_t(base.bytesPerLine()), alpha.bits(), size_t(alpha.bytesPerLine()), size_t(width), size_t(height)); + layer_unpremultiply_opaque(base.bits(), size_t(base.bytesPerLine()), size_t(width), size_t(height)); + layer_unpremultiply_opaque(top.bits(), size_t(top.bytesPerLine()), size_t(width), size_t(height)); + { + QPainter painter(&base); + LayerRenderer::composite(painter, QTransform(), QRectF(base.rect()), LayerSampling::nearest, InterpolationQuality::none, + {.opacity = 1, .blendMode = mode}, QRectF(), [&](QPainter &aside) { aside.drawImage(QRectF(top.rect()), top); }); + } + layer_restore_alpha(base.bits(), size_t(base.bytesPerLine()), alpha.constBits(), size_t(alpha.bytesPerLine()), size_t(width), size_t(height)); + return base; +} + +// Adjusted over original by opacity, coverage and clip. +QImage mixed(const QImage &adjusted, const QImage &original, const QImage &coverage, const QImage &clip, double opacity) +{ + QImage result = original.copy(); + if (result.isNull()) + throw ExportError(ExportError::Kind::render); + for (int y = 0; y < result.height(); ++y) { + const uchar *top = adjusted.constScanLine(y), *weight = coverage.constScanLine(y); + const uchar *outer = clip.isNull() ? nullptr : clip.constScanLine(y); + uchar *target = result.scanLine(y); + for (int x = 0; x < result.width(); ++x) { + const double share = opacity * weight[x] / 255 * (outer ? outer[x] / 255.0 : 1); + for (int channel = 0; channel < 4; ++channel) { + const int index = x * 4 + channel; + target[index] = uchar(std::lround(target[index] + (top[index] - target[index]) * share)); + } + } + } + return result; +} +} + +void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip) +{ + auto *device = dynamic_cast<QImage *>(context.device()); + if (!device) + throw std::logic_error("an adjustment reads a painter on an image"); + if (!fits(context)) { + qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size(); + return; + } + // The document the device shows, where Grain's pattern sits. + const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect())); + try { + // The kernels' byte order, one image pixel a device pixel. + QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy(); + if (original.isNull()) + throw ExportError(ExportError::Kind::render); + original.setDevicePixelRatio(1); + QImage adjusted = adjustment(id).value().apply(original, region); + const LayerBlendMode mode = m_modes.value(id, LayerBlendMode::normal); + if (mode != LayerBlendMode::normal) + adjusted = blended(original, adjusted, mode); + QImage coverage(device->size(), QImage::Format_Alpha8); + if (coverage.isNull()) + throw ExportError(ExportError::Kind::render); + coverage.fill(255); + adjustmentClip(id, context, coverage); + const QImage result = mixed(adjusted, original, coverage, clip, adjustmentOpacity(id)); + // Back in device pixels, through the painter's own clip. + context.save(); + context.setTransform(context.deviceTransform().inverted(), true); + context.setCompositionMode(QPainter::CompositionMode_Source); + context.drawImage(QRectF(result.rect()), result); + context.restore(); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "an adjustment could not be drawn:" << error.what(); + } +} diff --git a/src/Rendering/LiveMaskRenderer.h b/src/Rendering/LiveMaskRenderer.h new file mode 100644 index 0000000..1a2289b --- /dev/null +++ b/src/Rendering/LiveMaskRenderer.h @@ -0,0 +1,49 @@ +#pragma once +#include "Document/LayerAdjustment.h" +#include "Document/LayerAppearance.h" +#include <QHash> +#include <QImage> +#include <QPainter> +#include <QSet> +#include <QUuid> +#include <functional> +#include <optional> +#include <vector> + +// Per-render dependency cache: a layer shows through its source's alpha. +class LiveMaskRenderer { +public: + static constexpr qint64 pixelBudget = 100'000'000; + using Source = std::function<std::optional<QUuid>(QUuid)>; + // Draws a layer through `clip`: device-sized coverage, null for none. + using DrawOwn = std::function<void(QUuid, QPainter &, const QImage &clip)>; + + LiveMaskRenderer(Source source, DrawOwn drawOwn, qint64 pixelBudget = LiveMaskRenderer::pixelBudget); + // Clipping stacks share the base's alpha instead of repainting it. + void prepareStacks(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent, + const std::function<LayerBlendMode(QUuid)> &blend); + void drawComposite(QUuid id, QPainter &context, const QImage &clip = QImage()); + void draw(QUuid id, QPainter &context, const QImage &clip = QImage()); + // An adjustment layer's settings, opacity and own mask as coverage. + std::function<std::optional<LayerAdjustment>(QUuid)> adjustment = [](QUuid) { return std::optional<LayerAdjustment>(); }; + std::function<double(QUuid)> adjustmentOpacity = [](QUuid) { return 1.0; }; + std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {}; + +private: + // The pixels beneath, adjusted and mixed back through coverage. + void adjust(QUuid id, QPainter &context, const QImage &clip); + // A layer's alpha through its own source; nil on failure. + std::optional<QImage> coverage(QUuid id, const QPainter &context); + bool fits(const QPainter &context) const; + + const Source m_source; + const DrawOwn m_drawOwn; + const qint64 m_pixelBudget; + QHash<QUuid, QImage> m_cache; + QSet<QUuid> m_visiting; + QHash<QUuid, std::vector<QUuid>> m_stacks; + QSet<QUuid> m_stacked; + QHash<QUuid, LayerBlendMode> m_stackModes; + // Every prepared layer's mode, as Swift's `blendMode`. + QHash<QUuid, LayerBlendMode> m_modes; +}; diff --git a/src/Rendering/PoolMap.h b/src/Rendering/PoolMap.h new file mode 100644 index 0000000..1c2f390 --- /dev/null +++ b/src/Rendering/PoolMap.h @@ -0,0 +1,26 @@ +#pragma once +#include <QThread> +#include <QThreadPool> +#include <QtConcurrent> +#include <utility> + +// QtConcurrent's blockingMap, which deadlocks a full pool from inside. +namespace PoolMap { +// A pool thread waiting on the pool lends its place. +template <typename Sequence, typename Function> +void blocking(Sequence &sequence, Function &&function) +{ + struct Lent { + QThreadPool *const pool; + const bool lent; + ~Lent() + { + if (lent) + pool->reserveThread(); + } + } guard{QThreadPool::globalInstance(), QThreadPool::globalInstance()->contains(QThread::currentThread())}; + if (guard.lent) + guard.pool->releaseThread(); + QtConcurrent::blockingMap(sequence, std::forward<Function>(function)); +} +} diff --git a/src/Rendering/RasterSnapshot.cpp b/src/Rendering/RasterSnapshot.cpp new file mode 100644 index 0000000..c1fd521 --- /dev/null +++ b/src/Rendering/RasterSnapshot.cpp @@ -0,0 +1,177 @@ +#include "Rendering/RasterSnapshot.h" +#include <algorithm> +#include <cmath> +#include <map> +#include <set> + +namespace { +using Cell = std::pair<int, int>; + +std::vector<Cell> cells(const QRectF &rect) +{ + std::vector<Cell> result; + if (rect.isEmpty()) + return result; + for (int y = int(std::floor(rect.top() / 256)); y <= int(std::ceil(rect.bottom() / 256)) - 1; ++y) { + for (int x = int(std::floor(rect.left() / 256)); x <= int(std::ceil(rect.right() / 256)) - 1; ++x) + result.push_back({x, y}); + } + return result; +} + +// Borrowed pixels, as CGImage.cropping; valid while image lives. +QImage view(const QImage &image, const QRect &rect) +{ + return QImage(image.constScanLine(rect.top()) + rect.left() * image.depth() / 8, rect.width(), rect.height(), + image.bytesPerLine(), image.format()); +} + +BrushPatch cropped(const BrushPatch &patch, const QRectF &rect) +{ + return {rect, patch.image.copy(rect.translated(-patch.rect.topLeft()).toAlignedRect())}; +} + +std::vector<BrushPatch> without(const BrushPatch &piece, const QRectF &overlap) +{ + const QRectF r = piece.rect; + const QRectF rects[] = {QRectF(r.left(), r.top(), r.width(), overlap.top() - r.top()), + QRectF(r.left(), overlap.bottom(), r.width(), r.bottom() - overlap.bottom()), + QRectF(r.left(), overlap.top(), overlap.left() - r.left(), overlap.height()), + QRectF(overlap.right(), overlap.top(), r.right() - overlap.right(), overlap.height())}; + std::vector<BrushPatch> result; + for (const QRectF &rect : rects) { + if (rect.width() > 0 && rect.height() > 0) + result.push_back(cropped(piece, rect)); + } + return result; +} +} + +RasterSnapshot::RasterSnapshot(int width, int height, QImage base, QRectF baseRect, std::vector<BrushPatch> patches, + bool isMask, std::optional<QPointF> alignment) + : width(width), height(height), base(std::move(base)), baseRect(baseRect), patches(std::move(patches)), + isMask(isMask), alignment(alignment.value_or(baseRect.topLeft())) +{ +} + +std::shared_ptr<const RasterSnapshot> RasterSnapshot::replacing(const std::optional<ImportedImage> &source, + const QRectF &sourceRect, + const std::vector<BrushPatch> &added, + const QRectF &crop, bool isMask) +{ + const std::shared_ptr<const RasterSnapshot> old = source ? source->raster : nullptr; + const QPointF shift = sourceRect.topLeft() - crop.topLeft(); + std::vector<BrushPatch> additions; + for (const BrushPatch &patch : added) + additions.push_back({patch.rect.translated(-crop.topLeft()), patch.image}); + // Indexed by tile, so the handoff follows touched tiles. + std::map<Cell, std::vector<int>> buckets; + for (int index = 0; index < int(additions.size()); ++index) { + for (const Cell &cell : cells(additions[index].rect)) + buckets[cell].push_back(index); + } + std::vector<BrushPatch> kept; + for (const BrushPatch &oldPatch : old ? old->patches : std::vector<BrushPatch>()) { + const BrushPatch patch{oldPatch.rect.translated(shift), oldPatch.image}; + std::set<int> candidates; + for (const Cell &cell : cells(patch.rect)) { + if (const auto found = buckets.find(cell); found != buckets.end()) + candidates.insert(found->second.begin(), found->second.end()); + } + std::vector<BrushPatch> pieces{patch}; + for (int index : candidates) { + std::vector<BrushPatch> split; + for (const BrushPatch &piece : pieces) { + const QRectF overlap = piece.rect.intersected(additions[index].rect); + if (overlap.isEmpty()) { + split.push_back(piece); + continue; + } + const std::vector<BrushPatch> rest = without(piece, overlap); + split.insert(split.end(), rest.begin(), rest.end()); + } + pieces = std::move(split); + } + kept.insert(kept.end(), pieces.begin(), pieces.end()); + } + kept.insert(kept.end(), additions.begin(), additions.end()); + const QRectF bounds(0, 0, crop.width(), crop.height()); + std::vector<BrushPatch> inside; + for (const BrushPatch &patch : kept) { + const QRectF rect = patch.rect.intersected(bounds); + if (rect.isEmpty()) + continue; + inside.push_back(rect == patch.rect ? patch : cropped(patch, rect)); + } + const QImage base = old ? old->base : source ? source->image() : QImage(); + const QRectF baseRect = old ? old->baseRect.translated(shift) : sourceRect.translated(-crop.topLeft()); + const QPointF alignment = sourceRect.topLeft() + (old ? old->alignment : QPointF(0, 0)) - crop.topLeft(); + return std::make_shared<const RasterSnapshot>(int(crop.width()), int(crop.height()), base, baseRect, + std::move(inside), isMask, alignment); +} + +void RasterSnapshot::draw(const QRectF &rect, QPainter &context) const +{ + context.save(); + context.setClipRect(rect, Qt::IntersectClip); + context.setRenderHint(QPainter::Antialiasing, false); + // A grown mask reveals the pixels outside it. + if (isMask) + context.fillRect(rect, Qt::white); + const double sx = rect.width() / width, sy = rect.height() / height; + const auto mapped = [&](const QRectF &r) { + return QRectF(rect.left() + r.left() * sx, rect.top() + r.top() * sy, r.width() * sx, r.height() * sy); + }; + const QRectF visible = BrushRaster::visibleRect(context); + if (!base.isNull()) { + const QRectF target = mapped(baseRect); + const QRectF overlap = target.intersected(visible); + if (!overlap.isEmpty()) { + const QRect crop = QRectF((overlap.left() - target.left()) / target.width() * base.width(), + (overlap.top() - target.top()) / target.height() * base.height(), + overlap.width() / target.width() * base.width(), + overlap.height() / target.height() * base.height()) + .toAlignedRect().intersected(base.rect()); + if (!crop.isEmpty()) { + const QRectF destination(target.left() + double(crop.left()) / base.width() * target.width(), + target.top() + double(crop.top()) / base.height() * target.height(), + double(crop.width()) / base.width() * target.width(), + double(crop.height()) / base.height() * target.height()); + BrushRaster::draw(view(base, crop), destination, context); + } + } + } + for (const BrushPatch &patch : patches) { + if (mapped(patch.rect).intersects(visible)) + BrushRaster::draw(patch.image, mapped(patch.rect), context); + } + context.restore(); +} + +QImage RasterSnapshot::makeImage() const +{ + const std::lock_guard<std::mutex> guard(m_lock); + if (m_materialized.isNull()) { + QImage surface = BrushRaster::context(width, height, isMask); + QPainter painter(&surface); + draw(QRectF(0, 0, width, height), painter); + painter.end(); + m_materialized = surface; + } + return m_materialized; +} + +bool RasterSnapshot::hasMaterializedPixels() const +{ + const std::lock_guard<std::mutex> guard(m_lock); + return !m_materialized.isNull(); +} + +QImage RasterSnapshot::thumbnail() const +{ + const double factor = std::min(1.0, 96.0 / std::max(width, height)); + QImage surface = BrushRaster::context(std::max(1, int(width * factor)), std::max(1, int(height * factor)), isMask); + QPainter painter(&surface); + draw(QRectF(0, 0, surface.width(), surface.height()), painter); + return surface; +} diff --git a/src/Rendering/RasterSnapshot.h b/src/Rendering/RasterSnapshot.h new file mode 100644 index 0000000..1a1cf1c --- /dev/null +++ b/src/Rendering/RasterSnapshot.h @@ -0,0 +1,37 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "IO/ImageImporter.h" +#include <memory> +#include <mutex> +#include <optional> +#include <vector> + +// Immutable sparse raster; paint commits share untouched tiles. +class RasterSnapshot { +public: + const int width; + const int height; + const QImage base; + const QRectF baseRect; + const std::vector<BrushPatch> patches; + const bool isMask; + // Origin of the halving grids, kept across commits. + const QPointF alignment; + + RasterSnapshot(int width, int height, QImage base, QRectF baseRect, std::vector<BrushPatch> patches, + bool isMask = false, std::optional<QPointF> alignment = std::nullopt); + + static std::shared_ptr<const RasterSnapshot> replacing(const std::optional<ImportedImage> &source, + const QRectF &sourceRect, + const std::vector<BrushPatch> &patches, + const QRectF &crop, bool isMask = false); + + void draw(const QRectF &rect, QPainter &context) const; + QImage makeImage() const; + bool hasMaterializedPixels() const; + QImage thumbnail() const; + +private: + mutable std::mutex m_lock; + mutable QImage m_materialized; +}; diff --git a/src/Rendering/SampleRingOverlay.cpp b/src/Rendering/SampleRingOverlay.cpp new file mode 100644 index 0000000..2d228ab --- /dev/null +++ b/src/Rendering/SampleRingOverlay.cpp @@ -0,0 +1,33 @@ +#include "Rendering/SampleRingOverlay.h" + +QRegion SampleRingOverlay::update(std::optional<QPointF> point, const PaletteColor &original, const PaletteColor &sampled) +{ + QRegion dirty; + if (m_frame) + dirty += m_frame->toAlignedRect(); + m_frame = point ? std::optional(QRectF(point->x() - 58, point->y() - 58, 116, 116)) : std::nullopt; + m_original = original; + m_sampled = sampled; + if (m_frame) + dirty += m_frame->toAlignedRect(); + return dirty; +} + +void SampleRingOverlay::draw(QPainter &painter) const +{ + if (!m_frame) + return; + painter.save(); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setBrush(Qt::NoBrush); + const QRectF ring = m_frame->adjusted(15, 15, -15, -15); + painter.setPen(QPen(QColor::fromRgbF(0.45f, 0.45f, 0.45f), 24)); + painter.drawEllipse(ring); + // The sampled colour on top, the original below; restore unclips. + for (const auto &[colour, top] : {std::pair(m_sampled, m_frame->top()), std::pair(m_original, m_frame->center().y())}) { + painter.setClipRect(QRectF(m_frame->left(), top, m_frame->width(), m_frame->height() / 2)); + painter.setPen(QPen(colour.color(), 16)); + painter.drawEllipse(ring); + } + painter.restore(); +} diff --git a/src/Rendering/SampleRingOverlay.h b/src/Rendering/SampleRingOverlay.h new file mode 100644 index 0000000..c25ae2a --- /dev/null +++ b/src/Rendering/SampleRingOverlay.h @@ -0,0 +1,21 @@ +#pragma once +#include "Document/ColorPalette.h" +#include <QPainter> +#include <QRegion> +#include <optional> + +// Swift's sample ring: the sampled colour over the original. +class SampleRingOverlay { +public: + // Rings a view point or hides; returns the areas touched. + QRegion update(std::optional<QPointF> point, const PaletteColor &original, const PaletteColor &sampled); + void draw(QPainter &painter) const; + std::optional<QRectF> frame() const { return m_frame; } + PaletteColor original() const { return m_original; } + PaletteColor sampled() const { return m_sampled; } + +private: + std::optional<QRectF> m_frame; + PaletteColor m_original = PaletteColor::black(); + PaletteColor m_sampled = PaletteColor::black(); +}; diff --git a/src/Rendering/SelectionIcons.cpp b/src/Rendering/SelectionIcons.cpp new file mode 100644 index 0000000..bb7f162 --- /dev/null +++ b/src/Rendering/SelectionIcons.cpp @@ -0,0 +1,42 @@ +#include "Rendering/SelectionIcons.h" +#include <QPainterPath> + +namespace { +void dashed(QPainter &painter) +{ + QPen pen = painter.pen(); + pen.setDashPattern({2.2, 1.8}); + pen.setCapStyle(Qt::FlatCap); + painter.setPen(pen); +} +} + +void SelectionIcons::paint(QPainter &painter, SelectionIcon icon) +{ + switch (icon) { + case SelectionIcon::rectangleMarquee: + dashed(painter); + painter.drawRoundedRect(QRectF(2.5, 3.5, 13, 11), 2, 2); + return; + // Swift's circle.dashed: the Marquee in Ellipse mode. + case SelectionIcon::ellipseMarquee: + dashed(painter); + painter.drawEllipse(QRectF(2.5, 2.5, 13, 13)); + return; + case SelectionIcon::freehandLasso: { + painter.drawEllipse(QRectF(2, 2.5, 14, 8.5)); + QPainterPath tail(QPointF(5.2, 10.2)); + tail.cubicTo(QPointF(3.2, 12), QPointF(4.6, 14.2), QPointF(6.6, 13.2)); + tail.cubicTo(QPointF(8, 12.4), QPointF(6.4, 10.8), QPointF(5.2, 12.2)); + tail.cubicTo(QPointF(4.4, 13.4), QPointF(5, 15.6), QPointF(7.4, 16)); + painter.drawPath(tail); + return; + } + // Swift's PolygonalLassoToolIcon: loop, knot and rope as segments. + case SelectionIcon::polygonalLasso: + painter.drawPolygon(QPolygonF{QPointF(1.2, 7.0), QPointF(4.0, 2.4), QPointF(11.8, 1.8), QPointF(16.8, 5.2), QPointF(15.6, 10.4), QPointF(7.0, 11.6)}); + painter.drawPolygon(QPolygonF{QPointF(8.9, 10.9), QPointF(13.3, 10.5), QPointF(11.6, 14.5)}); + painter.drawLine(QPointF(11.6, 14.5), QPointF(12.9, 17.3)); + return; + } +} diff --git a/src/Rendering/SelectionIcons.h b/src/Rendering/SelectionIcons.h new file mode 100644 index 0000000..940d09f --- /dev/null +++ b/src/Rendering/SelectionIcons.h @@ -0,0 +1,11 @@ +#pragma once +#include <QPainter> + +// Which selection tool an icon names, by its kind. +enum class SelectionIcon { freehandLasso, polygonalLasso, rectangleMarquee, ellipseMarquee }; + +// Icons the rail and the cursors share, 18-unit grid. +namespace SelectionIcons { +// Draws with the painter's pen; the Marquee's take a dash. +void paint(QPainter &painter, SelectionIcon icon); +} diff --git a/src/Rendering/TextLayout.cpp b/src/Rendering/TextLayout.cpp new file mode 100644 index 0000000..6d5dd24 --- /dev/null +++ b/src/Rendering/TextLayout.cpp @@ -0,0 +1,250 @@ +#include "Rendering/TextLayout.h" +#include "Logging.h" +#include <QPainter> +#include <QRegularExpression> +#include <algorithm> +#include <cmath> +#include <fontconfig/fontconfig.h> +#include <map> +#include <stdexcept> + +namespace { +QString text(FcPattern *pattern, const char *object) +{ + FcChar8 *value = nullptr; + if (FcPatternGetString(pattern, object, 0, &value) != FcResultMatch) + return QString(); + return QString::fromUtf8(reinterpret_cast<const char *>(value)); +} + +// Each installed face by PostScript name: its family and style. +const std::map<QString, std::pair<QString, QString>> &faces() +{ + static const std::map<QString, std::pair<QString, QString>> listed = [] { + std::map<QString, std::pair<QString, QString>> found; + FcPattern *pattern = FcPatternCreate(); + FcObjectSet *objects = FcObjectSetBuild(FC_POSTSCRIPT_NAME, FC_FAMILY, FC_STYLE, nullptr); + FcFontSet *set = FcFontList(nullptr, pattern, objects); + for (int index = 0; set && index < set->nfont; ++index) { + const QString name = text(set->fonts[index], FC_POSTSCRIPT_NAME); + if (!name.isEmpty()) + found.emplace(name, std::pair(text(set->fonts[index], FC_FAMILY), text(set->fonts[index], FC_STYLE))); + } + if (set) + FcFontSetDestroy(set); + FcObjectSetDestroy(objects); + FcPatternDestroy(pattern); + qCInfo(lcRendering) << "fontconfig lists" << int(found.size()) << "faces"; + return found; + }(); + return listed; +} +} + +QFont TextLayout::font(const LayerTextStyle &style) +{ + QFont font; + if (faces().contains(style.fontName)) { + font.setFamily(faces().at(style.fontName).first); + font.setStyleName(faces().at(style.fontName).second); + } else { + // Not installed: Qt's match, where macOS takes its own. + font.setFamily(style.fontName); + } + // A point is a pixel here; Qt's faces come whole. + font.setPixelSize(qRound(style.fontSize)); + // Swift's kern: no pairs; spacing after every letter. + font.setKerning(false); + font.setLetterSpacing(QFont::AbsoluteSpacing, style.tracking); + font.setHintingPreference(QFont::PreferNoHinting); + return font; +} + +QStringList TextLayout::availableFonts() +{ + QStringList names; + for (const auto &[name, face] : faces()) + names << name; + return names; +} + +TextLines::TextLines(const LayerTextStyle &style, QSizeF container) + : m_lineHeight(style.lineHeight()), m_width(container.width()), m_laidEnd(int(style.content.size())) +{ + const QFont font = TextLayout::font(style); + QTextOption option(style.alignment == TextAlignment::left ? Qt::AlignLeft : style.alignment == TextAlignment::center ? Qt::AlignHCenter : Qt::AlignRight); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + // NSParagraphStyle's twelve stops, 28 points apart, and on. + option.setTabStopDistance(28); + // Cocoa's paragraph separators: LF, CR, CRLF and U+2029. + static const QRegularExpression separator(QStringLiteral("\\r\\n|[\\r\\n\\x{2029}]")); + std::vector<TextRange> paragraphs; + int from = 0; + for (QRegularExpressionMatchIterator match = separator.globalMatch(style.content); match.hasNext();) { + const QRegularExpressionMatch next = match.next(); + paragraphs.push_back({from, int(next.capturedStart())}); + from = int(next.capturedEnd()); + } + paragraphs.push_back({from, int(style.content.size())}); + // TextKit's extra line after a final break holds the caret. + const QChar last = style.content.isEmpty() ? QChar() : style.content.back(); + const bool caretLine = last == u'\n' || last == u'\r' || last == QChar::LineSeparator || last == QChar::ParagraphSeparator; + for (const TextRange &range : paragraphs) { + const QString paragraph = style.content.mid(range.start, range.end - range.start); + auto layout = std::make_unique<QTextLayout>(paragraph, font); + layout->setTextOption(option); + layout->beginLayout(); + const auto laid = [&] { + const int count = layout->lineCount(); + return count == 0 ? 0 : layout->lineAt(count - 1).textStart() + layout->lineAt(count - 1).textLength(); + }; + // Empty paragraphs take a line, as do final U+2028s. + const auto pending = [&] { + return layout->lineCount() == 0 || laid() < paragraph.length() + || (layout->lineAt(layout->lineCount() - 1).textLength() > 0 && paragraph.endsWith(QChar::LineSeparator)); + }; + while (pending()) { + // Past the bottom no line is laid, but the caret's. + const bool caret = caretLine && &range == ¶graphs.back() && laid() == paragraph.length(); + if ((lineCount() + 1) * m_lineHeight > container.height() && !caret) { + // TextKit's overflow: some character left unlaid. + m_laidEnd = range.start + laid(); + m_overflows = m_laidEnd < style.content.size(); + break; + } + QTextLine line = layout->createLine(); + if (!line.isValid()) + throw std::logic_error("Qt laid out no line for pending text"); + line.setLineWidth(container.width()); + // The fixed height's extra space sits above the text. + line.setPosition(QPointF(0, (lineCount() + 1) * m_lineHeight - line.descent() - line.ascent())); + m_lineList.push_back({m_paragraphs.size(), line.lineNumber()}); + } + layout->endLayout(); + m_paragraphs.push_back(std::move(layout)); + m_starts.push_back(range.start); + if (m_overflows) + break; + } + m_caretLine = caretLine && !m_overflows; +} + +QSizeF TextLines::usedSize() const +{ + double width = 0; + for (const std::unique_ptr<QTextLayout> ¶graph : m_paragraphs) { + for (int index = 0; index < paragraph->lineCount(); ++index) + width = std::max(width, paragraph->lineAt(index).naturalTextWidth()); + } + // Measures leave the caret's line out, as TextKit's do. + return QSizeF(width, (lineCount() - (m_caretLine ? 1 : 0)) * m_lineHeight); +} + +void TextLines::draw(QPainter &painter, QPointF origin, TextRange selection, const QList<QTextLayout::FormatRange> &formats, const QColor &highlight) const +{ + for (int line = 0; line < lineCount() && !selection.isEmpty(); ++line) + drawSelection(painter, origin, line, selection, highlight); + for (size_t index = 0; index < m_paragraphs.size(); ++index) { + const int offset = m_starts[index], length = int(m_paragraphs[index]->text().size()); + QList<QTextLayout::FormatRange> local; + for (const QTextLayout::FormatRange &format : formats) { + const int start = std::max(format.start - offset, 0), end = std::min(format.start + format.length - offset, length); + if (start < end) + local << QTextLayout::FormatRange{start, end - start, format.format}; + } + m_paragraphs[index]->draw(&painter, origin, local); + } +} + +// TextKit's highlight: visual runs, the whole line, to its edge. +void TextLines::drawSelection(QPainter &painter, QPointF origin, int line, TextRange selection, const QColor &highlight) const +{ + const Line &laid = m_lineList[size_t(line)]; + const QTextLayout ¶graph = *m_paragraphs[laid.paragraph]; + const QTextLine text = paragraph.lineAt(laid.index); + const int offset = m_starts[laid.paragraph], first = offset + text.textStart(), last = first + text.textLength(); + // The paragraph's separator ends its last line. + const bool separated = laid.index == paragraph.lineCount() - 1; + const bool before = selection.start < first && selection.end > first; + const bool after = selection.end > last && (separated ? selection.start <= last : selection.start < last); + const int start = std::max(selection.start, first), end = std::min(selection.end, last); + const QRectF band(origin.x(), origin.y() + line * m_lineHeight, m_width, m_lineHeight); + painter.save(); + painter.setClipRect(band, Qt::IntersectClip); + if (start < end) { + QTextCharFormat format; + format.setBackground(highlight); + painter.save(); + painter.setPen(Qt::NoPen); + painter.translate(0, band.top()); + painter.scale(1, m_lineHeight / text.height()); + painter.translate(0, -(origin.y() + text.y())); + const QRectF own(origin.x(), origin.y() + text.y(), m_width, text.height()); + paragraph.draw(&painter, origin, {QTextLayout::FormatRange{start - offset, end - start, format}}, own); + painter.restore(); + } + const bool rtl = paragraph.text().isRightToLeft(); + const double head = origin.x() + text.cursorToX(first - offset), tail = origin.x() + text.cursorToX(last - offset); + if (before) + painter.fillRect(rtl ? QRectF(QPointF(head, band.top()), band.bottomRight()) : QRectF(band.topLeft(), QPointF(head, band.bottom())), highlight); + if (after) + painter.fillRect(rtl ? QRectF(band.topLeft(), QPointF(tail, band.bottom())) : QRectF(QPointF(tail, band.top()), band.bottomRight()), highlight); + painter.restore(); +} + +std::optional<int> TextLines::lineOf(int position) const +{ + if (position > m_laidEnd) + return std::nullopt; + // The last laid paragraph starting at or before the position. + const size_t paragraph = size_t(std::upper_bound(m_starts.begin(), m_starts.end(), position) - m_starts.begin() - 1); + const int local = std::min(position - m_starts[paragraph], int(m_paragraphs[paragraph]->text().size())); + const QTextLine line = m_paragraphs[paragraph]->lineForTextPosition(local); + if (!line.isValid()) + return std::nullopt; + for (size_t index = 0; index < m_lineList.size(); ++index) { + if (m_lineList[index].paragraph == paragraph && m_lineList[index].index == line.lineNumber()) + return int(index); + } + throw std::logic_error("a laid line is missing from the list"); +} + +std::optional<QRectF> TextLines::caret(int position) const +{ + const std::optional<int> line = lineOf(position); + if (!line) + return std::nullopt; + const Line &laid = m_lineList[size_t(*line)]; + const int local = std::min(position - m_starts[laid.paragraph], int(m_paragraphs[laid.paragraph]->text().size())); + const double x = m_paragraphs[laid.paragraph]->lineAt(laid.index).cursorToX(local); + return QRectF(x, *line * m_lineHeight, 1, m_lineHeight); +} + +int TextLines::lineStart(int line) const +{ + const Line &laid = m_lineList[size_t(line)]; + return m_starts[laid.paragraph] + m_paragraphs[laid.paragraph]->lineAt(laid.index).textStart(); +} + +int TextLines::lineEnd(int line) const +{ + const Line &laid = m_lineList[size_t(line)]; + const QTextLayout ¶graph = *m_paragraphs[laid.paragraph]; + const QTextLine text = paragraph.lineAt(laid.index); + const int end = text.textStart() + text.textLength(); + // A line ends before the space or separator breaking it. + const bool wraps = (end < paragraph.text().size() || laid.index < paragraph.lineCount() - 1) && paragraph.text().at(end - 1).isSpace(); + return m_starts[laid.paragraph] + end - (wraps ? 1 : 0); +} + +int TextLines::position(int line, double x) const +{ + const Line &laid = m_lineList[size_t(line)]; + return m_starts[laid.paragraph] + m_paragraphs[laid.paragraph]->lineAt(laid.index).xToCursor(x); +} + +int TextLines::character(int line, double x) const +{ + const Line &laid = m_lineList[size_t(line)]; + return m_starts[laid.paragraph] + m_paragraphs[laid.paragraph]->lineAt(laid.index).xToCursor(x, QTextLine::CursorOnCharacter); +} diff --git a/src/Rendering/TextLayout.h b/src/Rendering/TextLayout.h new file mode 100644 index 0000000..55f6976 --- /dev/null +++ b/src/Rendering/TextLayout.h @@ -0,0 +1,71 @@ +#pragma once +#include "Document/TypeTool.h" +#include <QFont> +#include <QStringList> +#include <QTextLayout> +#include <memory> +#include <optional> +#include <vector> + +class QPainter; + +// Where Swift has TextKit: a style's font and lines. +namespace TextLayout { +// The face a PostScript name names, or Qt's match. +QFont font(const LayerTextStyle &style); +// Every installed face's PostScript name, sorted. +QStringList availableFonts(); +} + +// Content positions from `start` up to `end`, as NSRange. +struct TextRange { + int start = 0; + int end = 0; + bool isEmpty() const { return start == end; } + friend bool operator==(const TextRange &, const TextRange &) = default; +}; + +// A style's paragraphs in a container, as TextKit lays them. +class TextLines { +public: + TextLines(const LayerTextStyle &style, QSizeF container); + TextLines(const TextLines &) = delete; + TextLines &operator=(const TextLines &) = delete; + // The lines' widest width and their heights together. + QSizeF usedSize() const; + // Whether text fell past the container's bottom. + bool overflows() const { return m_overflows; } + // A selection shows behind the text; formats in content positions. + void draw(QPainter &painter, QPointF origin, TextRange selection = {}, const QList<QTextLayout::FormatRange> &formats = {}, + const QColor &highlight = QColor()) const; + int lineCount() const { return int(m_lineList.size()); } + // The laid line holding a content position, if any. + std::optional<int> lineOf(int position) const; + // The caret before a position: a unit wide. + std::optional<QRectF> caret(int position) const; + // A line's first position; its last before any wrap. + int lineStart(int line) const; + int lineEnd(int line) const; + // The position nearest `x` on a laid line. + int position(int line, double x) const; + // The character under `x` on a laid line. + int character(int line, double x) const; + +private: + struct Line { + size_t paragraph; + int index; + }; + std::vector<std::unique_ptr<QTextLayout>> m_paragraphs; + // Each laid paragraph's first content position. + std::vector<int> m_starts; + void drawSelection(QPainter &painter, QPointF origin, int line, TextRange selection, const QColor &highlight) const; + std::vector<Line> m_lineList; + double m_lineHeight; + double m_width; + // Where the laid text ends: past it, no line. + int m_laidEnd; + bool m_overflows = false; + // TextKit's line after a final newline, the caret's alone. + bool m_caretLine = false; +}; diff --git a/src/Rendering/TiledLayerRenderer.cpp b/src/Rendering/TiledLayerRenderer.cpp new file mode 100644 index 0000000..7f59940 --- /dev/null +++ b/src/Rendering/TiledLayerRenderer.cpp @@ -0,0 +1,428 @@ +#include "Rendering/TiledLayerRenderer.h" +#include "Rendering/PoolMap.h" +#include "Logging.h" +#include "Rendering/DownsampleCache.h" +#include <QRegion> +#include <QtConcurrent> +#include <algorithm> +#include <cmath> +#include <numeric> +#include <map> +#include <stdexcept> + +namespace { +// Pieces compose and halve side by side, kept in order. +std::vector<TiledLayerRenderer::Piece> sideBySide(const std::vector<QRectF> &squares, + const std::function<std::optional<TiledLayerRenderer::Piece>(const QRectF &)> &build) +{ + std::vector<std::optional<TiledLayerRenderer::Piece>> built(squares.size()); + std::vector<size_t> order(squares.size()); + std::iota(order.begin(), order.end(), size_t(0)); + PoolMap::blocking(order, [&](size_t index) { built[index] = build(squares[index]); }); + std::vector<TiledLayerRenderer::Piece> pieces; + for (std::optional<TiledLayerRenderer::Piece> &one : built) { + if (one) + pieces.push_back(std::move(*one)); + } + return pieces; +} + +using TiledLayerRenderer::Frame; +using TiledLayerRenderer::Piece; + +// Columns whose centres satisfy low <= a*x+b < high. +std::pair<int, int> columns(double a, double b, double low, double high, int first, int last) +{ + if (a == 0) + return low <= b && b < high ? std::pair(first, last) : std::pair(first, first); + const double from = (low - b) / a, to = (high - b) / a; + // A negative slope turns the half-open interval around. + const double begin = a > 0 ? std::ceil(from - 0.5) : std::floor(to - 0.5) + 1; + const double end = a > 0 ? std::ceil(to - 0.5) : std::floor(from - 0.5) + 1; + // Near a right angle the slope is tiny: clamp first. + return {int(std::clamp(begin, double(first), double(last))), int(std::clamp(end, double(first), double(last)))}; +} + +// Device pixels to grid pixels. +QTransform toGrid(const Frame &frame, const QPainter &context) +{ + return context.deviceTransform().inverted() + * QTransform::fromTranslate(-frame.bounds.left(), -frame.bounds.top()) + * QTransform::fromScale(frame.pixelWidth / frame.bounds.width(), frame.pixelHeight / frame.bounds.height()); +} + +// Device pixels whose centres lie inside `area`, half-open. +QRegion pixelsInside(const QRectF &area, const Frame &frame, const QPainter &context) +{ + const QTransform grid = toGrid(frame, context); + const QRect device(0, 0, context.device()->width(), context.device()->height()); + const QRect rows = context.deviceTransform().mapRect(frame.mapped(area)).toAlignedRect().intersected(device); + QRegion region; + for (int y = rows.top(); y <= rows.bottom(); ++y) { + const double row = y + 0.5; + const auto [left, right] = columns(grid.m11(), grid.m21() * row + grid.dx(), area.left(), area.right(), + device.left(), device.right() + 1); + const auto [begin, end] = columns(grid.m12(), grid.m22() * row + grid.dy(), area.top(), area.bottom(), left, right); + if (end > begin) + region += QRect(begin, y, end - begin, 1); + } + return region; +} + +// The unchanged image, reduced by the frame's halvings. +void drawBase(const QImage &image, const QRectF &rect, const Frame &frame, QPainter &context) +{ + const DownsampleCache::Reduced reduced = DownsampleCache::shared().imageAtLevel(image, frame.level); + const double step = 1 << reduced.level; + const QRectF covered(rect.left(), rect.top(), reduced.image.width() * step * rect.width() / std::max(1, image.width()), + reduced.image.height() * step * rect.height() / std::max(1, image.height())); + context.drawImage(frame.mapped(covered), reduced.image); +} + +// An interior ending with its image opens one device pixel. +QRectF opened(const Piece &piece, const Frame &frame, const QPainter &context) +{ + // How far one device pixel reaches across each grid axis. + const QTransform grid = toGrid(frame, context); + const double dx = std::hypot(grid.m11(), grid.m21()), dy = std::hypot(grid.m12(), grid.m22()); + const QRectF &inside = piece.interior, &image = piece.region; + return inside.adjusted(inside.left() <= image.left() ? -dx : 0, inside.top() <= image.top() ? -dy : 0, + inside.right() >= image.right() ? dx : 0, inside.bottom() >= image.bottom() ? dy : 0); +} + +// A piece replaces its interior: cleared, then drawn. +void draw(const Piece &piece, const Frame &frame, QPainter &context) +{ + const QRegion pixels = pixelsInside(opened(piece, frame, context), frame, context); + if (pixels.isEmpty()) + return; + context.save(); + // A region in device pixels stays hard under antialiasing. + context.setWorldMatrixEnabled(false); + context.setClipRegion(pixels, Qt::IntersectClip); + context.setCompositionMode(QPainter::CompositionMode_Clear); + context.fillRect(pixels.boundingRect(), Qt::transparent); + context.setCompositionMode(QPainter::CompositionMode_SourceOver); + context.setWorldMatrixEnabled(true); + context.drawImage(frame.mapped(piece.region), piece.image); + context.restore(); +} + +// A committed raster placed at `offset` in the frame's grid. +void drawCommitted(const std::shared_ptr<const RasterSnapshot> &raster, QPointF offset, const Frame &frame, QPainter &context) +{ + if (!raster->base.isNull()) + drawBase(raster->base, raster->baseRect.translated(offset), frame, context); + for (const Piece &cached : TiledPieceCache::shared().pieces(raster, frame.level)) { + const Piece piece = cached.offsetBy(offset); + if (opened(piece, frame, context).intersects(frame.visible)) + draw(piece, frame, context); + } +} + +// A mask piece replaces its interior of the mask coverage. +void drawCoverage(const Piece &piece, const Frame &frame, QPainter &veil) +{ + draw(Piece{piece.interior, piece.region, BrushRaster::alphaView(piece.image)}, frame, veil); +} + +// The part of `image`, placed at `rect`, inside `region`. +void drawCropped(const QImage &image, const QRectF &rect, const QRectF ®ion, QPainter &context) +{ + if (image.width() != rect.width() || image.height() != rect.height()) { + // A solid 1 x 1 mask, say, stretches over `rect`. + BrushRaster::draw(image, rect, context); + return; + } + const QRectF local = QRectF(region.translated(-rect.topLeft()).intersected(QRectF(image.rect())).toAlignedRect()); + if (!local.isEmpty()) + BrushRaster::draw(image, local.translated(rect.topLeft()), context, local); +} + +using Body = std::function<void(const Frame &, QPainter &)>; + +// Composes a grid holding `painted` aside; `maskRect` places the mask. +void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRectF &maskRect, const LayerTransform &transform, + QPointF center, QPainter &context, const LayerRenderer::Options &options, const Body &body, const Body &veil) +{ + const double width = transform.size.width() * options.scale, height = transform.size.height() * options.scale; + if (width <= 0 || height <= 0 || pixelWidth <= 0 || pixelHeight <= 0) + return; + const double device = LayerRenderer::deviceScale(context); + const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth); + const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level)); + QTransform placement; + placement.translate(center.x(), center.y()); + placement.rotateRadians(transform.radians()); + placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1); + const QRectF bounds(-width / 2, -height / 2, width, height); + const QRectF shown = placement.inverted().mapRect(BrushRaster::visibleRect(context)); + const double sx = pixelWidth / width, sy = pixelHeight / height; + const Frame frame{bounds, double(pixelWidth), double(pixelHeight), level, device, transform.sampling, + QRectF((shown.left() - bounds.left()) * sx, (shown.top() - bounds.top()) * sy, shown.width() * sx, shown.height() * sy)}; + // Halvings and aligned pieces overhang by less than a step. + const double step = 1 << level; + const QRectF extent = frame.mapped(painted.adjusted(-step, -step, step, step)); + const auto framed = [&](const Body &paint) { + return paint ? std::function<void(QPainter &)>([&, paint](QPainter &aside) { paint(frame, aside); }) : std::function<void(QPainter &)>(); + }; + LayerRenderer::composite(context, placement, extent, transform.sampling, quality, options, frame.mapped(maskRect), framed(body), framed(veil)); +} +} + +TiledLayerRenderer::Piece TiledLayerRenderer::Piece::offsetBy(QPointF offset) const +{ + return {interior.translated(offset), region.translated(offset), image}; +} + +QRectF TiledLayerRenderer::Frame::mapped(const QRectF &rect) const +{ + return {bounds.left() + rect.left() / pixelWidth * bounds.width(), bounds.top() + rect.top() / pixelHeight * bounds.height(), + rect.width() / pixelWidth * bounds.width(), rect.height() / pixelHeight * bounds.height()}; +} + +double TiledLayerRenderer::support(int level) +{ + return level == 0 ? 8 : 16 << level; +} + +void TiledLayerRenderer::drawRaster(const std::shared_ptr<const RasterSnapshot> &raster, const LayerTransform &transform, + QPointF center, QPainter &context, const LayerRenderer::Options &options) +{ + const QRectF full(0, 0, raster->width, raster->height); + withFrame(raster->width, raster->height, full, full, transform, center, context, options, + [&](const Frame &frame, QPainter &aside) { drawCommitted(raster, QPointF(0, 0), frame, aside); }, {}); +} + +void TiledLayerRenderer::drawStroke(int width, int height, const QRectF &sourceRect, const std::vector<BrushPatch> &patches, + const QImage &image, const std::shared_ptr<const RasterSnapshot> &raster, + const LayerTransform &transform, QPointF center, QPainter &context, + const LayerRenderer::Options &options) +{ + QRectF painted(0, 0, width, height); + std::vector<QRectF> changed; + for (const BrushPatch &patch : patches) { + painted = painted.united(patch.rect); + changed.push_back(patch.rect); + } + std::vector<Piece> pieces; + const QPointF origin = sourceRect.topLeft() + (raster ? raster->alignment : QPointF(0, 0)); + const Body body = [&](const Frame &frame, QPainter &aside) { + const std::vector<QRectF> squares = interiors(changed, support(frame.level), strokeCell, 1 << frame.level, origin, frame.visible); + const std::vector<Piece> built = sideBySide(squares, [&](const QRectF &square) { + return piece(square, frame.level, origin, painted, false, [&](QPainter &painter, const QRectF ®ion) { + if (raster) + raster->draw(QRectF(sourceRect.topLeft(), QSizeF(raster->width, raster->height)), painter); + else if (!image.isNull()) + drawCropped(image, sourceRect, region, painter); + for (const BrushPatch &patch : patches) { + if (patch.rect.intersects(region)) + BrushRaster::draw(patch.image, patch.rect, painter); + } + }); + }); + pieces.insert(pieces.end(), built.begin(), built.end()); + if (raster) + drawCommitted(raster, sourceRect.topLeft(), frame, aside); + else if (!image.isNull()) + drawBase(image, sourceRect, frame, aside); + for (const Piece &piece : pieces) + draw(piece, frame, aside); + }; + const Body veil = [&](const Frame &frame, QPainter &masking) { + // Paint past the old bounds is revealed, not masked. + std::vector<QRectF> past; + for (const Piece &built : pieces) { + if (!sourceRect.contains(built.interior)) + past.push_back(built.interior); + } + const std::vector<Piece> shown = sideBySide(past, [&](const QRectF &interior) { + return piece(interior, frame.level, origin, painted, true, [&](QPainter &painter, const QRectF ®ion) { + painter.fillRect(region, Qt::white); + drawCropped(options.mask, sourceRect, region, painter); + }); + }); + for (const Piece &one : shown) + drawCoverage(one, frame, masking); + }; + withFrame(width, height, painted, sourceRect, transform, center, context, options, body, options.mask.isNull() ? Body() : veil); +} + +void TiledLayerRenderer::drawMaskStroke(int width, int height, const QRectF &sourceRect, const std::vector<BrushPatch> &patches, + const std::optional<ImportedImage> &oldMask, const QImage &image, + const std::shared_ptr<const RasterSnapshot> &raster, const LayerTransform &transform, + QPointF center, QPainter &context, const LayerRenderer::Options &options) +{ + if (!options.mask.isNull()) + throw std::logic_error("a mask stroke draws through the mask it paints"); + LayerRenderer::Options masked = options; + if (oldMask) + masked.mask = oldMask->image(); + std::vector<QRectF> changed; + for (const BrushPatch &patch : patches) + changed.push_back(patch.rect); + const Body body = [&](const Frame &frame, QPainter &aside) { + if (raster) + drawCommitted(raster, sourceRect.topLeft(), frame, aside); + else if (!image.isNull()) + drawBase(image, sourceRect, frame, aside); + }; + const Body veil = [&](const Frame &frame, QPainter &masking) { + // Pieces halve on the mask's own grid and levels. + const int level = frame.sampling == LayerSampling::nearest + ? 0 : DownsampleCache::level(frame.mapped(sourceRect).width() * frame.device / std::max(1.0, sourceRect.width())); + const QPointF origin = sourceRect.topLeft(); + const std::vector<Piece> built = sideBySide(interiors(changed, support(level), strokeCell, 1 << level, origin, frame.visible), [&](const QRectF &interior) { + return piece(interior, level, origin, sourceRect, true, [&](QPainter &painter, const QRectF ®ion) { + // Beyond the old mask an edit reveals. + painter.fillRect(region, Qt::white); + if (oldMask) + drawCropped(masked.mask, sourceRect, region, painter); + for (const BrushPatch &patch : patches) { + if (patch.rect.intersects(region)) + BrushRaster::draw(patch.image, patch.rect, painter); + } + }); + }); + for (const Piece &one : built) + drawCoverage(one, frame, masking); + }; + withFrame(width, height, QRectF(0, 0, width, height), sourceRect, transform, center, context, masked, body, veil); +} + +std::optional<TiledLayerRenderer::Piece> TiledLayerRenderer::piece(const QRectF &interior, int level, QPointF origin, const std::optional<QRectF> &bounds, + bool mask, const std::function<void(QPainter &, const QRectF &)> &compose) +{ + const double margin = support(level), step = 1 << level; + QRectF region = aligned(interior.adjusted(-margin, -margin, margin, margin), step, origin); + if (bounds) { + // Past the pixels' edge there is nothing to compose. + region = region.intersected(aligned(*bounds, step, origin)); + if (region.isEmpty()) + return std::nullopt; + } + const int width = int(region.width()), height = int(region.height()); + if (width <= 0 || height <= 0 || qint64(width) * height > 64'000'000) + return std::nullopt; + QImage image(width, height, mask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied); + if (image.isNull()) { + qCWarning(lcRendering) << "a tile piece could not be allocated:" << width << "x" << height; + return std::nullopt; + } + image.fill(0); + { + QPainter painter(&image); + painter.translate(-region.topLeft()); + compose(painter, region); + } + for (int halving = 0; halving < level; ++halving) { + const std::optional<QImage> half = DownsampleCache::halve(image, false); + if (!half) { + qCWarning(lcRendering) << "a tile piece could not be halved"; + return std::nullopt; + } + image = *half; + } + const QRectF kept = bounds ? interior.intersected(region) : interior; + if (kept.isEmpty()) + return std::nullopt; + return Piece{kept, region, image}; +} + +std::vector<QRectF> TiledLayerRenderer::interiors(const std::vector<QRectF> &rects, double margin, double size, double step, + QPointF origin, const std::optional<QRectF> &visible) +{ + std::map<std::pair<int, int>, QRectF> parts; + for (const QRectF &rect : rects) { + const QRectF grown = rect.adjusted(-margin, -margin, margin, margin); + if (visible && !grown.intersects(*visible)) + continue; + const int x0 = int(std::floor((grown.left() - origin.x()) / size)), x1 = int(std::ceil((grown.right() - origin.x()) / size)); + const int y0 = int(std::floor((grown.top() - origin.y()) / size)), y1 = int(std::ceil((grown.bottom() - origin.y()) / size)); + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const QRectF part = grown.intersected(QRectF(origin.x() + x * size, origin.y() + y * size, size, size)); + if (part.isEmpty()) + continue; + const auto found = parts.find({y, x}); + if (found == parts.end()) + parts.insert({{y, x}, part}); + else + found->second = found->second.united(part); + } + } + } + // Squares sit on the step grid: grown parts stay inside. + std::vector<QRectF> result; + for (const auto &[cell, part] : parts) { + const QRectF interior = aligned(part, step, origin); + if (!visible || interior.intersects(*visible)) + result.push_back(interior); + } + return result; +} + +QRectF TiledLayerRenderer::aligned(const QRectF &rect, double step, QPointF origin) +{ + const double left = origin.x() + std::floor((rect.left() - origin.x()) / step) * step; + const double top = origin.y() + std::floor((rect.top() - origin.y()) / step) * step; + const double right = origin.x() + std::ceil((rect.right() - origin.x()) / step) * step; + const double bottom = origin.y() + std::ceil((rect.bottom() - origin.y()) / step) * step; + return {left, top, right - left, bottom - top}; +} + +TiledPieceCache::TiledPieceCache(qint64 pixelBudget) : m_pixelBudget(pixelBudget) {} + +TiledPieceCache &TiledPieceCache::shared() +{ + static TiledPieceCache cache; + return cache; +} + +std::size_t TiledPieceCache::KeyHash::operator()(const Key &key) const +{ + return std::hash<const RasterSnapshot *>()(key.raster) ^ (std::hash<int>()(key.level) << 1); +} + +std::vector<TiledLayerRenderer::Piece> TiledPieceCache::pieces(const std::shared_ptr<const RasterSnapshot> &raster, int level) +{ + const Key key{raster.get(), level}; + { + const std::lock_guard<std::mutex> guard(m_lock); + m_clock += 1; + if (const auto found = m_entries.find(key); found != m_entries.end()) { + found->second.lastUse = m_clock; + return found->second.pieces; + } + } + const QPointF origin = raster->alignment; + const QRectF full(0, 0, raster->width, raster->height); + std::vector<QRectF> changed; + for (const BrushPatch &patch : raster->patches) + changed.push_back(patch.rect); + const std::vector<TiledLayerRenderer::Piece> pieces = sideBySide( + TiledLayerRenderer::interiors(changed, TiledLayerRenderer::support(level), TiledLayerRenderer::committedCell, 1 << level, origin, std::nullopt), + [&](const QRectF &square) { + return TiledLayerRenderer::piece(square, level, origin, full, raster->isMask, [&](QPainter &painter, const QRectF &) { raster->draw(full, painter); }); + }); + qint64 pixels = 0; + for (const TiledLayerRenderer::Piece &piece : pieces) + pixels += qint64(piece.image.width()) * piece.image.height(); + const std::lock_guard<std::mutex> guard(m_lock); + m_entries.insert_or_assign(key, Entry{raster, pieces, m_clock, pixels}); + qint64 total = 0; + for (const auto &[entryKey, entry] : m_entries) + total += entry.pixels; + while (total > m_pixelBudget) { + auto oldest = m_entries.end(); + for (auto entry = m_entries.begin(); entry != m_entries.end(); ++entry) { + if (!(entry->first == key) && (oldest == m_entries.end() || entry->second.lastUse < oldest->second.lastUse)) + oldest = entry; + } + if (oldest == m_entries.end()) + break; + total -= oldest->second.pixels; + m_entries.erase(oldest); + } + return pieces; +} diff --git a/src/Rendering/TiledLayerRenderer.h b/src/Rendering/TiledLayerRenderer.h new file mode 100644 index 0000000..ee76630 --- /dev/null +++ b/src/Rendering/TiledLayerRenderer.h @@ -0,0 +1,86 @@ +#pragma once +#include "Rendering/LayerRenderer.h" +#include "Rendering/RasterSnapshot.h" +#include <functional> +#include <memory> +#include <mutex> +#include <optional> +#include <unordered_map> +#include <vector> + +// Draws an image plus replacement tiles as one image would. +namespace TiledLayerRenderer { +struct Piece { + // Grid pixels this piece draws. + QRectF interior; + // Grid pixels its image holds: the interior and a margin. + QRectF region; + QImage image; + Piece offsetBy(QPointF offset) const; +}; + +// How one layer's grid maps into its local drawing space. +struct Frame { + QRectF bounds; + double pixelWidth; + double pixelHeight; + int level; + double device; + LayerSampling sampling; + // Grid pixels the painter's clip can show. + QRectF visible; + QRectF mapped(const QRectF &rect) const; +}; + +// Piece squares: large for committed snapshots, small for live strokes. +inline constexpr double committedCell = 1024; +inline constexpr double strokeCell = 256; + +// Grid pixels a change's reduced, resampled pixels can reach. +double support(int level); +void drawRaster(const std::shared_ptr<const RasterSnapshot> &raster, const LayerTransform &transform, QPointF center, + QPainter &context, const LayerRenderer::Options &options = {}); +// A tiled edit in progress over the pixels at `sourceRect`. +void drawStroke(int width, int height, const QRectF &sourceRect, const std::vector<BrushPatch> &patches, const QImage &image, + const std::shared_ptr<const RasterSnapshot> &raster, const LayerTransform &transform, QPointF center, + QPainter &context, const LayerRenderer::Options &options = {}); +// The layer through its mask as the stroke leaves it. +void drawMaskStroke(int width, int height, const QRectF &sourceRect, const std::vector<BrushPatch> &patches, + const std::optional<ImportedImage> &oldMask, const QImage &image, + const std::shared_ptr<const RasterSnapshot> &raster, const LayerTransform &transform, QPointF center, + QPainter &context, const LayerRenderer::Options &options = {}); +std::optional<Piece> piece(const QRectF &interior, int level, QPointF origin, const std::optional<QRectF> &bounds, bool mask, + const std::function<void(QPainter &, const QRectF ®ion)> &compose); +std::vector<QRectF> interiors(const std::vector<QRectF> &rects, double margin, double size, double step, QPointF origin, + const std::optional<QRectF> &visible); +QRectF aligned(const QRectF &rect, double step, QPointF origin); +} + +// Committed rasters' pieces, built once per snapshot and level. +class TiledPieceCache { +public: + static constexpr qint64 pixelBudget = 150'000'000; + explicit TiledPieceCache(qint64 pixelBudget = TiledPieceCache::pixelBudget); + static TiledPieceCache &shared(); + std::vector<TiledLayerRenderer::Piece> pieces(const std::shared_ptr<const RasterSnapshot> &raster, int level); + +private: + struct Key { + const RasterSnapshot *raster; + int level; + bool operator==(const Key &) const = default; + }; + struct KeyHash { + std::size_t operator()(const Key &key) const; + }; + struct Entry { + std::shared_ptr<const RasterSnapshot> raster; + std::vector<TiledLayerRenderer::Piece> pieces; + quint64 lastUse; + qint64 pixels; + }; + const qint64 m_pixelBudget; + std::unordered_map<Key, Entry, KeyHash> m_entries; + quint64 m_clock = 0; + std::mutex m_lock; +}; diff --git a/src/Rendering/TransformOverlay.cpp b/src/Rendering/TransformOverlay.cpp new file mode 100644 index 0000000..cbf1d12 --- /dev/null +++ b/src/Rendering/TransformOverlay.cpp @@ -0,0 +1,379 @@ +#include "Rendering/TransformOverlay.h" +#include <QPainterPath> +#include <cmath> +#include <numbers> + +TransformOverlayGeometry::TransformOverlayGeometry(const LayerTransform &transform, const CanvasViewport &viewport, QSizeF documentSize) +{ + for (size_t index = 0; index < handles.size(); ++index) + handles[index] = viewport.viewPoint(transform.point(LayerTransform::handles[index]), documentSize); + rotationHandle = handles[1] + QPointF(std::sin(transform.radians()) * 28, -std::cos(transform.radians()) * 28); + showsRotation = true; +} + +TransformOverlayGeometry::TransformOverlayGeometry(const Corners &corners, const CanvasViewport &viewport, QSizeF documentSize) +{ + std::array<QPointF, 4> view; + for (size_t index = 0; index < 4; ++index) + view[index] = viewport.viewPoint(corners[index], documentSize); + const auto middle = [](QPointF a, QPointF b) { return QPointF((a.x() + b.x()) / 2, (a.y() + b.y()) / 2); }; + handles = {view[0], middle(view[0], view[1]), view[1], middle(view[1], view[2]), view[2], middle(view[2], view[3]), view[3], middle(view[3], view[0])}; + rotationHandle = handles[1]; + showsRotation = false; +} + +std::optional<TransformDrag::Mode> TransformOverlayGeometry::hit(QPointF point) const +{ + const auto near = [&](QPointF other) { return std::hypot(point.x() - other.x(), point.y() - other.y()) <= 10; }; + if (showsRotation && near(rotationHandle)) + return TransformDrag::Mode{TransformDrag::Kind::rotate}; + for (size_t index = 0; index < handles.size(); ++index) { + if (near(handles[index])) + return TransformDrag::Mode{TransformDrag::Kind::resize, int(index)}; + } + // Along an edge, its middle handle: the whole edge resizes. + for (const auto &[start, end, handle] : {std::tuple(0, 2, 1), std::tuple(2, 4, 3), std::tuple(4, 6, 5), std::tuple(6, 0, 7)}) { + const QPointF a = handles[start], b = handles[end]; + const double dx = b.x() - a.x(), dy = b.y() - a.y(); + const double lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 0) + continue; + const double t = ((point.x() - a.x()) * dx + (point.y() - a.y()) * dy) / lengthSquared; + if (t >= 0 && t <= 1 && std::hypot(point.x() - a.x() - t * dx, point.y() - a.y() - t * dy) <= 10) + return TransformDrag::Mode{TransformDrag::Kind::resize, handle}; + } + return std::nullopt; +} + +Qt::CursorShape TransformOverlayGeometry::resizeCursor(int index) const +{ + constexpr double quarter = std::numbers::pi / 4; + const double angle = std::atan2(handles[2].y() - handles[0].y(), handles[2].x() - handles[0].x()); + const std::array<double, 8> offsets = {quarter, 2 * quarter, 3 * quarter, 0, quarter, 2 * quarter, 3 * quarter, 0}; + const int direction = (int(std::round((angle + offsets[size_t(index)]) / quarter)) % 4 + 4) % 4; + // Swift's frame positions: right, bottom right, bottom, top right. + const std::array<Qt::CursorShape, 4> shapes = {Qt::SizeHorCursor, Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor}; + return shapes[size_t(direction)]; +} + +std::optional<TransformOverlayGeometry> TransformOverlay::geometry() const +{ + const std::optional<TransformEdit> &edit = m_session.transformEdit(); + const std::optional<CanvasDocument> &document = m_session.document(); + if (m_session.tool() != NavigationTool::move || !(m_session.showsTransformControls() || (edit && edit->persistent)) || !document) + return std::nullopt; + // Several layers or a folder: one box around them all. + if ((edit && edit->group) || (!edit && m_session.transformsAsGroup())) { + if (edit && edit->corners) + return TransformOverlayGeometry(*edit->corners, m_session.viewport, document->size()); + const std::optional<LayerTransform> box = edit ? std::optional(edit->draft) : m_session.groupTransformBox(); + if (!box) + return std::nullopt; + return TransformOverlayGeometry(*box, m_session.viewport, document->size()); + } + const std::optional<ImageLayer> layer = m_session.activeLayer(); + if (!layer || !layer->asset || layer->isGroup || !document->effectiveVisibleIDs().contains(layer->id)) + return std::nullopt; + if (edit && edit->layerID == layer->id && edit->corners) + return TransformOverlayGeometry(*edit->corners, m_session.viewport, document->size()); + return TransformOverlayGeometry(m_session.editedTransform(*layer), m_session.viewport, document->size()); +} + +QRect TransformOverlay::drawnRect(const QRect &canvas) const +{ + const SnapGuides &guides = m_session.snapGuides; + if (!guides.xs.empty() || !guides.ys.empty()) + return canvas; + QRect drawn; + if (const std::optional<TransformOverlayGeometry> geometry = this->geometry()) { + QPointF low = geometry->rotationHandle, high = low; + for (const QPointF handle : geometry->handles) { + low = {std::min(low.x(), handle.x()), std::min(low.y(), handle.y())}; + high = {std::max(high.x(), handle.x()), std::max(high.y(), handle.y())}; + } + // The strokes reach five points past a handle's middle. + drawn = QRectF(low, high).adjusted(-5, -5, 5, 5).toAlignedRect(); + } + // Outside both frames the dimming stays: only the frames change. + if (const std::optional<QRectF> crop = cropViewRect()) + drawn |= crop->adjusted(-5, -5, 5, 5).toAlignedRect(); + drawn |= selectionRect(canvas); + if (const std::optional<LassoDraft> &draft = m_session.lassoDraft()) { + QPointF low = draft->points.front(), high = low; + for (const QPointF point : draft->points) { + low = {std::min(low.x(), point.x()), std::min(low.y(), point.y())}; + high = {std::max(high.x(), point.x()), std::max(high.y(), point.y())}; + } + if (draft->cursor) { + low = {std::min(low.x(), draft->cursor->x()), std::min(low.y(), draft->cursor->y())}; + high = {std::max(high.x(), draft->cursor->x()), std::max(high.y(), draft->cursor->y())}; + } + // The first corner's handle reaches five points out. + drawn |= documentToView().mapRect(QRectF(low, high)).adjusted(-5, -5, 5, 5).toAlignedRect(); + } + return drawn.intersected(canvas); +} + +QRect TransformOverlay::selectionRect(const QRect &canvas) const +{ + const std::optional<DocumentSelection> selection = m_session.displayedSelection(); + if (!selection || selection->isEmpty()) + return QRect(); + return documentToView().map(selection->path).boundingRect().adjusted(-2, -2, 2, 2).toAlignedRect().intersected(canvas); +} + +QTransform TransformOverlay::documentToView() const +{ + const std::optional<CanvasDocument> &document = m_session.document(); + if (!document) + return QTransform(); + const QPointF origin = m_session.viewport.documentRect(document->size()).topLeft(); + const double scale = m_session.viewport.pointsPerPixel(); + return QTransform::fromTranslate(origin.x(), origin.y()).scale(scale, scale); +} + +namespace { +std::array<QPointF, 8> cropHandles(const QRectF &rect) +{ + std::array<QPointF, 8> handles; + for (size_t index = 0; index < handles.size(); ++index) + handles[index] = QPointF(rect.left() + LayerTransform::handles[index].x() * rect.width(), rect.top() + LayerTransform::handles[index].y() * rect.height()); + return handles; +} +} + +// Swift's document guard is dead: visibleCropRect reads it. +std::optional<QRectF> TransformOverlay::cropViewRect() const +{ + const std::optional<QRectF> rect = m_session.visibleCropRect(); + if (!rect) + return std::nullopt; + const double scale = m_session.viewport.pointsPerPixel(); + return QRectF(m_session.viewport.viewPoint(rect->topLeft(), m_session.document().value().size()), rect->size() * scale); +} + +std::vector<TransformOverlay::CropRegion> TransformOverlay::cropResizeRegions() const +{ + const std::optional<QRectF> rect = cropViewRect(); + if (!rect) + return {}; + const std::array<QPointF, 8> handles = cropHandles(*rect); + constexpr double radius = 10; + std::vector<CropRegion> regions; + for (const size_t index : {0, 2, 4, 6}) + regions.push_back({int(index), QRectF(handles[index] - QPointF(radius, radius), QSizeF(radius * 2, radius * 2))}); + // Whole edges resize; Swift's max(0) is dead under the corners. + for (const size_t index : {1, 5}) + regions.push_back({int(index), QRectF(rect->left() + radius, handles[index].y() - radius, rect->width() - radius * 2, radius * 2)}); + for (const size_t index : {3, 7}) + regions.push_back({int(index), QRectF(handles[index].x() - radius, rect->top() + radius, radius * 2, rect->height() - radius * 2)}); + return regions; +} + +std::optional<std::pair<QPointF, QPointF>> TransformOverlay::gradientLine() const +{ + const std::optional<GradientEdit> &edit = m_session.gradientEdit(); + const std::optional<CanvasDocument> &document = m_session.document(); + if (!edit || !edit->hasLine() || !document) + return std::nullopt; + return std::pair(m_session.viewport.viewPoint(edit->start, document->size()), m_session.viewport.viewPoint(edit->end, document->size())); +} + +void TransformOverlay::draw(QPainter &context, const QPalette &palette) const +{ + if (m_session.tool() == NavigationTool::crop) + drawCrop(context); + else if (const std::optional<std::pair<QPointF, QPointF>> line = gradientLine()) + drawGradientLine(context, line->first, line->second); + else + drawTransformHandles(context, palette); + drawSelection(context); + drawLassoDraft(context); + drawSnapGuides(context, palette); +} + +// The line, its ends in their colours, a radial's rim. +void TransformOverlay::drawGradientLine(QPainter &context, QPointF start, QPointF end) const +{ + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.setBrush(Qt::NoBrush); + if (m_session.gradientSettings().shape == GradientShape::radial) { + // A faint rim where the radial reaches its end colour. + const double radius = std::hypot(end.x() - start.x(), end.y() - start.y()); + const QRectF rim(start.x() - radius, start.y() - radius, radius * 2, radius * 2); + for (const auto &[colour, width] : {std::pair(QColor(0, 0, 0, 128), 2.0), std::pair(QColor(255, 255, 255, 204), 1.0)}) { + QPen pen(colour, width, Qt::SolidLine, Qt::FlatCap); + // Four on, four off, in points: Qt counts widths. + pen.setDashPattern({4 / width, 4 / width}); + context.setPen(pen); + context.drawEllipse(rim); + } + } + context.setPen(QPen(QColor(0, 0, 0, 179), 3)); + context.drawLine(start, end); + context.setPen(QPen(Qt::white, 1)); + context.drawLine(start, end); + const std::array<QColor, 2> colors = m_session.gradientColors(false); + for (const auto &[point, colour] : {std::pair(start, colors[0]), std::pair(end, colors[1])}) { + const QRectF rect(point.x() - 6, point.y() - 6, 12, 12); + context.setPen(QPen(Qt::black, 1)); + context.setBrush(Qt::white); + context.drawEllipse(rect); + // A gray under the colour shows a clear end. + const QRectF inner = rect.adjusted(2.5, 2.5, -2.5, -2.5); + context.setPen(Qt::NoPen); + context.setBrush(QColor::fromRgbF(0.75, 0.75, 0.75)); + context.drawEllipse(inner); + context.setBrush(colour); + context.drawEllipse(inner); + } + context.restore(); +} + +// The outside dimmed, the frame, its thirds and its grips. +void TransformOverlay::drawCrop(QPainter &context) const +{ + const std::optional<QRectF> rect = cropViewRect(); + if (!rect) + return; + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + // Odd-even, a path's own rule: the frame stays clear. + QPainterPath outside; + outside.addRect(context.window()); + outside.addRect(*rect); + context.fillPath(outside, QColor::fromRgbF(0, 0, 0, 0.6)); + context.setBrush(Qt::NoBrush); + // CoreGraphics' butt caps and miter joins. + context.setPen(QPen(Qt::white, 1, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin)); + context.drawRect(*rect); + QPainterPath thirds; + for (const int index : {1, 2}) { + const double fraction = index / 3.0; + thirds.moveTo(rect->left() + rect->width() * fraction, rect->top()); + thirds.lineTo(rect->left() + rect->width() * fraction, rect->bottom()); + thirds.moveTo(rect->left(), rect->top() + rect->height() * fraction); + thirds.lineTo(rect->right(), rect->top() + rect->height() * fraction); + } + context.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.4), 1, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin)); + context.drawPath(thirds); + context.setPen(QPen(Qt::black, 1, Qt::SolidLine, Qt::FlatCap, Qt::MiterJoin)); + context.setBrush(Qt::white); + for (const QPointF point : cropHandles(*rect)) + context.drawRect(QRectF(point.x() - 4, point.y() - 4, 8, 8)); + context.restore(); +} + +// Marching ants: a white line under an animated black dash. +void TransformOverlay::drawSelection(QPainter &context) const +{ + const std::optional<DocumentSelection> selection = m_session.displayedSelection(); + if (!selection || selection->isEmpty() || !m_session.document()) + return; + const QPainterPath path = documentToView().map(selection->path); + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.setBrush(Qt::NoBrush); + context.setPen(QPen(Qt::white, 1)); + context.drawPath(path); + // Butt caps, as CoreGraphics dashes: four on, four off. + QPen ants(Qt::black, 1); + ants.setCapStyle(Qt::FlatCap); + ants.setDashPattern({4, 4}); + ants.setDashOffset(antsPhase); + context.setPen(ants); + context.drawPath(path); + context.restore(); +} + +void TransformOverlay::drawLassoDraft(QPainter &context) const +{ + const std::optional<LassoDraft> &draft = m_session.lassoDraft(); + if (!draft || !m_session.document()) + return; + const QTransform transform = documentToView(); + std::vector<QPointF> points; + for (const QPointF point : draft->points) + points.push_back(transform.map(point)); + if (draft->kind == LassoKind::polygonal && draft->cursor) + points.push_back(transform.map(*draft->cursor)); + QPainterPath path; + if (draft->kind == LassoKind::ellipse && points.size() == 4) { + QPointF low = points[0], high = low; + for (const QPointF point : points) { + low = {std::min(low.x(), point.x()), std::min(low.y(), point.y())}; + high = {std::max(high.x(), point.x()), std::max(high.y(), point.y())}; + } + path.addEllipse(QRectF(low, high)); + } else { + path.moveTo(points.front()); + for (std::size_t index = 1; index < points.size(); ++index) + path.lineTo(points[index]); + if (draft->kind == LassoKind::rectangle) + path.closeSubpath(); + } + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.setBrush(Qt::NoBrush); + // Butt caps: an open outline ends at its last point. + context.setPen(QPen(QColor(0, 0, 0, 204), 2, Qt::SolidLine, Qt::FlatCap)); + context.drawPath(path); + context.setPen(QPen(Qt::white, 1, Qt::SolidLine, Qt::FlatCap)); + context.drawPath(path); + if (draft->kind == LassoKind::polygonal) { + // The first corner: click it to close the outline. + const QRectF handle(points.front().x() - 4, points.front().y() - 4, 8, 8); + context.fillRect(handle, Qt::white); + context.setPen(QPen(Qt::black, 1)); + context.drawRect(handle); + } + context.restore(); +} + +void TransformOverlay::drawTransformHandles(QPainter &context, const QPalette &palette) const +{ + const std::optional<TransformOverlayGeometry> geometry = this->geometry(); + if (!geometry) + return; + const QColor accent = palette.color(QPalette::Highlight); + QPainterPath path(geometry->handles[0]); + for (const int index : {2, 4, 6}) + path.lineTo(geometry->handles[size_t(index)]); + path.closeSubpath(); + if (geometry->showsRotation) { + path.moveTo(geometry->handles[1]); + path.lineTo(geometry->rotationHandle); + } + context.save(); + context.setRenderHint(QPainter::Antialiasing, true); + context.setBrush(Qt::NoBrush); + context.setPen(QPen(QColor(0, 0, 0, 179), 3)); + context.drawPath(path); + context.setPen(QPen(accent, 1)); + context.drawPath(path); + context.setBrush(Qt::white); + for (const QPointF point : geometry->handles) + context.drawRect(QRectF(point.x() - 3.5, point.y() - 3.5, 7, 7)); + if (geometry->showsRotation) + context.drawEllipse(QRectF(geometry->rotationHandle.x() - 4, geometry->rotationHandle.y() - 4, 8, 8)); + context.restore(); +} + +// While a move snaps, a line along what it met. +void TransformOverlay::drawSnapGuides(QPainter &context, const QPalette &palette) const +{ + const SnapGuides &guides = m_session.snapGuides; + const std::optional<CanvasDocument> &document = m_session.document(); + if ((guides.xs.empty() && guides.ys.empty()) || !document) + return; + const QSizeF size = document->size(); + const CanvasViewport &viewport = m_session.viewport; + context.save(); + context.setPen(QPen(palette.color(QPalette::Highlight), 1)); + for (const double x : guides.xs) + context.drawLine(viewport.viewPoint(QPointF(x, 0), size), viewport.viewPoint(QPointF(x, size.height()), size)); + for (const double y : guides.ys) + context.drawLine(viewport.viewPoint(QPointF(0, y), size), viewport.viewPoint(QPointF(size.width(), y), size)); + context.restore(); +} diff --git a/src/Rendering/TransformOverlay.h b/src/Rendering/TransformOverlay.h new file mode 100644 index 0000000..89eab37 --- /dev/null +++ b/src/Rendering/TransformOverlay.h @@ -0,0 +1,62 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QPainter> +#include <QPalette> +#include <array> +#include <optional> +#include <vector> + +// The Move tool's box on screen: handles and a grip. +struct TransformOverlayGeometry { + std::array<QPointF, 8> handles; + QPointF rotationHandle; + // A distortion has no single rotation: its grip is hidden. + bool showsRotation; + + TransformOverlayGeometry(const LayerTransform &transform, const CanvasViewport &viewport, QSizeF documentSize); + // A distortion: its corners and the middles of its edges. + TransformOverlayGeometry(const Corners &corners, const CanvasViewport &viewport, QSizeF documentSize); + + // What a press takes hold of, within ten points. + std::optional<TransformDrag::Mode> hit(QPointF point) const; + // The resize cursor along the handle's edge, turned with it. + Qt::CursorShape resizeCursor(int index) const; + friend bool operator==(const TransformOverlayGeometry &, const TransformOverlayGeometry &) = default; +}; + +// Drawn by the canvas over its pixels: Swift's overlay view. +class TransformOverlay { +public: + explicit TransformOverlay(const EditorSession &session) : m_session(session) {} + + // The marching ants' dash offset, stepped by the canvas. + double antsPhase = 0; + + std::optional<TransformOverlayGeometry> geometry() const; + // Swift's gradientLine: the pending gradient's ends in view points. + std::optional<std::pair<QPointF, QPointF>> gradientLine() const; + // Where it draws, so the canvas repaints only that. + QRect drawnRect(const QRect &canvas) const; + // Where the ants draw: a tick repaints that alone. + QRect selectionRect(const QRect &canvas) const; + // Swift's crop frame in view points; none off the tool. + std::optional<QRectF> cropViewRect() const; + // A grip and the view points it takes, corners first. + struct CropRegion { + int index; + QRectF rect; + }; + std::vector<CropRegion> cropResizeRegions() const; + void draw(QPainter &context, const QPalette &palette) const; + +private: + QTransform documentToView() const; + void drawTransformHandles(QPainter &context, const QPalette &palette) const; + void drawGradientLine(QPainter &context, QPointF start, QPointF end) const; + void drawCrop(QPainter &context) const; + void drawSelection(QPainter &context) const; + void drawLassoDraft(QPainter &context) const; + void drawSnapGuides(QPainter &context, const QPalette &palette) const; + + const EditorSession &m_session; +}; diff --git a/src/UI/BlendModePicker.cpp b/src/UI/BlendModePicker.cpp new file mode 100644 index 0000000..e020357 --- /dev/null +++ b/src/UI/BlendModePicker.cpp @@ -0,0 +1,65 @@ +#include "UI/BlendModePicker.h" + +BlendModePicker::BlendModePicker(EditorSession &session, QWidget *parent) : QComboBox(parent), m_session(session) +{ + setAccessibleName(QStringLiteral("Blend mode")); + setObjectName(QStringLiteral("blendMode")); + for (const LayerBlendMode mode : allLayerBlendModes) + addItem(rawValue(mode)); + connect(this, &QComboBox::highlighted, this, &BlendModePicker::highlight); + connect(this, &QComboBox::activated, this, &BlendModePicker::choose); + connect(&m_session, &EditorSession::changed, this, &BlendModePicker::synchronize); + synchronize(); +} + +void BlendModePicker::showPopup() +{ + m_tracking = true; + m_layerID = m_session.activeLayerID(); + m_highlighted = std::nullopt; + QComboBox::showPopup(); +} + +void BlendModePicker::hidePopup() +{ + QComboBox::hidePopup(); + m_tracking = false; + // A click closes the menu, then chooses: clear after. + QMetaObject::invokeMethod(this, [this] { + m_session.previewBlendMode(std::nullopt, std::nullopt); + m_layerID = std::nullopt; + m_highlighted = std::nullopt; + }, Qt::QueuedConnection); +} + +void BlendModePicker::synchronize() +{ + setEnabled(m_session.canEditAppearance()); + if (m_tracking) + return; + const std::optional<ImageLayer> active = m_session.activeLayer(); + setCurrentText(rawValue(active ? active->blendMode : LayerBlendMode::normal)); +} + +void BlendModePicker::highlight(int index) +{ + const std::optional<LayerBlendMode> mode = layerBlendMode(itemText(index)); + if (mode) + m_highlighted = mode; + m_session.previewBlendMode(mode, m_layerID); +} + +void BlendModePicker::choose(int index) +{ + // The layer the menu opened on; closed, the active one. + const std::optional<QUuid> target = m_layerID ? m_layerID : m_session.activeLayerID(); + const std::optional<LayerBlendMode> mode = m_highlighted ? m_highlighted : layerBlendMode(itemText(index)); + m_highlighted = std::nullopt; + m_layerID = std::nullopt; + if (!mode || m_session.activeLayerID() != target) { + synchronize(); + return; + } + m_session.setLayerBlendMode(*mode); + setCurrentText(rawValue(*mode)); +} diff --git a/src/UI/BlendModePicker.h b/src/UI/BlendModePicker.h new file mode 100644 index 0000000..be1aef7 --- /dev/null +++ b/src/UI/BlendModePicker.h @@ -0,0 +1,23 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QComboBox> + +// The blend menu: hovering tries a mode, choosing keeps it. +class BlendModePicker : public QComboBox { + Q_OBJECT +public: + explicit BlendModePicker(EditorSession &session, QWidget *parent = nullptr); + + void showPopup() override; + void hidePopup() override; + +private: + void synchronize(); + void highlight(int index); + void choose(int index); + + EditorSession &m_session; + bool m_tracking = false; + std::optional<QUuid> m_layerID; + std::optional<LayerBlendMode> m_highlighted; +}; diff --git a/src/UI/BrushControls.cpp b/src/UI/BrushControls.cpp new file mode 100644 index 0000000..6a9751b --- /dev/null +++ b/src/UI/BrushControls.cpp @@ -0,0 +1,175 @@ +#include "UI/BrushControls.h" +#include "Document/EditorSession.h" +#include "UI/ColorPaletteControls.h" +#include "UI/LassoControls.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QComboBox> +#include <QLabel> +#include <QSignalBlocker> +#include <QSlider> +#include <QToolButton> +#include <cmath> + +namespace { +// The session's settings with one field rewritten. +void changeBrush(EditorSession &session, const std::function<void(BrushSettings &)> &change) +{ + BrushSettings settings = session.brushSettings(); + change(settings); + session.setBrushSettings(settings); +} + +const QString modeTip = QStringLiteral("Paint with the foreground color (B), or erase pixels away (E)"); +const QString blurTip = QStringLiteral("Liquify pushes pixels · Blur softens · Smudge drags color along"); +const QString sampleTip = QStringLiteral("Copy from the active layer only, or from every visible layer as shown"); + +QLabel *label(const QString &text, QWidget *parent) +{ + auto *made = new QLabel(text, parent); + made->setFont(ToolHeaderStyle::controlFont()); + return made; +} +} + +BrushControls::BrushControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QString(), parent), m_session(session), m_modes(new QButtonGroup(this)), + m_paint(choice(m_modes, QStringLiteral("brushPaint"), rawValue(BrushToolMode::paint), modeTip, [this] { m_session.setBrushMode(BrushToolMode::paint); })), + m_erase(choice(m_modes, QStringLiteral("brushErase"), rawValue(BrushToolMode::erase), modeTip, [this] { m_session.setBrushMode(BrushToolMode::erase); })), + m_blurModes(new QButtonGroup(this)), m_healingTypes(new QButtonGroup(this)), m_aligned(new QCheckBox(QStringLiteral("Aligned"), this)), m_samples(new QButtonGroup(this)), + m_thisLayer(choice(m_samples, QStringLiteral("cloneThisLayer"), QStringLiteral("This Layer"), sampleTip, + [this] { m_session.setCloneSettings(CloneSettings{m_session.cloneSettings().aligned, false}); })), + m_allLayers(choice(m_samples, QStringLiteral("cloneAllLayers"), QStringLiteral("All Layers"), sampleTip, + [this] { m_session.setCloneSettings(CloneSettings{m_session.cloneSettings().aligned, true}); })), + m_size(new SelectionAmountField(session, 1, 2000, [this] { return m_session.brushSettings().diameter; }, + [this](double size) { changeBrush(m_session, [size](BrushSettings &brush) { brush.diameter = size; }); }, this)), + m_hardnessSlider(slider(QStringLiteral("brushHardnessSlider"), 0, 1000, + [this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.hardness = value / 1000.0; }); })), + m_hardness(new SelectionAmountField(session, 0, 100, [this] { return m_session.brushSettings().hardness * 100; }, + [this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.hardness = percent / 100; }); }, this)), + m_opacityLabel(label(QStringLiteral("Opacity"), this)), + m_opacitySlider(slider(QStringLiteral("brushOpacitySlider"), 10, 1000, + [this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.opacity = value / 1000.0; }); })), + m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.brushSettings().opacity * 100; }, + [this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.opacity = percent / 100; }); }, this)), + m_paintLabel(label(QStringLiteral("Paint"), this)), m_maskPaint(new QComboBox(this)), m_colourLabel(label(QStringLiteral("Color"), this)), + m_colour(new SwatchButton([&session] { return session.foregroundColor(); }, 4, 1, 1, this)), + m_cloneNote(label(QStringLiteral("Alt-click to set the source"), this)), m_mask(label(QStringLiteral("Mask"), this)) +{ + m_blurModes->setObjectName(QStringLiteral("blurMode")); + for (const BlurToolMode mode : allBlurToolModes) + choice(m_blurModes, QString(), rawValue(mode), blurTip, [this, mode] { m_session.setBlurMode(mode); }); + m_healingTypes->setObjectName(QStringLiteral("spotHealingType")); + for (const SpotHealingMode type : allSpotHealingModes) + choice(m_healingTypes, QString(), rawValue(type), QString(), [this, type] { m_session.setSpotHealingMode(type); }); + m_aligned->setObjectName(QStringLiteral("cloneAligned")); + m_aligned->setToolTip(QStringLiteral("Keep the source moving with the brush between strokes; off starts every stroke at the source point")); + connect(m_aligned, &QCheckBox::clicked, this, + [this](bool on) { m_session.setCloneSettings(CloneSettings{on, m_session.cloneSettings().sampleAllLayers}); }); + m_size->setObjectName(QStringLiteral("brushSize")); + m_size->setFixedWidth(48); + m_hardness->setObjectName(QStringLiteral("brushHardness")); + m_hardness->setFixedWidth(42); + m_opacity->setObjectName(QStringLiteral("brushOpacity")); + m_opacity->setFixedWidth(42); + m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%")); + m_maskPaint->setObjectName(QStringLiteral("maskPaint")); + m_maskPaint->setFixedWidth(180); + m_maskPaint->addItem(QStringLiteral("Black · Hide"), false); + m_maskPaint->addItem(QStringLiteral("White · Reveal"), true); + connect(m_maskPaint, &QComboBox::activated, this, [this](int index) { m_session.setMaskPaintWhite(index == 1); }); + m_cloneNote->setObjectName(QStringLiteral("cloneSourceNote")); + m_cloneNote->setForegroundRole(QPalette::PlaceholderText); + m_mask->setObjectName(QStringLiteral("brushMaskNote")); + m_mask->setForegroundRole(QPalette::PlaceholderText); + m_colour->setObjectName(QStringLiteral("brushColor")); + m_colour->setFixedSize(34, 18); + m_colour->setToolTip(QStringLiteral("Foreground color")); + m_colour->setAccessibleName(QStringLiteral("Foreground color")); + connect(m_colour, &QAbstractButton::clicked, this, [this] { m_session.openColorPicker(false); }); + std::vector<QWidget *> widgets{m_paint, m_erase}; + for (QAbstractButton *mode : m_blurModes->buttons()) + widgets.push_back(mode); + for (QAbstractButton *type : m_healingTypes->buttons()) + widgets.push_back(type); + widgets.insert(widgets.end(), {m_aligned, m_thisLayer, m_allLayers}); + widgets.insert(widgets.end(), {label(QStringLiteral("Size"), this), m_size, label(QStringLiteral("px"), this), label(QStringLiteral("Hardness"), this), + m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity, + label(QStringLiteral("%"), this), 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); + connect(&m_session, &EditorSession::changed, this, &BrushControls::synchronize); + synchronize(); +} + +QToolButton *BrushControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick) +{ + auto *button = new QToolButton(this); + button->setObjectName(name); + button->setText(text); + button->setCheckable(true); + button->setToolTip(tip); + group->addButton(button); + connect(button, &QToolButton::clicked, this, pick); + return button; +} + +QSlider *BrushControls::slider(const QString &name, int low, int high, const std::function<void(int)> &change) +{ + auto *made = new QSlider(Qt::Horizontal, this); + made->setObjectName(name); + made->setRange(low, high); + made->setFixedWidth(100); + connect(made, &QSlider::valueChanged, this, change); + return made; +} + +void BrushControls::synchronize() +{ + const BrushSettings &brush = m_session.brushSettings(); + const NavigationTool tool = m_session.tool(); + const bool erasing = m_session.brushMode() == BrushToolMode::erase; + title->setText(tool == NavigationTool::spotHealing ? QStringLiteral("Spot Healing") : tool == NavigationTool::cloneStamp ? QStringLiteral("Clone Stamp") + : tool == NavigationTool::blur ? QStringLiteral("Smear") : erasing ? QStringLiteral("Eraser") : QStringLiteral("Brush")); + m_paint->setVisible(tool == NavigationTool::brush); + m_erase->setVisible(tool == NavigationTool::brush); + (erasing ? m_erase : m_paint)->setChecked(true); + const QList<QAbstractButton *> smears = m_blurModes->buttons(); + for (QAbstractButton *smear : smears) + smear->setVisible(tool == NavigationTool::blur); + smears[qsizetype(m_session.blurMode())]->setChecked(true); + const QList<QAbstractButton *> types = m_healingTypes->buttons(); + for (QAbstractButton *type : types) + type->setVisible(tool == NavigationTool::spotHealing); + types[qsizetype(m_session.spotHealingMode())]->setChecked(true); + const bool cloning = tool == NavigationTool::cloneStamp; + const CloneSettings &clone = m_session.cloneSettings(); + for (QWidget *widget : std::initializer_list<QWidget *>{m_aligned, m_thisLayer, m_allLayers}) + widget->setVisible(cloning); + if (m_aligned->isChecked() != clone.aligned) + m_aligned->setChecked(clone.aligned); + (clone.sampleAllLayers ? m_allLayers : m_thisLayer)->setChecked(true); + m_cloneNote->setVisible(cloning && !m_session.cloneSource()); + m_opacityLabel->setText(tool == NavigationTool::blur ? QStringLiteral("Strength") : QStringLiteral("Opacity")); + m_size->sync(int(std::lround(brush.diameter))); + m_hardness->sync(int(std::lround(brush.hardness * 100))); + m_opacity->sync(int(std::lround(brush.opacity * 100))); + // The sliders show the session's numbers without writing back. + const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider); + m_hardnessSlider->setValue(int(std::lround(brush.hardness * 1000))); + m_opacitySlider->setValue(int(std::lround(brush.opacity * 1000))); + const bool mask = m_session.isMaskSelected(); + m_paintLabel->setVisible(mask); + m_maskPaint->setVisible(mask); + m_maskPaint->setCurrentIndex(m_session.maskPaintWhite() ? 1 : 0); + m_mask->setVisible(mask); + // Clone Stamp and the Smear paint no colour of theirs. + const bool colour = !mask && tool != NavigationTool::cloneStamp && tool != NavigationTool::blur; + m_colourLabel->setVisible(colour); + m_colour->setVisible(colour); + m_colour->setEnabled(m_session.canEditPalette()); + m_colour->update(); + setEnabled(!m_session.showsBusy()); +} diff --git a/src/UI/BrushControls.h b/src/UI/BrushControls.h new file mode 100644 index 0000000..da27f6e --- /dev/null +++ b/src/UI/BrushControls.h @@ -0,0 +1,54 @@ +#pragma once +#include "UI/ToolHeaderStyle.h" +#include <functional> + +class EditorSession; +class QButtonGroup; +class QCheckBox; +class QComboBox; +class QLabel; +class QSlider; +class QToolButton; +class SelectionAmountField; +class SwatchButton; + +// Swift's BrushControls: mode, type or source; tip, opacity, mask. +class BrushControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit BrushControls(EditorSession &session, QWidget *parent = nullptr); + +private: + // A segment of Swift's segmented picker: one checkable button. + QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick); + QSlider *slider(const QString &name, int low, int high, const std::function<void(int)> &change); + void synchronize(); + + EditorSession &m_session; + QButtonGroup *const m_modes; + QToolButton *const m_paint; + QToolButton *const m_erase; + // The Smear's mode, Swift's second segmented picker. + QButtonGroup *const m_blurModes; + // Spot Healing's type, Swift's `spotHealingType` picker. + QButtonGroup *const m_healingTypes; + // Clone Stamp's options: Aligned, and what it samples. + QCheckBox *const m_aligned; + QButtonGroup *const m_samples; + QToolButton *const m_thisLayer; + QToolButton *const m_allLayers; + SelectionAmountField *const m_size; + // Sliders step in thousandths, fields show whole percents. + QSlider *const m_hardnessSlider; + SelectionAmountField *const m_hardness; + QLabel *const m_opacityLabel; + QSlider *const m_opacitySlider; + SelectionAmountField *const m_opacity; + QLabel *const m_paintLabel; + QComboBox *const m_maskPaint; + // The foreground colour, the rail swatch's own picker. + QLabel *const m_colourLabel; + SwatchButton *const m_colour; + QLabel *const m_cloneNote; + QLabel *const m_mask; +}; diff --git a/src/UI/ByteCounts.cpp b/src/UI/ByteCounts.cpp new file mode 100644 index 0000000..11dcf13 --- /dev/null +++ b/src/UI/ByteCounts.cpp @@ -0,0 +1,37 @@ +#include "UI/ByteCounts.h" +#include <QLocale> +#include <array> + +namespace { +// Adaptive: places by unit, trailing zeros dropped, digits grouped. +QString counted(qint64 bytes, int base) +{ + const QLocale english(QLocale::English, QLocale::UnitedStates); + // Nothing counted here holds one byte or a terabyte. + if (bytes < base) + return english.toString(bytes) + QStringLiteral(" bytes"); + const std::array<QString, 3> units = {QStringLiteral("KB"), QStringLiteral("MB"), QStringLiteral("GB")}; + double value = double(bytes) / base; + size_t unit = 0; + while (value >= base) { + value /= base; + ++unit; + } + QString number = english.toString(value, 'f', int(unit)); + while (unit > 0 && number.endsWith(QLatin1Char('0'))) + number.chop(1); + if (number.endsWith(QLatin1Char('.'))) + number.chop(1); + return number + QLatin1Char(' ') + units.at(unit); +} +} + +QString ByteCounts::memory(qint64 bytes) +{ + return counted(bytes, 1024); +} + +QString ByteCounts::file(qint64 bytes) +{ + return counted(bytes, 1000); +} diff --git a/src/UI/ByteCounts.h b/src/UI/ByteCounts.h new file mode 100644 index 0000000..8d4043d --- /dev/null +++ b/src/UI/ByteCounts.h @@ -0,0 +1,10 @@ +#pragma once +#include <QString> + +// Foundation's ByteCountFormatter, its memory and file styles, in English. +namespace ByteCounts { +// Binary units: bytes, KB whole, MB one place, GB two. +QString memory(qint64 bytes); +// Decimal units, as files count, with the same places. +QString file(qint64 bytes); +} diff --git a/src/UI/CanvasSizeSheet.cpp b/src/UI/CanvasSizeSheet.cpp new file mode 100644 index 0000000..1c732f0 --- /dev/null +++ b/src/UI/CanvasSizeSheet.cpp @@ -0,0 +1,292 @@ +#include "UI/CanvasSizeSheet.h" +#include "UI/ByteCounts.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QColorDialog> +#include <QComboBox> +#include <QFrame> +#include <QGridLayout> +#include <QLabel> +#include <QPainter> +#include <QPushButton> +#include <QToolButton> +#include <QVBoxLayout> +#include <cmath> + +namespace { +const std::array<QString, 9> anchorNames = {QStringLiteral("Top left"), QStringLiteral("Top center"), QStringLiteral("Top right"), + QStringLiteral("Middle left"), QStringLiteral("Center"), QStringLiteral("Middle right"), + QStringLiteral("Bottom left"), QStringLiteral("Bottom center"), QStringLiteral("Bottom right")}; + +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + label->setTextFormat(Qt::PlainText); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + label->setForegroundRole(role); + return label; +} + +// Swift's `.number`: three places at most, none trailing, ties even. +QString shown(double value, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3); + while (number.endsWith(locale.zeroDigit())) + number.chop(locale.zeroDigit().size()); + if (number.endsWith(locale.decimalPoint())) + number.chop(locale.decimalPoint().size()); + return number; +} + +QString grouped(qint64 value) +{ + return QLocale(QLocale::English, QLocale::UnitedStates).toString(value); +} + +// Swift's anchor: a circle, filled with the accent when chosen. +class AnchorButton : public QToolButton { +public: + using QToolButton::QToolButton; + bool chosen = false; + +protected: + void paintEvent(QPaintEvent *event) override + { + QToolButton::paintEvent(event); + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QRectF dot(0, 0, 11, 11); + dot.moveCenter(QRectF(rect()).center()); + if (chosen) { + painter.setPen(Qt::NoPen); + painter.setBrush(palette().color(QPalette::Highlight)); + } else { + painter.setPen(QPen(palette().color(QPalette::PlaceholderText), 1.2)); + } + painter.drawEllipse(dot); + } +}; +} + +CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background, + std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent) + : QWidget(parent), m_foreground(foreground), m_background(background), m_finish(std::move(finish)), + m_draft(document.width, document.height, document.resolution), m_width(dimension(true)), m_height(dimension(false)), + m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), + m_anchors(new QButtonGroup(this)), m_anchorName(text(QString(), 12, QFont::Bold, QPalette::WindowText, this)), m_extension(new QComboBox(this)), + m_customRow(new QWidget(this)), m_customSwatch(new SwatchButton([this] { return m_custom; }, 3, 0, 0.5, m_customRow)), + m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + setFixedWidth(450); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + column->addWidget(text(QStringLiteral("Canvas Size"), 17, QFont::Bold, QPalette::WindowText, this)); + column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal, + QPalette::WindowText, this)); + column->addWidget(text(ByteCounts::memory(qint64(document.width) * document.height * 4) + QStringLiteral(" uncompressed RGBA canvas"), 12, QFont::Normal, + QPalette::PlaceholderText, this)); + auto *divider = new QFrame(this); + divider->setFrameShape(QFrame::HLine); + divider->setForegroundRole(QPalette::Mid); + column->addWidget(divider); + + auto *fields = new QGridLayout; + fields->setHorizontalSpacing(8); + fields->setVerticalSpacing(16); + fields->setColumnMinimumWidth(0, 60); + auto *units = new QComboBox(this); + auto *unitsLabel = new QLabel(QStringLiteral("Units"), this); + unitsLabel->setBuddy(units); + units->setObjectName(QStringLiteral("canvasUnits")); + for (const CanvasUnit unit : {CanvasUnit::pixels, CanvasUnit::percent, CanvasUnit::inches, CanvasUnit::centimeters}) + units->addItem(rawValue(unit), int(unit)); + connect(units, &QComboBox::activated, this, [this, units](int index) { + // A wheel leaves the focus: pending typing commits first. + units->setFocus(); + m_draft.unit = CanvasUnit(units->itemData(index).toInt()); + synchronize(); + }); + fields->addWidget(unitsLabel, 0, 0); + fields->addWidget(units, 0, 1); + fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0); + fields->addWidget(m_width, 1, 1); + fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0); + fields->addWidget(m_height, 2, 1); + column->addLayout(fields); + + auto *relative = new QCheckBox(QStringLiteral("Relative to current dimensions"), this); + relative->setObjectName(QStringLiteral("canvasRelative")); + connect(relative, &QCheckBox::clicked, this, [this](bool on) { + m_draft.relative = on; + synchronize(); + }); + auto *locked = new QCheckBox(QStringLiteral("Lock original aspect ratio"), this); + locked->setObjectName(QStringLiteral("canvasLocked")); + // Swift's onChange: locking takes the width's proportions. + connect(locked, &QCheckBox::clicked, this, [this](bool on) { + m_draft.locked = on; + if (on) + m_draft.set(m_draft.displayed(true), true); + synchronize(); + }); + column->addWidget(relative); + column->addWidget(locked); + m_note->setObjectName(QStringLiteral("canvasNote")); + column->addWidget(m_note); + + auto *grid = new QGridLayout; + grid->setSpacing(3); + for (int index = 0; index < 9; ++index) { + auto *button = new AnchorButton(this); + button->setObjectName(QStringLiteral("anchor%1").arg(index)); + button->setFixedSize(25, 25); + button->setToolTip(anchorNames[size_t(index)]); + button->setAccessibleName(anchorNames[size_t(index)]); + m_anchors->addButton(button, index); + grid->addWidget(button, index / 3, index % 3); + } + connect(m_anchors, &QButtonGroup::idClicked, this, [this](int index) { + m_anchor = index; + synchronize(); + }); + auto *anchorColumn = new QVBoxLayout; + anchorColumn->setSpacing(8); + anchorColumn->addWidget(new QLabel(QStringLiteral("Anchor"), this)); + anchorColumn->addLayout(grid); + m_anchorName->setObjectName(QStringLiteral("anchorName")); + auto *explanation = text(QStringLiteral("Keeps this point fixed. Artwork is not scaled; cropped content remains outside the canvas."), 12, + QFont::Normal, QPalette::PlaceholderText, this); + explanation->setWordWrap(true); + auto *named = new QVBoxLayout; + named->setContentsMargins(0, 28, 0, 0); + named->setSpacing(8); + named->addWidget(m_anchorName); + named->addWidget(explanation); + named->addStretch(1); + auto *anchorRow = new QHBoxLayout; + anchorRow->setSpacing(24); + anchorRow->addLayout(anchorColumn); + anchorRow->addLayout(named, 1); + column->addLayout(anchorRow); + + auto *extension = new QHBoxLayout; + auto *extensionLabel = new QLabel(QStringLiteral("Canvas extension"), this); + extensionLabel->setBuddy(m_extension); + m_extension->setObjectName(QStringLiteral("canvasExtension")); + m_extension->addItems({QStringLiteral("Transparent"), QStringLiteral("Foreground"), QStringLiteral("Background"), QStringLiteral("Black"), + QStringLiteral("White"), QStringLiteral("Custom")}); + connect(m_extension, &QComboBox::activated, this, &CanvasSizeSheet::synchronize); + extension->addWidget(extensionLabel); + extension->addWidget(m_extension, 1); + column->addLayout(extension); + // Swift's ColorPicker: its label and a well, no opacity. + m_customRow->setObjectName(QStringLiteral("extensionColorRow")); + m_customSwatch->setObjectName(QStringLiteral("extensionColor")); + m_customSwatch->setFixedSize(36, 18); + m_customSwatch->setAccessibleName(QStringLiteral("Extension color")); + connect(m_customSwatch, &QAbstractButton::clicked, this, &CanvasSizeSheet::pickCustomColor); + auto *customLayout = new QHBoxLayout(m_customRow); + customLayout->setContentsMargins(0, 0, 0, 0); + customLayout->addWidget(new QLabel(QStringLiteral("Extension color"), m_customRow)); + customLayout->addWidget(m_customSwatch); + customLayout->addStretch(1); + column->addWidget(m_customRow); + + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("canvasCancel")); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + m_ok->setObjectName(QStringLiteral("canvasOK")); + m_ok->setDefault(true); + // OK rests while the draft is invalid: no guard needed. + connect(m_ok, &QPushButton::clicked, this, [this] { + m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor, + .fill = fill()}); + }); + auto *buttons = new QHBoxLayout; + buttons->addWidget(cancel); + buttons->addStretch(1); + buttons->addWidget(m_ok); + column->addLayout(buttons); + synchronize(); +} + +// Swift's value binding: a readable number sets the draft. +PickerField *CanvasSizeSheet::dimension(bool widthAxis) +{ + auto *field = new PickerField([this, widthAxis] { + PickerField &edited = widthAxis ? *m_width : *m_height; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number && std::isfinite(typed)) + m_draft.set(typed, widthAxis); + edited.setModified(false); + synchronize(); + }, nullptr, this); + field->setObjectName(widthAxis ? QStringLiteral("canvasWidth") : QStringLiteral("canvasHeight")); + field->setPlaceholderText(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height")); + field->setAccessibleName(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height")); + return field; +} + +std::optional<CanvasExtensionColor> CanvasSizeSheet::fill() const +{ + const auto colour = [](PaletteColor value) { return CanvasExtensionColor{value.red, value.green, value.blue}; }; + switch (m_extension->currentIndex()) { + case 0: return std::nullopt; + case 1: return colour(m_foreground); + case 2: return colour(m_background); + case 3: return colour(PaletteColor::black()); + case 4: return colour(PaletteColor::white()); + default: return colour(m_custom); + } +} + +// The desktop's colour dialog, SwiftUI's colour panel, without alpha. +void CanvasSizeSheet::pickCustomColor() +{ + auto *dialog = new QColorDialog(m_custom.color(), this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(QStringLiteral("Extension color")); + connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) { + const QColor srgb = picked.toRgb(); + m_custom = PaletteColor{srgb.redF(), srgb.greenF(), srgb.blueF()}; + m_customSwatch->update(); + }); + dialog->open(); +} + +// Units, Relative and Lock change only through their own controls. +void CanvasSizeSheet::synchronize() +{ + for (const auto &[field, widthAxis] : {std::pair(m_width, true), std::pair(m_height, false)}) { + // A field being typed in keeps its typing. + const QString number = shown(m_draft.displayed(widthAxis), field->locale()); + if (!field->isModified() && field->text() != number) + field->setText(number); + } + const bool valid = m_draft.valid(); + if (valid) { + const qint64 width = qint64(std::round(m_draft.width)), height = qint64(std::round(m_draft.height)); + m_note->setText(QStringLiteral("New: %1 × %2 pixels · %3 uncompressed").arg(grouped(width), grouped(height), ByteCounts::memory(width * height * 4))); + } else { + m_note->setText(QStringLiteral("Final dimensions must be 1–30,000 pixels per side.")); + } + m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText); + for (QAbstractButton *each : m_anchors->buttons()) { + auto *button = static_cast<AnchorButton *>(each); + button->chosen = m_anchors->id(each) == m_anchor; + button->setAccessibleDescription(button->chosen ? QStringLiteral("Selected") : QString()); + button->update(); + } + m_anchorName->setText(anchorNames[size_t(m_anchor)]); + m_customRow->setVisible(m_extension->currentIndex() == 5); + m_ok->setEnabled(valid); +} diff --git a/src/UI/CanvasSizeSheet.h b/src/UI/CanvasSizeSheet.h new file mode 100644 index 0000000..df07db8 --- /dev/null +++ b/src/UI/CanvasSizeSheet.h @@ -0,0 +1,44 @@ +#pragma once +#include "Document/CanvasSize.h" +#include "Document/ColorPalette.h" +#include "Document/EditorSession+Model.h" +#include <QWidget> +#include <array> +#include <functional> + +class PickerField; +class QButtonGroup; +class QComboBox; +class QLabel; +class QPushButton; +class SwatchButton; + +// Swift's CanvasSizeSheet: the new size, the point kept, the extension. +class CanvasSizeSheet : public QWidget { + Q_OBJECT +public: + CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background, + std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent = nullptr); + +private: + PickerField *dimension(bool widthAxis); + std::optional<CanvasExtensionColor> fill() const; + void pickCustomColor(); + void synchronize(); + + const PaletteColor m_foreground; + const PaletteColor m_background; + const std::function<void(std::optional<CanvasSizeOptions>)> m_finish; + CanvasSizeDraft m_draft; + int m_anchor = 4; + PaletteColor m_custom = PaletteColor::white(); + PickerField *const m_width; + PickerField *const m_height; + QLabel *const m_note; + QButtonGroup *const m_anchors; + QLabel *const m_anchorName; + QComboBox *const m_extension; + QWidget *const m_customRow; + SwatchButton *const m_customSwatch; + QPushButton *const m_ok; +}; diff --git a/src/UI/CanvasThumbnail.cpp b/src/UI/CanvasThumbnail.cpp new file mode 100644 index 0000000..8569412 --- /dev/null +++ b/src/UI/CanvasThumbnail.cpp @@ -0,0 +1,91 @@ +#include "UI/CanvasThumbnail.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include "Rendering/LayerRenderer.h" +#include <QPainter> +#include <cmath> + +namespace { +// A canvas-shaped picture; `draw` gets pixels and their scale. +QPixmap render(QSizeF canvas, double box, const std::function<void(QPainter &, QSize, double)> &draw) +{ + const QSize points = CanvasThumbnail::fittedSize(canvas, box); + const QSize pixels = points * CanvasThumbnail::backingScale; + QImage image; + try { + image = BrushRaster::context(pixels.width(), pixels.height(), false); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "a thumbnail could not be allocated:" << pixels << error.what(); + QPixmap empty(pixels); + empty.fill(Qt::transparent); + empty.setDevicePixelRatio(CanvasThumbnail::backingScale); + return empty; + } + { + QPainter painter(&image); + draw(painter, pixels, canvas.width() > 0 ? pixels.width() / canvas.width() : 1); + } + QPixmap result = QPixmap::fromImage(image); + result.setDevicePixelRatio(CanvasThumbnail::backingScale); + return result; +} + +// The image where the transform puts it, as the canvas. +void place(const QImage &image, const LayerTransform &transform, double scale, QPainter &painter) +{ + LayerRenderer::draw(image, transform, QPointF(transform.center().x() * scale, transform.center().y() * scale), painter, {.scale = scale}); +} +} + +QSize CanvasThumbnail::fittedSize(QSizeF canvas, double box) +{ + if (!(canvas.width() > 0) || !(canvas.height() > 0) || !std::isfinite(canvas.width()) || !std::isfinite(canvas.height())) + return QSize(int(box), int(box)); + const double scale = box / std::max(canvas.width(), canvas.height()); + return QSize(std::max(1, int(std::round(canvas.width() * scale))), std::max(1, int(std::round(canvas.height() * scale)))); +} + +QPixmap CanvasThumbnail::layer(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box) +{ + return render(canvas, box, [&](QPainter &painter, QSize size, double scale) { + painter.fillRect(QRect(QPoint(0, 0), size), QColor::fromRgbF(0.22, 0.22, 0.22)); + const double tile = 6 * backingScale; + for (int row = 0; row < int(std::ceil(size.height() / tile)); ++row) { + for (int column = 0; column < int(std::ceil(size.width() / tile)); ++column) { + if ((row + column) % 2 == 0) + painter.fillRect(QRectF(column * tile, row * tile, tile, tile), QColor::fromRgbF(0.32, 0.32, 0.32)); + } + } + if (!image.isNull()) + place(image, transform, scale, painter); + }); +} + +QPixmap CanvasThumbnail::mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box) +{ + return render(canvas, box, [&](QPainter &painter, QSize size, double scale) { + const double tone = edgeTone(image); + painter.fillRect(QRect(QPoint(0, 0), size), QColor::fromRgbF(tone, tone, tone)); + place(image, transform, scale, painter); + }); +} + +double CanvasThumbnail::edgeTone(const QImage &image) +{ + const QImage gray = image.convertToFormat(QImage::Format_Grayscale8); + if (gray.width() <= 0 || gray.height() <= 0) + return 1; + qint64 total = 0, count = 0; + for (int y = 0; y < gray.height(); ++y) { + const uchar *row = gray.constScanLine(y); + const bool edgeRow = y == 0 || y == gray.height() - 1; + for (int x = 0; x < gray.width(); ++x) { + if (edgeRow || x == 0 || x == gray.width() - 1) { + total += row[x]; + ++count; + } + } + } + return count == 0 ? 1 : double(total) / double(count) / 255; +} diff --git a/src/UI/CanvasThumbnail.h b/src/UI/CanvasThumbnail.h new file mode 100644 index 0000000..48baae4 --- /dev/null +++ b/src/UI/CanvasThumbnail.h @@ -0,0 +1,20 @@ +#pragma once +#include "Document/LayerTransform.h" +#include <QImage> +#include <QPixmap> +#include <QSizeF> + +// Thumbnails framed by the whole canvas, as Photoshop shows them. +namespace CanvasThumbnail { +// Pixels per point in the pictures, sharp on scaled displays. +inline constexpr double backingScale = 2; + +// The canvas's shape fitted in a square of `box` points. +QSize fittedSize(QSizeF canvas, double box); +// The checkerboard with the pixels placed; null: empty canvas. +QPixmap layer(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box); +// The mask placed; its edge tone fills the rest. +QPixmap mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box); +// The mean gray, 0 to 1, of the outermost pixels. +double edgeTone(const QImage &image); +} diff --git a/src/UI/ColorPaletteControls.cpp b/src/UI/ColorPaletteControls.cpp new file mode 100644 index 0000000..64ba174 --- /dev/null +++ b/src/UI/ColorPaletteControls.cpp @@ -0,0 +1,186 @@ +#include "UI/ColorPaletteControls.h" +#include <QFrame> +#include <QHBoxLayout> +#include <QLabel> +#include <QPainter> +#include <QPainterPath> +#include <QPushButton> +#include <QVBoxLayout> +#include <cmath> + +namespace { +constexpr double swatchSize = 24; +constexpr double swatchOffset = 12; +// Swift's buttons reach 3 points past the swatches' frame. +constexpr QPoint origin(3, 3); + +// A 12-point glyph in the secondary ink, as Swift's buttons. +class GlyphButton : public QAbstractButton { +public: + GlyphButton(std::function<void(QPainter &)> glyph, QWidget *parent) : QAbstractButton(parent), m_glyph(std::move(glyph)) + { + setFixedSize(12, 12); + setCursor(Qt::ArrowCursor); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QColor ink = palette().color(isEnabled() ? QPalette::Active : QPalette::Disabled, QPalette::PlaceholderText); + painter.setPen(QPen(ink, 1.1, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + m_glyph(painter); + } + +private: + const std::function<void(QPainter &)> m_glyph; +}; + +// SF Symbols' arrow.left.and.right, turned 45 degrees. +void swapGlyph(QPainter &painter) +{ + painter.translate(6, 6); + painter.rotate(45); + QPainterPath arrow; + arrow.moveTo(-4.5, 0); + arrow.lineTo(4.5, 0); + arrow.moveTo(-2.5, -2); + arrow.lineTo(-4.5, 0); + arrow.lineTo(-2.5, 2); + arrow.moveTo(2.5, -2); + arrow.lineTo(4.5, 0); + arrow.lineTo(2.5, 2); + painter.drawPath(arrow); +} + +// SF Symbols' arrow.counterclockwise: a ring, its head at the top. +void resetGlyph(QPainter &painter) +{ + QPainterPath ring; + ring.arcMoveTo(QRectF(2.5, 2.5, 7, 7), 100); + ring.arcTo(QRectF(2.5, 2.5, 7, 7), 100, -290); + painter.drawPath(ring); + QPainterPath head; + head.moveTo(7.2, 1.2); + head.lineTo(5.3, 2.6); + head.lineTo(7, 4.2); + painter.drawPath(head); +} +} + +SwatchButton::SwatchButton(std::function<PaletteColor()> colour, double radius, double whiteRing, double rimAlpha, QWidget *parent) + : QAbstractButton(parent), m_colour(std::move(colour)), m_radius(radius), m_whiteRing(whiteRing), m_rimAlpha(rimAlpha) +{ +} + +void SwatchButton::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF bounds(rect()); + QPainterPath shape; + shape.addRoundedRect(bounds, m_radius, m_radius); + painter.fillPath(shape, m_colour().color()); + painter.setBrush(Qt::NoBrush); + // Swift's inset(by: 1).strokeBorder(.white): a ring inside the rim. + if (m_whiteRing > 0) { + const double inset = 1 + m_whiteRing / 2; + painter.setPen(QPen(Qt::white, m_whiteRing)); + painter.drawRoundedRect(bounds.adjusted(inset, inset, -inset, -inset), m_radius - inset, m_radius - inset); + } + painter.setPen(QPen(QColor::fromRgbF(0, 0, 0, float(m_rimAlpha)), 1)); + painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), m_radius - 0.5, m_radius - 0.5); +} + +ColorPaletteControls::ColorPaletteControls(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), + m_background(new SwatchButton([&session] { return session.paletteColor(true); }, 6, 1.5, 1, this)), + m_foreground(new SwatchButton([&session] { return session.paletteColor(false); }, 6, 1.5, 1, this)), + m_swap(new GlyphButton(swapGlyph, this)), m_reset(new GlyphButton(resetGlyph, this)), m_pickerPanel(*this), + m_masked(session.isMaskSelected()) +{ + setFixedSize(int(swatchSize + swatchOffset) + 2 * origin.x(), int(swatchSize + swatchOffset) + 2 * origin.y()); + m_background->setObjectName(QStringLiteral("backgroundSwatch")); + m_background->setGeometry(QRect(origin + QPoint(int(swatchOffset), int(swatchOffset)), QSize(int(swatchSize), int(swatchSize)))); + m_background->setToolTip(QStringLiteral("Background color")); + m_background->setAccessibleName(QStringLiteral("Background color")); + m_foreground->setObjectName(QStringLiteral("foregroundSwatch")); + m_foreground->setGeometry(QRect(origin, QSize(int(swatchSize), int(swatchSize)))); + m_foreground->setToolTip(QStringLiteral("Foreground color")); + m_foreground->setAccessibleName(QStringLiteral("Foreground color")); + m_swap->setObjectName(QStringLiteral("swapColors")); + m_swap->move(origin + QPoint(int(swatchSize) + 3, -3)); + m_swap->setToolTip(QStringLiteral("Swap foreground and background (X)")); + m_swap->setAccessibleName(QStringLiteral("Swap colors")); + m_reset->setObjectName(QStringLiteral("defaultColors")); + m_reset->move(origin + QPoint(-1, int(swatchSize) + 3)); + m_reset->setToolTip(QStringLiteral("Default colors (D)")); + m_reset->setAccessibleName(QStringLiteral("Default colors")); + for (const auto &[swatch, background] : {std::pair(m_background, true), std::pair(m_foreground, false)}) { + connect(swatch, &QAbstractButton::clicked, this, [this, background] { + if (m_session.isMaskSelected()) + chooseMask(background); + else + m_session.openColorPicker(background); + }); + } + connect(m_swap, &QAbstractButton::clicked, this, [this] { m_session.swapPaletteColors(); }); + connect(m_reset, &QAbstractButton::clicked, this, [this] { m_session.resetPaletteColors(); }); + connect(&m_session, &EditorSession::changed, this, &ColorPaletteControls::synchronize); + synchronize(); +} + +void ColorPaletteControls::chooseMask(bool background) +{ + auto *popup = new QFrame(this, Qt::Popup); + popup->setObjectName(QStringLiteral("maskColorChoice")); + popup->setAttribute(Qt::WA_DeleteOnClose); + popup->setFrameShape(QFrame::StyledPanel); + auto *headline = new QLabel(background ? QStringLiteral("Mask background") : QStringLiteral("Mask foreground"), popup); + QFont bold = headline->font(); + bold.setBold(true); + headline->setFont(bold); + auto *buttons = new QHBoxLayout; + for (const auto &[text, colour] : {std::pair(QStringLiteral("Black · Hide"), PaletteColor::black()), std::pair(QStringLiteral("White · Reveal"), PaletteColor::white())}) { + auto *button = new QPushButton(text, popup); + connect(button, &QPushButton::clicked, popup, [this, popup, background, colour] { + m_session.setPaletteColor(colour, background); + popup->close(); + }); + buttons->addWidget(button); + } + auto *column = new QVBoxLayout(popup); + column->setContentsMargins(16, 16, 16, 16); + column->setSpacing(12); + column->addWidget(headline); + column->addLayout(buttons); + popup->move(mapToGlobal(QPoint(0, height()))); + popup->show(); + m_maskChoice = popup; +} + +void ColorPaletteControls::synchronize() +{ + setEnabled(m_session.canEditPalette()); + // Another target closes the choice; a mask cancels the picker. + if (m_session.isMaskSelected() != m_masked) { + m_masked = m_session.isMaskSelected(); + if (m_maskChoice) + m_maskChoice->close(); + if (m_masked) + m_session.closeColorPicker(false); + } + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + const std::optional<QUuid> shown = picker ? std::optional(picker->id) : std::nullopt; + if (shown != m_shownPicker) { + m_shownPicker = shown; + if (picker) + m_pickerPanel.show(*picker, m_session); + else + m_pickerPanel.close(); + } + m_background->update(); + m_foreground->update(); +} diff --git a/src/UI/ColorPaletteControls.h b/src/UI/ColorPaletteControls.h new file mode 100644 index 0000000..a550d62 --- /dev/null +++ b/src/UI/ColorPaletteControls.h @@ -0,0 +1,46 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ColorPickerSheet.h" +#include <QAbstractButton> +#include <QPointer> +#include <QWidget> +#include <functional> + +// A colour in a rounded rect; rims vary by bar. +class SwatchButton : public QAbstractButton { + Q_OBJECT +public: + SwatchButton(std::function<PaletteColor()> colour, double radius, double whiteRing, double rimAlpha, QWidget *parent); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + const std::function<PaletteColor()> m_colour; + const double m_radius; + const double m_whiteRing; + const double m_rimAlpha; +}; + +// Swift's ColorPaletteControls: the swatches, swap, defaults, picker. +class ColorPaletteControls : public QWidget { + Q_OBJECT +public: + explicit ColorPaletteControls(EditorSession &session, QWidget *parent = nullptr); + +private: + // A mask's swatch offers black or white, as Swift's popover. + void chooseMask(bool background); + void synchronize(); + + EditorSession &m_session; + SwatchButton *const m_background; + SwatchButton *const m_foreground; + QAbstractButton *const m_swap; + QAbstractButton *const m_reset; + ColorPickerPanelController m_pickerPanel; + // The picker the panel shows: a new one shows anew. + std::optional<QUuid> m_shownPicker; + bool m_masked; + QPointer<QWidget> m_maskChoice; +}; diff --git a/src/UI/ColorPickerSheet.cpp b/src/UI/ColorPickerSheet.cpp new file mode 100644 index 0000000..1ca0c84 --- /dev/null +++ b/src/UI/ColorPickerSheet.cpp @@ -0,0 +1,362 @@ +#include "UI/ColorPickerSheet.h" +#include <QFontDatabase> +#include <QGridLayout> +#include <QHBoxLayout> +#include <QKeyEvent> +#include <QLabel> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QRegularExpressionValidator> +#include <QVBoxLayout> +#include <algorithm> +#include <cmath> + +namespace { +constexpr double fieldSize = 256; + +// A pointer drag reports each point it passes. +class Picking : public QWidget { +public: + Picking(const EditorSession &session, std::function<void(QPointF)> pick, QWidget *parent) + : QWidget(parent), m_session(session), m_pick(std::move(pick)) + { + } + +protected: + // Swift's drag answers the primary button alone. + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_pick(event->position()); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (event->buttons().testFlag(Qt::LeftButton)) + m_pick(event->position()); + } + const EditorSession &m_session; + +private: + const std::function<void(QPointF)> m_pick; +}; + +// Swift's saturation and brightness field, its marker ringed. +class SaturationBrightness : public Picking { +public: + using Picking::Picking; + +protected: + void paintEvent(QPaintEvent *) override + { + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + QPainter painter(this); + const QRectF rect(0, 0, fieldSize, fieldSize); + QLinearGradient across(rect.topLeft(), rect.topRight()); + across.setColorAt(0, Qt::white); + across.setColorAt(1, PickerHSB(picker->hsb.hue, 1, 1).rgb().color()); + painter.fillRect(rect, across); + QLinearGradient down(rect.topLeft(), rect.bottomLeft()); + down.setColorAt(0, Qt::transparent); + down.setColorAt(1, Qt::black); + painter.fillRect(rect, down); + painter.setRenderHint(QPainter::Antialiasing); + painter.setBrush(Qt::NoBrush); + const QPointF centre(picker->hsb.saturation * fieldSize, (1 - picker->hsb.brightness) * fieldSize); + // Black behind, then white: Swift's ring and its background. + painter.setPen(QPen(Qt::black, 0.75)); + painter.drawEllipse(centre, 6.375, 6.375); + painter.setPen(QPen(Qt::white, 1.5)); + painter.drawEllipse(centre, 5.25, 5.25); + painter.setPen(QPen(QColor(0, 0, 0, 153), 1)); + painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5)); + } +}; + +// Swift's hue strip, 360 on top; the sheet draws arrows. +class HueStrip : public Picking { +public: + using Picking::Picking; + +protected: + void paintEvent(QPaintEvent *) override + { + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + QPainter painter(this); + const QRectF strip(7, 0, 20, fieldSize); + QLinearGradient down(strip.topLeft(), strip.bottomLeft()); + for (int step = 0; step <= 6; ++step) + down.setColorAt(step / 6.0, PickerHSB(360 - step * 60, 1, 1).rgb().color()); + painter.fillRect(strip, down); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(QColor(0, 0, 0, 153), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(strip.adjusted(0.5, 0.5, -0.5, -0.5)); + } +}; + +// The new colour, rounded, a 60% black rim. +class ColorPreview : public QWidget { +public: + ColorPreview(const EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session) {} + +protected: + void paintEvent(QPaintEvent *) override + { + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QPainterPath shape; + shape.addRoundedRect(QRectF(rect()), 5, 5); + painter.fillPath(shape, picker->color().color()); + painter.setPen(QPen(QColor(0, 0, 0, 153), 1)); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 4.5, 4.5); + } + +private: + const EditorSession &m_session; +}; + +QLabel *label(const QString &text, QWidget *parent) +{ + auto *label = new QLabel(text, parent); + label->setFixedWidth(14); + return label; +} +} + +PickerField::PickerField(std::function<void()> commit, std::function<void(int)> step, QWidget *parent) + : QLineEdit(parent), m_commit(std::move(commit)), m_step(std::move(step)) +{ +} + +void PickerField::keyPressEvent(QKeyEvent *event) +{ + // Swift's arrowSteps: one a press, ten with Shift. + if (m_step && (event->key() == Qt::Key_Up || event->key() == Qt::Key_Down)) { + m_step((event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1)); + return; + } + // Return commits, then goes on to OK, the default. + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) + m_commit(); + QLineEdit::keyPressEvent(event); +} + +void PickerField::focusOutEvent(QFocusEvent *event) +{ + // A menu or popup borrows the focus: the typing stays. + if (event->reason() != Qt::MenuBarFocusReason && event->reason() != Qt::PopupFocusReason) + m_commit(); + QLineEdit::focusOutEvent(event); +} + +ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent) + : QWidget(parent), m_session(session), + m_field(new SaturationBrightness(session, [this](QPointF point) { + pick([point](PickerHSB &hsb) { + hsb.saturation = std::clamp(point.x() / fieldSize, 0.0, 1.0); + hsb.brightness = 1 - std::clamp(point.y() / fieldSize, 0.0, 1.0); + }); + }, this)), + m_hue(new HueStrip(session, [this](QPointF point) { + pick([point](PickerHSB &hsb) { hsb.hue = (1 - std::clamp(point.y() / fieldSize, 0.0, 1.0)) * 360; }); + }, this)), + m_preview(new ColorPreview(session, this)), m_channels{channel(0), channel(1), channel(2)}, + m_hex(new PickerField([this] { commitHex(); }, nullptr, this)), + m_ok(new QPushButton(QStringLiteral("OK"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)) +{ + m_field->setObjectName(QStringLiteral("saturationBrightness")); + m_field->setFixedSize(int(fieldSize), int(fieldSize)); + m_field->setAccessibleName(QStringLiteral("Saturation and brightness")); + m_hue->setObjectName(QStringLiteral("hueStrip")); + m_hue->setFixedSize(34, int(fieldSize)); + m_hue->setAccessibleName(QStringLiteral("Hue")); + m_preview->setObjectName(QStringLiteral("newColor")); + m_preview->setFixedSize(64, 64); + m_preview->setAccessibleName(QStringLiteral("New color")); + m_ok->setObjectName(QStringLiteral("pickerOK")); + m_ok->setDefault(true); + m_ok->setFixedWidth(90); + m_cancel->setObjectName(QStringLiteral("pickerCancel")); + m_cancel->setAutoDefault(false); + m_cancel->setFixedWidth(90); + connect(m_ok, &QPushButton::clicked, this, [finish] { finish(true); }); + connect(m_cancel, &QPushButton::clicked, this, [finish] { finish(false); }); + auto *buttons = new QVBoxLayout; + buttons->setSpacing(8); + buttons->addWidget(m_ok); + buttons->addWidget(m_cancel); + buttons->addStretch(); + auto *top = new QHBoxLayout; + top->setSpacing(16); + top->addWidget(m_preview, 0, Qt::AlignTop); + top->addLayout(buttons); + auto *fields = new QGridLayout; + fields->setHorizontalSpacing(8); + fields->setVerticalSpacing(6); + for (int index = 0; index < 3; ++index) { + fields->addWidget(label(QString(QStringLiteral("RGB").at(index)), this), index, 0); + fields->addWidget(m_channels[size_t(index)], index, 1, Qt::AlignLeft); + } + m_hex->setObjectName(QStringLiteral("hex")); + m_hex->setPlaceholderText(QStringLiteral("Hex")); + m_hex->setAccessibleName(QStringLiteral("Hex color")); + m_hex->setFixedWidth(84); + m_hex->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + fields->addWidget(label(QStringLiteral("#"), this), 3, 0); + fields->addWidget(m_hex, 3, 1, Qt::AlignLeft); + auto *hint = new QLabel(QStringLiteral("Click the canvas to sample"), this); + QFont small = hint->font(); + small.setPixelSize(10); + hint->setFont(small); + hint->setForegroundRole(QPalette::PlaceholderText); + auto *column = new QVBoxLayout; + column->setSpacing(0); + column->addLayout(top); + column->addStretch(); + column->addSpacing(12); + column->addLayout(fields); + column->addSpacing(8); + column->addWidget(hint); + auto *side = new QWidget(this); + side->setFixedSize(180, int(fieldSize)); + side->setLayout(column); + column->setContentsMargins(0, 0, 0, 0); + auto *row = new QHBoxLayout(this); + row->setContentsMargins(20, 20, 20, 20); + row->setSpacing(14); + row->addWidget(m_field, 0, Qt::AlignTop); + row->addWidget(m_hue, 0, Qt::AlignTop); + row->addWidget(side, 0, Qt::AlignTop); + connect(&m_session, &EditorSession::changed, this, &ColorPickerSheet::synchronize); + synchronize(); +} + +PickerField *ColorPickerSheet::channel(int index) +{ + auto *field = new PickerField([this, index] { + // Typed numbers clamp, as the binding; overflows are 255. + PickerField &edited = *m_channels[size_t(index)]; + if (edited.isModified() && !edited.text().isEmpty()) { + bool fits = false; + const qlonglong typed = edited.text().toLongLong(&fits); + setChannel(index, fits ? double(typed) : 255); + } + synchronize(); + }, [this, index](int step) { + const PaletteColor color = m_session.colorPicker().value().color(); + const double value = std::round((index == 0 ? color.red : index == 1 ? color.green : color.blue) * 255); + setChannel(index, value + step); + m_channels[size_t(index)]->setModified(false); + synchronize(); + }, this); + field->setObjectName(QString(QStringLiteral("rgb").at(index))); + // Swift's title shows while empty; the label names the channel. + field->setPlaceholderText(QString(QStringLiteral("RGB").at(index))); + field->setAccessibleName(QStringList{QStringLiteral("Red"), QStringLiteral("Green"), QStringLiteral("Blue")}.at(index)); + field->setFixedWidth(52); + field->setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), field)); + return field; +} + +void ColorPickerSheet::change(const std::function<void(PickerHSB &)> &edit) +{ + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + PickerHSB hsb = picker->hsb; + edit(hsb); + m_session.setColorPickerHSB(hsb); +} + +void ColorPickerSheet::pick(const std::function<void(PickerHSB &)> &edit) +{ + change(edit); + for (PickerField *channel : m_channels) + channel->setModified(false); + synchronize(); +} + +// Swift's channel binding: the 8-bit colour, one channel set. +void ColorPickerSheet::setChannel(int channel, double value) +{ + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + PaletteColor rgb = picker->color(); + (channel == 0 ? rgb.red : channel == 1 ? rgb.green : rgb.blue) = std::clamp(value, 0.0, 255.0) / 255; + change([&rgb](PickerHSB &hsb) { hsb.setRGB(rgb); }); +} + +void ColorPickerSheet::commitHex() +{ + if (const std::optional<PaletteColor> parsed = PaletteColor::fromHex(m_hex->text())) + change([&parsed](PickerHSB &hsb) { hsb.setRGB(*parsed); }); + if (m_session.colorPicker()) + m_hex->setText(m_session.colorPicker()->color().hex()); +} + +void ColorPickerSheet::synchronize() +{ + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + const PaletteColor color = picker->color(); + const std::array<double, 3> values{color.red, color.green, color.blue}; + // An entry being typed in keeps its typing. + for (size_t index = 0; index < 3; ++index) { + if (!m_channels[index]->hasFocus() || !m_channels[index]->isModified()) + m_channels[index]->setText(QString::number(std::lround(values[index] * 255))); + } + // Swift's hex draft follows only while its field is unfocused. + if (!m_hex->hasFocus()) + m_hex->setText(color.hex()); + // Qt's plain widgets hold no value: the degrees describe it. + m_hue->setAccessibleDescription(QStringLiteral("%1 degrees").arg(std::lround(picker->hsb.hue))); + m_field->update(); + // The arrows reach five points past the strip. + update(m_hue->geometry().adjusted(0, -5, 0, 5)); + m_preview->update(); +} + +// The strip's arrows overflow it, as Swift's; widgets clip. +void ColorPickerSheet::paintEvent(QPaintEvent *) +{ + const std::optional<ColorPickerState> &picker = m_session.colorPicker(); + if (!picker) + return; + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF strip(m_hue->geometry()); + const double y = strip.top() + (1 - picker->hsb.hue / 360) * fieldSize; + QPainterPath arrows; + arrows.addPolygon(QPolygonF({QPointF(strip.left(), y - 5), QPointF(strip.left() + 7, y), QPointF(strip.left(), y + 5)})); + arrows.addPolygon(QPolygonF({QPointF(strip.right(), y - 5), QPointF(strip.right() - 7, y), QPointF(strip.right(), y + 5)})); + painter.fillPath(arrows, palette().color(QPalette::WindowText)); +} + +ColorPickerPanelController::ColorPickerPanelController(QWidget &owner) : m_panel(identifier(), owner) {} + +void ColorPickerPanelController::show(const ColorPickerState &state, EditorSession &session) +{ + // Its close button cancels; closing none does nothing. + m_panel.onClose = [&session] { session.closeColorPicker(false); }; + m_panel.show(state.target.title(), new ColorPickerSheet(session, [&session](bool commit) { session.closeColorPicker(commit); })); +} + +void ColorPickerPanelController::close() +{ + m_panel.close(); +} + +void ColorPickerPanelController::refocus() +{ + FloatingPanel::refocus(identifier()); +} diff --git a/src/UI/ColorPickerSheet.h b/src/UI/ColorPickerSheet.h new file mode 100644 index 0000000..4cd4d6f --- /dev/null +++ b/src/UI/ColorPickerSheet.h @@ -0,0 +1,68 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/FloatingPanel.h" +#include <QLineEdit> +#include <QPushButton> +#include <QWidget> +#include <array> +#include <functional> + +// A picker entry commits on Return or leaving, as Swift's. +class PickerField : public QLineEdit { + Q_OBJECT +public: + PickerField(std::function<void()> commit, std::function<void(int)> step, QWidget *parent); + +protected: + void keyPressEvent(QKeyEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + +private: + const std::function<void()> m_commit; + const std::function<void(int)> m_step; +}; + +// Swift's ColorPickerSheet: field, hue strip, preview, entry. +class ColorPickerSheet : public QWidget { + Q_OBJECT +public: + ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent = nullptr); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + // A channel's entry: typed, stepped, clamped, as Swift's row. + PickerField *channel(int index); + // Swift writes the working hsb; here through the session. + void change(const std::function<void(PickerHSB &)> &edit); + // The field or strip's pick replaces the channels' typing. + void pick(const std::function<void(PickerHSB &)> &edit); + void setChannel(int channel, double value); + void commitHex(); + void synchronize(); + + EditorSession &m_session; + QWidget *const m_field; + QWidget *const m_hue; + QWidget *const m_preview; + // Made first: keys start at R, as the grid reads. + const std::array<PickerField *, 3> m_channels; + PickerField *const m_hex; + QPushButton *const m_ok; + QPushButton *const m_cancel; +}; + +// Swift's ColorPickerPanelController: the picker in its panel. +class ColorPickerPanelController { +public: + explicit ColorPickerPanelController(QWidget &owner); + void show(const ColorPickerState &state, EditorSession &session); + void close(); + // Hands the keys back to the picker after a sample. + static void refocus(); + static QString identifier() { return QStringLiteral("colorPickerPanel"); } + +private: + FloatingPanel m_panel; +}; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp new file mode 100644 index 0000000..5674cbe --- /dev/null +++ b/src/UI/CompositorMenus.cpp @@ -0,0 +1,327 @@ +#include "UI/CompositorMenus.h" +#include "Logging.h" +#include <QApplication> +#include <QMenu> + +namespace { +// An entry's name: Hue/Saturation is newHueSaturationAdjustment. +QString adjustmentName(AdjustmentKind kind) +{ + return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/'))); +} + +// A filter's entry from Swift's title: gaussianBlur. +QString filterName(FilterKind kind) +{ + QString name = rawValue(kind); + name.remove(QLatin1Char(' ')); + name[0] = name[0].toLower(); + return name; +} +} + +CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWidget &window) + : QObject(&bar), m_workspace(workspace), m_window(window) +{ + QMenu *file = bar.addMenu(QStringLiteral("&File")); + add(file, QStringLiteral("newCanvas"), QStringLiteral("New Canvas…"), QKeySequence(Qt::CTRL | Qt::Key_N), [this] { projects().newCanvas(); }); + add(file, QStringLiteral("openProject"), QStringLiteral("Open Project…"), QKeySequence(Qt::CTRL | Qt::Key_O), [this] { projects().open(); }); + add(file, QStringLiteral("importImages"), QStringLiteral("Import Images…"), QKeySequence(), [this] { session().setShowsImporter(true); }); + file->addSeparator(); + add(file, QStringLiteral("save"), QStringLiteral("Save"), QKeySequence(Qt::CTRL | Qt::Key_S), [this] { projects().save(); }); + add(file, QStringLiteral("saveAs"), QStringLiteral("Save As…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_S), [this] { projects().save(true); }); + file->addSeparator(); + add(file, QStringLiteral("exportPNG"), QStringLiteral("Export PNG…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_E), [this] { projects().exportPNG(); }); + add(file, QStringLiteral("exportJPEG"), QStringLiteral("Export JPEG…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_S), + [this] { projects().exportJPEG(); }); + file->addSeparator(); + add(file, QStringLiteral("closeProject"), QStringLiteral("Close Project"), QKeySequence(Qt::CTRL | Qt::Key_W), [this] { projects().close(&m_window); }); + + QMenu *edit = bar.addMenu(QStringLiteral("&Edit")); + add(edit, QStringLiteral("undo"), QStringLiteral("Undo"), QKeySequence(Qt::CTRL | Qt::Key_Z), [this] { + if (m_field) + m_field->undo(); + else if (InlineTextEditor *text = typedText()) + text->undo(); + else + session().undo(); + }); + add(edit, QStringLiteral("redo"), QStringLiteral("Redo"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z), [this] { + if (m_field) + m_field->redo(); + else if (InlineTextEditor *text = typedText()) + text->redo(); + else + session().redo(); + }); + edit->addSeparator(); + // Cut, Copy and Paste check when chosen, as Swift's do. + add(edit, QStringLiteral("cut"), QStringLiteral("Cut"), QKeySequence(Qt::CTRL | Qt::Key_X), [this] { + if (m_field) + m_field->cut(); + else if (InlineTextEditor *text = typedText()) + text->cut(); + else if (session().selection() && session().canCopyPixels()) + session().cutSelection(); + else + qCWarning(lcApp) << "nothing to cut"; + }); + add(edit, QStringLiteral("copy"), QStringLiteral("Copy"), QKeySequence(Qt::CTRL | Qt::Key_C), [this] { + if (m_field) + m_field->copy(); + else if (InlineTextEditor *text = typedText()) + text->copy(); + else if (session().canCopyPixels()) + session().copySelection(); + else + qCWarning(lcApp) << "nothing to copy"; + }); + add(edit, QStringLiteral("copyMerged"), QStringLiteral("Copy Merged"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_C), [this] { session().copyMergedSelection(); }); + add(edit, QStringLiteral("paste"), QStringLiteral("Paste"), QKeySequence(Qt::CTRL | Qt::Key_V), [this] { + if (m_field) + m_field->paste(); + else if (InlineTextEditor *text = typedText()) + text->paste(); + else if (session().canPaste()) + session().paste(); + else + qCWarning(lcApp) << "nothing to paste"; + }); + edit->addSeparator(); + // Text fields delete the selection, else back from the caret. + add(edit, QStringLiteral("fillForeground"), QStringLiteral("Fill with Foreground Color"), QKeySequence(Qt::ALT | Qt::Key_Backspace), [this] { + if (!m_field) { + session().fillSelection(EditorSession::FillSource::foreground); + return; + } + if (!m_field->hasSelectedText()) + m_field->cursorWordBackward(true); + m_field->backspace(); + }); + add(edit, QStringLiteral("fillBackground"), QStringLiteral("Fill with Background Color"), QKeySequence(Qt::CTRL | Qt::Key_Backspace), [this] { + if (!m_field) { + session().fillSelection(EditorSession::FillSource::background); + return; + } + if (!m_field->hasSelectedText()) + m_field->home(true); + m_field->backspace(); + }); + add(edit, QStringLiteral("clearSelectionPixels"), QStringLiteral("Clear Selection Pixels"), QKeySequence(), [this] { session().clearSelectedPixels(); }); + add(edit, QStringLiteral("contentAwareFill"), QStringLiteral("Content-Aware Fill…"), QKeySequence(Qt::SHIFT | Qt::Key_Backspace), + [this] { session().beginFilter(FilterKind::contentAwareFill); }); + + QMenu *view = bar.addMenu(QStringLiteral("&View")); + add(view, QStringLiteral("fit"), QStringLiteral("Fit Canvas"), QKeySequence(Qt::CTRL | Qt::Key_0), [this] { session().fit(); }); + add(view, QStringLiteral("actualPixels"), QStringLiteral("Actual Pixels"), QKeySequence(Qt::CTRL | Qt::Key_1), [this] { session().zoom(1); }); + add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [this] { session().zoom(session().viewport.zoom() * 1.25); }); + add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [this] { session().zoom(session().viewport.zoom() / 1.25); }); + add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); }) + ->setCheckable(true); + // Ctrl+H: the Move tool's box; the desktop hides apps. + add(view, QStringLiteral("transformControls"), QStringLiteral("Show Transform Controls"), QKeySequence(Qt::CTRL | Qt::Key_H), + [this] { session().setShowsTransformControls(!session().showsTransformControls()); }) + ->setCheckable(true); + + QMenu *select = bar.addMenu(QStringLiteral("&Select")); + // Text fields keep their own Select All. + add(select, QStringLiteral("selectAll"), QStringLiteral("All"), QKeySequence(Qt::CTRL | Qt::Key_A), [this] { + if (m_field) + m_field->selectAll(); + else if (InlineTextEditor *text = typedText()) + text->selectAll(); + else + session().selectAll(); + }); + add(select, QStringLiteral("deselect"), QStringLiteral("Deselect"), QKeySequence(Qt::CTRL | Qt::Key_D), [this] { session().deselect(); }); + add(select, QStringLiteral("inverse"), QStringLiteral("Inverse"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_I), [this] { session().invertSelection(); }); + add(select, QStringLiteral("layerPixels"), QStringLiteral("Layer's Pixels"), QKeySequence(), [this] { + if (const std::optional<QUuid> id = session().activeLayerID()) + session().loadLayerSelection(*id); + }); + add(select, QStringLiteral("maskBlackAreas"), QStringLiteral("Mask's Black Areas"), QKeySequence(), [this] { + if (const std::optional<QUuid> id = session().activeLayerID()) + session().loadMaskSelection(*id); + }); + select->addSeparator(); + add(select, QStringLiteral("expandSelection"), QStringLiteral("Expand by 1 px"), QKeySequence(), [this] { session().expandSelection(session().selectionExpandAmount()); }); + add(select, QStringLiteral("contractSelection"), QStringLiteral("Contract by 1 px"), QKeySequence(), [this] { session().contractSelection(session().selectionContractAmount()); }); + + QMenu *image = bar.addMenu(QStringLiteral("&Image")); + add(image, QStringLiteral("curves"), QStringLiteral("Curves…"), QKeySequence(Qt::CTRL | Qt::Key_M), [this] { session().beginFilter(FilterKind::curves); }); + add(image, QStringLiteral("levels"), QStringLiteral("Levels…"), QKeySequence(Qt::CTRL | Qt::Key_L), [this] { session().beginLevels(); }); + add(image, QStringLiteral("hueSaturation"), QStringLiteral("Hue/Saturation…"), QKeySequence(Qt::CTRL | Qt::Key_U), + [this] { session().beginHueSaturation(); }); + for (const FilterKind kind : {FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain}) + add(image, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); }); + add(image, QStringLiteral("invert"), QStringLiteral("Invert"), QKeySequence(Qt::CTRL | Qt::Key_I), [this] { session().invertPixels(); }); + image->addSeparator(); + add(image, QStringLiteral("canvasSize"), QStringLiteral("Canvas Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C), [this] { projects().canvasSize(); }); + add(image, QStringLiteral("imageSize"), QStringLiteral("Image Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I), [this] { projects().imageSize(); }); + image->addSeparator(); + add(image, QStringLiteral("flipCanvasHorizontal"), QStringLiteral("Flip Canvas Horizontal"), QKeySequence(), [this] { session().flipCanvas(true); }); + add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); }); + + QMenu *filter = bar.addMenu(QStringLiteral("Fil&ter")); + for (const FilterKind kind : allFilterKinds) { + if (kind != FilterKind::contentAwareFill && !isImageAdjustment(kind)) + add(filter, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); }); + } + + QMenu *layer = bar.addMenu(QStringLiteral("&Layer")); + QMenu *adjustments = layer->addMenu(QStringLiteral("New Adjustment Layer")); + adjustments->menuAction()->setObjectName(QStringLiteral("newAdjustmentLayer")); + for (const AdjustmentKind kind : allAdjustmentKinds) + add(adjustments, adjustmentName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().addAdjustment(kind); }); + add(layer, QStringLiteral("editAdjustment"), QStringLiteral("Edit Adjustment…"), QKeySequence(), + [this] { session().setAdjustmentEditingID(session().activeLayerID()); }); + layer->addSeparator(); + // Swift's transformCommand: with selected pixels, Transform Selection. + add(layer, QStringLiteral("transformLayer"), QStringLiteral("Transform Layer"), QKeySequence(Qt::CTRL | Qt::Key_T), [this] { session().transformCommand(); }); + // Swift's layerViaCopy: a selection makes it Layer via Copy. + add(layer, QStringLiteral("layerViaCopy"), QStringLiteral("Duplicate Layer"), QKeySequence(Qt::CTRL | Qt::Key_J), [this] { session().layerViaCopy(); }); + layer->addSeparator(); + add(layer, QStringLiteral("clippingMask"), QStringLiteral("Create Clipping Mask"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_G), [this] { + if (const std::optional<QUuid> id = session().activeLayerID()) + session().toggleClippingMask(*id); + }); + layer->addSeparator(); + add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); }); + add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); }); + add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); }); + add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); }); + add(layer, QStringLiteral("layerVisibility"), QStringLiteral("Hide Layer"), QKeySequence(), [this] { + if (const std::optional<QUuid> id = session().activeLayerID()) + session().toggleLayerVisibility(*id); + }); + layer->addSeparator(); + add(layer, QStringLiteral("moveLayerUp"), QStringLiteral("Move Layer Up"), QKeySequence(Qt::CTRL | Qt::Key_BracketRight), [this] { session().moveActiveLayer(1); }); + add(layer, QStringLiteral("moveLayerDown"), QStringLiteral("Move Layer Down"), QKeySequence(Qt::CTRL | Qt::Key_BracketLeft), [this] { session().moveActiveLayer(-1); }); + add(layer, QStringLiteral("mergeLayers"), QStringLiteral("Merge Down"), QKeySequence(Qt::CTRL | Qt::Key_E), [this] { session().mergeLayers(); }); + layer->addSeparator(); + add(layer, QStringLiteral("flipHorizontal"), QStringLiteral("Flip Layer Horizontal"), QKeySequence(), [this] { session().flipLayers(true); }); + add(layer, QStringLiteral("flipVertical"), QStringLiteral("Flip Layer Vertical"), QKeySequence(), [this] { session().flipLayers(false); }); + layer->addSeparator(); + add(layer, QStringLiteral("deleteLayer"), QStringLiteral("Delete Layer"), QKeySequence(), [this] { session().deleteLayerOrMask(); }); + + connect(&m_workspace, &ProjectWorkspace::changed, this, &CompositorMenus::watchFront); + // A field gaining or losing focus changes Undo's meaning. + connect(qApp, &QApplication::focusChanged, this, &CompositorMenus::focusMoved); + watchFront(); +} + +QAction *CompositorMenus::add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run) +{ + QAction *made = menu->addAction(text); + made->setObjectName(name); + made->setShortcut(shortcut); + connect(made, &QAction::triggered, this, run); + return made; +} + +QAction *CompositorMenus::action(const QString &name) const +{ + return parent()->findChild<QAction *>(name); +} + +void CompositorMenus::watchFront() +{ + // The front tab's session drives every entry. + if (m_watchedTab != std::optional(m_workspace.current().id)) { + m_watchedTab = m_workspace.current().id; + disconnect(m_sessionWatch); + m_sessionWatch = connect(&session(), &EditorSession::changed, this, &CompositorMenus::synchronize); + } + synchronize(); +} + +void CompositorMenus::focusMoved(QWidget *, QWidget *to) +{ + // The menu bar borrows focus; the field keeps Undo. + if (qobject_cast<QMenuBar *>(to) == nullptr) { + m_field = qobject_cast<QLineEdit *>(to); + m_canvas = qobject_cast<CanvasView *>(to); + } + synchronize(); +} + +void CompositorMenus::synchronize() +{ + EditorSession &s = session(); + ProjectController &p = projects(); + const std::optional<ImageLayer> active = s.activeLayer(); + const bool drawn = s.document().has_value(); + // Swift's text branch: open text makes the entries bare. + const bool typing = m_field != nullptr || s.textDraft().has_value(); + action(QStringLiteral("newCanvas"))->setEnabled(p.canStart()); + action(QStringLiteral("openProject"))->setEnabled(p.canStart()); + action(QStringLiteral("importImages"))->setEnabled(!s.levels() && !s.showsBusy() && !s.isImporting() && !s.showsNewDocument()); + action(QStringLiteral("save"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("saveAs"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("closeProject"))->setEnabled(p.canStart()); + // In a field, Undo is the field's and bare. + action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(s.history.undoName()) : QStringLiteral("Undo")); + action(QStringLiteral("undo"))->setEnabled(typing || s.canUndo()); + action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(s.history.redoName()) : QStringLiteral("Redo")); + action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo()); + for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"}) + action(QString::fromLatin1(name))->setEnabled(drawn); + action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid()); + action(QStringLiteral("transformControls"))->setChecked(s.showsTransformControls()); + action(QStringLiteral("transformControls"))->setEnabled(s.tool() == NavigationTool::move && s.document().has_value()); + const bool selected = s.selection().has_value(); + action(QStringLiteral("selectAll"))->setEnabled(drawn); + action(QStringLiteral("deselect"))->setEnabled(selected && s.canEditSelection()); + action(QStringLiteral("inverse"))->setEnabled(selected && s.canEditSelection()); + action(QStringLiteral("layerPixels"))->setEnabled(active && active->asset && s.canEditSelection()); + action(QStringLiteral("maskBlackAreas"))->setEnabled(active && active->mask && s.canEditSelection()); + action(QStringLiteral("expandSelection"))->setText(QStringLiteral("Expand by %1 px").arg(s.selectionExpandAmount())); + action(QStringLiteral("expandSelection"))->setEnabled(s.canModifySelection()); + action(QStringLiteral("contractSelection"))->setText(QStringLiteral("Contract by %1 px").arg(s.selectionContractAmount())); + action(QStringLiteral("contractSelection"))->setEnabled(s.canModifySelection()); + action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation()); + for (const FilterKind kind : allFilterKinds) { + if (kind != FilterKind::contentAwareFill) + action(filterName(kind))->setEnabled(s.canAdjustColors() && !s.hueSaturation()); + } + action(QStringLiteral("hueSaturation"))->setEnabled(s.canAdjustColors()); + action(QStringLiteral("invert"))->setText(s.isMaskSelected() ? QStringLiteral("Invert Mask") : QStringLiteral("Invert")); + action(QStringLiteral("invert"))->setEnabled(s.canInvert()); + action(QStringLiteral("canvasSize"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("imageSize"))->setEnabled(drawn && p.canStart()); + action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment); + action(QStringLiteral("transformLayer"))->setText(s.canTransformSelection() ? QStringLiteral("Transform Selection") : QStringLiteral("Transform Layer")); + action(QStringLiteral("transformLayer"))->setEnabled(s.canTransform() || s.canTransformSelection()); + action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask")); + action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID())); + action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID); + action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active); + action(QStringLiteral("layerVisibility"))->setText(active && !active->isVisible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer")); + action(QStringLiteral("layerVisibility"))->setEnabled(s.canEditLayers() && active); + action(QStringLiteral("moveLayerUp"))->setEnabled(s.canMoveActiveLayer(1)); + action(QStringLiteral("moveLayerDown"))->setEnabled(s.canMoveActiveLayer(-1)); + action(QStringLiteral("copyMerged"))->setEnabled(s.canCopyMerged()); + action(QStringLiteral("contentAwareFill"))->setEnabled(s.canContentAwareFill()); + action(QStringLiteral("fillForeground"))->setEnabled(s.canEditPixels()); + action(QStringLiteral("fillBackground"))->setEnabled(s.canEditPixels()); + action(QStringLiteral("clearSelectionPixels"))->setEnabled(s.selection().has_value() && s.canEditPixels()); + action(QStringLiteral("layerViaCopy"))->setText(s.selection() ? QStringLiteral("Layer via Copy") : QStringLiteral("Duplicate Layer")); + action(QStringLiteral("layerViaCopy"))->setEnabled(s.canCopyPixels() || (!s.selection() && s.canEditLayers() && active && !active->isGroup)); + action(QStringLiteral("mergeLayers"))->setText(s.mergeTitle()); + action(QStringLiteral("mergeLayers"))->setEnabled(s.canMergeLayers()); + action(QStringLiteral("flipHorizontal"))->setEnabled(s.canTransform()); + action(QStringLiteral("flipVertical"))->setEnabled(s.canTransform()); + const std::optional<LayerEffectSelection> effect = s.selectedEffect(); + action(QStringLiteral("deleteLayer"))->setText(effect ? QStringLiteral("Delete ") + rawValue(effect->kind) + : s.isMaskSelected() && active && active->mask ? QStringLiteral("Delete Layer Mask") + : s.selectedLayerIDs().size() > 1 ? QStringLiteral("Delete Layers") + : QStringLiteral("Delete Layer")); + action(QStringLiteral("deleteLayer"))->setEnabled(s.canEditLayers() && active); +} diff --git a/src/UI/CompositorMenus.h b/src/UI/CompositorMenus.h new file mode 100644 index 0000000..b26068a --- /dev/null +++ b/src/UI/CompositorMenus.h @@ -0,0 +1,34 @@ +#pragma once +#include "Document/ProjectWorkspace.h" +#include "Rendering/EditorCanvas.h" +#include <QAction> +#include <QLineEdit> +#include <QMenuBar> +#include <QPointer> + +// The menu bar: Swift's commands whose session functions exist. +class CompositorMenus : public QObject { + Q_OBJECT +public: + CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWidget &window); + + QAction *action(const QString &name) const; + +private: + void synchronize(); + void focusMoved(QWidget *from, QWidget *to); + // The open text in the focused canvas, Swift's text view. + InlineTextEditor *typedText() const { return m_canvas ? m_canvas->inlineTextEditor() : nullptr; } + void watchFront(); + EditorSession &session() const { return m_workspace.current().session; } + ProjectController &projects() const { return m_workspace.current().controller; } + QAction *add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run); + + ProjectWorkspace &m_workspace; + QWidget &m_window; + QMetaObject::Connection m_sessionWatch; + std::optional<QUuid> m_watchedTab; + // The field whose undo the Edit entries drive. + QPointer<QLineEdit> m_field; + QPointer<CanvasView> m_canvas; +}; diff --git a/src/UI/CropControls.cpp b/src/UI/CropControls.cpp new file mode 100644 index 0000000..2213a74 --- /dev/null +++ b/src/UI/CropControls.cpp @@ -0,0 +1,57 @@ +#include "UI/CropControls.h" +#include "Document/EditorSession.h" +#include <QComboBox> +#include <QLocale> +#include <QPushButton> + +CropControls::CropControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QStringLiteral("Crop"), parent), m_session(session), m_ratio(new QComboBox(this)), m_size(new QLabel(this)), + m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply Crop"), this)) +{ + row->setSpacing(14); + // Swift's labelled picker: label and menu share 170 points. + auto *picker = new QWidget(this); + picker->setFixedWidth(170); + auto *pickerRow = new QHBoxLayout(picker); + pickerRow->setContentsMargins(0, 0, 0, 0); + auto *label = new QLabel(QStringLiteral("Ratio"), picker); + label->setBuddy(m_ratio); + pickerRow->addWidget(label); + pickerRow->addWidget(m_ratio, 1); + m_ratio->setObjectName(QStringLiteral("cropRatio")); + for (const QString &choice : {QStringLiteral("Free"), QStringLiteral("Original"), QStringLiteral("1:1"), QStringLiteral("4:3"), QStringLiteral("16:9")}) + m_ratio->addItem(choice); + // Swift's onChange: the frame takes a ratio that changed. + connect(m_ratio, &QComboBox::activated, this, [this](int index) { + if (m_ratio->itemText(index) == m_session.cropRatioChoice()) + return; + m_session.setCropRatioChoice(m_ratio->itemText(index)); + m_session.changeCropRatio(); + }); + m_size->setObjectName(QStringLiteral("cropSize")); + m_cancel->setObjectName(QStringLiteral("cropCancel")); + m_apply->setObjectName(QStringLiteral("cropApply")); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelCrop(); }); + connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitCrop(); }); + row->insertWidget(row->count() - 1, picker); + row->insertWidget(row->count() - 1, m_size); + row->addWidget(m_cancel); + row->addWidget(m_apply); + connect(&m_session, &EditorSession::changed, this, &CropControls::synchronize); + synchronize(); +} + +void CropControls::synchronize() +{ + m_ratio->setCurrentText(m_session.cropRatioChoice()); + const std::optional<QRectF> &rect = m_session.cropRect(); + // Whole pixels, grouped as the status bar prints them. + if (rect) { + const QLocale english(QLocale::English, QLocale::UnitedStates); + m_size->setText(QStringLiteral("%1 × %2 px").arg(english.toString(qint64(rect->width())), english.toString(qint64(rect->height())))); + } + m_size->setVisible(rect.has_value()); + m_cancel->setEnabled(rect.has_value()); + m_apply->setEnabled(rect.has_value()); + setEnabled(!m_session.showsBusy() && m_session.document().has_value()); +} diff --git a/src/UI/CropControls.h b/src/UI/CropControls.h new file mode 100644 index 0000000..8de7052 --- /dev/null +++ b/src/UI/CropControls.h @@ -0,0 +1,22 @@ +#pragma once +#include "UI/ToolHeaderStyle.h" + +class EditorSession; +class QComboBox; +class QPushButton; + +// Swift's CropControls: the ratio, the frame's size, cancel and apply. +class CropControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit CropControls(EditorSession &session, QWidget *parent = nullptr); + +private: + void synchronize(); + + EditorSession &m_session; + QComboBox *const m_ratio; + QLabel *const m_size; + QPushButton *const m_cancel; + QPushButton *const m_apply; +}; diff --git a/src/UI/CurvesControls.cpp b/src/UI/CurvesControls.cpp new file mode 100644 index 0000000..ddd8354 --- /dev/null +++ b/src/UI/CurvesControls.cpp @@ -0,0 +1,181 @@ +#include "UI/CurvesControls.h" +#include <QHBoxLayout> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QVBoxLayout> +#include <algorithm> +#include <cmath> + +// The curve over its grid; presses and drags edit points. +class CurveGraph : public QWidget { +public: + explicit CurveGraph(CurvesControls &controls) : QWidget(&controls), m_controls(controls) { setFixedHeight(260); } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(rect(), QColor(0, 0, 0, 89)); + const CurvesSettings settings = m_controls.m_value(); + const auto position = [&](double x, double y) { return QPointF(x / 255 * width(), (1 - y / 255) * height()); }; + QPainterPath grid; + for (int index = 0; index <= 4; ++index) { + const double f = index / 4.0; + grid.moveTo(f * width(), 0); + grid.lineTo(f * width(), height()); + grid.moveTo(0, f * height()); + grid.lineTo(width(), f * height()); + } + painter.setRenderHint(QPainter::Antialiasing); + painter.strokePath(grid, QPen(QColor(255, 255, 255, 31), 1)); + QPainterPath line; + for (int x = 0; x <= 255; ++x) { + const QPointF point = position(x, settings.value(x, size_t(settings.channel))); + if (x == 0) + line.moveTo(point); + else + line.lineTo(point); + } + painter.strokePath(line, QPen(Qt::white, 2)); + const std::vector<CurvePoint> &points = settings.channels.at(size_t(settings.channel)); + for (size_t index = 0; index < points.size(); ++index) { + const QPointF at = position(points[index].x, points[index].y); + QPainterPath dot; + dot.addEllipse(QRectF(at.x() - 4, at.y() - 4, 8, 8)); + painter.fillPath(dot, m_controls.m_selected == index ? palette().color(QPalette::Highlight) : QColor(Qt::white)); + } + } + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_controls.drag(event->position(), size()); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (event->buttons().testFlag(Qt::LeftButton)) + m_controls.drag(event->position(), size()); + } + void mouseReleaseEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_controls.m_dragging.reset(); + } + +private: + CurvesControls &m_controls; +}; + +CurvesControls::CurvesControls(std::function<CurvesSettings()> value, std::function<void(const CurvesSettings &)> change, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), m_channel(new QComboBox(this)), m_graph(new CurveGraph(*this)), + m_readout(new QLabel(this)), m_remove(new QPushButton(QStringLiteral("Remove point"), this)), + m_reset(new QPushButton(QStringLiteral("Reset curve"), this)) +{ + for (const LevelsChannel channel : allLevelsChannels) + m_channel->addItem(rawValue(channel)); + connect(m_channel, &QComboBox::activated, this, [this](int index) { + CurvesSettings settings = m_value(); + settings.channel = allLevelsChannels[size_t(index)]; + m_change(settings); + synchronize(); + }); + m_graph->setObjectName(QStringLiteral("curvesGraph")); + m_readout->setObjectName(QStringLiteral("curvesReadout")); + auto *title = new QLabel(QStringLiteral("Channel"), this); + title->setBuddy(m_channel); + auto *picker = new QHBoxLayout; + picker->addWidget(title); + picker->addWidget(m_channel, 1); + auto *hint = new QLabel(QStringLiteral("Click to add a point. Drag to adjust."), this); + QFont small = hint->font(); + small.setPixelSize(10); + hint->setFont(small); + hint->setForegroundRole(QPalette::PlaceholderText); + m_remove->setAutoDefault(false); + m_reset->setAutoDefault(false); + connect(m_remove, &QPushButton::clicked, this, [this] { + std::vector<CurvePoint> points = this->points(); + // Swift's guard: an inner point that is still there. + if (!m_selected || *m_selected == 0 || *m_selected >= points.size() - 1) + return; + points.erase(points.begin() + qsizetype(*m_selected)); + m_selected.reset(); + setPoints(points); + }); + connect(m_reset, &QPushButton::clicked, this, [this] { + m_selected.reset(); + setPoints({{0, 0}, {255, 255}}); + }); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(12); + column->addLayout(picker); + column->addWidget(m_graph); + column->addWidget(hint); + auto *selection = new QHBoxLayout; + selection->addWidget(m_readout); + selection->addStretch(1); + selection->addWidget(m_remove); + column->addLayout(selection); + column->addWidget(m_reset, 0, Qt::AlignLeft); + synchronize(); +} + +std::vector<CurvePoint> CurvesControls::points() const +{ + const CurvesSettings settings = m_value(); + return settings.channels.at(size_t(settings.channel)); +} + +void CurvesControls::setPoints(const std::vector<CurvePoint> &points) +{ + CurvesSettings settings = m_value(); + settings.channels.at(size_t(settings.channel)) = points; + m_change(settings); + synchronize(); +} + +void CurvesControls::drag(QPointF at, QSizeF size) +{ + const double x = std::min(255.0, std::max(0.0, at.x() / size.width() * 255)); + const double y = std::min(255.0, std::max(0.0, 255 - at.y() / size.height() * 255)); + std::vector<CurvePoint> p = points(); + if (!m_dragging) { + const auto distance = [&](const CurvePoint &point) { return std::hypot(point.x - x, point.y - y); }; + const auto nearest = std::min_element(p.begin(), p.end(), [&](const CurvePoint &a, const CurvePoint &b) { return distance(a) < distance(b); }); + if (distance(*nearest) < 14) { + m_dragging = size_t(nearest - p.begin()); + } else if (p.size() < 32 && x > 1 && x < 254 && std::all_of(p.begin(), p.end(), [x](const CurvePoint &point) { return std::abs(point.x - x) > 1; })) { + p.push_back({x, y}); + std::sort(p.begin(), p.end(), [](const CurvePoint &a, const CurvePoint &b) { return a.x < b.x; }); + m_dragging = size_t(std::find_if(p.begin(), p.end(), [x](const CurvePoint &point) { return point.x == x; }) - p.begin()); + } + } + if (!m_dragging || *m_dragging >= p.size()) + return; + const size_t i = *m_dragging; + m_selected = i; + p[i].y = y; + if (i > 0 && i < p.size() - 1) + p[i].x = std::min(p[i + 1].x - 1, std::max(p[i - 1].x + 1, x)); + setPoints(p); +} + +void CurvesControls::synchronize() +{ + const CurvesSettings settings = m_value(); + // Another channel drops the selection, as Swift's onChange. + if (m_shownChannel != settings.channel) { + m_shownChannel = settings.channel; + m_selected.reset(); + m_dragging.reset(); + } + m_channel->setCurrentIndex(int(settings.channel)); + const std::vector<CurvePoint> &points = settings.channels.at(size_t(settings.channel)); + const bool shown = m_selected && *m_selected < points.size(); + m_readout->setVisible(shown); + if (shown) + m_readout->setText(QStringLiteral("Input %1 · Output %2").arg(int(points[*m_selected].x)).arg(int(points[*m_selected].y))); + m_remove->setEnabled(m_selected && *m_selected != 0 && *m_selected != points.size() - 1); + m_graph->update(); +} diff --git a/src/UI/CurvesControls.h b/src/UI/CurvesControls.h new file mode 100644 index 0000000..ab9c1a4 --- /dev/null +++ b/src/UI/CurvesControls.h @@ -0,0 +1,36 @@ +#pragma once +#include "Document/Curves.h" +#include <QComboBox> +#include <QLabel> +#include <QPushButton> +#include <QWidget> +#include <functional> +#include <optional> + +// Swift's CurvesControls: a channel's curve, its points and buttons. +class CurvesControls : public QWidget { + Q_OBJECT +public: + // Swift's binding: the settings read, and written back. + CurvesControls(std::function<CurvesSettings()> value, std::function<void(const CurvesSettings &)> change, QWidget *parent = nullptr); + // Shows the settings as they now stand. + void synchronize(); + +private: + friend class CurveGraph; + std::vector<CurvePoint> points() const; + void setPoints(const std::vector<CurvePoint> &points); + // Swift's drag: take a near point or add one. + void drag(QPointF at, QSizeF size); + + const std::function<CurvesSettings()> m_value; + const std::function<void(const CurvesSettings &)> m_change; + std::optional<size_t> m_selected; + std::optional<size_t> m_dragging; + std::optional<LevelsChannel> m_shownChannel; + QComboBox *const m_channel; + QWidget *const m_graph; + QLabel *const m_readout; + QPushButton *const m_remove; + QPushButton *const m_reset; +}; diff --git a/src/UI/EffectsSheet.cpp b/src/UI/EffectsSheet.cpp new file mode 100644 index 0000000..cbee55e --- /dev/null +++ b/src/UI/EffectsSheet.cpp @@ -0,0 +1,256 @@ +#include "UI/EffectsSheet.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet.h" +#include <QButtonGroup> +#include <QHBoxLayout> +#include <QLabel> +#include <QLocale> +#include <QPushButton> +#include <QSignalBlocker> +#include <QSlider> +#include <QToolButton> +#include <QVBoxLayout> +#include <cmath> + +namespace { +// A thousand steps across each slider's range. +constexpr int travel = 1000; + +// Swift's .number with no decimals, ties to even, never grouped. +QString shown(double value, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + return locale.toString(std::nearbyint(value), 'f', 0); +} + +QLabel *words(const QString &text, bool headline, QWidget *parent) +{ + auto *label = new QLabel(text, parent); + if (headline) { + QFont font = label->font(); + font.setPixelSize(13); + font.setWeight(QFont::DemiBold); + label->setFont(font); + } + return label; +} + +std::optional<double> percent(const auto &effect) +{ + return effect ? std::optional(effect->opacity * 100) : std::nullopt; +} +} + +EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent) + : QWidget(parent), m_session(session), m_kind(kind), m_column(new QVBoxLayout(this)) +{ + setObjectName(QStringLiteral("effectsSheet")); + setFixedWidth(340); + m_column->setContentsMargins(20, 20, 20, 20); + m_column->setSpacing(16); + switch (kind) { + case LayerEffectKind::stroke: + header(QStringLiteral("Stroke"), true); + colour(true); + slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.stroke ? std::optional(e.stroke->size) : std::nullopt; }, + [](LayerEffects &e, double value) { e.stroke->size = value; }, 1, StrokeEffect::maxSize, QStringLiteral("px")); + slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.stroke); }, + [](LayerEffects &e, double value) { e.stroke->opacity = value / 100; }, 0, 100, QStringLiteral("%")); + break; + case LayerEffectKind::shadow: + header(QStringLiteral("Drop Shadow"), false); + slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.shadow); }, + [](LayerEffects &e, double value) { e.shadow->opacity = value / 100; }, 0, 100, QStringLiteral("%")); + slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->angle) : std::nullopt; }, + [](LayerEffects &e, double value) { e.shadow->angle = value; }, -180, 180, QStringLiteral("°")); + slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->distance) : std::nullopt; }, + [](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 300, QStringLiteral("px")); + slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->blur) : std::nullopt; }, + [](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 300, QStringLiteral("px")); + break; + case LayerEffectKind::colorOverlay: + header(QStringLiteral("Color Overlay"), false); + slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.colorOverlay); }, + [](LayerEffects &e, double value) { e.colorOverlay->opacity = value / 100; }, 0, 100, QStringLiteral("%")); + break; + case LayerEffectKind::innerShadow: + header(QStringLiteral("Inner Shadow"), false); + slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.innerShadow); }, + [](LayerEffects &e, double value) { e.innerShadow->opacity = value / 100; }, 0, 100, QStringLiteral("%")); + slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->angle) : std::nullopt; }, + [](LayerEffects &e, double value) { e.innerShadow->angle = value; }, -180, 180, QStringLiteral("°")); + slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->distance) : std::nullopt; }, + [](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 300, QStringLiteral("px")); + slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->blur) : std::nullopt; }, + [](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 300, QStringLiteral("px")); + break; + } + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("effectsCancel")); + cancel->setAutoDefault(false); + auto *ok = new QPushButton(QStringLiteral("OK"), this); + ok->setObjectName(QStringLiteral("effectsOK")); + ok->setDefault(true); + connect(cancel, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(false); }); + connect(ok, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(true); }); + auto *buttons = new QHBoxLayout; + buttons->setSpacing(10); + buttons->addStretch(1); + buttons->addWidget(cancel); + buttons->addWidget(ok); + m_column->addLayout(buttons); + connect(&m_session, &EditorSession::changed, this, &EffectsSheet::synchronize); + synchronize(); +} + +// The kind's headline, then the stroke's position or the swatch. +void EffectsSheet::header(const QString &title, bool position) +{ + auto *row = new QHBoxLayout; + row->addWidget(words(title, true, this)); + row->addStretch(1); + if (!position) { + colour(false); + row->addWidget(m_controls.back()); + } else { + auto *choice = new QWidget(this); + choice->setObjectName(QStringLiteral("strokePosition")); + choice->setAccessibleName(QStringLiteral("Position")); + auto *buttons = new QHBoxLayout(choice); + buttons->setContentsMargins(0, 0, 0, 0); + buttons->setSpacing(0); + m_position = new QButtonGroup(choice); + for (const int inside : {0, 1}) { + auto *button = new QToolButton(choice); + button->setText(inside ? QStringLiteral("Inside") : QStringLiteral("Outside")); + button->setCheckable(true); + m_position->addButton(button, inside); + buttons->addWidget(button); + } + connect(m_position, &QButtonGroup::idClicked, this, [this](int inside) { + m_session.changeEffects([inside](LayerEffects &effects) { effects.stroke->inside = inside == 1; }); + }); + row->addWidget(choice); + m_controls.push_back(choice); + } + m_column->addLayout(row); +} + +// The effect's colour, opened in the app's own picker. +void EffectsSheet::colour(bool labelled) +{ + auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 0, 0.5, this); + swatch->setObjectName(QStringLiteral("effectColor")); + swatch->setFixedSize(36, 18); + swatch->setToolTip(rawValue(m_kind) + QStringLiteral(" color")); + swatch->setAccessibleName(rawValue(m_kind) + QStringLiteral(" color")); + connect(swatch, &QAbstractButton::clicked, this, [this] { m_session.openEffectColorPicker(m_kind); }); + if (!labelled) { + m_controls.push_back(swatch); + return; + } + auto *row = new QWidget(this); + auto *layout = new QHBoxLayout(row); + layout->setContentsMargins(0, 0, 0, 0); + auto *label = words(QStringLiteral("Color"), false, row); + label->setFixedWidth(64); + layout->addWidget(label); + layout->addWidget(swatch); + layout->addStretch(1); + m_column->addWidget(row); + m_controls.push_back(row); +} + +void EffectsSheet::slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value, + std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit) +{ + const size_t index = m_sliders.size(); + const QString name = title.toLower(); + auto *row = new QWidget(this); + auto *layout = new QHBoxLayout(row); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(10); + auto *label = words(title, false, row); + label->setFixedWidth(64); + auto *slider = new QSlider(Qt::Horizontal, row); + slider->setObjectName(name + QStringLiteral("Slider")); + slider->setRange(0, travel); + slider->setFixedWidth(130); + label->setBuddy(slider); + connect(slider, &QSlider::valueChanged, this, [this, index](int position) { + const Slider &at = m_sliders[index]; + apply(at, at.low + (at.high - at.low) * position / travel); + // Its value replaces the field's typing, as a step does. + at.field->setModified(false); + synchronize(); + }); + // A readable number applies on Return or leaving; else reverts. + auto *field = new PickerField([this, index] { + PickerField &edited = *m_sliders[index].field; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number && std::isfinite(typed)) + apply(m_sliders[index], typed); + edited.setModified(false); + synchronize(); + }, [this, index](int step) { + const Slider &at = m_sliders[index]; + if (const std::optional<double> current = at.value(m_session.editingEffects())) + apply(at, *current + step); + // The step replaces any typing, clamped or not. + at.field->setModified(false); + synchronize(); + }, row); + field->setObjectName(name + QStringLiteral("Field")); + field->setAccessibleName(title); + field->setPlaceholderText(title); + field->setAlignment(Qt::AlignRight); + field->setFixedWidth(48); + auto *suffixed = new QHBoxLayout; + suffixed->setSpacing(2); + suffixed->addWidget(field); + suffixed->addWidget(new QLabel(unit, row)); + layout->addWidget(label); + layout->addWidget(slider); + layout->addLayout(suffixed); + m_column->addWidget(row); + m_controls.push_back(row); + m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, slider, field}); +} + +void EffectsSheet::apply(const Slider &control, double value) +{ + const double clamped = std::clamp(value, control.low, control.high); + m_session.changeEffects([&control, clamped](LayerEffects &effects) { control.change(effects, clamped); }); +} + +void EffectsSheet::synchronize() +{ + // Swift's onChange: the open picker previews on the layer. + const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt; + if (picked != m_pickerColour) { + m_pickerColour = picked; + m_session.previewEffectColor(); + } + const LayerEffects effects = m_session.editingEffects(); + for (QWidget *control : m_controls) { + control->setVisible(effects.contains(m_kind)); + control->update(); + } + if (m_position && effects.stroke) + m_position->button(effects.stroke->inside ? 1 : 0)->setChecked(true); + for (const Slider &control : m_sliders) { + const std::optional<double> value = control.value(effects); + if (!value) + continue; + { + // The slider shows the session's number without writing back. + const QSignalBlocker quiet(control.slider); + control.slider->setValue(int(std::lround((*value - control.low) / (control.high - control.low) * travel))); + } + // A field being typed in keeps its typing. + const QString text = shown(*value, control.field->locale()); + if (!control.field->isModified() && control.field->text() != text) + control.field->setText(text); + } +} diff --git a/src/UI/EffectsSheet.h b/src/UI/EffectsSheet.h new file mode 100644 index 0000000..45434f9 --- /dev/null +++ b/src/UI/EffectsSheet.h @@ -0,0 +1,45 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QWidget> +#include <functional> +#include <vector> + +class PickerField; +class QButtonGroup; +class QSlider; +class QVBoxLayout; + +// Swift's EffectsSheet: one effect's controls, bound to its panel's layer. +class EffectsSheet : public QWidget { + Q_OBJECT +public: + EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent = nullptr); + +private: + // Swift's slider: a title, a slider, a whole-number field. + struct Slider { + std::function<std::optional<double>(const LayerEffects &)> value; + std::function<void(LayerEffects &, double)> change; + double low; + double high; + QSlider *slider; + PickerField *field; + }; + void header(const QString &title, bool position); + void colour(bool labelled); + void slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value, + std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit); + // A value clamped to the slider's range, as Swift's binding. + void apply(const Slider &control, double value); + void synchronize(); + + EditorSession &m_session; + const LayerEffectKind m_kind; + QVBoxLayout *const m_column; + // What shows only while the effect is on its layer. + std::vector<QWidget *> m_controls; + std::vector<Slider> m_sliders; + QButtonGroup *m_position = nullptr; + // The picker's colour as last seen, Swift's onChange. + std::optional<PaletteColor> m_pickerColour; +}; diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp new file mode 100644 index 0000000..a975615 --- /dev/null +++ b/src/UI/FilterSheet.cpp @@ -0,0 +1,423 @@ +#include "UI/FilterSheet.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet.h" +#include "UI/CurvesControls.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QFrame> +#include <QHBoxLayout> +#include <QLabel> +#include <QPainter> +#include <QPainterPath> +#include <QProgressBar> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> +#include <QVBoxLayout> +#include <cmath> + +namespace { +// A slider's travel: thousandths of its range. +constexpr int travel = 1000; + +QLabel *words(const QString &text, bool callout, QWidget *parent) +{ + auto *label = new QLabel(text, parent); + label->setWordWrap(true); + label->setTextFormat(Qt::PlainText); + label->setObjectName(QStringLiteral("filterWords")); + if (callout) { + QFont font = label->font(); + font.setPixelSize(12); + label->setFont(font); + label->setForegroundRole(QPalette::PlaceholderText); + } + return label; +} + +// Swift's `.number`: at most `decimals` places, none trailing. +QString shown(double value, int decimals, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString text = locale.toString(value, 'f', decimals); + if (decimals == 0) + return text; + while (text.endsWith(locale.zeroDigit())) + text.chop(locale.zeroDigit().size()); + if (text.endsWith(locale.decimalPoint())) + text.chop(locale.decimalPoint().size()); + return text; +} + +QColor colour(const AdjustmentColor &value) +{ + return QColor::fromRgbF(float(value.red), float(value.green), float(value.blue)); +} + +// Swift's LinearGradient from the dark end to the light. +class GradientBar : public QWidget { +public: + GradientBar(std::function<GradientMapSettings()> value, QWidget *parent) : QWidget(parent), m_value(std::move(value)) + { + setFixedHeight(20); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF bounds(rect()); + const GradientMapSettings::Ends ends = m_value().ends(); + QLinearGradient gradient(bounds.topLeft(), bounds.topRight()); + gradient.setColorAt(0, colour(ends.dark)); + gradient.setColorAt(1, colour(ends.light)); + QPainterPath shape; + shape.addRoundedRect(bounds, 4, 4); + painter.fillPath(shape, gradient); + painter.setPen(QPen(QColor::fromRgbF(0, 0, 0, 0.35f), 1)); + painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), 3.5, 3.5); + } + +private: + const std::function<GradientMapSettings()> m_value; +}; +} + +GradientMapControls::GradientMapControls(std::function<GradientMapSettings()> value, std::function<void(const GradientMapSettings &)> change, + std::function<void(bool)> pick, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), m_gradient(new GradientBar(m_value, this)), + m_reverse(new QCheckBox(QStringLiteral("Reverse"), this)) +{ + m_gradient->setObjectName(QStringLiteral("gradientMapBar")); + m_reverse->setObjectName(QStringLiteral("gradientMapReverse")); + connect(m_reverse, &QCheckBox::clicked, this, [this](bool on) { + GradientMapSettings settings = m_value(); + settings.reversed = on; + m_change(settings); + }); + auto *ends = new QHBoxLayout; + ends->setSpacing(20); + ends->addWidget(swatch(QStringLiteral("Shadows"), false, pick)); + ends->addWidget(swatch(QStringLiteral("Highlights"), true, pick)); + ends->addStretch(1); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(12); + column->addWidget(m_gradient); + column->addLayout(ends); + column->addWidget(m_reverse); +} + +QWidget *GradientMapControls::swatch(const QString &title, bool highlights, const std::function<void(bool)> &pick) +{ + auto *box = new QWidget(this); + auto *button = new SwatchButton([this, highlights] { + const GradientMapSettings settings = m_value(); + const AdjustmentColor &end = highlights ? settings.highlights : settings.shadows; + return PaletteColor{end.red, end.green, end.blue}; + }, 6, 1.5, 1, box); + button->setObjectName(title.toLower() + QStringLiteral("Swatch")); + button->setFixedSize(24, 24); + button->setToolTip(QStringLiteral("Choose the %1 color").arg(title.toLower())); + button->setAccessibleName(QStringLiteral("%1 color").arg(title)); + connect(button, &QAbstractButton::clicked, this, [pick, highlights] { pick(highlights); }); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(8); + row->addWidget(button); + row->addWidget(new QLabel(title, box)); + m_swatches.push_back(button); + return box; +} + +void GradientMapControls::synchronize() +{ + const QSignalBlocker quiet(m_reverse); + m_reverse->setChecked(m_value().reversed); + m_gradient->update(); + for (SwatchButton *each : m_swatches) + each->update(); +} + +FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_column(new QVBoxLayout(this)), m_preview(new QCheckBox(QStringLiteral("Preview"), this)), + m_error(words(QString(), false, this)), m_limited(words(QStringLiteral("Limited to the selection"), true, this)), + m_spinner(new QProgressBar(this)), m_activity(words(QString(), true, this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), + m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + setFixedWidth(380); + m_column->setContentsMargins(24, 24, 24, 24); + m_column->setSpacing(16); + const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; }; + // Swift's `edit?.kind ?? .gaussianBlur`. + switch (m_session.filterEdit() ? m_session.filterEdit()->kind : FilterKind::gaussianBlur) { + case FilterKind::curves: + m_curves = new CurvesControls([this] { return settings().curves; }, + [this](const CurvesSettings &curves) { update([&curves](FilterSettings &settings) { settings.curves = curves; }); }, this); + m_column->addWidget(m_curves); + break; + case FilterKind::exposure: + control(QStringLiteral("Exposure"), [](FilterSettings &settings) -> double & { return settings.exposure.exposure; }, + ExposureSettings::exposureLow, ExposureSettings::exposureHigh, QString(), 2, false); + control(QStringLiteral("Offset"), [](FilterSettings &settings) -> double & { return settings.exposure.offset; }, ExposureSettings::offsetLow, + ExposureSettings::offsetHigh, QString(), 4, false); + control(QStringLiteral("Gamma"), [](FilterSettings &settings) -> double & { return settings.exposure.gamma; }, ExposureSettings::gammaLow, + ExposureSettings::gammaHigh, QString(), 2, true); + break; + case FilterKind::gradientMap: + m_gradientMap = new GradientMapControls([this] { return settings().gradientMap; }, + [this](const GradientMapSettings &map) { update([&map](FilterSettings &settings) { settings.gradientMap = map; }); }, + [this](bool highlights) { m_session.openGradientMapColorPicker(highlights); }, this); + m_column->addWidget(m_gradientMap); + break; + case FilterKind::grain: + control(QStringLiteral("Amount"), [](FilterSettings &settings) -> double & { return settings.grain.amount; }, GrainSettings::amountLow, + GrainSettings::amountHigh, QString(), 0, false); + control(QStringLiteral("Size"), [](FilterSettings &settings) -> double & { return settings.grain.size; }, GrainSettings::sizeLow, + GrainSettings::sizeHigh, QStringLiteral("px"), 1, true); + control(QStringLiteral("Roughness"), [](FilterSettings &settings) -> double & { return settings.grain.roughness; }, + GrainSettings::roughnessLow, GrainSettings::roughnessHigh, QString(), 0, false); + break; + case FilterKind::removeBackground: background(); break; + case FilterKind::contentAwareFill: + m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this)); + break; + case FilterKind::gaussianBlur: + control(QStringLiteral("Radius"), key(&FilterSettings::radius), 0.1, 250, QStringLiteral("px"), 1, true); + break; + case FilterKind::motionBlur: + control(QStringLiteral("Angle"), key(&FilterSettings::angle), -90, 90, QStringLiteral("°"), 0, false); + control(QStringLiteral("Distance"), key(&FilterSettings::distance), 1, 2000, QStringLiteral("px"), 0, true); + break; + case FilterKind::addNoise: + control(QStringLiteral("Amount"), key(&FilterSettings::amount), 0.1, 400, QStringLiteral("%"), 1, true); + noise(); + break; + case FilterKind::lensCorrection: + control(QStringLiteral("Remove Distortion"), key(&FilterSettings::distortion), -100, 100, QString(), 0, false); + m_column->addWidget(words(QStringLiteral("Positive straightens lines that bow outward (barrel); negative, lines that bow inward (pincushion)."), + true, this)); + break; + } + m_preview->setObjectName(QStringLiteral("filterPreview")); + m_error->setObjectName(QStringLiteral("filterError")); + m_error->setForegroundRole(QPalette::BrightText); + m_limited->setObjectName(QStringLiteral("filterLimited")); + m_spinner->setObjectName(QStringLiteral("filterSpinner")); + m_spinner->setRange(0, 0); + m_spinner->setTextVisible(false); + m_spinner->setFixedSize(16, 16); + m_activity->setObjectName(QStringLiteral("filterActivity")); + m_cancel->setObjectName(QStringLiteral("filterCancel")); + m_cancel->setAutoDefault(false); + m_ok->setObjectName(QStringLiteral("filterOK")); + m_ok->setDefault(true); + connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateFilter(settings(), on); }); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelFilter(); }); + connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitFilter(); }); + auto *divider = new QFrame(this); + divider->setFrameShape(QFrame::HLine); + divider->setForegroundRole(QPalette::Mid); + auto *buttons = new QHBoxLayout; + buttons->addWidget(m_cancel); + buttons->addStretch(1); + buttons->addWidget(m_spinner); + buttons->addWidget(m_activity); + buttons->addWidget(m_ok); + m_column->addWidget(m_preview); + m_column->addWidget(m_error); + m_column->addWidget(m_limited); + m_column->addWidget(divider); + m_column->addLayout(buttons); + connect(&m_session, &EditorSession::changed, this, &FilterSheet::synchronize); + synchronize(); +} + +void FilterSheet::control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, + int decimals, bool logarithmic) +{ + QString name = title; + name.remove(QLatin1Char(' ')); + name[0] = name[0].toLower(); + const size_t index = m_controls.size(); + auto *box = new QWidget(this); + auto *label = new QLabel(title, box); + label->setMinimumWidth(60); + label->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + auto *slider = new QSlider(Qt::Horizontal, box); + slider->setObjectName(name + QStringLiteral("Slider")); + slider->setRange(0, travel); + label->setBuddy(slider); + // Swift rounds what the slider sets to the field's decimals. + connect(slider, &QSlider::valueChanged, this, [this, index](int position) { + const Control &at = m_controls[index]; + const double from = at.logarithmic ? std::log(at.low) : at.low, to = at.logarithmic ? std::log(at.high) : at.high; + const double value = from + (to - from) * position / travel, step = std::pow(10.0, at.decimals); + const double rounded = std::round((at.logarithmic ? std::exp(value) : value) * step) / step; + // Unchanged, the thumb stays, so arrow steps add up. + FilterSettings current = settings(); + if (at.key(current) == rounded) + return; + update([&at, rounded](FilterSettings &settings) { at.key(settings) = rounded; }); + // Its value replaces the field's typing. + at.field->setModified(false); + synchronize(); + }); + // A readable number applies as typed; normalizing clamps it. + auto *field = new PickerField([this, index] { + PickerField &edited = *m_controls[index].field; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number && std::isfinite(typed)) + update([this, index, typed](FilterSettings &settings) { m_controls[index].key(settings) = typed; }); + edited.setModified(false); + synchronize(); + }, nullptr, box); + field->setObjectName(name + QStringLiteral("Field")); + field->setAccessibleName(title); + field->setPlaceholderText(title); + field->setAlignment(Qt::AlignRight); + field->setFixedWidth(56); + auto *suffixed = new QHBoxLayout; + suffixed->setSpacing(2); + suffixed->addWidget(field); + suffixed->addWidget(new QLabel(unit, box)); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + row->addWidget(label); + row->addWidget(slider, 1); + row->addLayout(suffixed); + m_column->addWidget(box); + m_controls.push_back(Control{std::move(key), low, high, decimals, logarithmic, slider, field}); +} + +void FilterSheet::noise() +{ + auto *box = new QWidget(this); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + row->addWidget(new QLabel(QStringLiteral("Distribution"), box)); + m_distribution = new QButtonGroup(this); + for (const bool gaussian : {false, true}) { + auto *choice = new QToolButton(box); + choice->setText(gaussian ? QStringLiteral("Gaussian") : QStringLiteral("Uniform")); + choice->setObjectName(gaussian ? QStringLiteral("gaussianChoice") : QStringLiteral("uniformChoice")); + choice->setCheckable(true); + choice->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_distribution->addButton(choice, gaussian ? 1 : 0); + row->addWidget(choice); + } + connect(m_distribution, &QButtonGroup::idClicked, this, [this](int id) { update([id](FilterSettings &settings) { settings.gaussian = id == 1; }); }); + m_column->addWidget(box); + m_monochromatic = new QCheckBox(QStringLiteral("Monochromatic"), this); + m_monochromatic->setObjectName(QStringLiteral("monochromatic")); + connect(m_monochromatic, &QCheckBox::clicked, this, [this](bool on) { update([on](FilterSettings &settings) { settings.monochromatic = on; }); }); + m_column->addWidget(m_monochromatic); +} + +void FilterSheet::background() +{ + m_column->addWidget(words(QStringLiteral("Hide the background behind a layer mask, keeping the foreground subjects. The pixels stay, " + "so the background can be painted back at any time."), + false, this)); + // Swift's segmented picker, its label hidden. + auto *box = new QWidget(this); + box->setObjectName(QStringLiteral("backgroundQuality")); + box->setAccessibleName(QStringLiteral("Quality")); + box->setToolTip(QStringLiteral("Basic is quick; Advanced refines the mask against the layer's own detail, for hair and fur")); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(0); + m_quality = new QButtonGroup(this); + for (const BackgroundQuality quality : allBackgroundQualities) { + auto *choice = new QToolButton(box); + choice->setText(rawValue(quality)); + choice->setCheckable(true); + choice->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_quality->addButton(choice, int(quality)); + row->addWidget(choice); + } + connect(m_quality, &QButtonGroup::idClicked, this, + [this](int id) { update([id](FilterSettings &settings) { settings.backgroundQuality = BackgroundQuality(id); }); }); + m_column->addWidget(box); + const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; }; + control(QStringLiteral("Refine"), key(&FilterSettings::refineEdges), 0, 40, QStringLiteral("px"), 0, false); + control(QStringLiteral("Contrast"), key(&FilterSettings::matteContrast), 0, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Shift Edge"), key(&FilterSettings::shiftEdge), -10, 10, QStringLiteral("px"), 0, false); + const QStringList helps{QStringLiteral("Pull the mask onto the image's own edges, which recovers hair and fur"), + QStringLiteral("Clear the haze that leaves background showing through thin areas"), + QStringLiteral("Shrink the mask to drop the rim of background color around the subject, or grow it")}; + for (qsizetype index = 0; index < helps.size(); ++index) { + QWidget *advanced = m_controls[size_t(index)].slider->parentWidget(); + advanced->setToolTip(helps[index]); + m_advanced.push_back(advanced); + } +} + +FilterSettings FilterSheet::settings() const +{ + return m_session.filterEdit() ? m_session.filterEdit()->settings : FilterSettings(); +} + +void FilterSheet::update(const std::function<void(FilterSettings &)> &change) +{ + FilterSettings value = settings(); + change(value); + m_session.updateFilter(value, !m_session.filterEdit() || m_session.filterEdit()->preview); +} + +void FilterSheet::synchronize() +{ + // Closed, the panel hides the sheet until the next edit. + if (!m_session.filterEdit()) + return; + // Swift's onChange: the open map follows the picker's colour. + const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt; + if (picked != m_pickerColour) { + m_pickerColour = picked; + m_session.previewGradientMapColor(); + } + const FilterEdit &edit = m_session.filterEdit().value(); + for (Control &control : m_controls) { + FilterSettings copy = edit.settings; + const double value = control.key(copy); + const double from = control.logarithmic ? std::log(control.low) : control.low, to = control.logarithmic ? std::log(control.high) : control.high; + const double at = control.logarithmic ? std::log(value) : value; + { + // The slider shows the session's number without writing back. + const QSignalBlocker quiet(control.slider); + control.slider->setValue(int(std::lround((at - from) / (to - from) * travel))); + } + // A field being typed in keeps its typing. + const QString text = shown(value, control.decimals, control.field->locale()); + if (!control.field->isModified() && control.field->text() != text) + control.field->setText(text); + } + if (m_curves) + m_curves->synchronize(); + if (m_gradientMap) + m_gradientMap->synchronize(); + if (m_distribution) + m_distribution->button(edit.settings.gaussian ? 1 : 0)->setChecked(true); + if (m_monochromatic) + m_monochromatic->setChecked(edit.settings.monochromatic); + if (m_quality) + m_quality->button(int(edit.settings.backgroundQuality))->setChecked(true); + for (QWidget *advanced : m_advanced) + advanced->setVisible(edit.settings.backgroundQuality == BackgroundQuality::advanced); + m_preview->setChecked(edit.preview); + m_error->setText(edit.previewError.value_or(QString())); + m_error->setVisible(edit.previewError.has_value()); + // An adjustment layer's editor ignores the selection. + m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection()); + // While the result is made, OK waits and says so. + const bool working = edit.committing || edit.preparing; + m_spinner->setVisible(working); + m_activity->setVisible(working); + m_activity->setText(edit.committing ? QStringLiteral("Applying…") : QStringLiteral("Working…")); + m_ok->setEnabled(!(isAutomatic(edit.kind) && (edit.preparing || edit.previewError))); + setEnabled(!edit.committing); +} diff --git a/src/UI/FilterSheet.h b/src/UI/FilterSheet.h new file mode 100644 index 0000000..09ff614 --- /dev/null +++ b/src/UI/FilterSheet.h @@ -0,0 +1,82 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QWidget> + +class CurvesControls; +class PickerField; +class QButtonGroup; +class QCheckBox; +class QLabel; +class QProgressBar; +class QPushButton; +class QSlider; +class QVBoxLayout; +class SwatchButton; + +// Swift's GradientMapControls: the gradient, both ends, Reverse. +class GradientMapControls : public QWidget { + Q_OBJECT +public: + // `pick` opens the picker: false for Shadows, true for Highlights. + GradientMapControls(std::function<GradientMapSettings()> value, std::function<void(const GradientMapSettings &)> change, + std::function<void(bool)> pick, QWidget *parent = nullptr); + // Shows the settings as they now stand. + void synchronize(); + +private: + QWidget *swatch(const QString &title, bool highlights, const std::function<void(bool)> &pick); + + const std::function<GradientMapSettings()> m_value; + const std::function<void(const GradientMapSettings &)> m_change; + QWidget *const m_gradient; + QCheckBox *const m_reverse; + std::vector<SwatchButton *> m_swatches; +}; + +// Swift's FilterSheet: the kind's settings, Preview, Cancel and OK. +class FilterSheet : public QWidget { + Q_OBJECT +public: + explicit FilterSheet(EditorSession &session, QWidget *parent = nullptr); + +private: + // Swift's control: a slider, an exact field and its unit. + struct Control { + std::function<double &(FilterSettings &)> key; + double low; + double high; + int decimals; + bool logarithmic; + QSlider *slider; + PickerField *field; + }; + void control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, + int decimals, bool logarithmic); + void noise(); + // Remove Background's words, Quality, and Advanced's three rows. + void background(); + // Swift's `settings` and `update`: the defaults once the edit goes. + FilterSettings settings() const; + void update(const std::function<void(FilterSettings &)> &change); + void synchronize(); + + EditorSession &m_session; + QVBoxLayout *const m_column; + std::vector<Control> m_controls; + CurvesControls *m_curves = nullptr; + GradientMapControls *m_gradientMap = nullptr; + QButtonGroup *m_distribution = nullptr; + QCheckBox *m_monochromatic = nullptr; + QButtonGroup *m_quality = nullptr; + // Shown while the quality is Advanced, as Swift's `if`. + std::vector<QWidget *> m_advanced; + QCheckBox *const m_preview; + QLabel *const m_error; + QLabel *const m_limited; + QProgressBar *const m_spinner; + QLabel *const m_activity; + QPushButton *const m_cancel; + QPushButton *const m_ok; + // The picker's colour as last seen, Swift's onChange. + std::optional<PaletteColor> m_pickerColour; +}; diff --git a/src/UI/FloatingPanel.cpp b/src/UI/FloatingPanel.cpp new file mode 100644 index 0000000..41f2658 --- /dev/null +++ b/src/UI/FloatingPanel.cpp @@ -0,0 +1,118 @@ +#include "UI/FloatingPanel.h" +#include "Rendering/EditorCanvas.h" +#include <QApplication> +#include <QCloseEvent> +#include <QDialog> +#include <QHash> +#include <QVBoxLayout> + +namespace { +// Top-left corners by panel, for this run of the app. +QHash<QString, QPoint> &positions() +{ + static QHash<QString, QPoint> positions; + return positions; +} +} + +// Swift's NSPanel: a tool window whose close is Cancel. +class PanelWindow : public QDialog { +public: + PanelWindow(FloatingPanel &owner, QWidget *parent) : QDialog(parent), m_owner(owner) + { + // A dialog is non-modal unless asked: Swift's panel. + setWindowFlag(Qt::Tool); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSizeConstraint(QLayout::SetFixedSize); + } + +protected: + // QDialog's own would reject, and so close, again. + void closeEvent(QCloseEvent *event) override + { + m_owner.closed(); + QWidget::closeEvent(event); + } + // Escape closes as the close button does. + void reject() override { close(); } + void moveEvent(QMoveEvent *event) override + { + QDialog::moveEvent(event); + if (isVisible()) + m_owner.remember(); + } + +private: + FloatingPanel &m_owner; +}; + +FloatingPanel::FloatingPanel(const QString &name, QWidget &owner) : m_name(name), m_owner(owner) {} + +FloatingPanel::~FloatingPanel() +{ + delete m_panel; +} + +void FloatingPanel::show(const QString &title, QWidget *content) +{ + if (!m_panel) { + m_panel = new PanelWindow(*this, m_owner.window()); + m_panel->setObjectName(m_name); + } + const bool wasVisible = m_panel->isVisible(); + m_panel->setWindowTitle(title); + // Later: a signal from the old content may still run. + if (m_content) { + m_content->hide(); + m_content->deleteLater(); + } + m_content = content; + m_panel->layout()->addWidget(content); + // Shown now: layouts show late children from the event loop. + content->show(); + m_panel->adjustSize(); + // A shown panel stays; without a canvas QDialog centres itself. + if (!wasVisible) { + const CanvasView *canvas = m_owner.window()->findChild<CanvasView *>(); + if (positions().contains(m_name)) + m_panel->move(positions().value(m_name)); + else if (canvas) + m_panel->move(canvas->mapToGlobal(canvas->rect().center()) - QPoint(m_panel->width() / 2, m_panel->height() / 2)); + } + m_panel->show(); + m_panel->raise(); + m_panel->activateWindow(); + remember(); +} + +// Moves were remembered as they came; hide sends no close. +void FloatingPanel::close() +{ + if (m_panel) + m_panel->hide(); +} + +bool FloatingPanel::isVisible() const +{ + return m_panel && m_panel->isVisible(); +} + +void FloatingPanel::refocus(const QString &name) +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == name && widget->isVisible()) + widget->activateWindow(); + } +} + +void FloatingPanel::remember() +{ + positions().insert(m_name, m_panel->pos()); +} + +void FloatingPanel::closed() +{ + if (onClose) + onClose(); +} diff --git a/src/UI/FloatingPanel.h b/src/UI/FloatingPanel.h new file mode 100644 index 0000000..25db039 --- /dev/null +++ b/src/UI/FloatingPanel.h @@ -0,0 +1,35 @@ +#pragma once +#include <QPointer> +#include <QString> +#include <QWidget> +#include <functional> + +class QDialog; + +// Swift's FloatingPanelController: a movable, non-modal tool panel. +class FloatingPanel { +public: + FloatingPanel(const QString &name, QWidget &owner); + ~FloatingPanel(); + FloatingPanel(const FloatingPanel &) = delete; + FloatingPanel &operator=(const FloatingPanel &) = delete; + // New content and title; a shown panel keeps its place. + void show(const QString &title, QWidget *content); + // Hides the panel without reporting a close. + void close(); + bool isVisible() const; + // Its close button and Escape report here, as Cancel. + std::function<void()> onClose; + // Hands the keys back to a shown panel, after clicks. + static void refocus(const QString &name); + +private: + friend class PanelWindow; + void remember(); + void closed(); + + const QString m_name; + QWidget &m_owner; + QPointer<QDialog> m_panel; + QPointer<QWidget> m_content; +}; diff --git a/src/UI/GradientControls.cpp b/src/UI/GradientControls.cpp new file mode 100644 index 0000000..3f510dc --- /dev/null +++ b/src/UI/GradientControls.cpp @@ -0,0 +1,129 @@ +#include "UI/GradientControls.h" +#include "Document/EditorSession.h" +#include "UI/LassoControls.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QComboBox> +#include <QPainter> +#include <QPainterPath> +#include <QPushButton> +#include <QSignalBlocker> +#include <QSlider> +#include <QToolButton> +#include <cmath> + +namespace { +// The session's gradient settings with one field rewritten. +void changeGradient(EditorSession &session, const std::function<void(GradientSettings &)> &change) +{ + GradientSettings settings = session.gradientSettings(); + change(settings); + session.setGradientSettings(settings); +} +} + +GradientSwatch::GradientSwatch(const EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session) +{ + setObjectName(QStringLiteral("gradientSwatch")); + setFixedSize(56, 18); +} + +void GradientSwatch::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath shape; + shape.addRoundedRect(QRectF(rect()), 3, 3); + painter.setClipPath(shape); + painter.fillRect(rect(), Qt::white); + // Four-point squares: transparency reads as transparency. + const QColor square = QColor::fromRgbF(0.5, 0.5, 0.5, 0.45); + for (int row = 0; row * 4 < height(); ++row) { + for (int column = 0; column * 4 < width(); ++column) { + if ((row + column) % 2 == 0) + painter.fillRect(QRectF(column * 4, row * 4, 4, 4), square); + } + } + const std::array<QColor, 2> colors = m_session.gradientColors(false); + QLinearGradient ramp(QPointF(0, 0), QPointF(width(), 0)); + ramp.setColorAt(0, colors[0]); + ramp.setColorAt(1, colors[1]); + painter.fillRect(rect(), ramp); + painter.setClipping(false); + painter.setPen(QPen(QColor(0, 0, 0, 128), 1)); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 2.5, 2.5); +} + +GradientControls::GradientControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QStringLiteral("Gradient"), parent), m_session(session), m_shapes(new QButtonGroup(this)), + m_linear(shape(QStringLiteral("gradientLinear"), rawValue(GradientShape::linear), false)), + m_radial(shape(QStringLiteral("gradientRadial"), rawValue(GradientShape::radial), true)), m_swatch(new GradientSwatch(session, this)), + m_style(new QComboBox(this)), m_reverse(new QCheckBox(QStringLiteral("Reverse"), this)), m_opacitySlider(new QSlider(Qt::Horizontal, this)), + m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.gradientSettings().opacity * 100; }, + [this](double percent) { + changeGradient(m_session, [percent](GradientSettings &gradient) { gradient.opacity = percent / 100; }); + }, this)), + m_mask(new QLabel(QStringLiteral("Mask"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), + m_apply(new QPushButton(QStringLiteral("Apply"), this)) +{ + m_style->setObjectName(QStringLiteral("gradientStyle")); + for (const GradientStyle style : allGradientStyles) + m_style->addItem(rawValue(style)); + connect(m_style, &QComboBox::activated, this, + [this](int index) { changeGradient(m_session, [index](GradientSettings &gradient) { gradient.style = allGradientStyles[size_t(index)]; }); }); + m_reverse->setObjectName(QStringLiteral("gradientReverse")); + connect(m_reverse, &QCheckBox::clicked, this, [this](bool on) { changeGradient(m_session, [on](GradientSettings &gradient) { gradient.reversed = on; }); }); + m_opacitySlider->setObjectName(QStringLiteral("gradientOpacitySlider")); + m_opacitySlider->setRange(10, 1000); + m_opacitySlider->setFixedWidth(100); + connect(m_opacitySlider, &QSlider::valueChanged, this, + [this](int value) { changeGradient(m_session, [value](GradientSettings &gradient) { gradient.opacity = value / 1000.0; }); }); + m_opacity->setObjectName(QStringLiteral("gradientOpacity")); + m_opacity->setFixedWidth(42); + m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%")); + m_mask->setObjectName(QStringLiteral("gradientMaskNote")); + m_mask->setForegroundRole(QPalette::PlaceholderText); + m_cancel->setObjectName(QStringLiteral("gradientCancel")); + m_apply->setObjectName(QStringLiteral("gradientApply")); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelGradient(); }); + connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitGradient(); }); + for (QWidget *widget : std::initializer_list<QWidget *>{m_linear, m_radial, m_swatch, m_style, m_reverse, new QLabel(QStringLiteral("Opacity"), this), + m_opacitySlider, m_opacity, new QLabel(QStringLiteral("%"), this)}) + row->insertWidget(row->count() - 1, widget); + for (QWidget *widget : std::initializer_list<QWidget *>{m_mask, m_cancel, m_apply}) + row->addWidget(widget); + connect(&m_session, &EditorSession::changed, this, &GradientControls::synchronize); + synchronize(); +} + +// A segment of Swift's shape picker. +QToolButton *GradientControls::shape(const QString &name, const QString &text, bool radial) +{ + auto *button = new QToolButton(this); + button->setObjectName(name); + button->setText(text); + button->setCheckable(true); + button->setToolTip(QStringLiteral("Linear runs along the line; Radial spreads out from the start point")); + m_shapes->addButton(button); + connect(button, &QToolButton::clicked, this, [this, radial] { + changeGradient(m_session, [radial](GradientSettings &gradient) { gradient.shape = radial ? GradientShape::radial : GradientShape::linear; }); + }); + return button; +} + +void GradientControls::synchronize() +{ + const GradientSettings &gradient = m_session.gradientSettings(); + (gradient.shape == GradientShape::radial ? m_radial : m_linear)->setChecked(true); + m_style->setCurrentIndex(int(gradient.style)); + m_reverse->setChecked(gradient.reversed); + // The slider shows the session's number without writing back. + const QSignalBlocker blocker(m_opacitySlider); + m_opacitySlider->setValue(int(std::lround(gradient.opacity * 1000))); + m_opacity->sync(int(std::lround(gradient.opacity * 100))); + m_mask->setVisible(m_session.isMaskSelected()); + m_cancel->setVisible(m_session.gradientEdit().has_value()); + m_apply->setVisible(m_session.gradientEdit().has_value()); + m_swatch->update(); + setEnabled(!m_session.showsBusy() && m_session.document().has_value()); +} diff --git a/src/UI/GradientControls.h b/src/UI/GradientControls.h new file mode 100644 index 0000000..eb54584 --- /dev/null +++ b/src/UI/GradientControls.h @@ -0,0 +1,49 @@ +#pragma once +#include "UI/ToolHeaderStyle.h" +#include <functional> + +class EditorSession; +class QButtonGroup; +class QCheckBox; +class QComboBox; +class QPushButton; +class QSlider; +class QToolButton; +class SelectionAmountField; + +// Swift's gradient swatch: the colours over a checkerboard. +class GradientSwatch : public QWidget { +public: + GradientSwatch(const EditorSession &session, QWidget *parent); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + const EditorSession &m_session; +}; + +// Swift's GradientControls: shape, colours, reverse, opacity, apply. +class GradientControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit GradientControls(EditorSession &session, QWidget *parent = nullptr); + +private: + QToolButton *shape(const QString &name, const QString &text, bool radial); + void synchronize(); + + EditorSession &m_session; + QButtonGroup *const m_shapes; + QToolButton *const m_linear; + QToolButton *const m_radial; + GradientSwatch *const m_swatch; + QComboBox *const m_style; + QCheckBox *const m_reverse; + // The slider steps in thousandths, the field in whole percents. + QSlider *const m_opacitySlider; + SelectionAmountField *const m_opacity; + QLabel *const m_mask; + QPushButton *const m_cancel; + QPushButton *const m_apply; +}; diff --git a/src/UI/HueSaturationSheet.cpp b/src/UI/HueSaturationSheet.cpp new file mode 100644 index 0000000..f460e90 --- /dev/null +++ b/src/UI/HueSaturationSheet.cpp @@ -0,0 +1,465 @@ +#include "UI/HueSaturationSheet.h" +#include "Rendering/EyedropperIcon.h" +#include "UI/ColorPickerSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QFrame> +#include <QHBoxLayout> +#include <QLabel> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> +#include <QVBoxLayout> +#include <algorithm> +#include <cmath> + +namespace { +// Swift's slider rows: title, unit and the settings' value. +struct Row { + const char *title; + const char *unit; + double (HueSaturationSettings::*value)() const; + void (HueSaturationSettings::*set)(double); +}; +constexpr std::array<Row, 3> rows{{{"Hue", "°", &HueSaturationSettings::hue, &HueSaturationSettings::setHue}, + {"Saturation", "", &HueSaturationSettings::saturation, &HueSaturationSettings::setSaturation}, + {"Lightness", "", &HueSaturationSettings::lightness, &HueSaturationSettings::setLightness}}}; + +// Sliders move in hundredths, as Swift's run freely. +constexpr double sliderScale = 100; + +// Swift's ranges: Colorize sets hue and saturation outright. +std::pair<double, double> bounds(size_t index, bool colorize) +{ + if (index == 0) + return colorize ? std::pair(0.0, 360.0) : std::pair(-180.0, 180.0); + if (index == 1) + return colorize ? std::pair(0.0, 100.0) : std::pair(-100.0, 100.0); + return {-100.0, 100.0}; +} + +// Swift's `current`: the open edit's settings, else the defaults. +HueSaturationSettings current(const EditorSession &session) +{ + return session.hueSaturation() ? session.hueSaturation()->settings : HueSaturationSettings(); +} + +// Swift's 72 hue slices, as they are or adjusted. +class Spectrum : public QWidget { +public: + Spectrum(std::function<HueSaturationSettings()> value, bool after, QWidget *parent) : QWidget(parent), m_value(std::move(value)), m_after(after) + { + setFixedHeight(16); + } + +protected: + void paintEvent(QPaintEvent *) override + { + constexpr int slices = 72; + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QPainterPath rounded; + rounded.addRoundedRect(QRectF(rect()), 3, 3); + painter.setClipPath(rounded); + const HueSaturationSettings settings = m_value(); + const double width = double(this->width()) / slices; + for (int slice = 0; slice < slices; ++slice) { + const double hue = double(slice) / slices * 360; + const double shown = m_after ? HueSaturationFilter::shiftedHue(hue, settings) : hue; + painter.fillRect(QRectF(slice * width, 0, width + 0.5, height()), QColor::fromHsvF(float(shown / 360), 1, 1)); + } + } + +private: + const std::function<HueSaturationSettings()> m_value; + const bool m_after; +}; + +// Swift's handles: shoulders as blocks, the core's ends as bars. +class SpectrumHandles : public QWidget { +public: + SpectrumHandles(std::function<HueSaturationSettings()> value, std::function<void(double, double, bool)> drag, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_drag(std::move(drag)) + { + setFixedHeight(12); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const std::array<double, 4> handles = m_value().band().handles(); + for (size_t index = 0; index < handles.size(); ++index) { + const double x = handles[index] / 360 * width(); + const bool inner = index == 1 || index == 2; + painter.fillRect(inner ? QRectF(x - 1, 0, 2, height()) : QRectF(x - 3.5, height() / 2.0 - 2.5, 7, 5), palette().color(QPalette::WindowText)); + } + } + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + m_drag(event->position().x(), width(), true); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (event->buttons().testFlag(Qt::LeftButton)) + m_drag(event->position().x(), width(), false); + } + +private: + const std::function<HueSaturationSettings()> m_value; + const std::function<void(double, double, bool)> m_drag; +}; + +// The nearest handle round the circle; ties take the first. +int nearestHandle(const HueBand &band, double degrees) +{ + const std::array<double, 4> handles = band.handles(); + std::array<double, 4> distances{}; + for (size_t index = 0; index < handles.size(); ++index) { + const double gap = std::fmod(std::abs(handles[index] - degrees), 360); + distances[index] = std::min(gap, 360 - gap); + } + return int(std::min_element(distances.begin(), distances.end()) - distances.begin()); +} + +// Swift's plain sampling buttons: a glyph, tinted when chosen. +class SampleButton : public QToolButton { +public: + SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph)) + { + setCheckable(true); + setFixedSize(24, 20); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + if (isChecked()) { + QColor tint = palette().color(QPalette::Highlight); + tint.setAlphaF(0.25f); + painter.setPen(Qt::NoPen); + painter.setBrush(tint); + painter.drawRoundedRect(QRectF(rect()), 4, 4); + } + m_glyph(painter, palette().color(QPalette::WindowText)); + } + +private: + const std::function<void(QPainter &, const QColor &)> m_glyph; +}; + +// Swift's eyedropper, badged plus or minus for Add and Remove. +void eyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink) +{ + // A 14-point glyph, centred in the 24 by 20 frame. + painter.save(); + painter.translate(5, 3); + painter.save(); + painter.scale(14.0 / 18, 14.0 / 18); + painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + EyedropperIcon::paint(painter); + painter.restore(); + if (mode != HueSampleMode::replace) { + // An 8-point disc, bottom right, nudged 3 and 1. + QPainterPath badge; + badge.addEllipse(QRectF(9, 7, 8, 8)); + QPainterPath sign; + sign.setFillRule(Qt::WindingFill); + sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2)); + if (mode == HueSampleMode::add) + sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5)); + painter.fillPath(badge.subtracted(sign), ink); + } + painter.restore(); +} + +// Swift's hand.point.up.left: a finger up and left, outlined. +void pointingHand(QPainter &painter, const QColor &ink) +{ + QPainterPath finger, palm; + finger.addRoundedRect(QRectF(-1.5, -7, 3, 9), 1.5, 1.5); + palm.addRoundedRect(QRectF(-4, 0, 9.5, 7.5), 2.5, 2.5); + painter.save(); + painter.translate(12, 10); + painter.rotate(-35); + painter.translate(-0.75, -0.25); + painter.setPen(QPen(ink, 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(finger.united(palm)); + painter.restore(); +} + +QToolButton *sampleButton(HueSampleMode mode, QWidget *parent) +{ + auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { eyedropper(painter, mode, ink); }, parent); + button->setObjectName(QStringLiteral("hue") + rawValue(mode)); + button->setToolTip(help(mode)); + button->setAccessibleName(rawValue(mode) + QStringLiteral(" color")); + return button; +} + +QFrame *line(QFrame::Shape shape, QWidget *parent) +{ + auto *made = new QFrame(parent); + made->setFrameShape(shape); + made->setForegroundRole(QPalette::Mid); + return made; +} +} + +SpectrumEditor::SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent) + : QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), + m_handles(new SpectrumHandles(m_value, [this](double x, double width, bool pressed) { drag(x, width, pressed); }, this)), + m_after(new Spectrum(m_value, true, this)), m_readout(new QLabel(this)) +{ + auto *before = new Spectrum(m_value, false, this); + before->setObjectName(QStringLiteral("spectrumBefore")); + m_handles->setObjectName(QStringLiteral("spectrumHandles")); + m_after->setObjectName(QStringLiteral("spectrumAfter")); + m_readout->setObjectName(QStringLiteral("spectrumReadout")); + // Swift's .caption in the secondary ink. + QFont small = m_readout->font(); + small.setPixelSize(10); + m_readout->setFont(small); + m_readout->setForegroundRole(QPalette::PlaceholderText); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(5); + column->addWidget(before); + column->addWidget(m_handles); + column->addWidget(m_after); + column->addWidget(m_readout, 0, Qt::AlignHCenter); + synchronize(); +} + +void SpectrumEditor::synchronize() +{ + QStringList degrees; + for (const double handle : m_value().band().handles()) + degrees << QStringLiteral("%1°").arg(std::lround(handle)); + m_readout->setText(degrees.join(QStringLiteral(" "))); + m_handles->update(); + m_after->update(); +} + +void SpectrumEditor::drag(double x, double width, bool pressed) +{ + // A press starts afresh: Qt can lose a release. + if (pressed) + m_dragging.reset(); + const double degrees = std::clamp(x, 0.0, width) / width * 360; + HueSaturationSettings settings = m_value(); + HueBand band = settings.band(); + const int index = m_dragging.value_or(nearestHandle(band, degrees)); + m_dragging = index; + band.setHandle(index, degrees); + settings.setBand(band); + m_change(settings); +} + +HueSaturationSheet::HueSaturationSheet(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_range(new QComboBox(this)), + m_samples{sampleButton(HueSampleMode::replace, this), sampleButton(HueSampleMode::add, this), sampleButton(HueSampleMode::remove, this)}, + m_divider(line(QFrame::VLine, this)), m_targeting(new SampleButton(pointingHand, this)), m_rows{row(0), row(1), row(2)}, + m_spectrum(new SpectrumEditor([this] { return current(m_session); }, + [this](const HueSaturationSettings &edited) { change([&edited](HueSaturationSettings &settings) { settings = edited; }); }, this)), + m_invert(new QCheckBox(QStringLiteral("Apply outside this range instead"), this)), m_colorize(new QCheckBox(QStringLiteral("Colorize"), this)), + m_preview(new QCheckBox(QStringLiteral("Preview"), this)), m_reset(new QPushButton(QStringLiteral("Reset"), this)), + m_limited(new QLabel(QStringLiteral("Limited to the selection"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), + m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + setFixedWidth(460); + m_range->setObjectName(QStringLiteral("hueRange")); + m_range->setFixedWidth(160); + // Swift hides the label; Linux reads it through the relation. + auto *rangeLabel = new QLabel(QStringLiteral("Range"), this); + rangeLabel->setBuddy(m_range); + rangeLabel->hide(); + for (const ColorRange range : allColorRanges) + m_range->addItem(rawValue(range)); + connect(m_range, &QComboBox::activated, this, + [this](int index) { change([index](HueSaturationSettings &settings) { settings.range = allColorRanges[size_t(index)]; }); }); + for (size_t index = 0; index < m_samples.size(); ++index) { + // A second click puts the eyedropper away. + connect(m_samples[index], &QToolButton::clicked, this, [this, index] { + const HueSampleMode mode = allHueSampleModes[index]; + m_session.setHueTargeting(false); + m_session.setHueSampleMode(m_session.hueSampleMode() == mode ? std::nullopt : std::optional(mode)); + }); + } + m_divider->setFixedHeight(16); + m_targeting->setObjectName(QStringLiteral("hueTargeting")); + m_targeting->setToolTip(QStringLiteral("Targeted adjustment: drag on the image to change that color's saturation, or its hue with Ctrl held")); + m_targeting->setAccessibleName(QStringLiteral("Targeted adjustment")); + connect(m_targeting, &QToolButton::clicked, this, [this] { + m_session.setHueSampleMode(std::nullopt); + m_session.setHueTargeting(!m_session.hueTargeting()); + }); + m_spectrum->setObjectName(QStringLiteral("hueSpectrum")); + connect(m_invert, &QCheckBox::clicked, this, [this](bool on) { change([on](HueSaturationSettings &settings) { settings.invertRange = on; }); }); + // Photoshop starts colorizing at hue 0, saturation 25. + connect(m_colorize, &QCheckBox::clicked, this, [this](bool on) { + change([on](HueSaturationSettings &settings) { settings = on ? HueSaturationSettings::colorizeStart() : HueSaturationSettings(); }); + }); + connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateHueSaturation(current(m_session), on); }); + m_reset->setAutoDefault(false); + connect(m_reset, &QPushButton::clicked, this, [this] { + change([](HueSaturationSettings &settings) { settings = settings.colorize ? HueSaturationSettings::colorizeStart() : HueSaturationSettings(); }); + }); + // Swift's .callout in the secondary ink. + QFont callout = m_limited->font(); + callout.setPixelSize(12); + m_limited->setFont(callout); + m_limited->setForegroundRole(QPalette::PlaceholderText); + m_limited->setObjectName(QStringLiteral("hueLimited")); + // Return is OK's from anywhere, as Swift's default action. + m_cancel->setAutoDefault(false); + m_ok->setDefault(true); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelHueSaturation(); }); + connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitHueSaturation(); }); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + auto *top = new QHBoxLayout; + top->setSpacing(12); + top->addWidget(m_range); + top->addStretch(1); + auto *sampling = new QHBoxLayout; + sampling->setSpacing(6); + for (QToolButton *sample : m_samples) + sampling->addWidget(sample); + sampling->addWidget(m_divider); + sampling->addWidget(m_targeting); + top->addLayout(sampling); + column->addLayout(top); + for (QWidget *each : m_rows) + column->addWidget(each); + column->addWidget(m_spectrum); + column->addWidget(m_invert); + auto *toggles = new QHBoxLayout; + toggles->setSpacing(18); + toggles->addWidget(m_colorize); + toggles->addWidget(m_preview); + toggles->addWidget(m_reset); + toggles->addStretch(1); + column->addLayout(toggles); + column->addWidget(m_limited); + column->addWidget(line(QFrame::HLine, this)); + auto *buttons = new QHBoxLayout; + buttons->addWidget(m_cancel); + buttons->addStretch(1); + buttons->addWidget(m_ok); + column->addLayout(buttons); + connect(&m_session, &EditorSession::changed, this, &HueSaturationSheet::synchronize); + synchronize(); +} + +QWidget *HueSaturationSheet::row(size_t index) +{ + const QString title = QString::fromUtf8(rows[index].title); + auto *box = new QWidget(this); + auto *layout = new QHBoxLayout(box); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(10); + auto *label = new QLabel(title, box); + label->setFixedWidth(76); + auto *slider = new QSlider(Qt::Horizontal, box); + slider->setObjectName(title.toLower() + QStringLiteral("Slider")); + label->setBuddy(slider); + connect(slider, &QSlider::valueChanged, this, [this, index](int value) { + change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(value / sliderScale); }); + // Its value replaces the field's typing. + m_fields[index]->setModified(false); + synchronize(); + }); + // A readable number applies as typed, as Swift's value binding. + auto *field = new PickerField([this, index] { + PickerField &edited = *m_fields[index]; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + // Swift's Int() traps on what is no finite number. + if (edited.isModified() && number && std::isfinite(typed)) + change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(typed); }); + edited.setModified(false); + synchronize(); + }, nullptr, box); + field->setObjectName(title.toLower() + QStringLiteral("Field")); + field->setAccessibleName(title); + field->setPlaceholderText(title); + field->setAlignment(Qt::AlignRight); + field->setFixedWidth(48); + // Swift's onSubmit clamps; the default button then commits. + connect(field, &QLineEdit::returnPressed, this, [this, index] { + const std::pair<double, double> limits = bounds(index, current(m_session).colorize); + change([&](HueSaturationSettings &settings) { + (settings.*rows[index].set)(std::clamp((settings.*rows[index].value)(), limits.first, limits.second)); + }); + }); + auto *suffixed = new QHBoxLayout; + suffixed->setSpacing(2); + suffixed->addWidget(field); + suffixed->addWidget(new QLabel(QString::fromUtf8(rows[index].unit), box)); + layout->addWidget(label); + layout->addWidget(slider, 1); + layout->addLayout(suffixed); + m_sliders[index] = slider; + m_fields[index] = field; + return box; +} + +void HueSaturationSheet::change(const std::function<void(HueSaturationSettings &)> &edit) +{ + HueSaturationSettings settings = current(m_session); + edit(settings); + m_session.updateHueSaturation(settings, !m_session.hueSaturation() || m_session.hueSaturation()->preview); +} + +void HueSaturationSheet::synchronize() +{ + // Closed, the panel hides the sheet until the next edit. + const std::optional<HueSaturationEdit> &edit = m_session.hueSaturation(); + if (!edit) + return; + const HueSaturationSettings &settings = edit->settings; + const bool spectrum = settings.range != ColorRange::master && !settings.colorize; + m_range->setCurrentIndex(int(settings.range)); + m_range->setEnabled(!settings.colorize); + for (size_t index = 0; index < m_samples.size(); ++index) { + m_samples[index]->setVisible(spectrum); + m_samples[index]->setChecked(m_session.hueSampleMode() == allHueSampleModes[index]); + } + m_divider->setVisible(spectrum); + m_targeting->setVisible(!settings.colorize); + m_targeting->setChecked(m_session.hueTargeting()); + for (size_t index = 0; index < rows.size(); ++index) { + const auto [low, high] = bounds(index, settings.colorize); + const double value = (settings.*rows[index].value)(); + { + // The sliders show the session's numbers without writing back. + const QSignalBlocker quiet(m_sliders[index]); + m_sliders[index]->setRange(int(low * sliderScale), int(high * sliderScale)); + m_sliders[index]->setValue(int(std::lround(std::clamp(value, low, high) * sliderScale))); + } + // Ungrouped digits; a field being typed in keeps its typing. + QLocale locale = m_fields[index]->locale(); + locale.setNumberOptions(QLocale::OmitGroupSeparator); + const QString shown = locale.toString(value, 'f', 0); + if (!m_fields[index]->isModified() && m_fields[index]->text() != shown) + m_fields[index]->setText(shown); + } + m_spectrum->setVisible(spectrum); + m_spectrum->synchronize(); + m_invert->setVisible(spectrum); + m_invert->setChecked(settings.invertRange); + m_colorize->setChecked(settings.colorize); + m_preview->setChecked(edit->preview); + // An adjustment layer's editor ignores the selection. + m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection()); +} diff --git a/src/UI/HueSaturationSheet.h b/src/UI/HueSaturationSheet.h new file mode 100644 index 0000000..81ca2cb --- /dev/null +++ b/src/UI/HueSaturationSheet.h @@ -0,0 +1,67 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QWidget> +#include <array> +#include <functional> +#include <optional> + +class PickerField; +class QCheckBox; +class QComboBox; +class QLabel; +class QPushButton; +class QSlider; +class QToolButton; + +// Swift's SpectrumEditor: the hues, the band's handles, the hues after. +class SpectrumEditor : public QWidget { + Q_OBJECT +public: + // Swift's binding: the settings read, and written back. + SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent = nullptr); + // Shows the settings as they now stand. + void synchronize(); + +private: + // Swift's drag: the held handle, else the nearest. + void drag(double x, double width, bool pressed); + + const std::function<HueSaturationSettings()> m_value; + const std::function<void(const HueSaturationSettings &)> m_change; + std::optional<int> m_dragging; + QWidget *const m_handles; + QWidget *const m_after; + QLabel *const m_readout; +}; + +// Swift's HueSaturationSheet: range, samples, sliders, spectrum, toggles. +class HueSaturationSheet : public QWidget { + Q_OBJECT +public: + explicit HueSaturationSheet(EditorSession &session, QWidget *parent = nullptr); + +private: + // Swift's settings binding: changed, then handed to the session. + void change(const std::function<void(HueSaturationSettings &)> &edit); + // Swift's slider row: title, slider, field and unit. + QWidget *row(size_t index); + void synchronize(); + + EditorSession &m_session; + QComboBox *const m_range; + const std::array<QToolButton *, 3> m_samples; + QWidget *const m_divider; + QToolButton *const m_targeting; + // Hue, Saturation, Lightness; the rows make them. + std::array<QSlider *, 3> m_sliders{}; + std::array<PickerField *, 3> m_fields{}; + const std::array<QWidget *, 3> m_rows; + SpectrumEditor *const m_spectrum; + QCheckBox *const m_invert; + QCheckBox *const m_colorize; + QCheckBox *const m_preview; + QPushButton *const m_reset; + QLabel *const m_limited; + QPushButton *const m_cancel; + QPushButton *const m_ok; +}; diff --git a/src/UI/ImageSizeSheet.cpp b/src/UI/ImageSizeSheet.cpp new file mode 100644 index 0000000..eba90ab --- /dev/null +++ b/src/UI/ImageSizeSheet.cpp @@ -0,0 +1,270 @@ +#include "UI/ImageSizeSheet.h" +#include "UI/ColorPickerSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QGridLayout> +#include <QLabel> +#include <QPushButton> +#include <QVBoxLayout> +#include <cmath> + +namespace { +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + label->setTextFormat(Qt::PlainText); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + label->setForegroundRole(role); + return label; +} + +// Swift's `.number`: three places at most, none trailing, ties even. +QString shown(double value, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3); + while (number.endsWith(locale.zeroDigit())) + number.chop(locale.zeroDigit().size()); + if (number.endsWith(locale.decimalPoint())) + number.chop(locale.decimalPoint().size()); + return number; +} + +QString grouped(qint64 value) +{ + return QLocale(QLocale::English, QLocale::UnitedStates).toString(value); +} + +bool physical(const QString &unit) +{ + return unit == QLatin1String("Inches") || unit == QLatin1String("Centimeters"); +} +} + +ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent) + : QWidget(parent), m_originalWidth(document.width), m_originalHeight(document.height), m_finish(std::move(finish)), + m_width(double(document.width)), m_height(double(document.height)), m_resolution(document.resolution), m_units(new QComboBox(this)), + m_widthField(dimension(true)), m_heightField(dimension(false)), m_lock(new QCheckBox(QStringLiteral("Lock aspect ratio"), this)), + m_resolutionField(new PickerField([this] { + bool number = false; + const double typed = m_resolutionField->locale().toDouble(m_resolutionField->text(), &number); + if (m_resolutionField->isModified() && number) { + // Swift's onChange: a physical size keeps, its pixels scale. + const double old = std::exchange(m_resolution, typed); + if (m_resample && physical(m_unit) && old > 0 && typed > 0 && std::isfinite(typed)) { + m_width *= typed / old; + m_height *= typed / old; + } + } + m_resolutionField->setModified(false); + synchronize(); + }, nullptr, this)), + m_samplingRow(new QWidget(this)), m_samplingChoice(new QComboBox(m_samplingRow)), + m_explanation(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), + m_result(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), m_resize(new QPushButton(QStringLiteral("Resize"), this)) +{ + setFixedWidth(430); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(18); + column->addWidget(text(QStringLiteral("Image Size"), 17, QFont::Bold, QPalette::WindowText, this)); + column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal, + QPalette::PlaceholderText, this)); + + auto *fields = new QGridLayout; + fields->setHorizontalSpacing(8); + fields->setVerticalSpacing(18); + fields->setColumnMinimumWidth(0, 75); + auto *units = new QLabel(QStringLiteral("Units"), this); + units->setBuddy(m_units); + m_units->setObjectName(QStringLiteral("imageUnits")); + connect(m_units, &QComboBox::activated, this, [this](int index) { + // A wheel leaves the focus: pending typing commits first. + m_units->setFocus(); + m_unit = m_units->itemText(index); + synchronize(); + }); + fields->addWidget(units, 0, 0); + fields->addWidget(m_units, 0, 1); + fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0); + fields->addWidget(m_widthField, 1, 1); + fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0); + fields->addWidget(m_heightField, 2, 1); + column->addLayout(fields); + + m_lock->setObjectName(QStringLiteral("imageLocked")); + connect(m_lock, &QCheckBox::clicked, this, [this](bool on) { + m_locked = on; + synchronize(); + }); + column->addWidget(m_lock); + auto *resolution = new QHBoxLayout; + resolution->addWidget(new QLabel(QStringLiteral("Resolution"), this)); + m_resolutionField->setObjectName(QStringLiteral("imageResolution")); + m_resolutionField->setPlaceholderText(QStringLiteral("Resolution")); + m_resolutionField->setAccessibleName(QStringLiteral("Resolution")); + resolution->addWidget(m_resolutionField, 1); + resolution->addWidget(text(QStringLiteral("pixels/inch"), 13, QFont::Normal, QPalette::PlaceholderText, this)); + column->addLayout(resolution); + // Resample changes only by its own box. + auto *resample = new QCheckBox(QStringLiteral("Resample"), this); + resample->setObjectName(QStringLiteral("imageResample")); + resample->setChecked(true); + connect(resample, &QCheckBox::clicked, this, &ImageSizeSheet::setResample); + column->addWidget(resample); + + m_samplingRow->setObjectName(QStringLiteral("imageSamplingRow")); + auto *samplingLayout = new QHBoxLayout(m_samplingRow); + samplingLayout->setContentsMargins(0, 0, 0, 0); + auto *sampling = new QLabel(QStringLiteral("Sampling"), m_samplingRow); + sampling->setBuddy(m_samplingChoice); + m_samplingChoice->setObjectName(QStringLiteral("imageSampling")); + for (const LayerSampling each : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high}) + m_samplingChoice->addItem(rawValue(each), int(each)); + connect(m_samplingChoice, &QComboBox::activated, this, [this](int index) { + m_sampling = LayerSampling(m_samplingChoice->itemData(index).toInt()); + }); + samplingLayout->addWidget(sampling); + samplingLayout->addWidget(m_samplingChoice, 1); + column->addWidget(m_samplingRow); + m_explanation->setObjectName(QStringLiteral("imageExplanation")); + m_explanation->setWordWrap(true); + column->addWidget(m_explanation); + m_result->setObjectName(QStringLiteral("imageResult")); + m_result->setWordWrap(true); + column->addWidget(m_result); + + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("imageCancel")); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + m_resize->setObjectName(QStringLiteral("imageResize")); + m_resize->setDefault(true); + // Resize rests while the size is invalid: no guard needed. + connect(m_resize, &QPushButton::clicked, this, [this] { + m_finish(ImageSizeOptions{.width = qint64(std::round(m_width)), .height = qint64(std::round(m_height)), .resolution = m_resolution, + .sampling = m_sampling}); + }); + auto *buttons = new QHBoxLayout; + buttons->addWidget(cancel); + buttons->addStretch(1); + buttons->addWidget(m_resize); + column->addLayout(buttons); + synchronize(); +} + +// NaN and infinity fail every range test. +bool ImageSizeSheet::valid() const +{ + const double width = std::round(m_width), height = std::round(m_height); + return m_resolution >= 1 && m_resolution <= 9600 && width >= 1 && width <= 30'000 && height >= 1 && height <= 30'000 + && (!m_resample || width * height <= 100'000'000); +} + +double ImageSizeSheet::display(double pixels, qint64 original) const +{ + if (m_unit == QLatin1String("Percent")) + return pixels / double(original) * 100; + if (m_unit == QLatin1String("Inches")) + return pixels / m_resolution; + if (m_unit == QLatin1String("Centimeters")) + return pixels / m_resolution * 2.54; + return pixels; +} + +PickerField *ImageSizeSheet::dimension(bool isWidth) +{ + auto *field = new PickerField([this, isWidth] { + PickerField &edited = isWidth ? *m_widthField : *m_heightField; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number) + setDimension(typed, isWidth); + edited.setModified(false); + synchronize(); + }, nullptr, this); + field->setObjectName(isWidth ? QStringLiteral("imageWidth") : QStringLiteral("imageHeight")); + field->setPlaceholderText(isWidth ? QStringLiteral("Width") : QStringLiteral("Height")); + field->setAccessibleName(isWidth ? QStringLiteral("Width") : QStringLiteral("Height")); + return field; +} + +// Swift's binding: pixels, or without resampling the resolution. +void ImageSizeSheet::setDimension(double value, bool isWidth) +{ + if (!(std::isfinite(value) && value > 0)) + return; + if (!m_resample) { + m_resolution = (isWidth ? m_width : m_height) / value * (m_unit == QLatin1String("Centimeters") ? 2.54 : 1); + return; + } + double pixels = value; + if (m_unit == QLatin1String("Percent")) + pixels = value / 100 * double(isWidth ? m_originalWidth : m_originalHeight); + else if (m_unit == QLatin1String("Inches")) + pixels = value * m_resolution; + else if (m_unit == QLatin1String("Centimeters")) + pixels = value / 2.54 * m_resolution; + if (isWidth) { + if (m_locked) + m_height = pixels * m_height / m_width; + m_width = pixels; + } else { + if (m_locked) + m_width = pixels * m_width / m_height; + m_height = pixels; + } +} + +// Swift's onChange: without resampling the pixels stay the document's. +void ImageSizeSheet::setResample(bool enabled) +{ + m_resample = enabled; + if (!enabled) { + m_width = double(m_originalWidth); + m_height = double(m_originalHeight); + m_locked = true; + if (!physical(m_unit)) + m_unit = QStringLiteral("Inches"); + } + synchronize(); +} + +void ImageSizeSheet::synchronize() +{ + // Pixels and Percent need resampling, as Swift's filter. + QStringList shownUnits = {QStringLiteral("Pixels"), QStringLiteral("Percent"), QStringLiteral("Inches"), QStringLiteral("Centimeters")}; + if (!m_resample) + shownUnits = {QStringLiteral("Inches"), QStringLiteral("Centimeters")}; + QStringList listed; + for (int index = 0; index < m_units->count(); ++index) + listed << m_units->itemText(index); + if (listed != shownUnits) { + m_units->clear(); + m_units->addItems(shownUnits); + } + m_units->setCurrentText(m_unit); + for (const auto &[field, isWidth] : {std::pair(m_widthField, true), std::pair(m_heightField, false)}) { + // A field being typed in keeps its typing. + const QString number = shown(display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight), field->locale()); + if (!field->isModified() && field->text() != number) + field->setText(number); + } + const QString resolution = shown(m_resolution, m_resolutionField->locale()); + if (!m_resolutionField->isModified() && m_resolutionField->text() != resolution) + m_resolutionField->setText(resolution); + m_lock->setChecked(m_locked); + m_lock->setEnabled(m_resample); + m_samplingRow->setVisible(m_resample); + m_samplingChoice->setCurrentIndex(m_samplingChoice->findData(int(m_sampling))); + m_explanation->setText(m_resample ? QStringLiteral("Resizes layer pixels and applies existing transforms. Undo restores the originals.") + : QStringLiteral("Only print dimensions and resolution change. Pixels stay unchanged.")); + const bool isValid = valid(); + m_result->setText(isValid ? QStringLiteral("Result: %1 × %2 pixels").arg(grouped(qint64(std::round(m_width))), grouped(qint64(std::round(m_height)))) + : QStringLiteral("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch.")); + m_result->setForegroundRole(isValid ? QPalette::PlaceholderText : QPalette::BrightText); + m_resize->setEnabled(isValid); +} diff --git a/src/UI/ImageSizeSheet.h b/src/UI/ImageSizeSheet.h new file mode 100644 index 0000000..6572221 --- /dev/null +++ b/src/UI/ImageSizeSheet.h @@ -0,0 +1,47 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include "IO/ImageResizer.h" +#include <QWidget> +#include <functional> + +class PickerField; +class QCheckBox; +class QComboBox; +class QLabel; +class QPushButton; + +// Swift's ImageSizeSheet: pixels resampled, or print size alone. +class ImageSizeSheet : public QWidget { + Q_OBJECT +public: + ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent = nullptr); + +private: + bool valid() const; + double display(double pixels, qint64 original) const; + PickerField *dimension(bool isWidth); + void setDimension(double value, bool isWidth); + void setResample(bool enabled); + void synchronize(); + + const qint64 m_originalWidth; + const qint64 m_originalHeight; + const std::function<void(std::optional<ImageSizeOptions>)> m_finish; + double m_width; + double m_height; + double m_resolution; + bool m_locked = true; + bool m_resample = true; + QString m_unit = QStringLiteral("Pixels"); + LayerSampling m_sampling = LayerSampling::high; + QComboBox *const m_units; + PickerField *const m_widthField; + PickerField *const m_heightField; + QCheckBox *const m_lock; + PickerField *const m_resolutionField; + QWidget *const m_samplingRow; + QComboBox *const m_samplingChoice; + QLabel *const m_explanation; + QLabel *const m_result; + QPushButton *const m_resize; +}; diff --git a/src/UI/JPEGExportSheet.cpp b/src/UI/JPEGExportSheet.cpp new file mode 100644 index 0000000..38fbfb1 --- /dev/null +++ b/src/UI/JPEGExportSheet.cpp @@ -0,0 +1,253 @@ +#include "UI/JPEGExportSheet.h" +#include "UI/ByteCounts.h" +#include "UI/ColorPaletteControls.h" +#include <QColorDialog> +#include <QFutureWatcher> +#include <QHBoxLayout> +#include <QLabel> +#include <QPainter> +#include <QProgressBar> +#include <QPushButton> +#include <QSettings> +#include <QSlider> +#include <QTimer> +#include <QVBoxLayout> +#include <QtConcurrent> +#include <algorithm> +#include <cmath> + +const QString JPEGExportSheet::qualityKey = QStringLiteral("jpegExportQuality"); + +namespace { +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + label->setTextFormat(Qt::PlainText); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + label->setForegroundRole(role); + return label; +} + +QString grouped(qint64 value) +{ + return QLocale(QLocale::English, QLocale::UnitedStates).toString(value); +} + +// Swift reads a finite number alone, clamped to 0–1. +double startQuality() +{ + bool number = false; + const double saved = QSettings().value(JPEGExportSheet::qualityKey).toDouble(&number); + return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality; +} + +struct Encoded { + std::optional<JPEGResult> result; + std::optional<QString> failure; +}; +} + +// Swift's ZStack: dark gray, the fitted preview, a plate. +class JPEGExportSheet::Preview : public QWidget { +public: + using QWidget::QWidget; + QImage image; + bool updating = false; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(rect(), QColor::fromRgbF(0.12f, 0.12f, 0.12f)); + if (!image.isNull()) { + painter.setRenderHint(QPainter::SmoothPixmapTransform); + QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio)); + target.moveCenter(QRectF(rect()).center()); + painter.drawImage(target, image); + } + if (updating) { + // Swift's regular material behind the spinner. + QColor material = palette().color(QPalette::Window); + material.setAlphaF(0.85f); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(material); + QRectF plate(0, 0, 64, 64); + plate.moveCenter(QRectF(rect()).center()); + painter.drawRoundedRect(plate, 8, 8); + } + } +}; + +JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent) + : QWidget(parent), m_raster(std::move(raster)), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)), + m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)), + m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new SwatchButton([this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, 3, 0, 0.5, this)), + m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), + m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)) +{ + m_options.quality = startQuality(); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + column->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this)); + m_preview->setObjectName(QStringLiteral("jpegPreview")); + m_preview->setFixedSize(560, 330); + m_spinner->setObjectName(QStringLiteral("jpegSpinner")); + m_spinner->setRange(0, 0); + m_spinner->setTextVisible(false); + m_spinner->setGeometry(264, 149, 32, 32); + column->addWidget(m_preview); + + auto *quality = new QHBoxLayout; + auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this); + qualityLabel->setBuddy(m_quality); + // Swift's slider steps by hundredths. + m_quality->setObjectName(QStringLiteral("jpegQuality")); + m_quality->setRange(0, 100); + m_quality->setValue(int(std::lround(m_options.quality * 100))); + connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) { + m_options.quality = hundredths / 100.0; + request(); + }); + m_percent->setObjectName(QStringLiteral("jpegPercent")); + m_percent->setFixedWidth(45); + m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + quality->addWidget(qualityLabel); + quality->addWidget(m_quality, 1); + quality->addWidget(m_percent); + column->addLayout(quality); + + // Swift's ColorPicker: its label and a well, no opacity. + auto *matte = new QHBoxLayout; + matte->addWidget(new QLabel(QStringLiteral("Background for transparency"), this)); + m_matteSwatch->setObjectName(QStringLiteral("jpegMatte")); + m_matteSwatch->setFixedSize(36, 18); + m_matteSwatch->setAccessibleName(QStringLiteral("Background for transparency")); + connect(m_matteSwatch, &QAbstractButton::clicked, this, &JPEGExportSheet::pickMatte); + matte->addWidget(m_matteSwatch); + matte->addStretch(1); + column->addLayout(matte); + column->addWidget(text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal, + QPalette::PlaceholderText, this)); + + auto *buttons = new QHBoxLayout; + m_failure->setObjectName(QStringLiteral("jpegError")); + m_bytes->setObjectName(QStringLiteral("jpegBytes")); + m_note->setObjectName(QStringLiteral("jpegNote")); + buttons->addWidget(m_failure); + buttons->addWidget(m_bytes); + buttons->addWidget(m_note); + buttons->addStretch(1); + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("jpegCancel")); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + m_export->setObjectName(QStringLiteral("jpegExport")); + m_export->setDefault(true); + // Export rests until a result is ready. + connect(m_export, &QPushButton::clicked, this, [this] { + QSettings().setValue(qualityKey, m_options.quality); + m_finish(m_result.value().data); + }); + buttons->addWidget(cancel); + buttons->addWidget(m_export); + column->addLayout(buttons); + + m_wait->setObjectName(QStringLiteral("jpegWait")); + m_wait->setSingleShot(true); + m_wait->setInterval(200); + m_wait->setTimerType(Qt::PreciseTimer); + connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode); + request(); +} + +// SwiftUI cancels the task when the sheet goes. +JPEGExportSheet::~JPEGExportSheet() +{ + m_cancelled->store(true); +} + +// Swift's task(id:): the last request stops; this waits 200 ms. +void JPEGExportSheet::request() +{ + if (m_cancelled) + m_cancelled->store(true); + m_cancelled = std::make_shared<std::atomic_bool>(false); + m_error.reset(); + m_wait->start(); + synchronize(); +} + +void JPEGExportSheet::encode() +{ + const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled; + const JPEGOptions requested = m_options; + auto *watcher = new QFutureWatcher<Encoded>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] { + watcher->deleteLater(); + // Swift's check after the await: a newer request supersedes. + if (cancelled->load()) + return; + Encoded encoded = watcher->future().takeResult(); + if (encoded.result) { + m_result = std::move(encoded.result); + m_readyOptions = requested; + } else { + m_error = encoded.failure.value(); + } + synchronize(); + }); + watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Encoded { + try { + return {ImageExporter::jpeg(raster, requested, [cancelled] { return cancelled->load(); }), std::nullopt}; + } catch (const CancellationError &) { + return {}; + } catch (const ExportError &error) { + return {std::nullopt, QString::fromUtf8(error.what())}; + } + })); +} + +// The desktop's colour dialog, SwiftUI's colour panel, without alpha. +void JPEGExportSheet::pickMatte() +{ + auto *dialog = new QColorDialog(QColor::fromRgbF(float(m_options.red), float(m_options.green), float(m_options.blue)), this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(QStringLiteral("Background for transparency")); + connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) { + const QColor srgb = picked.toRgb(); + JPEGOptions next = m_options; + next.red = srgb.redF(); + next.green = srgb.greenF(); + next.blue = srgb.blueF(); + // Swift's onChange: the same colour asks nothing. + if (next == m_options) + return; + m_options = next; + m_matteSwatch->update(); + request(); + }); + dialog->open(); +} + +void JPEGExportSheet::synchronize() +{ + const bool ready = m_result && m_readyOptions == m_options; + const bool updating = m_readyOptions != m_options && !m_error; + m_preview->image = m_result ? m_result->preview : QImage(); + m_preview->updating = updating; + m_preview->update(); + m_spinner->setVisible(updating); + m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100))); + m_failure->setVisible(m_error.has_value()); + m_failure->setText(m_error.value_or(QString())); + m_bytes->setVisible(ready && !m_error); + m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString()); + m_note->setVisible(!m_error); + m_note->setText(ready ? QStringLiteral("· encoded preview, fitted to window") : QStringLiteral("Updating preview…")); + m_export->setEnabled(ready && !m_error); +} diff --git a/src/UI/JPEGExportSheet.h b/src/UI/JPEGExportSheet.h new file mode 100644 index 0000000..82a08d0 --- /dev/null +++ b/src/UI/JPEGExportSheet.h @@ -0,0 +1,52 @@ +#pragma once +#include "Document/ColorPalette.h" +#include "IO/ImageExporter.h" +#include <QWidget> +#include <atomic> +#include <functional> +#include <memory> +#include <optional> + +class QLabel; +class QProgressBar; +class QPushButton; +class QSlider; +class QTimer; +class SwatchButton; + +// Swift's JPEGExportSheet: quality, matte and the encoded preview. +class JPEGExportSheet : public QWidget { + Q_OBJECT +public: + // The last export's quality, where the next one starts. + static const QString qualityKey; + + JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr); + ~JPEGExportSheet() override; + +private: + class Preview; + void request(); + void encode(); + void pickMatte(); + void synchronize(); + + const ExportRaster m_raster; + const std::function<void(std::optional<QByteArray>)> m_finish; + JPEGOptions m_options; + std::optional<JPEGResult> m_result; + std::optional<JPEGOptions> m_readyOptions; + std::optional<QString> m_error; + // The newest request's flag, Swift's task cancellation. + std::shared_ptr<std::atomic_bool> m_cancelled; + QTimer *const m_wait; + Preview *const m_preview; + QProgressBar *const m_spinner; + QSlider *const m_quality; + QLabel *const m_percent; + SwatchButton *const m_matteSwatch; + QLabel *const m_failure; + QLabel *const m_bytes; + QLabel *const m_note; + QPushButton *const m_export; +}; diff --git a/src/UI/LassoControls.cpp b/src/UI/LassoControls.cpp new file mode 100644 index 0000000..ca3eb99 --- /dev/null +++ b/src/UI/LassoControls.cpp @@ -0,0 +1,214 @@ +#include "UI/LassoControls.h" +#include <QFrame> +#include <QRegularExpressionValidator> +#include <QFocusEvent> +#include <QKeyEvent> +#include <algorithm> +#include <cmath> + +SelectionAmountField::SelectionAmountField(EditorSession &session, int low, int high, std::function<double()> value, + std::function<void(double)> change, QWidget *parent) + : QLineEdit(parent), m_session(session), m_low(low), m_high(high), m_value(std::move(value)), m_change(std::move(change)), m_amount(low) +{ + setFont(ToolHeaderStyle::controlFont()); + setFixedWidth(40); + setAlignment(Qt::AlignRight | Qt::AlignVCenter); + // Digits only; the bounds clamp, as Swift's binding does. + setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), this)); + // Typing applies at once; an overflow reads as the top. + connect(this, &QLineEdit::textEdited, this, [this](const QString &text) { + if (text.isEmpty()) + return; + bool fits = false; + const qlonglong typed = text.toLongLong(&fits); + m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : m_high)); + }); +} + +void SelectionAmountField::sync(int amount) +{ + m_amount = amount; + if (!hasFocus() && !m_borrowed) + setText(QString::number(amount)); +} + +void SelectionAmountField::apply() +{ + setText(QString::number(m_amount)); +} + +void SelectionAmountField::keyPressEvent(QKeyEvent *event) +{ + // Return and Escape hand the canvas the keys. + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Escape) { + apply(); + clearFocus(); + m_session.requestCanvasFocus(); + return; + } + if (event->key() != Qt::Key_Up && event->key() != Qt::Key_Down) { + QLineEdit::keyPressEvent(event); + return; + } + const int amount = (event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1); + const double stepped = std::clamp(m_value() + amount, double(m_low), double(m_high)); + m_change(stepped); + setText(QString::number(std::lround(stepped))); +} + +void SelectionAmountField::focusOutEvent(QFocusEvent *event) +{ + m_borrowed = event->reason() == Qt::MenuBarFocusReason || event->reason() == Qt::PopupFocusReason; + if (!m_borrowed) + apply(); + QLineEdit::focusOutEvent(event); +} + +LassoControls::LassoControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QString(), parent), m_session(session), m_marqueeKinds(new QButtonGroup(this)), m_lassoKinds(new QButtonGroup(this)), + m_modes(new QButtonGroup(this)), + m_rectangle(choice(m_marqueeKinds, QStringLiteral("marqueeRectangle"), rawValue(LassoKind::rectangle), QStringLiteral("Switch between Rectangle and Ellipse"), + [this] { m_session.cancelLasso(); m_session.setMarqueeKind(LassoKind::rectangle); })), + m_ellipse(choice(m_marqueeKinds, QStringLiteral("marqueeEllipse"), rawValue(LassoKind::ellipse), QStringLiteral("Switch between Rectangle and Ellipse"), + [this] { m_session.cancelLasso(); m_session.setMarqueeKind(LassoKind::ellipse); })), + m_freehand(choice(m_lassoKinds, QStringLiteral("lassoFreehand"), rawValue(LassoKind::freehand), QStringLiteral("Switch between Freehand and Polygonal"), + [this] { m_session.cancelLasso(); m_session.setLassoKind(LassoKind::freehand); })), + m_polygonal(choice(m_lassoKinds, QStringLiteral("lassoPolygonal"), rawValue(LassoKind::polygonal), QStringLiteral("Switch between Freehand and Polygonal"), + [this] { m_session.cancelLasso(); m_session.setLassoKind(LassoKind::polygonal); })), + m_replace(choice(m_modes, QStringLiteral("selectionModeNew"), rawValue(SelectionMode::replace), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"), + [this] { m_session.setSelectionModeChoice(SelectionMode::replace); })), + m_add(choice(m_modes, QStringLiteral("selectionModeAdd"), rawValue(SelectionMode::add), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"), + [this] { m_session.setSelectionModeChoice(SelectionMode::add); })), + m_subtract(choice(m_modes, QStringLiteral("selectionModeSubtract"), rawValue(SelectionMode::subtract), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"), + [this] { m_session.setSelectionModeChoice(SelectionMode::subtract); })), + m_toleranceLabel(new QLabel(QStringLiteral("Tolerance"), this)), + m_tolerance(new SelectionAmountField(session, 0, 255, [this] { return double(m_session.wandSettings().tolerance); }, + [this](double tolerance) { changeWand([tolerance](WandSettings &wand) { wand.tolerance = int(std::lround(tolerance)); }); }, this)), + m_sampleSize(new QComboBox(this)), m_sources(new QButtonGroup(this)), + m_thisLayer(choice(m_sources, QStringLiteral("wandThisLayer"), QStringLiteral("This Layer"), + QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"), + [this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = false; }); })), + m_allLayers(choice(m_sources, QStringLiteral("wandAllLayers"), QStringLiteral("All Layers"), + QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"), + [this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = true; }); })), + m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)), + m_antialias(new QCheckBox(QStringLiteral("Anti-alias"), this)), m_expand(new QPushButton(QStringLiteral("Expand"), this)), + m_expandAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionExpandAmount()); }, + [this](double amount) { m_session.setSelectionExpandAmount(int(std::lround(amount))); }, this)), + m_contract(new QPushButton(QStringLiteral("Contract"), this)), + m_contractAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionContractAmount()); }, + [this](double amount) { m_session.setSelectionContractAmount(int(std::lround(amount))); }, this)), + m_empty(new QLabel(QStringLiteral("Empty selection"), this)), m_deselect(new QPushButton(QStringLiteral("Deselect"), this)) +{ + m_toleranceLabel->setFont(ToolHeaderStyle::controlFont()); + m_tolerance->setObjectName(QStringLiteral("wandTolerance")); + m_tolerance->setFixedWidth(44); + m_tolerance->setToolTip(QStringLiteral("How far each color channel (0–255) can differ from the clicked color and still be selected")); + m_toleranceLabel->setToolTip(m_tolerance->toolTip()); + m_sampleSize->setObjectName(QStringLiteral("wandSampleSize")); + m_sampleSize->setToolTip(QStringLiteral("Match the clicked pixel, or the average of the pixels around it")); + for (const WandSampleSize size : {WandSampleSize::point, WandSampleSize::threeByThree, WandSampleSize::fiveByFive}) + m_sampleSize->addItem(::title(size), int(size)); + connect(m_sampleSize, &QComboBox::activated, this, [this](int index) { + changeWand([index](WandSettings &wand) { wand.sampleSize = WandSampleSize(index); }); + }); + m_contiguous->setObjectName(QStringLiteral("wandContiguous")); + m_contiguous->setToolTip(QStringLiteral("Select only similar pixels connected to the one you click; off selects them everywhere")); + connect(m_contiguous, &QCheckBox::toggled, this, [this](bool on) { changeWand([on](WandSettings &wand) { wand.contiguous = on; }); }); + // Rectangles snap to whole pixels, so smoothing applies elsewhere. + m_antialias->setObjectName(QStringLiteral("selectionAntialiased")); + m_antialias->setToolTip(QStringLiteral("Smooth selection edges; turn off for hard pixel edges")); + connect(m_antialias, &QCheckBox::toggled, this, [this](bool on) { m_session.setSelectionAntialiased(on); }); + auto *divider = new QFrame(this); + divider->setFrameShape(QFrame::VLine); + divider->setFixedHeight(18); + m_expand->setObjectName(QStringLiteral("expandSelection")); + m_expand->setToolTip(QStringLiteral("Expand the selection by this many pixels")); + connect(m_expand, &QPushButton::clicked, this, [this] { m_session.expandSelection(m_session.selectionExpandAmount()); }); + m_expandAmount->setObjectName(QStringLiteral("selectionExpandAmount")); + m_expandAmount->setToolTip(m_expand->toolTip()); + m_contract->setObjectName(QStringLiteral("contractSelection")); + m_contract->setToolTip(QStringLiteral("Contract the selection by this many pixels")); + connect(m_contract, &QPushButton::clicked, this, [this] { m_session.contractSelection(m_session.selectionContractAmount()); }); + m_contractAmount->setObjectName(QStringLiteral("selectionContractAmount")); + m_contractAmount->setToolTip(m_contract->toolTip()); + m_empty->setObjectName(QStringLiteral("emptySelection")); + m_empty->setFont(ToolHeaderStyle::controlFont()); + m_empty->setForegroundRole(QPalette::PlaceholderText); + m_deselect->setObjectName(QStringLiteral("deselect")); + connect(m_deselect, &QPushButton::clicked, this, [this] { m_session.deselect(); }); + // Swift's `unitSuffix`: a unit sits by each field. + for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel, + m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_antialias, + divider, m_expand, m_expandAmount, new QLabel(QStringLiteral("px"), this), m_contract, m_contractAmount, + new QLabel(QStringLiteral("px"), this)}) + row->insertWidget(row->count() - 1, widget); + row->addWidget(m_empty); + row->addWidget(m_deselect); + connect(&m_session, &EditorSession::changed, this, &LassoControls::synchronize); + synchronize(); +} + +// One field of the wand's settings changes; the rest stay. +void LassoControls::changeWand(const std::function<void(WandSettings &)> &change) +{ + WandSettings wand = m_session.wandSettings(); + change(wand); + m_session.setWandSettings(wand); +} + +// A segment of Swift's segmented picker: one checkable button. +QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick) +{ + auto *button = new QToolButton(this); + button->setObjectName(name); + button->setText(text); + button->setToolTip(tip); + button->setFont(ToolHeaderStyle::controlFont()); + button->setCheckable(true); + button->setFocusPolicy(Qt::NoFocus); + group->addButton(button); + connect(button, &QToolButton::clicked, this, [pick] { pick(); }); + return button; +} + +void LassoControls::synchronize() +{ + const NavigationTool tool = m_session.tool(); + title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic Wand") : QStringLiteral("Lasso")); + for (QToolButton *button : {m_rectangle, m_ellipse}) + button->setVisible(tool == NavigationTool::marquee); + for (QToolButton *button : {m_freehand, m_polygonal}) + button->setVisible(tool == NavigationTool::lasso); + m_rectangle->setChecked(m_session.marqueeKind() == LassoKind::rectangle); + m_ellipse->setChecked(m_session.marqueeKind() == LassoKind::ellipse); + m_freehand->setChecked(m_session.lassoKind() == LassoKind::freehand); + m_polygonal->setChecked(m_session.lassoKind() == LassoKind::polygonal); + // Held Shift or Alt, or an outline's mode, shows live. + const SelectionMode mode = m_session.displayedSelectionMode(); + m_replace->setChecked(mode == SelectionMode::replace); + m_add->setChecked(mode == SelectionMode::add); + m_subtract->setChecked(mode == SelectionMode::subtract); + const WandSettings &wand = m_session.wandSettings(); + for (QWidget *widget : std::initializer_list<QWidget *>{m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous}) + widget->setVisible(tool == NavigationTool::wand); + m_tolerance->sync(wand.tolerance); + m_sampleSize->setCurrentIndex(int(wand.sampleSize)); + m_thisLayer->setChecked(!wand.sampleAllLayers); + m_allLayers->setChecked(wand.sampleAllLayers); + if (m_contiguous->isChecked() != wand.contiguous) + m_contiguous->setChecked(wand.contiguous); + m_antialias->setVisible(tool == NavigationTool::lasso || tool == NavigationTool::wand || (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse)); + if (m_antialias->isChecked() != m_session.selectionAntialiased()) + m_antialias->setChecked(m_session.selectionAntialiased()); + const bool modifies = m_session.canModifySelection(); + for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_contract, m_contractAmount}) + widget->setEnabled(modifies); + m_expandAmount->sync(m_session.selectionExpandAmount()); + m_contractAmount->sync(m_session.selectionContractAmount()); + const std::optional<DocumentSelection> selection = m_session.selection(); + m_empty->setVisible(selection && selection->isEmpty()); + m_deselect->setVisible(selection.has_value()); + m_deselect->setEnabled(m_session.canEditSelection()); + setEnabled(!m_session.showsBusy() && m_session.document().has_value()); +} diff --git a/src/UI/LassoControls.h b/src/UI/LassoControls.h new file mode 100644 index 0000000..d720b42 --- /dev/null +++ b/src/UI/LassoControls.h @@ -0,0 +1,75 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ToolHeaderStyle.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QComboBox> +#include <QLineEdit> +#include <QPushButton> +#include <QToolButton> +#include <functional> + +// A bounded whole number, typed or stepped, clamped. +class SelectionAmountField : public QLineEdit { + Q_OBJECT +public: + // Swift's arrowSteps: arrows step from `value`, clamped, into `change`. + SelectionAmountField(EditorSession &session, int low, int high, std::function<double()> value, std::function<void(double)> change, + QWidget *parent = nullptr); + void sync(int amount); + +protected: + void keyPressEvent(QKeyEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + +private: + void apply(); + + EditorSession &m_session; + const int m_low; + const int m_high; + const std::function<double()> m_value; + const std::function<void(double)> m_change; + int m_amount; + // A menu or popup borrows the focus: the typing stays. + bool m_borrowed = false; +}; + +// The selection tools' bar: kinds, mode, edges. +class LassoControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit LassoControls(EditorSession &session, QWidget *parent = nullptr); + +private: + QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick); + void changeWand(const std::function<void(WandSettings &)> &change); + void synchronize(); + + EditorSession &m_session; + QButtonGroup *const m_marqueeKinds; + QButtonGroup *const m_lassoKinds; + QButtonGroup *const m_modes; + QToolButton *const m_rectangle; + QToolButton *const m_ellipse; + QToolButton *const m_freehand; + QToolButton *const m_polygonal; + QToolButton *const m_replace; + QToolButton *const m_add; + QToolButton *const m_subtract; + // The wand's own: tolerance, sample size, source, contiguous. + QLabel *const m_toleranceLabel; + SelectionAmountField *const m_tolerance; + QComboBox *const m_sampleSize; + QButtonGroup *const m_sources; + QToolButton *const m_thisLayer; + QToolButton *const m_allLayers; + QCheckBox *const m_contiguous; + QCheckBox *const m_antialias; + QPushButton *const m_expand; + SelectionAmountField *const m_expandAmount; + QPushButton *const m_contract; + SelectionAmountField *const m_contractAmount; + QLabel *const m_empty; + QPushButton *const m_deselect; +}; diff --git a/src/UI/LayerAppearanceControls.cpp b/src/UI/LayerAppearanceControls.cpp new file mode 100644 index 0000000..1880fc8 --- /dev/null +++ b/src/UI/LayerAppearanceControls.cpp @@ -0,0 +1,157 @@ +#include "UI/LayerAppearanceControls.h" +#include "UI/BlendModePicker.h" +#include <QApplication> +#include <QHBoxLayout> +#include <QKeyEvent> +#include <QMouseEvent> +#include <QLabel> +#include <QVBoxLayout> +#include <cmath> + +namespace { +QLabel *caption(const QString &text, QWidget *parent) +{ + auto *label = new QLabel(text, parent); + QFont font = label->font(); + font.setPixelSize(10); + label->setFont(font); + return label; +} +} + +LayerAppearanceControls::LayerAppearanceControls(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_picker(new BlendModePicker(session, this)), m_slider(new QSlider(Qt::Horizontal, this)), + m_percentage(new QLineEdit(this)) +{ + setObjectName(QStringLiteral("layerAppearance")); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(12, 12, 12, 12); + column->setSpacing(8); + auto *blend = new QHBoxLayout; + blend->addWidget(caption(QStringLiteral("Blend"), this)); + blend->addWidget(m_picker, 1); + column->addLayout(blend); + auto *opacity = new QHBoxLayout; + opacity->setSpacing(6); + opacity->addWidget(caption(QStringLiteral("Opacity"), this)); + // A thousandth a step; a drag is one undo step. + m_slider->setRange(0, 1000); + m_slider->setObjectName(QStringLiteral("opacitySlider")); + m_slider->setAccessibleName(QStringLiteral("Opacity")); + opacity->addWidget(m_slider, 1); + m_percentage->setObjectName(QStringLiteral("opacityPercent")); + m_percentage->setAccessibleName(QStringLiteral("Opacity percent")); + m_percentage->setFixedWidth(44); + m_percentage->installEventFilter(this); + auto *field = new QHBoxLayout; + field->setSpacing(2); + field->addWidget(m_percentage); + field->addWidget(caption(QStringLiteral("%"), this)); + opacity->addLayout(field); + column->addLayout(opacity); + // A release in a popup opened mid-drag is watched application-wide. + connect(m_slider, &QSlider::sliderPressed, this, [this] { + m_session.beginOpacityEdit(); + qApp->installEventFilter(this); + }); + connect(m_slider, &QSlider::sliderReleased, this, [this] { + qApp->removeEventFilter(this); + m_session.finishOpacityEdit(); + }); + connect(m_slider, &QSlider::valueChanged, this, [this](int value) { + if (!m_syncing) + m_session.setLayerOpacity(value / 1000.0); + }); + connect(&m_session, &EditorSession::changed, this, &LayerAppearanceControls::synchronize); + synchronize(); +} + +LayerAppearanceControls::~LayerAppearanceControls() +{ + // Swift's onDisappear: a drag never outlives its controls. + m_session.finishOpacityEdit(); +} + +void LayerAppearanceControls::synchronize() +{ + setEnabled(m_session.canEditAppearance()); + const std::optional<ImageLayer> active = m_session.activeLayer(); + const double opacity = active ? active->opacity : 1; + m_syncing = true; + m_slider->setValue(int(std::lround(opacity * 1000))); + m_syncing = false; + // Another layer makes the controls anew, as Swift's `.id` does. + if (m_layerID != m_session.activeLayerID()) { + // Leaving applies to the old layer only; it is gone. + m_percentage->clearFocus(); + m_layerID = m_session.activeLayerID(); + sync(); + } else if (!m_percentage->hasFocus()) { + sync(); + } +} + +void LayerAppearanceControls::step(double percent) +{ + if (m_session.activeLayerID() != m_layerID) + return; + // The session clamps; Swift's own clamp is left out. + m_session.setLayerOpacity(percent / 100); + sync(); +} + +// Leaving applies the text once, through the FocusOut. +void LayerAppearanceControls::releaseFocus() +{ + m_percentage->clearFocus(); + m_session.requestCanvasFocus(); +} + +void LayerAppearanceControls::sync() +{ + const std::optional<ImageLayer> active = m_session.activeLayer(); + m_percentage->setText(QString::number(std::lround((active ? active->opacity : 1) * 100))); +} + +void LayerAppearanceControls::applyPercentage() +{ + if (m_session.activeLayerID() != m_layerID) + return; + bool number = false; + const double value = m_percentage->text().toDouble(&number); + // The session refuses a value that is no finite number. + if (number) + m_session.setLayerOpacity(value / 100); + sync(); +} + +bool LayerAppearanceControls::eventFilter(QObject *watched, QEvent *event) +{ + if (watched != m_percentage) { + const bool release = event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton; + if (release && m_slider->isSliderDown()) + m_slider->setSliderDown(false); + return false; + } + if (event->type() == QEvent::FocusOut) { + // A menu borrows focus and gives it back: no leaving. + const Qt::FocusReason reason = static_cast<QFocusEvent *>(event)->reason(); + if (reason != Qt::MenuBarFocusReason && reason != Qt::PopupFocusReason) + applyPercentage(); + return false; + } + if (event->type() != QEvent::KeyPress) + return QWidget::eventFilter(watched, event); + const auto *key = static_cast<QKeyEvent *>(event); + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) { + releaseFocus(); + return true; + } + if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down) + return QWidget::eventFilter(watched, event); + // Up and Down nudge one percent, ten with Shift. + const std::optional<ImageLayer> active = m_session.activeLayer(); + const double amount = (key->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1); + step(std::round((active ? active->opacity : 1) * 100) + amount); + return true; +} diff --git a/src/UI/LayerAppearanceControls.h b/src/UI/LayerAppearanceControls.h new file mode 100644 index 0000000..7eb36de --- /dev/null +++ b/src/UI/LayerAppearanceControls.h @@ -0,0 +1,33 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QLineEdit> +#include <QSlider> +#include <QWidget> + +class BlendModePicker; + +// The active layer's blend mode and opacity. +class LayerAppearanceControls : public QWidget { + Q_OBJECT +public: + explicit LayerAppearanceControls(EditorSession &session, QWidget *parent = nullptr); + ~LayerAppearanceControls() override; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void synchronize(); + void step(double percent); + void releaseFocus(); + void sync(); + void applyPercentage(); + + EditorSession &m_session; + BlendModePicker *const m_picker; + QSlider *const m_slider; + QLineEdit *const m_percentage; + // The layer the controls were last made for. + std::optional<QUuid> m_layerID; + bool m_syncing = false; +}; diff --git a/src/UI/LayerIcons.cpp b/src/UI/LayerIcons.cpp new file mode 100644 index 0000000..03495b3 --- /dev/null +++ b/src/UI/LayerIcons.cpp @@ -0,0 +1,245 @@ +#include "UI/LayerIcons.h" +#include "UI/ToolIcons.h" +#include <QPainterPath> + +namespace { +void eye(QPainter &painter, bool slashed) +{ + QPainterPath lid; + lid.moveTo(1.5, 9); + lid.cubicTo(5, 3, 13, 3, 16.5, 9); + lid.cubicTo(13, 15, 5, 15, 1.5, 9); + painter.drawPath(lid); + painter.drawEllipse(QPointF(9, 9), 2.6, 2.6); + if (slashed) + painter.drawLine(QPointF(3, 15), QPointF(15, 3)); +} + +void chevron(QPainter &painter, bool down) +{ + if (down) + painter.drawPolyline(QPolygonF{QPointF(4.5, 6.5), QPointF(9, 11), QPointF(13.5, 6.5)}); + else + painter.drawPolyline(QPolygonF{QPointF(6.5, 4.5), QPointF(11, 9), QPointF(6.5, 13.5)}); +} + +void folder(QPainter &painter, bool badged) +{ + QPainterPath shape; + shape.moveTo(2, 4.5); + shape.lineTo(7, 4.5); + shape.lineTo(8.5, 6.5); + shape.lineTo(16, 6.5); + shape.lineTo(16, 14.5); + shape.lineTo(2, 14.5); + shape.closeSubpath(); + painter.drawPath(shape); + if (badged) { + painter.drawLine(QPointF(9, 8.5), QPointF(9, 12.5)); + painter.drawLine(QPointF(7, 10.5), QPointF(11, 10.5)); + } +} + +// Swift's chain turned 45 degrees: upright in a narrow gap. +void link(QPainter &painter) +{ + painter.save(); + painter.translate(9, 9); + painter.rotate(-45); + painter.drawRoundedRect(QRectF(-6.5, -2.2, 7.5, 4.4), 2.2, 2.2); + painter.drawRoundedRect(QRectF(-1, -2.2, 7.5, 4.4), 2.2, 2.2); + painter.restore(); +} + +void newLayer(QPainter &painter) +{ + painter.drawRoundedRect(QRectF(2.5, 2.5, 13, 13), 2, 2); + painter.drawLine(QPointF(9, 6), QPointF(9, 12)); + painter.drawLine(QPointF(6, 9), QPointF(12, 9)); +} + +void addMask(QPainter &painter) +{ + painter.drawRoundedRect(QRectF(2.5, 3.5, 13, 11), 2, 2); + painter.fillRect(QRectF(5.5, 6.5, 7, 5), painter.pen().color()); +} + +void trash(QPainter &painter) +{ + painter.drawLine(QPointF(3, 5), QPointF(15, 5)); + painter.drawLine(QPointF(7, 3), QPointF(11, 3)); + painter.drawPolyline(QPolygonF{QPointF(4.5, 5), QPointF(5.5, 15.5), QPointF(12.5, 15.5), QPointF(13.5, 5)}); + painter.drawLine(QPointF(7.5, 8), QPointF(7.8, 13)); + painter.drawLine(QPointF(10.5, 8), QPointF(10.2, 13)); +} + +// Swift's square.3.layers.3d: three tilted plates. +void layers(QPainter &painter) +{ + for (int step = 0; step < 3; ++step) { + const double y = 4 + step * 4; + painter.drawPolygon(QPolygonF{QPointF(9, y - 3), QPointF(16, y), QPointF(9, y + 3), QPointF(2, y)}); + } +} + +// Swift's circle.lefthalf.filled: a ring, its left half solid. +void halfFilledCircle(QPainter &painter) +{ + QPainterPath half; + half.moveTo(9, 2); + half.arcTo(QRectF(2, 2, 14, 14), 90, 180); + half.closeSubpath(); + painter.fillPath(half, painter.pen().color()); + painter.drawEllipse(QPointF(9, 9), 7, 7); +} + +// Swift's slider.horizontal.3: three tracks, each with a knob. +void sliders(QPainter &painter) +{ + for (const auto &[y, knob] : {std::pair{4.0, 12.0}, std::pair{9.0, 6.0}, std::pair{14.0, 10.5}}) { + painter.drawLine(QPointF(2, y), QPointF(knob - 2.8, y)); + painter.drawLine(QPointF(knob + 2.8, y), QPointF(16, y)); + painter.drawEllipse(QPointF(knob, y), 2, 2); + } +} + +// Swift's point.topleft.down.to.point.bottomright.curvepath. +void curvePath(QPainter &painter) +{ + QPainterPath curve; + curve.moveTo(5.5, 3.5); + curve.cubicTo(12, 3.5, 6, 14.5, 12.5, 14.5); + painter.drawPath(curve); + painter.setBrush(painter.pen().color()); + painter.drawEllipse(QPointF(3.5, 3.5), 1.6, 1.6); + painter.drawEllipse(QPointF(14.5, 14.5), 1.6, 1.6); +} + +// Swift's plusminus.circle. +void plusMinusCircle(QPainter &painter) +{ + painter.drawEllipse(QPointF(9, 9), 7, 7); + painter.drawLine(QPointF(6.5, 7.5), QPointF(11.5, 7.5)); + painter.drawLine(QPointF(9, 5), QPointF(9, 10)); + painter.drawLine(QPointF(6.5, 12.2), QPointF(11.5, 12.2)); +} + +// Swift's paintpalette: a palette with a thumb notch and wells. +void paintPalette(QPainter &painter) +{ + QPainterPath palette; + palette.moveTo(9, 2.5); + palette.cubicTo(13.5, 2.5, 16, 5.5, 16, 8.5); + palette.cubicTo(16, 11, 13.5, 11.5, 12, 11.2); + palette.cubicTo(10.8, 11, 10.2, 12.2, 10.8, 13.4); + palette.cubicTo(11.5, 15, 10.5, 15.8, 9, 15.8); + palette.cubicTo(5, 15.8, 2, 12.8, 2, 9); + palette.cubicTo(2, 5.2, 5, 2.5, 9, 2.5); + painter.drawPath(palette); + painter.setBrush(painter.pen().color()); + for (const QPointF well : {QPointF(5.8, 6.8), QPointF(9.3, 5.4), QPointF(12.7, 7.3), QPointF(5.4, 10.6)}) + painter.drawEllipse(well, 1.1, 1.1); +} + +// Swift's circle.grid.3x3: nine dots. +void circleGrid(QPainter &painter) +{ + painter.setBrush(painter.pen().color()); + painter.setPen(Qt::NoPen); + for (const double y : {4.0, 9.0, 14.0}) { + for (const double x : {4.0, 9.0, 14.0}) + painter.drawEllipse(QPointF(x, y), 1.7, 1.7); + } +} + +// Swift's sparkles: a large four-pointed star, two small ones. +void sparkles(QPainter &painter) +{ + const auto star = [&painter](QPointF centre, double radius) { + const double bend = radius * 0.18; + QPainterPath path; + path.moveTo(centre.x(), centre.y() - radius); + path.quadTo(centre.x() + bend, centre.y() - bend, centre.x() + radius, centre.y()); + path.quadTo(centre.x() + bend, centre.y() + bend, centre.x(), centre.y() + radius); + path.quadTo(centre.x() - bend, centre.y() + bend, centre.x() - radius, centre.y()); + path.quadTo(centre.x() - bend, centre.y() - bend, centre.x(), centre.y() - radius); + painter.fillPath(path, painter.pen().color()); + }; + star(QPointF(7.5, 10.5), 6.5); + star(QPointF(14, 4.5), 3); + star(QPointF(14.5, 14), 2); +} +} + +void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double side, const QColor &colour) +{ + painter.save(); + painter.setRenderHint(QPainter::Antialiasing); + painter.translate(origin); + painter.scale(side / 18, side / 18); + painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.setBackgroundMode(Qt::TransparentMode); + switch (icon) { + case LayerIcon::eye: eye(painter, false); break; + case LayerIcon::eyeSlash: eye(painter, true); break; + case LayerIcon::chevronRight: chevron(painter, false); break; + case LayerIcon::chevronDown: chevron(painter, true); break; + case LayerIcon::folder: folder(painter, false); break; + case LayerIcon::link: link(painter); break; + case LayerIcon::newLayer: newLayer(painter); break; + case LayerIcon::newFolder: folder(painter, true); break; + case LayerIcon::addMask: addMask(painter); break; + case LayerIcon::trash: trash(painter); break; + case LayerIcon::layers: layers(painter); break; + // Swift's textformat, as the rail draws it, 1.21 times up. + case LayerIcon::text: ToolIcons::paint(painter, NavigationTool::type, QPointF(3.6, 3.6), 10.8, colour); break; + case LayerIcon::halfFilledCircle: halfFilledCircle(painter); break; + case LayerIcon::sliders: sliders(painter); break; + case LayerIcon::curvePath: curvePath(painter); break; + case LayerIcon::plusMinusCircle: plusMinusCircle(painter); break; + case LayerIcon::paintPalette: paintPalette(painter); break; + case LayerIcon::circleGrid: circleGrid(painter); break; + case LayerIcon::sparkles: sparkles(painter); break; + } + painter.restore(); +} + +QPixmap LayerIcons::pixmap(LayerIcon icon, double side, const QColor &colour, double ratio) +{ + QPixmap result(QSize(int(side * ratio), int(side * ratio))); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + QPainter painter(&result); + paint(painter, icon, QPointF(0, 0), side, colour); + return result; +} + +LayerIcon LayerIcons::symbol(AdjustmentKind kind) +{ + switch (kind) { + case AdjustmentKind::curves: return LayerIcon::curvePath; + case AdjustmentKind::levels: return LayerIcon::sliders; + case AdjustmentKind::hsv: return LayerIcon::halfFilledCircle; + case AdjustmentKind::exposure: return LayerIcon::plusMinusCircle; + case AdjustmentKind::gradientMap: return LayerIcon::paintPalette; + case AdjustmentKind::grain: return LayerIcon::circleGrid; + } + throw std::logic_error("unknown adjustment kind"); +} + +QPixmap LayerIcons::adjustmentThumbnail(AdjustmentKind kind, const QColor &colour, double ratio) +{ + QPixmap result(QSize(int(36 * ratio), int(36 * ratio))); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + QPainter painter(&result); + // Swift turns the curve a quarter clockwise: a tone curve. + if (kind == AdjustmentKind::curves) { + painter.translate(18, 18); + painter.rotate(90); + painter.translate(-18, -18); + } + paint(painter, symbol(kind), QPointF(7.2, 7.2), 18 * 1.2, colour); + return result; +} diff --git a/src/UI/LayerIcons.h b/src/UI/LayerIcons.h new file mode 100644 index 0000000..39a01ba --- /dev/null +++ b/src/UI/LayerIcons.h @@ -0,0 +1,22 @@ +#pragma once +#include "Document/LayerAdjustment.h" +#include <QColor> +#include <QPainter> +#include <QPixmap> + +// The layers panel's icons, drawn: SF Symbols have no twin. +enum class LayerIcon { + eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text, + halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles +}; + +namespace LayerIcons { +// Paints the icon into a `side` point square, one colour. +void paint(QPainter &painter, LayerIcon icon, QPointF origin, double side, const QColor &colour); +// The icon as a button's pixmap at a pixel ratio. +QPixmap pixmap(LayerIcon icon, double side, const QColor &colour, double ratio); +// Swift's AdjustmentKind.symbol: the glyph that names each kind. +LayerIcon symbol(AdjustmentKind kind); +// An adjustment row's 36-point thumbnail: its glyph 1.2 times up. +QPixmap adjustmentThumbnail(AdjustmentKind kind, const QColor &colour, double ratio); +} diff --git a/src/UI/LayerMaskMenu.cpp b/src/UI/LayerMaskMenu.cpp new file mode 100644 index 0000000..22bb295 --- /dev/null +++ b/src/UI/LayerMaskMenu.cpp @@ -0,0 +1,29 @@ +#include "UI/LayerMaskMenu.h" +#include "UI/LayerIcons.h" +#include <QEvent> + +LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session) +{ + setObjectName(QStringLiteral("addLayerMask")); + setAccessibleName(QStringLiteral("Add layer mask")); + setAutoRaise(true); + setFixedSize(32, 40); + connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); }); + connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize); + synchronize(); +} + +void LayerMaskMenu::synchronize() +{ + const std::optional<ImageLayer> active = m_session.activeLayer(); + setEnabled(m_session.canEditMask() && !(active && active->mask)); + setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask")); + setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio())); +} + +void LayerMaskMenu::changeEvent(QEvent *event) +{ + QToolButton::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + synchronize(); +} diff --git a/src/UI/LayerMaskMenu.h b/src/UI/LayerMaskMenu.h new file mode 100644 index 0000000..1a2cd89 --- /dev/null +++ b/src/UI/LayerMaskMenu.h @@ -0,0 +1,18 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QToolButton> + +// One click adds a mask: white, or the selection black. +class LayerMaskMenu : public QToolButton { + Q_OBJECT +public: + explicit LayerMaskMenu(EditorSession &session, QWidget *parent = nullptr); + +protected: + void changeEvent(QEvent *event) override; + +private: + void synchronize(); + + EditorSession &m_session; +}; diff --git a/src/UI/LayersPanel.cpp b/src/UI/LayersPanel.cpp new file mode 100644 index 0000000..4b5411b --- /dev/null +++ b/src/UI/LayersPanel.cpp @@ -0,0 +1,166 @@ +#include "UI/LayersPanel.h" +#include "UI/LayerAppearanceControls.h" +#include "UI/LayerIcons.h" +#include "UI/LayerMaskMenu.h" +#include "UI/NativeLayerList.h" +#include <QEvent> +#include <QFrame> +#include <QHBoxLayout> +#include <QMenu> +#include <QVBoxLayout> + +namespace { +QFrame *divider(QWidget *parent) +{ + auto *line = new QFrame(parent); + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Plain); + line->setForegroundRole(QPalette::Mid); + return line; +} + +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + return label; +} +} + +LayersPanel::LayersPanel(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_count(text(QStringLiteral("0"), 11, QFont::Normal, this)), m_body(new QStackedWidget(this)), + m_list(new NativeLayerList(session, this)), m_emptyHint(text(QString(), 11, QFont::Normal, this)) +{ + setObjectName(QStringLiteral("layersPanel")); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(0); + auto *heading = new QHBoxLayout; + heading->setContentsMargins(18, 18, 18, 18); + heading->addWidget(text(QStringLiteral("Layers"), 12, QFont::DemiBold, this)); + heading->addStretch(1); + m_count->setObjectName(QStringLiteral("layerCount")); + m_count->setForegroundRole(QPalette::PlaceholderText); + heading->addWidget(m_count); + column->addLayout(heading); + column->addWidget(divider(this)); + column->addWidget(new LayerAppearanceControls(session, this)); + column->addWidget(divider(this)); + // The rows, or the words for none: create, import, add. + auto *empty = new QWidget(this); + auto *emptyColumn = new QVBoxLayout(empty); + emptyColumn->setContentsMargins(16, 16, 16, 16); + emptyColumn->setSpacing(10); + emptyColumn->addStretch(1); + m_emptyIcon = new QLabel(empty); + m_emptyIcon->setAlignment(Qt::AlignCenter); + emptyColumn->addWidget(m_emptyIcon); + auto *title = text(QStringLiteral("No layers yet"), 13, QFont::Medium, empty); + title->setAlignment(Qt::AlignCenter); + title->setForegroundRole(QPalette::PlaceholderText); + emptyColumn->addWidget(title); + m_emptyHint->setObjectName(QStringLiteral("noLayersHint")); + m_emptyHint->setAlignment(Qt::AlignCenter); + m_emptyHint->setWordWrap(true); + m_emptyHint->setForegroundRole(QPalette::PlaceholderText); + emptyColumn->addWidget(m_emptyHint); + emptyColumn->addStretch(1); + m_body->setObjectName(QStringLiteral("layersBody")); + m_body->addWidget(m_list); + m_body->addWidget(empty); + column->addWidget(m_body, 1); + column->addWidget(divider(this)); + // Swift spaces the icons sixteen points apart. + auto *footer = new QHBoxLayout; + footer->setContentsMargins(8, 4, 8, 4); + footer->setSpacing(0); + m_addBlankLayer = footerButton(QStringLiteral("addBlankLayer"), QStringLiteral("New blank layer"), QStringLiteral("New blank layer (Ctrl+Shift+N)"), [this] { m_session.addBlankLayer(); }); + footer->addWidget(m_addBlankLayer); + m_group = footerButton(QStringLiteral("groupLayers"), QStringLiteral("New folder"), QStringLiteral("Group selected layers (Ctrl+G)"), [this] { m_session.groupSelectedLayers(); }); + footer->addWidget(m_group); + footer->addWidget(new LayerMaskMenu(session, this)); + // Presses open these menus; a click adds nothing. + m_effects = footerButton(QStringLiteral("layerEffects"), QStringLiteral("Layer effects"), QStringLiteral("Layer effects: stroke and drop shadow"), [] {}); + auto *effects = new QMenu(m_effects); + for (const LayerEffectKind kind : allLayerEffectKinds) + effects->addAction(rawValue(kind) + QStringLiteral("…"), this, [this, kind] { m_session.addEffect(kind); }); + m_effects->setMenu(effects); + m_effects->setPopupMode(QToolButton::InstantPopup); + footer->addWidget(m_effects); + m_adjustments = footerButton(QStringLiteral("addAdjustment"), QStringLiteral("New adjustment layer"), QStringLiteral("New adjustment layer"), [] {}); + auto *kinds = new QMenu(m_adjustments); + for (const AdjustmentKind kind : allAdjustmentKinds) + kinds->addAction(rawValue(kind), this, [this, kind] { m_session.addAdjustment(kind); }); + m_adjustments->setMenu(kinds); + m_adjustments->setPopupMode(QToolButton::InstantPopup); + footer->addWidget(m_adjustments); + footer->addStretch(1); + m_delete = footerButton(QStringLiteral("deleteLayer"), QStringLiteral("Delete selected layer"), QStringLiteral("Delete selected layer"), + [this] { m_session.deleteLayerOrMask(); }); + footer->addWidget(m_delete); + column->addLayout(footer); + applyIcons(); + connect(&m_session, &EditorSession::changed, this, &LayersPanel::synchronize); + synchronize(); +} + +// The icons take the palette's ink, again after each theme. +void LayersPanel::applyIcons() +{ + const QColor ink = palette().color(QPalette::PlaceholderText); + m_emptyIcon->setPixmap(LayerIcons::pixmap(LayerIcon::layers, 25, ink, devicePixelRatio())); + m_addBlankLayer->setIcon(LayerIcons::pixmap(LayerIcon::newLayer, 16, ink, devicePixelRatio())); + m_group->setIcon(LayerIcons::pixmap(LayerIcon::newFolder, 16, ink, devicePixelRatio())); + m_effects->setIcon(LayerIcons::pixmap(LayerIcon::sparkles, 16, ink, devicePixelRatio())); + m_adjustments->setIcon(LayerIcons::pixmap(LayerIcon::halfFilledCircle, 16, ink, devicePixelRatio())); + m_delete->setIcon(LayerIcons::pixmap(LayerIcon::trash, 16, ink, devicePixelRatio())); +} + +void LayersPanel::changeEvent(QEvent *event) +{ + QWidget::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + applyIcons(); +} + +QToolButton *LayersPanel::footerButton(const QString &name, const QString &label, const QString &tip, const std::function<void()> &run) +{ + auto *button = new QToolButton(this); + button->setObjectName(name); + button->setAccessibleName(label); + button->setToolTip(tip); + button->setAutoRaise(true); + button->setFixedSize(32, 40); + connect(button, &QToolButton::clicked, this, run); + return button; +} + +void LayersPanel::synchronize() +{ + const std::optional<CanvasDocument> &document = m_session.document(); + const size_t count = document ? document->layers.size() : 0; + 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()); + 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") + : several ? QStringLiteral("Delete selected layers") + : QStringLiteral("Delete selected layer"); + m_delete->setToolTip(what); + m_delete->setAccessibleName(what); + m_delete->setEnabled(m_session.canEditLayers() && m_session.activeLayer().has_value()); + // Swift's .task(id:): a new request begins its editor. + if (m_session.adjustmentEditingID() != m_adjustmentTask) { + m_adjustmentTask = m_session.adjustmentEditingID(); + if (m_adjustmentTask) + m_session.beginAdjustmentEditing(*m_adjustmentTask); + } +} diff --git a/src/UI/LayersPanel.h b/src/UI/LayersPanel.h new file mode 100644 index 0000000..cb9864b --- /dev/null +++ b/src/UI/LayersPanel.h @@ -0,0 +1,43 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QLabel> +#include <QStackedWidget> +#include <QToolButton> +#include <QWidget> + +class NativeLayerList; + +// The layers panel: heading, appearance, the rows, the footer. +class LayersPanel : public QWidget { + Q_OBJECT +public: + explicit LayersPanel(EditorSession &session, QWidget *parent = nullptr); + // Dragging the panel's left edge sets it, within these. + static constexpr double minimumWidth = 202; + static constexpr double maximumWidth = 352; + static constexpr double defaultWidth = 252; + + NativeLayerList &list() const { return *m_list; } + +protected: + void changeEvent(QEvent *event) override; + +private: + void synchronize(); + void applyIcons(); + QToolButton *footerButton(const QString &name, const QString &label, const QString &tip, const std::function<void()> &run); + + EditorSession &m_session; + QLabel *const m_count; + QStackedWidget *const m_body; + NativeLayerList *const m_list; + QLabel *const m_emptyHint; + QLabel *m_emptyIcon = nullptr; + QToolButton *m_addBlankLayer = nullptr; + QToolButton *m_group = nullptr; + QToolButton *m_effects = nullptr; + QToolButton *m_adjustments = nullptr; + QToolButton *m_delete = nullptr; + // The request Swift's .task(id:) last began. + std::optional<QUuid> m_adjustmentTask; +}; diff --git a/src/UI/LevelsSheet.cpp b/src/UI/LevelsSheet.cpp new file mode 100644 index 0000000..083bfaa --- /dev/null +++ b/src/UI/LevelsSheet.cpp @@ -0,0 +1,424 @@ +#include "UI/LevelsSheet.h" +#include "Rendering/EyedropperIcon.h" +#include <QEvent> +#include <QFrame> +#include <QHBoxLayout> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QVBoxLayout> +#include <cmath> + +namespace { +// The five entries: Swift's names, fields and decimals. +struct Entry { + const char *name; + double LevelRange::*value; + int decimals; +}; +constexpr std::array<Entry, 5> entries{{{"Input black", &LevelRange::black, 0}, + {"Gamma", &LevelRange::gamma, 2}, + {"Input white", &LevelRange::white, 0}, + {"Output black", &LevelRange::outputBlack, 0}, + {"Output white", &LevelRange::outputWhite, 0}}}; + +// SwiftUI's gray, and its dark red, green and blue. +const QColor gray(142, 142, 147); + +QColor colour(LevelsChannel channel) +{ + switch (channel) { + case LevelsChannel::rgb: return gray; + case LevelsChannel::red: return QColor(255, 66, 69); + case LevelsChannel::green: return QColor(48, 209, 88); + case LevelsChannel::blue: return QColor(0, 145, 255); + } + throw std::logic_error("no such channel"); +} + +// Swift's .caption: ten points, in the secondary ink unless primary. +QLabel *caption(const QString &text, QWidget *parent, QPalette::ColorRole ink = QPalette::PlaceholderText) +{ + auto *label = new QLabel(text, parent); + QFont small = label->font(); + small.setPixelSize(10); + label->setFont(small); + label->setForegroundRole(ink); + return label; +} + +// The channel's bins, scaled so spikes cannot flatten the rest. +class Histogram : public QWidget { +public: + Histogram(const EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_loading(caption(QStringLiteral("Loading histogram…"), this, QPalette::WindowText)) + { + setFixedHeight(150); + setToolTip(QStringLiteral("Linear histogram with automatic vertical scaling. Tall spikes may extend beyond the graph; all tones from 0 " + "to 255 remain included.")); + m_loading->move(8, 8); + connect(&session, &EditorSession::changed, this, [this] { synchronize(); }); + synchronize(); + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(rect(), QColor(0, 0, 0, 64)); + const std::optional<LevelsEdit> &edit = m_session.levels(); + if (!edit) + return; + const std::array<double, 256> &bins = edit->histogram[size_t(edit->settings.channel)]; + const double peak = LevelsHistogramDisplay::scale(bins); + if (peak <= 0) + return; + QPainterPath bars; + bars.setFillRule(Qt::WindingFill); + const double step = width() / 256.0; + for (size_t index = 0; index < 256; ++index) { + const double tall = height() * std::min(1.0, std::max(0.0, bins[index] / peak)); + bars.addRect(QRectF(double(index) * step, height() - tall, step + 0.1, tall)); + } + painter.setRenderHint(QPainter::Antialiasing); + painter.fillPath(bars, colour(edit->settings.channel)); + } + +private: + void synchronize() + { + const std::optional<LevelsEdit> &edit = m_session.levels(); + if (!edit) + return; + setAccessibleName(QStringLiteral("Original %1 histogram").arg(rawValue(edit->settings.channel))); + m_loading->setVisible(!edit->histogramReady); + update(); + } + + const EditorSession &m_session; + QLabel *const m_loading; +}; + +// Black to white across, over the output handles. +class Gradient : public QWidget { +public: + explicit Gradient(QWidget *parent) : QWidget(parent) { setFixedHeight(14); } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + QLinearGradient across(QPointF(0, 0), QPointF(width(), 0)); + across.setColorAt(0, Qt::black); + across.setColorAt(1, Qt::white); + painter.fillRect(rect(), across); + } +}; + +// Swift's handle: a triangle in a 22 by 20 frame. +class Handle : public QWidget { +public: + Handle(const QString &name, const QColor &fill, const QWidget &track, std::function<void(double)> drag) + : QWidget(track.parentWidget()), m_fill(fill), m_track(track), m_drag(std::move(drag)) + { + setAccessibleName(name); + resize(22, 20); + } + // Centred on the tone, nine points down the track. + void place(double tone) { move(int(std::round(m_track.x() + tone / 255 * m_track.width() - 11)), m_track.y() - 1); } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QPainterPath triangle(QPointF(11, 4.75)); + triangle.lineTo(17, 15.25); + triangle.lineTo(5, 15.25); + triangle.closeSubpath(); + // Swift's half-point gray shadow: Qt has no blur. + painter.strokePath(triangle, QPen(gray, 1)); + painter.fillPath(triangle, m_fill); + } + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) + drag(event->position()); + } + void mouseMoveEvent(QMouseEvent *event) override + { + if (event->buttons().testFlag(Qt::LeftButton)) + drag(event->position()); + } + +private: + // The pointer's tone along the track, as Swift's named space. + void drag(QPointF point) { m_drag((mapToParent(point).x() - m_track.x()) / m_track.width() * 255); } + + const QColor m_fill; + const QWidget &m_track; + const std::function<void(double)> m_drag; +}; + +// Swift's handles view: tones across, each triangle on top. +class Track : public QWidget { +public: + Track(const EditorSession &session, bool output, const std::function<void(size_t, double)> &drag, QWidget *parent) + : QWidget(parent), m_session(session), m_output(output) + { + setFixedHeight(20); + const QStringList names = output ? QStringList{QStringLiteral("Output black"), QStringLiteral("Output white")} + : QStringList{QStringLiteral("Input black"), QStringLiteral("Gamma"), QStringLiteral("Input white")}; + for (qsizetype index = 0; index < names.size(); ++index) { + const QColor fill = index == 0 ? QColor(Qt::black) : index + 1 == names.size() ? QColor(Qt::white) : gray; + m_handles.push_back(new Handle(names[index], fill, *this, [drag, index](double tone) { drag(size_t(index), tone); })); + } + connect(&session, &EditorSession::changed, this, [this] { place(); }); + } + +protected: + void moveEvent(QMoveEvent *) override { place(); } + void resizeEvent(QResizeEvent *) override { place(); } + +private: + void place() + { + const std::optional<LevelsEdit> &edit = m_session.levels(); + if (!edit) + return; + const LevelRange range = edit->settings.current(); + const std::vector<double> tones = m_output ? std::vector{range.outputBlack, range.outputWhite} + : std::vector{range.black, range.black + (range.white - range.black) * std::pow(0.5, range.gamma), range.white}; + for (size_t index = 0; index < tones.size(); ++index) + m_handles[index]->place(tones[index]); + } + + const EditorSession &m_session; + const bool m_output; + std::vector<Handle *> m_handles; +}; + +// Swift's drag: whole tones for ends, a fraction for gamma. +void dragHandle(LevelRange &range, bool output, size_t index, double tone) +{ + const double x = std::min(255.0, std::max(0.0, tone)); + if (output) + (index == 0 ? range.outputBlack : range.outputWhite) = std::round(x); + else if (index == 0) + range.black = std::min(range.white - 1, std::round(x)); + else if (index == 2) + range.white = std::max(range.black + 1, std::round(x)); + else + range.gamma = std::log(std::min(0.999, std::max(0.001, (x - range.black) / (range.white - range.black)))) / std::log(0.5); +} + +// The eyedropper glyph in one ink, 14 points. +QIcon eyedropper(const QColor &ink, double ratio) +{ + QPixmap pixmap(QSize(14, 14) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.scale(14.0 / 18, 14.0 / 18); + painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + EyedropperIcon::paint(painter); + return QIcon(pixmap); +} + +// Swift's HStack: spread by Spacers, or packed to the left. +QHBoxLayout *row(std::initializer_list<QWidget *> widgets, bool spread) +{ + auto *layout = new QHBoxLayout; + for (QWidget *widget : widgets) { + if (spread && layout->count() > 0) + layout->addStretch(1); + layout->addWidget(widget); + } + if (!spread) + layout->addStretch(1); + return layout; +} + +QVBoxLayout *stack(std::initializer_list<QWidget *> widgets, int spacing) +{ + auto *layout = new QVBoxLayout; + layout->setSpacing(spacing); + for (QWidget *widget : widgets) + layout->addWidget(widget); + return layout; +} +} + +LevelsSheet::LevelsSheet(EditorSession &session, QWidget *parent) + : QWidget(parent), m_session(session), m_channel(new QComboBox(this)), m_histogram(new Histogram(session, this)), + m_input(new Track(session, false, [this](size_t index, double tone) { dragTo(false, index, tone); }, this)), + m_gradient(new Gradient(this)), m_output(new Track(session, true, [this](size_t index, double tone) { dragTo(true, index, tone); }, this)), + m_fields{field(0), field(1), field(2), field(3), field(4)}, m_samples{new QPushButton(this), new QPushButton(this), new QPushButton(this)}, + m_sampleHint(caption(QString(), this)), m_autos{new QPushButton(this), new QPushButton(this), new QPushButton(this)}, + m_preview(new QCheckBox(QStringLiteral("Preview"), this)), m_reset(new QPushButton(QStringLiteral("Reset"), this)), + m_caption(caption(QString(), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_spinner(new QProgressBar(this)), + m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + setFixedWidth(440); + for (const LevelsChannel channel : allLevelsChannels) + m_channel->addItem(rawValue(channel)); + connect(m_channel, &QComboBox::activated, this, + [this](int index) { change([index](LevelsSettings &settings) { settings.channel = allLevelsChannels[size_t(index)]; }); }); + auto *picker = new QWidget(this); + picker->setFixedWidth(180); + auto *pickerRow = new QHBoxLayout(picker); + pickerRow->setContentsMargins(0, 0, 0, 0); + // The label names the picker, as Swift's Picker title. + auto *title = new QLabel(QStringLiteral("Channel"), picker); + title->setBuddy(m_channel); + pickerRow->addWidget(title); + pickerRow->addWidget(m_channel, 1); + m_histogram->setObjectName(QStringLiteral("levelsHistogram")); + m_input->setObjectName(QStringLiteral("levelsInputHandles")); + m_output->setObjectName(QStringLiteral("levelsOutputHandles")); + for (size_t index = 0; index < 3; ++index) { + m_samples[index]->setText(rawValue(allLevelsSamples[index])); + m_samples[index]->setCheckable(true); + m_samples[index]->setAutoDefault(false); + // A second click puts the eyedropper away. + connect(m_samples[index], &QPushButton::clicked, this, [this, index] { + const std::optional<LevelsSample> mode = allLevelsSamples[index]; + m_session.setLevelsSampleMode(m_session.levels().value().sampleMode == mode ? std::nullopt : mode); + }); + m_autos[index]->setText(rawValue(allLevelsAutos[index])); + m_autos[index]->setAutoDefault(false); + connect(m_autos[index], &QPushButton::clicked, this, [this, index] { m_session.autoLevels(allLevelsAutos[index]); }); + } + m_sampleHint->setWordWrap(true); + m_preview->setShortcut(QKeySequence(Qt::ALT | Qt::Key_P)); + connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateLevels(m_session.levels().value().settings, on); }); + m_reset->setAutoDefault(false); + connect(m_reset, &QPushButton::clicked, this, [this] { + m_session.setLevelsSampleMode(std::nullopt); + change([](LevelsSettings &settings) { settings = LevelsSettings(); }); + }); + m_cancel->setAutoDefault(false); + m_ok->setDefault(true); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelLevels(); }); + connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitLevels(); }); + m_spinner->setRange(0, 0); + m_spinner->setFixedWidth(40); + m_spinner->setTextVisible(false); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(16); + column->addWidget(picker); + column->addLayout(stack({m_histogram, m_input}, 0)); + column->addLayout(row({m_fields[0]->parentWidget(), m_fields[1]->parentWidget(), m_fields[2]->parentWidget()}, true)); + column->addLayout(stack({m_gradient, m_output}, 0)); + column->addLayout(row({m_fields[3]->parentWidget(), m_fields[4]->parentWidget()}, true)); + column->addLayout(row({caption(QStringLiteral("Sample"), this), m_samples[0], m_samples[1], m_samples[2]}, false)); + column->addWidget(m_sampleHint); + QVBoxLayout *automatic = stack({caption(QStringLiteral("Auto"), this)}, 6); + automatic->addLayout(row({m_autos[0], m_autos[1], m_autos[2]}, false)); + column->addLayout(automatic); + column->addLayout(row({m_preview, m_reset}, true)); + column->addWidget(m_caption); + auto *divider = new QFrame(this); + divider->setFrameShape(QFrame::HLine); + divider->setForegroundRole(QPalette::Mid); + column->addWidget(divider); + QHBoxLayout *buttons = row({m_cancel, m_spinner}, true); + buttons->addWidget(m_ok); + column->addLayout(buttons); + connect(&m_session, &EditorSession::changed, this, &LevelsSheet::synchronize); + synchronize(); +} + +PickerField *LevelsSheet::field(size_t index) +{ + const QString name = QString::fromLatin1(entries[index].name); + auto *box = new QWidget(this); + auto *layout = new QVBoxLayout(box); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(5); + layout->addWidget(caption(name, box)); + // A number in the user's locale applies; other text reverts. + auto *entry = new PickerField([this, index] { + PickerField &edited = *m_fields[index]; + bool number = false; + const double typed = edited.locale().toDouble(edited.text(), &number); + if (edited.isModified() && number) { + change([&](LevelsSettings &settings) { + LevelRange range = settings.current(); + range.*entries[index].value = typed; + settings.setCurrent(range); + }); + } + edited.setModified(false); + synchronize(); + }, nullptr, box); + entry->setObjectName(QStringLiteral("levels") + QString(name).remove(QLatin1Char(' '))); + entry->setPlaceholderText(name); + entry->setAccessibleName(name); + entry->setAlignment(Qt::AlignRight); + entry->setFixedWidth(80); + layout->addWidget(entry); + return entry; +} + +void LevelsSheet::dragTo(bool output, size_t index, double tone) +{ + change([&](LevelsSettings &settings) { + LevelRange range = settings.current(); + dragHandle(range, output, index, tone); + settings.setCurrent(range); + }); + m_fields[output ? 3 + index : index]->setModified(false); + synchronize(); +} + +void LevelsSheet::change(const std::function<void(LevelsSettings &)> &edit) +{ + const std::optional<LevelsEdit> &levels = m_session.levels(); + if (!levels) + return; + LevelsSettings settings = levels->settings; + edit(settings); + m_session.updateLevels(settings, levels->preview); +} + +void LevelsSheet::changeEvent(QEvent *event) +{ + QWidget::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + synchronize(); +} + +void LevelsSheet::synchronize() +{ + // Closed, the panel hides the sheet until the next edit. + const std::optional<LevelsEdit> &edit = m_session.levels(); + if (!edit) + return; + const LevelRange range = edit->settings.current(); + m_channel->setCurrentIndex(int(edit->settings.channel)); + // An entry being typed in keeps its typing. + for (size_t index = 0; index < entries.size(); ++index) { + const QString shown = m_fields[index]->locale().toString(range.*entries[index].value, 'f', entries[index].decimals); + if (!m_fields[index]->isModified() && m_fields[index]->text() != shown) + m_fields[index]->setText(shown); + } + for (size_t index = 0; index < 3; ++index) { + const bool chosen = edit->sampleMode == allLevelsSamples[index]; + m_samples[index]->setChecked(chosen); + m_samples[index]->setIcon(eyedropper(palette().color(chosen ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF())); + m_autos[index]->setEnabled(edit->histogramReady); + } + m_sampleHint->setVisible(edit->sampleMode.has_value()); + if (edit->sampleMode) + m_sampleHint->setText(QStringLiteral("Click the original layer to set %1. Click the eyedropper again to stop.") + .arg(rawValue(*edit->sampleMode).toLower())); + m_preview->setChecked(edit->preview); + m_caption->setText(m_session.adjustmentOriginal() ? QStringLiteral("Underlying pixels · alpha-weighted histogram") + : m_session.selection() ? QStringLiteral("Original pixels · selection and alpha-weighted histogram") + : QStringLiteral("Original pixels · alpha-weighted histogram")); + m_spinner->setVisible(edit->committing); + setEnabled(!edit->committing); +} diff --git a/src/UI/LevelsSheet.h b/src/UI/LevelsSheet.h new file mode 100644 index 0000000..51689cc --- /dev/null +++ b/src/UI/LevelsSheet.h @@ -0,0 +1,50 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ColorPickerSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QLabel> +#include <QProgressBar> +#include <QPushButton> +#include <QWidget> +#include <array> +#include <functional> + +// Swift's LevelsSheet: channel, histogram, handles, entries, samples, auto. +class LevelsSheet : public QWidget { + Q_OBJECT +public: + explicit LevelsSheet(EditorSession &session, QWidget *parent = nullptr); + +protected: + // A new theme recolours the eyedroppers' ink. + void changeEvent(QEvent *event) override; + +private: + // Swift's update: the settings changed and handed to the session. + void change(const std::function<void(LevelsSettings &)> &edit); + // A handle's tone, which replaces its entry's typing. + void dragTo(bool output, size_t index, double tone); + // A labelled entry of the five, in Swift's order. + PickerField *field(size_t index); + void synchronize(); + + EditorSession &m_session; + QComboBox *const m_channel; + QWidget *const m_histogram; + QWidget *const m_input; + // Made before the output handles, which lie on top. + QWidget *const m_gradient; + QWidget *const m_output; + // Input black, gamma, input white, output black, output white. + const std::array<PickerField *, 5> m_fields; + const std::array<QPushButton *, 3> m_samples; + QLabel *const m_sampleHint; + const std::array<QPushButton *, 3> m_autos; + QCheckBox *const m_preview; + QPushButton *const m_reset; + QLabel *const m_caption; + QPushButton *const m_cancel; + QProgressBar *const m_spinner; + QPushButton *const m_ok; +}; diff --git a/src/UI/NativeLayerList+Buttons.cpp b/src/UI/NativeLayerList+Buttons.cpp new file mode 100644 index 0000000..10880c2 --- /dev/null +++ b/src/UI/NativeLayerList+Buttons.cpp @@ -0,0 +1,185 @@ +#include "UI/NativeLayerList.h" +#include <QApplication> +#include <QMouseEvent> +#include <QPainter> +#include <QScrollBar> + +EyeSwipeButton::EyeSwipeButton(NativeLayerList &list, QWidget *parent) : QToolButton(parent), m_list(list) +{ + setAutoRaise(true); + setFixedSize(20, 32); +} + +// A row rebuilt mid-swipe takes the button; the step ends. +EyeSwipeButton::~EyeSwipeButton() +{ + if (!m_swipe) + return; + qApp->removeEventFilter(this); + QMetaObject::invokeMethod(&m_list.session(), &EditorSession::endVisibilitySwipe, Qt::QueuedConnection); +} + +void EyeSwipeButton::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || !isEnabled()) + return; + m_swipe = m_list.session().beginVisibilitySwipe(layerID); + if (m_swipe) + qApp->installEventFilter(this); +} + +void EyeSwipeButton::endSwipe() +{ + m_swipe = std::nullopt; + qApp->removeEventFilter(this); + m_list.session().endVisibilitySwipe(); +} + +// Swift tracks the drag itself: no release is missed. +bool EyeSwipeButton::eventFilter(QObject *watched, QEvent *event) +{ + if (m_swipe && event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) + endSwipe(); + return QToolButton::eventFilter(watched, event); +} + +void EyeSwipeButton::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_swipe) + return; + // Past the viewport's edge the list scrolls, as autoscroll does. + const QPoint inList = mapTo(&m_list, event->position().toPoint()); + const int overshoot = inList.y() < 0 ? inList.y() : std::max(0, inList.y() - m_list.viewport()->height()); + if (overshoot != 0) + m_list.verticalScrollBar()->setValue(m_list.verticalScrollBar()->value() + overshoot); + const int row = m_list.rowAt(inList); + if (row >= 0 && row < int(m_list.cells().size())) + m_list.session().setVisibilityInSwipe(m_list.cells()[row]->layerID(), *m_swipe); +} + +void EyeSwipeButton::mouseReleaseEvent(QMouseEvent *event) +{ + // Another button's release leaves the left one's swipe. + if (m_swipe && event->button() == Qt::LeftButton) + endSwipe(); +} + +LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent) + : QToolButton(parent), isMaskTarget(maskTarget), m_list(list) +{ + setAutoRaise(true); +} + +void LayerThumbnailButton::setTargeted(bool targeted) +{ + m_targeted = targeted; + QWidget::update(); +} + +void LayerThumbnailButton::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || !isEnabled()) + return; + auto *cell = static_cast<LayerCell *>(parentWidget()); + // Every press starts from nothing: no earlier arm survives. + cell->disarmDrag(); + const QPoint inCell = mapToParent(event->position().toPoint()); + const bool control = event->modifiers().testFlag(Qt::ControlModifier), shift = event->modifiers().testFlag(Qt::ShiftModifier); + // Alt on a mask drags a copy; else the row's. + if (event->modifiers().testFlag(Qt::AltModifier) && !control) { + if (isMaskTarget) { + m_altMaskPress = event->position().toPoint(); + qApp->installEventFilter(this); + } else if (!m_list.clickRow(*cell, event->modifiers(), inCell)) + cell->armDrag(inCell, event->modifiers()); + return; + } + // Ctrl loads a selection: Ctrl-Shift adds, Ctrl-Alt subtracts. + if (control) { + const SelectionMode mode = event->modifiers().testFlag(Qt::AltModifier) ? SelectionMode::subtract : shift ? SelectionMode::add : SelectionMode::replace; + if (isMaskTarget) + m_list.session().loadMaskSelection(layerID, mode); + else + m_list.session().loadLayerSelection(layerID, mode); + return; + } + if (isMaskTarget && shift) { + m_list.session().selectLayerTarget(layerID, true); + m_list.session().toggleLayerMask(); + return; + } + if (!shift) + m_list.session().selectLayerTarget(layerID, isMaskTarget); + if (!m_list.clickRow(*cell, event->modifiers(), inCell)) + cell->armDrag(inCell, event->modifiers()); +} + +// Moved far enough, Alt on a mask drags a copy. +void LayerThumbnailButton::mouseMoveEvent(QMouseEvent *event) +{ + auto *cell = static_cast<LayerCell *>(parentWidget()); + if (!m_altMaskPress) { + cell->moveDrag(mapToParent(event->position().toPoint()), event->buttons()); + return; + } + if ((event->position().toPoint() - *m_altMaskPress).manhattanLength() < QApplication::startDragDistance()) + return; + endAltMaskPress(); + m_list.startMaskDrag(*this); +} + +LayerThumbnailButton::~LayerThumbnailButton() +{ + if (m_altMaskPress) + qApp->removeEventFilter(this); +} + +void LayerThumbnailButton::endAltMaskPress() +{ + m_altMaskPress = std::nullopt; + qApp->removeEventFilter(this); +} + +// Another widget's release ends it; the window's does not. +bool LayerThumbnailButton::eventFilter(QObject *watched, QEvent *event) +{ + if (m_altMaskPress && watched->isWidgetType() && watched != this && event->type() == QEvent::MouseButtonRelease + && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) + endAltMaskPress(); + return QToolButton::eventFilter(watched, event); +} + +void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event) +{ + // Only the left button's release ends its press. + if (!m_altMaskPress || event->button() != Qt::LeftButton) + return; + endAltMaskPress(); + // Released where pressed: a click, not a drag; Shift toggles. + if (!rect().contains(event->position().toPoint())) + return; + m_list.session().selectLayerTarget(layerID, true); + if (event->modifiers().testFlag(Qt::ShiftModifier)) + m_list.session().toggleLayerMask(); +} + +// With a modifier the second press is the button's again. +void LayerThumbnailButton::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (event->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier)) + mousePressEvent(event); + else + event->ignore(); +} + +void LayerThumbnailButton::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.drawPixmap(rect(), icon().pixmap(size())); + if (!m_targeted) + return; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(palette().color(QPalette::Highlight), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3); +} diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp new file mode 100644 index 0000000..0a6705c --- /dev/null +++ b/src/UI/NativeLayerList+Cell.cpp @@ -0,0 +1,381 @@ +#include "UI/CanvasThumbnail.h" +#include "UI/LayerIcons.h" +#include "UI/NativeLayerList.h" +#include <QApplication> +#include <QContextMenuEvent> +#include <QKeyEvent> +#include <QMenu> +#include <QMouseEvent> +#include <QPainter> +#include <cmath> + +LayerCell::LayerCell(NativeLayerList &list) + : QWidget(nullptr), m_list(list), m_eye(new EyeSwipeButton(list, this)), m_disclosure(new QToolButton(this)), + m_thumbnail(new LayerThumbnailButton(list, false, this)), m_maskThumbnail(new LayerThumbnailButton(list, true, this)), + m_link(new QToolButton(this)), m_disabledMaskMark(new QLabel(QStringLiteral("╱"), this)), m_name(new QLabel(this)), + m_dimensions(new QLabel(this)), m_editor(new QLineEdit(this)), m_fade(new QGraphicsOpacityEffect(this)) +{ + setObjectName(QStringLiteral("layerCell")); + setFixedHeight(rowHeight); + setGraphicsEffect(m_fade); + // Hover reaches the list's cursor through every control. + setMouseTracking(true); + for (QWidget *child : findChildren<QWidget *>()) + child->setMouseTracking(true); + m_eye->setObjectName(QStringLiteral("layerEye")); + m_disclosure->setObjectName(QStringLiteral("layerDisclosure")); + m_disclosure->setAutoRaise(true); + m_disclosure->setFixedSize(16, 24); + m_disclosure->setAccessibleName(QStringLiteral("Expand or collapse folder")); + connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); }); + m_thumbnail->setObjectName(QStringLiteral("layerThumbnail")); + m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail")); + m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + m_link->setObjectName(QStringLiteral("maskLink")); + m_link->setAutoRaise(true); + m_link->setFixedSize(9, 20); + connect(m_link, &QToolButton::clicked, this, [this] { m_list.session().toggleMaskLink(m_layerID); }); + QFont mark = m_disabledMaskMark->font(); + mark.setPixelSize(32); + mark.setWeight(QFont::Medium); + m_disabledMaskMark->setFont(mark); + m_disabledMaskMark->setForegroundRole(QPalette::BrightText); + m_disabledMaskMark->setAttribute(Qt::WA_TransparentForMouseEvents); + m_disabledMaskMark->setObjectName(QStringLiteral("maskDisabledMark")); + QFont name = m_name->font(); + name.setPixelSize(13); + m_name->setFont(name); + m_name->setTextFormat(Qt::PlainText); + m_name->setObjectName(QStringLiteral("layerName")); + QFont small = m_dimensions->font(); + small.setPixelSize(10); + m_dimensions->setFont(small); + m_dimensions->setForegroundRole(QPalette::PlaceholderText); + m_dimensions->setTextFormat(Qt::PlainText); + m_dimensions->setObjectName(QStringLiteral("layerDimensions")); + m_editor->setObjectName(QStringLiteral("layerNameEditor")); + m_editor->setFont(name); + m_editor->hide(); + m_editor->installEventFilter(this); + menuAction(QStringLiteral("renameLayer"), QStringLiteral("Rename…"), [this] { + if (!m_list.session().canEditLayers()) + return; + m_list.session().setActiveLayerID(m_layerID); + m_list.session().setRenamingLayerID(m_layerID); + }); + menuAction(QStringLiteral("toggleVisibility"), QStringLiteral("Hide/Show Layer"), [this] { m_list.session().toggleLayerVisibility(m_layerID); }); + // With a selection, white hides it, black shows it alone. + menuAction(QStringLiteral("addWhiteMask"), QStringLiteral("Add White Mask"), [this] { + m_list.session().selectLayerTarget(m_layerID, false); + m_list.session().addMask(); + }); + menuAction(QStringLiteral("addBlackMask"), QStringLiteral("Add Black Mask"), [this] { + m_list.session().selectLayerTarget(m_layerID, false); + m_list.session().addMask(false); + }); + menuAction(QStringLiteral("toggleMask"), QStringLiteral("Enable/Disable Mask"), [this] { + m_list.session().selectLayerTarget(m_layerID, false); + m_list.session().toggleLayerMask(); + }); + menuAction(QStringLiteral("deleteMask"), QStringLiteral("Delete Mask"), [this] { + m_list.session().selectLayerTarget(m_layerID, false); + m_list.session().deleteLayerMask(); + }); + menuAction(QStringLiteral("releaseClippingMask"), QStringLiteral("Release Clipping Mask"), [this] { m_list.session().removeLiveMask(m_layerID); }); + menuAction(QStringLiteral("moveOut"), QStringLiteral("Move Out of Folder"), [this] { + m_list.session().selectLayer(m_layerID); + m_list.session().moveActiveLayerOutOfGroup(); + }); + menuAction(QStringLiteral("deleteLayer"), QStringLiteral("Delete Layer / Folder"), [this] { + // Part of a multi-selection, the whole selection goes. + EditorSession &session = m_list.session(); + if (session.selectedLayerIDs().size() > 1 && session.selectedLayerIDs().contains(m_layerID)) + session.deleteSelectedLayers(); + else + session.deleteLayer(m_layerID); + }); +} + +QAction *LayerCell::menuAction(const QString &name, const QString &title, const std::function<void()> &run) +{ + auto *action = new QAction(title, this); + action->setObjectName(name); + connect(action, &QAction::triggered, this, run); + addAction(action); + return action; +} + +void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool visible) +{ + EditorSession &session = m_list.session(); + const QColor ink = palette().color(QPalette::WindowText), faint = palette().color(QPalette::PlaceholderText); + const double ratio = devicePixelRatio(); + // A folder's step and a clipping mask's step add up. + m_indent = std::min(depth, 8) * 24 + (layer.maskSourceID ? 24 : 0); + m_isGroup = layer.isGroup; + m_disclosure->setVisible(layer.isGroup); + m_disclosure->setEnabled(enabled); + m_disclosure->setIcon(LayerIcons::pixmap(session.collapsedGroupIDs().contains(layer.id) ? LayerIcon::chevronRight : LayerIcon::chevronDown, 12, ink, ratio)); + // Pixels show the canvas; text, adjustments, folders an icon. + const QSizeF canvas = session.document() ? session.document()->size() : QSizeF(1, 1); + m_editableText = layer.liveText().has_value(); + m_isAdjustment = layer.adjustment.has_value(); + m_thumbnailSize = layer.isGroup || m_editableText || m_isAdjustment ? QSize(36, 36) : CanvasThumbnail::fittedSize(canvas, 36); + const ThumbnailKey key{layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, layer.transform, canvas, ink, m_editableText}; + if (m_layerID != layer.id || m_thumbnailKey != key) { + m_thumbnail->setIcon(m_isAdjustment ? LayerIcons::adjustmentThumbnail(layer.adjustment->kind, ink, ratio) + : layer.isGroup ? LayerIcons::pixmap(LayerIcon::folder, 36 * 0.8, ink, ratio) + : m_editableText ? LayerIcons::pixmap(LayerIcon::text, 36, ink, ratio) + : CanvasThumbnail::layer(layer.asset ? layer.asset->thumbnail : QImage(), layer.transform, canvas, 36)); + m_thumbnailKey = key; + } + m_maskSize = CanvasThumbnail::fittedSize(canvas, 30); + const ThumbnailKey maskKey{layer.mask ? std::optional(layer.mask->asset.identity()) : std::nullopt, layer.maskTransform(), canvas, ink}; + if (m_layerID != layer.id || m_maskThumbnailKey != maskKey) { + m_maskThumbnail->setIcon(layer.mask ? CanvasThumbnail::mask(layer.mask->asset.thumbnail, layer.maskTransform(), canvas, 30) : QPixmap()); + m_maskThumbnailKey = maskKey; + } + m_layerID = layer.id; + m_thumbnail->layerID = layer.id; + m_maskThumbnail->layerID = layer.id; + m_hasMask = layer.mask.has_value(); + m_maskThumbnail->setVisible(m_hasMask); + m_disabledMaskMark->setVisible(layer.mask && !layer.mask->isEnabled); + m_thumbnail->setEnabled(!session.showsBusy() && !session.isImporting()); + m_maskThumbnail->setEnabled(m_thumbnail->isEnabled()); + m_linkable = layer.mask && !m_isAdjustment && !layer.isGroup; + m_link->setVisible(m_linkable); + const bool linked = !layer.mask || layer.mask->isLinked; + m_link->setIcon(linked ? LayerIcons::pixmap(LayerIcon::link, 9, faint, ratio) : QPixmap()); + m_link->setEnabled(m_thumbnail->isEnabled()); + m_link->setToolTip(linked ? QStringLiteral("Unlink layer and mask to move or transform them separately") : QStringLiteral("Link layer and mask so they move together")); + m_link->setAccessibleName((linked ? QStringLiteral("Unlink mask: ") : QStringLiteral("Link mask: ")) + layer.name); + m_thumbnail->setToolTip(m_editableText ? QStringLiteral("Editable text layer") : QStringLiteral("Select image pixels")); + m_thumbnail->setAccessibleName((m_editableText ? QStringLiteral("Select text: ") : QStringLiteral("Select image: ")) + layer.name); + m_maskThumbnail->setAccessibleName(QStringLiteral("Select mask: ") + layer.name); + m_layerName = layer.name; + // A pending rename closes the edits: rows never move. + if (!m_renaming) + m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name); + m_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit") + : m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit") + : layer.isGroup ? QStringLiteral("Folder") + : QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height()))); + m_dimensions->setToolTip(QString()); + if (layer.maskSourceID && session.document()) { + const int source = indexOf(session.document()->layers, layer.maskSourceID); + const QString sourceName = source >= 0 ? session.document()->layers[source].name : QStringLiteral("Missing source"); + m_dimensions->setText(QStringLiteral("Clipped to %1").arg(sourceName)); + m_dimensions->setToolTip(QStringLiteral("Clipping mask based on %1. Option-click the bottom of its row to release.").arg(sourceName)); + } + m_eye->setIcon(LayerIcons::pixmap(layer.isVisible ? LayerIcon::eye : LayerIcon::eyeSlash, 16, ink, ratio)); + m_eye->setAccessibleName((layer.isVisible ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + layer.name); + m_eye->setEnabled(enabled); + m_eye->layerID = layer.id; + m_fade->setOpacity(visible ? 1 : 0.35); + // Effects under the row, made anew when their kinds change. + const std::vector<LayerEffectKind> kinds = layer.effects ? layer.effects->kinds() : std::vector<LayerEffectKind>(); + std::vector<LayerEffectKind> shown; + for (const LayerEffectRow *row : m_effectButtons) + shown.push_back(row->kind); + if (shown != kinds) { + // A row's own press may take it away: deleted later. + for (LayerEffectRow *row : m_effectButtons) { + row->hide(); + row->deleteLater(); + } + m_effectButtons.clear(); + for (const LayerEffectKind kind : kinds) { + m_effectButtons.push_back(new LayerEffectRow(m_list, layer.id, kind, this)); + // Made after the cell shows, a child needs showing. + m_effectButtons.back()->show(); + } + } + setFixedHeight(rowHeight + int(kinds.size()) * effectHeight); + for (LayerEffectRow *row : m_effectButtons) + row->configure(layer.effects->isEnabled(row->kind), enabled, m_indent); + relayout(); +} + +void LayerCell::updateTarget() +{ + EditorSession &session = m_list.session(); + const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1; + const bool mask = session.isMaskSelected(); + m_thumbnail->setTargeted(active && !mask); + m_maskThumbnail->setTargeted(active && mask); + QWidget::update(); +} + +void LayerCell::relayout() +{ + m_eye->move(8, 26 - 16); + m_disclosure->move(28 + m_indent, 26 - 12); + const int thumbnailSlot = 28 + m_indent + 16 - 2; + m_thumbnail->setFixedSize(m_thumbnailSize); + m_thumbnail->move(thumbnailSlot + (36 - m_thumbnailSize.width()) / 2, 26 - m_thumbnailSize.height() / 2); + const int maskSlot = thumbnailSlot + 36 + (m_linkable ? 13 : 5); + m_link->move(maskSlot - 6 - 5, 26 - 10); + const int maskWidth = m_hasMask ? 30 : 0; + m_maskThumbnail->setFixedSize(m_maskSize); + m_maskThumbnail->move(maskSlot + (maskWidth - m_maskSize.width()) / 2, 26 - m_maskSize.height() / 2); + m_disabledMaskMark->adjustSize(); + m_disabledMaskMark->move(m_maskThumbnail->geometry().center() - QPoint(m_disabledMaskMark->width() / 2, m_disabledMaskMark->height() / 2)); + const int nameLeft = maskSlot + maskWidth + 5, nameWidth = std::max(10, width() - nameLeft - 8); + m_name->setGeometry(nameLeft, 9, nameWidth, 17); + m_editor->setGeometry(nameLeft - 2, 6, nameWidth + 2, 23); + m_dimensions->setGeometry(nameLeft, 29, nameWidth, 14); + for (size_t each = 0; each < m_effectButtons.size(); ++each) + m_effectButtons[each]->setGeometry(0, rowHeight + int(each) * effectHeight, width(), effectHeight); +} + +void LayerCell::resizeEvent(QResizeEvent *) +{ + relayout(); +} + +void LayerCell::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + // A chosen effect hides the rows' selection, as Swift's table. + if (m_list.session().selectedLayerIDs().contains(m_layerID) && !m_list.session().selectedEffect()) { + QColor fill = palette().color(QPalette::Highlight); + fill.setAlphaF(0.35); + painter.fillRect(rect(), fill); + } + // A faint device pixel where the next row begins. + QColor edge = Qt::white; + edge.setAlphaF(0.06); + painter.fillRect(QRectF(0, height() - 1.0 / devicePixelRatio(), width(), 1.0 / devicePixelRatio()), edge); +} + +bool LayerCell::isOnControl(QPoint point) const +{ + for (const QWidget *control : {static_cast<QWidget *>(m_eye), static_cast<QWidget *>(m_disclosure), static_cast<QWidget *>(m_thumbnail), + static_cast<QWidget *>(m_link), static_cast<QWidget *>(m_maskThumbnail)}) { + if (control->isVisible() && control->geometry().contains(point)) + return true; + } + return false; +} + +void LayerCell::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) + return; + disarmDrag(); + // A press that clipped is done, as in Swift. + if (!m_list.clickRow(*this, event->modifiers(), event->position().toPoint())) + armDrag(event->position().toPoint(), event->modifiers()); +} + +void LayerCell::mouseMoveEvent(QMouseEvent *event) +{ + moveDrag(event->position().toPoint(), event->buttons()); +} + +void LayerCell::armDrag(QPoint cellPoint, Qt::KeyboardModifiers modifiers) +{ + m_press = cellPoint; + m_pressModifiers = modifiers; +} + +// Left held past the distance drags. +void LayerCell::moveDrag(QPoint cellPoint, Qt::MouseButtons buttons) +{ + if (!m_press || !buttons.testFlag(Qt::LeftButton) || (cellPoint - *m_press).manhattanLength() < QApplication::startDragDistance()) + return; + m_press = std::nullopt; + m_list.startDrag(*this, m_pressModifiers); +} + +void LayerCell::mouseDoubleClickEvent(QMouseEvent *event) +{ + EditorSession &session = m_list.session(); + if (event->button() != Qt::LeftButton || !session.canEditLayers()) + return; + // With Alt the second press clips again, as in Swift. + if (event->modifiers().testFlag(Qt::AltModifier) && !event->modifiers().testFlag(Qt::ControlModifier)) { + m_list.clickRow(*this, event->modifiers(), event->position().toPoint()); + return; + } + session.setActiveLayerID(m_layerID); + // On a control, text or an adjustment's settings open. + if (isOnControl(event->position().toPoint())) { + if (m_editableText) { + session.editActiveText(); + return; + } + if (m_isAdjustment) { + session.setAdjustmentEditingID(m_layerID); + return; + } + } + session.setRenamingLayerID(m_layerID); +} + +void LayerCell::contextMenuEvent(QContextMenuEvent *event) +{ + QMenu::exec(actions(), event->globalPos(), nullptr, this); +} + +void LayerCell::beginRenaming() +{ + EditorSession &session = m_list.session(); + // Not canEditLayers: a pending rename closes that gate itself. + if (m_renaming || !session.document() || session.isProjectBusy() || session.isImporting()) + return; + m_renaming = true; + m_editor->setText(m_layerName); + m_editor->show(); + m_editor->setFocus(Qt::OtherFocusReason); + m_editor->selectAll(); +} + +void LayerCell::endRenaming(bool keeping) +{ + if (!m_renaming) + return; + EditorSession &session = m_list.session(); + const QString typed = m_editor->text(); + m_renaming = false; + m_editor->hide(); + // Cleared first: the rename's signal would reopen the editor. + if (session.renamingLayerID() == m_layerID) + session.setRenamingLayerID(std::nullopt); + if (keeping) + session.renameLayer(m_layerID, typed); + // The name the layer ended up with, marked as shown. + if (session.document()) { + const int index = indexOf(session.document()->layers, m_layerID); + if (index >= 0) { + const ImageLayer &layer = session.document()->layers[index]; + m_layerName = layer.name; + m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name); + } + } + m_list.focusList(); +} + +bool LayerCell::eventFilter(QObject *watched, QEvent *event) +{ + if (watched != m_editor || !m_renaming) + return QWidget::eventFilter(watched, event); + if (event->type() == QEvent::FocusOut) { + // A menu borrows focus and gives it back: no leaving. + const Qt::FocusReason reason = static_cast<QFocusEvent *>(event)->reason(); + if (reason != Qt::MenuBarFocusReason && reason != Qt::PopupFocusReason) + endRenaming(true); + return false; + } + if (event->type() != QEvent::KeyPress) + return false; + const int key = static_cast<QKeyEvent *>(event)->key(); + if (key == Qt::Key_Return || key == Qt::Key_Enter) + endRenaming(true); + else if (key == Qt::Key_Escape) + endRenaming(false); + else + return false; + return true; +} diff --git a/src/UI/NativeLayerList+Drag.cpp b/src/UI/NativeLayerList+Drag.cpp new file mode 100644 index 0000000..15a4243 --- /dev/null +++ b/src/UI/NativeLayerList+Drag.cpp @@ -0,0 +1,376 @@ +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectStore.h" +#include "Rendering/EditorCanvas.h" +#include "UI/NativeLayerList.h" +#include <QApplication> +#include <QDrag> +#include <QDragEnterEvent> +#include <QDragMoveEvent> +#include <QDropEvent> +#include <QKeyEvent> +#include <QMimeData> +#include <QMouseEvent> +#include <QPainter> +#include <QPainterPath> +#include <QScrollBar> + +const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask"); +const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list"); + +namespace { +// A glyph drawn black over a white halo. +void drawHaloed(QPainter &painter, const QPainterPath &path, double width) +{ + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(Qt::white, width + 2.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(path); + painter.setPen(QPen(Qt::black, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(path); +} +} + +void LayerColumn::paintEvent(QPaintEvent *) +{ + if (!indicator) + return; + QPainter painter(this); + QColor accent = palette().color(QPalette::Highlight); + if (!indicatorFills) { + painter.fillRect(*indicator, accent); + return; + } + accent.setAlphaF(0.3); + painter.fillRect(*indicator, accent); + painter.setPen(QPen(palette().color(QPalette::Highlight), 2)); + painter.drawRect(indicator->adjusted(1, 1, -1, -1)); +} + +std::unique_ptr<QMimeData> NativeLayerList::dragData(const LayerCell &cell) const +{ + // The pressed row, with the selection it belongs to. + const QSet<QUuid> selected = m_session.selectedLayerIDs(); + const bool whole = selected.contains(cell.layerID()); + QStringList lines; + for (const ImageLayer &row : m_rows) { + if (row.id == cell.layerID() || (whole && selected.contains(row.id))) + lines << uuidString(row.id); + } + auto data = std::make_unique<QMimeData>(); + data->setData(ProjectWorkspace::layerType, lines.join(QLatin1Char('\n')).toUtf8()); + data->setData(sourceType, m_dragToken.toUtf8()); + return data; +} + +// Alt offers a copy alone; plain drags move or copy. +void NativeLayerList::startDrag(LayerCell &cell, Qt::KeyboardModifiers modifiers) +{ + if (!m_session.canEditLayers()) + return; + const bool copying = modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier); + auto *drag = new QDrag(this); + drag->setMimeData(dragData(cell).release()); + drag->setPixmap(cell.grab()); + if (copying) + drag->setDragCursor(CanvasView::duplicateCursor(devicePixelRatio()).pixmap(), Qt::CopyAction); + drag->exec(copying ? Qt::CopyAction : Qt::MoveAction | Qt::CopyAction, copying ? Qt::CopyAction : Qt::MoveAction); +} + +void NativeLayerList::startMaskDrag(LayerThumbnailButton &thumbnail) +{ + auto *data = new QMimeData; + data->setData(maskType, uuidString(thumbnail.layerID).toUtf8()); + auto *drag = new QDrag(this); + drag->setMimeData(data); + drag->setPixmap(thumbnail.grab()); + drag->exec(Qt::CopyAction); +} + +// Dragged rows in order, folder contents left out; strangers none. +std::vector<QUuid> NativeLayerList::draggedLayers(const QMimeData &data) const +{ + const std::vector<QUuid> dropped = ProjectWorkspace::layerIDs(data); + const QSet<QUuid> dragged(dropped.begin(), dropped.end()); + QSet<QUuid> carried; + for (const QUuid &id : dragged) + carried.unite(m_session.descendantIDs(id)); + std::vector<QUuid> result; + for (const ImageLayer &row : m_rows) { + if (dragged.contains(row.id) && !carried.contains(row.id)) + result.push_back(row.id); + } + return result; +} + +std::optional<LayerDropTarget> NativeLayerList::dropTarget(const QMimeData &data, Qt::DropActions offered, QPoint listPoint) const +{ + if (data.hasFormat(effectType)) { + // An effect lands on whichever row is under the pointer. + const int row = rowAt(listPoint); + const std::optional<LayerEffectSelection> source = draggedEffect(data); + if (row < 0 || !source || !m_session.canCopyEffect(source->kind, source->layerID, m_rows[row].id)) + return std::nullopt; + return LayerDropTarget{.row = row, .onRow = true, .atBottom = false, .copying = true}; + } + if (data.hasFormat(maskType)) { + // A mask lands on whichever row is under the pointer. + const int row = rowAt(listPoint); + const std::optional<QUuid> source = ProjectWorkspace::layerID(QString::fromUtf8(data.data(maskType))); + if (row < 0 || !source || !m_session.canCopyMask(*source, m_rows[row].id)) + return std::nullopt; + return LayerDropTarget{.row = row, .onRow = true, .atBottom = false, .copying = true}; + } + // Only this list's rows; canPlaceLayer covers canEditLayers. + if (!data.hasFormat(ProjectWorkspace::layerType) || QString::fromUtf8(data.data(sourceType)) != m_dragToken) + return std::nullopt; + const std::vector<QUuid> ids = draggedLayers(data); + if (ids.empty()) + return std::nullopt; + // Swift duplicates when the source offers a copy alone (Alt). + const bool copying = offered == Qt::CopyAction; + // Folders are not duplicated: a copy with one is refused. + for (const QUuid &id : ids) { + if (copying && m_rows[size_t(indexOf(m_rows, id))].isGroup) + return std::nullopt; + } + const QPoint inColumn = m_column->mapFrom(viewport(), viewport()->mapFrom(this, listPoint)); + int row = int(m_rows.size()); + bool onRow = false; + for (size_t each = 0; each < m_cells.size(); ++each) { + const QRect box = m_cells[each]->geometry(); + if (inColumn.y() < box.top() - 1) { + row = int(each); + break; + } + if (inColumn.y() > box.bottom() + 1) + continue; + // A folder's middle half takes the drop in. + const bool middle = std::abs(inColumn.y() - box.center().y()) <= box.height() / 4; + onRow = m_rows[each].isGroup && middle; + row = onRow || inColumn.y() < box.center().y() ? int(each) : int(each) + 1; + break; + } + const std::optional<QUuid> parent = onRow ? std::optional(m_rows[row].id) : row < int(m_rows.size()) ? m_rows[row].parentID : std::nullopt; + for (const QUuid &id : ids) { + if (!m_session.canPlaceLayer(id, parent)) + return std::nullopt; + } + return LayerDropTarget{.row = row, .onRow = onRow, .atBottom = row == int(m_rows.size()), .copying = copying}; +} + +bool NativeLayerList::acceptDrop(const QMimeData &data, Qt::DropActions offered, QPoint listPoint) +{ + const std::optional<LayerDropTarget> target = dropTarget(data, offered, listPoint); + if (!target) + return false; + if (data.hasFormat(effectType)) { + const LayerEffectSelection source = draggedEffect(data).value(); + m_session.copyEffect(source.kind, source.layerID, m_rows[target->row].id); + return true; + } + if (data.hasFormat(maskType)) { + m_session.copyMask(ProjectWorkspace::layerID(QString::fromUtf8(data.data(maskType))).value(), m_rows[target->row].id); + return true; + } + const std::vector<QUuid> ids = draggedLayers(data); + // Where the drop lands is worked out once. + const std::optional<QUuid> parent = target->onRow ? std::optional(m_rows[target->row].id) + : target->atBottom ? std::nullopt + : m_rows[target->row].parentID; + const std::optional<QUuid> above = target->onRow || target->atBottom ? std::nullopt : std::optional(m_rows[target->row].id); + // Above a row the last lands nearest; in folders, first. + std::vector<QUuid> order = ids; + if (target->onRow) + std::reverse(order.begin(), order.end()); + const bool several = ids.size() > 1; + m_session.beginEdit(target->copying ? (several ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer")) + : (several ? QStringLiteral("Move Layers") : QStringLiteral("Move Layer"))); + bool placed = false; + for (const QUuid &id : order) { + const bool done = target->copying ? m_session.duplicateLayer(id, parent, above, target->atBottom) + : m_session.placeLayer(id, parent, above, target->atBottom); + placed = done || placed; + } + // The moved layers stay selected, to drag on together. + if (placed && !target->copying) + m_session.selectLayers(QSet<QUuid>(ids.begin(), ids.end()), ids.front()); + m_session.endEdit(); + return placed; +} + +void NativeLayerList::showIndicator(const std::optional<LayerDropTarget> &target) +{ + m_column->indicator = std::nullopt; + if (target && target->onRow) { + m_column->indicator = m_cells[size_t(target->row)]->geometry(); + m_column->indicatorFills = true; + } else if (target) { + const int y = target->atBottom ? m_cells.back()->geometry().bottom() + 1 : m_cells[size_t(target->row)]->geometry().top() - 1; + m_column->indicator = QRect(0, y - 1, m_column->width(), 2); + m_column->indicatorFills = false; + } + m_column->update(); +} + +// The list moves, or copies what Alt offers alone. +void NativeLayerList::dragEnterEvent(QDragEnterEvent *event) +{ + const std::optional<LayerDropTarget> target = dropTarget(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(event->position().toPoint())); + showIndicator(target); + if (!target) + return; + event->setDropAction(target->copying ? Qt::CopyAction : Qt::MoveAction); + event->accept(); +} + +void NativeLayerList::dragMoveEvent(QDragMoveEvent *event) +{ + // Near an edge the list scrolls, as the table does. + const QPoint inViewport = event->position().toPoint(); + m_dragPoint = inViewport; + m_dragData = event->mimeData(); + m_dragActions = event->possibleActions(); + autoscroll(inViewport, *event->mimeData(), event->possibleActions()); + const std::optional<LayerDropTarget> target = dropTarget(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(inViewport)); + if (!target) { + event->ignore(); + return; + } + event->setDropAction(target->copying ? Qt::CopyAction : Qt::MoveAction); + event->accept(); +} + +void NativeLayerList::autoscroll(QPoint inViewport, const QMimeData &data, Qt::DropActions offered) +{ + const int margin = 16; + const int overshoot = inViewport.y() < margin ? inViewport.y() - margin : std::max(0, inViewport.y() - (viewport()->height() - margin)); + if (overshoot != 0) + verticalScrollBar()->setValue(verticalScrollBar()->value() + overshoot); + // Held at the edge, the timer scrolls on; elsewhere, rests. + if (overshoot != 0 && !m_edgeScroll.isActive()) + m_edgeScroll.start(); + else if (overshoot == 0) + m_edgeScroll.stop(); + showIndicator(dropTarget(data, offered, viewport()->mapToParent(inViewport))); +} + +void NativeLayerList::dragLeaveEvent(QDragLeaveEvent *) +{ + m_edgeScroll.stop(); + m_dragData = nullptr; + showIndicator(std::nullopt); +} + +void NativeLayerList::dropEvent(QDropEvent *event) +{ + m_edgeScroll.stop(); + m_dragData = nullptr; + showIndicator(std::nullopt); + const bool copying = event->possibleActions() == Qt::CopyAction; + if (!acceptDrop(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(event->position().toPoint()))) + return; + event->setDropAction(copying ? Qt::CopyAction : Qt::MoveAction); + event->accept(); +} + +// Photoshop's create and release clipping pointers, haloed. +QCursor NativeLayerList::clippingCursor(bool releasing, double ratio) +{ + QPixmap pixmap(QSize(30, 28) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + // An arrow turning down and right, at the left. + QPainterPath arrow(QPointF(4, 3)); + arrow.lineTo(QPointF(4, 12)); + arrow.lineTo(QPointF(14, 12)); + arrow.moveTo(QPointF(10.5, 8.5)); + arrow.lineTo(QPointF(14, 12)); + arrow.lineTo(QPointF(10.5, 15.5)); + drawHaloed(painter, arrow, 1.8); + // A box with a badge: plus creates, minus releases. + QPainterPath box; + box.addRoundedRect(QRectF(12, 11, 15, 12), 2, 2); + drawHaloed(painter, box, 1.6); + QPainterPath badge; + badge.addEllipse(QPointF(25, 12), 4.5, 4.5); + painter.setPen(QPen(Qt::white, 1.4)); + painter.setBrush(Qt::black); + painter.drawPath(badge); + painter.setPen(QPen(Qt::white, 1.4, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(QPointF(22.5, 12), QPointF(27.5, 12)); + if (!releasing) + painter.drawLine(QPointF(25, 9.5), QPointF(25, 14.5)); + return QCursor(pixmap, 3, 3); +} + +QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const +{ + const int row = rowAt(listPoint); + if (row < 0 || !(modifiers & (Qt::AltModifier | Qt::ControlModifier))) + return QCursor(Qt::ArrowCursor); + const ImageLayer &layer = m_rows[size_t(row)]; + LayerCell &cell = *m_cells[size_t(row)]; + const QPoint inCell = cell.mapFrom(this, listPoint); + const double ratio = devicePixelRatio(); + // Ctrl over a thumbnail loads it as a selection. + if (modifiers.testFlag(Qt::ControlModifier)) { + for (const LayerThumbnailButton *thumbnail : {&cell.thumbnail(), &cell.maskThumbnail()}) { + if (thumbnail->isVisible() && thumbnail->isEnabled() && thumbnail->geometry().contains(inCell)) + return CanvasView::loadSelectionCursor(ratio); + } + return QCursor(Qt::ArrowCursor); + } + const bool editable = m_session.canEditLayers(); + // A mask copies, the row duplicates, the strip clips. + if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell)) + return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); + if (!isClippingZone(cell, inCell)) + return editable && !layer.isGroup ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); + if (!m_session.canToggleClippingMask(layer.id)) + return QCursor(Qt::ArrowCursor); + return clippingCursor(layer.maskSourceID.has_value(), ratio); +} + +// A modifier changed: the cursor is worked out again. +void NativeLayerList::refreshCursor() +{ + viewport()->setCursor(m_hover ? cursorFor(*m_hover, QApplication::keyboardModifiers()) : QCursor(Qt::ArrowCursor)); +} + +void NativeLayerList::showEvent(QShowEvent *event) +{ + QScrollArea::showEvent(event); + // Alt pressed anywhere changes the cursor over the rows. + qApp->installEventFilter(this); +} + +void NativeLayerList::hideEvent(QHideEvent *event) +{ + qApp->removeEventFilter(this); + QScrollArea::hideEvent(event); +} + +// Alt, Ctrl or hover over rows sets the cursor. +bool NativeLayerList::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { + const int key = static_cast<QKeyEvent *>(event)->key(); + if (key == Qt::Key_Alt || key == Qt::Key_Control) + refreshCursor(); + } else if (event->type() == QEvent::MouseMove && watched->isWidgetType() && isAncestorOf(static_cast<QWidget *>(watched))) { + const auto *mouse = static_cast<QMouseEvent *>(event); + m_hover = mapFromGlobal(mouse->globalPosition().toPoint()); + viewport()->setCursor(cursorFor(*m_hover, mouse->modifiers())); + } + return QScrollArea::eventFilter(watched, event); +} + +bool NativeLayerList::viewportEvent(QEvent *event) +{ + if (event->type() == QEvent::Leave) { + m_hover = std::nullopt; + viewport()->setCursor(QCursor(Qt::ArrowCursor)); + } + return QScrollArea::viewportEvent(event); +} diff --git a/src/UI/NativeLayerList+Effects.cpp b/src/UI/NativeLayerList+Effects.cpp new file mode 100644 index 0000000..b568725 --- /dev/null +++ b/src/UI/NativeLayerList+Effects.cpp @@ -0,0 +1,178 @@ +#include "Document/ProjectWorkspace.h" +#include "Rendering/EditorCanvas.h" +#include "UI/LayerIcons.h" +#include "UI/NativeLayerList.h" +#include <QAccessibleWidget> +#include <QApplication> +#include <QDrag> +#include <QMimeData> +#include <QMouseEvent> +#include <QPainter> + +const QString NativeLayerList::effectType = QStringLiteral("com.compositor.layer-effect"); + +namespace { +// Swift's group element, whose press chooses the effect. +class EffectRowAccessible : public QAccessibleWidget { +public: + explicit EffectRowAccessible(LayerEffectRow *row) : QAccessibleWidget(row, QAccessible::Grouping) {} + QStringList actionNames() const override { return {pressAction()}; } + void doAction(const QString &name) override + { + if (name == pressAction()) + static_cast<LayerEffectRow *>(widget())->select(false); + } +}; + +QAccessibleInterface *effectRowAccessible(const QString &key, QObject *object) +{ + return key == QLatin1String("LayerEffectRow") ? new EffectRowAccessible(static_cast<LayerEffectRow *>(object)) : nullptr; +} + +bool option(Qt::KeyboardModifiers modifiers) +{ + return modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier); +} +} + +LayerEffectRow::LayerEffectRow(NativeLayerList &list, QUuid layerID, LayerEffectKind kind, QWidget *parent) + : QWidget(parent), layerID(layerID), kind(kind), m_list(list), m_eye(new QToolButton(this)), m_label(new QLabel(this)) +{ + [[maybe_unused]] static const bool accessible = (QAccessible::installFactory(effectRowAccessible), true); + setObjectName(QStringLiteral("layerEffectRow")); + setToolTip(QStringLiteral("Click to select; double-click to edit; Alt-drag to copy ") + rawValue(kind).toLower()); + setAccessibleName(rawValue(kind) + QStringLiteral(" effect")); + m_eye->setObjectName(QStringLiteral("effectEye")); + m_eye->setAutoRaise(true); + m_eye->setFixedSize(20, 22); + m_eye->installEventFilter(this); + connect(m_eye, &QToolButton::clicked, this, [this] { m_list.session().toggleEffect(this->kind, this->layerID); }); + QFont small = m_label->font(); + small.setPixelSize(11); + m_label->setFont(small); + // Hover reaches the list's cursor, as every row's controls. + for (QWidget *each : {static_cast<QWidget *>(this), static_cast<QWidget *>(m_eye), static_cast<QWidget *>(m_label)}) + each->setMouseTracking(true); +} + +void LayerEffectRow::configure(bool enabled, bool editable, int indent) +{ + m_eye->setIcon(LayerIcons::pixmap(enabled ? LayerIcon::eye : LayerIcon::eyeSlash, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio())); + m_eye->setEnabled(editable); + m_eye->setAccessibleName((enabled ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + rawValue(kind)); + m_label->setForegroundRole(enabled ? QPalette::WindowText : QPalette::PlaceholderText); + m_indent = indent; + relayout(); +} + +void LayerEffectRow::relayout() +{ + m_eye->move(38 + m_indent, (height() - m_eye->height()) / 2); + const int left = m_eye->geometry().right() + 1 + 8; + m_label->setGeometry(left, 0, std::max(0, width() - left - 8), height()); + m_label->setText(m_label->fontMetrics().elidedText(rawValue(kind), Qt::ElideRight, m_label->width())); +} + +void LayerEffectRow::resizeEvent(QResizeEvent *) +{ + relayout(); +} + +void LayerEffectRow::select(bool editing) +{ + m_list.session().selectEffect(kind, layerID, editing); + m_list.focusList(); +} + +// Alt clips on the strip, else arms; plain, it chooses. +void LayerEffectRow::press(QMouseEvent *event, bool twice) +{ + if (event->button() != Qt::LeftButton) + return; + m_copyDown = std::nullopt; + auto &cell = *static_cast<LayerCell *>(parentWidget()); + const QPoint inCell = mapToParent(event->position().toPoint()); + if (option(event->modifiers()) && NativeLayerList::isClippingZone(cell, inCell)) { + m_list.clickRow(cell, event->modifiers(), inCell); + return; + } + if (option(event->modifiers()) && m_list.session().canEditLayers()) + m_copyDown = event->position().toPoint(); + else + select(twice); +} + +void LayerEffectRow::mousePressEvent(QMouseEvent *event) +{ + press(event, false); +} + +void LayerEffectRow::mouseDoubleClickEvent(QMouseEvent *event) +{ + press(event, true); +} + +// Held past the distance with Alt, the effect is dragged. +void LayerEffectRow::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_copyDown || !event->buttons().testFlag(Qt::LeftButton) + || (event->position().toPoint() - *m_copyDown).manhattanLength() < QApplication::startDragDistance()) + return; + m_copyDown = std::nullopt; + if (event->modifiers().testFlag(Qt::AltModifier) && m_list.session().canEditLayers()) + m_list.startEffectDrag(*this); +} + +// Released without a drag, an Alt press chooses the effect. +void LayerEffectRow::mouseReleaseEvent(QMouseEvent *event) +{ + if (m_copyDown && event->button() == Qt::LeftButton) + select(false); +} + +// The eye's Alt press, ignored, goes on to the row. +bool LayerEffectRow::eventFilter(QObject *watched, QEvent *event) +{ + const bool press = event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick; + if (press && option(static_cast<QMouseEvent *>(event)->modifiers())) { + event->ignore(); + return true; + } + return QWidget::eventFilter(watched, event); +} + +void LayerEffectRow::paintEvent(QPaintEvent *) +{ + if (m_list.session().selectedEffect() != LayerEffectSelection{layerID, kind}) + return; + QColor fill = palette().color(QPalette::Highlight); + fill.setAlphaF(0.3); + QPainter(this).fillRect(rect(), fill); +} + +// Alt offers a copy alone, within this list. +void NativeLayerList::startEffectDrag(LayerEffectRow &row) +{ + auto *data = new QMimeData; + data->setData(effectType, (uuidString(row.layerID) + QLatin1Char(':') + rawValue(row.kind)).toUtf8()); + data->setData(sourceType, m_dragToken.toUtf8()); + auto *drag = new QDrag(this); + drag->setMimeData(data); + drag->setPixmap(row.grab()); + drag->setDragCursor(CanvasView::duplicateCursor(devicePixelRatio()).pixmap(), Qt::CopyAction); + drag->exec(Qt::CopyAction); +} + +// This list's dragged effect: its layer and kind. +std::optional<LayerEffectSelection> NativeLayerList::draggedEffect(const QMimeData &data) const +{ + if (QString::fromUtf8(data.data(sourceType)) != m_dragToken) + return std::nullopt; + const QStringList parts = QString::fromUtf8(data.data(effectType)).split(QLatin1Char(':')); + const std::optional<QUuid> id = parts.size() == 2 ? ProjectWorkspace::layerID(parts[0]) : std::nullopt; + for (const LayerEffectKind kind : allLayerEffectKinds) { + if (id && rawValue(kind) == parts[1]) + return LayerEffectSelection{*id, kind}; + } + return std::nullopt; +} diff --git a/src/UI/NativeLayerList.cpp b/src/UI/NativeLayerList.cpp new file mode 100644 index 0000000..a83bfc0 --- /dev/null +++ b/src/UI/NativeLayerList.cpp @@ -0,0 +1,241 @@ +#include "UI/NativeLayerList.h" +#include <QEvent> +#include <QKeyEvent> +#include <QScrollBar> +#include <QVBoxLayout> + +NativeLayerList::NativeLayerList(EditorSession &session, QWidget *parent) + : QScrollArea(parent), m_session(session), m_column(new LayerColumn(this)) +{ + setObjectName(QStringLiteral("layersList")); + setAccessibleName(QStringLiteral("Layers")); + setFrameShape(QFrame::NoFrame); + setWidgetResizable(true); + setAcceptDrops(true); + viewport()->setMouseTracking(true); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setFocusPolicy(Qt::StrongFocus); + setAutoFillBackground(false); + viewport()->setAutoFillBackground(false); + auto *rows = new QVBoxLayout(m_column); + rows->setContentsMargins(0, 0, 0, 0); + rows->setSpacing(2); + rows->addStretch(1); + setWidget(m_column); + m_column->setAutoFillBackground(false); + connect(&m_session, &EditorSession::changed, this, &NativeLayerList::update); + m_edgeScroll.setInterval(50); + connect(&m_edgeScroll, &QTimer::timeout, this, [this] { autoscroll(m_dragPoint, *m_dragData, m_dragActions); }); + update(); +} + +void NativeLayerList::update() +{ + const std::vector<LayerHierarchy::Entry> entries = m_session.layerRows(); + const std::vector<ImageLayer> &layers = m_session.document() ? m_session.document()->layers : std::vector<ImageLayer>(); + std::vector<ImageLayer> next; + m_details.clear(); + for (const LayerHierarchy::Entry &entry : entries) { + const int index = indexOf(layers, entry.layer.id); + if (index < 0) + continue; + next.push_back(layers[index]); + m_details.insert_or_assign(entry.layer.id, entry); + } + m_editingEnabled = m_session.canEditLayers(); + // 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) + same = next[row].id == m_rows[row].id; + m_rows = std::move(next); + if (!same) { + for (LayerCell *cell : m_cells) + delete cell; + m_cells.clear(); + auto *rows = static_cast<QVBoxLayout *>(m_column->layout()); + for (size_t row = 0; row < m_rows.size(); ++row) { + auto *cell = new LayerCell(*this); + rows->insertWidget(int(row), cell); + // A layout shows late children from the event loop. + cell->show(); + m_cells.push_back(cell); + } + rows->activate(); + } + for (size_t row = 0; row < m_rows.size(); ++row) { + const LayerHierarchy::Entry &entry = m_details.at(m_rows[row].id); + m_cells[row]->configure(m_rows[row], m_editingEnabled, entry.depth, entry.visible); + m_cells[row]->updateTarget(); + } + // Heights change at once, as Swift's noteHeightOfRows. + m_column->layout()->activate(); + // A rename, asked from anywhere, is typed in the row. + if (const std::optional<QUuid> renaming = m_session.renamingLayerID()) { + const int row = indexOf(m_rows, renaming); + if (row >= 0) { + ensureWidgetVisible(m_cells[row]); + m_cells[row]->beginRenaming(); + } + } +} + +int NativeLayerList::rowAt(QPoint listPoint) const +{ + const QPoint inColumn = m_column->mapFrom(viewport(), viewport()->mapFrom(this, listPoint)); + for (size_t row = 0; row < m_cells.size(); ++row) { + if (m_cells[row]->geometry().contains(inColumn)) + return int(row); + } + return -1; +} + +bool NativeLayerList::isClippingZone(const LayerCell &cell, QPoint cellPoint) +{ + return cellPoint.y() >= cell.height() - 10; +} + +bool NativeLayerList::clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint) +{ + const int row = indexOf(m_rows, cell.layerID()); + if (row < 0) + return false; + const QUuid id = m_rows[row].id; + focusList(); + // Swift's mouseDown lets go of a chosen effect first. + m_session.dropEffectSelection(); + if (modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier) && isClippingZone(cell, cellPoint)) { + m_session.toggleClippingMask(id); + // The cursor says what the next click does, as Swift's. + m_hover = cell.mapTo(this, cellPoint); + viewport()->setCursor(cursorFor(*m_hover, modifiers)); + return true; + } + if (modifiers.testFlag(Qt::ControlModifier)) { + m_session.extendSelection(id); + return false; + } + if (modifiers.testFlag(Qt::ShiftModifier)) { + // From the active row to this one, both included. + const int anchor = indexOf(m_rows, m_session.activeLayerID()); + QSet<QUuid> ids; + for (int each = std::min(anchor < 0 ? row : anchor, row); each <= std::max(anchor, row); ++each) + ids.insert(m_rows[each].id); + m_session.selectLayers(ids, id); + return false; + } + // A click on a lone layer's name targets its pixels. + const bool targetsMask = m_session.isMaskSelected() && m_session.selectedLayerIDs() == QSet<QUuid>{id} && !cell.isOnControl(cellPoint); + if (targetsMask) { + m_session.commitTransform(); + m_session.selectLayerTarget(id, false); + return false; + } + // Selected already, a press keeps the selection: 4.7 drags it. + if (m_session.selectedLayerIDs().contains(id)) + return false; + m_session.selectLayers({id}, id); + return false; +} + +void NativeLayerList::mousePressEvent(QMouseEvent *event) +{ + // The spacing between rows takes no press, as before. + const QPoint inColumn = m_column->mapFrom(viewport(), event->position().toPoint()); + if (event->button() != Qt::LeftButton || (!m_cells.empty() && inColumn.y() <= m_cells.back()->geometry().bottom())) + return; + m_session.dropEffectSelection(); + if (!(event->modifiers() & (Qt::ControlModifier | Qt::ShiftModifier))) + m_session.selectLayers({}, std::nullopt); +} + +void NativeLayerList::focusList() +{ + setFocus(Qt::MouseFocusReason); +} + +// Tab reaches the keys, not the focus chain, as Swift. +bool NativeLayerList::event(QEvent *event) +{ + if (event->type() == QEvent::KeyPress) { + auto *key = static_cast<QKeyEvent *>(event); + if (key->key() == Qt::Key_Tab || key->key() == Qt::Key_Backtab) { + keyPressEvent(key); + return true; + } + } + return QScrollArea::event(event); +} + +void NativeLayerList::keyPressEvent(QKeyEvent *event) +{ + const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)); + const int key = event->key(); + const bool enter = key == Qt::Key_Return || key == Qt::Key_Enter; + const bool arrow = key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down; + static const std::map<int, NavigationTool> tools{ + {Qt::Key_A, NavigationTool::idle}, {Qt::Key_V, NavigationTool::move}, {Qt::Key_H, NavigationTool::hand}, + {Qt::Key_Z, NavigationTool::zoom}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_L, NavigationTool::lasso}, {Qt::Key_M, NavigationTool::marquee}, + {Qt::Key_W, NavigationTool::wand}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp}, + {Qt::Key_U, NavigationTool::shape}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_I, NavigationTool::eyedropper}, + {Qt::Key_C, NavigationTool::crop}}; + if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) { + m_session.deleteKeyPressed(); + return; + } + if (key == Qt::Key_Escape && m_session.transformEdit()) { + m_session.cancelTransform(); + } else if (enter && m_session.transformEdit()) { + m_session.commitTransform(); + } else if (plain && (key == Qt::Key_Tab || key == Qt::Key_Backtab)) { + // Swift's list cycles on Shift-Tab as on Tab. + m_session.cycleToolMode(); + } else if (plain && (key == Qt::Key_B || key == Qt::Key_E)) { + m_session.selectTool(NavigationTool::brush); + m_session.setBrushMode(key == Qt::Key_E ? BrushToolMode::erase : BrushToolMode::paint); + } else if (plain && (key == Qt::Key_X || key == Qt::Key_D)) { + if (key == Qt::Key_X) + m_session.swapPaletteColors(); + else + m_session.resetPaletteColors(); + } else if (plain && tools.contains(key)) { + m_session.selectTool(tools.at(key)); + } else if (plain && event->text().size() == 1 && event->text().front().isDigit() && m_session.usesOpacityKeys()) { + m_session.typeOpacityDigit(event->text().front().digitValue()); + } else if (plain && arrow && m_session.tool() == NavigationTool::move) { + // Under Move the arrows nudge; an edit implies Move. + const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1; + m_session.nudgeLayer(key == Qt::Key_Left ? -step : key == Qt::Key_Right ? step : 0, key == Qt::Key_Up ? -step : key == Qt::Key_Down ? step : 0); + } else if (plain && (key == Qt::Key_Up || key == Qt::Key_Down)) { + const int row = indexOf(m_rows, m_session.activeLayerID()); + const int next = row + (key == Qt::Key_Up ? -1 : 1); + if (row < 0 || next < 0 || next >= int(m_rows.size())) + return; + // Shift ranges from the anchor; the row comes into view. + if (event->modifiers().testFlag(Qt::ShiftModifier)) { + const QSet<QUuid> selected = m_session.selectedLayerIDs(); + int first = row, last = row; + while (first > 0 && selected.contains(m_rows[first - 1].id)) + --first; + while (last + 1 < int(m_rows.size()) && selected.contains(m_rows[last + 1].id)) + ++last; + const int anchor = row == last ? first : last; + QSet<QUuid> range; + for (int each = std::min(anchor, next); each <= std::max(anchor, next); ++each) + range.insert(m_rows[each].id); + m_session.selectLayers(range, m_rows[next].id); + } else { + m_session.selectLayers({m_rows[next].id}, m_rows[next].id); + } + ensureWidgetVisible(m_cells[next], 0, 0); + } else { + QScrollArea::keyPressEvent(event); + } +} + +// A new palette: icons redraw once the widgets have it. +void NativeLayerList::changeEvent(QEvent *event) +{ + QScrollArea::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + QMetaObject::invokeMethod(this, &NativeLayerList::update, Qt::QueuedConnection); +} diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h new file mode 100644 index 0000000..f0165f7 --- /dev/null +++ b/src/UI/NativeLayerList.h @@ -0,0 +1,273 @@ +#pragma once +#include "Document/EditorSession.h" +#include "Document/LayerGroups.h" +#include <QGraphicsOpacityEffect> +#include <QLabel> +#include <QLineEdit> +#include <QMouseEvent> +#include <QScrollArea> +#include <QTimer> +#include <QToolButton> +#include <map> +#include <memory> + +class NativeLayerList; + +// A layer's eye: a press toggles, a drag swipes rows. +class EyeSwipeButton : public QToolButton { + Q_OBJECT +public: + explicit EyeSwipeButton(NativeLayerList &list, QWidget *parent); + ~EyeSwipeButton() override; + QUuid layerID; + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + // The release may land in a popup: watched application-wide. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void endSwipe(); + + NativeLayerList &m_list; + std::optional<bool> m_swipe; +}; + +// A thumbnail: a click targets it; Shift toggles a mask. +class LayerThumbnailButton : public QToolButton { + Q_OBJECT +public: + LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent); + QUuid layerID; + const bool isMaskTarget; + // An accent ring while the target is selected. + void setTargeted(bool targeted); + bool isTargeted() const { return m_targeted; } + + ~LayerThumbnailButton() override; + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *event) override; + // The release may land in a popup: watched application-wide. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void endAltMaskPress(); + + NativeLayerList &m_list; + bool m_targeted = false; + // Alt on a mask: chosen on release, or dragged. + std::optional<QPoint> m_altMaskPress; +}; + +// An effect under its layer: its eye, name and choice. +class LayerEffectRow : public QWidget { + Q_OBJECT +public: + LayerEffectRow(NativeLayerList &list, QUuid layerID, LayerEffectKind kind, QWidget *parent); + const QUuid layerID; + const LayerEffectKind kind; + // Shown or hidden, editable, stepped in with its layer. + void configure(bool enabled, bool editable, int indent); + void select(bool editing); + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + // Alt makes a press on the eye the row's. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void press(QMouseEvent *event, bool twice); + void relayout(); + + NativeLayerList &m_list; + QToolButton *const m_eye; + QLabel *const m_label; + int m_indent = 0; + // Swift's copyDown: an Alt press, chosen on release or dragged. + std::optional<QPoint> m_copyDown; +}; + +// What a row's canvas-framed thumbnail shows; redrawn only on change. +struct ThumbnailKey { + std::optional<ImageIdentity> image; + LayerTransform transform; + QSizeF canvas; + // A folder's icon takes the palette's ink. + QColor ink; + bool editableText = false; + friend bool operator==(const ThumbnailKey &, const ThumbnailKey &) = default; +}; + +// One row: eye, disclosure, thumbnails, link, name and size. +class LayerCell : public QWidget { + Q_OBJECT +public: + explicit LayerCell(NativeLayerList &list); + // Swift's row height, and 24 more for each effect. + static constexpr int rowHeight = 52; + static constexpr int effectHeight = 24; + + void configure(const ImageLayer &layer, bool enabled, int depth, bool visible); + void updateTarget(); + void beginRenaming(); + bool isOnControl(QPoint point) const; + bool isRenaming() const { return m_renaming; } + QUuid layerID() const { return m_layerID; } + LayerThumbnailButton &thumbnail() { return *m_thumbnail; } + LayerThumbnailButton &maskThumbnail() { return *m_maskThumbnail; } + // A left press becomes a drag past the distance. + void armDrag(QPoint cellPoint, Qt::KeyboardModifiers modifiers); + void disarmDrag() { m_press = std::nullopt; } + void moveDrag(QPoint cellPoint, Qt::MouseButtons buttons); + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void contextMenuEvent(QContextMenuEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void relayout(); + void endRenaming(bool keeping); + QAction *menuAction(const QString &name, const QString &title, const std::function<void()> &run); + + NativeLayerList &m_list; + EyeSwipeButton *const m_eye; + QToolButton *const m_disclosure; + LayerThumbnailButton *const m_thumbnail; + LayerThumbnailButton *const m_maskThumbnail; + QToolButton *const m_link; + QLabel *const m_disabledMaskMark; + QLabel *const m_name; + QLabel *const m_dimensions; + QLineEdit *const m_editor; + QGraphicsOpacityEffect *const m_fade; + QUuid m_layerID; + QString m_layerName; + bool m_renaming = false; + bool m_hasMask = false; + bool m_linkable = false; + bool m_isGroup = false; + bool m_editableText = false; + bool m_isAdjustment = false; + int m_indent = 0; + std::optional<QPoint> m_press; + // Alt at the press makes the drag a copy. + Qt::KeyboardModifiers m_pressModifiers; + QSize m_thumbnailSize{36, 36}; + QSize m_maskSize{30, 30}; + std::optional<ThumbnailKey> m_thumbnailKey; + std::optional<ThumbnailKey> m_maskThumbnailKey; + std::vector<LayerEffectRow *> m_effectButtons; +}; + +// Where a drag lands: above, into a folder, or last. +struct LayerDropTarget { + int row; + bool onRow; + bool atBottom; + bool copying; + friend bool operator==(const LayerDropTarget &, const LayerDropTarget &) = default; +}; + +// The column of rows; it paints the drop indicator. +class LayerColumn : public QWidget { + Q_OBJECT +public: + using QWidget::QWidget; + std::optional<QRect> indicator; + bool indicatorFills = false; + +protected: + void paintEvent(QPaintEvent *event) override; +}; + +// The layer rows, top down, following the session's hierarchy. +class NativeLayerList : public QScrollArea { + Q_OBJECT +public: + explicit NativeLayerList(EditorSession &session, QWidget *parent = nullptr); + // A dragged mask carries its layer's id under this type. + static const QString maskType; + // A row drag names its list, as Swift checks tables. + static const QString sourceType; + // A dragged effect: its layer's id and kind, colon apart. + static const QString effectType; + QString dragToken() const { return m_dragToken; } + + EditorSession &session() const { return m_session; } + void update(); + std::vector<LayerCell *> cells() const { return m_cells; } + // The row under a point of this list, or -1. + int rowAt(QPoint listPoint) const; + // A row pressed; modifiers extend or clip; true when clipped. + bool clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint); + // Photoshop's strip along the bottom of a row. + static bool isClippingZone(const LayerCell &cell, QPoint cellPoint); + void focusList(); + // What a drag of this row carries: its selection. + std::unique_ptr<QMimeData> dragData(const LayerCell &cell) const; + void startDrag(LayerCell &cell, Qt::KeyboardModifiers modifiers); + void startMaskDrag(LayerThumbnailButton &thumbnail); + void startEffectDrag(LayerEffectRow &row); + // Where a drop lands; Alt offers a copy alone. + std::optional<LayerDropTarget> dropTarget(const QMimeData &data, Qt::DropActions offered, QPoint listPoint) const; + bool acceptDrop(const QMimeData &data, Qt::DropActions offered, QPoint listPoint); + // Whether a drag held at an edge still scrolls. + bool autoscrolling() const { return m_edgeScroll.isActive(); } + // The cursor Alt asks for here: clip, duplicate, arrow. + QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const; + static QCursor clippingCursor(bool releasing, double ratio); + +protected: + bool event(QEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + // Below every row: Swift's table lets go of everything. + void mousePressEvent(QMouseEvent *event) override; + void changeEvent(QEvent *event) override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + bool viewportEvent(QEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dragLeaveEvent(QDragLeaveEvent *event) override; + void dropEvent(QDropEvent *event) override; + +private: + std::vector<QUuid> draggedLayers(const QMimeData &data) const; + std::optional<LayerEffectSelection> draggedEffect(const QMimeData &data) const; + void showIndicator(const std::optional<LayerDropTarget> &target); + void refreshCursor(); + // A drag held at an edge scrolls until it ends. + void autoscroll(QPoint inViewport, const QMimeData &data, Qt::DropActions offered); + + EditorSession &m_session; + LayerColumn *const m_column; + std::vector<LayerCell *> m_cells; + std::vector<ImageLayer> m_rows; + std::map<QUuid, LayerHierarchy::Entry> m_details; + bool m_editingEnabled = false; + const QString m_dragToken = QUuid::createUuid().toString(); + // Where the pointer last hovered, in the list's coordinates. + std::optional<QPoint> m_hover; + QTimer m_edgeScroll; + QPoint m_dragPoint; + const QMimeData *m_dragData = nullptr; + Qt::DropActions m_dragActions; +}; diff --git a/src/UI/NavigationToolHeader.cpp b/src/UI/NavigationToolHeader.cpp new file mode 100644 index 0000000..aeaaf32 --- /dev/null +++ b/src/UI/NavigationToolHeader.cpp @@ -0,0 +1,83 @@ +#include "UI/NavigationToolHeader.h" +#include <QKeyEvent> +#include <QRegularExpression> +#include <cmath> + +NavigationToolHeader::NavigationToolHeader(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QString(), parent), m_session(session), m_zoom(new QLineEdit(this)), m_unit(new QLabel(QStringLiteral("%"), this)) +{ + m_zoom->setObjectName(QStringLiteral("zoomPercentage")); + m_zoom->setAccessibleName(QStringLiteral("Zoom percentage")); + m_zoom->setToolTip(QStringLiteral("Zoom percentage (0.1–3200%). Press Return to apply.")); + m_zoom->setFixedWidth(72); + m_zoom->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_zoom->installEventFilter(this); + // The unit sits tight against its field: one value. + auto *value = new QHBoxLayout; + value->setSpacing(2); + value->addWidget(m_zoom); + value->addWidget(m_unit); + row->insertLayout(1, value); + connect(&m_session, &EditorSession::changed, this, &NavigationToolHeader::synchronize); + synchronize(); +} + +void NavigationToolHeader::synchronize() +{ + const bool zooms = m_session.tool() == NavigationTool::zoom; + title->setText(zooms ? QStringLiteral("Zoom") : QStringLiteral("Pan")); + m_zoom->setVisible(zooms); + m_unit->setVisible(zooms); + m_zoom->setEnabled(m_session.document().has_value() && !m_session.showsBusy()); + if (!m_zoom->hasFocus()) + syncZoom(); +} + +void NavigationToolHeader::syncZoom() +{ + static const QRegularExpression trailingZeros(QStringLiteral("\\.?0+$")); + m_displayed = QString::number(m_session.viewport.zoom() * 100, 'f', 2).remove(trailingZeros); + m_zoom->setText(m_displayed); +} + +void NavigationToolHeader::applyZoom() +{ + if (m_zoom->text() != m_displayed) { + // No number reads as zero; "inf" the viewport refuses. + const double value = m_zoom->text().trimmed().remove(QLatin1Char('%')).toDouble(); + if (value > 0 && !m_session.isProjectBusy()) + m_session.zoom(value / 100); + } + syncZoom(); +} + +void NavigationToolHeader::step(double percent) +{ + m_zoom->setText(QString::number(std::clamp(percent, 0.1, 3200.0), 'g', 6)); + applyZoom(); +} + +bool NavigationToolHeader::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::FocusOut) + applyZoom(); + if (event->type() != QEvent::KeyPress) + return ToolHeaderBar::eventFilter(watched, event); + const auto *key = static_cast<QKeyEvent *>(event); + // Return and Escape apply, then hand the canvas the keys. + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) { + applyZoom(); + m_zoom->clearFocus(); + m_session.requestCanvasFocus(); + return true; + } + if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down) + return ToolHeaderBar::eventFilter(watched, event); + // Up and Down step one percent, ten with Shift. + static const QRegularExpression notNumeric(QStringLiteral("[^0-9.]")); + bool isNumber = false; + const double typed = m_zoom->text().remove(notNumeric).toDouble(&isNumber); + const double amount = (key->modifiers() & Qt::ShiftModifier ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1); + step((isNumber ? typed : m_session.viewport.zoom() * 100) + amount); + return true; +} diff --git a/src/UI/NavigationToolHeader.h b/src/UI/NavigationToolHeader.h new file mode 100644 index 0000000..12c9ffa --- /dev/null +++ b/src/UI/NavigationToolHeader.h @@ -0,0 +1,26 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ToolHeaderStyle.h" +#include <QLineEdit> + +// The Hand and Zoom tools' bar: Zoom's percentage field. +class NavigationToolHeader : public ToolHeaderBar { + Q_OBJECT +public: + explicit NavigationToolHeader(EditorSession &session, QWidget *parent = nullptr); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void synchronize(); + void syncZoom(); + void applyZoom(); + void step(double percent); + + EditorSession &m_session; + QLineEdit *const m_zoom; + QLabel *const m_unit; + // What the field showed before the user typed. + QString m_displayed; +}; diff --git a/src/UI/NewCanvasSheet.cpp b/src/UI/NewCanvasSheet.cpp new file mode 100644 index 0000000..74aa8dc --- /dev/null +++ b/src/UI/NewCanvasSheet.cpp @@ -0,0 +1,134 @@ +#include "UI/NewCanvasSheet.h" +#include <QApplication> +#include <QBuffer> +#include <QClipboard> +#include <QGridLayout> +#include <QImageReader> +#include <QShortcut> +#include <QVBoxLayout> + +namespace { +QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + QFont font = label->font(); + font.setPixelSize(pixels); + font.setWeight(weight); + label->setFont(font); + label->setForegroundRole(role); + return label; +} +} + +NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent) + : QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)), + m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), + m_create(new QPushButton(QStringLiteral("Create canvas"), this)) +{ + // Swift's fields stretch the sheet to its full 500 points. + setFixedWidth(500); + m_width->setObjectName(QStringLiteral("widthInput")); + m_height->setObjectName(QStringLiteral("heightInput")); + m_note->setObjectName(QStringLiteral("canvasNote")); + m_create->setObjectName(QStringLiteral("createCanvas")); + m_create->setDefault(true); + auto *open = new QPushButton(QStringLiteral("Open project"), this); + open->setObjectName(QStringLiteral("openProject")); + auto *import = new QPushButton(QStringLiteral("Import image"), this); + import->setObjectName(QStringLiteral("importImage")); + + auto *fields = new QGridLayout; + fields->setHorizontalSpacing(16); + fields->setVerticalSpacing(8); + fields->addWidget(text(QStringLiteral("Width"), 12, QFont::Medium, QPalette::WindowText, this), 0, 0); + fields->addWidget(text(QStringLiteral("Height"), 12, QFont::Medium, QPalette::WindowText, this), 0, 3); + fields->addWidget(m_width, 1, 0); + fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 1); + fields->addWidget(text(QStringLiteral("×"), 14, QFont::Normal, QPalette::PlaceholderText, this), 1, 2); + fields->addWidget(m_height, 1, 3); + fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 4); + + auto *buttons = new QHBoxLayout; + buttons->setSpacing(10); + buttons->addWidget(open); + buttons->addWidget(import); + buttons->addStretch(1); + buttons->addWidget(m_create); + + auto *column = new QVBoxLayout(this); + column->setContentsMargins(28, 28, 28, 28); + column->setSpacing(24); + auto *heading = new QVBoxLayout; + heading->setSpacing(6); + heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this)); + heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this)); + column->addLayout(heading); + column->addLayout(fields); + column->addWidget(m_note); + column->addLayout(buttons); + + connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); + connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); + // Return anywhere on the sheet is the default action. + for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter}) + new QShortcut(QKeySequence(key), this, this, &NewCanvasSheet::create, Qt::WidgetWithChildrenShortcut); + connect(m_create, &QPushButton::clicked, this, &NewCanvasSheet::create); + connect(open, &QPushButton::clicked, this, [onOpen = std::move(onOpen)] { onOpen(); }); + connect(import, &QPushButton::clicked, this, [this] { m_session.setShowsImporter(true); }); + connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize); + synchronize(); + validate(); +} + +void NewCanvasSheet::synchronize() +{ + setEnabled(!m_session.isImporting() && !m_session.showsBusy()); +} + +void NewCanvasSheet::validate() +{ + const bool valid = CanvasDocument::validDimension(m_width->text()) && CanvasDocument::validDimension(m_height->text()); + m_note->setText(valid ? QStringLiteral("Transparent canvas · sRGB") : QStringLiteral("Enter whole numbers from 1 to 30,000 pixels.")); + // The palette's bright text is the theme's warning colour. + m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText); + m_create->setEnabled(valid); +} + +void NewCanvasSheet::create() +{ + const std::optional<int> width = CanvasDocument::validDimension(m_width->text()), height = CanvasDocument::validDimension(m_height->text()); + if (width && height) + m_onCreate(*width, *height); +} + +void NewCanvasSheet::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!m_suggestedClipboardSize) { + m_suggestedClipboardSize = true; + if (m_session.skipsInitialClipboardCanvasSize) { + m_session.skipsInitialClipboardCanvasSize = false; + } else if (const std::optional<QSize> size = clipboardDimensions(QApplication::clipboard()->mimeData())) { + m_width->setText(QString::number(size->width())); + m_height->setText(QString::number(size->height())); + } + } + m_width->setFocus(); +} + +std::optional<QSize> NewCanvasSheet::clipboardDimensions(const QMimeData *clipboard) +{ + for (const QString &type : {QStringLiteral("image/png"), QStringLiteral("image/tiff")}) { + QByteArray data = clipboard ? clipboard->data(type) : QByteArray(); + QBuffer buffer(&data); + QImageReader reader(&buffer); + QSize size = reader.size(); + // Stored on its side, a picture swaps its sides. + if (reader.transformation() & QImageIOHandler::TransformationRotate90) + size.transpose(); + // In range on one side alone is no canvas. + if (CanvasDocument::validDimension(QString::number(size.width())) && CanvasDocument::validDimension(QString::number(size.height()))) + return size; + } + return std::nullopt; +} diff --git a/src/UI/NewCanvasSheet.h b/src/UI/NewCanvasSheet.h new file mode 100644 index 0000000..55ef78c --- /dev/null +++ b/src/UI/NewCanvasSheet.h @@ -0,0 +1,34 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QLabel> +#include <QLineEdit> +#include <QMimeData> +#include <QPushButton> +#include <QWidget> +#include <functional> + +// The welcome: a new canvas, a project or an image. +class NewCanvasSheet : public QWidget { + Q_OBJECT +public: + NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent = nullptr); + + // A copied picture's size, read from its header alone. + static std::optional<QSize> clipboardDimensions(const QMimeData *clipboard); + +protected: + void showEvent(QShowEvent *event) override; + +private: + void synchronize(); + void validate(); + void create(); + + EditorSession &m_session; + const std::function<void(int, int)> m_onCreate; + QLineEdit *const m_width; + QLineEdit *const m_height; + QLabel *const m_note; + QPushButton *const m_create; + bool m_suggestedClipboardSize = false; +}; diff --git a/src/UI/OmarchyTheme.cpp b/src/UI/OmarchyTheme.cpp new file mode 100644 index 0000000..e736dda --- /dev/null +++ b/src/UI/OmarchyTheme.cpp @@ -0,0 +1,222 @@ +#include "UI/OmarchyTheme.h" +#include "Logging.h" +#include <QApplication> +#include <QDir> +#include <QFile> +#include <QRegularExpression> +#include <QStyleFactory> +#include <cerrno> +#include <cstring> +#include <stdexcept> +#include <sys/stat.h> + +namespace { +// The file's assignments: quoted values, and keys without one. +struct Assignments { + QMap<QString, QString> values; + QSet<QString> malformed; + + QString value(const QString &key) const + { + if (malformed.contains(key)) + throw std::runtime_error("colors.toml: " + key.toStdString() + " is no quoted string"); + if (!values.contains(key)) + throw std::runtime_error("colors.toml lacks " + key.toStdString()); + return values.value(key); + } +}; + +bool isQuote(QChar character) +{ + return character == QLatin1Char('"') || character == QLatin1Char('\''); +} + +// A key or value with its matching quotes taken off. +QString unquoted(const QString &text) +{ + const bool quoted = text.size() >= 2 && isQuote(text.front()) && text.back() == text.front(); + return quoted ? text.mid(1, text.size() - 2) : text; +} + +Assignments assignments(const QString &toml) +{ + Assignments result; + for (const QString &each : toml.split(QLatin1Char('\n'))) { + const QString line = each.trimmed(); + const qsizetype equals = line.indexOf(QLatin1Char('=')); + if (line.isEmpty() || line.startsWith(QLatin1Char('#')) || line.startsWith(QLatin1Char('[')) || equals < 0) + continue; + const QString key = unquoted(line.left(equals).trimmed()); + const QString rest = line.mid(equals + 1).trimmed(); + const qsizetype close = rest.isEmpty() || !isQuote(rest.front()) ? -1 : rest.indexOf(rest.front(), 1); + const QString after = close < 0 ? QString() : rest.mid(close + 1).trimmed(); + if (close < 0 || !(after.isEmpty() || after.startsWith(QLatin1Char('#')))) + result.malformed.insert(key); + else + result.values.insert(key, rest.mid(1, close - 1)); + } + return result; +} + +QColor colour(const Assignments &found, const QString &key) +{ + static const QRegularExpression hex(QStringLiteral("^#[0-9a-fA-F]{6}$")); + const QString value = found.value(key); + if (!hex.match(value).hasMatch()) + throw std::runtime_error("colors.toml: " + key.toStdString() + " is no #rrggbb colour: " + value.toStdString()); + return QColor(value); +} +} + +OmarchyColors OmarchyColors::parse(const QString &toml) +{ + // Flat `key = "value"` lines; recognised keys well formed. + const Assignments values = assignments(toml); + const QString mode = values.value(QStringLiteral("mode")); + if (mode != QLatin1String("dark") && mode != QLatin1String("light")) + throw std::runtime_error("colors.toml: mode must be dark or light, not " + mode.toStdString()); + return {.dark = mode == QLatin1String("dark"), + .accent = colour(values, QStringLiteral("accent")), + .selection = colour(values, QStringLiteral("selection")), + .muted = colour(values, QStringLiteral("muted")), + .background = colour(values, QStringLiteral("background")), + .darkBackground = colour(values, QStringLiteral("dark_background")), + .darkerBackground = colour(values, QStringLiteral("darker_background")), + .lighterBackground = colour(values, QStringLiteral("lighter_background")), + .foreground = colour(values, QStringLiteral("foreground")), + .darkForeground = colour(values, QStringLiteral("dark_foreground")), + .lightForeground = colour(values, QStringLiteral("light_foreground")), + .brightForeground = colour(values, QStringLiteral("bright_foreground")), + .red = colour(values, QStringLiteral("red")), + .warning = colour(values, values.values.contains(QStringLiteral("orange")) || values.malformed.contains(QStringLiteral("orange")) + ? QStringLiteral("orange") + : QStringLiteral("yellow"))}; +} + +OmarchyColors OmarchyColors::builtInDark() +{ + // Swift's grays: the window at 0.14, fields at 0.105. + return {.dark = true, + .accent = QColor(0x0a, 0x84, 0xff), + .selection = QColor(0x3a, 0x3a, 0x3a), + .muted = QColor(0x4a, 0x4a, 0x4a), + .background = QColor(0x24, 0x24, 0x24), + .darkBackground = QColor(0x1e, 0x1e, 0x1e), + .darkerBackground = QColor(0x1b, 0x1b, 0x1b), + .lighterBackground = QColor(0x30, 0x30, 0x30), + .foreground = QColor(0xe5, 0xe5, 0xe5), + .darkForeground = QColor(0x8a, 0x8a, 0x8a), + .lightForeground = QColor(0xcc, 0xcc, 0xcc), + .brightForeground = QColor(0xff, 0xff, 0xff), + .red = QColor(0xff, 0x45, 0x3a), + .warning = QColor(0xff, 0x9f, 0x0a)}; +} + +QPalette OmarchyColors::palette() const +{ + QPalette result; + result.setColor(QPalette::Window, background); + result.setColor(QPalette::WindowText, foreground); + result.setColor(QPalette::Base, darkerBackground); + result.setColor(QPalette::AlternateBase, darkBackground); + result.setColor(QPalette::Text, foreground); + result.setColor(QPalette::Button, lighterBackground); + result.setColor(QPalette::ButtonText, foreground); + result.setColor(QPalette::Highlight, accent); + result.setColor(QPalette::HighlightedText, background); + result.setColor(QPalette::Link, accent); + result.setColor(QPalette::PlaceholderText, darkForeground); + result.setColor(QPalette::BrightText, warning); + result.setColor(QPalette::Mid, muted); + result.setColor(QPalette::Dark, darkerBackground); + result.setColor(QPalette::Light, lighterBackground); + result.setColor(QPalette::ToolTipBase, darkBackground); + result.setColor(QPalette::ToolTipText, foreground); + for (const QPalette::ColorRole role : {QPalette::WindowText, QPalette::Text, QPalette::ButtonText}) + result.setColor(QPalette::Disabled, role, muted); + return result; +} + +QString OmarchyTheme::defaultDirectory() +{ + return QDir::homePath() + QStringLiteral("/.local/state/omarchy/current"); +} + +OmarchyTheme::OmarchyTheme(const QString &directory, QObject *parent) + : QObject(parent), m_directory(directory), m_colors(OmarchyColors::builtInDark()), m_watcher(this), m_settle(this) +{ + // Fusion follows the palette on every desktop. + QApplication::setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); + // A switch removes, moves, writes: one load after the last. + m_settle.setSingleShot(true); + m_settle.setInterval(100); + connect(&m_settle, &QTimer::timeout, this, &OmarchyTheme::apply); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, &m_settle, qOverload<>(&QTimer::start)); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, &m_settle, qOverload<>(&QTimer::start)); + // The built-in dark stands until a file is read whole. + QApplication::setPalette(m_colors.palette()); + apply(); +} + +void OmarchyTheme::apply() +{ + const QString path = m_directory + QStringLiteral("/theme/colors.toml"); + watch(); + QFile file(path); + OmarchyColors loaded = OmarchyColors::builtInDark(); + // Only a path leading nowhere is absent; barred keeps. + struct stat status; + if (::stat(QFile::encodeName(path).constData(), &status) != 0 && (errno == ENOENT || errno == ENOTDIR)) { + qCInfo(lcApp).noquote() << "no Omarchy theme at" << path << "- built-in dark"; + } else if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette:" << file.errorString() << path; + return; + } else { + // A read cut short must not pass for the file. + const QByteArray bytes = file.readAll(); + if (file.error() != QFileDevice::NoError || bytes.size() != file.size()) { + qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette: read" << bytes.size() << "of" << file.size() << "bytes" << path; + return; + } + try { + loaded = OmarchyColors::parse(QString::fromUtf8(bytes)); + } catch (const std::runtime_error &error) { + qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette:" << error.what(); + return; + } + QFile name(m_directory + QStringLiteral("/theme.name")); + const QString called = name.open(QIODevice::ReadOnly) ? QString::fromUtf8(name.readAll()).trimmed() : QStringLiteral("(unnamed)"); + qCInfo(lcApp).noquote() << "Omarchy theme" << called << (loaded.dark ? "dark" : "light") << "from" << path; + } + if (loaded == m_colors) + return; + m_colors = loaded; + QApplication::setPalette(m_colors.palette()); +} + +void OmarchyTheme::watch() +{ + // Removed paths leave the watcher: add them back each load. + QStringList wanted{m_directory, m_directory + QStringLiteral("/theme"), m_directory + QStringLiteral("/theme/colors.toml")}; + // A file's watch follows its inode: renew it each load. + if (m_watcher.files().contains(wanted.last())) + m_watcher.removePath(wanted.last()); + for (const QString &path : wanted) { + if (!m_watcher.files().contains(path) && !m_watcher.directories().contains(path)) + m_watcher.addPath(path); + } + // Missing or barred, the state directory's nearest watchable ancestor tells. + QString ancestor = m_directory; + while (!m_watcher.directories().contains(m_directory) && ancestor.contains(QLatin1Char('/'))) { + ancestor = ancestor.section(QLatin1Char('/'), 0, -2); + const QString candidate = ancestor.isEmpty() ? QStringLiteral("/") : ancestor; + if (m_watcher.directories().contains(candidate) || m_watcher.addPath(candidate)) { + wanted.prepend(candidate); + break; + } + } + for (const QString &stale : m_watcher.directories()) { + if (!wanted.contains(stale)) + m_watcher.removePath(stale); + } +} diff --git a/src/UI/OmarchyTheme.h b/src/UI/OmarchyTheme.h new file mode 100644 index 0000000..4c1dffc --- /dev/null +++ b/src/UI/OmarchyTheme.h @@ -0,0 +1,51 @@ +#pragma once +#include <QColor> +#include <QFileSystemWatcher> +#include <QObject> +#include <QPalette> +#include <QTimer> + +// Omarchy's colors.toml, read into the colours the chrome uses. +struct OmarchyColors { + bool dark; + QColor accent; + QColor selection; + QColor muted; + QColor background; + QColor darkBackground; + QColor darkerBackground; + QColor lighterBackground; + QColor foreground; + QColor darkForeground; + QColor lightForeground; + QColor brightForeground; + QColor red; + // `orange` where the theme has it, else `yellow`. + QColor warning; + + // Throws std::runtime_error naming the missing or malformed key. + static OmarchyColors parse(const QString &toml); + // The macOS look, for a desktop without Omarchy. + static OmarchyColors builtInDark(); + QPalette palette() const; + friend bool operator==(const OmarchyColors &, const OmarchyColors &) = default; +}; + +// Applies the current theme to the application and follows switches. +class OmarchyTheme : public QObject { + Q_OBJECT +public: + static QString defaultDirectory(); + explicit OmarchyTheme(const QString &directory = defaultDirectory(), QObject *parent = nullptr); + + const OmarchyColors &colors() const { return m_colors; } + void apply(); + +private: + void watch(); + + const QString m_directory; + OmarchyColors m_colors; + QFileSystemWatcher m_watcher; + QTimer m_settle; +}; diff --git a/src/UI/ProjectTabs.cpp b/src/UI/ProjectTabs.cpp new file mode 100644 index 0000000..ff5e612 --- /dev/null +++ b/src/UI/ProjectTabs.cpp @@ -0,0 +1,282 @@ +#include "UI/ProjectTabs.h" +#include "IO/ImageFileDrop.h" +#include <QApplication> +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QEvent> +#include <QHBoxLayout> +#include <QPainter> +#include <QScrollBar> + +namespace { +QFont tabFont(bool active) +{ + QFont font; + font.setPixelSize(12); + font.setWeight(active ? QFont::DemiBold : QFont::Medium); + return font; +} +} + +ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent) + : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new QToolButton(this)), m_close(new QToolButton(this)) +{ + // 28 high: the strip's 34 less its margins. + m_select->setObjectName(QStringLiteral("selectTab")); + m_select->setAutoRaise(true); + m_select->setFocusPolicy(Qt::NoFocus); + m_select->setMinimumWidth(35); + m_select->setMaximumWidth(155); + m_close->setObjectName(QStringLiteral("closeTab")); + m_close->setAutoRaise(true); + m_close->setFocusPolicy(Qt::NoFocus); + m_close->setText(QStringLiteral("×")); + m_close->setFixedSize(16, 28); + auto *row = new QHBoxLayout(this); + row->setContentsMargins(11, 0, 5, 0); + row->setSpacing(0); + row->addWidget(m_select); + row->addWidget(m_close); + setAcceptDrops(true); + connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); }); + connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); }); + // The strip syncs every button on the workspace's news. + connect(&this->tab->session, &EditorSession::changed, this, &ProjectTabButton::synchronize); + synchronize(); +} + +void ProjectTabButton::synchronize() +{ + const bool active = m_workspace.selectedID() == tab->id; + // Swift's dot for unsaved changes stands before the title. + m_select->setText((tab->session.isModified() ? QStringLiteral("● ") : QString()) + tab->title()); + m_select->setFont(tabFont(active)); + m_select->setToolTip(tab->title()); + m_select->setEnabled(m_workspace.canSwitch() || active); + m_close->setToolTip(QStringLiteral("Close %1").arg(tab->title())); + m_close->setAccessibleName(QStringLiteral("Close %1").arg(tab->title())); + m_close->setEnabled(m_workspace.canSwitch()); + // The capsule repaints with the buttons it holds. + setProperty("active", active); +} + +// Another tab's layer, unless Alt duplicates it, or files. +bool ProjectTabButton::acceptsDrop(const QMimeData &data) const +{ + const bool layer = data.hasFormat(ProjectWorkspace::layerType); + if ((layer && QApplication::keyboardModifiers().testFlag(Qt::AltModifier)) || !(layer || ImageFileDrop::holdsImages(data))) + return false; + return m_workspace.canSwitch() && m_workspace.canReceiveDrag(data, tab->id); +} + +void ProjectTabButton::setTargeted(bool targeted) +{ + m_targeted = targeted; + m_select->setToolTip(targeted ? QStringLiteral("Add to %1").arg(tab->title()) : tab->title()); + QWidget::update(); +} + +void ProjectTabButton::dragEnterEvent(QDragEnterEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + setTargeted(true); +} + +void ProjectTabButton::dragMoveEvent(QDragMoveEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) { + event->ignore(); + return; + } + event->setDropAction(Qt::CopyAction); + event->accept(); +} + +void ProjectTabButton::dragLeaveEvent(QDragLeaveEvent *) +{ + setTargeted(false); +} + +void ProjectTabButton::dropEvent(QDropEvent *event) +{ + setTargeted(false); + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + m_workspace.receiveProviders(*event->mimeData(), tab->id); +} + +bool ProjectTabButton::isActive() const +{ + return property("active").toBool(); +} + +void ProjectTabButton::paintEvent(QPaintEvent *) +{ + // A capsule: brighter under the front tab. + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const bool active = property("active").toBool(); + QColor fill = palette().color(QPalette::WindowText), edge = fill; + fill.setAlphaF(active ? 0.12 : 0.035); + edge.setAlphaF(active ? 0.22 : 0.08); + // A drop target takes the accent, filled and rimmed. + if (m_targeted) { + fill = palette().color(QPalette::Highlight); + fill.setAlphaF(0.3); + edge = palette().color(QPalette::Highlight); + } + painter.setPen(QPen(edge, m_targeted ? 2 : 1)); + painter.setBrush(fill); + const double inset = m_targeted ? 1 : 0.5; + painter.drawRoundedRect(QRectF(inset, inset, width() - 2 * inset, height() - 2 * inset), 14, 14); +} + +NewCanvasButton::NewCanvasButton(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace) +{ + setObjectName(QStringLiteral("newCanvasButton")); + setAcceptDrops(true); +} + +// Any tab's layer or files, for a fresh tab. +bool NewCanvasButton::acceptsDrop(const QMimeData &data) const +{ + const bool layer = data.hasFormat(ProjectWorkspace::layerType); + if ((layer && QApplication::keyboardModifiers().testFlag(Qt::AltModifier)) || !(layer || ImageFileDrop::holdsImages(data))) + return false; + return m_workspace.canSwitch() && m_workspace.canReceiveDrag(data, std::nullopt); +} + +void NewCanvasButton::setTargeted(bool targeted) +{ + m_targeted = targeted; + setToolTip(targeted ? QStringLiteral("Open in a new project tab") : defaultAction()->toolTip()); + update(); +} + +void NewCanvasButton::paintEvent(QPaintEvent *event) +{ + QToolButton::paintEvent(event); + if (!m_targeted) + return; + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(palette().color(QPalette::Highlight), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 6, 6); +} + +void NewCanvasButton::dragEnterEvent(QDragEnterEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + setTargeted(true); +} + +void NewCanvasButton::dragMoveEvent(QDragMoveEvent *event) +{ + if (!acceptsDrop(*event->mimeData())) { + event->ignore(); + return; + } + event->setDropAction(Qt::CopyAction); + event->accept(); +} + +void NewCanvasButton::dragLeaveEvent(QDragLeaveEvent *) +{ + setTargeted(false); +} + +void NewCanvasButton::dropEvent(QDropEvent *event) +{ + setTargeted(false); + if (!acceptsDrop(*event->mimeData())) + return; + event->setDropAction(Qt::CopyAction); + event->accept(); + m_workspace.receiveProviders(*event->mimeData(), std::nullopt); +} + +ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent) + : QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this)) +{ + setObjectName(QStringLiteral("projectTabs")); + setAccessibleName(QStringLiteral("Project tabs")); + setFixedHeight(34); + setFrameShape(QFrame::NoFrame); + // The toolbar shows through; a stylesheet would freeze the palette. + setAutoFillBackground(false); + viewport()->setAutoFillBackground(false); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *row = new QHBoxLayout(m_row); + row->setContentsMargins(0, 3, 0, 3); + row->setSpacing(6); + row->addStretch(1); + setWidget(m_row); + // setWidget turns the row's fill on; the toolbar shows through. + m_row->setAutoFillBackground(false); + m_row->installEventFilter(this); + connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize); + synchronize(); +} + +QList<ProjectTabButton *> ProjectTabStrip::buttons() const +{ + QList<ProjectTabButton *> result; + for (int index = 0; index < m_row->layout()->count(); ++index) { + if (auto *button = qobject_cast<ProjectTabButton *>(m_row->layout()->itemAt(index)->widget())) + result << button; + } + return result; +} + +void ProjectTabStrip::synchronize() +{ + // Buttons follow the tabs: kept where the tab stays. + auto *row = static_cast<QHBoxLayout *>(m_row->layout()); + QList<ProjectTabButton *> shown = buttons(); + int position = 0; + for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) { + const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; }); + ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept; + if (kept != shown.end()) + shown.erase(kept); + row->removeWidget(button); + row->insertWidget(position, button); + button->show(); + button->synchronize(); + position += 1; + } + for (ProjectTabButton *gone : shown) + delete gone; + // The row is as wide as its tabs, never squeezed. + m_row->resize(m_row->sizeHint()); + showFront(); +} + +bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event) +{ + // A title changed size: the row takes its new width. + if (event->type() == QEvent::LayoutRequest) { + m_row->resize(m_row->sizeHint()); + showFront(); + } + return QScrollArea::eventFilter(watched, event); +} + +// The front tab scrolls into view. +void ProjectTabStrip::showFront() +{ + for (ProjectTabButton *button : buttons()) { + if (button->tab->id == m_workspace.selectedID()) + ensureWidgetVisible(button, 0, 0); + } +} diff --git a/src/UI/ProjectTabs.h b/src/UI/ProjectTabs.h new file mode 100644 index 0000000..6dc9bc7 --- /dev/null +++ b/src/UI/ProjectTabs.h @@ -0,0 +1,73 @@ +#pragma once +#include "Document/ProjectWorkspace.h" +#include <QScrollArea> +#include <QToolButton> +#include <QWidget> + +// One tab: its title, the unsaved dot, its close button. +class ProjectTabButton : public QWidget { + Q_OBJECT +public: + ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent = nullptr); + + const std::shared_ptr<ProjectTab> tab; + // What the session and the workspace hold, shown. + void synchronize(); + bool isActive() const; + +protected: + void paintEvent(QPaintEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dragLeaveEvent(QDragLeaveEvent *event) override; + void dropEvent(QDropEvent *event) override; + +private: + bool acceptsDrop(const QMimeData &data) const; + void setTargeted(bool targeted); + + ProjectWorkspace &m_workspace; + QToolButton *const m_select; + QToolButton *const m_close; + bool m_targeted = false; +}; + +// Swift's NewProjectDropTarget: the New canvas button takes drops. +class NewCanvasButton : public QToolButton { + Q_OBJECT +public: + explicit NewCanvasButton(ProjectWorkspace &workspace, QWidget *parent = nullptr); + +protected: + void paintEvent(QPaintEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dragMoveEvent(QDragMoveEvent *event) override; + void dragLeaveEvent(QDragLeaveEvent *event) override; + void dropEvent(QDropEvent *event) override; + +private: + bool acceptsDrop(const QMimeData &data) const; + void setTargeted(bool targeted); + + ProjectWorkspace &m_workspace; + bool m_targeted = false; +}; + +// The row of tabs, one a project, scrolling when long. +class ProjectTabStrip : public QScrollArea { + Q_OBJECT +public: + explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr); + + QList<ProjectTabButton *> buttons() const; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void synchronize(); + void showFront(); + + ProjectWorkspace &m_workspace; + QWidget *const m_row; +}; diff --git a/src/UI/ProjectWorkspaceView.cpp b/src/UI/ProjectWorkspaceView.cpp new file mode 100644 index 0000000..1991d1c --- /dev/null +++ b/src/UI/ProjectWorkspaceView.cpp @@ -0,0 +1,112 @@ +#include "UI/ProjectWorkspaceView.h" +#include <QCloseEvent> + +namespace { +QAction *action(QToolBar *bar, const QString &text, const QString &help, const QString &name) +{ + QAction *made = bar->addAction(text); + made->setToolTip(help); + made->setObjectName(name); + return made; +} +} + +ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget *parent) + : QMainWindow(parent), m_workspace(workspace), m_toolbar(new QToolBar(this)), m_tabs(new ProjectTabStrip(workspace, this)) +{ + setMinimumSize(800, 520); + resize(1180, 780); + m_toolbar->setObjectName(QStringLiteral("toolbar")); + m_toolbar->setMovable(false); + m_toolbar->setFloatable(false); + // Its button takes drops for a fresh tab. + m_newCanvas = new QAction(QStringLiteral("+"), this); + m_newCanvas->setToolTip(QStringLiteral("New canvas (Ctrl+N) · Drop images here for new tabs")); + m_newCanvas->setObjectName(QStringLiteral("newCanvasToolbar")); + auto *newButton = new NewCanvasButton(m_workspace, m_toolbar); + newButton->setDefaultAction(m_newCanvas); + m_toolbar->addWidget(newButton); + m_toolbar->addWidget(m_tabs); + // The strip takes the toolbar's free width; zooms stay right. + auto *spacer = new QWidget(m_toolbar); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + m_toolbar->addWidget(spacer); + m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas")); + m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels")); + m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn")); + m_zoomOut = action(m_toolbar, QStringLiteral("−"), QStringLiteral("Zoom out (Ctrl+−)"), QStringLiteral("zoomOut")); + addToolBar(Qt::TopToolBarArea, m_toolbar); + new CompositorMenus(m_workspace, *menuBar(), *this); + m_workspace.window = this; + connect(m_newCanvas, &QAction::triggered, this, [this] { m_workspace.current().controller.newCanvas(); }); + connect(m_fit, &QAction::triggered, this, [this] { m_workspace.current().session.fit(); }); + connect(m_actualPixels, &QAction::triggered, this, [this] { m_workspace.current().session.zoom(1); }); + connect(m_zoomIn, &QAction::triggered, this, [this] { + EditorSession &session = m_workspace.current().session; + session.zoom(session.viewport.zoom() * 1.25); + }); + connect(m_zoomOut, &QAction::triggered, this, [this] { + EditorSession &session = m_workspace.current().session; + session.zoom(session.viewport.zoom() / 1.25); + }); + connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectWorkspaceView::synchronize); + synchronize(); +} + +void ProjectWorkspaceView::watchFront() +{ + // Every tab's buttons dim while the front tab is busy. + disconnect(m_sessionWatch); + m_sessionWatch = connect(&m_workspace.current().session, &EditorSession::changed, this, [this] { + synchronizeSession(); + for (ProjectTabButton *button : m_tabs->buttons()) + button->synchronize(); + }); +} + +void ProjectWorkspaceView::synchronize() +{ + const std::shared_ptr<ProjectTab> front = m_workspace.tab(m_workspace.current().id); + // A new front tab gets a fresh editor. + if (m_shownTab != front) { + front->controller.window = this; + delete m_content; + // Only now may the old tab, and its session, end. + m_shownTab = front; + m_content = new ContentView(front->session, &front->controller, this); + m_content->setWorkspace(&m_workspace); + // The window owns the minimum; the editor may be shorter. + m_content->setMinimumSize(0, 0); + setCentralWidget(m_content); + // A late central widget shows only later: show now. + m_content->show(); + watchFront(); + synchronizeSession(); + } + m_content->setEnabled(!m_workspace.isManaging()); +} + +void ProjectWorkspaceView::synchronizeSession() +{ + const EditorSession &session = m_workspace.current().session; + const bool drawn = session.document().has_value(); + m_newCanvas->setEnabled(!session.isImporting() && !session.showsBusy() && !session.levels()); + for (QAction *zoom : {m_fit, m_actualPixels, m_zoomIn, m_zoomOut}) + zoom->setEnabled(drawn); + // The front tab's title, as its strip button shows it. + setWindowTitle(m_workspace.current().title() + QStringLiteral("[*]")); + setWindowModified(session.isModified()); + setWindowFilePath(session.projectPath().value_or(QString())); +} + +void ProjectWorkspaceView::closeEvent(QCloseEvent *event) +{ + // The workspace asks first, then closes us with its mark. + if (property("closeConfirmed").toBool()) { + setProperty("closeConfirmed", QVariant()); + event->accept(); + return; + } + event->ignore(); + m_workspace.closeWindow(this); +} diff --git a/src/UI/ProjectWorkspaceView.h b/src/UI/ProjectWorkspaceView.h new file mode 100644 index 0000000..a53aac1 --- /dev/null +++ b/src/UI/ProjectWorkspaceView.h @@ -0,0 +1,38 @@ +#pragma once +#include "ContentView.h" +#include "Document/ProjectWorkspace.h" +#include "UI/CompositorMenus.h" +#include "UI/ProjectTabs.h" +#include <QMainWindow> +#include <QToolBar> + +// The window: the front tab's editor under the tab strip. +class ProjectWorkspaceView : public QMainWindow { + Q_OBJECT +public: + explicit ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget *parent = nullptr); + + ContentView *content() const { return m_content; } + ProjectTabStrip *tabs() const { return m_tabs; } + +protected: + void closeEvent(QCloseEvent *event) override; + +private: + void synchronize(); + void synchronizeSession(); + void watchFront(); + + ProjectWorkspace &m_workspace; + QToolBar *const m_toolbar; + ProjectTabStrip *const m_tabs; + QAction *m_newCanvas = nullptr; + QAction *m_fit = nullptr; + QAction *m_actualPixels = nullptr; + QAction *m_zoomIn = nullptr; + QAction *m_zoomOut = nullptr; + ContentView *m_content = nullptr; + // Held until its editor goes, as Swift's views hold sessions. + std::shared_ptr<ProjectTab> m_shownTab; + QMetaObject::Connection m_sessionWatch; +}; diff --git a/src/UI/ShapeControls.cpp b/src/UI/ShapeControls.cpp new file mode 100644 index 0000000..58be214 --- /dev/null +++ b/src/UI/ShapeControls.cpp @@ -0,0 +1,95 @@ +#include "UI/ShapeControls.h" +#include "Document/EditorSession.h" +#include "UI/ColorPaletteControls.h" +#include "UI/LassoControls.h" +#include <QButtonGroup> +#include <QHBoxLayout> +#include <QLabel> +#include <QSignalBlocker> +#include <QSlider> +#include <QToolButton> +#include <cmath> + +ShapeControls::ShapeControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QStringLiteral("Shape"), parent), m_session(session), m_kinds(new QButtonGroup(this)), + m_kindButtons{kind(ShapeKind::rectangle), kind(ShapeKind::ellipse), kind(ShapeKind::line)}, + m_widthSlider(new QSlider(Qt::Horizontal, this)), + m_widthField(new SelectionAmountField(session, 1, 5000, [this] { return m_session.shapeLineWidth(); }, + [this](double width) { m_session.setShapeLineWidth(width); }, this)), + m_width(amount(QStringLiteral("Width"), m_widthSlider, m_widthField)), m_radiusSlider(new QSlider(Qt::Horizontal, this)), + m_radiusField(new SelectionAmountField(session, 0, 5000, [this] { return m_session.shapeCornerRadius(); }, + [this](double radius) { m_session.setShapeCornerRadius(radius); }, this)), + m_radius(amount(QStringLiteral("Radius"), m_radiusSlider, m_radiusField)), + m_fill(new SwatchButton([&session] { return session.foregroundColor(); }, 3, 0, 0.5, this)) +{ + m_widthSlider->setObjectName(QStringLiteral("shapeWidthSlider")); + m_widthSlider->setRange(1, 100); + connect(m_widthSlider, &QSlider::valueChanged, this, [this](int value) { m_session.setShapeLineWidth(value); }); + m_widthField->setObjectName(QStringLiteral("shapeWidth")); + m_width->setObjectName(QStringLiteral("shapeWidthGroup")); + m_radiusSlider->setObjectName(QStringLiteral("shapeRadiusSlider")); + m_radiusSlider->setRange(0, 200); + connect(m_radiusSlider, &QSlider::valueChanged, this, [this](int value) { m_session.setShapeCornerRadius(value); }); + m_radiusField->setObjectName(QStringLiteral("shapeRadius")); + m_radius->setObjectName(QStringLiteral("shapeRadiusGroup")); + m_radius->setToolTip(QStringLiteral("Round the rectangle's corners by this many pixels; 0 keeps them square")); + m_fill->setObjectName(QStringLiteral("shapeFill")); + m_fill->setFixedSize(36, 18); + m_fill->setToolTip(QStringLiteral("Shapes fill with the foreground color; click to change it")); + connect(m_fill, &QAbstractButton::clicked, this, [this] { m_session.openColorPicker(false); }); + auto *fill = new QWidget(this); + auto *fillRow = new QHBoxLayout(fill); + fillRow->setContentsMargins(0, 0, 0, 0); + fillRow->setSpacing(6); + fillRow->addWidget(new QLabel(QStringLiteral("Fill"), fill)); + fillRow->addWidget(m_fill); + for (QWidget *widget : std::initializer_list<QWidget *>{m_kindButtons[0], m_kindButtons[1], m_kindButtons[2], m_width, m_radius, fill}) + row->insertWidget(row->count() - 1, widget); + connect(&m_session, &EditorSession::changed, this, &ShapeControls::synchronize); + synchronize(); +} + +// A segment of Swift's picker; choosing drops a draft first. +QToolButton *ShapeControls::kind(ShapeKind value) +{ + auto *button = new QToolButton(this); + button->setObjectName(QStringLiteral("shape") + rawValue(value)); + button->setText(rawValue(value)); + button->setCheckable(true); + button->setToolTip(QStringLiteral("Shift-U (or Tab) steps through Rectangle, Ellipse and Line")); + m_kinds->addButton(button); + connect(button, &QToolButton::clicked, this, [this, value] { + m_session.cancelShape(); + m_session.setShapeKind(value); + }); + return button; +} + +QWidget *ShapeControls::amount(const QString &name, QSlider *slider, SelectionAmountField *field) +{ + auto *group = new QWidget(this); + auto *layout = new QHBoxLayout(group); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + slider->setFixedWidth(100); + field->setFixedWidth(48); + for (QWidget *widget : std::initializer_list<QWidget *>{new QLabel(name, group), slider, field, new QLabel(QStringLiteral("px"), group)}) + layout->addWidget(widget); + return group; +} + +void ShapeControls::synchronize() +{ + const ShapeKind kind = m_session.shapeKind(); + m_kindButtons[size_t(kind)]->setChecked(true); + m_width->setVisible(kind == ShapeKind::line); + m_radius->setVisible(kind == ShapeKind::rectangle); + // The sliders show the session's number without writing back. + const QSignalBlocker widthBlocker(m_widthSlider), radiusBlocker(m_radiusSlider); + m_widthSlider->setValue(int(std::lround(std::min(100.0, m_session.shapeLineWidth())))); + m_radiusSlider->setValue(int(std::lround(std::min(200.0, m_session.shapeCornerRadius())))); + m_widthField->sync(int(std::lround(m_session.shapeLineWidth()))); + m_radiusField->sync(int(std::lround(m_session.shapeCornerRadius()))); + m_fill->update(); + setEnabled(!m_session.showsBusy() && m_session.document().has_value()); +} diff --git a/src/UI/ShapeControls.h b/src/UI/ShapeControls.h new file mode 100644 index 0000000..feff379 --- /dev/null +++ b/src/UI/ShapeControls.h @@ -0,0 +1,36 @@ +#pragma once +#include "Document/ShapeTool.h" +#include "UI/ToolHeaderStyle.h" +#include <array> + +class EditorSession; +class QButtonGroup; +class QSlider; +class QToolButton; +class SelectionAmountField; +class SwatchButton; + +// Swift's ShapeControls: the kind, a line's width, a rectangle's radius. +class ShapeControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit ShapeControls(EditorSession &session, QWidget *parent = nullptr); + +private: + QToolButton *kind(ShapeKind value); + // A label, slider, field and "px", as Swift's HStack. + QWidget *amount(const QString &name, QSlider *slider, SelectionAmountField *field); + void synchronize(); + + EditorSession &m_session; + QButtonGroup *const m_kinds; + const std::array<QToolButton *, 3> m_kindButtons; + QSlider *const m_widthSlider; + SelectionAmountField *const m_widthField; + QWidget *const m_width; + QSlider *const m_radiusSlider; + SelectionAmountField *const m_radiusField; + QWidget *const m_radius; + // Shapes fill with the foreground colour. + SwatchButton *const m_fill; +}; diff --git a/src/UI/SliderSnap.cpp b/src/UI/SliderSnap.cpp new file mode 100644 index 0000000..d30fa03 --- /dev/null +++ b/src/UI/SliderSnap.cpp @@ -0,0 +1,73 @@ +#include "UI/SliderSnap.h" +#include <QApplication> +#include <QMouseEvent> +#include <QSlider> +#include <QStyle> +#include <QStyleOptionSlider> +#include <algorithm> + +namespace { +// Swift's snapValue: the value under a press on the track. +class Snap : public QObject { +public: + using QObject::QObject; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + auto *slider = qobject_cast<QSlider *>(watched); + if (!slider || event->type() != QEvent::MouseButtonPress) + return false; + const auto *press = static_cast<QMouseEvent *>(event); + // Qt refuses a press made with another button held. + if (press->buttons() != Qt::LeftButton || slider->orientation() != Qt::Horizontal || !slider->isEnabled() + || slider->maximum() <= slider->minimum()) + return false; + // QSlider's own style option, which it keeps protected. + QStyleOptionSlider option; + option.initFrom(slider); + option.orientation = Qt::Horizontal; + option.state |= QStyle::State_Horizontal; + option.minimum = slider->minimum(); + option.maximum = slider->maximum(); + option.tickPosition = slider->tickPosition(); + option.tickInterval = slider->tickInterval(); + option.upsideDown = slider->invertedAppearance() != (option.direction == Qt::RightToLeft); + option.direction = Qt::LeftToRight; + option.sliderPosition = slider->sliderPosition(); + option.sliderValue = slider->value(); + option.singleStep = slider->singleStep(); + option.pageStep = slider->pageStep(); + const QRect groove = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderGroove, slider); + const QRect knob = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, slider); + const QPoint point = press->position().toPoint(); + // The knob itself drags from where it is. + if (knob.contains(point) || groove.width() <= knob.width()) + return false; + // Down first, as Swift's editing begins before the value. + slider->setSliderDown(true); + slider->setValue(QStyle::sliderValueFromPosition(slider->minimum(), slider->maximum(), point.x() - groove.x() - knob.width() / 2, + groove.width() - knob.width(), option.upsideDown)); + // A rounding owner may move the knob: press there. + option.sliderPosition = slider->sliderPosition(); + option.sliderValue = slider->value(); + const QRect moved = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, slider); + const QPoint inside(std::clamp(point.x(), moved.left(), moved.right()), std::clamp(point.y(), moved.top(), moved.bottom())); + const QPointF shift = inside - point; + QMouseEvent forwarded(QEvent::MouseButtonPress, inside, press->scenePosition() + shift, press->globalPosition() + shift, Qt::LeftButton, + Qt::LeftButton, press->modifiers()); + QCoreApplication::sendEvent(slider, &forwarded); + return true; + } +}; +} + +void SliderSnap::install() +{ + // Once, as Swift's lazy static swaps the method once. + static const bool installed = [] { + qApp->installEventFilter(new Snap(qApp)); + return true; + }(); + (void)installed; +} diff --git a/src/UI/SliderSnap.h b/src/UI/SliderSnap.h new file mode 100644 index 0000000..3c1fabc --- /dev/null +++ b/src/UI/SliderSnap.h @@ -0,0 +1,7 @@ +#pragma once + +// Swift's SliderSnap: a press on the track sets the value. +namespace SliderSnap { +// Every slider of the app, from then on. +void install(); +} diff --git a/src/UI/ToolHeaderStyle.cpp b/src/UI/ToolHeaderStyle.cpp new file mode 100644 index 0000000..37c2172 --- /dev/null +++ b/src/UI/ToolHeaderStyle.cpp @@ -0,0 +1,29 @@ +#include "UI/ToolHeaderStyle.h" +#include <QApplication> + +QFont ToolHeaderStyle::titleFont() +{ + QFont font = QApplication::font(); + font.setPixelSize(13); + font.setWeight(QFont::DemiBold); + return font; +} + +QFont ToolHeaderStyle::controlFont() +{ + QFont font = QApplication::font(); + font.setPixelSize(12); + return font; +} + +ToolHeaderBar::ToolHeaderBar(const QString &text, QWidget *parent) + : QWidget(parent), title(new QLabel(text, this)), row(new QHBoxLayout(this)) +{ + setFixedHeight(ToolHeaderStyle::height); + setFont(ToolHeaderStyle::controlFont()); + title->setFont(ToolHeaderStyle::titleFont()); + row->setContentsMargins(18, 0, 18, 0); + row->setSpacing(12); + row->addWidget(title); + row->addStretch(1); +} diff --git a/src/UI/ToolHeaderStyle.h b/src/UI/ToolHeaderStyle.h new file mode 100644 index 0000000..8c8a763 --- /dev/null +++ b/src/UI/ToolHeaderStyle.h @@ -0,0 +1,24 @@ +#pragma once +#include <QFont> +#include <QHBoxLayout> +#include <QLabel> +#include <QWidget> + +// Shared metrics: switching tools never moves the canvas. +namespace ToolHeaderStyle { +inline constexpr int height = 42; +QFont titleFont(); +QFont controlFont(); +} + +// A tool's bar: its title, then its controls' row. +class ToolHeaderBar : public QWidget { + // findChild needs the meta-object; Qt 6.11 checks at compile time. + Q_OBJECT +public: + explicit ToolHeaderBar(const QString &title, QWidget *parent = nullptr); + + QLabel *const title; + // Controls go before the stretch that ends the row. + QHBoxLayout *const row; +}; diff --git a/src/UI/ToolIcons.cpp b/src/UI/ToolIcons.cpp new file mode 100644 index 0000000..39662bd --- /dev/null +++ b/src/UI/ToolIcons.cpp @@ -0,0 +1,229 @@ +#include "UI/ToolIcons.h" +#include "Rendering/EyedropperIcon.h" +#include "Rendering/SelectionIcons.h" +#include <QFont> +#include <QPainterPath> +#include <array> +#include <stdexcept> + +namespace { +// Swift's GradientToolIcon: a Floyd-Steinberg fade, left to right. +std::array<std::array<bool, 16>, 16> ditheredFade() +{ + std::array<std::array<double, 16>, 16> ramp; + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) + ramp[y][x] = x / 15.0; + } + std::array<std::array<bool, 16>, 16> result{}; + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) { + const bool on = ramp[y][x] >= 0.5; + result[y][x] = on; + const double error = ramp[y][x] - (on ? 1 : 0); + if (x + 1 < 16) + ramp[y][x + 1] += error * 7 / 16; + if (y + 1 == 16) + continue; + if (x > 0) + ramp[y + 1][x - 1] += error * 3 / 16; + ramp[y + 1][x] += error * 5 / 16; + if (x + 1 < 16) + ramp[y + 1][x + 1] += error / 16; + } + } + return result; +} + +void gradient(QPainter &painter) +{ + static const std::array<std::array<bool, 16>, 16> pattern = ditheredFade(); + const QRectF frame(1, 1, 16, 16); + QPainterPath shape; + shape.addRoundedRect(frame, 3.5, 3.5); + // Swift clips dots and frame alike: half the frame shows. + painter.save(); + painter.setClipPath(shape, Qt::IntersectClip); + // One path, as in Swift: dots filled apiece leave seams. + QPainterPath dots; + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) { + if (pattern[y][x]) + dots.addRect(QRectF(frame.left() + x, frame.top() + y, 1, 1)); + } + } + painter.fillPath(dots, painter.pen().color()); + QPen pen = painter.pen(); + pen.setWidthF(1.4); + painter.setPen(pen); + painter.drawPath(shape); + painter.restore(); +} + +// Swift's CloneStampToolIcon: handle, neck, body and pad, filled. +void cloneStamp(QPainter &painter) +{ + const double w = 18, h = 18; + QPainterPath stamp; + stamp.addEllipse(QRectF(w * 0.33, h * 0.02, w * 0.34, h * 0.30)); + stamp.addRect(QRectF(w * 0.43, h * 0.28, w * 0.14, h * 0.28)); + stamp.addRoundedRect(QRectF(w * 0.12, h * 0.54, w * 0.76, h * 0.22), w * 0.08, w * 0.08); + stamp.addRect(QRectF(w * 0.06, h * 0.82, w * 0.88, h * 0.12)); + stamp.setFillRule(Qt::WindingFill); + painter.fillPath(stamp, painter.pen().color()); +} + +void move(QPainter &painter) +{ + painter.drawLine(QPointF(3, 3), QPointF(15, 15)); + painter.drawPolyline(QPolygonF{QPointF(3, 8.5), QPointF(3, 3), QPointF(8.5, 3)}); + painter.drawPolyline(QPolygonF{QPointF(15, 9.5), QPointF(15, 15), QPointF(9.5, 15)}); +} + +void marquee(QPainter &painter, ToolIconKind kind) +{ + SelectionIcons::paint(painter, kind == ToolIconKind::ellipse ? SelectionIcon::ellipseMarquee : SelectionIcon::rectangleMarquee); +} + +void lasso(QPainter &painter, ToolIconKind kind) +{ + SelectionIcons::paint(painter, kind == ToolIconKind::polygonal ? SelectionIcon::polygonalLasso : SelectionIcon::freehandLasso); +} + +void star(QPainter &painter, QPointF centre, double reach) +{ + painter.drawLine(centre - QPointF(reach, 0), centre + QPointF(reach, 0)); + painter.drawLine(centre - QPointF(0, reach), centre + QPointF(0, reach)); +} + +void wand(QPainter &painter) +{ + painter.drawLine(QPointF(2.5, 15.5), QPointF(10.5, 7.5)); + star(painter, QPointF(13, 5), 2.4); + star(painter, QPointF(5, 4.5), 1.5); + star(painter, QPointF(14, 12.5), 1.5); +} + +void crop(QPainter &painter) +{ + painter.drawPolyline(QPolygonF{QPointF(5, 1.5), QPointF(5, 13), QPointF(16.5, 13)}); + painter.drawPolyline(QPolygonF{QPointF(1.5, 5), QPointF(13, 5), QPointF(13, 16.5)}); +} + +void brush(QPainter &painter) +{ + // A slanted handle over a pointed tip. + painter.drawLine(QPointF(15.5, 2.5), QPointF(8.5, 9.5)); + QPainterPath tip(QPointF(8.8, 8)); + tip.lineTo(QPointF(10.2, 9.4)); + tip.cubicTo(QPointF(8.6, 12.2), QPointF(6, 14.6), QPointF(2.6, 15.4)); + tip.cubicTo(QPointF(3.4, 12), QPointF(5.8, 9.4), QPointF(8.8, 8)); + painter.fillPath(tip, painter.pen().color()); +} + +// SF Symbols' eraser: a tilted block, its rubber end apart. +void eraser(QPainter &painter) +{ + painter.save(); + painter.translate(9.5, 8); + painter.rotate(-45); + painter.drawRoundedRect(QRectF(-7, -3.5, 14, 7), 1.5, 1.5); + painter.drawLine(QPointF(-2, -3.5), QPointF(-2, 3.5)); + painter.restore(); + painter.drawLine(QPointF(8, 16), QPointF(16, 16)); +} + +void spotHealing(QPainter &painter) +{ + // A plaster across the square, its pad in the middle. + painter.save(); + painter.translate(9, 9); + painter.rotate(-45); + painter.drawRoundedRect(QRectF(-8, -3.4, 16, 6.8), 3.4, 3.4); + painter.drawRect(QRectF(-2.6, -3.4, 5.2, 6.8)); + painter.restore(); +} + +void blur(QPainter &painter) +{ + // A drop: a point above a round belly. + QPainterPath drop(QPointF(9, 1.8)); + drop.cubicTo(QPointF(11.5, 5.5), QPointF(14.4, 8.4), QPointF(14.4, 11.2)); + drop.cubicTo(QPointF(14.4, 14.2), QPointF(12, 16.4), QPointF(9, 16.4)); + drop.cubicTo(QPointF(6, 16.4), QPointF(3.6, 14.2), QPointF(3.6, 11.2)); + drop.cubicTo(QPointF(3.6, 8.4), QPointF(6.5, 5.5), QPointF(9, 1.8)); + painter.drawPath(drop); +} + +void shape(QPainter &painter) +{ + painter.drawEllipse(QRectF(7, 7, 9.5, 9.5)); + painter.fillRect(QRectF(1.5, 1.5, 9.5, 9.5), painter.pen().color()); +} + +void type(QPainter &painter) +{ + // The caller's font stays out: no underline, no slant. + QFont font; + font.setPixelSize(13); + font.setBold(true); + painter.setFont(font); + painter.drawText(QRectF(0, 0, 18, 18), Qt::AlignCenter, QStringLiteral("Aa")); +} + +void hand(QPainter &painter) +{ + // Four fingers over a palm, a thumb at the side. + const double tops[] = {4.6, 2.6, 2, 3.4}; + for (int finger = 0; finger < 4; ++finger) + painter.drawLine(QPointF(5.4 + finger * 2.7, tops[finger]), QPointF(5.4 + finger * 2.7, 9)); + QPainterPath palm(QPointF(13.5, 9)); + palm.lineTo(QPointF(13.5, 12)); + palm.cubicTo(QPointF(13.5, 15), QPointF(11.4, 16.4), QPointF(9.2, 16.4)); + palm.cubicTo(QPointF(7, 16.4), QPointF(5.6, 15.2), QPointF(4.4, 13.2)); + palm.lineTo(QPointF(2.2, 9.6)); + palm.cubicTo(QPointF(1.6, 8.4), QPointF(3.2, 7.4), QPointF(4, 8.6)); + palm.lineTo(QPointF(5.4, 10.6)); + painter.drawPath(palm); +} + +void zoom(QPainter &painter) +{ + painter.drawEllipse(QRectF(2, 2, 10.5, 10.5)); + painter.drawLine(QPointF(11.2, 11.2), QPointF(16, 16)); +} +} + +void ToolIcons::paint(QPainter &painter, NavigationTool tool, QPointF origin, double side, const QColor &colour, ToolIconKind kind) +{ + // Refused before the painter is touched. + if (tool == NavigationTool::idle) + throw std::logic_error("no tool has no icon"); + painter.save(); + painter.setRenderHint(QPainter::Antialiasing); + painter.translate(origin); + painter.scale(side / points, side / points); + painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + // An opaque background would fill dashes and letters. + painter.setBackgroundMode(Qt::TransparentMode); + switch (tool) { + case NavigationTool::move: move(painter); break; + case NavigationTool::marquee: marquee(painter, kind); break; + case NavigationTool::lasso: lasso(painter, kind); break; + case NavigationTool::wand: wand(painter); break; + case NavigationTool::crop: crop(painter); break; + case NavigationTool::brush: kind == ToolIconKind::eraser ? eraser(painter) : brush(painter); break; + case NavigationTool::spotHealing: spotHealing(painter); break; + case NavigationTool::cloneStamp: cloneStamp(painter); break; + case NavigationTool::blur: blur(painter); break; + case NavigationTool::gradient: gradient(painter); break; + case NavigationTool::shape: shape(painter); break; + case NavigationTool::type: type(painter); break; + case NavigationTool::eyedropper: EyedropperIcon::paint(painter); break; + case NavigationTool::hand: hand(painter); break; + case NavigationTool::zoom: zoom(painter); break; + case NavigationTool::idle: break; + } + painter.restore(); +} diff --git a/src/UI/ToolIcons.h b/src/UI/ToolIcons.h new file mode 100644 index 0000000..35f505c --- /dev/null +++ b/src/UI/ToolIcons.h @@ -0,0 +1,16 @@ +#pragma once +#include "Document/EditorSession+Model.h" +#include <QColor> +#include <QPainter> +#include <QRectF> + +// The Marquee's and the Lasso's icons follow their kinds. +enum class ToolIconKind { plain, ellipse, polygonal, eraser }; + +// The tool rail's icons, drawn: SF Symbols have no twin. +namespace ToolIcons { +// Swift's icons sit in an 18 point square. +inline constexpr int points = 18; +// Paints the tool's icon into a square, in one colour. +void paint(QPainter &painter, NavigationTool tool, QPointF origin, double side, const QColor &colour, ToolIconKind kind = ToolIconKind::plain); +} diff --git a/src/UI/TransformInspector.cpp b/src/UI/TransformInspector.cpp new file mode 100644 index 0000000..fffeb23 --- /dev/null +++ b/src/UI/TransformInspector.cpp @@ -0,0 +1,265 @@ +#include "UI/TransformInspector.h" +#include "UI/LayerIcons.h" +#include <QEvent> +#include <QKeyEvent> +#include <QLabel> +#include <cmath> + +TransformValueField::TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change, + QWidget *parent) + : QWidget(parent), field(new QLineEdit(this)), m_session(session), m_change(std::move(change)) +{ + auto *row = new QHBoxLayout(this); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(4); + auto *name = new QLabel(label, this); + name->setFont(ToolHeaderStyle::controlFont()); + name->setForegroundRole(QPalette::PlaceholderText); + row->addWidget(name); + field->setObjectName(QStringLiteral("transform") + label); + field->setAccessibleName(label); + field->setFont(ToolHeaderStyle::controlFont()); + field->installEventFilter(this); + row->addWidget(field, 1); + if (!suffix.isEmpty()) { + auto *unit = new QLabel(suffix, this); + unit->setFont(ToolHeaderStyle::controlFont()); + unit->setForegroundRole(QPalette::PlaceholderText); + row->addWidget(unit); + } + // Typing applies at once; previewTransform refuses what is no box. + connect(field, &QLineEdit::textEdited, this, [this](const QString &text) { + bool number = false; + const double typed = text.toDouble(&number); + if (number) + m_change(typed); + }); +} + +void TransformValueField::sync(double value) +{ + m_value = value; + if (!field->hasFocus() && !m_borrowed) + field->setText(formatted(value)); +} + +QString TransformValueField::formatted(double value) +{ + return std::abs(value - std::round(value)) < 0.005 ? QString::number(std::lround(value)) : QString::number(value, 'f', 2); +} + +bool TransformValueField::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::FocusOut) { + const Qt::FocusReason reason = static_cast<QFocusEvent *>(event)->reason(); + m_borrowed = reason == Qt::MenuBarFocusReason || reason == Qt::PopupFocusReason; + if (!m_borrowed) + field->setText(formatted(m_value)); + } + if (event->type() != QEvent::KeyPress) + return QWidget::eventFilter(watched, event); + const auto *key = static_cast<QKeyEvent *>(event); + // Return and Escape hand the canvas the keys. + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) { + field->clearFocus(); + m_session.requestCanvasFocus(); + return true; + } + if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down) + return QWidget::eventFilter(watched, event); + // A step is no typing: it writes what it applied. + const double amount = (key->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1); + const double stepped = m_value + amount; + m_change(stepped); + field->setText(formatted(stepped)); + return true; +} + +TransformInspector::TransformInspector(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QStringLiteral("Transform"), parent), m_session(session), m_autoSelect(new QCheckBox(QStringLiteral("Auto Select"), this)), + m_showControls(new QCheckBox(QStringLiteral("Show Controls"), this)), m_fields(new QScrollArea(this)), + m_x(new TransformValueField(QStringLiteral("X"), QString(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })), + m_y(new TransformValueField(QStringLiteral("Y"), QString(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })), + m_width(new TransformValueField(QStringLiteral("W"), QString(), session, [this](double number) { resizeBox(number, true); })), + m_height(new TransformValueField(QStringLiteral("H"), QString(), session, [this](double number) { resizeBox(number, false); })), + m_lock(new QToolButton(this)), + m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), session, [this](double number) { + change([&](LayerTransform &value) { value = value.scaled(number, pixelSize()); }); + })), + m_rotation(new TransformValueField(QStringLiteral("°"), QString(), session, [this](double number) { + change([number](LayerTransform &value) { value.rotation = std::fmod(number, 360.0); }); + })), + m_sampling(new QComboBox(this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply"), this)) +{ + m_autoSelect->setObjectName(QStringLiteral("transformAutoSelect")); + m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. When off, hold Ctrl to select a layer.")); + connect(m_autoSelect, &QCheckBox::toggled, this, [this](bool picks) { m_session.setTransformAutoSelect(picks); }); + m_showControls->setObjectName(QStringLiteral("showTransformControls")); + m_showControls->setToolTip(QStringLiteral("Show the transform box and handles (Ctrl+H). When hidden, drag anywhere to move the layer.")); + connect(m_showControls, &QCheckBox::toggled, this, [this](bool shows) { m_session.setShowsTransformControls(shows); }); + // The numbers scroll sideways when the window is narrow. + auto *numbers = new QWidget; + numbers->setObjectName(QStringLiteral("transformFields")); + auto *fields = new QHBoxLayout(numbers); + fields->setContentsMargins(18, 0, 18, 0); + fields->setSpacing(12); + for (const auto &[field, width] : {std::pair(m_x, 85), std::pair(m_y, 85), std::pair(m_width, 85), std::pair(m_height, 85)}) { + field->setFixedWidth(width); + fields->addWidget(field); + } + m_lock->setObjectName(QStringLiteral("locksTransformRatio")); + m_lock->setCheckable(true); + m_lock->setAutoRaise(true); + m_lock->setToolTip(QStringLiteral("Lock aspect ratio")); + applyLockIcon(); + connect(m_lock, &QToolButton::toggled, this, [this](bool locks) { m_session.setLocksTransformRatio(locks); }); + fields->addWidget(m_lock); + m_scale->setFixedWidth(110); + m_scale->setToolTip(QStringLiteral("Scale width and height together, about the center")); + fields->addWidget(m_scale); + m_rotation->setFixedWidth(75); + fields->addWidget(m_rotation); + m_sampling->setObjectName(QStringLiteral("transformSampling")); + m_sampling->setFixedWidth(170); + for (const LayerSampling sampling : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high}) + m_sampling->addItem(rawValue(sampling), int(sampling)); + connect(m_sampling, &QComboBox::activated, this, [this](int index) { + change([index](LayerTransform &value) { value.sampling = LayerSampling(index); }); + }); + fields->addWidget(m_sampling); + auto *flipH = new QPushButton(QStringLiteral("Flip H"), numbers); + flipH->setObjectName(QStringLiteral("flipHorizontal")); + connect(flipH, &QPushButton::clicked, this, [this] { change([](LayerTransform &value) { value.flipX = !value.flipX; }); }); + fields->addWidget(flipH); + auto *flipV = new QPushButton(QStringLiteral("Flip V"), numbers); + flipV->setObjectName(QStringLiteral("flipVertical")); + connect(flipV, &QPushButton::clicked, this, [this] { change([](LayerTransform &value) { value.flipY = !value.flipY; }); }); + fields->addWidget(flipV); + // Swift's scroll content keeps its size at the leading edge. + fields->addStretch(1); + m_fields->setObjectName(QStringLiteral("transformScroll")); + m_fields->setWidget(numbers); + m_fields->setWidgetResizable(true); + m_fields->setFrameShape(QFrame::NoFrame); + m_fields->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_fields->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_fields->setFixedHeight(numbers->sizeHint().height()); + m_cancel->setObjectName(QStringLiteral("cancelTransform")); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelTransform(); }); + m_apply->setObjectName(QStringLiteral("applyTransform")); + connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitTransform(); }); + // Before the stretch ending the row, in Swift's order. + for (QWidget *widget : std::initializer_list<QWidget *>{m_autoSelect, m_showControls, m_fields, m_cancel, m_apply}) + row->insertWidget(row->count() - 1, widget, widget == m_fields ? 1 : 0); + // The scroll view takes the spare room, as SwiftUI's does. + row->setStretch(row->count() - 1, 0); + connect(&m_session, &EditorSession::changed, this, &TransformInspector::synchronize); + synchronize(); +} + +void TransformInspector::applyLockIcon() +{ + m_lock->setIcon(LayerIcons::pixmap(LayerIcon::link, 16, palette().color(QPalette::Text), devicePixelRatio())); +} + +// The theme's ink reaches the icon, as the panel's icons. +void TransformInspector::changeEvent(QEvent *event) +{ + ToolHeaderBar::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + applyLockIcon(); +} + +// Swift's default and cancel actions; a field keeps its keys. +void TransformInspector::keyPressEvent(QKeyEvent *event) +{ + const bool enter = event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter; + if (!m_session.transformEdit() || (!enter && event->key() != Qt::Key_Escape)) { + ToolHeaderBar::keyPressEvent(event); + return; + } + if (enter) + m_session.commitTransform(); + else + m_session.cancelTransform(); +} + +// The draft, else the active layer's box, else one unit. +LayerTransform TransformInspector::value() const +{ + if (m_session.transformEdit()) + return m_session.transformEdit()->draft; + const std::optional<ImageLayer> active = m_session.activeLayer(); + return active ? m_session.editedTransform(*active) : LayerTransform{.origin = {0, 0}, .size = {1, 1}}; +} + +// 100%: the pixels, or a blank layer's size before editing. +QSizeF TransformInspector::pixelSize() const +{ + if (const std::optional<QSizeF> pixels = m_session.transformPixelSize()) + return *pixels; + const std::optional<ImageLayer> active = m_session.activeLayer(); + return active ? active->size() : value().size; +} + +void TransformInspector::change(const std::function<void(LayerTransform &)> &update) +{ + if (!m_session.transformEdit()) + m_session.beginTransform(); + if (!m_session.transformEdit()) + return; + LayerTransform next = m_session.transformEdit()->draft; + update(next); + m_session.previewTransform(next); +} + +void TransformInspector::resizeBox(double number, bool width) +{ + // A side under a pixel makes a box previewTransform refuses. + change([&](LayerTransform &next) { + if (width) { + if (m_session.locksTransformRatio()) + next.size.setHeight(next.size.height() * number / next.size.width()); + next.size.setWidth(number); + } else { + if (m_session.locksTransformRatio()) + next.size.setWidth(next.size.width() * number / next.size.height()); + next.size.setHeight(number); + } + }); +} + +void TransformInspector::synchronize() +{ + // Another active layer makes the fields anew (Swift's `.id`). + if (m_layerID != m_session.activeLayerID()) { + m_layerID = m_session.activeLayerID(); + if (QWidget *focused = focusWidget(); focused && isAncestorOf(focused)) + focused->clearFocus(); + } + title->setText(m_session.transformTargetsMask() ? QStringLiteral("Transform Mask") : QStringLiteral("Transform")); + const auto set = [](QAbstractButton *button, bool checked) { + const QSignalBlocker blocker(button); + button->setChecked(checked); + }; + set(m_autoSelect, m_session.transformAutoSelect()); + set(m_showControls, m_session.showsTransformControls()); + set(m_lock, m_session.locksTransformRatio()); + const LayerTransform shown = value(); + m_x->sync(shown.origin.x()); + m_y->sync(shown.origin.y()); + m_width->sync(shown.size.width()); + m_height->sync(shown.size.height()); + m_scale->sync(shown.scalePercent(pixelSize())); + m_rotation->sync(shown.rotation); + { + const QSignalBlocker blocker(m_sampling); + m_sampling->setCurrentIndex(int(shown.sampling)); + } + const bool editing = m_session.transformEdit().has_value(); + // While distorted, the handles are the controls. + const bool distorted = editing && m_session.transformEdit()->corners.has_value(); + m_fields->widget()->setEnabled((m_session.canTransform() || editing) && !distorted); + m_cancel->setEnabled(editing); + m_apply->setEnabled(editing); +} diff --git a/src/UI/TransformInspector.h b/src/UI/TransformInspector.h new file mode 100644 index 0000000..d1fed61 --- /dev/null +++ b/src/UI/TransformInspector.h @@ -0,0 +1,73 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ToolHeaderStyle.h" +#include <QCheckBox> +#include <QComboBox> +#include <QLineEdit> +#include <QPushButton> +#include <QScrollArea> +#include <QToolButton> +#include <functional> + +// A labelled number, typed at once, stepped by arrows. +class TransformValueField : public QWidget { + Q_OBJECT +public: + TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change, + QWidget *parent = nullptr); + + // Shown when the field is not being edited. + void sync(double value); + // No idle zeros on a whole number, two decimals otherwise. + static QString formatted(double value); + + QLineEdit *const field; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + EditorSession &m_session; + const std::function<void(double)> m_change; + double m_value = 0; + // A menu or popup borrows the focus: the typing stays. + bool m_borrowed = false; +}; + +// The Move tool's bar: numbers, settings, Apply and Cancel. +class TransformInspector : public ToolHeaderBar { + Q_OBJECT +public: + explicit TransformInspector(EditorSession &session, QWidget *parent = nullptr); + +protected: + // Return applies and Escape cancels outside the fields. + void keyPressEvent(QKeyEvent *event) override; + void changeEvent(QEvent *event) override; + +private: + void applyLockIcon(); + LayerTransform value() const; + QSizeF pixelSize() const; + void change(const std::function<void(LayerTransform &)> &update); + // Swift's `resize`; the name would hide QWidget's. + void resizeBox(double number, bool width); + void synchronize(); + + EditorSession &m_session; + // The layer the fields were built for, as Swift's `.id`. + std::optional<QUuid> m_layerID; + QCheckBox *const m_autoSelect; + QCheckBox *const m_showControls; + QScrollArea *const m_fields; + TransformValueField *const m_x; + TransformValueField *const m_y; + TransformValueField *const m_width; + TransformValueField *const m_height; + QToolButton *const m_lock; + TransformValueField *const m_scale; + TransformValueField *const m_rotation; + QComboBox *const m_sampling; + QPushButton *const m_cancel; + QPushButton *const m_apply; +}; diff --git a/src/UI/TypeControls.cpp b/src/UI/TypeControls.cpp new file mode 100644 index 0000000..67fec1d --- /dev/null +++ b/src/UI/TypeControls.cpp @@ -0,0 +1,248 @@ +#include "UI/TypeControls.h" +#include "UI/ColorPaletteControls.h" +#include "Document/EditorSession.h" +#include "Rendering/TextLayout.h" +#include <QComboBox> +#include <QFocusEvent> +#include <QHBoxLayout> +#include <QLabel> +#include <QPainter> +#include <QPushButton> +#include <QScrollArea> +#include <QToolButton> +#include <cmath> + +namespace { +// Swift's `.number`: the locale's mark, three decimals at most. +QString number(double value, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString text = locale.toString(value, 'f', 3); + while (text.endsWith(locale.zeroDigit())) + text.chop(locale.zeroDigit().size()); + if (text.endsWith(locale.decimalPoint())) + text.chop(locale.decimalPoint().size()); + return text; +} + +// SF Symbols' text.align glyphs: four lines against one side. +QPixmap alignmentGlyph(TextAlignment alignment, const QColor &ink, double ratio) +{ + QPixmap pixmap(QSize(18, 18) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(ink, 1.6, Qt::SolidLine, Qt::RoundCap)); + for (int row = 0; row < 4; ++row) { + const double length = row % 2 == 0 ? 14 : 9, y = 4 + row * 3.4; + const double left = alignment == TextAlignment::left ? 2 : alignment == TextAlignment::center ? 9 - length / 2 : 16 - length; + painter.drawLine(QPointF(left, y), QPointF(left + length, y)); + } + return pixmap; +} +} + +TextStyleField::TextStyleField(std::function<QString()> shown, std::function<void(const QString &)> typed, std::function<void(double)> step, + QWidget *parent) + : QLineEdit(parent), m_shown(std::move(shown)), m_typed(std::move(typed)), m_step(std::move(step)) +{ + setAlignment(Qt::AlignRight | Qt::AlignVCenter); + // Swift's binding takes each edit it can read. + connect(this, &QLineEdit::textEdited, this, [this](const QString &text) { m_typed(text); }); +} + +void TextStyleField::sync() +{ + if (!hasFocus() && !m_borrowed) + setText(m_shown()); +} + +void TextStyleField::keyPressEvent(QKeyEvent *event) +{ + // Swift's bar keeps the focus: Return shows the number, selected. + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + setText(m_shown()); + selectAll(); + return; + } + if (event->key() != Qt::Key_Up && event->key() != Qt::Key_Down) { + QLineEdit::keyPressEvent(event); + return; + } + m_step((event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1)); + setText(m_shown()); +} + +void TextStyleField::focusOutEvent(QFocusEvent *event) +{ + m_borrowed = event->reason() == Qt::MenuBarFocusReason || event->reason() == Qt::PopupFocusReason; + if (!m_borrowed) + setText(m_shown()); + QLineEdit::focusOutEvent(event); +} + +TypeControls::TypeControls(EditorSession &session, QWidget *parent) + : ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session), m_font(new QComboBox), + m_size(new TextStyleField( + [this] { return number(m_session.currentTextStyle().fontSize, locale()); }, + [this](const QString &text) { + // No number reads as zero, which no size passes. + const double size = locale().toDouble(text); + m_session.changeTextStyle([size](LayerTextStyle &style) { style.fontSize = size; }); + }, + [this](double amount) { + const double size = std::min(2000.0, std::max(1.0, m_session.currentTextStyle().fontSize + amount)); + m_session.changeTextStyle([size](LayerTextStyle &style) { style.fontSize = size; }); + }, + nullptr)), + m_colour(new SwatchButton([&session] { return session.typeColor(); }, 3, 0, 0.5, nullptr)), + m_alignments{alignment(TextAlignment::left), alignment(TextAlignment::center), alignment(TextAlignment::right)}, + m_tracking(new TextStyleField( + [this] { return number(m_session.currentTextStyle().tracking, locale()); }, + [this](const QString &text) { + bool read = false; + const double tracking = locale().toDouble(text, &read); + if (read) + m_session.changeTextStyle([tracking](LayerTextStyle &style) { style.tracking = tracking; }); + }, + [this](double amount) { + const double tracking = m_session.currentTextStyle().tracking + amount; + m_session.changeTextStyle([tracking](LayerTextStyle &style) { style.tracking = tracking; }); + }, + nullptr)), + m_leading(new TextStyleField( + [this] { + // Auto shows as the empty field's placeholder. + const double leading = m_session.currentTextStyle().leading; + return leading > 0 ? QString::number(std::lround(leading)) : QString(); + }, + [this](const QString &text) { + const double typed = text.toDouble(); + m_session.changeTextStyle([typed](LayerTextStyle &style) { style.leading = std::max(0.0, std::min(5000.0, typed)); }); + }, + [this](double amount) { + // Steps count from what Auto works out to. + const double leading = std::max(0.0, m_session.currentTextStyle().lineHeight() + amount); + m_session.changeTextStyle([leading](LayerTextStyle &style) { style.leading = leading; }); + }, + nullptr)), + m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_done(new QPushButton(QStringLiteral("Done"), this)), + m_edit(new QPushButton(QStringLiteral("Edit Text"), this)) +{ + auto *controls = new QWidget; + controls->setObjectName(QStringLiteral("typeFields")); + auto *fields = new QHBoxLayout(controls); + fields->setContentsMargins(0, 0, 0, 0); + fields->setSpacing(10); + m_font->setObjectName(QStringLiteral("typeFont")); + m_font->setFixedWidth(185); + m_font->setToolTip(QStringLiteral("Font face, including bold and italic variants")); + connect(m_font, &QComboBox::activated, this, [this](int index) { + const QString name = m_font->itemText(index); + m_session.changeTextStyle([&name](LayerTextStyle &style) { style.fontName = name; }); + }); + m_size->setObjectName(QStringLiteral("typeSize")); + m_size->setFixedWidth(52); + m_tracking->setObjectName(QStringLiteral("typeTracking")); + m_tracking->setFixedWidth(45); + m_leading->setObjectName(QStringLiteral("typeLeading")); + m_leading->setFixedWidth(52); + m_leading->setPlaceholderText(QStringLiteral("Auto")); + m_leading->setToolTip(QStringLiteral("Line height, baseline to baseline. Empty or 0 is Auto: 120% of the font size.")); + m_colour->setObjectName(QStringLiteral("typeColor")); + m_colour->setFixedSize(36, 18); + m_colour->setToolTip(QStringLiteral("Text color")); + m_colour->setAccessibleName(QStringLiteral("Text color")); + connect(m_colour, &QAbstractButton::clicked, this, [this] { m_session.openTextColorPicker(); }); + auto *alignments = new QWidget; + auto *aligned = new QHBoxLayout(alignments); + aligned->setContentsMargins(0, 0, 0, 0); + aligned->setSpacing(2); + for (QToolButton *button : m_alignments) + aligned->addWidget(button); + for (QWidget *widget : std::initializer_list<QWidget *>{m_font, m_size, new QLabel(QStringLiteral("px")), m_colour, alignments, new QLabel(QStringLiteral("Tracking")), + m_tracking, new QLabel(QStringLiteral("Leading")), m_leading}) + fields->addWidget(widget); + // Swift's scroll content keeps its size at the leading edge. + fields->addStretch(1); + // Swift's horizontal ScrollView: the controls scroll when narrow. + auto *scroll = new QScrollArea(this); + scroll->setObjectName(QStringLiteral("typeScroll")); + scroll->setWidget(controls); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setFixedHeight(controls->sizeHint().height()); + m_cancel->setObjectName(QStringLiteral("typeCancel")); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelText(); }); + m_done->setObjectName(QStringLiteral("typeDone")); + connect(m_done, &QPushButton::clicked, this, [this] { m_session.finishText(); }); + m_edit->setObjectName(QStringLiteral("typeEdit")); + connect(m_edit, &QPushButton::clicked, this, [this] { m_session.editActiveText(); }); + for (QWidget *widget : std::initializer_list<QWidget *>{scroll, m_cancel, m_done, m_edit}) + row->insertWidget(row->count() - 1, widget, widget == scroll ? 1 : 0); + // The scroll view takes the spare room, as SwiftUI's does. + row->setStretch(row->count() - 1, 0); + applyGlyphs(); + connect(&m_session, &EditorSession::changed, this, &TypeControls::synchronize); + synchronize(); +} + +void TypeControls::applyGlyphs() +{ + for (size_t index = 0; index < m_alignments.size(); ++index) + m_alignments[index]->setIcon(alignmentGlyph(allTextAlignments[index], palette().color(QPalette::WindowText), devicePixelRatio())); +} + +// The theme's ink reaches the glyphs, as the panel's icons. +void TypeControls::changeEvent(QEvent *event) +{ + ToolHeaderBar::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + applyGlyphs(); +} + +// One of Swift's three alignment buttons. +QToolButton *TypeControls::alignment(TextAlignment value) +{ + auto *button = new QToolButton; + button->setObjectName(QStringLiteral("typeAlign") + rawValue(value)); + button->setCheckable(true); + button->setAutoRaise(true); + button->setFixedSize(30, 26); + button->setToolTip(QStringLiteral("Align ") + rawValue(value).toLower()); + button->setAccessibleName(button->toolTip()); + connect(button, &QToolButton::clicked, this, [this, value] { + m_session.changeTextStyle([value](LayerTextStyle &style) { style.alignment = value; }); + }); + return button; +} + +void TypeControls::synchronize() +{ + const LayerTextStyle style = m_session.currentTextStyle(); + // Every installed face, and the style's own though missing. + QStringList names = TextLayout::availableFonts(); + if (!names.contains(style.fontName)) + names << style.fontName; + names.sort(); + if (m_font->count() != names.size() || m_font->findText(style.fontName) < 0) { + m_font->clear(); + m_font->addItems(names); + } + m_font->setCurrentIndex(m_font->findText(style.fontName)); + for (size_t index = 0; index < m_alignments.size(); ++index) + m_alignments[index]->setChecked(style.alignment == allTextAlignments[index]); + for (TextStyleField *field : {m_size, m_tracking, m_leading}) + field->sync(); + m_colour->update(); + const bool drafting = m_session.textDraft().has_value(); + m_cancel->setVisible(drafting); + m_done->setVisible(drafting); + m_edit->setVisible(!drafting); + const std::optional<ImageLayer> active = m_session.activeLayer(); + m_edit->setEnabled(active && active->liveText()); + setEnabled(m_session.document().has_value() && !m_session.showsBusy()); +} diff --git a/src/UI/TypeControls.h b/src/UI/TypeControls.h new file mode 100644 index 0000000..f407d28 --- /dev/null +++ b/src/UI/TypeControls.h @@ -0,0 +1,60 @@ +#pragma once +#include "Document/TypeTool.h" +#include "UI/ToolHeaderStyle.h" +#include <QLineEdit> +#include <array> +#include <functional> + +class EditorSession; +class QComboBox; +class QPushButton; +class QToolButton; +class SwatchButton; + +// A text style number: typed at once, stepped by arrows. +class TextStyleField : public QLineEdit { + Q_OBJECT +public: + TextStyleField(std::function<QString()> shown, std::function<void(const QString &)> typed, std::function<void(double)> step, + QWidget *parent); + // Shows the session's number unless being edited. + void sync(); + +protected: + void keyPressEvent(QKeyEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + +private: + const std::function<QString()> m_shown; + const std::function<void(const QString &)> m_typed; + const std::function<void(double)> m_step; + // A menu or popup borrows the focus: the typing stays. + bool m_borrowed = false; +}; + +// Swift's TypeControls: font, size, alignment, spacing, Done. +class TypeControls : public ToolHeaderBar { + Q_OBJECT +public: + explicit TypeControls(EditorSession &session, QWidget *parent = nullptr); + +protected: + void changeEvent(QEvent *event) override; + +private: + QToolButton *alignment(TextAlignment value); + void applyGlyphs(); + void synchronize(); + + EditorSession &m_session; + QComboBox *const m_font; + TextStyleField *const m_size; + // Open text's colour, else the next text's: the foreground. + SwatchButton *const m_colour; + const std::array<QToolButton *, 3> m_alignments; + TextStyleField *const m_tracking; + TextStyleField *const m_leading; + QPushButton *const m_cancel; + QPushButton *const m_done; + QPushButton *const m_edit; +}; diff --git a/tests/AddressSpaceLimit.h b/tests/AddressSpaceLimit.h new file mode 100644 index 0000000..8f80e3d --- /dev/null +++ b/tests/AddressSpaceLimit.h @@ -0,0 +1,35 @@ +#pragma once +#include <QFile> +#include <QLibraryInfo> +#include <QVersionNumber> +#include <stdexcept> +#include <sys/resource.h> +#include <unistd.h> + +// While alive, the process may map only `margin` more bytes. +class AddressSpaceLimit { +public: + explicit AddressSpaceLimit(qint64 margin) + { + QFile statm("/proc/self/statm"); + if (!statm.open(QIODevice::ReadOnly) || getrlimit(RLIMIT_AS, &m_previous) != 0) + throw std::runtime_error("cannot read the address space in use"); + const qint64 mapped = statm.readAll().split(' ').first().toLongLong() * sysconf(_SC_PAGESIZE); + rlimit tight = m_previous; + tight.rlim_cur = rlim_t(mapped + margin); + if (setrlimit(RLIMIT_AS, &tight) != 0) + throw std::runtime_error("cannot lower RLIMIT_AS"); + } + ~AddressSpaceLimit() { setrlimit(RLIMIT_AS, &m_previous); } + AddressSpaceLimit(const AddressSpaceLimit &) = delete; + AddressSpaceLimit &operator=(const AddressSpaceLimit &) = delete; + +private: + rlimit m_previous; +}; + +// Failure points are placed by Qt 6.4's own allocations. +inline bool placesStarvation() +{ + return QLibraryInfo::version().majorVersion() == 6 && QLibraryInfo::version().minorVersion() == 4; +} diff --git a/tests/AdjustmentEditorTests.cpp b/tests/AdjustmentEditorTests.cpp new file mode 100644 index 0000000..ff5c7dc --- /dev/null +++ b/tests/AdjustmentEditorTests.cpp @@ -0,0 +1,477 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore+Json.h" +#include "SessionFixtures.h" +#include "SessionRecord.h" +#include <QSignalSpy> +#include <QtTest> + +// Swift's AdjustmentEditorTests: the colour editors write layer settings. +namespace { +ImportedImage filled(int width, int height, const QColor &colour) +{ + QImage pixels(width, height, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(colour); + return ImportedImage(pixels, pixels, QStringLiteral("Fixture")); +} + +// Swift's await: true once the call's callback has run. +bool awaited(const std::function<void(std::function<void()>)> &call) +{ + bool done = false; + call([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 10'000); +} + +bool opened(EditorSession &session, QUuid id) +{ + return awaited([&](std::function<void()> done) { session.beginAdjustmentEditing(id, std::move(done)); }); +} + +// The pixels the open editor reads, premultiplied RGBA. +std::vector<int> input(const EditorSession &session) +{ + const QImage image = session.levels().value().original.image().convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector<int> result; + for (int y = 0; y < image.height(); ++y) + result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4); + return result; +} +} + +class AdjustmentEditorTests : public QObject { + Q_OBJECT +private slots: + void sharedEditorsKeepPixelsDynamicAndSupportCancel_data(); + void sharedEditorsKeepPixelsDynamicAndSupportCancel(); + void theEditorReadsOnlyWhatLiesBeneath(); + void theEditorOpensOnlyForItsRequest(); + void aFailedRenderEndsTheRequest(); + void anEditBegunMeanwhileKeepsItsEditor(); + void editingClosesLayerEditsAndFileRequests(); + void closingDropsTheHueTools(); + void aLayerUndoneMeanwhileStillCloses(); + void aGradientMapPickerOutlivesItsEditor(); + void everyStepIsAnnouncedAndItsLastSignalSeesIt(); +}; + +void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel_data() +{ + QTest::addColumn<AdjustmentKind>("kind"); + for (const AdjustmentKind kind : allAdjustmentKinds) + QTest::newRow(qPrintable(rawValue(kind))) << kind; +} + +void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel() +{ + QFETCH(AdjustmentKind, kind); + EditorSession session; + session.createDocument(2, 2); + const ImportedImage asset = filled(2, 2, Qt::white); + session.insert(asset); + const QUuid baseID = session.activeLayerID().value(); + session.addAdjustment(kind); + const QUuid id = session.adjustmentEditingID().value(); + // As made: a Gradient Map's palette, Grain's own pattern. + const LayerAdjustment created = session.activeLayer().value().adjustment.value(); + QVERIFY(opened(session, id)); + QVERIFY(session.adjustmentOriginal().value().kind == kind); + QVERIFY(!session.showsBusy()); + switch (kind) { + case AdjustmentKind::levels: { + QTRY_VERIFY(session.levels().value().histogramReady); + QCOMPARE(session.levels().value().histogram[0][255], 4.0); + LevelsSettings settings = session.levels().value().settings; + settings.ranges[0].outputWhite = 0; + session.updateLevels(settings, true); + QVERIFY(session.activeLayer().value().adjustment.value().levels == settings); + session.updateLevels(settings, false); + QVERIFY(session.activeLayer().value().adjustment.value() == LayerAdjustment{kind}); + QVERIFY(awaited([&](std::function<void()> done) { session.commitLevels(std::move(done)); })); + break; + } + case AdjustmentKind::curves: { + QVERIFY(session.filterEdit().value().kind == FilterKind::curves); + FilterSettings settings = session.filterEdit().value().settings; + settings.curves.channels[0] = {{0, 0}, {255, 0}}; + session.updateFilter(settings, true); + QVERIFY(session.activeLayer().value().adjustment.value().curves == settings.curves); + QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); })); + break; + } + case AdjustmentKind::exposure: + case AdjustmentKind::gradientMap: + case AdjustmentKind::grain: { + QVERIFY(session.filterEdit().value().kind == filterKind(kind)); + FilterSettings settings = session.filterEdit().value().settings; + if (kind == AdjustmentKind::exposure) + settings.exposure.exposure = 1; + else if (kind == AdjustmentKind::gradientMap) + settings.gradientMap.reversed = true; + else + settings.grain.amount = 70; + session.updateFilter(settings, true); + const LayerAdjustment live = session.activeLayer().value().adjustment.value(); + QVERIFY(live.exposure() == settings.exposure && live.gradientMap() == settings.gradientMap && live.grain() == settings.grain); + QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); })); + break; + } + case AdjustmentKind::hsv: { + HueSaturationSettings settings = session.hueSaturation().value().settings; + settings.range = ColorRange::reds; + settings.setHue(80); + settings.setBand(settings.band().centered(25)); + settings.invertRange = true; + session.updateHueSaturation(settings, true); + QVERIFY(session.activeLayer().value().adjustment.value().resolvedHSV() == settings); + QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); })); + break; + } + } + const LayerAdjustment saved = session.activeLayer().value().adjustment.value(); + QVERIFY(!session.adjustmentEditingID() && saved != created); + QVERIFY(layerWith(session, baseID).asset.value().identity() == asset.identity()); + QVERIFY(!session.activeLayer().value().asset); + QVERIFY(ManifestJson::adjustment(ManifestJson::encoded(saved)) == saved); + session.setAdjustmentEditingID(id); + QVERIFY(opened(session, id)); + switch (kind) { + case AdjustmentKind::levels: + QVERIFY(session.levels().value().settings == saved.levels); + session.updateLevels(LevelsSettings(), true); + session.cancelLevels(); + break; + case AdjustmentKind::curves: + case AdjustmentKind::exposure: + case AdjustmentKind::gradientMap: + case AdjustmentKind::grain: { + const FilterSettings reopened = session.filterEdit().value().settings; + QVERIFY(reopened.curves == saved.curves && reopened.exposure == saved.exposure() && reopened.gradientMap == saved.gradientMap() + && reopened.grain == saved.grain()); + session.updateFilter(FilterSettings(), true); + session.cancelFilter(); + break; + } + case AdjustmentKind::hsv: + QVERIFY(session.hueSaturation().value().settings == saved.resolvedHSV()); + session.updateHueSaturation(HueSaturationSettings(), true); + session.cancelHueSaturation(); + break; + } + QVERIFY(session.activeLayer().value().adjustment.value() == saved); + QVERIFY(!session.adjustmentEditingID()); + session.undo(); + QVERIFY(session.activeLayer().value().adjustment.value() == created); + session.redo(); + QVERIFY(session.activeLayer().value().adjustment.value() == saved); +} + +void AdjustmentEditorTests::theEditorReadsOnlyWhatLiesBeneath() +{ + // Red, then a folder: green, the adjustment, then blue. + EditorSession session; + session.createDocument(2, 1); + session.insert(filled(2, 1, Qt::red)); + session.insert(filled(1, 1, Qt::green), QPointF(0.5, 0.5)); + const QUuid green = session.activeLayerID().value(); + session.groupSelectedLayers(); + session.selectLayer(green); + session.addAdjustment(AdjustmentKind::levels); + const QUuid id = session.activeLayerID().value(); + session.setAdjustmentEditingID(std::nullopt); + // Its own settings would turn the input black. + LayerAdjustment black{AdjustmentKind::levels}; + black.levels.ranges[0].outputWhite = 0; + session.updateAdjustment(id, black); + session.insert(filled(2, 1, Qt::blue)); + QCOMPARE(layerWith(session, id).parentID, layerWith(session, green).parentID); + session.setAdjustmentEditingID(id); + QVERIFY(opened(session, id)); + QVERIFY((input(session) == std::vector<int>{0, 255, 0, 255, 255, 0, 0, 255})); + // The editors preview nothing: the layer's settings are the preview. + LevelsSettings settings; + settings.ranges[0].outputWhite = 100; + session.updateLevels(settings, true); + QTest::qWait(50); + QVERIFY(!session.levels().value().preparedPreview && !session.levels().value().pending); + session.finishAdjustmentEditing(false); + QVERIFY(layerWith(session, id).adjustment.value() == black); +} + +void AdjustmentEditorTests::theEditorOpensOnlyForItsRequest() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::white)); + const QUuid pixels = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::levels); + const QUuid id = session.adjustmentEditingID().value(); + // Another request, a missing layer, a layer without settings. + for (const std::optional<QUuid> request : {std::optional<QUuid>(), std::optional(QUuid::createUuid()), std::optional(pixels)}) { + session.setAdjustmentEditingID(request); + QVERIFY(opened(session, request.value_or(id))); + QVERIFY(!session.levels() && !session.adjustmentOriginal() && session.adjustmentEditingID() == request); + } + // A pixel edit already open refuses it; the request stays. + session.selectLayer(pixels); + for (int edit = 0; edit < 3; ++edit) { + if (edit == 0) + session.beginLevels(); + else if (edit == 1) + session.beginHueSaturation(); + else + session.beginFilter(FilterKind::curves); + QVERIFY(session.levels() || session.hueSaturation() || session.filterEdit()); + session.setAdjustmentEditingID(id); + QVERIFY(opened(session, id)); + QVERIFY(!session.adjustmentOriginal() && session.adjustmentEditingID() == id); + session.cancelLevels(); + session.cancelHueSaturation(); + session.cancelFilter(); + } + // A request changed while it renders opens nothing. + session.setAdjustmentEditingID(id); + bool dropped = false; + session.beginAdjustmentEditing(id, [&dropped] { dropped = true; }); + session.setAdjustmentEditingID(std::nullopt); + QTRY_VERIFY(dropped); + QVERIFY(!session.levels() && !session.adjustmentOriginal()); + // Two renders: the first lands, the other finds it open. + session.setAdjustmentEditingID(id); + bool first = false, second = false; + session.beginAdjustmentEditing(id, [&first] { first = true; }); + session.beginAdjustmentEditing(id, [&second] { second = true; }); + QTRY_VERIFY(first && second); + const QUuid edit = session.levels().value().id; + QVERIFY(opened(session, id)); + QCOMPARE(session.levels().value().id, edit); + session.cancelLevels(); + QVERIFY(!session.levels() && !session.adjustmentEditingID() && !session.adjustmentOriginal()); + // One transaction opened, so one close ends it. + QVERIFY(session.canUndo() && session.history.undoName() == QString("New Levels Adjustment")); +} + +void AdjustmentEditorTests::aFailedRenderEndsTheRequest() +{ + // Past the export's hundred million pixels: the render refuses. + EditorSession session; + session.createDocument(10'001, 10'000); + session.addAdjustment(AdjustmentKind::curves); + const int steps = session.history.undoCount(); + int ran = 0; + session.waitForFileRequest([&ran] { ++ran; }); + QVERIFY(opened(session, session.adjustmentEditingID().value())); + QCOMPARE(session.brushError(), std::optional(QString::fromUtf8(ExportError(ExportError::Kind::tooLarge).what()))); + QTRY_COMPARE(ran, 1); + QVERIFY(!session.adjustmentEditingID() && !session.adjustmentOriginal() && !session.filterEdit()); + QVERIFY(session.canEditLayers() && session.canUndo() && session.history.undoCount() == steps); +} + +void AdjustmentEditorTests::anEditBegunMeanwhileKeepsItsEditor() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::white)); + const QUuid pixels = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::levels); + const QUuid id = session.adjustmentEditingID().value(); + int ran = 0; + session.waitForFileRequest([&ran] { ++ran; }); + bool landed = false; + session.beginAdjustmentEditing(id, [&landed] { landed = true; }); + // Hue/Saturation opens on the pixels while the render runs. + session.selectLayer(pixels); + session.beginHueSaturation(); + QCOMPARE(session.hueSaturation().value().layerID, pixels); + std::optional<QUuid> last = id; + connect(&session, &EditorSession::changed, this, [&] { last = session.adjustmentEditingID(); }); + QTRY_VERIFY(landed); + QCOMPARE(session.hueSaturation().value().layerID, pixels); + QVERIFY(!session.levels() && !session.adjustmentOriginal() && !session.adjustmentEditingID() && !session.brushError()); + // Its signal saw the request gone; waiting requests go on. + QVERIFY(!last); + QTRY_COMPARE(ran, 1); + session.cancelHueSaturation(); + QVERIFY(session.canEditLayers()); +} + +void AdjustmentEditorTests::editingClosesLayerEditsAndFileRequests() +{ + std::vector<bool> open; + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::white)); + session.addAdjustment(AdjustmentKind::exposure); + const QUuid id = session.adjustmentEditingID().value(); + QVERIFY(!session.canEditLayers() && !session.canStartProjectOperation()); + int ran = 0; + session.waitForFileRequest([&ran] { ++ran; }); + QVERIFY(opened(session, id)); + QCOMPARE(ran, 0); + QVERIFY(!session.canUndo()); + // The last signal of the close sees it closed. + connect(&session, &EditorSession::changed, this, [&] { open.push_back(session.adjustmentEditingID() || session.filterEdit()); }); + const int focus = session.canvasFocusRequest(), revision = session.brushRevision(); + QVERIFY(session.finishAdjustmentEditing(true)); + QVERIFY(!open.empty() && !open.back()); + QVERIFY(session.canvasFocusRequest() == focus + 1 && session.brushRevision() > revision); + QVERIFY(session.canEditLayers() && session.canStartProjectOperation()); + QTRY_COMPARE(ran, 1); + // OK writes the settings out, as Swift does: a step. + QCOMPARE(session.history.undoName(), QString("Edit Exposure Adjustment")); + QVERIFY(layerWith(session, id).adjustment.value().exposureSettings == ExposureSettings()); + // Cancel puts the original back: no step. + const int steps = session.history.undoCount(); + session.setAdjustmentEditingID(id); + QVERIFY(opened(session, id)); + session.cancelFilter(); + QVERIFY(session.history.undoCount() == steps && !session.adjustmentEditingID()); + QVERIFY(!session.finishAdjustmentEditing(true) && !session.previewAdjustmentEditing(true)); +} + +void AdjustmentEditorTests::closingDropsTheHueTools() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::red)); + const QUuid pixels = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::hsv); + QVERIFY(opened(session, session.adjustmentEditingID().value())); + session.setHueSampleMode(HueSampleMode::add); + session.setHueTargeting(true); + QVERIFY(session.beginHueTargeting(QPointF(1, 1))); + QVERIFY(session.finishAdjustmentEditing(true)); + QVERIFY(!session.hueSampleMode() && !session.hueTargeting()); + // The drag went too: a new edit's drag changes nothing. + session.selectLayer(pixels); + session.beginHueSaturation(); + session.dragHueTargeting(40, false); + QVERIFY(session.hueSaturation().value().settings.isIdentity()); +} + +void AdjustmentEditorTests::aLayerUndoneMeanwhileStillCloses() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::white)); + session.addAdjustment(AdjustmentKind::levels); + const QUuid id = session.adjustmentEditingID().value(); + bool landed = false; + session.beginAdjustmentEditing(id, [&landed] { landed = true; }); + // History stays open while it renders, as in Swift. + session.undo(); + QVERIFY(!session.document().value().layers.empty() && indexOf(session.document().value().layers, id) < 0); + QTRY_VERIFY(landed); + QVERIFY(session.levels() && session.adjustmentOriginal()); + // Nothing to write back; the canvas still redraws once. + const int revision = session.brushRevision(); + session.cancelLevels(); + QVERIFY(!session.levels() && !session.adjustmentEditingID() && session.brushRevision() == revision + 1); + QVERIFY(session.canUndo() && session.history.redoName() == QString("New Levels Adjustment")); +} + +void AdjustmentEditorTests::aGradientMapPickerOutlivesItsEditor() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::gray)); + const QUuid pixels = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::gradientMap); + const QUuid id = session.activeLayerID().value(); + for (const bool commit : {true, false}) { + const LayerAdjustment before = layerWith(session, id).adjustment.value(); + QVERIFY(before.gradientMap().highlights != AdjustmentColor(1, 0, 0)); + session.setAdjustmentEditingID(id); + QVERIFY(opened(session, id)); + session.openGradientMapColorPicker(true); + session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + session.previewGradientMapColor(); + QVERIFY(layerWith(session, id).adjustment.value().gradientMap().highlights == AdjustmentColor(1, 0, 0)); + // Finished directly, as Swift's tests do: the picker stays open. + QVERIFY(session.finishAdjustmentEditing(commit)); + QVERIFY(session.colorPicker().has_value()); + const LayerAdjustment kept = layerWith(session, id).adjustment.value(); + QVERIFY(kept.gradientMap().highlights == (commit ? AdjustmentColor(1, 0, 0) : before.gradientMap().highlights)); + if (commit) { + // With no filter open, it just closes. + session.closeColorPicker(true); + QVERIFY(!session.colorPicker() && !session.filterEdit()); + } else { + // A filter opened meanwhile takes nothing from it. + session.selectLayer(pixels); + session.beginFilter(FilterKind::curves); + const FilterSettings curves = session.filterEdit().value().settings; + session.closeColorPicker(true); + QVERIFY(!session.colorPicker() && session.filterEdit().value().settings == curves); + session.cancelFilter(); + } + QVERIFY(layerWith(session, id).adjustment.value() == kept); + // The next round starts from white again. + LayerAdjustment white = kept; + white.setGradientMap(GradientMapSettings{}); + session.updateAdjustment(id, white); + session.selectLayer(id); + } +} + +void AdjustmentEditorTests::everyStepIsAnnouncedAndItsLastSignalSeesIt() +{ + QStringList seen; + EditorSession session; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + // Empty when the last signal saw the state left behind. + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + session.createDocument(2, 2); + session.insert(filled(2, 2, Qt::gray)); + QCOMPARE(stale([&] { session.addAdjustment(AdjustmentKind::curves); }), QString()); + const QUuid id = session.adjustmentEditingID().value(); + QCOMPARE(stale([&] { QVERIFY(opened(session, id)); }), QString()); + QVERIFY(session.adjustmentOriginal() && session.filterEdit()); + FilterSettings settings = session.filterEdit().value().settings; + settings.curves.channels[0] = {{0, 0}, {255, 128}}; + QCOMPARE(stale([&] { session.updateFilter(settings, true); }), QString()); + QCOMPARE(stale([&] { session.updateFilter(settings, false); }), QString()); + // Settings the layer refuses still change the editor, announced. + FilterSettings single = settings; + single.curves.channels[0] = {{0, 0}}; + QCOMPARE(stale([&] { session.updateFilter(single, true); }), QString()); + QCOMPARE(stale([&] { session.finishAdjustmentEditing(true); }), QString()); + for (const AdjustmentKind kind : {AdjustmentKind::levels, AdjustmentKind::hsv}) { + session.addAdjustment(kind); + QVERIFY(opened(session, session.adjustmentEditingID().value())); + QTest::qWait(20); + if (kind == AdjustmentKind::levels) { + LevelsSettings reversed; + reversed.ranges[0].black = 200; + reversed.ranges[0].white = 100; + QCOMPARE(stale([&] { session.updateLevels(reversed, true); }), QString()); + QCOMPARE(stale([&] { session.cancelLevels(); }), QString()); + } else { + QCOMPARE(stale([&] { session.updateHueSaturation(HueSaturationSettings(1000), true); }), QString()); + QCOMPARE(stale([&] { session.cancelHueSaturation(); }), QString()); + } + } + QCOMPARE(stale([&] { session.setAdjustmentEditingID(QUuid::createUuid()); }), QString()); + // Refusals stay silent: another request, nothing open. + QSignalSpy changed(&session, &EditorSession::changed); + QVERIFY(opened(session, id)); + QVERIFY(!session.finishAdjustmentEditing(true) && !session.previewAdjustmentEditing(true)); + QCOMPARE(changed.count(), 0); + // A failed render's last signal sees the request end. + EditorSession huge; + connect(&huge, &EditorSession::changed, this, [&] { seen = described(huge); }); + huge.createDocument(10'001, 10'000); + huge.addAdjustment(AdjustmentKind::levels); + seen.clear(); + QVERIFY(opened(huge, huge.adjustmentEditingID().value())); + QVERIFY(!huge.adjustmentEditingID() && huge.brushError()); + QCOMPARE(seen, described(huge)); +} + +QTEST_GUILESS_MAIN(AdjustmentEditorTests) +#include "AdjustmentEditorTests.moc" diff --git a/tests/AdjustmentLayerTests.cpp b/tests/AdjustmentLayerTests.cpp new file mode 100644 index 0000000..10a8506 --- /dev/null +++ b/tests/AdjustmentLayerTests.cpp @@ -0,0 +1,375 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore+Json.h" +#include "SessionFixtures.h" +#include <QTemporaryDir> +#include <QtTest> + +// Swift's AdjustmentLayerTests and AdjustmentEditorTests, as far as they reach. +namespace { +// Swift's fixture: two by two, one colour, these alphas. +ImportedImage image(const PaletteColor &colour, const std::array<int, 4> &alpha = {255, 255, 255, 255}) +{ + QImage pixels(2, 2, QImage::Format_RGBA8888_Premultiplied); + for (int index = 0; index < 4; ++index) { + uchar *pixel = pixels.scanLine(index / 2) + (index % 2) * 4; + const int a = alpha[size_t(index)]; + pixel[0] = uchar(std::lround(colour.red * a)); + pixel[1] = uchar(std::lround(colour.green * a)); + pixel[2] = uchar(std::lround(colour.blue * a)); + pixel[3] = uchar(a); + } + return ImportedImage(pixels, pixels, QStringLiteral("Fixture")); +} + +// Premultiplied bytes, row after row. +std::vector<int> pixels(const QImage &image) +{ + const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector<int> result; + for (int y = 0; y < drawn.height(); ++y) + result.insert(result.end(), drawn.constScanLine(y), drawn.constScanLine(y) + drawn.width() * 4); + return result; +} + +std::vector<int> rendered(const EditorSession &session) +{ + return pixels(ImageExporter::render(session.projectSnapshot().value()).image); +} + +std::vector<int> part(const std::vector<int> &bytes, size_t from, size_t to) +{ + return std::vector<int>(bytes.begin() + qsizetype(from), bytes.begin() + qsizetype(to)); +} + +// Swift's `add`: no editor opens here. +QUuid add(AdjustmentKind kind, EditorSession &session) +{ + session.addAdjustment(kind); + session.setAdjustmentEditingID(std::nullopt); + return session.activeLayerID().value(); +} +} + +class AdjustmentLayerTests : public QObject { + Q_OBJECT +private slots: + void globalAdjustmentAffectsBelowButNotAboveAndRemainsLive(); + void clippedCurveChangesOnlyItsBaseAndCopyMergedMatchesExport(); + void hueOpacityAndMaskPreserveOriginalPixels(); + void adjustmentPersistsDuplicatesAndUndoRestoresSettings(); + void curvesIdentityAndImageCommandPreserveAlpha(); + void adjustmentBlendAndSoftMaskPreserveCoverage(); + void legacyHSVStillDecodesAndRenders(); + void theLiveCompositeDrawsAsTheExport(); + void opacityMixesAndGrainSitsInTheDocument(); + void everyKindAdjustsThroughItsSettings(); + void aFoldersMaskClipsTheAdjustmentsInside(); + void anyImageTargetTakesTheAdjustment(); +}; + +void AdjustmentLayerTests::globalAdjustmentAffectsBelowButNotAboveAndRemainsLive() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor::white())); + const QUuid base = s.activeLayerID().value(); + const QUuid adjustment = add(AdjustmentKind::levels, s); + LayerAdjustment settings{AdjustmentKind::levels}; + settings.levels.ranges[0].outputWhite = 0; + s.updateAdjustment(adjustment, settings); + QVERIFY((rendered(s) == std::vector<int>{0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255})); + s.insert(image(PaletteColor{1, 0, 0}, {255, 0, 0, 0})); + QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255})); + rewrite(s, [&](ProjectSnapshot &snapshot) { snapshot.images.insert_or_assign(base, image(PaletteColor{0, 0, 1})); }); + QCOMPARE(s.document().value().layers[0].id, base); + QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{0, 0, 0, 255})); + s.selectLayer(adjustment); + s.toggleLayerVisibility(adjustment); + QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{0, 0, 255, 255})); +} + +void AdjustmentLayerTests::clippedCurveChangesOnlyItsBaseAndCopyMergedMatchesExport() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{0, 0, 1})); + s.insert(image(PaletteColor{0, 1, 0}, {255, 0, 128, 0})); + const QUuid adjustment = add(AdjustmentKind::curves, s); + LayerAdjustment settings{AdjustmentKind::curves}; + settings.curves.channels[0] = {{0, 255}, {255, 0}}; + s.updateAdjustment(adjustment, settings); + s.toggleClippingMask(adjustment); + const std::vector<int> result = rendered(s); + QVERIFY((part(result, 0, 4) == std::vector<int>{255, 0, 255, 255})); + QVERIFY((part(result, 4, 8) == std::vector<int>{0, 0, 255, 255})); + QVERIFY(pixels(s.renderMergedPixels().value().image) == result); + s.toggleClippingMask(adjustment); + QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{255, 255, 0, 255})); +} + +void AdjustmentLayerTests::hueOpacityAndMaskPreserveOriginalPixels() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{1, 0, 0})); + const ImageIdentity original = s.document().value().layers[0].asset.value().identity(); + const QUuid id = add(AdjustmentKind::hsv, s); + LayerAdjustment value{AdjustmentKind::hsv}; + value.hue = 120; + s.updateAdjustment(id, value); + const std::vector<int> result = rendered(s); + QVERIFY2(result[1] > 250 && result[0] < 5 && result[2] < 5, qPrintable(QString::number(result[0]))); + s.setLayerOpacity(0); + QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255})); + s.setLayerOpacity(1); + // Swift's LayerMask.solid(revealing: false). + s.addMask(false); + QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255})); + QVERIFY(s.document().value().layers[0].asset.value().identity() == original); +} + +void AdjustmentLayerTests::adjustmentPersistsDuplicatesAndUndoRestoresSettings() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor::white())); + const QUuid id = add(AdjustmentKind::curves, s); + LayerAdjustment value{AdjustmentKind::curves}; + value.curves.channels[0].insert(value.curves.channels[0].begin() + 1, CurvePoint{128, 190}); + s.beginEdit(QStringLiteral("Edit Curves")); + s.updateAdjustment(id, value); + s.endEdit(); + s.undo(); + QVERIFY(s.activeLayer().value().adjustment.value().curves == CurvesSettings()); + s.redo(); + QVERIFY(s.activeLayer().value().adjustment == value); + s.duplicateActiveLayer(); + QVERIFY(s.activeLayer().value().adjustment == value); + QTemporaryDir root; + const QString path = root.filePath(QStringLiteral("Adjustment-%1.comp").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))); + ProjectStore::save(s.projectSnapshot().value(), path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QVERIFY(loaded.manifest.layers.back().adjustment == value); + EditorSession restored; + restored.installProject(loaded, path); + QVERIFY(rendered(restored) == rendered(s)); +} + +void AdjustmentLayerTests::curvesIdentityAndImageCommandPreserveAlpha() +{ + const ImportedImage asset = image(PaletteColor{0.4, 0.7, 0.1}, {255, 128, 32, 0}); + QVERIFY(pixels(CurvesSettings().apply(asset.image())) == pixels(asset.image())); + EditorSession s; + s.createDocument(2, 2); + s.insert(asset); + s.beginFilter(FilterKind::curves); + FilterSettings settings; + settings.curves.channels[0] = {{0, 255}, {255, 255}}; + s.updateFilter(settings, true); + bool done = false; + s.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY((pixels(s.activeLayer().value().asset.value().image()) == std::vector<int>{255, 255, 255, 255, 128, 128, 128, 128, 32, 32, 32, 32, 0, 0, 0, 0})); + s.undo(); + QVERIFY(s.activeLayer().value().asset.value().identity() == asset.identity()); +} + +void AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{1, 0, 0}, {255, 128, 32, 0})); + const QUuid id = add(AdjustmentKind::hsv, s); + LayerAdjustment value{AdjustmentKind::hsv}; + value.hue = 120; + s.updateAdjustment(id, value); + s.setLayerBlendMode(LayerBlendMode::multiply); + QVERIFY((rendered(s) == std::vector<int>{0, 0, 0, 255, 0, 0, 0, 128, 0, 0, 0, 32, 0, 0, 0, 0})); + s.setLayerBlendMode(LayerBlendMode::normal); + QImage gray(1, 1, QImage::Format_Grayscale8); + gray.fill(128); + rewrite(s, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(gray)); }); + const std::vector<int> p = rendered(s); + QVERIFY(p[3] == 255 && p[7] == 128 && p[11] == 32 && p[15] == 0); + QVERIFY2(std::abs(p[0] - 127) <= 2 && std::abs(p[1] - 128) <= 2, qPrintable(QStringLiteral("%1 %2").arg(p[0]).arg(p[1]))); +} + +void AdjustmentLayerTests::legacyHSVStillDecodesAndRenders() +{ + const QJsonObject data = ManifestJson::encoded(LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 120}); + const LayerAdjustment decoded = ManifestJson::adjustment(data); + QVERIFY(!decoded.hsvSettings); + QCOMPARE(decoded.resolvedHSV().hue(), 120.0); +} + +void AdjustmentLayerTests::theLiveCompositeDrawsAsTheExport() +{ + // A global Levels, a clipped Curve and a masked Hue. + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{0.2, 0.6, 1}, {255, 200, 128, 64})); + const QUuid levels = add(AdjustmentKind::levels, s); + LayerAdjustment darker{AdjustmentKind::levels}; + darker.levels.ranges[0].outputWhite = 180; + s.updateAdjustment(levels, darker); + s.insert(image(PaletteColor{1, 0.5, 0}, {255, 0, 255, 0})); + const QUuid curve = add(AdjustmentKind::curves, s); + LayerAdjustment inverted{AdjustmentKind::curves}; + inverted.curves.channels[0] = {{0, 255}, {255, 0}}; + s.updateAdjustment(curve, inverted); + s.toggleClippingMask(curve); + const QUuid hue = add(AdjustmentKind::hsv, s); + s.updateAdjustment(hue, LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 90}); + s.addMask(true); + s.setLayerOpacity(0.5); + QImage live(2, 2, QImage::Format_RGBA8888_Premultiplied); + live.fill(0); + { + QPainter painter(&live); + s.drawLiveComposite(s.document().value(), painter); + } + QVERIFY(pixels(live) == rendered(s)); + // Under a translucent painter the composite fades once, whole. + const QImage exported = ImageExporter::render(s.projectSnapshot().value()).image; + QImage faded(2, 2, QImage::Format_RGBA8888_Premultiplied), expected(2, 2, QImage::Format_RGBA8888_Premultiplied); + faded.fill(Qt::white); + expected.fill(Qt::white); + { + QPainter painter(&faded); + painter.setOpacity(0.5); + s.drawLiveComposite(s.document().value(), painter); + QPainter over(&expected); + over.setOpacity(0.5); + over.drawImage(QRectF(0, 0, 2, 2), exported); + } + const std::vector<int> got = pixels(faded), wanted = pixels(expected); + for (size_t index = 0; index < got.size(); ++index) + QVERIFY2(std::abs(got[index] - wanted[index]) <= 1, qPrintable(QString::number(index))); +} + +void AdjustmentLayerTests::opacityMixesAndGrainSitsInTheDocument() +{ + // Half opacity lies halfway to the change. + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{1, 0, 0})); + const QUuid hue = add(AdjustmentKind::hsv, s); + s.updateAdjustment(hue, LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 120}); + s.setLayerOpacity(0.5); + QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{128, 128, 0, 255})); + // Grain drawn for part of a document matches the whole. + EditorSession grained; + grained.createDocument(12, 12); + QImage gray(12, 12, QImage::Format_RGBA8888_Premultiplied); + gray.fill(QColor(128, 128, 128)); + grained.insert(ImportedImage(gray, gray, QStringLiteral("Gray"))); + const QUuid grain = add(AdjustmentKind::grain, grained); + LayerAdjustment strong = grained.activeLayer().value().adjustment.value(); + GrainSettings settings = strong.grain(); + settings.amount = 100; + strong.setGrain(settings); + grained.updateAdjustment(grain, strong); + const QImage whole = ImageExporter::render(grained.projectSnapshot().value()).image; + QImage corner(5, 4, QImage::Format_RGBA8888_Premultiplied); + corner.fill(0); + { + QPainter painter(&corner); + painter.translate(-7, -8); + grained.drawLiveComposite(grained.document().value(), painter); + } + QVERIFY(corner == whole.copy(7, 8, 5, 4)); + QVERIFY(corner != gray.copy(7, 8, 5, 4)); + // Twice as fine, each pixel is half a grain unit. + QImage fine(24, 24, QImage::Format_RGBA8888_Premultiplied); + fine.fill(0); + { + QPainter painter(&fine); + painter.scale(2, 2); + grained.drawLiveComposite(grained.document().value(), painter); + } + QImage flat(24, 24, QImage::Format_RGBA8888_Premultiplied); + flat.fill(QColor(128, 128, 128)); + QVERIFY(fine == settings.apply(flat, QPointF(), 0.5)); +} + +void AdjustmentLayerTests::everyKindAdjustsThroughItsSettings() +{ + QImage gray(2, 2, QImage::Format_RGBA8888_Premultiplied); + gray.fill(QColor(100, 150, 200)); + std::vector<LayerAdjustment> kinds; + for (const AdjustmentKind kind : allAdjustmentKinds) + kinds.push_back(LayerAdjustment{kind}); + kinds[0].hue = 60; + kinds[1].levels.ranges[0].outputWhite = 100; + kinds[2].curves.channels[0] = {{0, 50}, {255, 255}}; + kinds[3].setExposure(ExposureSettings{.exposure = 1}); + kinds[4].setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, false}); + kinds[5].setGrain(GrainSettings{.amount = 80, .seed = 4}); + for (const LayerAdjustment &adjustment : kinds) { + EditorSession s; + s.createDocument(2, 2); + s.insert(ImportedImage(gray, gray, QStringLiteral("Colour"))); + s.updateAdjustment(add(adjustment.kind, s), adjustment); + const std::vector<int> made = pixels(adjustment.apply(gray)); + QVERIFY2(made != pixels(gray) && rendered(s) == made, qPrintable(rawValue(adjustment.kind))); + } +} + +void AdjustmentLayerTests::aFoldersMaskClipsTheAdjustmentsInside() +{ + // White beneath; a folder whose mask hides its left column. + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor::white())); + const QUuid levels = add(AdjustmentKind::levels, s); + LayerAdjustment black{AdjustmentKind::levels}; + black.levels.ranges[0].outputWhite = 0; + s.updateAdjustment(levels, black); + s.groupSelectedLayers(); + const QUuid folder = s.activeLayerID().value(); + QImage right(2, 2, QImage::Format_Grayscale8); + right.fill(255); + right.setPixel(0, 0, 0); + right.setPixel(0, 1, 0); + rewrite(s, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(right)); }); + QVERIFY((rendered(s) == std::vector<int>{255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255})); +} + +void AdjustmentLayerTests::anyImageTargetTakesTheAdjustment() +{ + EditorSession s; + s.createDocument(2, 2); + s.insert(image(PaletteColor{0.2, 0.6, 1}, {255, 200, 128, 64})); + const QUuid curve = add(AdjustmentKind::curves, s); + LayerAdjustment inverted{AdjustmentKind::curves}; + inverted.curves.channels[0] = {{0, 255}, {255, 0}}; + s.updateAdjustment(curve, inverted); + // ARGB32's byte order: the adjustment reads colours, not bytes. + QImage argb(2, 2, QImage::Format_ARGB32_Premultiplied); + argb.fill(0); + { + QPainter painter(&argb); + s.drawLiveComposite(s.document().value(), painter); + } + QVERIFY(pixels(argb) == rendered(s)); + // ARGB32 byte order: the adjustment reads colours, not bytes. + for (const LayerBlendMode mode : {LayerBlendMode::normal, LayerBlendMode::multiply}) { + s.setLayerBlendMode(mode); + QImage sharp(4, 4, QImage::Format_RGBA8888_Premultiplied), scaled(4, 4, QImage::Format_RGBA8888_Premultiplied); + sharp.setDevicePixelRatio(2); + sharp.fill(0); + scaled.fill(0); + { + QPainter ratio(&sharp); + s.drawLiveComposite(s.document().value(), ratio); + QPainter scale(&scaled); + scale.scale(2, 2); + s.drawLiveComposite(s.document().value(), scale); + } + QVERIFY2(pixels(sharp) == pixels(scaled), qPrintable(rawValue(mode))); + } +} + +QTEST_MAIN(AdjustmentLayerTests) +#include "AdjustmentLayerTests.moc" diff --git a/tests/AdjustmentPanelTests.cpp b/tests/AdjustmentPanelTests.cpp new file mode 100644 index 0000000..6681bd1 --- /dev/null +++ b/tests/AdjustmentPanelTests.cpp @@ -0,0 +1,227 @@ +#include "ContentView.h" +#include "MenuFixtures.h" +#include "UI/LayerIcons.h" +#include "UI/LayersPanel.h" +#include "UI/NativeLayerList.h" +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QToolButton> +#include <QtTest> + +// Adjustment layers in the window: footer, rows, menu, sheets. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +ImportedImage whiteImage(int width, int height) +{ + QImage pixels(width, height, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::white); + return ImportedImage(pixels, pixels, QStringLiteral("White")); +} + +// A shown label with these words, if any. +QLabel *shownLabel(QWidget &root, const QString &words) +{ + for (QLabel *label : root.findChildren<QLabel *>()) { + if (label->text() == words && label->isVisible()) + return label; + } + return nullptr; +} + +bool inked(const QImage &image, int x, int y) +{ + return qAlpha(image.pixel(x, y)) > 128; +} +} + +class AdjustmentPanelTests : public QObject { + Q_OBJECT +private slots: + void theFooterAddsEachKindAndItsEditorOpens(); + void rowsShowTheirGlyphAndOpenOnADoubleClick(); + void theLayerMenuAddsAndEditsAdjustments(); + void theSheetsSayTheyReadWhatLiesBeneath(); +}; + +void AdjustmentPanelTests::theFooterAddsEachKindAndItsEditorOpens() +{ + EditorSession session; + LayersPanel panel(session); + panel.show(); + QVERIFY(QTest::qWaitForWindowExposed(&panel)); + auto &button = find<QToolButton>(panel, "addAdjustment"); + QCOMPARE(button.toolTip(), QString("New adjustment layer")); + QCOMPARE(button.accessibleName(), QString("New adjustment layer")); + QCOMPARE(button.popupMode(), QToolButton::InstantPopup); + QVERIFY(!button.isEnabled()); + // Swift's circle.lefthalf.filled, in the footer's ink. + const QImage icon = LayerIcons::pixmap(LayerIcon::halfFilledCircle, 16, panel.palette().color(QPalette::PlaceholderText), panel.devicePixelRatio()).toImage(); + QCOMPARE(button.icon().pixmap(QSize(16, 16), panel.devicePixelRatio()).toImage().convertToFormat(icon.format()), icon); + // Swift's Menu: the six kinds by name, no ellipsis. + const QList<QAction *> entries = button.menu()->actions(); + QCOMPARE(entries.size(), qsizetype(allAdjustmentKinds.size())); + for (size_t index = 0; index < allAdjustmentKinds.size(); ++index) + QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index])); + session.createDocument(4, 4); + session.insert(whiteImage(4, 4)); + QVERIFY(button.isEnabled()); + entries[1]->trigger(); + QCOMPARE(session.activeLayer().value().adjustment.value().kind, AdjustmentKind::levels); + // The panel's task opens the editor; the footer rests meanwhile. + QTRY_VERIFY(session.levels().has_value()); + QVERIFY(session.adjustmentOriginal() && !button.isEnabled()); + session.cancelLevels(); + QVERIFY(button.isEnabled() && !session.adjustmentEditingID()); + // A request made again begins again. + session.setAdjustmentEditingID(session.activeLayerID()); + QTRY_VERIFY(session.levels().has_value()); + session.commitLevels(); + QTRY_VERIFY(!session.adjustmentEditingID()); +} + +void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick() +{ + EditorSession session; + session.createDocument(40, 30); + session.insert(whiteImage(40, 30)); + const QUuid pixels = session.activeLayerID().value(); + session.addMask(); + std::vector<QUuid> ids; + for (const AdjustmentKind kind : allAdjustmentKinds) { + session.addAdjustment(kind); + session.setAdjustmentEditingID(std::nullopt); + ids.push_back(session.activeLayerID().value()); + } + session.addMask(); + NativeLayerList list(session); + list.resize(252, 500); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + // Top first: the last kind added heads the list. + QCOMPARE(list.cells().size(), size_t(7)); + for (size_t index = 0; index < allAdjustmentKinds.size(); ++index) { + LayerCell &row = *list.cells().at(allAdjustmentKinds.size() - 1 - index); + const QColor ink = row.palette().color(QPalette::WindowText); + QCOMPARE(row.findChild<QLabel *>("layerDimensions")->text(), QString("Adjustment · Double-click to edit")); + QCOMPARE(row.thumbnail().size(), QSize(36, 36)); + const QImage expected = LayerIcons::adjustmentThumbnail(allAdjustmentKinds[index], ink, 1).toImage(); + QCOMPARE(row.thumbnail().icon().pixmap(QSize(36, 36), 1).toImage().convertToFormat(expected.format()), expected); + } + // Each kind its own glyph, as Swift's symbols. + const std::pair<AdjustmentKind, LayerIcon> symbols[] = {{AdjustmentKind::hsv, LayerIcon::halfFilledCircle}, {AdjustmentKind::levels, LayerIcon::sliders}, + {AdjustmentKind::curves, LayerIcon::curvePath}, {AdjustmentKind::exposure, LayerIcon::plusMinusCircle}, + {AdjustmentKind::gradientMap, LayerIcon::paintPalette}, {AdjustmentKind::grain, LayerIcon::circleGrid}}; + QList<QImage> glyphs; + for (const auto &[kind, symbol] : symbols) { + QVERIFY(LayerIcons::symbol(kind) == symbol); + glyphs << LayerIcons::adjustmentThumbnail(kind, Qt::black, 1).toImage(); + } + for (qsizetype first = 0; first < glyphs.size(); ++first) { + for (qsizetype second = first + 1; second < glyphs.size(); ++second) + QVERIFY(glyphs[first] != glyphs[second]); + } + // The glyph is 1.2 times the rail's, centred: 21.6 points. + const QImage circle = glyphs.front(); + QVERIFY(inked(circle, 9, 18) && inked(circle, 26, 18) && !inked(circle, 5, 18) && !inked(circle, 30, 18)); + // The curve turns a quarter clockwise: its dot moves right. + const QImage curve = LayerIcons::adjustmentThumbnail(AdjustmentKind::curves, Qt::black, 1).toImage(); + const QImage upright = LayerIcons::pixmap(LayerIcon::curvePath, 36, Qt::black, 1).toImage(); + QVERIFY(inked(curve, 24, 11) && !inked(curve, 11, 11)); + QVERIFY(inked(upright, 7, 7) && !inked(upright, 29, 7)); + // The masks: an adjustment's shows no link, the pixels' does. + QVERIFY(list.cells().front()->findChild<QToolButton *>("maskLink")->isHidden()); + QVERIFY(!list.cells().back()->findChild<QToolButton *>("maskLink")->isHidden()); + // A double click on the glyph asks for its editor. + session.selectLayer(pixels); + LayerCell &levels = *list.cells().at(allAdjustmentKinds.size() - 2); + QTest::mouseDClick(&levels.thumbnail(), Qt::LeftButton); + QCOMPARE(session.activeLayerID(), std::optional(ids[1])); + QCOMPARE(session.adjustmentEditingID(), std::optional(ids[1])); + QVERIFY(!session.renamingLayerID()); + // While one is asked for, rows refuse the gesture. + QTest::mouseDClick(&levels, Qt::LeftButton, Qt::NoModifier, QPoint(levels.width() - 20, 20)); + QVERIFY(!session.renamingLayerID()); + // On the name it renames, as every layer. + session.setAdjustmentEditingID(std::nullopt); + QTest::mouseDClick(&levels, Qt::LeftButton, Qt::NoModifier, QPoint(levels.width() - 20, 20)); + QCOMPARE(session.renamingLayerID(), std::optional(ids[1])); + QVERIFY(!session.adjustmentEditingID()); + QTest::keyClick(levels.findChild<QLineEdit *>("layerNameEditor"), Qt::Key_Escape); + QVERIFY(!session.renamingLayerID()); +} + +void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments() +{ + Bar bar; + QAction &adjustments = bar.action("newAdjustmentLayer"), &edit = bar.action("editAdjustment"); + QCOMPARE(adjustments.text(), QString("New Adjustment Layer")); + QCOMPARE(edit.text(), QString("Edit Adjustment…")); + QVERIFY(!adjustments.isEnabled() && !edit.isEnabled()); + const std::pair<const char *, const char *> entries[] = {{"newHueSaturationAdjustment", "Hue/Saturation…"}, {"newLevelsAdjustment", "Levels…"}, + {"newCurvesAdjustment", "Curves…"}, {"newExposureAdjustment", "Exposure…"}, + {"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"}}; + const QList<QAction *> listed = adjustments.menu()->actions(); + QCOMPARE(listed.size(), qsizetype(std::size(entries))); + for (qsizetype index = 0; index < listed.size(); ++index) { + QCOMPARE(listed[index]->objectName(), QString(entries[index].first)); + QCOMPARE(listed[index]->text(), QString(entries[index].second)); + } + bar.session().createDocument(4, 4); + bar.session().insert(whiteImage(4, 4)); + const QUuid pixels = bar.session().activeLayerID().value(); + QVERIFY(adjustments.isEnabled() && !edit.isEnabled()); + bar.action("newCurvesAdjustment").trigger(); + const QUuid curves = bar.session().activeLayerID().value(); + // The window's panel opens the filter sheet on it. + QTRY_VERIFY(bar.session().filterEdit().has_value()); + QVERIFY(bar.session().filterEdit().value().kind == FilterKind::curves); + QVERIFY(!adjustments.isEnabled() && !edit.isEnabled()); + bar.session().cancelFilter(); + QVERIFY(adjustments.isEnabled() && edit.isEnabled()); + edit.trigger(); + QCOMPARE(bar.session().adjustmentEditingID(), std::optional(curves)); + QTRY_VERIFY(bar.session().filterEdit().has_value()); + bar.session().cancelFilter(); + bar.session().selectLayer(pixels); + QVERIFY(adjustments.isEnabled() && !edit.isEnabled()); +} + +void AdjustmentPanelTests::theSheetsSayTheyReadWhatLiesBeneath() +{ + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.createDocument(20, 20); + session.insert(whiteImage(20, 20)); + session.selectAll(); + for (const AdjustmentKind kind : {AdjustmentKind::levels, AdjustmentKind::hsv, AdjustmentKind::grain}) { + session.addAdjustment(kind); + QTRY_VERIFY(session.adjustmentOriginal().has_value()); + QTest::qWait(50); + const auto shown = [&](const char *words) { + for (QWidget *top : QApplication::topLevelWidgets()) { + if (top->isVisible() && shownLabel(*top, QString::fromUtf8(words))) + return true; + } + return false; + }; + // No selection limits a layer's settings. + QVERIFY(!shown("Limited to the selection")); + if (kind == AdjustmentKind::levels) + QVERIFY(shown("Underlying pixels · alpha-weighted histogram")); + session.finishAdjustmentEditing(false); + } +} + +QTEST_MAIN(AdjustmentPanelTests) +#include "AdjustmentPanelTests.moc" diff --git a/tests/AdjustmentSurfaceTests.cpp b/tests/AdjustmentSurfaceTests.cpp new file mode 100644 index 0000000..79dfabb --- /dev/null +++ b/tests/AdjustmentSurfaceTests.cpp @@ -0,0 +1,196 @@ +#include "Rendering/AdjustmentSurface.h" +#include "AddressSpaceLimit.h" +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include <QPixmap> +#include <QtTest> + +class AdjustmentSurfaceTests : public QObject { + Q_OBJECT +private slots: + void init(); + void theBodyDrawsInThePaintersOwnCoordinates(); + void onlyWhatThePainterShowsIsAllocated(); + void handBlendedModesWorkOnAnyDevice(); + void pastItsPixelBudgetNothingIsDrawn(); + void theSurfaceStartsClear(); + void theSurfaceComesBackUnderThePaintersOwnState(); + void aSurfaceThatCannotBeAllocatedIsLoggedAndNothingIsDrawn(); +}; + +void AdjustmentSurfaceTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void AdjustmentSurfaceTests::theBodyDrawsInThePaintersOwnCoordinates() +{ + const QImage image = noise(20, 12, 1, 200); + const LayerTransform transform{.origin = {3, 2}, .size = {20, 12}, .rotation = 20, .sampling = LayerSampling::smooth}; + const auto scene = [&](bool throughSurface) { + QImage surface = noise(64, 48, 2); + QPainter painter(&surface); + painter.translate(5.5, 4); + painter.scale(1.5, 1.5); + const auto body = [&](QPainter &target) { LayerRenderer::draw(image, transform, transform.center(), target, {.opacity = 0.8}); }; + if (throughSurface) + AdjustmentSurface::draw(painter, body); + else + body(painter); + painter.end(); + return surface; + }; + QCOMPARE(largestAnywhere(scene(false), scene(true)), 0); + QVERIFY(scene(true) != noise(64, 48, 2)); +} + +void AdjustmentSurfaceTests::onlyWhatThePainterShowsIsAllocated() +{ + QImage surface = BrushRaster::context(64, 48, false); + QPainter painter(&surface); + painter.scale(2, 2); + painter.setClipRect(QRectF(4, 6, 10, 5)); + QSize allocated; + QTransform placement; + AdjustmentSurface::draw(painter, [&](QPainter &target) { + allocated = QSize(target.device()->width(), target.device()->height()); + placement = target.deviceTransform(); + target.fillRect(QRectF(0, 0, 32, 24), QColor(0, 0, 255)); + }); + painter.end(); + QCOMPARE(allocated, QSize(20, 10)); + QCOMPARE(placement, QTransform::fromScale(2, 2) * QTransform::fromTranslate(-8, -12)); + QCOMPARE(surface.pixel(8, 12), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(27, 21), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(7, 12), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(28, 21), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(8, 22), qRgba(0, 0, 0, 0)); + + // A turned painter's box passes the device; the surface stays. + QImage turned = BrushRaster::context(40, 30, false); + QPainter rotated(&turned); + rotated.translate(20, 15); + rotated.rotate(30); + AdjustmentSurface::draw(rotated, [&](QPainter &target) { allocated = QSize(target.device()->width(), target.device()->height()); }); + QCOMPARE(allocated, QSize(40, 30)); +} + +void AdjustmentSurfaceTests::handBlendedModesWorkOnAnyDevice() +{ + const LayerTransform transform = placedAt({0, 0}, {8, 8}); + const auto body = [&](QPainter &target) { + LayerRenderer::draw(solid(8, 8, qRgba(200, 40, 40, 255)), transform, transform.center(), target); + LayerRenderer::draw(solid(8, 8, qRgba(20, 20, 220, 255)), transform, transform.center(), target, {.blendMode = LayerBlendMode::luminosity}); + }; + QImage expected = BrushRaster::context(8, 8, false); + { + QPainter painter(&expected); + body(painter); + } + // No pixmap reads back; a surface supplies the backdrop. + QPixmap pixmap(8, 8); + pixmap.fill(Qt::transparent); + { + QPainter painter(&pixmap); + QVERIFY_EXCEPTION_THROWN(body(painter), std::logic_error); + } + pixmap.fill(Qt::transparent); + { + QPainter painter(&pixmap); + AdjustmentSurface::draw(painter, body); + } + QCOMPARE(pixmap.toImage().convertToFormat(QImage::Format_RGBA8888_Premultiplied), expected); + QVERIFY(expected.pixel(4, 4) != qRgba(200, 40, 40, 255)); +} + +void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn() +{ + QImage surface = BrushRaster::context(8, 8, false); + QPainter painter(&surface); + // The budget counts device pixels: 64 here, not 16 units. + painter.scale(2, 2); + bool called = false; + QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)"); + AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 63); + QVERIFY(!called); + AdjustmentSurface::draw(painter, [&](QPainter &target) { + called = true; + target.fillRect(QRectF(0, 0, 4, 4), Qt::red); + }, 64); + painter.end(); + QVERIFY(called); + QCOMPARE(surface.pixel(7, 7), qRgba(255, 0, 0, 255)); +} + +void AdjustmentSurfaceTests::theSurfaceStartsClear() +{ + const QImage background = noise(64, 48, 3); + for (int round = 0; round < 8; ++round) { + // The next surface may get freed memory full of ones. + for (int dirty = 0; dirty < 4; ++dirty) { + QImage scratch(64, 48, QImage::Format_RGBA8888_Premultiplied); + scratch.fill(0xffffffff); + } + QImage surface = background; + QPainter painter(&surface); + AdjustmentSurface::draw(painter, [](QPainter &target) { target.fillRect(QRectF(0, 0, 8, 8), Qt::red); }); + painter.end(); + QCOMPARE(surface.pixel(4, 4), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.copy(8, 8, 56, 40), background.copy(8, 8, 56, 40)); + } +} + +void AdjustmentSurfaceTests::theSurfaceComesBackUnderThePaintersOwnState() +{ + const auto body = [](QPainter &target) { target.fillRect(QRectF(0, 0, 8, 6), QColor(128, 128, 128)); }; + // Opacity. + QImage faded = solid(8, 6, qRgba(200, 100, 50, 255)); + { + QPainter painter(&faded); + painter.setOpacity(0); + AdjustmentSurface::draw(painter, body); + QCOMPARE(painter.opacity(), 0.0); + } + QCOMPARE(faded, solid(8, 6, qRgba(200, 100, 50, 255))); + // Composition mode. + QImage multiplied = solid(8, 6, qRgba(200, 100, 50, 255)); + { + QPainter painter(&multiplied); + painter.setCompositionMode(QPainter::CompositionMode_Multiply); + AdjustmentSurface::draw(painter, body); + QCOMPARE(painter.compositionMode(), QPainter::CompositionMode_Multiply); + } + QCOMPARE(multiplied.pixel(7, 5), qRgba(100, 50, 25, 255)); + // A clip of two rectangles; the painter's transform stays. + QImage clipped = solid(8, 6, qRgba(200, 100, 50, 255)); + { + QPainter painter(&clipped); + painter.translate(1, 1); + painter.setClipRegion(QRegion(0, 0, 2, 2) + QRegion(4, 2, 2, 2)); + AdjustmentSurface::draw(painter, body); + QCOMPARE(painter.worldTransform(), QTransform::fromTranslate(1, 1)); + } + QCOMPARE(clipped.pixel(1, 1), qRgba(128, 128, 128, 255)); + QCOMPARE(clipped.pixel(5, 3), qRgba(128, 128, 128, 255)); + QCOMPARE(clipped.pixel(4, 2), qRgba(200, 100, 50, 255)); + QCOMPARE(clipped.pixel(0, 0), qRgba(200, 100, 50, 255)); +} + +void AdjustmentSurfaceTests::aSurfaceThatCannotBeAllocatedIsLoggedAndNothingIsDrawn() +{ + // The surface would need 256 MB. + QImage device(8192, 8192, QImage::Format_Alpha8); + device.fill(0); + QPainter painter(&device); + bool called = false; + std::optional<AddressSpaceLimit> limit(std::in_place, 96 * 1024 * 1024); + QTest::ignoreMessage(QtWarningMsg, "an adjustment surface could not be allocated: QSize(8192, 8192)"); + AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }); + limit.reset(); + painter.end(); + QVERIFY(!called); + QCOMPARE(int(device.constScanLine(100)[100]), 0); +} + +QTEST_MAIN(AdjustmentSurfaceTests) +#include "AdjustmentSurfaceTests.moc" diff --git a/tests/BlendModePickerTests.cpp b/tests/BlendModePickerTests.cpp new file mode 100644 index 0000000..f915a69 --- /dev/null +++ b/tests/BlendModePickerTests.cpp @@ -0,0 +1,160 @@ +#include "UI/BlendModePicker.h" +#include "UI/LayerMaskMenu.h" +#include <QAbstractItemView> +#include <QtTest> + +// The blend menu and the add-mask button follow the session. +namespace { +ImportedImage white() +{ + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + return ImportedImage(image, image, "White"); +} +} + +class BlendModePickerTests : public QObject { + Q_OBJECT +private slots: + void theMenuListsEveryModeAndFollowsTheActiveLayer(); + void hoveringTriesAModeOnAndChoosingKeepsIt(); + void aMenuClosedWithoutAChoiceLeavesTheLayerAlone(); + void theMaskButtonAddsAWhiteMaskOnce(); +}; + +void BlendModePickerTests::theMenuListsEveryModeAndFollowsTheActiveLayer() +{ + EditorSession session; + BlendModePicker picker(session); + QCOMPARE(picker.count(), int(allLayerBlendModes.size())); + QStringList titles; + for (int index = 0; index < picker.count(); ++index) + titles << picker.itemText(index); + QCOMPARE(titles.first(), QString("Normal")); + QCOMPARE(titles.last(), QString("Luminosity")); + QCOMPARE(titles[1], QString("Multiply")); + QVERIFY(!picker.isEnabled()); + session.createDocument(4, 4); + session.insert(white()); + QVERIFY(picker.isEnabled()); + QCOMPARE(picker.currentText(), QString("Normal")); + session.setLayerBlendMode(LayerBlendMode::screen); + QCOMPARE(picker.currentText(), QString("Screen")); + // A second layer's own mode; a folder disables the menu. + session.insert(white()); + session.setLayerBlendMode(LayerBlendMode::hue); + QCOMPARE(picker.currentText(), QString("Hue")); + session.selectLayer(session.document().value().layers.front().id); + QCOMPARE(picker.currentText(), QString("Screen")); + session.groupSelectedLayers(); + QVERIFY(!picker.isEnabled()); + QCOMPARE(picker.currentText(), QString("Normal")); +} + +void BlendModePickerTests::hoveringTriesAModeOnAndChoosingKeepsIt() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(white()); + const QUuid layer = session.activeLayerID().value(); + BlendModePicker picker(session); + picker.show(); + QVERIFY(QTest::qWaitForWindowExposed(&picker)); + picker.showPopup(); + QTRY_VERIFY(picker.view()->isVisible()); + // Hovering previews on the layer the menu opened for. + emit picker.highlighted(1); + QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::multiply); + QCOMPARE(session.blendPreview().value().layerID, layer); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal); + emit picker.highlighted(2); + QCOMPARE(session.blendPreview().value().mode, LayerBlendMode::screen); + // The session's news leaves an open menu alone. + picker.view()->setCurrentIndex(picker.model()->index(2, 0)); + session.setLayerOpacity(0.5); + QVERIFY(session.blendPreview().has_value()); + QCOMPARE(picker.view()->currentIndex().row(), 2); + // A click closes the menu, then chooses; preview clears after. + picker.hidePopup(); + emit picker.activated(2); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen); + QCOMPARE(picker.currentText(), QString("Screen")); + QCOMPARE(session.history.undoName(), QString("Layer Blend Mode")); + QTRY_VERIFY(!session.blendPreview().has_value()); + QCOMPARE(picker.currentText(), QString("Screen")); + // Keys on the closed menu choose for the active layer. + picker.setFocus(); + QTest::keyClick(&picker, Qt::Key_Down); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay); + QCOMPARE(picker.currentText(), QString("Overlay")); + // Closed, the menu follows the session again. + session.undo(); + QCOMPARE(picker.currentText(), QString("Screen")); +} + +void BlendModePickerTests::aMenuClosedWithoutAChoiceLeavesTheLayerAlone() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(white()); + session.setLayerBlendMode(LayerBlendMode::multiply); + session.insert(white()); + const QUuid top = session.activeLayerID().value(); + BlendModePicker picker(session); + picker.show(); + QVERIFY(QTest::qWaitForWindowExposed(&picker)); + picker.showPopup(); + emit picker.highlighted(4); + picker.hidePopup(); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal); + QTRY_VERIFY(!session.blendPreview().has_value()); + QCOMPARE(picker.currentText(), QString("Normal")); + // Another layer active at the choice: nothing changes. + picker.showPopup(); + emit picker.highlighted(4); + session.selectLayer(session.document().value().layers.front().id); + // Open, the menu keeps its text; closed, the active layer. + QCOMPARE(picker.currentText(), QString("Normal")); + picker.hidePopup(); + emit picker.activated(4); + QCOMPARE(session.document().value().layers.front().blendMode, LayerBlendMode::multiply); + QCOMPARE(session.document().value().layers.back().blendMode, LayerBlendMode::normal); + QCOMPARE(session.history.undoName(), QString("Import Image")); + QCOMPARE(picker.currentText(), QString("Multiply")); + QTRY_VERIFY(!session.blendPreview().has_value()); + session.selectLayer(top); + QCOMPARE(picker.currentText(), QString("Normal")); + // A cancelled menu keeps no target: keys choose. + picker.showPopup(); + picker.hidePopup(); + QCoreApplication::processEvents(); + session.selectLayer(session.document().value().layers.front().id); + picker.setFocus(); + QTest::keyClick(&picker, Qt::Key_Down); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::screen); +} + +void BlendModePickerTests::theMaskButtonAddsAWhiteMaskOnce() +{ + EditorSession session; + LayerMaskMenu button(session); + QCOMPARE(button.accessibleName(), QString("Add layer mask")); + QVERIFY(!button.isEnabled()); + session.createDocument(4, 4); + session.insert(white()); + QVERIFY(button.isEnabled()); + button.click(); + const ImageLayer layer = session.activeLayer().value(); + QVERIFY(layer.mask.has_value()); + QCOMPARE(layer.mask.value().asset.image().pixelColor(0, 0), QColor(255, 255, 255)); + QVERIFY(!button.isEnabled()); + session.undo(); + QVERIFY(!session.activeLayer().value().mask.has_value() && button.isEnabled()); + session.setIsProjectBusy(true); + QVERIFY(!button.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(button.isEnabled()); +} + +QTEST_MAIN(BlendModePickerTests) +#include "BlendModePickerTests.moc" diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp new file mode 100644 index 0000000..c4342d3 --- /dev/null +++ b/tests/BlendModeTests.cpp @@ -0,0 +1,281 @@ +#include "IO/ImageExporter.h" +#include "Rendering/HslBlend.h" +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include <QPixmap> +#include <QRandomGenerator> +#include "AddressSpaceLimit.h" +#include <QtTest> +#include <algorithm> +#include <array> + +namespace { +using Rgb = std::array<double, 3>; + + + + + +double lum(const Rgb &color) +{ + return 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]; +} + +Rgb clipColor(Rgb color) +{ + const double l = lum(color), low = *std::min_element(color.begin(), color.end()), + high = *std::max_element(color.begin(), color.end()); + for (double &channel : color) { + if (low < 0) + channel = l + (channel - l) * l / (l - low); + if (high > 1) + channel = l + (channel - l) * (1 - l) / (high - l); + } + return color; +} + +Rgb setLum(Rgb color, double l) +{ + const double shift = l - lum(color); + for (double &channel : color) + channel += shift; + return clipColor(color); +} + +double sat(const Rgb &color) +{ + return *std::max_element(color.begin(), color.end()) - *std::min_element(color.begin(), color.end()); +} + +Rgb setSat(Rgb color, double s) +{ + const auto [low, high] = std::minmax_element(color.begin(), color.end()); + const double floor = *low, ceiling = *high; + for (double &channel : color) + channel = ceiling > floor ? (channel - floor) * s / (ceiling - floor) : 0; + return color; +} + +// The PDF and W3C blend function B(backdrop, source) per mode. +Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source) +{ + Rgb result{}; + for (int channel = 0; channel < 3; ++channel) { + const double b = backdrop[channel], s = source[channel]; + const auto hardLight = [](double below, double above) { + return above <= 0.5 ? below * 2 * above : below + (2 * above - 1) - below * (2 * above - 1); + }; + switch (mode) { + case LayerBlendMode::normal: result[channel] = s; break; + case LayerBlendMode::multiply: result[channel] = b * s; break; + case LayerBlendMode::screen: result[channel] = b + s - b * s; break; + case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break; + case LayerBlendMode::darken: result[channel] = std::min(b, s); break; + case LayerBlendMode::lighten: result[channel] = std::max(b, s); break; + case LayerBlendMode::difference: result[channel] = std::abs(b - s); break; + case LayerBlendMode::colorDodge: result[channel] = b == 0 ? 0 : s >= 1 ? 1 : std::min(1.0, b / (1 - s)); break; + case LayerBlendMode::colorBurn: result[channel] = b >= 1 ? 1 : s <= 0 ? 0 : 1 - std::min(1.0, (1 - b) / s); break; + default: break; + } + } + switch (mode) { + case LayerBlendMode::hue: return setLum(setSat(source, sat(backdrop)), lum(backdrop)); + case LayerBlendMode::saturation: return setLum(setSat(backdrop, sat(source)), lum(backdrop)); + case LayerBlendMode::color: return setLum(source, lum(backdrop)); + case LayerBlendMode::luminosity: return setLum(backdrop, lum(source)); + default: return result; + } +} + +// Expected premultiplied bytes, from the premultiplied bytes drawn. +std::array<int, 4> composited(LayerBlendMode mode, QRgb backdropPixel, QRgb sourcePixel) +{ + const auto color = [](QRgb pixel) { + const double alpha = std::max(1, qAlpha(pixel)); + return Rgb{qRed(pixel) / alpha, qGreen(pixel) / alpha, qBlue(pixel) / alpha}; + }; + const Rgb backdrop = color(backdropPixel), source = color(sourcePixel); + const double backdropAlpha = qAlpha(backdropPixel) / 255.0, sourceAlpha = qAlpha(sourcePixel) / 255.0; + const Rgb both = reference(mode, backdrop, source); + std::array<int, 4> bytes{}; + for (int channel = 0; channel < 3; ++channel) { + const double value = source[channel] * sourceAlpha * (1 - backdropAlpha) + + backdrop[channel] * backdropAlpha * (1 - sourceAlpha) + both[channel] * sourceAlpha * backdropAlpha; + bytes[channel] = int(std::lround(value * 255)); + } + bytes[3] = int(std::lround((sourceAlpha + backdropAlpha - sourceAlpha * backdropAlpha) * 255)); + return bytes; +} + +QRgb premultiplied(const Rgb &color, double alpha) +{ + return qRgba(int(std::lround(color[0] * alpha * 255)), int(std::lround(color[1] * alpha * 255)), + int(std::lround(color[2] * alpha * 255)), int(std::lround(alpha * 255))); +} +} + +class BlendModeTests : public QObject { + Q_OBJECT +private slots: + void everyBlendModeMatchesThePdfFormulas_data(); + void everyBlendModeMatchesThePdfFormulas(); + void hslModesHonorLayerOpacity(); + void hslModesIgnoreThePaintersOwnOpacity(); + void hslResultsStayValidPremultipliedPixels(); + void hslModesRespectThePainterClip(); + void hslModesNeedAnImageBackedPainter(); + void hslBlendChecksItsInputs(); +}; + +void BlendModeTests::everyBlendModeMatchesThePdfFormulas_data() +{ + QTest::addColumn<LayerBlendMode>("mode"); + const std::pair<const char *, LayerBlendMode> modes[] = { + {"normal", LayerBlendMode::normal}, {"multiply", LayerBlendMode::multiply}, {"screen", LayerBlendMode::screen}, + {"overlay", LayerBlendMode::overlay}, {"darken", LayerBlendMode::darken}, {"lighten", LayerBlendMode::lighten}, + {"difference", LayerBlendMode::difference}, {"colorDodge", LayerBlendMode::colorDodge}, + {"colorBurn", LayerBlendMode::colorBurn}, {"hue", LayerBlendMode::hue}, {"saturation", LayerBlendMode::saturation}, + {"color", LayerBlendMode::color}, {"luminosity", LayerBlendMode::luminosity}}; + for (const auto &[name, mode] : modes) + QTest::newRow(name) << mode; +} + +void BlendModeTests::everyBlendModeMatchesThePdfFormulas() +{ + QFETCH(LayerBlendMode, mode); + const Rgb colors[] = {{0.9, 0.2, 0.4}, {0.1, 0.6, 0.3}, {0.5, 0.5, 0.5}, {1, 1, 1}, {0, 0, 0}, {0.25, 0.75, 1}}; + const double alphas[] = {1, 0.6, 0.2, 0}; + const LayerTransform transform = placedAt({0, 0}, {2, 2}); + for (const Rgb &backdrop : colors) { + for (const Rgb &source : colors) { + for (double backdropAlpha : alphas) { + for (double sourceAlpha : alphas) { + const QRgb below = premultiplied(backdrop, backdropAlpha), above = premultiplied(source, sourceAlpha); + QImage surface = solid(2, 2, below); + QPainter painter(&surface); + LayerRenderer::draw(solid(2, 2, above), transform, transform.center(), painter, {.blendMode = mode}); + painter.end(); + const std::array<int, 4> expected = composited(mode, below, above); + const uchar *pixel = surface.constScanLine(1) + 4; + for (int channel = 0; channel < 4; ++channel) { + QVERIFY2(std::abs(pixel[channel] - expected[channel]) <= 2, + qPrintable(QString("channel %1: %2 not %3, backdrop alpha %4, source alpha %5") + .arg(channel).arg(pixel[channel]).arg(expected[channel]) + .arg(backdropAlpha).arg(sourceAlpha))); + } + } + } + } + } +} + +void BlendModeTests::hslModesHonorLayerOpacity() +{ + const QRgb below = qRgba(200, 40, 40, 255), above = qRgba(30, 90, 220, 255); + const LayerTransform transform = placedAt({0, 0}, {2, 2}); + for (LayerBlendMode mode : {LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color, LayerBlendMode::luminosity}) { + QImage surface = solid(2, 2, below); + QPainter painter(&surface); + LayerRenderer::draw(solid(2, 2, above), transform, transform.center(), painter, {.opacity = 0.4, .blendMode = mode}); + painter.end(); + const std::array<int, 4> expected = composited(mode, below, qRgba(12, 36, 88, 102)); + const uchar *pixel = surface.constScanLine(0); + for (int channel = 0; channel < 4; ++channel) + QVERIFY(std::abs(pixel[channel] - expected[channel]) <= 2); + QVERIFY(surface.pixel(0, 0) != below); + } +} + +void BlendModeTests::hslModesRespectThePainterClip() +{ + QImage surface = solid(8, 8, qRgba(200, 40, 40, 255)); + QPainter painter(&surface); + painter.setClipRect(QRect(0, 0, 4, 8)); + const LayerTransform transform = placedAt({0, 0}, {8, 8}); + LayerRenderer::draw(solid(8, 8, qRgba(40, 40, 200, 255)), transform, transform.center(), painter, + {.blendMode = LayerBlendMode::color}); + painter.end(); + QVERIFY(surface.pixel(1, 1) != qRgba(200, 40, 40, 255)); + QCOMPARE(surface.pixel(5, 5), qRgba(200, 40, 40, 255)); +} + +void BlendModeTests::hslModesNeedAnImageBackedPainter() +{ + QPixmap pixmap(4, 4); + QPainter painter(&pixmap); + const LayerTransform transform = placedAt({0, 0}, {4, 4}); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(solid(4, 4, qRgba(9, 9, 9, 255)), transform, + transform.center(), painter, + {.blendMode = LayerBlendMode::luminosity})); +} + +void BlendModeTests::hslBlendChecksItsInputs() +{ + const QImage color = solid(2, 2, qRgba(9, 9, 9, 255)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, HslBlend::blend(color, solid(3, 2, qRgba(9, 9, 9, 255)), LayerBlendMode::hue, 1)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, HslBlend::blend(color.convertToFormat(QImage::Format_ARGB32), color, LayerBlendMode::hue, 1)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, HslBlend::blend(color, color.convertToFormat(QImage::Format_ARGB32), LayerBlendMode::hue, 1)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, HslBlend::blend(color, color, LayerBlendMode::multiply, 1)); + QVERIFY(HslBlend::handles(LayerBlendMode::hue) && HslBlend::handles(LayerBlendMode::saturation)); + QVERIFY(HslBlend::handles(LayerBlendMode::color) && HslBlend::handles(LayerBlendMode::luminosity)); + QVERIFY(!HslBlend::handles(LayerBlendMode::normal) && !HslBlend::handles(LayerBlendMode::colorBurn)); + + const QImage large(8192, 8192, QImage::Format_RGBA8888_Premultiplied), other(8192, 8192, QImage::Format_RGBA8888_Premultiplied); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + std::optional<ExportError::Kind> thrown; + try { + HslBlend::blend(large, other, LayerBlendMode::hue, 1); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); +} + +void BlendModeTests::hslModesIgnoreThePaintersOwnOpacity() +{ + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + painter.setOpacity(0.25); + const LayerTransform transform = placedAt({0, 0}, {2, 2}); + LayerRenderer::draw(solid(2, 2, qRgba(30, 90, 220, 255)), transform, transform.center(), painter, + {.blendMode = LayerBlendMode::color}); + QCOMPARE(painter.opacity(), 0.25); + painter.end(); + QCOMPARE(surface.pixel(0, 0), qRgba(30, 90, 220, 255)); +} + +void BlendModeTests::hslResultsStayValidPremultipliedPixels() +{ + const QImage worst = HslBlend::blend(solid(1, 1, qRgba(30, 30, 30, 30)), solid(1, 1, qRgba(17, 17, 17, 17)), + LayerBlendMode::hue, 0.9); + const uchar *pixel = worst.constScanLine(0); + QVERIFY(pixel[0] <= pixel[3] && pixel[1] <= pixel[3] && pixel[2] <= pixel[3]); + QCOMPARE(pixel[3], uchar(43)); + + QRandomGenerator random(7); + QImage backdrop = BrushRaster::context(64, 64, false), source = backdrop; + for (QImage *image : {&backdrop, &source}) { + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) { + const int alpha = random.bounded(256); + image->setPixel(x, y, qRgba(random.bounded(alpha + 1), random.bounded(alpha + 1), random.bounded(alpha + 1), alpha)); + } + } + } + for (LayerBlendMode mode : {LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color, LayerBlendMode::luminosity}) { + for (double opacity : {1.0, 0.9, 0.37}) { + const QImage blended = HslBlend::blend(backdrop, source, mode, opacity); + int invalid = 0; + for (int y = 0; y < 64; ++y) { + const uchar *row = blended.constScanLine(y); + for (int x = 0; x < 64; ++x, row += 4) + invalid += row[0] > row[3] || row[1] > row[3] || row[2] > row[3]; + } + QCOMPARE(invalid, 0); + } + } +} + +QTEST_MAIN(BlendModeTests) +#include "BlendModeTests.moc" diff --git a/tests/BlendShortcutTests.cpp b/tests/BlendShortcutTests.cpp new file mode 100644 index 0000000..9118b39 --- /dev/null +++ b/tests/BlendShortcutTests.cpp @@ -0,0 +1,52 @@ +#include "Rendering/EditorCanvas.h" +#include "UI/NativeLayerList.h" +#include <QLineEdit> +#include <QtTest> + +// Shift-+ and Shift-− step the blend mode wherever focus sits. +class BlendShortcutTests : public QObject { + Q_OBJECT +private slots: + void shiftPlusAndMinusStepTheActiveLayersBlendModeWhereverFocusIsExceptTextFields(); +}; + +void BlendShortcutTests::shiftPlusAndMinusStepTheActiveLayersBlendModeWhereverFocusIsExceptTextFields() +{ + EditorSession session; + session.createDocument(40, 20); + session.addBlankLayer(); + QWidget window; + auto *canvas = new CanvasView(session, &window); + canvas->setGeometry(0, 0, 300, 200); + auto *list = new NativeLayerList(session, &window); + list->setGeometry(300, 0, 200, 200); + auto *field = new QLineEdit(&window); + field->setGeometry(0, 210, 100, 22); + window.resize(500, 240); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + const auto mode = [&] { return session.activeLayer().value().blendMode; }; + session.selectTool(NavigationTool::brush); + canvas->setFocus(); + QTRY_VERIFY(canvas->hasFocus()); + QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::multiply); + // From the list, back past Normal to the last mode. + session.selectTool(NavigationTool::lasso); + list->setFocus(); + QTRY_VERIFY(list->hasFocus()); + QTest::keyClick(list, Qt::Key_Underscore, Qt::ShiftModifier); + QTest::keyClick(list, Qt::Key_Underscore, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + session.undo(); + QCOMPARE(mode(), LayerBlendMode::normal); + // Typing in a text field keeps its characters. + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + QTest::keyClick(field, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::normal); + QCOMPARE(field->text(), QString("+")); +} + +QTEST_MAIN(BlendShortcutTests) +#include "BlendShortcutTests.moc" diff --git a/tests/BlurToolTests.cpp b/tests/BlurToolTests.cpp new file mode 100644 index 0000000..7119ad0 --- /dev/null +++ b/tests/BlurToolTests.cpp @@ -0,0 +1,302 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include <cmath> + +// Swift's BlurTool and Core Image's Gaussian, twinned. +namespace { +// Normalised bell weights, three sigmas each way. +std::vector<double> bell(double sigma) +{ + const int reach = int(std::ceil(3 * sigma)); + std::vector<double> weights; + double total = 0; + for (int i = 0; i <= reach; ++i) { + weights.push_back(std::exp(-double(i * i) / (2 * sigma * sigma))); + total += i == 0 ? weights.back() : 2 * weights.back(); + } + for (double &weight : weights) + weight /= total; + return weights; +} + +std::vector<int> exported(const EditorSession &session, int x, int y) +{ + return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y); +} + +// Red left of x 30, blue right: a hard edge. +QImage edge(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 30, height), QColor(255, 0, 0)); + painter.fillRect(QRect(30, 0, width - 30, height), QColor(0, 0, 255)); + return image; +} + +void blurring(EditorSession &session, const BrushSettings &settings) +{ + session.createDocument(60, 40); + const QImage image = edge(60, 40); + session.insert(ImportedImage(image, image, QStringLiteral("Edge"))); + session.selectTool(NavigationTool::blur); + session.setBlurMode(BlurToolMode::blur); + session.setBrushSettings(settings); +} +} + +class BlurToolTests : public QObject { + Q_OBJECT +private slots: + void aPointSpreadsAlongTheBellCurve(); + void clearEdgesFadeAndClampedEdgesHold(); + void grayBlursAndBadInputIsRefused(); + void theSampleFollowsTheBrushSize(); + void aMaskSampleKeepsItsEdgeToneAndPlacement(); + void aBlurStrokeSoftensUnderTheBrushAsOneStep(); + void aBlurStrokeSoftensAMask(); + void aMaskBetweenPixelsTakesTheFraction(); + void aMaskPastTheCanvasEdgeKeepsItsTone(); +}; + +void BlurToolTests::aPointSpreadsAlongTheBellCurve() +{ + QImage point = BrushRaster::context(41, 41, false); + point.setPixelColor(20, 20, QColor(255, 255, 255)); + const QImage soft = PixelAdjust::gaussianBlur(point, 2, false); + const std::vector<double> weights = bell(2); + for (const auto &[dx, dy] : {std::pair(0, 0), std::pair(1, 0), std::pair(0, 3), std::pair(2, 2), std::pair(-4, 1), std::pair(6, 0), std::pair(0, -6)}) { + const int expected = int(std::lround(255 * weights[size_t(std::abs(dx))] * weights[size_t(std::abs(dy))])); + const std::vector<int> found = pixel(soft, 20 + dx, 20 + dy); + QVERIFY2(std::abs(found[3] - expected) <= 1 && found[0] == found[3], qPrintable(QString("%1,%2: %3 for %4").arg(dx).arg(dy).arg(found[3]).arg(expected))); + } + // Past three sigmas nothing arrives. + QCOMPARE(pixel(soft, 27, 20)[3], 0); + QCOMPARE(pixel(soft, 20, 13)[3], 0); + // A line keeps the far taps a point loses. + QImage line = BrushRaster::context(41, 41, false); + QPainter(&line).fillRect(QRect(20, 0, 1, 41), Qt::white); + const QImage spread = PixelAdjust::gaussianBlur(line, 2, true); + for (int dx = 0; dx <= 7; ++dx) + QCOMPARE(pixel(spread, 20 + dx, 20)[3], int(std::lround(255 * (dx < int(weights.size()) ? weights[size_t(dx)] : 0.0)))); + QCOMPARE(pixel(spread, 25, 20)[3], 2); + QCOMPARE(pixel(spread, 26, 20)[3], 1); + // What spreads is what was there. + long total = 0; + for (int y = 0; y < 41; ++y) + for (int x = 0; x < 41; ++x) + total += pixel(soft, x, y)[3]; + QVERIFY2(std::abs(total - 255) < 40, qPrintable(QString::number(total))); +} + +void BlurToolTests::clearEdgesFadeAndClampedEdgesHold() +{ + QImage red = BrushRaster::context(30, 30, false); + red.fill(QColor(255, 0, 0)); + // Past the edge: clear blurs in, unless clamped to it. + const QImage faded = PixelAdjust::gaussianBlur(red, 3, false); + const std::vector<double> weights = bell(3); + double half = weights[0]; + for (size_t i = 1; i < weights.size(); ++i) + half += weights[i]; + QVERIFY(std::abs(pixel(faded, 0, 0)[3] - int(std::lround(255 * half * half))) <= 1); + QVERIFY(std::abs(pixel(faded, 0, 15)[3] - int(std::lround(255 * half))) <= 1); + QCOMPARE(pixel(faded, 15, 15), (std::vector<int>{255, 0, 0, 255})); + const QImage held = PixelAdjust::gaussianBlur(red, 3, true); + QCOMPARE(held, red); +} + +void BlurToolTests::grayBlursAndBadInputIsRefused() +{ + QImage step(20, 10, QImage::Format_Grayscale8); + for (int y = 0; y < 10; ++y) + for (int x = 0; x < 20; ++x) + step.scanLine(y)[x] = x < 10 ? 0 : 255; + const QImage soft = PixelAdjust::gaussianBlur(step, 1.5, true); + QCOMPARE(soft.format(), QImage::Format_Grayscale8); + // Every row softens alike; clamped, the edges hold. + QCOMPARE(int(soft.constScanLine(0)[0]), 0); + QCOMPARE(int(soft.constScanLine(9)[19]), 255); + QCOMPARE(int(soft.constScanLine(0)[9]), int(soft.constScanLine(9)[9])); + QVERIFY(soft.constScanLine(5)[9] > 40 && soft.constScanLine(5)[9] < 128 && soft.constScanLine(5)[10] > 128); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step, 0, true)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step, std::nan(""), true)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true)); +} + +void BlurToolTests::theSampleFollowsTheBrushSize() +{ + EditorSession session; + blurring(session, BrushSettings{.diameter = 60}); + const CanvasDocument document = session.document().value(); + const QImage sharp = edge(60, 40); + // A tenth of the size, within 1.5 and 30. + for (const auto &[diameter, sigma] : {std::pair(60.0, 6.0), std::pair(5.0, 1.5), std::pair(900.0, 30.0)}) { + BrushSettings settings = session.brushSettings(); + settings.diameter = diameter; + session.setBrushSettings(settings); + QCOMPARE(session.blurSample(document).value(), PixelAdjust::gaussianBlur(sharp, sigma, false)); + } + // No layer, nothing to soften. + session.selectLayer(std::nullopt); + QVERIFY(!session.blurSample(document)); + QVERIFY(!session.blurSample(document, true)); +} + +void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() +{ + EditorSession session; + blurring(session, BrushSettings{.diameter = 20}); + const QUuid id = session.activeLayerID().value(); + // A white mask with a black bar, placed mid-layer. + QImage mask(20, 20, QImage::Format_Grayscale8); + mask.fill(255); + for (int y = 0; y < 20; ++y) + for (int x = 8; x < 12; ++x) + mask.scanLine(y)[x] = 0; + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(mask)); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}}; + record(snapshot, id).maskLinked = false; + }); + const QImage sample = session.blurSample(session.document().value(), true).value(); + QCOMPARE(sample.format(), QImage::Format_Grayscale8); + QImage expected(60, 40, QImage::Format_Grayscale8); + // Past its pixels the mask keeps its edge's tone: white. + expected.fill(255); + QPainter painter(&expected); + painter.drawImage(QRectF(20, 10, 20, 20), mask); + painter.end(); + QCOMPARE(sample, PixelAdjust::gaussianBlur(expected, 2, true)); + QVERIFY(sample.constScanLine(20)[30] < 128 && sample.constScanLine(2)[2] == 255); + // Turned, the black box turns with the mask. + EditorSession turned; + turned.createDocument(100, 60); + const QImage wide = edge(100, 60); + turned.insert(ImportedImage(wide, wide, QStringLiteral("Edge"))); + const QUuid wideID = turned.activeLayerID().value(); + QImage white(40, 40, QImage::Format_Grayscale8); + white.fill(255); + rewrite(turned, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, wideID, LayerMask::assetFrom(white)); + record(snapshot, wideID).maskPlacement = LayerTransform{.origin = {30, 10}, .size = {40, 40}, .rotation = 30}; + record(snapshot, wideID).maskLinked = false; + }); + turned.setBrushSettings(BrushSettings{.diameter = 5}); + const QImage soft = turned.blurSample(turned.document().value(), true).value(); + // Past the true box, inside one turned the other way. + QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73]))); + QVERIFY2(soft.constScanLine(24)[27] > 240, qPrintable(QString::number(soft.constScanLine(24)[27]))); +} + +void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep() +{ + EditorSession session; + blurring(session, BrushSettings{.diameter = 20, .hardness = 1}); + const int steps = session.history.undoCount(); + session.beginBrush(QPointF(30, 12)); + QVERIFY(session.brushStroke() && session.brushStroke()->isBlur && !session.warpStroke()); + session.continueBrush(QPointF(30, 20)); + session.finishBrush(); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Blur")); + // Under the brush, the sample's edge; away, still hard. + const QImage soft = PixelAdjust::gaussianBlur(edge(60, 40), 2, false); + QCOMPARE(exported(session, 29, 16), pixel(soft, 29, 16)); + QCOMPARE(exported(session, 30, 16), pixel(soft, 30, 16)); + QVERIFY(exported(session, 29, 16)[2] > 20); + QCOMPARE(exported(session, 29, 36), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(exported(session, 30, 36), (std::vector<int>{0, 0, 255, 255})); + // At half strength, halfway between sharp and soft. + session.undo(); + BrushSettings half = session.brushSettings(); + half.opacity = 0.5; + session.setBrushSettings(half); + session.beginBrush(QPointF(30, 12)); + session.continueBrush(QPointF(30, 20)); + session.finishBrush(); + const int blended = exported(session, 29, 16)[2]; + QVERIFY2(std::abs(blended - pixel(soft, 29, 16)[2] / 2) <= 1, qPrintable(QString::number(blended))); +} + +void BlurToolTests::aBlurStrokeSoftensAMask() +{ + EditorSession session; + blurring(session, BrushSettings{.diameter = 20, .hardness = 1}); + const QUuid id = session.activeLayerID().value(); + QImage mask(60, 40, QImage::Format_Grayscale8); + for (int y = 0; y < 40; ++y) + for (int x = 0; x < 60; ++x) + mask.scanLine(y)[x] = x < 30 ? 0 : 255; + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); }); + session.selectLayerTarget(id, true); + session.selectTool(NavigationTool::blur); + session.beginBrush(QPointF(30, 12)); + session.continueBrush(QPointF(30, 20)); + session.finishBrush(); + QCOMPARE(session.history.undoName(), QString("Paint Mask")); + const QImage painted = layerWith(session, id).mask.value().asset.image(); + const QImage soft = PixelAdjust::gaussianBlur(mask, 2, true); + QCOMPARE(int(painted.constScanLine(16)[29]), int(soft.constScanLine(16)[29])); + QCOMPARE(int(painted.constScanLine(16)[30]), int(soft.constScanLine(16)[30])); + QVERIFY(painted.constScanLine(16)[29] > 20 && painted.constScanLine(16)[30] < 235); + QCOMPARE(int(painted.constScanLine(36)[29]), 0); +} + +void BlurToolTests::aMaskBetweenPixelsTakesTheFraction() +{ + // Half a pixel right: the mask's grid falls between. + ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20)); + layer.transform.origin = QPointF(0.5, 0); + QImage clear(40, 20, QImage::Format_Grayscale8); + clear.fill(0); + layer.mask = LayerMask(LayerMask::assetFrom(clear)); + BrushSettings settings = brush(20, 1, 1, 1, 1); + BrushStroke paint(layer, true, settings, QSizeF(80, 20)); + QImage steps(80, 20, QImage::Format_Grayscale8); + for (int y = 0; y < 20; ++y) + for (int x = 0; x < 80; ++x) + steps.scanLine(y)[x] = x < 21 ? 0 : 200; + paint.clone = BrushStroke::Clone{steps, QSizeF(0, 0)}; + paint.isBlur = true; + paint.append(QPointF(21, 10)); + const QImage tile = paint.patches().at(0).image; + QCOMPARE(tile.format(), QImage::Format_Grayscale8); + // Mask pixel 20 spans document 20.5 to 21.5. + QCOMPARE(int(tile.constScanLine(10)[19]), 0); + QCOMPARE(int(tile.constScanLine(10)[20]), 100); + QCOMPARE(int(tile.constScanLine(10)[21]), 200); +} + +void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone() +{ + // Half a pixel past the left edge: half covered. + ImageLayer layer(QStringLiteral("Blank"), QSizeF(20, 20)); + layer.transform.origin = QPointF(-0.5, 0); + QImage white(20, 20, QImage::Format_Grayscale8); + white.fill(255); + layer.mask = LayerMask(LayerMask::assetFrom(white)); + BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); + paint.clone = BrushStroke::Clone{white, QSizeF(0, 0)}; + paint.isBlur = true; + paint.append(QPointF(0, 10)); + const QImage tile = paint.patches().at(0).image; + QCOMPARE(int(tile.constScanLine(10)[0]), 255); + QCOMPARE(int(tile.constScanLine(10)[1]), 255); + // Over a black mask, the covered half alone turns white. + QImage black(20, 20, QImage::Format_Grayscale8); + black.fill(0); + layer.mask = LayerMask(LayerMask::assetFrom(black)); + BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); + over.clone = BrushStroke::Clone{white, QSizeF(0, 0)}; + over.isBlur = true; + over.append(QPointF(0, 10)); + QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128); + QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[1]), 255); +} + +QTEST_GUILESS_MAIN(BlurToolTests) +#include "BlurToolTests.moc" diff --git a/tests/BrushCanvasFixtures.h b/tests/BrushCanvasFixtures.h new file mode 100644 index 0000000..d451e14 --- /dev/null +++ b/tests/BrushCanvasFixtures.h @@ -0,0 +1,22 @@ +#pragma once +#include "IO/ImageExporter.h" +#include "SelectionCanvasFixtures.h" + +// Shared by the brush's canvas tests: pixels, a red brush. +inline std::vector<int> pixel(const EditorSession &session, int x, int y) +{ + const QImage image = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = image.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +// A blank layer, and a hard 10 px red brush. +inline void red(Canvas &shown) +{ + shown.session.addBlankLayer(); + shown.session.selectTool(NavigationTool::brush); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 10; + settings.red = 1; + shown.session.setBrushSettings(settings); +} diff --git a/tests/BrushCanvasTests.cpp b/tests/BrushCanvasTests.cpp new file mode 100644 index 0000000..0e40cdd --- /dev/null +++ b/tests/BrushCanvasTests.cpp @@ -0,0 +1,279 @@ +#include "BrushFixtures.h" +#include "CanvasFixtures.h" +#include "SelectionCanvasFixtures.h" +#include "SelectionFixtures.h" + +// Painting on the canvas: nothing away from the brush moves. +namespace { +struct Shift { + int start; + int finish; + bool committed; +}; + +// Swift's canvasShift: a big photo scaled down, painted mid. +Shift canvasShift(bool paintingMask, double layerScale = 0.25) +{ + Shown shown(QSize(800, 600), QSize(800, 600)); + EditorSession &session = shown.session; + const QImage photo = noise(2400, 1800, 3); + session.insert(ImportedImage(photo, photo, "Photo")); + const QUuid id = session.activeLayerID().value(); + LayerTransform transform{.origin = {100, 75}, .size = {2400 * layerScale, 1800 * layerScale}}; + transform.sampling = LayerSampling::high; + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = transform; }); + session.selectLayer(id); + if (paintingMask) { + session.addLayerMask(); + if (!session.isMaskSelected()) + throw std::runtime_error("the mask was not targeted"); + } + shown.settle(); + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 30; + session.setBrushSettings(settings); + const auto snapshot = [&] { + shown.canvas->synchronizeDisplay(); + return shown.canvas->grab().toImage().convertToFormat(QImage::Format_RGBA8888); + }; + // The brushed area in view pixels, grown generously. + const QPointF brush = transform.center(); + const QSizeF size = session.document().value().size(); + const QPointF topLeft = session.viewport.viewPoint(QPointF(brush.x() - 60, brush.y() - 40), size); + const QPointF bottomRight = session.viewport.viewPoint(QPointF(brush.x() + 80, brush.y() + 40), size); + const auto largestDifference = [&](const QImage &a, const QImage &b) { + const double perPoint = double(a.width()) / shown.canvas->width(); + const QRectF box(topLeft.x() * perPoint, topLeft.y() * perPoint, (bottomRight.x() - topLeft.x()) * perPoint, (bottomRight.y() - topLeft.y()) * perPoint); + int largest = 0; + for (int y = 0; y < a.height(); ++y) { + const uchar *rowA = a.constScanLine(y), *rowB = b.constScanLine(y); + for (int x = 0; x < a.width(); ++x) { + if (box.contains(QPointF(x, y))) + continue; + for (int c = 0; c < 4; ++c) + largest = std::max(largest, std::abs(int(rowA[x * 4 + c]) - int(rowB[x * 4 + c]))); + } + } + return largest; + }; + const QImage before = snapshot(); + session.beginBrush(brush); + session.continueBrush(QPointF(brush.x() + 20, brush.y())); + const QImage during = snapshot(); + session.finishBrushImmediately(); + const QImage after = snapshot(); + const ImageLayer layer = session.activeLayer().value(); + const bool committed = paintingMask ? layer.mask.value().asset.raster != nullptr : layer.asset.value().raster != nullptr; + return {largestDifference(before, during), largestDifference(during, after), committed}; +} +} + +class BrushCanvasTests : public QObject { + Q_OBJECT +private slots: + void paintingAScaledDownLayerDoesNotShiftItsPixels(); + void paintingAScaledDownLayersMaskDoesNotShiftItsPixels(); + void theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes(); + void aPlacedMaskStrokeShowsTheLayerThroughItsPreview(); + void aStrokeStepRepaintsWhereItWentAlone(); + void paintingAFolderMaskClipsItsLayersLive(); + void aStripedFolderMaskKeepsItsShadeWhilePainted(); +}; + +void BrushCanvasTests::paintingAScaledDownLayerDoesNotShiftItsPixels() +{ + const Shift shift = canvasShift(false); + QVERIFY2(shift.committed, "the stroke was committed"); + QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint changed pixels away from the brush by %1").arg(shift.start))); + QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the stroke changed pixels away from the brush by %1").arg(shift.finish))); +} + +void BrushCanvasTests::paintingAScaledDownLayersMaskDoesNotShiftItsPixels() +{ + const Shift shift = canvasShift(true); + QVERIFY2(shift.committed, "the mask stroke was committed"); + QVERIFY2(shift.start <= 3, qPrintable(QStringLiteral("starting to paint the mask changed pixels away from the brush by %1").arg(shift.start))); + QVERIFY2(shift.finish <= 3, qPrintable(QStringLiteral("finishing the mask stroke changed pixels away from the brush by %1").arg(shift.finish))); +} + +void BrushCanvasTests::theCanvasRepaintsEveryStrokeStepAndDeleteClearsOrDeletes() +{ + Shown shown(QSize(400, 300), QSize(400, 300)); + EditorSession &session = shown.session; + QImage blue = BrushRaster::context(400, 300, false); + blue.fill(Qt::blue); + session.insert(ImportedImage(blue, blue, "Blue")); + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(20, 1, 1, 0, 0)); + shown.canvas->synchronizeDisplay(); + // Each step of a stroke is a new display state. + session.beginBrush(QPointF(50, 50)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(50, 50), QColor(Qt::red)); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::blue)); + session.continueBrush(QPointF(200, 50)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(!shown.canvas->synchronizeDisplay()); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red)); + session.finishBrush(); + QVERIFY(shown.canvas->synchronizeDisplay()); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(200, 50), QColor(Qt::red)); + // Delete clears a selection; without one it deletes the layer. + session.selectTool(NavigationTool::marquee); + session.applySelection(rectPath(QRectF(0, 0, 100, 300)), SelectionMode::replace, "Select"); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Delete); + QTRY_COMPARE(session.history.undoName(), QString("Clear")); + session.deselect(); + const int layers = int(session.document().value().layers.size()); + QTest::keyClick(shown.canvas, Qt::Key_Backspace); + QCOMPARE(int(session.document().value().layers.size()), layers - 1); + // With Ctrl or Alt the key is not Delete's. + session.undo(); + QTest::keyClick(shown.canvas, Qt::Key_Delete, Qt::ControlModifier); + QCOMPARE(int(session.document().value().layers.size()), layers); +} + +void BrushCanvasTests::aPlacedMaskStrokeShowsTheLayerThroughItsPreview() +{ + Shown shown(QSize(200, 200), QSize(200, 200)); + EditorSession &session = shown.session; + QImage blue = BrushRaster::context(64, 64, false); + blue.fill(Qt::blue); + session.insert(ImportedImage(blue, blue, "Blue"), QPointF(96, 56)); + const QUuid id = session.activeLayerID().value(); + // A black 16 px mask at 32 from (80,40): hidden. + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(BrushRaster::context(16, 16, true))); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {80, 40}, .size = {32, 32}}; + record(snapshot, id).maskLinked = false; + }); + session.selectLayerTarget(id, true); + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(8, 1, 1, 1, 1)); + session.setMaskPaintWhite(true); + shown.canvas->synchronizeDisplay(); + QVERIFY(shown.canvas->grab().toImage().pixelColor(88, 48) != QColor(Qt::blue)); + session.beginBrush(QPointF(88, 48)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue)); + QVERIFY(shown.canvas->grab().toImage().pixelColor(70, 70) != QColor(Qt::blue)); + session.finishBrush(); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(88, 48), QColor(Qt::blue)); +} + +void BrushCanvasTests::aStrokeStepRepaintsWhereItWentAlone() +{ + Shown shown(QSize(400, 300), QSize(400, 300)); + EditorSession &session = shown.session; + session.addBlankLayer(); + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(20, 1, 1, 0, 0)); + shown.canvas->synchronizeDisplay(); + QTest::qWait(50); + PaintSpy spy(*shown.canvas); + // The first tile alone, two points out: not everything. + session.beginBrush(QPointF(50, 50)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QTRY_VERIFY(spy.count > 0); + QCOMPARE(spy.painted, QRect(0, 0, 258, 258)); + spy.painted = QRect(); + spy.count = 0; + // Into the next tile: both, still not everything. + session.continueBrush(QPointF(300, 50)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QTRY_VERIFY(spy.count > 0); + QCOMPARE(spy.painted, QRect(0, 0, 400, 258)); + session.cancelBrush(); +} + +void BrushCanvasTests::paintingAFolderMaskClipsItsLayersLive() +{ + Shown shown(QSize(300, 300), QSize(300, 300)); + EditorSession &session = shown.session; + QImage red = BrushRaster::context(100, 100, false); + red.fill(Qt::red); + session.insert(ImportedImage(red, red, "Red"), QPointF(100, 100)); + session.groupSelectedLayers(); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected() && session.activeLayer().value().isGroup); + shown.settle(); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(20, 1, 0, 0, 0)); + session.setMaskPaintWhite(false); + for (double zoom : {1.0, 3.0}) { + session.zoom(zoom); + shown.canvas->synchronizeDisplay(); + // Both points on screen at 3x: the view spans 100–200. + const QSizeF size = session.document().value().size(); + const QPoint centre = session.viewport.viewPoint(QPointF(125, 125), size).toPoint(); + const QPoint edge = session.viewport.viewPoint(QPointF(105, 105), size).toPoint(); + QVERIFY(QRect(QPoint(0, 0), shown.canvas->size()).contains(edge)); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(centre), QColor(Qt::red)); + // Black on the mask hides the red at once. + session.beginBrush(QPointF(125, 125)); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red)); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(edge), QColor(Qt::red)); + session.finishBrush(); + shown.canvas->synchronizeDisplay(); + QVERIFY(shown.canvas->grab().toImage().pixelColor(centre) != QColor(Qt::red)); + session.undo(); + QVERIFY(session.isMaskSelected()); + } +} + +void BrushCanvasTests::aStripedFolderMaskKeepsItsShadeWhilePainted() +{ + Shown shown(QSize(1200, 1200), QSize(300, 300)); + EditorSession &session = shown.session; + QImage red = BrushRaster::context(1200, 1200, false); + red.fill(Qt::red); + session.insert(ImportedImage(red, red, "Red")); + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + // One-pixel stripes: halved at a quarter, they read gray. + QImage stripes = BrushRaster::context(1200, 1200, true); + for (int y = 0; y < 1200; ++y) { + for (int x = 0; x < 1200; x += 2) + stripes.scanLine(y)[x] = 255; + } + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(stripes)); }); + session.selectLayerTarget(folder, true); + QVERIFY(session.isMaskSelected()); + shown.settle(); + session.zoom(0.25); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(40, 1, 1, 1, 1)); + session.setMaskPaintWhite(true); + shown.canvas->synchronizeDisplay(); + const QPoint far = session.viewport.viewPoint(QPointF(1000, 1000), session.document().value().size()).toPoint(); + const auto shade = [&] { return shown.canvas->grab().toImage().pixelColor(far); }; + const auto near = [](const QColor &a, const QColor &b) { + return std::abs(a.red() - b.red()) <= 3 && std::abs(a.green() - b.green()) <= 3 && std::abs(a.blue() - b.blue()) <= 3; + }; + const QColor before = shade(); + QVERIFY(before != QColor(Qt::red)); + // Far from each dab, the untouched shade stays put. + for (const QPointF dab : {QPointF(100, 100), QPointF(100, 300)}) { + session.beginBrush(dab); + QVERIFY(shown.canvas->synchronizeDisplay()); + const QColor during = shade(); + QVERIFY2(near(before, during), qPrintable(QStringLiteral("%1 then %2").arg(before.name(), during.name()))); + session.finishBrush(); + shown.canvas->synchronizeDisplay(); + QVERIFY(near(before, shade())); + // The second dab reads the tiles the first one painted. + QVERIFY(layerWith(session, folder).mask.value().asset.raster); + } +} + +QTEST_MAIN(BrushCanvasTests) +#include "BrushCanvasTests.moc" diff --git a/tests/BrushControlsTests.cpp b/tests/BrushControlsTests.cpp new file mode 100644 index 0000000..b10944a --- /dev/null +++ b/tests/BrushControlsTests.cpp @@ -0,0 +1,278 @@ +#include "Document/EditorSession.h" +#include "UI/BrushControls.h" +#include <QButtonGroup> +#include <QCheckBox> +#include <QSignalSpy> +#include <QComboBox> +#include <QLabel> +#include <QLineEdit> +#include <QSlider> +#include <QToolButton> +#include <QtTest> + +// Swift's BrushControls: mode, size, hardness, opacity, mask paint. +namespace { +template <typename Widget> Widget &find(QWidget &parent, const char *name) +{ + auto *found = parent.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} + +void type(QLineEdit &field, const QString &text) +{ + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); +} +} + +class BrushControlsTests : public QObject { + Q_OBJECT +private slots: + void theTitleAndModesFollowTheSession(); + void theFieldsAndSlidersSetTheBrush(); + void aMaskPaintsBlackOrWhite(); + void busyDimsTheBar(); + void eachBrushToolTitlesTheBarAndShowsItsPicker(); + void cloneStampShowsAlignedSampleAndTheSourceNote(); + void theSmearShowsItsThreeModes(); +}; + +void BrushControlsTests::theTitleAndModesFollowTheSession() +{ + EditorSession session; + session.createDocument(8, 8, true); + BrushControls bar(session); + bar.show(); + QCOMPARE(bar.title->text(), QString("Brush")); + auto &paint = find<QToolButton>(bar, "brushPaint"), &erase = find<QToolButton>(bar, "brushErase"); + QVERIFY(paint.isChecked() && !erase.isChecked()); + QCOMPARE(erase.text(), QString("Erase")); + QCOMPARE(paint.toolTip(), QString("Paint with the foreground color (B), or erase pixels away (E)")); + erase.click(); + QCOMPARE(session.brushMode(), BrushToolMode::erase); + QCOMPARE(bar.title->text(), QString("Eraser")); + QVERIFY(erase.isChecked() && !paint.isChecked()); + session.setBrushMode(BrushToolMode::paint); + QVERIFY(paint.isChecked() && !erase.isChecked()); + QCOMPARE(bar.title->text(), QString("Brush")); + erase.click(); + paint.click(); + QCOMPARE(session.brushMode(), BrushToolMode::paint); +} + +void BrushControlsTests::theFieldsAndSlidersSetTheBrush() +{ + EditorSession session; + session.createDocument(8, 8, true); + BrushControls bar(session); + bar.show(); + QVERIFY(QTest::qWaitForWindowActive(&bar)); + auto &size = find<QLineEdit>(bar, "brushSize"), &hardness = find<QLineEdit>(bar, "brushHardness"), &opacity = find<QLineEdit>(bar, "brushOpacity"); + QCOMPARE(size.text(), QString("40")); + QCOMPARE(hardness.text(), QString("100")); + QCOMPARE(opacity.text(), QString("100")); + QCOMPARE(opacity.toolTip(), QString("Press 1–9 for 10–90%, 0 for 100%")); + // Typing applies at once, within Swift's bounds. + type(size, QStringLiteral("120")); + QCOMPARE(session.brushSettings().diameter, 120.0); + type(size, QStringLiteral("5000")); + QCOMPARE(session.brushSettings().diameter, 2000.0); + type(size, QStringLiteral("0")); + QCOMPARE(session.brushSettings().diameter, 1.0); + QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session.brushSettings().diameter, 11.0); + type(hardness, QStringLiteral("0")); + QCOMPARE(session.brushSettings().hardness, 0.0); + type(hardness, QStringLiteral("25")); + QCOMPARE(session.brushSettings().hardness, 0.25); + type(opacity, QStringLiteral("0")); + QCOMPARE(session.brushSettings().opacity, 0.01); + // The sliders set a thousandth a step; the fields follow. + size.clearFocus(); + hardness.clearFocus(); + opacity.clearFocus(); + find<QSlider>(bar, "brushHardnessSlider").setValue(750); + QCOMPARE(session.brushSettings().hardness, 0.75); + QCOMPARE(hardness.text(), QString("75")); + auto &opacitySlider = find<QSlider>(bar, "brushOpacitySlider"); + QCOMPARE(opacitySlider.minimum(), 10); + opacitySlider.setValue(500); + QCOMPARE(session.brushSettings().opacity, 0.5); + QCOMPARE(opacity.text(), QString("50")); + // The session's own changes reach the sliders, silently. + session.changeBrushHardness(false); + QCOMPARE(find<QSlider>(bar, "brushHardnessSlider").value(), 500); + QCOMPARE(session.brushSettings().hardness, 0.5); + BrushSettings third = session.brushSettings(); + third.hardness = 1.0 / 3; + third.opacity = 2.0 / 3; + session.setBrushSettings(third); + QCOMPARE(find<QSlider>(bar, "brushHardnessSlider").value(), 333); + QCOMPARE(opacitySlider.value(), 667); + QVERIFY(session.brushSettings().hardness == 1.0 / 3 && session.brushSettings().opacity == 2.0 / 3); + // Arrows step from the exact value, as Swift's arrowSteps. + hardness.setFocus(); + QTest::keyClick(&hardness, Qt::Key_Up); + QCOMPARE(session.brushSettings().hardness, 1.0 / 3 + 0.01); + QCOMPARE(hardness.text(), QString("34")); + opacity.setFocus(); + QTest::keyClick(&opacity, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.brushSettings().opacity, 2.0 / 3 - 0.1); + QCOMPARE(opacity.text(), QString("57")); +} + +void BrushControlsTests::aMaskPaintsBlackOrWhite() +{ + EditorSession session; + session.createDocument(8, 8, true); + BrushControls bar(session); + bar.show(); + auto &picker = find<QComboBox>(bar, "maskPaint"); + auto ¬e = find<QLabel>(bar, "brushMaskNote"); + QLabel *label = nullptr; + for (QLabel *each : bar.findChildren<QLabel *>()) { + if (each->text() == QStringLiteral("Paint")) + label = each; + } + QVERIFY(label && !label->isVisible() && !picker.isVisible() && !note.isVisible()); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + QVERIFY(label->isVisible() && picker.isVisible() && note.isVisible()); + QCOMPARE(note.text(), QString("Mask")); + QCOMPARE(picker.itemText(0), QString("Black · Hide")); + QCOMPARE(picker.itemText(1), QString("White · Reveal")); + QCOMPARE(picker.currentIndex(), session.maskPaintWhite() ? 1 : 0); + picker.setCurrentIndex(1); + emit picker.activated(1); + QVERIFY(session.maskPaintWhite()); + session.setMaskPaintWhite(false); + QCOMPARE(picker.currentIndex(), 0); +} + +void BrushControlsTests::busyDimsTheBar() +{ + EditorSession session; + session.createDocument(8, 8, true); + BrushControls bar(session); + QVERIFY(bar.isEnabled()); + session.setIsProjectBusy(true); + QTRY_VERIFY(session.showsBusy()); + QVERIFY(!bar.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(bar.isEnabled()); +} + +void BrushControlsTests::eachBrushToolTitlesTheBarAndShowsItsPicker() +{ + EditorSession session; + session.createDocument(8, 8, true); + session.selectTool(NavigationTool::brush); + BrushControls bar(session); + bar.show(); + auto &paint = find<QToolButton>(bar, "brushPaint"); + const QList<QAbstractButton *> types = find<QButtonGroup>(bar, "spotHealingType").buttons(); + QCOMPARE(types.size(), 3); + QVERIFY(paint.isVisible() && !types[0]->isVisible()); + // Spot Healing: its title and Swift's three types, no mode. + session.selectTool(NavigationTool::spotHealing); + QCOMPARE(bar.title->text(), QString("Spot Healing")); + QVERIFY(!paint.isVisible() && !find<QToolButton>(bar, "brushErase").isVisible()); + QCOMPARE(types[0]->text(), QString("Content-Aware")); + QCOMPARE(types[1]->text(), QString("Create Texture")); + QCOMPARE(types[2]->text(), QString("Proximity Match")); + QVERIFY(types[0]->isVisible() && types[0]->isChecked()); + types[2]->click(); + QCOMPARE(session.spotHealingMode(), SpotHealingMode::proximityMatch); + session.setSpotHealingMode(SpotHealingMode::createTexture); + QVERIFY(types[1]->isChecked() && !types[2]->isChecked()); + // The other brush tools title theirs; Smear says strength. + QLabel *opacity = nullptr; + for (QLabel *each : bar.findChildren<QLabel *>()) { + if (each->text() == QStringLiteral("Opacity")) + opacity = each; + } + QVERIFY(opacity); + session.selectTool(NavigationTool::cloneStamp); + QCOMPARE(bar.title->text(), QString("Clone Stamp")); + QVERIFY(!types[0]->isVisible() && !paint.isVisible()); + session.selectTool(NavigationTool::blur); + QCOMPARE(bar.title->text(), QString("Smear")); + QCOMPARE(opacity->text(), QString("Strength")); + session.selectTool(NavigationTool::brush); + QCOMPARE(opacity->text(), QString("Opacity")); + QVERIFY(paint.isVisible()); +} + +void BrushControlsTests::cloneStampShowsAlignedSampleAndTheSourceNote() +{ + EditorSession session; + session.createDocument(8, 8, true); + session.selectTool(NavigationTool::brush); + BrushControls bar(session); + bar.show(); + auto &aligned = find<QCheckBox>(bar, "cloneAligned"); + auto &thisLayer = find<QToolButton>(bar, "cloneThisLayer"); + auto &allLayers = find<QToolButton>(bar, "cloneAllLayers"); + auto ¬e = find<QLabel>(bar, "cloneSourceNote"); + QVERIFY(!aligned.isVisible() && !thisLayer.isVisible() && !allLayers.isVisible() && !note.isVisible()); + session.selectTool(NavigationTool::cloneStamp); + QVERIFY(aligned.isVisible() && aligned.isChecked() && thisLayer.isChecked() && !allLayers.isChecked()); + QCOMPARE(aligned.text(), QString("Aligned")); + QCOMPARE(aligned.toolTip(), QString("Keep the source moving with the brush between strokes; off starts every stroke at the source point")); + QCOMPARE(thisLayer.text(), QString("This Layer")); + QCOMPARE(allLayers.text(), QString("All Layers")); + QCOMPARE(allLayers.toolTip(), QString("Copy from the active layer only, or from every visible layer as shown")); + QCOMPARE(thisLayer.toolTip(), allLayers.toolTip()); + // Without a source, the bar says how to set one. + QVERIFY(note.isVisible()); + QCOMPARE(note.text(), QString("Alt-click to set the source")); + QCOMPARE(note.foregroundRole(), QPalette::PlaceholderText); + session.setCloneSource(QPointF(2, 2)); + QVERIFY(!note.isVisible()); + // Clicks write the settings; session changes write nothing back. + allLayers.click(); + QVERIFY(session.cloneSettings().sampleAllLayers && session.cloneSettings().aligned); + aligned.click(); + QVERIFY(!session.cloneSettings().aligned && session.cloneSettings().sampleAllLayers); + thisLayer.click(); + QVERIFY(!session.cloneSettings().sampleAllLayers && !session.cloneSettings().aligned); + QSignalSpy changed(&session, &EditorSession::changed); + session.setCloneSettings(CloneSettings{true, true}); + QCOMPARE(changed.size(), 1); + QVERIFY(aligned.isChecked() && allLayers.isChecked() && !thisLayer.isChecked()); + session.setCloneSettings(CloneSettings{false, true}); + QVERIFY(!aligned.isChecked()); + session.selectTool(NavigationTool::spotHealing); + QVERIFY(!aligned.isVisible() && !allLayers.isVisible() && !note.isVisible()); +} + +void BrushControlsTests::theSmearShowsItsThreeModes() +{ + EditorSession session; + session.createDocument(8, 8, true); + session.selectTool(NavigationTool::brush); + BrushControls bar(session); + bar.show(); + const QList<QAbstractButton *> modes = find<QButtonGroup>(bar, "blurMode").buttons(); + QCOMPARE(modes.size(), 3); + QVERIFY(!modes[0]->isVisible()); + session.selectTool(NavigationTool::blur); + QCOMPARE(modes[0]->text(), QString("Liquify")); + QCOMPARE(modes[1]->text(), QString("Blur")); + QCOMPARE(modes[2]->text(), QString("Smudge")); + for (QAbstractButton *mode : modes) + QCOMPARE(mode->toolTip(), QString("Liquify pushes pixels · Blur softens · Smudge drags color along")); + QVERIFY(modes[0]->isVisible() && modes[0]->isChecked() && !find<QToolButton>(bar, "brushPaint").isVisible()); + modes[2]->click(); + QCOMPARE(session.blurMode(), BlurToolMode::smudge); + session.setBlurMode(BlurToolMode::blur); + QVERIFY(modes[1]->isChecked() && !modes[2]->isChecked()); + session.selectTool(NavigationTool::spotHealing); + QVERIFY(!modes[1]->isVisible()); +} + +QTEST_MAIN(BrushControlsTests) +#include "BrushControlsTests.moc" diff --git a/tests/BrushCoverageTests.cpp b/tests/BrushCoverageTests.cpp new file mode 100644 index 0000000..8019be5 --- /dev/null +++ b/tests/BrushCoverageTests.cpp @@ -0,0 +1,139 @@ +#include "BrushFixtures.h" +#include <cmath> + +// The CPU coverage against the shader's own falloff, in float. +namespace { +// One dab's alpha as the shader computes it, capped. +int shaderAlpha(float distanceSquared, float radius, float hardness) +{ + const float t = std::clamp((std::sqrt(distanceSquared) / radius - hardness) / (1.0f - hardness), 0.0f, 1.0f); + const float coverage = std::max(0.0f, (std::exp(-2.5f * t * t) - std::exp(-2.5f)) / (1.0f - std::exp(-2.5f))); + return int(std::lround(255 * std::min(coverage, 0.999f))); +} + +// The shader's optical density at d², float for float. +float shaderDensity(float distanceSquared, float radius, float hardness) +{ + const float t = std::clamp((std::sqrt(distanceSquared) / radius - hardness) / (1.0f - hardness), 0.0f, 1.0f); + const float coverage = std::max(0.0f, (std::exp(-2.5f * t * t) - std::exp(-2.5f)) / (1.0f - std::exp(-2.5f))); + return -std::log(std::max(1.0f - coverage, 0.001f)); +} + +// The shader's quadrature of one level segment, float for float. +float segmentDensity(float px, float py, float x0, float x1, float y, float radius, float hardness, float spacing) +{ + const float projection = px - x0, perpendicularSquared = (py - y) * (py - y); + if (perpendicularSquared >= radius * radius) + return 0; + const float reach = std::sqrt(radius * radius - perpendicularSquared); + const float lo = std::max(0.0f, projection - reach), hi = std::min(x1 - x0, projection + reach); + if (hi <= lo) + return 0; + const float midpoint = (lo + hi) * 0.5f, half = (hi - lo) * 0.5f; + constexpr float nodes[4] = {0.1834346425f, 0.5255324099f, 0.7966664774f, 0.9602898565f}; + constexpr float weights[4] = {0.3626837834f, 0.3137066459f, 0.2223810345f, 0.1012285363f}; + float integral = 0; + for (int i = 0; i < 4; ++i) { + const float a = midpoint - half * nodes[i] - projection, b = midpoint + half * nodes[i] - projection; + integral += weights[i] * (shaderDensity(perpendicularSquared + a * a, radius, hardness) + shaderDensity(perpendicularSquared + b * b, radius, hardness)); + } + return integral * half / spacing; +} + +// A lone dab at (x, 0.5), read at (pixel, 0). +int dabAlpha(double diameter, double hardness, double x, int pixel) +{ + const auto paint = stroke(pixel + 24, 4, red(diameter, hardness)); + paint->append(QPointF(x, 0.5)); + return alpha(preview(*paint, QSizeF(pixel + 24, 4)), pixel, 0); +} +} + +class BrushCoverageTests : public QObject { + Q_OBJECT +private slots: + void aSoftDabFollowsItsFalloffWithinAStep(); + void nearFullHardnessTheFormulaReplacesTheTable(); + void aStrokesSegmentsFollowTheShaderEitherSideOfTheSwitch(); + void farAlongAStrokeTheNodesRoundAsTheShaders(); +}; + +void BrushCoverageTests::aSoftDabFollowsItsFalloffWithinAStep() +{ + // One dab on a pixel's centre: the shader's falloff, capped. + for (const auto &[diameter, hardness] : {std::pair{100.0, 0.0}, std::pair{60.0, 0.5}}) { + const auto paint = stroke(200, 200, red(diameter, hardness)); + paint->append(QPointF(100.5, 100.5)); + const QImage live = preview(*paint, QSizeF(200, 200)); + for (const int distance : {0, 10, 20, 25, 29, 38, 45, 49}) { + const int expected = shaderAlpha(float(distance * distance), float(diameter / 2), float(hardness)); + const int found = alpha(live, 100 + distance, 100); + QVERIFY2(std::abs(found - expected) <= 1, qPrintable(QStringLiteral("%1 px at %2: %3, not %4").arg(diameter).arg(distance).arg(found).arg(expected))); + } + } +} + +void BrushCoverageTests::nearFullHardnessTheFormulaReplacesTheTable() +{ + // The reviewers' two pixels: the shader gives 247 and 188. + QCOMPARE(dabAlpha(2000, 0.9995, 0.94505, 1000), 247); + const float probe = 5.5f - float(0.500033379); + QCOMPARE(shaderAlpha(probe * probe, 5, 0.99999f), 188); + QVERIFY(std::abs(dabAlpha(10, 0.99999, 0.500033379, 5) - 188) <= 1); + // Across the fade, small and large tips, around the switch. + for (const double hardness : {0.999, 0.9995, 0.9996, 0.9999, 0.99999, 0.999999}) { + for (const auto &[diameter, pixel] : {std::pair{10.0, 5}, std::pair{2000.0, 1000}}) { + for (const double fade : {0.1, 0.3, 0.5, 0.7, 0.9}) { + const double radius = diameter / 2, x = pixel + 0.5 - radius * (hardness + fade * (1 - hardness)); + const float dx = float(pixel + 0.5) - float(x); + const int expected = shaderAlpha(dx * dx, float(radius), float(hardness)), found = dabAlpha(diameter, hardness, x, pixel); + QVERIFY2(std::abs(found - expected) <= 1, + qPrintable(QStringLiteral("%1 px, %2 hard, dab at %3: %4, not %5").arg(diameter).arg(hardness).arg(x, 0, 'g', 12).arg(found).arg(expected))); + } + } + } +} + +void BrushCoverageTests::aStrokesSegmentsFollowTheShaderEitherSideOfTheSwitch() +{ + // A dab where the stroke starts, then one level segment. + for (const double hardness : {0.5, 0.9995, 0.99999, 0.999999}) { + for (const double diameter : {10.0, 400.0}) { + const float radius = float(diameter / 2), spacing = float(std::max(0.25, diameter * BrushStroke::spacingFraction(hardness))); + const int width = int(diameter) + 40; + for (const double fade : {0.1, 0.5, 0.9}) { + const double y = 5.5 - radius * (hardness + fade * (1 - hardness)), x0 = -radius - 5.0, x1 = width + radius + 5.0; + const auto paint = stroke(width, 12, red(diameter, hardness)); + paint->append(QPointF(x0, y)); + paint->append(QPointF(x1, y)); + const QImage live = preview(*paint, QSizeF(width, 12)); + for (int x = 0; x < width; x += 7) { + const float px = float(x) + 0.5f, dx = px - float(x0), dy = 5.5f - float(y); + const float total = shaderDensity(dx * dx + dy * dy, radius, float(hardness)) + + segmentDensity(px, 5.5f, float(x0), float(x1), float(y), radius, float(hardness), spacing); + const int expected = int(std::round(255.0f * (1.0f - std::exp(-std::min(total, 20.0f))))), found = alpha(live, x, 5); + QVERIFY2(std::abs(found - expected) <= 1, + qPrintable(QStringLiteral("%1 px, %2 hard, fade %3, x %4: %5, not %6").arg(diameter).arg(hardness).arg(fade).arg(x).arg(found).arg(expected))); + } + } + } + } +} + +void BrushCoverageTests::farAlongAStrokeTheNodesRoundAsTheShaders() +{ + // 8192 pixels along, node offsets round as the shader's do. + const auto paint = stroke(30000, 16, red(10, 0.999999)); + paint->append(QPointF(10, 5.5000045)); + paint->append(QPointF(29990, 5.5000045)); + const float spacing = float(std::max(0.25, 10 * BrushStroke::spacingFraction(0.999999))); + const float y = float(5.5000045), total = segmentDensity(8202.5f, 10.5f, 10, 29990, y, 5, float(0.999999), spacing); + const int expected = int(std::round(255.0f * (1.0f - std::exp(-std::min(total, 20.0f))))); + QCOMPARE(expected, 17); + QCOMPARE(alpha(preview(*paint, QSizeF(30000, 16)), 8202, 10), 17); + paint->flush(); + QCOMPARE(alpha(preview(*paint, QSizeF(30000, 16)), 8202, 10), 17); +} + +QTEST_GUILESS_MAIN(BrushCoverageTests) +#include "BrushCoverageTests.moc" diff --git a/tests/BrushCursorOverlayTests.cpp b/tests/BrushCursorOverlayTests.cpp new file mode 100644 index 0000000..ad46592 --- /dev/null +++ b/tests/BrushCursorOverlayTests.cpp @@ -0,0 +1,242 @@ +#include "Rendering/BrushCursorOverlay.h" +#include <QImage> +#include <QtTest> +#include <cmath> +#include <numbers> + +// Swift's brush circle: what it repaints, and how it looks. +namespace { +const QColor ground(40, 60, 160); +// Eight pixels a point: the pens and dashes grow alike. +constexpr int scale = 8; + +// The overlay over the blue ground, eight pixels a point. +QImage drawn(const BrushCursorOverlay &overlay) +{ + QImage image(100 * scale, 100 * scale, QImage::Format_RGB32); + image.fill(ground); + QPainter painter(&image); + painter.scale(scale, scale); + overlay.draw(painter); + painter.end(); + return image; +} + +QColor point(const QImage &image, double x, double y) +{ + return image.pixelColor(int(x * scale), int(y * scale)); +} + +// A solid green preview; a tip opaque within ten points. +QImage green() +{ + QImage image(40, 40, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor(0, 255, 0)); + return image; +} + +QImage tip() +{ + QImage image(40, 40, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setBrush(Qt::white); + painter.setPen(Qt::NoPen); + painter.drawEllipse(QPointF(20, 20), 10, 10); + return image; +} + +// A 60-point circle at the middle of a blue square. +QImage drawn(std::optional<double> hardness) +{ + BrushCursorOverlay overlay; + overlay.update(QPointF(50, 50), 60, hardness); + QImage image(100 * scale, 100 * scale, QImage::Format_RGB32); + image.fill(ground); + QPainter painter(&image); + painter.scale(scale, scale); + overlay.draw(painter); + painter.end(); + return image; +} + +bool white(QColor colour) +{ + return colour.red() > 200 && colour.green() > 200 && colour.blue() > 200; +} + +bool black(QColor colour) +{ + return colour.red() < 40 && colour.green() < 40 && colour.blue() < 40; +} + +// The pixel a given number of points from the middle. +QColor at(const QImage &image, double radius, double angle) +{ + return image.pixelColor(int(50 * scale + radius * scale * std::cos(angle)), int(50 * scale + radius * scale * std::sin(angle))); +} + +// How many of 720 angles pass `test` at a radius. +int around(const QImage &image, double radius, const std::function<bool(QColor)> &test) +{ + int found = 0; + for (int step = 0; step < 720; ++step) + found += test(at(image, radius, step * std::numbers::pi / 360)); + return found; +} +} + +class BrushCursorOverlayTests : public QObject { + Q_OBJECT +private slots: + void updatesRepaintTheOldAndNewRings(); + void theRingIsBlackOverWhiteAndAntialiased(); + void theHardnessRingDashesInStepInsideTheCircle(); + void theSourceMarkerRepaintsAroundItselfWhiteUnderBlack(); + void thePreviewFillsTheCircleCutToTheTipAtItsOpacity(); + void imagesCompareByIdentity(); + void theTipCutsAtTheDrawingsResolution(); +}; + +void BrushCursorOverlayTests::updatesRepaintTheOldAndNewRings() +{ + BrushCursorOverlay overlay; + // Three points round the circle hold the white stroke. + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt), QRegion()); + // Hardness alone repaints the same circle. + QCOMPARE(overlay.update(QPointF(50, 50), 20, 0.5), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.hardness(), std::optional(0.5)); + // A move repaints where it was and where it went. + QCOMPARE(overlay.update(QPointF(90, 50), 20, 0.5), QRegion(QRect(37, 37, 26, 26)) + QRegion(QRect(77, 37, 26, 26))); + QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt), QRegion(QRect(77, 37, 26, 26))); + QVERIFY(!overlay.circle().has_value()); +} + +void BrushCursorOverlayTests::theRingIsBlackOverWhiteAndAntialiased() +{ + const QImage image = drawn(std::nullopt); + // Black a point wide on the circle, white round it. + QCOMPARE(around(image, 30, black), 720); + QCOMPARE(around(image, 30.9, white), 720); + QCOMPARE(around(image, 29.1, white), 720); + // Nothing fills it, and nothing lies past the white. + for (const double radius : {0.0, 10.0, 28.5, 31.5, 40.0}) + QCOMPARE(around(image, radius, [](QColor colour) { return colour == ground; }), 720); + // The white's outer edge blends into the ground. + QVERIFY(around(image, 31.25, [](QColor colour) { return colour != ground && !white(colour); }) > 300); +} + +void BrushCursorOverlayTests::theHardnessRingDashesInStepInsideTheCircle() +{ + // A quarter hard: the ring at three eighths across. + const QImage image = drawn(0.25); + QCOMPARE(around(image, 22.5, [](QColor colour) { return colour == ground; }), 720); + const int dashes = around(image, 7.5, black); + // Four points on, three off: black and white halo together. + int apart = 0, turns = 0; + for (int step = 0; step < 720; ++step) { + const double angle = step * std::numbers::pi / 360, next = (step + 1) * std::numbers::pi / 360; + apart += black(at(image, 7.5, angle)) != white(at(image, 8.4, angle)); + turns += white(at(image, 8.4, angle)) != white(at(image, 8.4, next)); + } + QVERIFY2(dashes > 300 && dashes < 520 && apart < 30 && turns >= 12, qPrintable(QString("%1 %2 %3").arg(dashes).arg(apart).arg(turns))); + // At no hardness there is no second ring. + const QImage soft = drawn(0.0); + for (const double radius : {0.0, 7.5, 15.0, 28.5}) + QCOMPARE(around(soft, radius, [](QColor colour) { return colour == ground; }), 720); +} + +void BrushCursorOverlayTests::theSourceMarkerRepaintsAroundItselfWhiteUnderBlack() +{ + BrushCursorOverlay overlay; + // Seven-point arms, three points of white round them. + QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(30, 40)), QRegion(QRect(20, 30, 20, 20))); + QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(30, 40)), QRegion()); + QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, QPointF(60, 40)), QRegion(QRect(20, 30, 20, 20)) + QRegion(QRect(50, 30, 20, 20))); + QCOMPARE(overlay.marker(), std::optional(QPointF(60, 40))); + // The marker draws without a circle. + const QImage image = drawn(overlay); + QVERIFY(black(point(image, 60, 40)) && black(point(image, 66, 40.1)) && black(point(image, 60.1, 34))); + QVERIFY(white(point(image, 63, 41)) && white(point(image, 67.9, 40.1)) && white(point(image, 59, 46))); + QCOMPARE(point(image, 63, 42), ground); + QCOMPARE(point(image, 69, 40.1), ground); + QCOMPARE(point(image, 63, 43), ground); + QCOMPARE(overlay.update(std::nullopt, 20, std::nullopt, std::nullopt), QRegion(QRect(50, 30, 20, 20))); +} + +void BrushCursorOverlayTests::thePreviewFillsTheCircleCutToTheTipAtItsOpacity() +{ + BrushCursorOverlay overlay; + overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, green(), 0.5, tip()); + const QImage cut = drawn(overlay); + // Half green over the ground, only where a click paints. + const QColor middle = point(cut, 50, 50); + QVERIFY2(std::abs(middle.red() - 20) <= 2 && std::abs(middle.green() - 158) <= 2 && std::abs(middle.blue() - 80) <= 2, + qPrintable(middle.name())); + QCOMPARE(point(cut, 65, 50), ground); + QCOMPARE(point(cut, 50, 38), ground); + // A new tip over the same preview cuts it anew. + QImage small = tip(); + small.fill(Qt::transparent); + QPainter(&small).fillRect(QRect(18, 18, 4, 4), Qt::white); + overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, overlay.preview(), 0.5, small); + QCOMPARE(point(drawn(overlay), 50, 45), ground); + // Without a tip the whole circle shows it, nothing past. + overlay.update(QPointF(50, 50), 40, std::nullopt, std::nullopt, green(), 1, QImage()); + const QImage whole = drawn(overlay); + QCOMPARE(point(whole, 65, 50), QColor(0, 255, 0)); + QCOMPARE(point(whole, 50, 32), QColor(0, 255, 0)); + QCOMPARE(point(whole, 50, 27), ground); + QCOMPARE(point(whole, 66, 34), ground); +} + +void BrushCursorOverlayTests::imagesCompareByIdentity() +{ + BrushCursorOverlay overlay; + const QImage preview = green(), cut = tip(); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview, 1, cut), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview, 1, cut), QRegion()); + // Equal pixels in another image differ, as Swift's `!==` says. + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, preview.copy(), 1, cut), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 1, cut.copy()), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 0.4, overlay.preview()), QRegion(QRect(37, 37, 26, 26))); + QCOMPARE(overlay.update(QPointF(50, 50), 20, std::nullopt, std::nullopt, overlay.preview(), 0.6, overlay.preview()), QRegion(QRect(37, 37, 26, 26))); +} + +void BrushCursorOverlayTests::theTipCutsAtTheDrawingsResolution() +{ + // A capped preview enlarged fourfold; a tip of one-pixel columns. + QImage preview(100, 100, QImage::Format_RGBA8888_Premultiplied); + preview.fill(QColor(0, 255, 0)); + QImage columns(400, 400, QImage::Format_RGBA8888_Premultiplied); + columns.fill(Qt::transparent); + QPainter stripes(&columns); + for (int x = 0; x < 400; x += 2) + stripes.fillRect(QRect(x, 0, 1, 400), Qt::white); + stripes.end(); + const auto draw = [](const BrushCursorOverlay &overlay) { + QImage image(500, 500, QImage::Format_RGB32); + image.fill(ground); + QPainter painter(&image); + overlay.draw(painter); + return image; + }; + BrushCursorOverlay overlay; + overlay.update(QPointF(250, 250), 400, std::nullopt, std::nullopt, preview, 1, columns); + const QImage cut = draw(overlay); + // Each column keeps or drops the preview, as clicks would. + QCOMPARE(cut.pixelColor(250, 250), QColor(0, 255, 0)); + QCOMPARE(cut.pixelColor(251, 250), ground); + QCOMPARE(cut.pixelColor(120, 300), QColor(0, 255, 0)); + QCOMPARE(cut.pixelColor(121, 300), ground); + // The enlarged preview is smoothed, as Swift's medium quality. + QImage halves = preview.copy(); + QPainter(&halves).fillRect(QRect(0, 0, 50, 100), QColor(255, 0, 0)); + overlay.update(QPointF(250, 250), 400, std::nullopt, std::nullopt, halves, 1, QImage()); + const QColor seam = draw(overlay).pixelColor(250, 250); + QVERIFY2(seam.red() > 40 && seam.green() > 40, qPrintable(seam.name())); +} + +QTEST_GUILESS_MAIN(BrushCursorOverlayTests) +#include "BrushCursorOverlayTests.moc" diff --git a/tests/BrushFixtures.h b/tests/BrushFixtures.h new file mode 100644 index 0000000..c7d4c24 --- /dev/null +++ b/tests/BrushFixtures.h @@ -0,0 +1,71 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Document/LayerMask.h" +#include "Rendering/LayerRenderer.h" +#include <QtTest> + +// Shared by the brush tests: strokes, previews, pixels. +inline BrushSettings brush(double diameter, double hardness, double red, double green, double blue, double opacity = 1) +{ + return BrushSettings{.diameter = diameter, .hardness = hardness, .red = red, .green = green, .blue = blue, .opacity = opacity}; +} + +inline ImageLayer blankLayer(int width, int height) +{ + return ImageLayer(QStringLiteral("Layer 1"), QSizeF(width, height)); +} + +// The stroke as the canvas shows it, canvas-sized. +inline QImage preview(const BrushStroke &stroke, QSizeF canvas) +{ + QImage context = BrushRaster::context(int(canvas.width()), int(canvas.height()), false); + QPainter painter(&context); + const ImageLayer &layer = stroke.layer; + const std::optional<ImportedImage> &asset = layer.asset; + const QImage image = asset && !asset->raster ? asset->image() : QImage(); + LayerRenderer::drawBrushPreview(image, stroke.paintTransform, stroke.paintTransform.center(), painter, + {.opacity = layer.opacity, .blendMode = layer.blendMode, .mask = layer.mask ? layer.mask->enabledImage().value_or(QImage()) : QImage()}, + {.patches = stroke.patches(), .pixelWidth = stroke.width, .pixelHeight = stroke.height, .paintingMask = stroke.isMask, + .sourceRect = stroke.sourceRect, .raster = asset ? asset->raster : nullptr}); + painter.end(); + return context; +} + +inline std::vector<int> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +inline int alpha(const QImage &image, int x, int y) +{ + return pixel(image, x, y)[3]; +} + +// A red hard 20 px brush, Swift's default session settings. +inline BrushSettings red(double diameter = 20, double hardness = 1) +{ + return brush(diameter, hardness, 1, 0, 0); +} + +inline std::unique_ptr<BrushStroke> stroke(int width, int height, const BrushSettings &settings) +{ + return std::make_unique<BrushStroke>(blankLayer(width, height), false, settings, QSizeF(width, height)); +} + +// A committed asset as the canvas shows it. +inline QImage render(const ImportedImage &asset, const LayerTransform &transform, QSizeF canvas) +{ + QImage context = BrushRaster::context(int(canvas.width()), int(canvas.height()), false); + QPainter painter(&context); + LayerRenderer::draw(asset.image(), transform, transform.center(), painter, {}); + return context; +} + +inline ImportedImage gray(uchar value) +{ + QImage image = BrushRaster::context(1, 1, true); + image.fill(value); + return LayerMask::assetFrom(image); +} diff --git a/tests/BrushGestureTests.cpp b/tests/BrushGestureTests.cpp new file mode 100644 index 0000000..6e3ae3a --- /dev/null +++ b/tests/BrushGestureTests.cpp @@ -0,0 +1,384 @@ +#include "BrushCanvasFixtures.h" + +// Swift's brush on the canvas: strokes, axes, tip, circle. +namespace { +// The live stroke's alpha at a document pixel. +int liveAlpha(const EditorSession &session, int x, int y) +{ + for (const BrushPatch &patch : session.brushStroke()->patches()) { + if (patch.rect.contains(QPointF(x + 0.5, y + 0.5))) + return patch.image.constScanLine(y - int(patch.rect.top()))[(x - int(patch.rect.left())) * 4 + 3]; + } + return 0; +} + +// Twenty pixels: wider than the curve's bend at a corner. +void wide(Canvas &shown) +{ + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 20; + shown.session.setBrushSettings(settings); +} + +void rightPress(Canvas &shown, QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) +{ + QTest::mousePress(shown.canvas, Qt::RightButton, modifiers, at.toPoint()); +} + +void rightMove(Canvas &shown, QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) +{ + QMouseEvent event(QEvent::MouseMove, to, to, shown.canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::RightButton, modifiers); + QApplication::sendEvent(shown.canvas, &event); +} +} + +class BrushGestureTests : public QObject { + Q_OBJECT +private slots: + void aPressDragAndReleasePaintOneStroke(); + void shiftDrawsOnFromTheLastStrokeAndKeepsAnAxis(); + void aShiftLineRunsThroughThePressAndTheAxisFollowsTheLastPixel(); + void theAxisSettlesOnceAndResetsWhenShiftIsLetGo(); + void beforeItSettlesTheAxisHoldsTheAnchor(); + void aBusyProjectHoldsTheStroke(); + void aStrokeHoldsTheViewStill(); + void spotHealingTakesTheBrushGestures(); + void losingFocusCancelsAStroke(); + void aRightDragSizesTheBrushAndShiftSetsHardness(); + void aTipDragSwapsWithShiftRoundsAndStopsAtTheLimit(); + void theCircleFollowsThePointerAndLeavesWithIt(); +}; + +void BrushGestureTests::aPressDragAndReleasePaintOneStroke() +{ + Canvas shown; + red(shown); + const int count = shown.session.history.undoCount(); + shown.press(QPointF(100, 100)); + QVERIFY(shown.session.brushStroke()); + shown.move(QPointF(200, 100)); + shown.release(QPointF(250, 100)); + QVERIFY(!shown.session.brushStroke()); + QCOMPARE(shown.session.history.undoCount(), count + 1); + QCOMPARE(shown.session.history.undoName(), QString("Brush Stroke")); + // The release reaches its own point. + for (const int x : {100, 150, 200, 245}) + QCOMPARE(pixel(shown.session, x, 100), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(shown.session, 150, 120)[3], 0); +} + +void BrushGestureTests::shiftDrawsOnFromTheLastStrokeAndKeepsAnAxis() +{ + Canvas shown; + red(shown); + shown.click(QPointF(50, 50)); + // Shift-press: a line on from where the last stroke ended. + shown.press(QPointF(150, 150), Qt::ShiftModifier); + shown.release(QPointF(150, 150), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 100, 100), (std::vector<int>{255, 0, 0, 255})); + // Held, Shift keeps the axis its first three pixels chose. + shown.press(QPointF(200, 250), Qt::ShiftModifier); + shown.move(QPointF(210, 251), Qt::ShiftModifier); + shown.move(QPointF(300, 270), Qt::ShiftModifier); + // The release itself reaches its own point, as Swift's. + shown.release(QPointF(300, 270), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 300, 250), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(shown.session, 280, 268)[3], 0); +} + +void BrushGestureTests::aShiftLineRunsThroughThePressAndTheAxisFollowsTheLastPixel() +{ + Canvas shown; + red(shown); + wide(shown); + const std::vector<int> painted{255, 0, 0, 255}; + // The line reaches the press, then turns down the axis. + shown.click(QPointF(20, 50)); + shown.press(QPointF(100, 50), Qt::ShiftModifier); + shown.move(QPointF(100, 90), Qt::ShiftModifier); + shown.release(QPointF(100, 90), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 60, 50), painted); + QCOMPARE(pixel(shown.session, 60, 70)[3], 0); + // Shift taken mid-stroke anchors where this stroke began. + shown.drag(QPointF(300, 250), QPointF(320, 260)); + shown.press(QPointF(50, 150)); + shown.move(QPointF(90, 152), Qt::ShiftModifier); + shown.release(QPointF(90, 150), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 70, 150), painted); + // Or at the last pixel the stroke reached. + shown.press(QPointF(50, 200)); + shown.move(QPointF(70, 220)); + shown.move(QPointF(150, 224), Qt::ShiftModifier); + shown.release(QPointF(150, 225), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 130, 220), painted); +} + +void BrushGestureTests::theAxisSettlesOnceAndResetsWhenShiftIsLetGo() +{ + Canvas shown; + red(shown); + wide(shown); + const std::vector<int> painted{255, 0, 0, 255}; + // Five pixels settle it sideways; a later drop keeps it. + shown.press(QPointF(50, 200)); + shown.move(QPointF(55, 202), Qt::ShiftModifier); + shown.move(QPointF(60, 260), Qt::ShiftModifier); + shown.release(QPointF(60, 200), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 50, 240)[3], 0); + // A Shift press starts a fresh axis. + shown.press(QPointF(120, 150), Qt::ShiftModifier); + shown.move(QPointF(122, 230), Qt::ShiftModifier); + shown.release(QPointF(160, 230)); + QCOMPARE(pixel(shown.session, 120, 210), painted); + // Settled upright, it keeps to the anchor's column. + shown.press(QPointF(300, 50)); + shown.move(QPointF(301, 60), Qt::ShiftModifier); + shown.move(QPointF(340, 100), Qt::ShiftModifier); + shown.release(QPointF(300, 100), Qt::ShiftModifier); + QCOMPARE(pixel(shown.session, 300, 80), painted); + // Letting Shift go frees both; Shift again settles afresh. + shown.press(QPointF(150, 30)); + shown.move(QPointF(190, 32), Qt::ShiftModifier); + shown.move(QPointF(190, 100)); + shown.move(QPointF(192, 180), Qt::ShiftModifier); + shown.release(QPointF(250, 180)); + QCOMPARE(pixel(shown.session, 190, 140), painted); +} + +void BrushGestureTests::beforeItSettlesTheAxisHoldsTheAnchor() +{ + Canvas shown; + red(shown); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 1; + shown.session.setBrushSettings(settings); + shown.session.zoom(8); + shown.canvas->synchronizeDisplay(); + const QPointF start = shown.session.viewport.documentPoint(QPointF(200, 150), shown.session.document().value().size()); + shown.press(QPointF(200, 150)); + // Under three pixels from the anchor, the stroke waits there. + shown.move(QPointF(220, 162), Qt::ShiftModifier); + QVERIFY(shown.session.brushStroke()); + const QPoint beside(int(std::floor(start.x() + 2.5)), int(std::floor(start.y() + 1.5))); + QCOMPARE(liveAlpha(shown.session, beside.x(), beside.y()), 0); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.brushStroke()); +} + +void BrushGestureTests::aBusyProjectHoldsTheStroke() +{ + Canvas shown; + red(shown); + shown.press(QPointF(100, 100)); + shown.session.setIsProjectBusy(true); + shown.move(QPointF(200, 100)); + shown.release(QPointF(200, 100)); + // Neither the drag nor the release reached the stroke. + QVERIFY(shown.session.brushStroke()); + QCOMPARE(liveAlpha(shown.session, 100, 100), 255); + QCOMPARE(liveAlpha(shown.session, 180, 100), 0); + // Escape and a lost focus wait for the project too. + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QFocusEvent out(QEvent::FocusOut, Qt::ActiveWindowFocusReason); + QApplication::sendEvent(shown.canvas, &out); + QVERIFY(shown.session.brushStroke()); + shown.session.setIsProjectBusy(false); + // A hover paints nothing; only a drag goes on. + shown.hover(QPointF(250, 100)); + QCOMPARE(liveAlpha(shown.session, 240, 100), 0); + shown.move(QPointF(250, 100)); + QCOMPARE(liveAlpha(shown.session, 240, 100), 255); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.brushStroke()); +} + +void BrushGestureTests::aStrokeHoldsTheViewStill() +{ + Canvas shown; + red(shown); + const QPointF pointer(200, 150); + const auto under = [&] { return shown.session.viewport.documentPoint(pointer, shown.session.document().value().size()); }; + const QPointF before = under(); + QNativeGestureEvent pinch(Qt::ZoomNativeGesture, QPointingDevice::primaryPointingDevice(), 2, pointer, pointer, + shown.canvas->mapToGlobal(pointer.toPoint()), 0.5, QPointF()); + // Mid-stroke, wheels and pinches neither pan nor zoom. + shown.press(QPointF(100, 100)); + wheel(*shown.canvas, pointer, QPoint(0, 40), QPoint(0, 120), Qt::NoModifier); + wheel(*shown.canvas, pointer, QPoint(0, 40), QPoint(0, 120), Qt::ControlModifier); + QApplication::sendEvent(shown.canvas, &pinch); + QCOMPARE(shown.session.viewport.zoom(), 1.0); + QCOMPARE(under(), before); + shown.release(QPointF(100, 100)); + // With the stroke done, both work again. + wheel(*shown.canvas, pointer, QPoint(0, 40), QPoint(0, 120), Qt::NoModifier); + QVERIFY(under() != before); + QApplication::sendEvent(shown.canvas, &pinch); + QCOMPARE(shown.session.viewport.zoom(), 1.5); +} + +void BrushGestureTests::losingFocusCancelsAStroke() +{ + Canvas shown; + red(shown); + shown.hover(QPointF(80, 80)); + QVERIFY(shown.canvas->brushCursor().circle().has_value()); + shown.press(QPointF(100, 100)); + QVERIFY(shown.session.brushStroke()); + QFocusEvent out(QEvent::FocusOut, Qt::ActiveWindowFocusReason); + QApplication::sendEvent(shown.canvas, &out); + QVERIFY(!shown.session.brushStroke() && !shown.canvas->brushCursor().circle().has_value()); +} + +void BrushGestureTests::aRightDragSizesTheBrushAndShiftSetsHardness() +{ + Canvas shown; + red(shown); + rightPress(shown, QPointF(100, 100)); + // Each point widens the radius by a point: twice across. + rightMove(shown, QPointF(150, 120)); + QCOMPARE(shown.session.brushSettings().diameter, 110.0); + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(100, 100)); + QVERIFY(!shown.canvas->brushCursor().hardness().has_value()); + rightMove(shown, QPointF(40, 100)); + QCOMPARE(shown.session.brushSettings().diameter, 1.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(60, 70)); + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(60, 70)); + // With Shift the drag sets hardness, 200 points the range. + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 30; + settings.hardness = 0.5; + shown.session.setBrushSettings(settings); + rightPress(shown, QPointF(100, 100), Qt::ShiftModifier); + rightMove(shown, QPointF(150, 100), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.75); + QCOMPARE(shown.session.brushSettings().diameter, 30.0); + QCOMPARE(shown.canvas->brushCursor().hardness(), std::optional(0.75)); + rightMove(shown, QPointF(400, 100), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 1.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::ShiftModifier, QPoint(100, 100)); + QVERIFY(!shown.canvas->brushCursor().hardness().has_value()); + // A stroke under way keeps the right button off. + shown.press(QPointF(200, 200)); + rightPress(shown, QPointF(200, 200)); + rightMove(shown, QPointF(260, 200)); + QCOMPARE(shown.session.brushSettings().diameter, 30.0); + shown.release(QPointF(200, 200)); +} + +void BrushGestureTests::aTipDragSwapsWithShiftRoundsAndStopsAtTheLimit() +{ + Canvas shown; + red(shown); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 30; + settings.hardness = 0.5; + shown.session.setBrushSettings(settings); + // Shift at the press shows the hardness ring at once. + rightPress(shown, QPointF(100, 100), Qt::ShiftModifier); + QCOMPARE(shown.canvas->brushCursor().hardness(), std::optional(0.5)); + rightMove(shown, QPointF(140, 100), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.7); + // Letting Shift go sizes from the press; hardness goes back. + rightMove(shown, QPointF(140, 100)); + QCOMPARE(shown.session.brushSettings().diameter, 110.0); + QCOMPARE(shown.session.brushSettings().hardness, 0.5); + // Shift again puts the size back. + rightMove(shown, QPointF(160, 100), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().diameter, 30.0); + QCOMPARE(shown.session.brushSettings().hardness, 0.8); + // Far right stops at two thousand pixels. + rightMove(shown, QPointF(1400, 100)); + QCOMPARE(shown.session.brushSettings().diameter, 2000.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(100, 100)); + // Between pixels the size rounds to a whole one. + settings.diameter = 30; + shown.session.setBrushSettings(settings); + shown.session.zoom(0.3); + rightPress(shown, QPointF(100, 100)); + rightMove(shown, QPointF(110, 100)); + QCOMPARE(shown.session.brushSettings().diameter, 97.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(110, 100)); + // Other tools and a held Space leave the right button. + shown.session.selectTool(NavigationTool::move); + rightPress(shown, QPointF(100, 100)); + rightMove(shown, QPointF(150, 100)); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(150, 100)); + shown.session.selectTool(NavigationTool::brush); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyPress(shown.canvas, Qt::Key_Space); + rightPress(shown, QPointF(100, 100)); + rightMove(shown, QPointF(150, 100)); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(150, 100)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.session.brushSettings().diameter, 97.0); +} + +void BrushGestureTests::theCircleFollowsThePointerAndLeavesWithIt() +{ + Canvas shown; + red(shown); + shown.session.zoom(2); + shown.canvas->synchronizeDisplay(); + shown.hover(QPointF(120, 80)); + // The brush's size on screen: ten pixels at 200%. + QCOMPARE(shown.canvas->brushCursor().circle().value(), QRectF(110, 70, 20, 20)); + // The canvas draws it: white halo over the grays. + const QImage shot = shown.canvas->grab().toImage(); + int light = 0; + for (int y = 60; y < 100; ++y) { + for (int x = 100; x < 140; ++x) + light += qGray(shot.pixel(x, y)) > 180; + } + QVERIFY2(light > 10, qPrintable(QString::number(light))); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyPress(shown.canvas, Qt::Key_Space); + QVERIFY(!shown.canvas->brushCursor().circle().has_value()); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.canvas->brushCursor().circle().value(), QRectF(110, 70, 20, 20)); + QEvent leave(QEvent::Leave); + QApplication::sendEvent(shown.canvas, &leave); + QVERIFY(!shown.canvas->brushCursor().circle().has_value()); + shown.hover(QPointF(120, 80)); + shown.session.selectTool(NavigationTool::move); + shown.canvas->synchronizeDisplay(); + QVERIFY(!shown.canvas->brushCursor().circle().has_value()); + // A pan leaves the circle where the pointer last hovered. + shown.session.selectTool(NavigationTool::brush); + shown.canvas->synchronizeDisplay(); + shown.hover(QPointF(100, 100)); + QTest::keyPress(shown.canvas, Qt::Key_Space); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 120)); + shown.release(QPointF(150, 120)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(100, 100)); +} + +void BrushGestureTests::spotHealingTakesTheBrushGestures() +{ + Canvas shown; + red(shown); + shown.session.selectTool(NavigationTool::spotHealing); + shown.canvas->synchronizeDisplay(); + // The circle, the tip drag, the brackets and the stroke. + shown.hover(QPointF(100, 100)); + QVERIFY(shown.canvas->brushCursor().circle().has_value()); + rightPress(shown, QPointF(100, 100)); + rightMove(shown, QPointF(110, 100)); + QCOMPARE(shown.session.brushSettings().diameter, 30.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(110, 100)); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_BracketRight); + QCOMPARE(shown.session.brushSettings().diameter, 36.0); + shown.press(QPointF(100, 100)); + QVERIFY(shown.session.brushStroke() && shown.session.brushStroke()->settings.healing); + shown.move(QPointF(140, 100)); + shown.release(QPointF(140, 100)); + QCOMPARE(shown.session.history.undoName(), QString("Spot Healing")); +} + +QTEST_MAIN(BrushGestureTests) +#include "BrushGestureTests.moc" diff --git a/tests/BrushIntersectionTests.cpp b/tests/BrushIntersectionTests.cpp new file mode 100644 index 0000000..20a9cfb --- /dev/null +++ b/tests/BrushIntersectionTests.cpp @@ -0,0 +1,112 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QDir> + +// Self-crossings, spacing by distance, the opacity cap, idempotent flush. +namespace { +std::unique_ptr<BrushStroke> stroke(double diameter = 120, double opacity = 1) +{ + return std::make_unique<BrushStroke>(blankLayer(800, 800), false, brush(diameter, 0, 1, 1, 1, opacity), QSizeF(800, 800)); +} + +void trace(BrushStroke &stroke, const std::vector<QPointF> &points, double step = 12) +{ + stroke.append(points[0]); + for (size_t i = 1; i < points.size(); ++i) { + const QPointF a = points[i - 1], b = points[i]; + const int count = std::max(1, int(std::ceil(std::hypot(b.x() - a.x(), b.y() - a.y()) / step))); + for (int index = 1; index <= count; ++index) { + const double t = double(index) / count; + stroke.append(QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)); + } + } + stroke.flush(); +} + +QImage raster(const BrushStroke &stroke) +{ + return preview(stroke, QSizeF(800, 800)); +} +} + +class BrushIntersectionTests : public QObject { + Q_OBJECT +private slots: + void selfCrossingsBlendInsteadOfTakingTheStrongestEdge(); + void accumulationDependsOnDistanceNotEventCount(); + void softCrossingsRespectStrokeOpacityAndFlushIsIdempotent(); + void exportCrossingExample(); +}; + +void BrushIntersectionTests::selfCrossingsBlendInsteadOfTakingTheStrongestEdge() +{ + const auto vertical = stroke(), horizontal = stroke(), crossing = stroke(); + trace(*vertical, {QPointF(400, 100), QPointF(400, 700)}); + trace(*horizontal, {QPointF(700, 400), QPointF(100, 400)}); + trace(*crossing, {QPointF(400, 100), QPointF(400, 700), QPointF(700, 700), QPointF(700, 400), QPointF(100, 400)}); + const QImage v = raster(*vertical), h = raster(*horizontal), c = raster(*crossing); + for (int offset : {45, 48, 51}) { + const int a = alpha(v, 400 + offset, 400 + offset), b = alpha(h, 400 + offset, 400 + offset); + const int actual = alpha(c, 400 + offset, 400 + offset); + const int expected = 255 - (255 - a) * (255 - b) / 255; + QVERIFY2(actual > std::max(a, b) + 10, qPrintable(QStringLiteral("offset %1: %2 over %3 and %4").arg(offset).arg(actual).arg(a).arg(b))); + QVERIFY2(std::abs(actual - expected) <= 3, qPrintable(QStringLiteral("offset %1: %2 vs %3").arg(offset).arg(actual).arg(expected))); + } +} + +void BrushIntersectionTests::accumulationDependsOnDistanceNotEventCount() +{ + for (double diameter : {12.0, 120.0, 520.0}) { + const auto sparse = stroke(diameter), dense = stroke(diameter); + const std::vector<QPointF> points{QPointF(60, 400), QPointF(740, 400)}; + trace(*sparse, points, 1000); + trace(*dense, points, 5); + const QImage a = raster(*sparse), b = raster(*dense); + for (int y = 400; y < std::min(800, 400 + int(diameter / 2)); ++y) + QVERIFY2(std::abs(alpha(a, 400, y) - alpha(b, 400, y)) <= 2, qPrintable(QStringLiteral("diameter %1, row %2").arg(diameter).arg(y))); + } +} + +void BrushIntersectionTests::softCrossingsRespectStrokeOpacityAndFlushIsIdempotent() +{ + const auto paint = stroke(120, 0.4); + trace(*paint, {QPointF(400, 100), QPointF(400, 700), QPointF(700, 700), QPointF(700, 400), QPointF(100, 400)}); + const QImage first = raster(*paint); + QCOMPARE(alpha(first, 400, 400), 102); + for (int y = 0; y < 800; ++y) { + const uchar *row = first.constScanLine(y); + for (int x = 0; x < 800; ++x) + QVERIFY(row[x * 4 + 3] <= 102); + } + paint->flush(); + const QImage second = raster(*paint); + QCOMPARE(second, first); +} + +// Swift's picture of a 520 px crossing, with BRUSH_BENCHMARK=1. +void BrushIntersectionTests::exportCrossingExample() +{ + if (qEnvironmentVariable("BRUSH_BENCHMARK") != QStringLiteral("1")) + QSKIP("BRUSH_BENCHMARK=1 exports the crossing"); + EditorSession session; + session.createDocument(4000, 4000); + QImage black = BrushRaster::context(4000, 4000, false); + black.fill(Qt::black); + session.insert(ImportedImage(black, black, "Crossing")); + session.selectTool(NavigationTool::brush); + session.setBrushSettings(brush(520, 0, 1, 1, 1)); + const std::vector<QPointF> path = {{1600, 700}, {1750, 3300}, {2900, 2400}, {3150, 1800}, {1600, 1700}, {450, 2000}}; + session.beginBrush(path[0]); + for (size_t i = 1; i < path.size(); ++i) { + for (int step = 1; step <= 60; ++step) { + const double t = step / 60.0; + session.continueBrush(path[i - 1] + (path[i] - path[i - 1]) * t); + } + } + QVERIFY(session.finishBrushImmediately()); + ImageExporter::exportPNG(session.projectSnapshot().value(), QDir::temp().filePath(QStringLiteral("compositor-brush-crossing.png"))); +} + +QTEST_GUILESS_MAIN(BrushIntersectionTests) +#include "BrushIntersectionTests.moc" diff --git a/tests/BrushKeyTests.cpp b/tests/BrushKeyTests.cpp new file mode 100644 index 0000000..1ff1dc3 --- /dev/null +++ b/tests/BrushKeyTests.cpp @@ -0,0 +1,184 @@ +#include "BrushCanvasFixtures.h" +#include "UI/NativeLayerList.h" +#include <QLineEdit> + +// Swift's brush keys: Tab, B, E, digits, brackets. +class BrushKeyTests : public QObject { + Q_OBJECT +private slots: + void aStrokeTakesEveryKeyAndEscapeCancelsIt(); + void tabCyclesModesAndBAndEChooseThem(); + void digitsSetTheBrushOpacity(); + void shiftBracketsStepHardnessFromTheCanvas(); + void bracketKeysReachTheBrushWhereverFocusIsExceptTextFields(); + void theCanvasTakesItsOwnBracketsAndRereadsAlt(); +}; + +void BrushKeyTests::aStrokeTakesEveryKeyAndEscapeCancelsIt() +{ + Canvas shown; + red(shown); + const int count = shown.session.history.undoCount(); + shown.press(QPointF(100, 100)); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_V); + QTest::keyClick(shown.canvas, Qt::Key_BracketRight); + QVERIFY(shown.session.tool() == NavigationTool::brush && shown.session.brushSettings().diameter == 10); + QVERIFY(shown.session.brushStroke()); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.brushStroke()); + shown.release(QPointF(100, 100)); + QCOMPARE(shown.session.history.undoCount(), count); + QCOMPARE(pixel(shown.session, 100, 100)[3], 0); +} + +void BrushKeyTests::tabCyclesModesAndBAndEChooseThem() +{ + Canvas shown; + // Tab stays the canvas's though focus could move on. + (new QLineEdit(&shown.window))->show(); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.hover(QPointF(200, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Tab); + QCOMPARE(shown.session.marqueeKind(), LassoKind::ellipse); + QVERIFY(shown.canvas->hasFocus()); + // The cursor takes the new kind at once. + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::replace, shown.canvas->devicePixelRatio()))); + QTest::keyClick(shown.canvas, Qt::Key_E); + QVERIFY(shown.session.tool() == NavigationTool::brush && shown.session.brushMode() == BrushToolMode::erase); + QTest::keyClick(shown.canvas, Qt::Key_Tab); + QCOMPARE(shown.session.brushMode(), BrushToolMode::paint); + QTest::keyClick(shown.canvas, Qt::Key_Tab); + QCOMPARE(shown.session.brushMode(), BrushToolMode::erase); + QTest::keyClick(shown.canvas, Qt::Key_B); + QCOMPARE(shown.session.brushMode(), BrushToolMode::paint); + // Shift-Tab is the focus chain's, not the mode's. + QTest::keyClick(shown.canvas, Qt::Key_Backtab, Qt::ShiftModifier); + QCOMPARE(shown.session.brushMode(), BrushToolMode::paint); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Tab, Qt::ShiftModifier); + QCOMPARE(shown.session.brushMode(), BrushToolMode::paint); + // With Ctrl or Alt the letters are someone else's. + QTest::keyClick(shown.canvas, Qt::Key_E, Qt::AltModifier); + QTest::keyClick(shown.canvas, Qt::Key_E, Qt::ControlModifier); + QCOMPARE(shown.session.brushMode(), BrushToolMode::paint); +} + +void BrushKeyTests::digitsSetTheBrushOpacity() +{ + Canvas shown; + red(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_3); + QCOMPARE(shown.session.brushSettings().opacity, 0.3); + QTest::qWait(700); + QTest::keyClick(shown.canvas, Qt::Key_0); + QCOMPARE(shown.session.brushSettings().opacity, 1.0); + // The Move tool's digits set the layer's opacity. + shown.session.selectTool(NavigationTool::move); + QTest::qWait(700); + QTest::keyClick(shown.canvas, Qt::Key_5); + QCOMPARE(shown.session.activeLayer().value().opacity, 0.5); + // Where digits set nothing, the key goes to the parent. + shown.session.selectTool(NavigationTool::hand); + QKeyEvent digit(QEvent::KeyPress, Qt::Key_5, Qt::NoModifier, QStringLiteral("5")); + QApplication::sendEvent(shown.canvas, &digit); + QVERIFY(!digit.isAccepted()); +} + +void BrushKeyTests::shiftBracketsStepHardnessFromTheCanvas() +{ + Canvas shown; + red(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + BrushSettings settings = shown.session.brushSettings(); + settings.hardness = 0.5; + shown.session.setBrushSettings(settings); + QTest::keyClick(shown.canvas, Qt::Key_BraceRight, Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.75); + QTest::keyClick(shown.canvas, Qt::Key_BraceLeft, Qt::ShiftModifier); + QTest::keyClick(shown.canvas, Qt::Key_BraceLeft, Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.25); + const double diameter = shown.session.brushSettings().diameter; + QTest::keyClick(shown.canvas, Qt::Key_BracketRight); + QVERIFY(shown.session.brushSettings().diameter > diameter && shown.session.brushSettings().hardness == 0.25); +} + +void BrushKeyTests::bracketKeysReachTheBrushWhereverFocusIsExceptTextFields() +{ + Canvas shown; + red(shown); + NativeLayerList list(shown.session, &shown.window); + list.setGeometry(0, 0, 100, 100); + auto *field = new QLineEdit(&shown.window); + field->show(); + list.show(); + // Through the application, as a real key arrives. + const auto press = [&](QWidget *target, int key, const QString &text, Qt::KeyboardModifiers modifiers) { + QKeyEvent event(QEvent::KeyPress, key, modifiers, text); + QApplication::sendEvent(target ? target : static_cast<QWidget *>(&shown.window), &event); + }; + for (QWidget *focus : {static_cast<QWidget *>(&list), static_cast<QWidget *>(nullptr)}) { + if (focus) + focus->setFocus(); + else if (QWidget *focused = QApplication::focusWidget()) + focused->clearFocus(); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 20; + settings.hardness = 0.5; + shown.session.setBrushSettings(settings); + press(focus, Qt::Key_BracketRight, QStringLiteral("]"), Qt::NoModifier); + QVERIFY(shown.session.brushSettings().diameter > 20); + press(focus, Qt::Key_BracketLeft, QStringLiteral("["), Qt::NoModifier); + press(focus, Qt::Key_BracketLeft, QStringLiteral("["), Qt::NoModifier); + QVERIFY(shown.session.brushSettings().diameter < 20); + press(focus, Qt::Key_BraceRight, QStringLiteral("}"), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.75); + press(focus, Qt::Key_BraceLeft, QStringLiteral("{"), Qt::ShiftModifier); + press(focus, Qt::Key_BraceLeft, QStringLiteral("{"), Qt::ShiftModifier); + QCOMPARE(shown.session.brushSettings().hardness, 0.25); + } + // Typing in a text field keeps its brackets. + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + const double diameter = shown.session.brushSettings().diameter; + press(field, Qt::Key_BracketRight, QStringLiteral("]"), Qt::NoModifier); + QCOMPARE(shown.session.brushSettings().diameter, diameter); + QCOMPARE(field->text(), QString("]")); + // Other tools leave the brush alone. + list.setFocus(); + shown.session.selectTool(NavigationTool::move); + press(&list, Qt::Key_BracketRight, QStringLiteral("]"), Qt::NoModifier); + QCOMPARE(shown.session.brushSettings().diameter, diameter); +} + +void BrushKeyTests::theCanvasTakesItsOwnBracketsAndRereadsAlt() +{ + Canvas shown; + red(shown); + // A wide dab gives the Move tool pixels to show. + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 100; + shown.session.setBrushSettings(settings); + shown.click(QPointF(200, 150)); + settings.diameter = 10; + shown.session.setBrushSettings(settings); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.hover(QPointF(200, 150)); + // Alt went down; a drag lost its release. + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QTest::keyClick(shown.canvas, Qt::Key_BracketRight); + QCOMPARE(shown.session.brushSettings().diameter, 12.0); + // The bracket reached the canvas, which read Alt as up. + shown.session.selectTool(NavigationTool::move); + shown.canvas->synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::moveCursor(shown.canvas->devicePixelRatio()))); +} + +QTEST_MAIN(BrushKeyTests) +#include "BrushKeyTests.moc" diff --git a/tests/BrushPerformanceTests.cpp b/tests/BrushPerformanceTests.cpp new file mode 100644 index 0000000..9034da6 --- /dev/null +++ b/tests/BrushPerformanceTests.cpp @@ -0,0 +1,86 @@ +#include "CanvasFixtures.h" +#include "IO/ImageExporter.h" +#include <QElapsedTimer> +#include <algorithm> + +// Swift's 4K brush benchmark: run alone, with BRUSH_BENCHMARK=1. +class BrushPerformanceTests : public QObject { + Q_OBJECT +private slots: + void fourKInteractiveStroke(); +}; + +void BrushPerformanceTests::fourKInteractiveStroke() +{ + if (qEnvironmentVariable("BRUSH_BENCHMARK") != QStringLiteral("1")) + QSKIP("BRUSH_BENCHMARK=1 runs the benchmark"); + for (const double diameter : {40.0, 800.0}) { + for (const bool opaque : {false, true}) { + Shown shown(QSize(4000, 4000), QSize(1000, 1000)); + EditorSession &session = shown.session; + if (opaque) { + QImage black = BrushRaster::context(4000, 4000, false); + black.fill(Qt::black); + session.insert(ImportedImage(black, black, "Opaque 4K")); + } else { + session.addBlankLayer(); + } + session.selectTool(NavigationTool::brush); + session.setBrushSettings(BrushSettings{.diameter = diameter, .hardness = 0, .red = 1, .green = 1, .blue = 1}); + shown.settle(); + session.fit(); + shown.canvas->synchronizeDisplay(); + shown.canvas->repaint(); + // Each update: the model, then the dirty region painted. + const auto display = [&] { + shown.canvas->synchronizeDisplay(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::UpdateRequest); + }; + std::vector<double> frames, models; + for (int pass = 0; pass < 2; ++pass) { + QElapsedTimer stroke; + stroke.start(); + session.beginBrush(QPointF(700, 3200)); + for (int i = 1; i <= 120; ++i) { + QElapsedTimer frame; + frame.start(); + session.continueBrush(i <= 60 ? QPointF(700, 3200 - i * 40) : QPointF(700 + (i - 60) * 40, 800)); + models.push_back(double(frame.nsecsElapsed()) / 1e6); + display(); + frames.push_back(double(frame.nsecsElapsed()) / 1e6); + } + const double draw = double(stroke.nsecsElapsed()) / 1e6; + QElapsedTimer up; + up.start(); + session.finishBrush(); + display(); + qInfo().noquote() << QStringLiteral("BRUSH BENCH diameter=%1 opaque=%2 pass=%3 draw=%4 mouseUp=%5ms") + .arg(diameter).arg(opaque).arg(pass).arg(draw, 0, 'f', 1).arg(double(up.nsecsElapsed()) / 1e6, 0, 'f', 2); + QVERIFY(!session.brushError().has_value()); + } + std::sort(frames.begin(), frames.end()); + std::sort(models.begin(), models.end()); + qInfo().noquote() << QStringLiteral("BRUSH BENCH diameter=%1 opaque=%2 frame median=%3 p95=%4 max=%5ms") + .arg(diameter).arg(opaque).arg(frames[frames.size() / 2], 0, 'f', 2) + .arg(frames[size_t(double(frames.size()) * 0.95)], 0, 'f', 2).arg(frames.back(), 0, 'f', 2); + // The model's share: the rest is the repaint. + qInfo().noquote() << QStringLiteral("BRUSH BENCH diameter=%1 opaque=%2 model median=%3ms").arg(diameter).arg(opaque).arg(models[models.size() / 2], 0, 'f', 2); + // The gate of linux/TASKS.md 8.3. + QVERIFY2(frames[frames.size() / 2] <= 8.0, qPrintable(QString::number(frames[frames.size() / 2]))); + if (diameter == 800 && opaque) { + const QString path = QDir::temp().filePath(QStringLiteral("compositor-brush-benchmark.png")); + ImageExporter::exportPNG(session.projectSnapshot().value(), path); + } + } + } +} + +int main(int argc, char **argv) +{ + // Swift's benchmark draws at a backing scale of two. + qputenv("QT_SCALE_FACTOR", "2"); + QApplication app(argc, argv); + BrushPerformanceTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "BrushPerformanceTests.moc" diff --git a/tests/BrushPreviewTests.cpp b/tests/BrushPreviewTests.cpp new file mode 100644 index 0000000..9a87915 --- /dev/null +++ b/tests/BrushPreviewTests.cpp @@ -0,0 +1,307 @@ +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include <QtTest> + +namespace { +QImage coverage(int width, int height, int step) +{ + QImage image = gray(width, height, 0); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.scanLine(y)[x] = uchar((x * step + y) % 256); + } + return image; +} + +QImage surface(const std::function<void(QPainter &)> &draw) +{ + QImage result = noise(96, 96, 77); + QPainter painter(&result); + draw(painter); + return result; +} +} + +class BrushPreviewTests : public QObject { + Q_OBJECT +private slots: + void init(); + void tilesReplaceTheImageAndBlendOnce_data(); + void tilesReplaceTheImageAndBlendOnce(); + void aPaintedSourceDrawsFromItsTiles(); + void paintingAMaskShowsTheSourceThroughTheTiles(); + void paintPastTheSourceIsRevealed(); + void edgesStayHardAndSmoothLayersInterpolate(); + void aSourcePastTheGridIsCutThere_data(); + void aSourcePastTheGridIsCutThere(); + void aPreviewNeedsAPixelGrid(); +}; + +void BrushPreviewTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void BrushPreviewTests::tilesReplaceTheImageAndBlendOnce_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<LayerBlendMode>("blendMode"); + QTest::addColumn<double>("rotation"); + QTest::addColumn<bool>("flipX"); + QTest::addColumn<bool>("flipY"); + QTest::newRow("1x") << 1.0 << LayerBlendMode::normal << 0.0 << false << false; + QTest::newRow("2x multiplied") << 2.0 << LayerBlendMode::multiply << 0.0 << false << false; + QTest::newRow("a right angle, flipped across") << 1.0 << LayerBlendMode::normal << 90.0 << true << false; + QTest::newRow("half a turn, flipped down") << 1.0 << LayerBlendMode::normal << 180.0 << false << true; +} + +void BrushPreviewTests::tilesReplaceTheImageAndBlendOnce() +{ + QFETCH(double, scale); + QFETCH(LayerBlendMode, blendMode); + QFETCH(double, rotation); + QFETCH(bool, flipX); + QFETCH(bool, flipY); + const QImage image = noise(40, 30, 1, 200); + // Translucent tiles that touch: a double draw would show. + const std::vector<BrushPatch> patches{{QRectF(8, 4, 16, 16), noise(16, 16, 2, 120)}, {QRectF(24, 4, 16, 16), noise(16, 16, 3, 60)}}; + LayerTransform transform = placedAt({6, 5}, {40, 30}); + transform.rotation = rotation; + transform.flipX = flipX; + transform.flipY = flipY; + const QPointF center(6 + 20 * scale, 25 + 15 * scale); + const LayerRenderer::Options options{.scale = scale, .opacity = 0.5, .blendMode = blendMode}; + const QImage expected = surface([&](QPainter &painter) { LayerRenderer::draw(composite(image, patches), transform, center, painter, options); }); + const QImage drawn = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(image, transform, center, painter, options, {.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false}); + }); + QCOMPARE(drawn, expected); + QVERIFY(drawn != surface([&](QPainter &painter) { LayerRenderer::draw(image, transform, center, painter, options); })); + + // Without an image the tiles stand alone. + const QImage alone = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(QImage(), transform, center, painter, options, {.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false}); + }); + QCOMPARE(alone, surface([&](QPainter &painter) { + LayerRenderer::draw(composite(BrushRaster::context(40, 30, false), patches), transform, center, painter, options); + })); +} + +void BrushPreviewTests::aPaintedSourceDrawsFromItsTiles() +{ + const QImage base = noise(32, 24, 4); + const std::vector<BrushPatch> committed{{QRectF(8, 8, 16, 16), noise(16, 16, 5, 90)}}; + // The raster's base covers a part of it only. + const auto raster = std::make_shared<const RasterSnapshot>(40, 30, base, QRectF(4, 3, 32, 24), committed); + const std::vector<BrushPatch> patches{{QRectF(20, 10, 16, 16), noise(16, 16, 6, 150)}}; + const LayerTransform transform = placedAt({6, 5}, {40, 30}); + const LayerRenderer::Options options{.opacity = 0.5}; + QImage finished = BrushRaster::context(40, 30, false); + { + QPainter painter(&finished); + raster->draw(QRectF(0, 0, 40, 30), painter); + for (const BrushPatch &patch : patches) + BrushRaster::draw(patch.image, patch.rect, painter); + } + const QImage expected = surface([&](QPainter &painter) { LayerRenderer::draw(finished, transform, transform.center(), painter, options); }); + const QImage drawn = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, options, + {.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false, .raster = raster}); + }); + QCOMPARE(drawn, expected); + QVERIFY(!raster->hasMaterializedPixels()); + + // A painted source may sit inside a grown grid. + const QRectF sourceRect(5, 3, 40, 30); + QImage grownFinished = BrushRaster::context(48, 36, false); + { + QPainter painter(&grownFinished); + raster->draw(sourceRect, painter); + } + const LayerTransform grown = placedAt({6, 5}, {48, 36}); + const QImage placed = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(QImage(), grown, grown.center(), painter, options, + {.patches = {}, .pixelWidth = 48, .pixelHeight = 36, .paintingMask = false, .sourceRect = sourceRect, .raster = raster}); + }); + QCOMPARE(placed, surface([&](QPainter &painter) { LayerRenderer::draw(grownFinished, grown, grown.center(), painter, options); })); + + // A cropped raster's cut-away base stays hidden. + const auto cropped = std::make_shared<const RasterSnapshot>(40, 30, base, QRectF(-4, -3, 32, 24), committed); + QImage croppedFinished = BrushRaster::context(48, 36, false); + { + QPainter painter(&croppedFinished); + cropped->draw(sourceRect, painter); + } + const QImage hidden = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(QImage(), grown, grown.center(), painter, options, + {.patches = {}, .pixelWidth = 48, .pixelHeight = 36, .paintingMask = false, .sourceRect = sourceRect, .raster = cropped}); + }); + QCOMPARE(hidden, surface([&](QPainter &painter) { LayerRenderer::draw(croppedFinished, grown, grown.center(), painter, options); })); + QCOMPARE(hidden.pixel(6 + 3, 5 + 1), noise(96, 96, 77).pixel(6 + 3, 5 + 1)); + + // A stand-in base replaces the raster's own. + const QImage standIn = solid(32, 24, qRgba(0, 255, 0, 255)); + const QImage replaced = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {}, + {.patches = {}, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false, .raster = raster, .rasterBase = standIn}); + }); + QCOMPARE(replaced.pixel(6 + 5, 5 + 4), qRgba(0, 255, 0, 255)); + QCOMPARE(replaced.pixel(6 + 35, 5 + 26), qRgba(0, 255, 0, 255)); + QCOMPARE(replaced.pixel(6 + 3, 5 + 4), noise(96, 96, 77).pixel(6 + 3, 5 + 4)); +} + +void BrushPreviewTests::paintingAMaskShowsTheSourceThroughTheTiles() +{ + const QImage image = noise(40, 30, 7); + const QImage oldMask = coverage(40, 30, 3); + const std::vector<BrushPatch> patches{{QRectF(8, 4, 16, 16), coverage(16, 16, 11)}}; + const LayerTransform transform = placedAt({6, 5}, {40, 30}); + QImage finishedMask = oldMask; + { + QPainter painter(&finishedMask); + BrushRaster::draw(patches[0].image, patches[0].rect, painter); + } + const LayerRenderer::BrushPreview preview{.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = true}; + const QImage drawn = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(image, transform, transform.center(), painter, {.opacity = 0.5, .mask = oldMask}, preview); + }); + QCOMPARE(drawn, surface([&](QPainter &painter) { + LayerRenderer::draw(image, transform, transform.center(), painter, {.opacity = 0.5, .mask = finishedMask}); + })); + + // With no old mask the rest of the source shows. + QImage fresh = gray(40, 30, 255); + { + QPainter painter(&fresh); + BrushRaster::draw(patches[0].image, patches[0].rect, painter); + } + const QImage bare = surface([&](QPainter &painter) { LayerRenderer::drawBrushPreview(image, transform, transform.center(), painter, {}, preview); }); + QCOMPARE(bare, surface([&](QPainter &painter) { LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = fresh}); })); + + // No source, nothing to show. + const QImage nothing = surface([&](QPainter &painter) { LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {}, preview); }); + QCOMPARE(nothing, noise(96, 96, 77)); +} + +void BrushPreviewTests::paintPastTheSourceIsRevealed() +{ + // The old 24x20 layer sits at (10,6) in the grid. + const QImage old = noise(24, 20, 8); + const QImage oldMask = coverage(24, 20, 5); + const QRectF sourceRect(10, 6, 24, 20); + // The last tile hangs over the grid's corner. + const std::vector<BrushPatch> patches{{QRectF(26, 12, 16, 16), noise(16, 16, 9, 200)}, {QRectF(0, 0, 8, 8), noise(8, 8, 10)}, + {QRectF(44, 28, 8, 8), noise(8, 8, 12)}}; + QImage finished = BrushRaster::context(52, 36, false), finishedMask = gray(52, 36, 255); + { + QPainter painter(&finished), masking(&finishedMask); + BrushRaster::draw(old, sourceRect, painter); + for (const BrushPatch &patch : patches) + BrushRaster::draw(patch.image, patch.rect, painter); + BrushRaster::draw(oldMask, sourceRect, masking); + } + const LayerTransform transform = placedAt({6, 5}, {48, 32}); + const LayerRenderer::Options options{.opacity = 0.7, .mask = oldMask}; + const QImage drawn = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(old, transform, transform.center(), painter, options, + {.patches = patches, .pixelWidth = 48, .pixelHeight = 32, .paintingMask = false, .sourceRect = sourceRect}); + }); + const LayerTransform grown = placedAt({6, 5}, {52, 36}); + QCOMPARE(drawn, surface([&](QPainter &painter) { + LayerRenderer::draw(finished, grown, grown.center(), painter, {.opacity = 0.7, .mask = finishedMask}); + })); +} + +void BrushPreviewTests::edgesStayHardAndSmoothLayersInterpolate() +{ + // Antialiased tile edges would blend twice where tiles meet. + const std::vector<BrushPatch> patches{{QRectF(4, 4, 8, 8), solid(8, 8, qRgba(0, 0, 255, 255))}}; + const LayerTransform turned{.origin = {20, 20}, .size = {24, 16}, .rotation = 30, .sampling = LayerSampling::smooth}; + QImage target = BrushRaster::context(64, 56, false); + { + QPainter painter(&target); + LayerRenderer::drawBrushPreview(solid(24, 16, qRgba(255, 0, 0, 255)), turned, turned.center(), painter, {}, + {.patches = patches, .pixelWidth = 24, .pixelHeight = 16, .paintingMask = false}); + } + int opaque = 0; + for (int y = 0; y < 56; ++y) { + for (int x = 0; x < 64; ++x) { + QVERIFY(qAlpha(target.pixel(x, y)) == 0 || qAlpha(target.pixel(x, y)) == 255); + opaque += qAlpha(target.pixel(x, y)) == 255; + } + } + QVERIFY(std::abs(opaque - 24 * 16) <= 12); + + // The layer's own quality resamples the pixels. + QImage steps = BrushRaster::context(2, 1, false); + steps.setPixel(0, 0, qRgba(0, 0, 0, 255)); + steps.setPixel(1, 0, qRgba(255, 255, 255, 255)); + const auto enlarged = [&](LayerSampling sampling) { + const LayerTransform transform{.origin = {0, 0}, .size = {2, 1}, .sampling = sampling}; + QImage result = BrushRaster::context(16, 8, false); + QPainter painter(&result); + LayerRenderer::drawBrushPreview(steps, transform, QPointF(8, 4), painter, {.scale = 8}, + {.patches = {}, .pixelWidth = 2, .pixelHeight = 1, .paintingMask = false}); + return result; + }; + QCOMPARE(enlarged(LayerSampling::nearest).pixel(7, 4), qRgba(0, 0, 0, 255)); + QCOMPARE(enlarged(LayerSampling::nearest).pixel(8, 4), qRgba(255, 255, 255, 255)); + const int blended = qRed(enlarged(LayerSampling::smooth).pixel(7, 4)); + QVERIFY2(blended > 90 && blended < 130, qPrintable(QString::number(blended))); +} + +void BrushPreviewTests::aSourcePastTheGridIsCutThere_data() +{ + QTest::addColumn<bool>("paintedSource"); + QTest::newRow("an image") << false; + QTest::newRow("a painted source") << true; +} + +void BrushPreviewTests::aSourcePastTheGridIsCutThere() +{ + QFETCH(bool, paintedSource); + // The source passes the grid; a tile overhangs its right. + const QImage image = noise(40, 30, 13); + const auto raster = std::make_shared<const RasterSnapshot>(40, 30, image, QRectF(0, 0, 40, 30), + std::vector<BrushPatch>{BrushPatch{QRectF(20, 10, 16, 16), noise(16, 16, 15, 90)}}); + const QRectF sourceRect(-6, -4, 40, 30); + const std::vector<BrushPatch> patches{{QRectF(28, 0, 8, 8), noise(8, 8, 14)}}; + QImage finished = BrushRaster::context(36, 24, false); + { + QPainter painter(&finished); + painter.setClipRect(QRectF(0, 0, 32, 24)); + if (paintedSource) + raster->draw(sourceRect, painter); + else + BrushRaster::draw(image, sourceRect, painter); + painter.setClipping(false); + BrushRaster::draw(patches[0].image, patches[0].rect, painter); + } + const LayerTransform transform = placedAt({20, 16}, {32, 24}), wide = placedAt({20, 16}, {36, 24}); + const QImage drawn = surface([&](QPainter &painter) { + LayerRenderer::drawBrushPreview(paintedSource ? QImage() : image, transform, transform.center(), painter, {.opacity = 0.5}, + {.patches = patches, .pixelWidth = 32, .pixelHeight = 24, .paintingMask = false, .sourceRect = sourceRect, + .raster = paintedSource ? raster : nullptr}); + }); + QCOMPARE(drawn, surface([&](QPainter &painter) { LayerRenderer::draw(finished, wide, wide.center(), painter, {.opacity = 0.5}); })); + // Left of the grid, and right below the tile: untouched. + QCOMPARE(drawn.pixel(17, 20), noise(96, 96, 77).pixel(17, 20)); + QCOMPARE(drawn.pixel(20 + 33, 16 + 12), noise(96, 96, 77).pixel(20 + 33, 16 + 12)); + QVERIFY(drawn.pixel(20 + 30, 16 + 12) != noise(96, 96, 77).pixel(20 + 30, 16 + 12)); +} + +void BrushPreviewTests::aPreviewNeedsAPixelGrid() +{ + QImage target = BrushRaster::context(8, 8, false); + QPainter painter(&target); + const LayerTransform transform = placedAt({0, 0}, {8, 8}); + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {}, + {.patches = {}, .pixelWidth = 0, .pixelHeight = 8, .paintingMask = false}), + std::logic_error); + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {}, + {.patches = {}, .pixelWidth = 8, .pixelHeight = 0, .paintingMask = false}), + std::logic_error); +} + +QTEST_MAIN(BrushPreviewTests) +#include "BrushPreviewTests.moc" diff --git a/tests/BrushRasterTests.cpp b/tests/BrushRasterTests.cpp new file mode 100644 index 0000000..df9d6b8 --- /dev/null +++ b/tests/BrushRasterTests.cpp @@ -0,0 +1,84 @@ +#include "AddressSpaceLimit.h" +#include "BrushFixtures.h" +#include "IO/ImageExporter.h" +#include <malloc.h> + +// The raster's edges: failed copies, canvas fills and clears. +class BrushRasterTests : public QObject { + Q_OBJECT +private slots: + void aTileThatCannotBeCopiedIsARenderError(); + void clearPixelsStaysWithinTheSourceAndFillCoversTheCanvas(); +}; + +namespace { +// Under a limit, every free chunk the copy could take. +struct Starved { + std::vector<void *> blocks; + explicit Starved(size_t bytes) + { + blocks.reserve(4096); + while (void *block = malloc(bytes)) + blocks.push_back(block); + } + + ~Starved() + { + for (void *block : blocks) + free(block); + } +}; +} + +void BrushRasterTests::aTileThatCannotBeCopiedIsARenderError() +{ + const auto paint = stroke(600, 300, red()); + paint->append(QPointF(20, 40)); + { + const AddressSpaceLimit limit(64 * 1024); + const Starved starved(256 * 256 * 4); + QVERIFY_THROWS_EXCEPTION(ExportError, paint->append(QPointF(30, 40))); + } +} + +void BrushRasterTests::clearPixelsStaysWithinTheSourceAndFillCoversTheCanvas() +{ + QImage blue = BrushRaster::context(20, 20, false); + blue.fill(Qt::blue); + const ImageLayer layer(ImportedImage(blue, blue, "Blue"), QPointF(10, 10)); + BrushStroke clearing(layer, false, red(), QSizeF(600, 300)); + DocumentSelection selection; + selection.path.addRect(QRectF(0, 0, 20, 300)); + clearing.selectionClip = selection.clip(QSizeF(600, 300)); + clearing.clearPixels(); + // Only the layer's tile; the canvas' rest is untouched. + QCOMPARE(clearing.patches().size(), size_t(1)); + QCOMPARE(clearing.dirtyDocumentRect(), std::optional(QRectF(0, 0, 600, 300))); + const BrushCommit::Output cleared = BrushCommit::render(clearing.commitInput()); + const QImage result = render(cleared.asset, clearing.transform(cleared.pixelBounds.translated(clearing.committedBounds().topLeft())), QSizeF(600, 300)); + QCOMPARE(alpha(result, 15, 15), 0); + QCOMPARE(pixel(result, 25, 15), (std::vector<int>{0, 0, 255, 255})); + // A fill covers the canvas, past the layer, selected. + BrushStroke filling(layer, false, red(), QSizeF(600, 300)); + filling.selectionClip = selection.clip(QSizeF(600, 300)); + filling.fill(Qt::red); + QCOMPARE(filling.patches().size(), size_t(2)); + const BrushCommit::Output filled = BrushCommit::render(filling.commitInput()); + const QImage whole = render(filled.asset, filling.transform(filled.pixelBounds.translated(filling.committedBounds().topLeft())), QSizeF(600, 300)); + QCOMPARE(pixel(whole, 5, 295), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(whole, 15, 15), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(whole, 25, 15), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(alpha(whole, 45, 35), 0); + // A mask never erases: the fill refuses the mode outright. + QImage gray = BrushRaster::context(4, 4, true); + QVERIFY_THROWS_EXCEPTION(std::logic_error, BrushRaster::fill(Qt::white, gray, QRectF(0, 0, 4, 4), 1, gray, QPainter::CompositionMode_DestinationOut)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, BrushRaster::fill(Qt::white, gray, QRectF(0, 0, 4, 4), 1, gray, QPainter::CompositionMode_Multiply)); + // A clip is gray coverage the coverage's size, or refused. + QImage colour = BrushRaster::context(4, 4, false); + const QImage small = BrushRaster::context(3, 4, true); + QVERIFY_THROWS_EXCEPTION(std::logic_error, BrushRaster::fill(Qt::white, gray, QRectF(0, 0, 4, 4), 1, colour, QPainter::CompositionMode_SourceOver, small)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, BrushRaster::fill(Qt::white, gray, QRectF(0, 0, 4, 4), 1, colour, QPainter::CompositionMode_SourceOver, colour)); +} + +QTEST_GUILESS_MAIN(BrushRasterTests) +#include "BrushRasterTests.moc" diff --git a/tests/BrushSessionTests.cpp b/tests/BrushSessionTests.cpp new file mode 100644 index 0000000..501ee7a --- /dev/null +++ b/tests/BrushSessionTests.cpp @@ -0,0 +1,390 @@ +#include "BrushFixtures.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "SelectionFixtures.h" +#include "SessionRecord.h" +#include <QTemporaryDir> + +// The session's brush: Swift's BrushTests through the session. +namespace { +// Swift's makeSession: a blank layer, the Brush, hard red 20. +std::unique_ptr<EditorSession> makeSession(int width = 600, int height = 80) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height, true); + session->addBlankLayer(); + session->selectTool(NavigationTool::brush); + session->setBrushSettings(brush(20, 1, 1, 0, 0)); + return session; +} + +QImage render(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} + +QImage live(const EditorSession &session) +{ + return preview(*session.brushStroke(), session.document().value().size()); +} + +BrushSettings withTip(BrushSettings settings, double diameter, double hardness) +{ + settings.diameter = diameter; + settings.hardness = hardness; + return settings; +} +} + +class BrushSessionTests : public QObject { + Q_OBJECT +private slots: + void continuousStrokeCrossesTilesAndCommitsOneUndo(); + void softBrushProducesPartialAlphaAndCancelPreservesDocument(); + void softMaskPaintingPreviewMatchesCommitAndPersists(); + void brushStaysCircularOnNonuniformRotatedFlippedLayer(); + void maskedImagePaintingUsesCoverageAndOpacityOnlyOnce(); + void importedImageLayerExpandsAcrossCanvasWithoutMovingImageOrMask(); + void paintedBoundsTrimTilePaddingAndKeepSoftEdges(); + void opacityCapsTheWholeStrokeEvenWhereItOverlapsItself(); + void liveStrokeReachesNewestSampleAndTailIsReplacedExactly(); + void opacityAppliesToMaskPainting(); + void shiftBracketsStepHardnessByQuarters(); + void numberKeysSetBrushOpacity(); + void largeBlankCanvasOnlyAllocatesTouchedTilesUntilCommit(); + void foldersHiddenLayersAndDisabledMasksRejectPainting(); +}; + +void BrushSessionTests::continuousStrokeCrossesTilesAndCommitsOneUndo() +{ + const auto session = makeSession(); + const int count = session->history.undoCount(); + session->beginBrush(QPointF(20, 40)); + session->continueBrush(QPointF(580, 40)); + QVERIFY(session->brushStroke()); + QCOMPARE(session->brushStroke()->patches().size(), size_t(3)); + QVERIFY(!session->activeLayer().value().asset.has_value() && session->history.undoCount() == count); + const QImage shown = live(*session); + for (int x : {20, 255, 256, 511, 512, 579}) + QCOMPARE(pixel(shown, x, 40), (std::vector<int>{255, 0, 0, 255})); + session->finishBrush(); + QVERIFY(!session->brushError().has_value() && !session->brushStroke()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Brush Stroke")); + const QImage result = render(*session); + for (int x : {20, 255, 256, 511, 512, 579}) + QCOMPARE(pixel(result, x, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(alpha(result, 300, 0), 0); + session->undo(); + QVERIFY(!session->activeLayer().value().asset.has_value()); + session->redo(); + QVERIFY(session->activeLayer().value().asset.has_value()); +} + +void BrushSessionTests::softBrushProducesPartialAlphaAndCancelPreservesDocument() +{ + const auto session = makeSession(80, 80); + session->setBrushSettings(withTip(session->brushSettings(), 40, 0)); + const CanvasDocument before = session->document().value(); + session->beginBrush(QPointF(40, 40)); + const QImage shown = live(*session); + QVERIFY(alpha(shown, 40, 40) > 230); + const int half = alpha(shown, 50, 40); + QVERIFY(half > 95 && half < 140); + QVERIFY(alpha(shown, 57, 40) < 40); + QCOMPARE(alpha(shown, 64, 40), 0); + session->cancelBrush(); + QVERIFY(session->document().value() == before); + session->beginBrush(QPointF(-100, -100)); + session->finishBrush(); + QVERIFY(session->document().value() == before); +} + +void BrushSessionTests::softMaskPaintingPreviewMatchesCommitAndPersists() +{ + const auto session = makeSession(80, 80); + session->setBrushSettings(withTip(session->brushSettings(), 200, 1)); + session->beginBrush(QPointF(40, 40)); + session->finishBrush(); + const ImageIdentity source = session->activeLayer().value().asset.value().identity(); + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + session->setBrushSettings(withTip(session->brushSettings(), 40, 0)); + session->beginBrush(QPointF(40, 40)); + const QImage shown = live(*session); + session->finishBrush(); + QCOMPARE(session->history.undoName(), QString("Paint Mask")); + const QImage result = render(*session); + for (int x : {10, 40, 54, 70}) + QVERIFY2(std::abs(alpha(result, x, 40) - alpha(shown, x, 40)) <= 1, qPrintable(QStringLiteral("x %1: %2 vs %3").arg(x).arg(alpha(result, x, 40)).arg(alpha(shown, x, 40)))); + QVERIFY(alpha(result, 40, 40) < 25); + QCOMPARE(session->activeLayer().value().asset.value().identity(), source); + QCOMPARE(session->activeLayer().value().mask.value().asset.size().width(), 80); + session->setMaskPaintWhite(true); + session->setBrushSettings(withTip(session->brushSettings(), 40, 1)); + session->beginBrush(QPointF(40, 40)); + session->finishBrush(); + QCOMPARE(alpha(render(*session), 40, 40), 255); + session->undo(); + QVERIFY(session->isMaskSelected()); + QVERIFY(alpha(render(*session), 40, 40) < 25); + session->redo(); + QVERIFY(session->isMaskSelected()); + // Through the project format and back. + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("Brush.comp")); + ProjectStore::save(session->projectSnapshot().value(), path); + QCOMPARE(alpha(ImageExporter::render(ProjectStore::load(path)).image, 40, 40), 255); +} + +void BrushSessionTests::brushStaysCircularOnNonuniformRotatedFlippedLayer() +{ + const auto session = makeSession(100, 100); + QImage source = BrushRaster::context(100, 100, false); + QPainter painter(&source); + painter.fillRect(QRect(2, 2, 6, 6), Qt::red); + painter.end(); + session->insert(ImportedImage(source, source, "Fixture")); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {25, -50}, .size = {50, 200}, .rotation = 90, .flipX = true, .flipY = true, .sampling = LayerSampling::nearest}; + }); + session->selectLayer(id); + session->selectTool(NavigationTool::brush); + session->setBrushSettings(brush(16, 1, 0, 1, 0)); + session->beginBrush(QPointF(50, 50)); + const QImage shown = live(*session); + session->finishBrush(); + const QImage result = render(*session); + for (const auto &[x, y] : std::vector<std::pair<int, int>>{{50, 50}, {54, 50}, {50, 54}}) { + QVERIFY(pixel(result, x, y)[1] > 240); + QVERIFY(pixel(shown, x, y)[1] > 240); + } + for (const auto &[x, y] : std::vector<std::pair<int, int>>{{61, 50}, {50, 61}}) + QCOMPARE(alpha(result, x, y), 0); +} + +void BrushSessionTests::maskedImagePaintingUsesCoverageAndOpacityOnlyOnce() +{ + const auto session = makeSession(520, 40); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, gray(128)); }); + session->selectLayerTarget(id, false); + session->selectTool(NavigationTool::brush); + session->setLayerOpacity(0.5); + session->beginBrush(QPointF(240, 20)); + session->continueBrush(QPointF(280, 20)); + const QImage shown = live(*session); + session->finishBrush(); + const QImage result = render(*session); + for (int x : {250, 255, 256, 260}) { + QVERIFY2(std::abs(alpha(shown, x, 20) - 64) <= 1, qPrintable(QString::number(alpha(shown, x, 20)))); + QVERIFY2(std::abs(alpha(result, x, 20) - 64) <= 1, qPrintable(QString::number(alpha(result, x, 20)))); + } +} + +void BrushSessionTests::importedImageLayerExpandsAcrossCanvasWithoutMovingImageOrMask() +{ + for (double rotation : {0.0, 37.0, 90.0}) { + const auto session = makeSession(600, 200); + QImage image = BrushRaster::context(40, 20, false); + image.fill(Qt::red); + session->insert(ImportedImage(image, image, "Imported")); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {240, 80}, .size = {80, 40}, .rotation = rotation, .flipX = true, .sampling = LayerSampling::nearest}; + setMask(snapshot, id, gray(128)); + }); + session->selectLayerTarget(id, false); + session->selectTool(NavigationTool::brush); + const CanvasDocument original = session->document().value(); + session->setBrushSettings(brush(16, 1, 0, 1, 0)); + session->beginBrush(QPointF(12, 12)); + session->continueBrush(QPointF(580, 12)); + const QImage shown = live(*session); + QVERIFY(pixel(shown, 12, 12)[1] > 240 && pixel(shown, 580, 12)[1] > 240); + session->finishBrush(); + QVERIFY(!session->brushError().has_value()); + const QImage result = render(*session); + QVERIFY(pixel(result, 12, 12)[1] > 240 && pixel(result, 580, 12)[1] > 240); + const std::vector<int> centre = pixel(result, 280, 100); + QVERIFY2(std::abs(centre[0] - 128) <= 1 && std::abs(centre[3] - 128) <= 1, qPrintable(QStringLiteral("%1 %2").arg(centre[0]).arg(centre[3]))); + const ImageLayer layer = session->activeLayer().value(); + QVERIFY(layer.transform.rotation == rotation && layer.transform.flipX); + session->undo(); + QVERIFY(session->document().value() == original); + session->redo(); + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("Expanded.comp")); + ProjectStore::save(session->projectSnapshot().value(), path); + QVERIFY(pixel(ImageExporter::render(ProjectStore::load(path)).image, 12, 12)[1] > 240); + } +} + +void BrushSessionTests::paintedBoundsTrimTilePaddingAndKeepSoftEdges() +{ + for (double hardness : {0.0, 1.0}) { + const auto session = makeSession(600, 200); + session->setBrushSettings(withTip(session->brushSettings(), 20, hardness)); + session->beginBrush(QPointF(300, 100)); + const QImage shown = live(*session); + session->finishBrush(); + const ImageLayer layer = session->activeLayer().value(); + const QImage image = layer.asset.value().image(); + QVERIFY(image.width() <= 20 && image.height() <= 20); + QVERIFY(layer.transform.origin.x() >= 290 && layer.transform.origin.y() >= 90); + const QImage result = render(*session); + for (int x = 288; x <= 312; ++x) + QCOMPARE(pixel(result, x, 100), pixel(shown, x, 100)); + session->undo(); + QVERIFY(!session->activeLayer().value().asset.has_value()); + session->redo(); + QCOMPARE(session->activeLayer().value().transform, layer.transform); + } +} + +void BrushSessionTests::opacityCapsTheWholeStrokeEvenWhereItOverlapsItself() +{ + const auto session = makeSession(200, 80); + session->setBrushSettings(brush(20, 1, 1, 0, 0, 0.5)); + session->beginBrush(QPointF(20, 40)); + for (int x : {180, 20, 180, 20, 100}) + session->continueBrush(QPointF(x, 40)); + const std::vector<int> shown = pixel(live(*session), 100, 40); + QVERIFY(std::abs(shown[3] - 128) <= 1 && std::abs(shown[0] - 128) <= 1 && shown[1] == 0); + session->finishBrush(); + const QImage result = render(*session); + QCOMPARE(pixel(result, 100, 40), shown); + QCOMPARE(alpha(result, 100, 0), 0); +} + +void BrushSessionTests::liveStrokeReachesNewestSampleAndTailIsReplacedExactly() +{ + const auto session = makeSession(300, 120); + session->setBrushSettings(withTip(session->brushSettings(), 8, 1)); + session->beginBrush(QPointF(20, 60)); + session->continueBrush(QPointF(150, 20)); + session->continueBrush(QPointF(280, 60)); + QCOMPARE(alpha(live(*session), 278, 60), 255); + session->finishBrush(); + const QImage result = render(*session); + QCOMPARE(alpha(result, 215, 40), 0); + QCOMPARE(alpha(result, 278, 60), 255); +} + +void BrushSessionTests::opacityAppliesToMaskPainting() +{ + const auto session = makeSession(80, 80); + session->setBrushSettings(withTip(session->brushSettings(), 200, 1)); + session->beginBrush(QPointF(40, 40)); + session->continueBrush(QPointF(41, 40)); + session->finishBrush(); + session->addLayerMask(true); + session->selectLayerTarget(session->activeLayerID().value(), true); + session->setBrushSettings(brush(20, 1, 1, 0, 0, 0.5)); + session->setMaskPaintWhite(false); + session->beginBrush(QPointF(40, 40)); + session->continueBrush(QPointF(42, 40)); + session->finishBrush(); + const QImage result = render(*session); + QVERIFY(std::abs(alpha(result, 40, 40) - 128) <= 1); + QCOMPARE(alpha(result, 5, 5), 255); +} + +void BrushSessionTests::shiftBracketsStepHardnessByQuarters() +{ + const auto session = makeSession(); + session->setBrushSettings(withTip(session->brushSettings(), 20, 0.8)); + session->changeBrushHardness(true); + QCOMPARE(session->brushSettings().hardness, 1.0); + session->changeBrushHardness(true); + QCOMPARE(session->brushSettings().hardness, 1.0); + session->setBrushSettings(withTip(session->brushSettings(), 20, 0.8)); + session->changeBrushHardness(false); + QCOMPARE(session->brushSettings().hardness, 0.75); + for (int i = 0; i < 5; ++i) + session->changeBrushHardness(false); + QCOMPARE(session->brushSettings().hardness, 0.0); + session->changeBrushHardness(true); + QCOMPARE(session->brushSettings().hardness, 0.25); + // A fifth a step, at least a pixel, in 1…2000. + session->setBrushSettings(withTip(session->brushSettings(), 2, 1)); + session->changeBrushSize(false); + QCOMPARE(session->brushSettings().diameter, 1.0); + session->changeBrushSize(false); + QCOMPARE(session->brushSettings().diameter, 1.0); + session->changeBrushSize(true); + QCOMPARE(session->brushSettings().diameter, 2.0); + session->setBrushSettings(withTip(session->brushSettings(), 100, 1)); + session->changeBrushSize(true); + QCOMPARE(session->brushSettings().diameter, 120.0); + session->setBrushSettings(withTip(session->brushSettings(), 1990, 1)); + session->changeBrushSize(true); + QCOMPARE(session->brushSettings().diameter, 2000.0); +} + +void BrushSessionTests::numberKeysSetBrushOpacity() +{ + const auto session = makeSession(); + session->typeOpacityDigit(5, 10); + QCOMPARE(session->brushSettings().opacity, 0.5); + session->typeOpacityDigit(0, 20); + QCOMPARE(session->brushSettings().opacity, 1.0); + session->typeOpacityDigit(4, 30); + session->typeOpacityDigit(5, 30.3); + QCOMPARE(session->brushSettings().opacity, 0.45); + session->typeOpacityDigit(0, 40); + session->typeOpacityDigit(5, 40.2); + QCOMPARE(session->brushSettings().opacity, 0.05); + // Move's digits go to the layers, not the tip. + session->selectTool(NavigationTool::move); + session->typeOpacityDigit(3, 60); + QCOMPARE(session->brushSettings().opacity, 0.05); + QCOMPARE(session->activeLayer().value().opacity, 0.3); + session->selectTool(NavigationTool::lasso); + session->typeOpacityDigit(7, 70); + QCOMPARE(session->activeLayer().value().opacity, 0.3); + // Without a time the clock is the session's own. + session->selectTool(NavigationTool::brush); + session->typeOpacityDigit(9); + QCOMPARE(session->brushSettings().opacity, 0.9); +} + +void BrushSessionTests::largeBlankCanvasOnlyAllocatesTouchedTilesUntilCommit() +{ + const auto session = makeSession(10000, 10000); + session->beginBrush(QPointF(100, 100)); + const std::vector<BrushPatch> patches = session->brushStroke()->patches(); + QCOMPARE(patches.size(), size_t(1)); + QVERIFY(patches[0].image.sizeInBytes() <= 256 * 256 * 4); + QVERIFY(!session->activeLayer().value().asset.has_value()); + session->cancelBrush(); +} + +void BrushSessionTests::foldersHiddenLayersAndDisabledMasksRejectPainting() +{ + const auto session = makeSession(); + session->addGroup(); + session->beginBrush(QPointF(10, 10)); + QVERIFY(!session->brushStroke()); + session->selectLayer(session->document().value().layers.front().id); + session->toggleLayerVisibility(session->activeLayerID().value()); + QVERIFY(!session->canPaint()); + session->toggleLayerVisibility(session->activeLayerID().value()); + QVERIFY(session->canPaint()); + session->addLayerMask(); + session->toggleLayerMask(); + QVERIFY(!session->canPaint()); + // Another tool paints nothing; an empty selection neither. + session->toggleLayerMask(); + session->selectTool(NavigationTool::move); + session->beginBrush(QPointF(10, 10)); + QVERIFY(!session->brushStroke()); + session->selectTool(NavigationTool::brush); + session->applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select"); + session->applySelection(rectPath(QRectF(0, 0, 600, 80)), SelectionMode::subtract, "Subtract"); + QVERIFY(!session->canPaint()); +} + +QTEST_GUILESS_MAIN(BrushSessionTests) +#include "BrushSessionTests.moc" diff --git a/tests/BrushTests.cpp b/tests/BrushTests.cpp new file mode 100644 index 0000000..60f9c9d --- /dev/null +++ b/tests/BrushTests.cpp @@ -0,0 +1,476 @@ +#include "AddressSpaceLimit.h" +#include "BrushFixtures.h" +#include "Document/LayerMask.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <malloc.h> + +// The stroke engine alone; the session's brush arrives with 8.2. +class BrushTests : public QObject { + Q_OBJECT +private slots: + // Large asks map memory, so the address limit counts them. + void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); } + void continuousStrokeCrossesTiles(); + void softBrushProducesPartialAlphaAndPaintsNothingOffTheCanvas(); + void brushStaysCircularOnNonuniformRotatedFlippedLayer(); + void maskedImagePaintingUsesCoverageAndOpacityOnlyOnce(); + void paintedBoundsTrimTilePaddingAndKeepSoftEdges(); + void opacityCapsTheWholeStrokeEvenWhereItOverlapsItself(); + void softStrokeBuildsCoverageWhileKeepingItsFeatheredRim(); + void spacedDabsLeaveNoVisibleRippleAlongTheStroke(); + void sparseMouseSamplesFollowACurveInsteadOfStraightChords(); + void liveStrokeReachesNewestSampleAndTailIsReplacedExactly(); + void largeBlankCanvasOnlyAllocatesTouchedTiles(); + void aSelectionClipLimitsTheStroke(); + void aSoftClipScalesEachCoverageByteOnce(); + void masksPaintInGrayAndCommitWhole(); + void wildSettingsAndPointsAreRefused(); + void hardTipsAntialiasTheirRimAndStopAtTheCanvas(); + void paintingPastTheLayerGrowsItsGrid(); + void aPaintedLayerPaintsFromItsTilesWithoutFlattening(); + void anOrdinarySourceKeepsItsPixelsBesideTheDab(); + void antialiasFollowsTheLayersScale(); + void tileKeysFitBigGridsAndACommitThatCannotCropThrows(); + void aSelectionClipsOnATransformedGrid(); + void aPlacedMaskPaintsInItsOwnGrid(); +}; + +void BrushTests::continuousStrokeCrossesTiles() +{ + const auto paint = stroke(600, 80, red()); + paint->append(QPointF(20, 40)); + paint->append(QPointF(580, 40)); + QCOMPARE(paint->patches().size(), size_t(3)); + QCOMPARE(paint->dirtyDocumentRect(), std::optional(QRectF(0, 0, 600, 80))); + const QImage live = preview(*paint, QSizeF(600, 80)); + for (int x : {20, 255, 256, 511, 512, 579}) + QCOMPARE(pixel(live, x, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(alpha(live, 300, 0), 0); + // The commit keeps every pixel, the tiles' padding cut away. + const BrushCommit::Output made = BrushCommit::render(paint->commitInput()); + QCOMPARE(made.pixelBounds, QRectF(10, 30, 580, 20)); + const QImage result = render(made.asset, paint->transform(made.pixelBounds.translated(paint->committedBounds().topLeft())), QSizeF(600, 80)); + for (int x : {20, 255, 256, 511, 512, 579}) + QCOMPARE(pixel(result, x, 40), (std::vector<int>{255, 0, 0, 255})); +} + +void BrushTests::softBrushProducesPartialAlphaAndPaintsNothingOffTheCanvas() +{ + const auto paint = stroke(80, 80, red(40, 0)); + paint->append(QPointF(40, 40)); + const QImage live = preview(*paint, QSizeF(80, 80)); + QVERIFY(alpha(live, 40, 40) > 230); + // Half at half radius, faint near the rim, none beyond. + const int half = alpha(live, 50, 40); + QVERIFY2(half > 95 && half < 140, qPrintable(QString::number(half))); + QVERIFY(alpha(live, 57, 40) < 40); + QCOMPARE(alpha(live, 64, 40), 0); + const auto outside = stroke(80, 80, red(40, 0)); + outside->append(QPointF(-100, -100)); + QVERIFY(outside->patches().empty() && !outside->dirtyDocumentRect().has_value()); +} + +void BrushTests::brushStaysCircularOnNonuniformRotatedFlippedLayer() +{ + QImage source = BrushRaster::context(100, 100, false); + QPainter painter(&source); + painter.fillRect(QRect(2, 2, 6, 6), Qt::red); + painter.end(); + ImageLayer layer(ImportedImage(source, source, "Fixture"), QPointF(0, 0)); + layer.transform = LayerTransform{.origin = {25, -50}, .size = {50, 200}, .rotation = 90, .flipX = true, .flipY = true, .sampling = LayerSampling::nearest}; + BrushStroke paint(layer, false, brush(16, 1, 0, 1, 0), QSizeF(100, 100)); + paint.append(QPointF(50, 50)); + const QImage live = preview(paint, QSizeF(100, 100)); + for (const auto &[x, y] : std::vector<std::pair<int, int>>{{50, 50}, {54, 50}, {50, 54}}) + QVERIFY2(pixel(live, x, y)[1] > 240, qPrintable(QStringLiteral("%1,%2").arg(x).arg(y))); + const BrushCommit::Output made = BrushCommit::render(paint.commitInput()); + const QImage result = render(made.asset, paint.transform(made.pixelBounds.translated(paint.committedBounds().topLeft())), QSizeF(100, 100)); + for (const auto &[x, y] : std::vector<std::pair<int, int>>{{50, 50}, {54, 50}, {50, 54}}) + QVERIFY2(pixel(result, x, y)[1] > 240, qPrintable(QStringLiteral("%1,%2").arg(x).arg(y))); + for (const auto &[x, y] : std::vector<std::pair<int, int>>{{61, 50}, {50, 61}}) + QCOMPARE(alpha(result, x, y), 0); +} + +void BrushTests::maskedImagePaintingUsesCoverageAndOpacityOnlyOnce() +{ + ImageLayer layer = blankLayer(520, 40); + layer.mask = LayerMask(gray(128)); + layer.opacity = 0.5; + BrushStroke paint(layer, false, red(), QSizeF(520, 40)); + paint.append(QPointF(240, 20)); + paint.append(QPointF(280, 20)); + const QImage live = preview(paint, QSizeF(520, 40)); + for (int x : {250, 255, 256, 260}) + QVERIFY2(std::abs(alpha(live, x, 20) - 64) <= 1, qPrintable(QString::number(alpha(live, x, 20)))); +} + +void BrushTests::paintedBoundsTrimTilePaddingAndKeepSoftEdges() +{ + for (double hardness : {0.0, 1.0}) { + const auto paint = stroke(600, 200, red(20, hardness)); + paint->append(QPointF(300, 100)); + const QImage live = preview(*paint, QSizeF(600, 200)); + const PaintSnapshot snapshot = paint->paintSnapshot(); + // The snapshot is tiles; reading its pixels flattens it. + QVERIFY(!snapshot.asset.raster->hasMaterializedPixels()); + const QImage image = snapshot.asset.image(); + QVERIFY(snapshot.asset.raster->hasMaterializedPixels()); + QVERIFY(image.width() <= 20 && image.height() <= 20); + QVERIFY(snapshot.transform.origin.x() >= 290 && snapshot.transform.origin.y() >= 90); + QCOMPARE(snapshot.bounds.size(), QSizeF(image.size())); + // Every edge of the crop holds a pixel. + const auto edgeHolds = [&](int x0, int y0, int dx, int dy, int count) { + for (int i = 0; i < count; ++i) { + if (alpha(image, x0 + dx * i, y0 + dy * i) > 0) + return true; + } + return false; + }; + QVERIFY(edgeHolds(0, 0, 1, 0, image.width()) && edgeHolds(0, image.height() - 1, 1, 0, image.width())); + QVERIFY(edgeHolds(0, 0, 0, 1, image.height()) && edgeHolds(image.width() - 1, 0, 0, 1, image.height())); + const QImage result = render(snapshot.asset, snapshot.transform, QSizeF(600, 200)); + for (int x = 288; x <= 312; ++x) + QCOMPARE(pixel(result, x, 100), pixel(live, x, 100)); + } +} + +void BrushTests::opacityCapsTheWholeStrokeEvenWhereItOverlapsItself() +{ + const auto paint = stroke(200, 80, brush(20, 1, 1, 0, 0, 0.5)); + paint->append(QPointF(20, 40)); + for (int x : {180, 20, 180, 20, 100}) + paint->append(QPointF(x, 40)); + const QImage live = preview(*paint, QSizeF(200, 80)); + const std::vector<int> shown = pixel(live, 100, 40); + QVERIFY2(std::abs(shown[3] - 128) <= 1 && std::abs(shown[0] - 128) <= 1 && shown[1] == 0, qPrintable(QStringLiteral("%1 %2").arg(shown[0]).arg(shown[3]))); + paint->flush(); + const BrushCommit::Output made = BrushCommit::render(paint->commitInput()); + const QImage result = render(made.asset, paint->transform(made.pixelBounds.translated(paint->committedBounds().topLeft())), QSizeF(200, 80)); + QCOMPARE(pixel(result, 100, 40), shown); + QCOMPARE(alpha(result, 100, 0), 0); +} + +void BrushTests::softStrokeBuildsCoverageWhileKeepingItsFeatheredRim() +{ + const auto dab = stroke(200, 80, red(40, 0)); + dab->append(QPointF(100, 40)); + const int single = alpha(preview(*dab, QSizeF(200, 80)), 100, 50); + const auto run = stroke(200, 80, red(40, 0)); + run->append(QPointF(20, 40)); + run->append(QPointF(180, 40)); + const int along = alpha(preview(*run, QSizeF(200, 80)), 100, 50); + QVERIFY2(along > single + 60, qPrintable(QStringLiteral("%1 vs %2").arg(along).arg(single))); + QVERIFY(along <= 255); +} + +void BrushTests::spacedDabsLeaveNoVisibleRippleAlongTheStroke() +{ + for (double hardness : {0.0, 0.5, 1.0}) { + const auto paint = stroke(900, 300, red(120, hardness)); + paint->append(QPointF(100, 150)); + paint->append(QPointF(800, 150)); + const QImage image = preview(*paint, QSizeF(900, 300)); + // Along the middle of the stroke, and nearer its edge. + for (int offset : {0, 30, 50}) { + int low = 255, high = 0; + for (int x = 300; x <= 600; ++x) { + low = std::min(low, alpha(image, x, 150 + offset)); + high = std::max(high, alpha(image, x, 150 + offset)); + } + QVERIFY2(high - low <= 16, qPrintable(QStringLiteral("hardness %1 offset %2 rippled by %3").arg(hardness).arg(offset).arg(high - low))); + } + } +} + +void BrushTests::sparseMouseSamplesFollowACurveInsteadOfStraightChords() +{ + const auto paint = stroke(300, 300, red(4)); + const QPointF center(150, 150); + const auto onCircle = [&](double degrees) { + return QPointF(center.x() + std::cos(degrees * M_PI / 180) * 100, center.y() + std::sin(degrees * M_PI / 180) * 100); + }; + paint->append(onCircle(0)); + for (int degrees = 30; degrees <= 180; degrees += 30) + paint->append(onCircle(degrees)); + paint->flush(); + const QImage image = preview(*paint, QSizeF(300, 300)); + // A 30°–60° chord misses the arc by 3.4 px. + for (double degrees : {15.0, 45.0, 75.0, 105.0, 135.0}) { + const QPointF point = onCircle(degrees); + QVERIFY2(alpha(image, int(point.x()), int(point.y())) > 0, qPrintable(QStringLiteral("arc at %1").arg(degrees))); + } +} + +void BrushTests::liveStrokeReachesNewestSampleAndTailIsReplacedExactly() +{ + const auto paint = stroke(300, 120, red(8)); + paint->append(QPointF(20, 60)); + paint->append(QPointF(150, 20)); + paint->append(QPointF(280, 60)); + // No lag: the provisional tail already reaches the cursor. + QCOMPARE(alpha(preview(*paint, QSizeF(300, 120)), 278, 60), 255); + paint->flush(); + const QImage settled = preview(*paint, QSizeF(300, 120)); + QCOMPARE(alpha(settled, 215, 40), 0); + QCOMPARE(alpha(settled, 278, 60), 255); + // Flushing again changes nothing. + paint->flush(); + QCOMPARE(preview(*paint, QSizeF(300, 120)), settled); +} + +void BrushTests::largeBlankCanvasOnlyAllocatesTouchedTiles() +{ + const auto paint = stroke(10000, 10000, red()); + paint->append(QPointF(100, 100)); + const std::vector<BrushPatch> patches = paint->patches(); + QCOMPARE(patches.size(), size_t(1)); + QVERIFY(patches[0].image.sizeInBytes() <= 256 * 256 * 4); + // The pixel budget refuses a stroke needing too many tiles. + const auto starved = stroke(10000, 10000, red()); + starved->pixelLimit = 100'000; + starved->append(QPointF(100, 100)); + QVERIFY_THROWS_EXCEPTION(ProjectError, starved->append(QPointF(9000, 9000))); +} + +void BrushTests::aSelectionClipLimitsTheStroke() +{ + const auto paint = stroke(600, 80, red()); + DocumentSelection selection; + selection.path.addRect(QRectF(0, 0, 300, 80)); + paint->selectionClip = selection.clip(QSizeF(600, 80)); + paint->append(QPointF(20, 40)); + paint->append(QPointF(580, 40)); + const QImage live = preview(*paint, QSizeF(600, 80)); + QCOMPARE(alpha(live, 100, 40), 255); + QCOMPARE(alpha(live, 400, 40), 0); + QCOMPARE(alpha(live, 299, 40), 255); + QCOMPARE(alpha(live, 300, 40), 0); +} + +void BrushTests::aSoftClipScalesEachCoverageByteOnce() +{ + // A soft dab through a soft outline: coverage times clip. + DocumentSelection selection; + selection.path.addEllipse(QRectF(10.3, 12.7, 50.4, 41.1)); + const SelectionClip clip = selection.clip(QSizeF(80, 80)); + const QImage within = PixelAdjust::coverage(clip, 80, 80, QTransform()); + const auto open = stroke(80, 80, red(60, 0)); + const auto clipped = stroke(80, 80, red(60, 0)); + clipped->selectionClip = clip; + open->append(QPointF(40, 40)); + clipped->append(QPointF(40, 40)); + const BrushPatch whole = open->patches().at(0), cut = clipped->patches().at(0); + QCOMPARE(cut.rect, QRectF(0, 0, 80, 80)); + int partial = 0; + for (int y = 0; y < 80; ++y) { + for (int x = 0; x < 80; ++x) { + const int coverage = whole.image.constScanLine(y)[x * 4 + 3], edge = within.constScanLine(y)[x]; + partial += coverage > 0 && coverage < 255 && edge > 0 && edge < 255; + QCOMPARE(int(cut.image.constScanLine(y)[x * 4 + 3]), (coverage * edge + 127) / 255); + } + } + QVERIFY(partial > 50); +} + +void BrushTests::masksPaintInGrayAndCommitWhole() +{ + QImage black = BrushRaster::context(600, 80, true); + ImageLayer layer = blankLayer(600, 80); + layer.mask = LayerMask(LayerMask::assetFrom(black)); + BrushStroke paint(layer, true, brush(20, 1, 1, 1, 1), QSizeF(600, 80)); + QCOMPARE(QSize(paint.width, paint.height), QSize(600, 80)); + paint.append(QPointF(300, 40)); + QCOMPARE(paint.patches()[0].image.format(), QImage::Format_Grayscale8); + const BrushCommit::Output made = BrushCommit::render(paint.commitInput()); + QCOMPARE(made.pixelBounds, QRectF(0, 0, 600, 80)); + const QImage mask = made.asset.image(); + QCOMPARE(mask.format(), QImage::Format_Grayscale8); + QCOMPARE(int(mask.constScanLine(40)[300]), 255); + QCOMPARE(int(mask.constScanLine(40)[100]), 0); + // A mask paints even when the settings say erase. + BrushSettings erasing = brush(20, 1, 1, 1, 1); + erasing.erasing = true; + BrushStroke painted(layer, true, erasing, QSizeF(600, 80)); + painted.append(QPointF(300, 40)); + QCOMPARE(int(BrushCommit::render(painted.commitInput()).asset.image().constScanLine(40)[300]), 255); + // A mask grown past its old grid reveals: white beyond. + const ImportedImage expanded = BrushCommit::expandMask(made.asset, paint.commitInput(), QRectF(-10, 0, 610, 80)); + QCOMPARE(expanded.size(), QSize(610, 80)); + QCOMPARE(int(expanded.image().constScanLine(40)[5]), 255); + QCOMPARE(int(expanded.image().constScanLine(40)[110]), 0); +} + +void BrushTests::wildSettingsAndPointsAreRefused() +{ + for (const BrushSettings &settings : {red(0), red(2001), red(NAN), red(20, 1.1), red(20, -0.1), brush(20, 1, 1, 0, 0, 0.001), brush(20, 1, 1, 0, 0, INFINITY)}) + QVERIFY_THROWS_EXCEPTION(ProjectError, BrushStroke(blankLayer(600, 80), false, settings, QSizeF(600, 80))); + // A billion-pixel grid is refused while still a double. + QImage strip = BrushRaster::context(30000, 1, false); + ImageLayer far(ImportedImage(strip, strip, "Strip"), QPointF(100000, 0)); + far.transform.size = QSizeF(1, 1); + QVERIFY_THROWS_EXCEPTION(ProjectError, BrushStroke(far, false, red(), QSizeF(10000, 10000))); + const auto paint = stroke(600, 80, red()); + paint->append(QPointF(NAN, 40)); + paint->append(QPointF(20, 20'000'000)); + QVERIFY(paint->patches().empty()); + paint->append(QPointF(20, 40)); + paint->append(QPointF(20, 40)); + QCOMPARE(paint->patches().size(), size_t(1)); + QCOMPARE(BrushStroke::spacingFraction(1), 0.015); + QCOMPARE(BrushStroke::spacingFraction(0.5), 0.025); +} + +void BrushTests::hardTipsAntialiasTheirRimAndStopAtTheCanvas() +{ + const auto paint = stroke(200, 80, red()); + paint->append(QPointF(100.5, 40.5)); + const QImage live = preview(*paint, QSizeF(200, 80)); + // A pixel centred on the rim is half covered. + QCOMPARE(alpha(live, 110, 40), 128); + QCOMPARE(alpha(live, 109, 40), 255); + QCOMPARE(alpha(live, 111, 40), 0); + // A layer past the canvas takes no paint out there. + ImageLayer wide = blankLayer(600, 80); + wide.transform.origin = QPointF(-100, 0); + BrushStroke edge(wide, false, red(), QSizeF(600, 80)); + edge.append(QPointF(5, 40)); + const BrushCommit::Output made = BrushCommit::render(edge.commitInput()); + // The crop begins at the canvas edge: grid pixel 100. + const QPointF shift = edge.committedBounds().topLeft() + made.pixelBounds.topLeft(); + QCOMPARE(shift, QPointF(100, 30)); + QCOMPARE(alpha(made.asset.image(), 105 - int(shift.x()), 40 - int(shift.y())), 255); + QCOMPARE(alpha(made.asset.image(), 0, 10), 255); +} + +void BrushTests::paintingPastTheLayerGrowsItsGrid() +{ + QImage small = BrushRaster::context(20, 20, false); + small.fill(Qt::blue); + const ImageLayer layer(ImportedImage(small, small, "Small"), QPointF(10, 10)); + BrushStroke paint(layer, false, red(), QSizeF(600, 80)); + QCOMPARE(QSize(paint.width, paint.height), QSize(600, 80)); + QCOMPARE(paint.sourceRect, QRectF(10, 10, 20, 20)); + paint.append(QPointF(300, 40)); + const PaintSnapshot snapshot = paint.paintSnapshot(); + // The blue stays put; the dab lands past it. + QCOMPARE(snapshot.bounds, QRectF(10, 10, 300, 40)); + const QImage result = render(snapshot.asset, snapshot.transform, QSizeF(600, 80)); + QCOMPARE(pixel(result, 15, 15), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(pixel(result, 300, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(alpha(result, 300, 20), 0); +} + +void BrushTests::aPaintedLayerPaintsFromItsTilesWithoutFlattening() +{ + QImage base = BrushRaster::context(600, 80, false); + base.fill(Qt::blue); + const auto raster = std::make_shared<const RasterSnapshot>(600, 80, base, QRectF(0, 0, 600, 80), std::vector<BrushPatch>{}); + const ImageLayer layer(ImportedImage(raster, PixelAdjust::thumbnail(base), "Painted"), QPointF(0, 0)); + BrushStroke paint(layer, false, red(), QSizeF(600, 80)); + paint.append(QPointF(300, 40)); + const QImage live = preview(paint, QSizeF(600, 80)); + QCOMPARE(pixel(live, 300, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(live, 100, 40), (std::vector<int>{0, 0, 255, 255})); + // Inside the touched tile, beside the dab: the base stays. + QCOMPARE(pixel(live, 280, 5), (std::vector<int>{0, 0, 255, 255})); + QVERIFY(!raster->hasMaterializedPixels()); + const PaintSnapshot snapshot = paint.paintSnapshot(); + QCOMPARE(snapshot.bounds, QRectF(0, 0, 600, 80)); + QVERIFY(!raster->hasMaterializedPixels() && !snapshot.asset.raster->hasMaterializedPixels()); + const BrushCommit::Output made = BrushCommit::render(paint.commitInput()); + QVERIFY(!raster->hasMaterializedPixels()); + QCOMPARE(pixel(made.asset.image(), 300, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(made.asset.image(), 280, 5), (std::vector<int>{0, 0, 255, 255})); +} + +void BrushTests::anOrdinarySourceKeepsItsPixelsBesideTheDab() +{ + QImage blue = BrushRaster::context(600, 80, false); + blue.fill(Qt::blue); + const ImageLayer layer(ImportedImage(blue, blue, "Blue"), QPointF(0, 0)); + BrushStroke paint(layer, false, red(), QSizeF(600, 80)); + paint.append(QPointF(300, 40)); + const QImage live = preview(paint, QSizeF(600, 80)); + QCOMPARE(pixel(live, 300, 40), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(live, 280, 5), (std::vector<int>{0, 0, 255, 255})); + const BrushCommit::Output made = BrushCommit::render(paint.commitInput()); + QCOMPARE(made.pixelBounds, QRectF(0, 0, 600, 80)); + QCOMPARE(pixel(made.asset.image(), 280, 5), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(pixel(made.asset.image(), 300, 40), (std::vector<int>{255, 0, 0, 255})); +} + +void BrushTests::antialiasFollowsTheLayersScale() +{ + // A 64 px source shown at 128: a two-pixel rim. + QImage small = BrushRaster::context(64, 64, false); + ImageLayer layer(ImportedImage(small, small, "Small"), QPointF(0, 0)); + layer.transform.size = QSizeF(128, 128); + BrushStroke paint(layer, false, red(), QSizeF(128, 128)); + paint.append(QPointF(64.5, 65)); + const std::vector<BrushPatch> patches = paint.patches(); + QCOMPARE(patches.size(), size_t(1)); + QCOMPARE(alpha(patches[0].image, 37, 32), 64); +} + +void BrushTests::tileKeysFitBigGridsAndACommitThatCannotCropThrows() +{ + if (!placesStarvation()) + QSKIP("the failure point is placed for Qt 6.4's allocations"); + // A tiny layer far across a huge canvas: big keys. + QImage image = BrushRaster::context(1200, 1200, false); + image.fill(Qt::blue); + ImageLayer layer(ImportedImage(image, image, "Far"), QPointF(9999, 9999)); + layer.transform.size = QSizeF(1, 1); + BrushStroke far(layer, false, red(1), QSizeF(10000, 10000)); + far.append(QPointF(9999.5, 9999.5)); + // A 1 px tip spans 1200 layer pixels: 15 tiles. + QCOMPARE(far.patches().size(), size_t(225)); + // A crop that cannot allocate is a render error. + const auto wide = stroke(3000, 3000, red(2000, 1)); + wide->append(QPointF(1500, 1500)); + const BrushCommit::Input input = wide->commitInput(); + malloc_trim(0); + const AddressSpaceLimit limit(qint64(input.width) * input.height * 4 + 4 * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, BrushCommit::render(input)); +} + +void BrushTests::aSelectionClipsOnATransformedGrid() +{ + // Source at 128 from (40,20): the clip follows. + QImage small = BrushRaster::context(64, 64, false); + ImageLayer layer(ImportedImage(small, small, "Small"), QPointF(40, 20)); + layer.transform.size = QSizeF(128, 128); + BrushStroke paint(layer, false, red(), QSizeF(200, 200)); + DocumentSelection selection; + selection.path.addRect(QRectF(80, 0, 120, 200)); + paint.selectionClip = selection.clip(QSizeF(200, 200)); + paint.append(QPointF(80, 80)); + const QImage live = preview(paint, QSizeF(200, 200)); + QCOMPARE(alpha(live, 85, 80), 255); + QCOMPARE(alpha(live, 75, 80), 0); +} + +void BrushTests::aPlacedMaskPaintsInItsOwnGrid() +{ + QImage image = BrushRaster::context(64, 64, false); + image.fill(Qt::blue); + ImageLayer layer(ImportedImage(image, image, "Blue"), QPointF(0, 0)); + QImage black = BrushRaster::context(16, 16, true); + // A 16 px mask at 32 from (80,40): own grid. + layer.mask = LayerMask(LayerMask::assetFrom(black), true, LayerTransform{.origin = {80, 40}, .size = {32, 32}}, false); + BrushStroke paint(layer, true, brush(8, 1, 1, 1, 1), QSizeF(200, 200)); + QCOMPARE(QSize(paint.width, paint.height), QSize(16, 16)); + QCOMPARE(paint.paintTransform, (LayerTransform{.origin = {80, 40}, .size = {32, 32}})); + paint.append(QPointF(88, 48)); + const BrushCommit::Output made = BrushCommit::render(paint.commitInput()); + const QImage mask = made.asset.image(); + QCOMPARE(mask.size(), QSize(16, 16)); + QCOMPARE(int(mask.constScanLine(4)[4]), 255); + QCOMPARE(int(mask.constScanLine(12)[12]), 0); +} + +QTEST_GUILESS_MAIN(BrushTests) +#include "BrushTests.moc" diff --git a/tests/ByteCountsTests.cpp b/tests/ByteCountsTests.cpp new file mode 100644 index 0000000..3308868 --- /dev/null +++ b/tests/ByteCountsTests.cpp @@ -0,0 +1,39 @@ +#include "UI/ByteCounts.h" +#include <QtTest> + +// Foundation's byte counts: units, places and grouping. +class ByteCountsTests : public QObject { + Q_OBJECT +private slots: + void memoryCountsInBinaryUnits(); + void filesCountInThousands(); +}; + +void ByteCountsTests::memoryCountsInBinaryUnits() +{ + QCOMPARE(ByteCounts::memory(4), QString("4 bytes")); + QCOMPARE(ByteCounts::memory(1023), QString("1,023 bytes")); + QCOMPARE(ByteCounts::memory(1024), QString("1 KB")); + QCOMPARE(ByteCounts::memory(10 * 1024), QString("10 KB")); + QCOMPARE(ByteCounts::memory(1000 * 1024), QString("1,000 KB")); + QCOMPARE(ByteCounts::memory(1024 * 1024), QString("1 MB")); + QCOMPARE(ByteCounts::memory(3 * 1024 * 1024 + 300 * 1024), QString("3.3 MB")); + QCOMPARE(ByteCounts::memory(qint64(30'000) * 30'000 * 4), QString("3.35 GB")); + QCOMPARE(ByteCounts::memory(qint64(2) * 1024 * 1024 * 1024 + 10 * 1024 * 1024), QString("2.01 GB")); + QCOMPARE(ByteCounts::memory(qint64(2) * 1024 * 1024 * 1024 + 100 * 1024 * 1024), QString("2.1 GB")); +} + +void ByteCountsTests::filesCountInThousands() +{ + QCOMPARE(ByteCounts::file(631), QString("631 bytes")); + QCOMPARE(ByteCounts::file(999), QString("999 bytes")); + QCOMPARE(ByteCounts::file(1000), QString("1 KB")); + QCOMPARE(ByteCounts::file(20'400), QString("20 KB")); + QCOMPARE(ByteCounts::file(999'999), QString("1,000 KB")); + QCOMPARE(ByteCounts::file(1'000'000), QString("1 MB")); + QCOMPARE(ByteCounts::file(2'345'678), QString("2.3 MB")); + QCOMPARE(ByteCounts::file(40'020'000), QString("40 MB")); +} + +QTEST_APPLESS_MAIN(ByteCountsTests) +#include "ByteCountsTests.moc" diff --git a/tests/CanvasDisplayTests.cpp b/tests/CanvasDisplayTests.cpp new file mode 100644 index 0000000..95a1474 --- /dev/null +++ b/tests/CanvasDisplayTests.cpp @@ -0,0 +1,423 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include <QtTest> + +// What the canvas paints: the checkerboard, the layers, the grid. +namespace { +ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name) +{ + const QImage image = solid(width, height, premultiplied); + return ImportedImage(image, image, name); +} + +// A sized canvas whose viewport is set by hand. +struct Scene { + EditorSession session; + CanvasView canvas{session}; + Scene(int width, int height, int canvasWidth, int canvasHeight, double zoom = 1) + { + session.createDocument(width, height); + // Pixels alone: Swift's overlay view draws the box apart. + session.setShowsTransformControls(false); + // The built-in dark's Base, Swift's surround gray. + QPalette dark = canvas.palette(); + dark.setColor(QPalette::Base, QColor(0x1b, 0x1b, 0x1b)); + canvas.setPalette(dark); + canvas.resize(canvasWidth, canvasHeight); + session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height)); + session.zoom(zoom); + } + QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); } + QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); } +}; + +// The worst channel gap, pixel centre against the export. +int gapToExport(Scene &scene, const std::function<bool(int)> &counts = [](int) { return true; }) +{ + const QImage exported = ImageExporter::render(scene.session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); + const QImage shot = scene.shot(); + const QPointF origin = scene.origin(); + const double zoom = scene.session.viewport.pointsPerPixel(); + int worst = 0; + for (int y = 1; y < exported.height() - 1; ++y) { + for (int x = 1; x < exported.width() - 1; ++x) { + if (!counts(x)) + continue; + const QRgb expected = exported.pixel(x, y), actual = shot.pixel(int(origin.x() + (x + 0.5) * zoom), int(origin.y() + (y + 0.5) * zoom)); + worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)), + std::abs(qBlue(expected) - qBlue(actual))}); + } + } + return worst; +} + +int level(const QImage &image, int x, int y) +{ + return qRed(image.pixel(x, y)); +} + +// A two by two layer: black column, white column. +ImportedImage blackWhite() +{ + QImage image = BrushRaster::context(2, 2, false); + for (int y = 0; y < 2; ++y) { + image.setPixel(0, y, qRgba(0, 0, 0, 255)); + image.setPixel(1, y, qRgba(255, 255, 255, 255)); + } + return ImportedImage(image, image, "Edge"); +} +} + +class CanvasDisplayTests : public QObject { + Q_OBJECT +private slots: + void theBackgroundCheckerboardShadowAndBorderSurroundTheDocument(); + void layersCompositeAsTheExportDoes(); + void blendModesMeetNoCheckerboardButTranslucencyDoes(); + void thePixelGridAppearsFromEightHundredPercent(); + void fromTwoHundredPercentPixelsAreHardEdged(); + void aPartialRepaintKeepsTheTilesAligned(); + void theLiveCompositeDrawsWhatTheExportDraws(); + void adjustmentsShowAsTheExportDoes(); + void anAdjustmentsMaskClipsItOnTheCanvas(); + void grainFollowsTheViewOnTheCanvas(); +}; + +void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument() +{ + Scene scene(100, 50, 300, 200); + QCOMPARE(scene.origin(), QPointF(100, 75)); + const QImage shot = scene.shot(); + QCOMPARE(shot.size(), QSize(300, 200)); + QCOMPARE(level(shot, 10, 10), 27); + // Ten-point tiles, the even ones lighter. + QCOMPARE(level(shot, 105, 80), 89); + QCOMPARE(level(shot, 115, 80), 77); + QCOMPARE(level(shot, 125, 90), 77); + QCOMPARE(level(shot, 195, 120), 77); + QCOMPARE(level(shot, 135, 90), 89); + // The shadow darkens below and fades over fourteen points. + QVERIFY(level(shot, 150, 127) < 22); + const int fading = level(shot, 150, 135); + QVERIFY2(fading > 18 && fading < 27, qPrintable(QString::number(fading))); + QCOMPARE(level(shot, 150, 145), 27); + QCOMPARE(level(shot, 150, 60), 27); + // The border brightens the edge, astride it. + QVERIFY(std::max(level(shot, 99, 80), level(shot, 100, 80)) >= 95); +} + +// Hue, opacity, a clip, a placed mask, a folder mask. +void richScene(EditorSession &session) +{ + if (!session.linkMask(QUuid(), QUuid())) + session.insert(filled(20, 10, qRgba(0, 0, 255, 255), "Blue"), QPointF(10, 5)); + session.insert(filled(6, 4, qRgba(255, 0, 0, 255), "Red"), QPointF(5, 3)); + const QUuid red = session.activeLayerID().value(); + session.setLayerOpacity(0.5); + session.insert(filled(20, 10, qRgba(0, 255, 0, 255), "Green"), QPointF(10, 5)); + const QUuid green = session.activeLayerID().value(); + if (!session.linkMask(red, green)) + throw std::runtime_error("the green layer could not be clipped"); + session.insert(filled(8, 5, qRgba(255, 255, 0, 255), "Yellow"), QPointF(14, 5)); + const QUuid yellow = session.activeLayerID().value(); + session.setLayerBlendMode(LayerBlendMode::hue); + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + // Red's mask is soft and offset; the folder's is hard. + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, red, LayerMask::assetFrom(gray(6, 4, 100))); + record(snapshot, red).maskPlacement = LayerTransform{.origin = {3, 1}, .size = {6, 4}}; + record(snapshot, red).maskLinked = false; + setMask(snapshot, green, LayerMask::assetFrom(gray(20, 10, 180))); + QImage half = QImage(20, 10, QImage::Format_Grayscale8); + half.fill(255); + for (int y = 0; y < 10; ++y) { + for (int x = 0; x < 15; ++x) + half.setPixel(x, y, qRgb(0, 0, 0)); + } + setMask(snapshot, folder, LayerMask::assetFrom(half)); + record(snapshot, yellow).transform.origin = QPointF(11, 3); + }); +} + +void CanvasDisplayTests::layersCompositeAsTheExportDoes() +{ + Scene scene(20, 10, 60, 40); + EditorSession &session = scene.session; + richScene(session); + session.zoom(1); + const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); + const QImage shot = scene.shot(); + const QPointF origin = scene.origin(); + QCOMPARE(origin, QPointF(20, 15)); + // Inside the border's hairline every pixel is the export's. + int worst = 0; + for (int y = 1; y < 9; ++y) { + for (int x = 1; x < 19; ++x) { + const QRgb expected = exported.pixel(x, y), actual = shot.pixel(int(origin.x()) + x, int(origin.y()) + y); + QCOMPARE(qAlpha(expected), 255); + worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)), + std::abs(qBlue(expected) - qBlue(actual))}); + } + } + QVERIFY2(worst <= 1, qPrintable(QString::number(worst))); + // The edge carries half the hairline: 13% white, halved. + const int edge = qRed(shot.pixel(int(origin.x()), int(origin.y()) + 5)) - qRed(exported.pixel(0, 5)); + QVERIFY2(edge >= 15 && edge <= 18, qPrintable(QString::number(edge))); + // The scene holds hue, opacity, a live mask, two masks. + QCOMPARE(exported.pixel(0, 0), qRgb(0, 0, 255)); + const QRgb clipped = exported.pixel(4, 2); + QVERIFY(qGreen(clipped) > qRed(clipped) && qRed(clipped) > 0 && qBlue(clipped) > qGreen(clipped)); + QCOMPARE(exported.pixel(12, 4), qRgb(0, 0, 255)); + const QRgb hued = exported.pixel(16, 4); + QVERIFY(qRed(hued) == qGreen(hued) && qRed(hued) > 0 && qBlue(hued) == 0); + // A tried blend mode and an open edit both show. + const QUuid yellow = session.document().value().layers.back().id; + session.selectLayer(yellow); + session.previewBlendMode(LayerBlendMode::normal, yellow); + QCOMPARE(scene.shot().pixel(int(origin.x()) + 16, int(origin.y()) + 4), qRgb(255, 255, 0)); + session.previewBlendMode(std::nullopt, std::nullopt); + // A drag's edit: hidden controls keep the pixels bare. + session.beginTransform(false); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(0, 2); + session.previewTransform(draft); + const QImage moved = scene.shot(); + QCOMPARE(moved.pixel(int(origin.x()) + 16, int(origin.y()) + 6), hued); + QCOMPARE(moved.pixel(int(origin.x()) + 16, int(origin.y()) + 4), qRgb(0, 0, 255)); + session.cancelTransform(); +} + +void CanvasDisplayTests::blendModesMeetNoCheckerboardButTranslucencyDoes() +{ + Scene multiplied(20, 10, 60, 40); + multiplied.session.insert(filled(20, 10, qRgba(255, 255, 255, 255), "White"), QPointF(10, 5)); + multiplied.session.setLayerBlendMode(LayerBlendMode::multiply); + const QImage white = multiplied.shot(); + QCOMPARE(level(white, 25, 20), 255); + QCOMPARE(level(white, 35, 20), 255); + // Half red over the tiles: half red, half tile. + Scene translucent(20, 10, 60, 40); + translucent.session.insert(filled(20, 10, qRgba(128, 0, 0, 128), "Red"), QPointF(10, 5)); + const QImage red = translucent.shot(); + const QRgb even = red.pixel(25, 20), odd = red.pixel(35, 20); + QVERIFY(std::abs(qRed(even) - 172) <= 2 && std::abs(qGreen(even) - 44) <= 2); + QVERIFY(std::abs(qRed(odd) - 166) <= 2 && std::abs(qGreen(odd) - 38) <= 2); +} + +void CanvasDisplayTests::thePixelGridAppearsFromEightHundredPercent() +{ + Scene scene(4, 2, 80, 60, 8); + scene.session.insert(filled(4, 2, qRgba(0, 0, 0, 255), "Black"), QPointF(2, 1)); + QCOMPARE(scene.origin(), QPointF(24, 22)); + QImage shot = scene.shot(); + QCOMPARE(level(shot, 28, 26), 0); + // A hairline on each pixel boundary, half in each neighbour. + QVERIFY(level(shot, 31, 26) > 15 && level(shot, 32, 26) > 15); + QVERIFY(level(shot, 32, 26) < 60); + QVERIFY(level(shot, 28, 30) > 15 && level(shot, 28, 29) > 15); + // Half a point over: whole pixels, crossings filled once. + scene.session.viewport.translate(QSizeF(0.5, 0.5)); + shot = scene.shot(); + QCOMPARE(level(shot, 32, 27), 63); + QCOMPARE(level(shot, 28, 30), 63); + QCOMPARE(level(shot, 32, 30), 63); + QCOMPARE(level(shot, 28, 27), 0); + scene.session.viewport.translate(QSizeF(-0.5, -0.5)); + scene.session.setShowsPixelGrid(false); + shot = scene.shot(); + QCOMPARE(level(shot, 31, 26), 0); + QCOMPARE(level(shot, 32, 26), 0); + QCOMPARE(level(shot, 28, 30), 0); + // Below 800% there is no grid, whatever the flag. + scene.session.setShowsPixelGrid(true); + scene.session.zoom(7.9); + shot = scene.shot(); + const QPointF origin = scene.origin(); + for (int x = int(origin.x()) + 2; x < int(origin.x()) + 29; ++x) + QCOMPARE(level(shot, x, int(origin.y()) + 7), 0); +} + +void CanvasDisplayTests::fromTwoHundredPercentPixelsAreHardEdged() +{ + Scene scene(2, 2, 40, 20, 4); + scene.session.insert(blackWhite(), QPointF(1, 1)); + QCOMPARE(scene.origin(), QPointF(16, 6)); + QImage shot = scene.shot(); + QCOMPARE(level(shot, 19, 10), 0); + QCOMPARE(level(shot, 20, 10), 255); + scene.session.zoom(2); + shot = scene.shot(); + QCOMPARE(scene.origin(), QPointF(18, 8)); + QCOMPARE(level(shot, 19, 10), 0); + QCOMPARE(level(shot, 20, 10), 255); + // Below 200% the boundary blends into its neighbours. + scene.session.zoom(1.9); + shot = scene.shot(); + QCOMPARE(scene.origin(), QPointF(18.1, 8.1)); + const int left = level(shot, 19, 10), right = level(shot, 20, 10); + QVERIFY2(left >= 40 && left <= 80, qPrintable(QString::number(left))); + QVERIFY2(right >= 175 && right <= 215, qPrintable(QString::number(right))); +} + +void CanvasDisplayTests::aPartialRepaintKeepsTheTilesAligned() +{ + Scene scene(100, 50, 300, 200); + QImage target(300, 200, QImage::Format_ARGB32); + target.fill(Qt::magenta); + // The region lands at the offset: its own top left. + scene.canvas.render(&target, QPoint(120, 80), QRegion(QRect(120, 80, 30, 30))); + QCOMPARE(target.pixel(125, 85), qRgb(77, 77, 77)); + QCOMPARE(target.pixel(135, 85), qRgb(89, 89, 89)); + QCOMPARE(target.pixel(125, 95), qRgb(89, 89, 89)); + QCOMPARE(target.pixel(200, 10), qRgb(255, 0, 255)); +} + +void CanvasDisplayTests::theLiveCompositeDrawsWhatTheExportDraws() +{ + EditorSession session; + session.createDocument(20, 10); + richScene(session); + const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image; + QImage composite = BrushRaster::context(20, 10, false); + { + QPainter painter(&composite); + session.drawLiveComposite(session.document().value(), painter); + } + QCOMPARE(composite, exported); + // An open edit shows its draft, not the stored transform. + const QUuid yellow = session.document().value().layers.back().id; + session.selectLayer(yellow); + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(0, 2); + session.previewTransform(draft); + const auto composed = [&] { + QImage image = BrushRaster::context(20, 10, false); + QPainter painter(&image); + session.drawLiveComposite(session.document().value(), painter); + return image; + }; + const QImage moved = composed(); + QVERIFY(moved != exported); + QCOMPARE(moved.pixel(16, 6), exported.pixel(16, 4)); + QCOMPARE(moved.pixel(16, 4), qRgba(0, 0, 255, 255)); + session.cancelTransform(); + // A blend mode tried on shows as well. + session.previewBlendMode(LayerBlendMode::normal, yellow); + const QImage previewed = composed(); + QCOMPARE(previewed.pixel(16, 4), qRgba(255, 255, 0, 255)); + session.previewBlendMode(std::nullopt, std::nullopt); + QCOMPARE(composed(), exported); +} + +void CanvasDisplayTests::adjustmentsShowAsTheExportDoes() +{ + Scene scene(20, 10, 60, 40); + EditorSession &session = scene.session; + richScene(session); + const QImage before = ImageExporter::render(session.projectSnapshot().value()).image; + session.selectLayer(session.document().value().layers.back().id); + session.addAdjustment(AdjustmentKind::levels); + session.setAdjustmentEditingID(std::nullopt); + const QUuid levels = session.activeLayerID().value(); + LayerAdjustment darker{AdjustmentKind::levels}; + darker.levels.ranges[0].outputWhite = 160; + session.updateAdjustment(levels, darker); + session.zoom(1); + QVERIFY(ImageExporter::render(session.projectSnapshot().value()).image != before); + QVERIFY2(gapToExport(scene) <= 1, qPrintable(QString::number(gapToExport(scene)))); + // The adjustment layer's own changes repaint the view. + scene.canvas.synchronizeDisplay(); + QVERIFY(!scene.canvas.synchronizeDisplay()); + session.setLayerOpacity(0.5); + QVERIFY(scene.canvas.synchronizeDisplay()); + QVERIFY(gapToExport(scene) <= 1); + // Undo restores settings, no revision: still a repaint. + LayerAdjustment lighter = darker; + lighter.levels.ranges[0].outputWhite = 220; + session.beginEdit(QStringLiteral("Edit Levels")); + session.updateAdjustment(levels, lighter); + session.endEdit(); + scene.canvas.synchronizeDisplay(); + session.undo(); + QVERIFY(scene.canvas.synchronizeDisplay()); +} + +void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas() +{ + // At 200%, its left half hidden by a hard mask. + Scene scene(20, 10, 80, 60, 2); + EditorSession &session = scene.session; + session.insert(filled(20, 10, qRgba(200, 120, 40, 255), "Orange")); + session.addAdjustment(AdjustmentKind::levels); + session.setAdjustmentEditingID(std::nullopt); + const QUuid levels = session.activeLayerID().value(); + LayerAdjustment darker{AdjustmentKind::levels}; + darker.levels.ranges[0].outputWhite = 60; + session.updateAdjustment(levels, darker); + QImage half(20, 10, QImage::Format_Grayscale8); + half.fill(255); + for (int y = 0; y < 10; ++y) { + for (int x = 0; x < 10; ++x) + half.setPixel(x, y, qRgb(0, 0, 0)); + } + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, levels, LayerMask::assetFrom(half)); }); + // Installing fits the view: back to two hundred percent. + session.zoom(2); + QVERIFY2(gapToExport(scene) <= 1, qPrintable(QString::number(gapToExport(scene)))); + // Smooth at 150%, the mask takes the view's scale. + session.zoom(1.5); + QVERIFY(gapToExport(scene, [](int x) { return x < 9 || x > 10; }) <= 1); + session.zoom(2); + // A disabled mask hides nothing. + session.toggleLayerMask(); + QVERIFY(!session.activeLayer().value().mask.value().isEnabled); + QVERIFY(gapToExport(scene) <= 1); + session.toggleLayerMask(); + // Mid-stroke, new paint on the mask shows at once. + const QPointF origin = scene.origin(); + const QPoint left(int(origin.x() + 3.5 * 2), int(origin.y() + 5.5 * 2)); + const int hidden = qRed(scene.shot().pixel(left)); + session.selectLayerTarget(levels, true); + session.setMaskPaintWhite(true); + session.selectTool(NavigationTool::brush); + session.beginBrush(QPointF(3.5, 5.5)); + session.continueBrush(QPointF(4, 5.5)); + QVERIFY(session.brushStroke()); + const int painted = qRed(scene.shot().pixel(left)); + QVERIFY2(hidden == 200 && painted < 70, qPrintable(QStringLiteral("%1 %2").arg(hidden).arg(painted))); + session.cancelBrush(); +} + +void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas() +{ + // Swift's canvas hands Grain the view's region, not the document's. + Scene scene(40, 40, 120, 120); + EditorSession &session = scene.session; + session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray")); + session.addAdjustment(AdjustmentKind::grain); + session.setAdjustmentEditingID(std::nullopt); + LayerAdjustment grain = session.activeLayer().value().adjustment.value(); + GrainSettings strong = grain.grain(); + strong.amount = 100; + // Pinned: under some seeds the pan keeps one pixel's tone. + strong.seed = 7; + grain.setGrain(strong); + session.updateAdjustment(session.activeLayerID().value(), grain); + session.zoom(1); + QVERIFY(gapToExport(scene) > 20); + // A pan moves the pattern under the same document pixel. + const auto pixel = [&scene] { + const QPointF origin = scene.origin(); + return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5)); + }; + const QRgb before = pixel(); + session.viewport.translate(QSizeF(3, 0)); + QVERIFY(pixel() != before); +} + +QTEST_MAIN(CanvasDisplayTests) +#include "CanvasDisplayTests.moc" diff --git a/tests/CanvasFixtures.h b/tests/CanvasFixtures.h new file mode 100644 index 0000000..3001aeb --- /dev/null +++ b/tests/CanvasFixtures.h @@ -0,0 +1,44 @@ +#pragma once +#include "Rendering/EditorCanvas.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include <QtTest> + +// A shown canvas and the events that drive it. +inline ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name) +{ + const QImage image = solid(width, height, premultiplied); + return ImportedImage(image, image, name); +} + +// A sized canvas in a window, shown and active. +struct Shown { + EditorSession session; + QWidget window; + CanvasView *const canvas; + explicit Shown(std::optional<QSize> document = QSize(100, 100), QSize size = QSize(400, 300)) : canvas(new CanvasView(session, &window)) + { + if (document) + session.createDocument(document->width(), document->height()); + window.resize(size); + canvas->setGeometry(QRect(QPoint(0, 0), size)); + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + } + // The viewport follows the widget after the event loop. + void settle() { QTRY_COMPARE(session.viewport.viewSize, QSizeF(canvas->size())); } + QSizeF documentSize() const { return session.document().value().size(); } +}; + +inline void drag(QWidget &widget, QPointF to) +{ + QMouseEvent event(QEvent::MouseMove, to, to, widget.mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&widget, &event); +} + +inline void wheel(QWidget &widget, QPointF at, QPoint pixels, QPoint angle, Qt::KeyboardModifiers modifiers) +{ + QWheelEvent event(at, widget.mapToGlobal(at.toPoint()), pixels, angle, Qt::NoButton, modifiers, Qt::NoScrollPhase, false); + QApplication::sendEvent(&widget, &event); +} diff --git a/tests/CanvasNavigationTests.cpp b/tests/CanvasNavigationTests.cpp new file mode 100644 index 0000000..fafb25d --- /dev/null +++ b/tests/CanvasNavigationTests.cpp @@ -0,0 +1,171 @@ +#include "CanvasFixtures.h" +#include <QSignalSpy> + +// The canvas: Zoom, Hand, Space, the wheel, a pinch. +class CanvasNavigationTests : public QObject { + Q_OBJECT +private slots: + void theZoomToolClicksAndDrags(); + void theHandToolAndSpaceDragTheView(); + void theWheelPansAndZoomsWithAModifier(); + void aPinchZoomsAboutThePointer(); +}; + +void CanvasNavigationTests::theZoomToolClicksAndDrags() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::zoom); + const QSizeF size = shown.documentSize(); + const QPointF click(120, 90); + const QPointF under = session.viewport.documentPoint(click, size); + QTest::mouseClick(&canvas, Qt::LeftButton, Qt::NoModifier, click.toPoint()); + QCOMPARE(session.viewport.zoom(), 2.0); + QCOMPARE(session.viewport.documentPoint(click, size), under); + QTest::mouseClick(&canvas, Qt::LeftButton, Qt::AltModifier, click.toPoint()); + QCOMPARE(session.viewport.zoom(), 1.0); + // Dragging 100 points doubles about the start; release adds nothing. + const QPointF start(260, 110); + const QPointF anchor = session.viewport.documentPoint(start, size); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, start.toPoint()); + drag(canvas, QPointF(360, 110)); + QCOMPARE(session.viewport.zoom(), 2.0); + QCOMPARE(session.viewport.documentPoint(start, size), anchor); + drag(canvas, QPointF(310, 110)); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), std::sqrt(2.0))); + QCOMPARE(session.viewport.documentPoint(start, size), anchor); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(310, 110)); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), std::sqrt(2.0))); + // Two points of drift still count as a click. + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + drag(canvas, QPointF(102, 100)); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(102, 100)); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), 2 * std::sqrt(2.0))); + // Busy, importing or without a document a press does nothing. + session.setIsProjectBusy(true); + QTest::mouseClick(&canvas, Qt::LeftButton, Qt::NoModifier, click.toPoint()); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), 2 * std::sqrt(2.0))); + session.setIsProjectBusy(false); + session.setIsImporting(true); + QTest::mouseClick(&canvas, Qt::LeftButton, Qt::NoModifier, click.toPoint()); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), 2 * std::sqrt(2.0))); + session.setIsImporting(false); + QTest::mouseClick(&canvas, Qt::RightButton, Qt::NoModifier, click.toPoint()); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), 2 * std::sqrt(2.0))); + Shown empty(std::nullopt); + empty.session.selectTool(NavigationTool::zoom); + QTest::mouseClick(empty.canvas, Qt::LeftButton, Qt::NoModifier, click.toPoint()); + QCOMPARE(empty.session.viewport.zoom(), 1.0); +} + +void CanvasNavigationTests::theHandToolAndSpaceDragTheView() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.selectTool(NavigationTool::hand); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + QSignalSpy changes(&session, &EditorSession::changed); + const QSizeF pan = session.viewport.pan; + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::ClosedHandCursor); + drag(canvas, QPointF(130, 120)); + QCOMPARE(session.viewport.pan, pan + QSizeF(30, 20)); + QVERIFY(!session.viewport.followsFit()); + QCOMPARE(changes.count(), 1); + drag(canvas, QPointF(135, 120)); + QCOMPARE(session.viewport.pan, pan + QSizeF(35, 20)); + QCOMPARE(changes.count(), 2); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(135, 120)); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + drag(canvas, QPointF(150, 120)); + QCOMPARE(session.viewport.pan, pan + QSizeF(35, 20)); + // Space drags the view in any tool. + session.selectTool(NavigationTool::move); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(50, 50)); + drag(canvas, QPointF(60, 70)); + QCOMPARE(session.viewport.pan, pan + QSizeF(45, 40)); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(60, 70)); + QTest::keyRelease(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + // Importing, a press starts no drag. + session.selectTool(NavigationTool::hand); + session.setIsImporting(true); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(50, 50)); + drag(canvas, QPointF(90, 90)); + QCOMPARE(session.viewport.pan, pan + QSizeF(45, 40)); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(90, 90)); +} + +void CanvasNavigationTests::theWheelPansAndZoomsWithAModifier() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + const QSizeF size = shown.documentSize(); + const QPointF pointer(120, 90); + const double lines = QApplication::wheelScrollLines(); + QSizeF pan = session.viewport.pan; + // Pixels count as they are, and win over notches. + wheel(canvas, pointer, QPoint(0, -30), QPoint(0, -240), Qt::NoModifier); + pan += QSizeF(0, -30); + QCOMPARE(session.viewport.pan, pan); + QVERIFY(!session.viewport.followsFit()); + // A notch is a few lines, twelve points each. + wheel(canvas, pointer, QPoint(), QPoint(0, 120), Qt::NoModifier); + pan += QSizeF(0, 12 * lines); + QCOMPARE(session.viewport.pan, pan); + wheel(canvas, pointer, QPoint(), QPoint(-60, 0), Qt::NoModifier); + pan += QSizeF(-6 * lines, 0); + QCOMPARE(session.viewport.pan, pan); + // Ctrl or Alt zooms about the pointer, by Swift's curve. + const QPointF under = session.viewport.documentPoint(pointer, size); + wheel(canvas, pointer, QPoint(), QPoint(0, 120), Qt::ControlModifier); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), std::exp(-lines * 0.015))); + QCOMPARE(session.viewport.documentPoint(pointer, size), under); + wheel(canvas, pointer, QPoint(0, 10), QPoint(), Qt::AltModifier); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), std::exp(-lines * 0.015) * std::exp(-10 * 0.015))); + QCOMPARE(session.viewport.documentPoint(pointer, size), under); + // Without a document the wheel does nothing. + Shown empty(std::nullopt); + wheel(*empty.canvas, pointer, QPoint(0, -30), QPoint(), Qt::NoModifier); + QCOMPARE(empty.session.viewport.pan, QSizeF(0, 0)); + wheel(*empty.canvas, pointer, QPoint(), QPoint(0, 120), Qt::ControlModifier); + QCOMPARE(empty.session.viewport.zoom(), 1.0); +} + +void CanvasNavigationTests::aPinchZoomsAboutThePointer() +{ + Shown shown; + shown.settle(); + shown.session.zoom(1); + const QSizeF size = shown.documentSize(); + const QPointF pointer(120, 90); + const QPointF under = shown.session.viewport.documentPoint(pointer, size); + QNativeGestureEvent pinch(Qt::ZoomNativeGesture, QPointingDevice::primaryPointingDevice(), 2, pointer, pointer, + shown.canvas->mapToGlobal(pointer.toPoint()), 0.5, QPointF()); + QVERIFY(QApplication::sendEvent(shown.canvas, &pinch)); + QCOMPARE(shown.session.viewport.zoom(), 1.5); + QCOMPARE(shown.session.viewport.documentPoint(pointer, size), under); + // Other gestures pass on. + QNativeGestureEvent swipe(Qt::SwipeNativeGesture, QPointingDevice::primaryPointingDevice(), 3, pointer, pointer, + shown.canvas->mapToGlobal(pointer.toPoint()), 0.5, QPointF()); + QApplication::sendEvent(shown.canvas, &swipe); + QCOMPARE(shown.session.viewport.zoom(), 1.5); +} + +QTEST_MAIN(CanvasNavigationTests) +#include "CanvasNavigationTests.moc" diff --git a/tests/CanvasPerformanceTests.cpp b/tests/CanvasPerformanceTests.cpp new file mode 100644 index 0000000..ace96b9 --- /dev/null +++ b/tests/CanvasPerformanceTests.cpp @@ -0,0 +1,99 @@ +#include "CanvasFixtures.h" +#include <QElapsedTimer> +#include <algorithm> + +// Pan and zoom at 4K, ten layers: CANVAS_BENCHMARK=1, alone. +class CanvasPerformanceTests : public QObject { + Q_OBJECT +private slots: + void fourKPanAndZoom(); +}; + +namespace { +struct Timings { + std::vector<double> frames; + double median() { return at(0.5); } + double at(double share) + { + std::sort(frames.begin(), frames.end()); + return frames[std::min(frames.size() - 1, size_t(double(frames.size()) * share))]; + } +}; +} + +void CanvasPerformanceTests::fourKPanAndZoom() +{ + if (qEnvironmentVariable("CANVAS_BENCHMARK") != QStringLiteral("1")) + QSKIP("CANVAS_BENCHMARK=1 runs the benchmark"); + Shown shown(QSize(3840, 2160), QSize(1000, 1000)); + EditorSession &session = shown.session; + // Ten layers: photographs, halves, blend modes and opacities. + const LayerBlendMode modes[] = {LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::screen, LayerBlendMode::overlay, LayerBlendMode::hue}; + const int count = qEnvironmentVariableIntValue("CANVAS_LAYERS") > 0 ? qEnvironmentVariableIntValue("CANVAS_LAYERS") : 10; + for (int index = 0; index < count; ++index) { + const bool whole = index % 2 == 0; + const QImage pixels = noise(whole ? 3840 : 1920, whole ? 2160 : 1080, quint32(index + 1), whole ? 255 : 200); + session.insert(ImportedImage(pixels, pixels, QStringLiteral("Layer %1").arg(index)), QPointF(whole ? 1920 : 800 + index * 150, whole ? 1080 : 700)); + session.setLayerBlendMode(qEnvironmentVariableIsSet("CANVAS_NORMAL") ? LayerBlendMode::normal : modes[index % 5]); + session.setLayerOpacity(index == 0 ? 1.0 : 0.7); + } + shown.settle(); + session.fit(); + shown.canvas->synchronizeDisplay(); + shown.canvas->repaint(); + const auto display = [&] { + shown.canvas->synchronizeDisplay(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::UpdateRequest); + }; + const QPointF middle(500, 500); + std::vector<double> medians; + for (const bool actual : {false, true}) { + // Actual Pixels, as the menu sets it. + if (actual) { + session.zoom(1, middle); + QCOMPARE(session.viewport.zoom(), 1.0); + display(); + } + // A trackpad's pan: pixel deltas through the canvas's own path. + Timings pan; + for (int step = 0; step < 120; ++step) { + QElapsedTimer frame; + frame.start(); + wheel(*shown.canvas, middle, QPoint(step < 60 ? 9 : -9, step % 2 ? 6 : -6), QPoint(0, 0), Qt::NoModifier); + display(); + pan.frames.push_back(double(frame.nsecsElapsed()) / 1e6); + } + qInfo().noquote() << QStringLiteral("CANVAS BENCH pan zoom=%1 median=%2 p95=%3 max=%4ms") + .arg(actual ? QStringLiteral("100%") : QStringLiteral("fit")).arg(pan.median(), 0, 'f', 2).arg(pan.at(0.95), 0, 'f', 2) + .arg(pan.at(1), 0, 'f', 2); + medians.push_back(pan.median()); + } + // A pinch: zoom steps in and out around the middle. + session.fit(); + display(); + Timings zooming; + for (int step = 0; step < 80; ++step) { + QElapsedTimer frame; + frame.start(); + session.zoom(session.viewport.zoom() * (step < 40 ? 1.05 : 1 / 1.05), middle); + display(); + zooming.frames.push_back(double(frame.nsecsElapsed()) / 1e6); + } + qInfo().noquote() << QStringLiteral("CANVAS BENCH zoom median=%1 p95=%2 max=%3ms") + .arg(zooming.median(), 0, 'f', 2).arg(zooming.at(0.95), 0, 'f', 2).arg(zooming.at(1), 0, 'f', 2); + medians.push_back(zooming.median()); + // Guards at half again the medians measured in 11.1. + const double limits[] = {45, 100, 115}; + for (size_t index = 0; index < medians.size(); ++index) + QVERIFY2(medians[index] <= limits[index], qPrintable(QString::number(medians[index]))); +} + +int main(int argc, char **argv) +{ + // Swift's benchmark draws at a backing scale of two. + qputenv("QT_SCALE_FACTOR", "2"); + QApplication app(argc, argv); + CanvasPerformanceTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "CanvasPerformanceTests.moc" diff --git a/tests/CanvasSizeSheetTests.cpp b/tests/CanvasSizeSheetTests.cpp new file mode 100644 index 0000000..496abbe --- /dev/null +++ b/tests/CanvasSizeSheetTests.cpp @@ -0,0 +1,304 @@ +#include "UI/CanvasSizeSheet.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet.h" +#include <QCheckBox> +#include <QColorDialog> +#include <QComboBox> +#include <QLabel> +#include <QPushButton> +#include <QToolButton> +#include <QApplication> +#include <QWheelEvent> +#include <QtTest> + +// Swift's CanvasSizeSheet: fields, units, anchor, extension, answers. +class CanvasSizeSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetShowsTheCanvasAndItsMemory(); + void fieldsSetTheDraftInItsUnits(); + void anInvalidSizeRestsOK(); + void theAnchorAndTheExtensionGoWithOK(); + void aCustomColorComesFromTheColorDialog(); + void theAnchorsDrawTheirCircles(); + void aFieldKeepsItsTypingAndItsNumber(); + void aWheelOverUnitsCommitsTheTypingFirst(); + void halfwayNumbersRoundToEven(); +}; + +namespace { +CanvasDocument document(qint64 width, qint64 height) +{ + CanvasDocument canvas{int(width), int(height)}; + canvas.resolution = 72; + return canvas; +} + +struct Sheet { + std::optional<std::optional<CanvasSizeOptions>> answer; + CanvasSizeSheet sheet; + explicit Sheet(qint64 width = 1200, qint64 height = 800, PaletteColor foreground = PaletteColor::black(), + PaletteColor background = PaletteColor::white()) + : sheet(document(width, height), foreground, background, [this](std::optional<CanvasSizeOptions> options) { answer = options; }) + { + sheet.show(); + } + template <typename Widget> Widget &find(const char *name) + { + Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; + } + // Typed, then Return: Swift's value binding commits. + void type(const char *name, const QString &text) + { + auto &field = find<PickerField>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + QTest::keyClick(&field, Qt::Key_Return); + } + QString note() { return find<QLabel>("canvasNote").text(); } + QString shown(const char *name) { return find<PickerField>(name).text(); } +}; + +QStringList labels(const QWidget &sheet) +{ + QStringList texts; + for (const QLabel *label : sheet.findChildren<QLabel *>()) + texts << label->text(); + return texts; +} +} + +void CanvasSizeSheetTests::theSheetShowsTheCanvasAndItsMemory() +{ + Sheet shown; + const QStringList texts = labels(shown.sheet); + QVERIFY(texts.contains("Canvas Size")); + QVERIFY(texts.contains("Current: 1,200 × 800 pixels")); + QVERIFY(texts.contains("3.7 MB uncompressed RGBA canvas")); + QCOMPARE(shown.sheet.width(), 450); + QCOMPARE(shown.shown("canvasWidth"), QString("1200")); + QCOMPARE(shown.shown("canvasHeight"), QString("800")); + QCOMPARE(shown.note(), QString("New: 1,200 × 800 pixels · 3.7 MB uncompressed")); + // Foundation's memory counts: bytes, whole KB, one place, two. + QVERIFY(labels(Sheet(1, 1).sheet).contains("4 bytes uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(16, 16).sheet).contains("1 KB uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(250, 1).sheet).contains("1,000 bytes uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(2560, 1).sheet).contains("10 KB uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(512, 500).sheet).contains("1,000 KB uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(1024, 512).sheet).contains("2 MB uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(30'000, 30'000).sheet).contains("3.35 GB uncompressed RGBA canvas")); + QVERIFY(labels(Sheet(23'170, 23'170).sheet).contains("2 GB uncompressed RGBA canvas")); +} + +void CanvasSizeSheetTests::fieldsSetTheDraftInItsUnits() +{ + Sheet shown; + shown.type("canvasWidth", "1600"); + QCOMPARE(shown.shown("canvasWidth"), QString("1600")); + QCOMPARE(shown.shown("canvasHeight"), QString("800")); + QCOMPARE(shown.note(), QString("New: 1,600 × 800 pixels · 4.9 MB uncompressed")); + // Locking takes the width's proportions at once. + shown.find<QCheckBox>("canvasLocked").click(); + QCOMPARE(shown.shown("canvasHeight"), QString("1066.667")); + shown.type("canvasHeight", "400"); + QCOMPARE(shown.shown("canvasWidth"), QString("600")); + // Relative fields show what is added; units convert. + shown.find<QCheckBox>("canvasRelative").click(); + QCOMPARE(shown.shown("canvasWidth"), QString("-600")); + QCOMPARE(shown.shown("canvasHeight"), QString("-400")); + shown.type("canvasWidth", "0"); + QCOMPARE(shown.shown("canvasHeight"), QString("0")); + auto &units = shown.find<QComboBox>("canvasUnits"); + QCOMPARE(units.count(), 4); + units.setCurrentIndex(1); + emit units.activated(1); + shown.type("canvasWidth", "50"); + QCOMPARE(shown.note(), QString("New: 1,800 × 1,200 pixels · 8.2 MB uncompressed")); + shown.find<QCheckBox>("canvasRelative").click(); + units.setCurrentIndex(2); + emit units.activated(2); + QCOMPARE(shown.shown("canvasWidth"), QString("25")); + units.setCurrentIndex(3); + emit units.activated(3); + QCOMPARE(shown.shown("canvasWidth"), QString("63.5")); + // Text that is no number goes back to the draft's. + shown.type("canvasWidth", "wide"); + QCOMPARE(shown.shown("canvasWidth"), QString("63.5")); + units.setCurrentIndex(0); + emit units.activated(0); + QCOMPARE(shown.shown("canvasWidth"), QString("1800")); +} + +void CanvasSizeSheetTests::anInvalidSizeRestsOK() +{ + Sheet shown; + auto &ok = shown.find<QPushButton>("canvasOK"); + QVERIFY(ok.isEnabled() && ok.isDefault()); + shown.type("canvasWidth", "30001"); + QCOMPARE(shown.note(), QString("Final dimensions must be 1–30,000 pixels per side.")); + QCOMPARE(shown.find<QLabel>("canvasNote").foregroundRole(), QPalette::BrightText); + QVERIFY(!ok.isEnabled()); + shown.type("canvasWidth", "0.4"); + QVERIFY(!ok.isEnabled()); + shown.type("canvasWidth", "0.5"); + QVERIFY(ok.isEnabled()); + QCOMPARE(shown.find<QLabel>("canvasNote").foregroundRole(), QPalette::PlaceholderText); + // Cancel answers with nothing. + shown.find<QPushButton>("canvasCancel").click(); + QVERIFY(!shown.answer.value().has_value()); +} + +void CanvasSizeSheetTests::theAnchorAndTheExtensionGoWithOK() +{ + Sheet shown(1200, 800, PaletteColor{1, 0, 0}, PaletteColor{0, 0, 1}); + QCOMPARE(shown.find<QLabel>("anchorName").text(), QString("Center")); + QCOMPARE(shown.find<QToolButton>("anchor4").accessibleDescription(), QString("Selected")); + shown.find<QToolButton>("anchor0").click(); + QCOMPARE(shown.find<QLabel>("anchorName").text(), QString("Top left")); + QCOMPARE(shown.find<QToolButton>("anchor0").toolTip(), QString("Top left")); + QCOMPARE(shown.find<QToolButton>("anchor0").accessibleDescription(), QString("Selected")); + QVERIFY(shown.find<QToolButton>("anchor4").accessibleDescription().isEmpty()); + auto &extension = shown.find<QComboBox>("canvasExtension"); + QStringList choices; + for (int index = 0; index < extension.count(); ++index) + choices << extension.itemText(index); + QCOMPARE(choices, (QStringList{"Transparent", "Foreground", "Background", "Black", "White", "Custom"})); + QVERIFY(!shown.find<QWidget>("extensionColorRow").isVisible()); + const std::pair<int, std::optional<std::array<double, 3>>> fills[] = { + {0, std::nullopt}, {1, std::array<double, 3>{1, 0, 0}}, {2, std::array<double, 3>{0, 0, 1}}, {3, std::array<double, 3>{0, 0, 0}}, + {4, std::array<double, 3>{1, 1, 1}}}; + shown.type("canvasWidth", "1600.6"); + shown.type("canvasHeight", "799.5"); + for (const auto &[index, fill] : fills) { + extension.setCurrentIndex(index); + emit extension.activated(index); + shown.find<QPushButton>("canvasOK").click(); + const CanvasSizeOptions options = shown.answer.value().value(); + QCOMPARE(options.width, 1601); + QCOMPARE(options.height, 800); + QCOMPARE(options.anchor, 0); + QCOMPARE(options.fill.has_value(), fill.has_value()); + if (fill) + QVERIFY(options.fill.value().red == fill.value()[0] && options.fill.value().green == fill.value()[1] && options.fill.value().blue == fill.value()[2]); + } +} + +void CanvasSizeSheetTests::aCustomColorComesFromTheColorDialog() +{ + Sheet shown; + auto &extension = shown.find<QComboBox>("canvasExtension"); + extension.setCurrentIndex(5); + emit extension.activated(5); + QVERIFY(shown.find<QWidget>("extensionColorRow").isVisible()); + auto &swatch = shown.find<SwatchButton>("extensionColor"); + QCOMPARE(swatch.accessibleName(), QString("Extension color")); + swatch.click(); + auto *dialog = shown.sheet.findChild<QColorDialog *>(); + QVERIFY(dialog && dialog->isVisible()); + QVERIFY(!dialog->testOption(QColorDialog::ShowAlphaChannel)); + QCOMPARE(dialog->currentColor(), QColor(Qt::white)); + dialog->setCurrentColor(QColor(0, 128, 255)); + // The swatch repaints with the colour chosen. + int painted = 0; + struct Spy : QObject { + int &count; + explicit Spy(int &count) : count(count) {} + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } + } spy(painted); + swatch.installEventFilter(&spy); + QApplication::processEvents(); + const int before = painted; + dialog->accept(); + QTRY_VERIFY(painted > before); + swatch.removeEventFilter(&spy); + shown.find<QPushButton>("canvasOK").click(); + const CanvasExtensionColor fill = shown.answer.value().value().fill.value(); + QCOMPARE(fill.red, 0.0); + QCOMPARE(fill.green, QColor(0, 128, 255).greenF()); + QCOMPARE(fill.blue, 1.0); + // Chosen again, the dialog opens on the colour kept. + swatch.click(); + QTRY_VERIFY(shown.sheet.findChild<QColorDialog *>() && shown.sheet.findChild<QColorDialog *>()->isVisible()); + QCOMPARE(shown.sheet.findChild<QColorDialog *>()->currentColor(), QColor(0, 128, 255)); +} + +void CanvasSizeSheetTests::theAnchorsDrawTheirCircles() +{ + Sheet shown; + const QImage chosen = shown.find<QToolButton>("anchor4").grab().toImage(); + const QImage other = shown.find<QToolButton>("anchor0").grab().toImage(); + const QColor accent = shown.sheet.palette().color(QPalette::Highlight); + // The chosen one filled in the accent; the others ringed. + QCOMPARE(chosen.pixelColor(12, 12), accent); + QCOMPARE(chosen.pixelColor(16, 12), accent); + QVERIFY(other.pixelColor(12, 12) != accent); + QVERIFY(other.pixelColor(12, 12) != other.pixelColor(17, 12)); + QCOMPARE(other.pixelColor(12, 12), other.pixelColor(20, 12)); +} + +void CanvasSizeSheetTests::aFieldKeepsItsTypingAndItsNumber() +{ + // Typing waits for Return while the sheet redraws. + Sheet shown; + auto &width = shown.find<PickerField>("canvasWidth"); + width.setFocus(); + width.selectAll(); + QTest::keyClicks(&width, "1234"); + emit shown.find<QComboBox>("canvasExtension").activated(1); + QCOMPARE(width.text(), QString("1234")); + QTest::keyClick(&width, Qt::Key_Return); + QCOMPARE(shown.note(), QString("New: 1,234 × 800 pixels · 3.8 MB uncompressed")); + // Return without typing keeps the number, not what it shows. + Sheet thin(30'000, 1); + thin.find<QCheckBox>("canvasLocked").click(); + thin.type("canvasWidth", "29999"); + QCOMPARE(thin.shown("canvasHeight"), QString("1")); + auto &height = thin.find<PickerField>("canvasHeight"); + height.setFocus(); + QTest::keyClick(&height, Qt::Key_Return); + QCOMPARE(thin.note(), QString("New: 29,999 × 1 pixels · 117 KB uncompressed")); +} + +// Scrolled, Units take no focus; the typing meant pixels. +void CanvasSizeSheetTests::aWheelOverUnitsCommitsTheTypingFirst() +{ + Sheet shown; + // Focus moves only in the active window. + shown.sheet.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&shown.sheet)); + auto &width = shown.find<PickerField>("canvasWidth"); + width.setFocus(); + width.selectAll(); + QTest::keyClicks(&width, "600"); + auto &units = shown.find<QComboBox>("canvasUnits"); + QWheelEvent wheel(QPointF(5, 5), units.mapToGlobal(QPointF(5, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(&units, &wheel); + QCOMPARE(units.currentText(), QString("Percent")); + QCOMPARE(width.text(), QString("50")); + QCOMPARE(shown.find<QLabel>("canvasNote").text(), QString("New: 600 × 800 pixels · 1.8 MB uncompressed")); +} + +// Foundation rounds halves to even: 17 pixels, 16 per inch. +void CanvasSizeSheetTests::halfwayNumbersRoundToEven() +{ + CanvasDocument canvas{17, 19}; + canvas.resolution = 16; + CanvasSizeSheet sheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}); + auto &units = *sheet.findChild<QComboBox *>(QStringLiteral("canvasUnits")); + const int inches = units.findText(QStringLiteral("Inches")); + units.setCurrentIndex(inches); + emit units.activated(inches); + QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("canvasWidth"))->text(), QString("1.062")); + QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("canvasHeight"))->text(), QString("1.188")); +} + +QTEST_MAIN(CanvasSizeSheetTests) +#include "CanvasSizeSheetTests.moc" diff --git a/tests/CanvasSizeTests.cpp b/tests/CanvasSizeTests.cpp new file mode 100644 index 0000000..73e39d7 --- /dev/null +++ b/tests/CanvasSizeTests.cpp @@ -0,0 +1,357 @@ +#include "AddressSpaceLimit.h" +#include "Document/CanvasSize.h" +#include "Document/EditorSession.h" +#include "Document/LayerMask.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "ProjectFixtures.h" +#include <QTemporaryDir> +#include <QtTest> + +namespace { +// A 64x32 image layer, turned and flipped, filling its document. +ProjectSnapshot turned() +{ + const QUuid id = QUuid::createUuid(); + const LayerTransform transform{.origin = {3.5, -2.25}, .size = {64, 32}, .rotation = 37, .flipX = true}; + const ProjectLayerRecord record{.id = id, .name = "Turned", .isVisible = true, .transform = transform, .imageFile = uuidString(id) + ".png"}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = 64, .height = 32, .activeLayerID = id, .layers = {record}}, + .images = {{id, asset(halfRed(), "Turned")}}}; +} + +// A 4x4 document holding one blank layer. +ProjectSnapshot blank() +{ + const QUuid id = QUuid::createUuid(); + const ProjectLayerRecord record{.id = id, .name = "Layer 1", .isVisible = true, .transform = {.origin = {0, 0}, .size = {4, 4}}, + .imageFile = std::nullopt}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = 4, .height = 4, .activeLayerID = id, .layers = {record}}, .images = {}}; +} + +std::optional<ProjectError::Kind> resizeError(const ProjectSnapshot &snapshot, const CanvasSizeOptions &options) +{ + return projectError([&] { CanvasResizer::resize(snapshot, options); }); +} +} + +class CanvasSizeTests : public QObject { + Q_OBJECT +private slots: + void everyAnchorPreservesSourceAndTransformForExpansionAndShrink(); + void relativeRatioAndUnitsUseFinalDimensions(); + void unitsHaveSwiftsNames(); + void coloredExtensionPreservesOldTransparencyAndRoundTripsWithUndo(); + void transparentResizeIsAllocationFreeAndShrinkDoesNotAddFill(); + void limitsAreJudgedAtTheirEdges(); + void nothingToDoReturnsTheSnapshot(); + void masksPlacedApartMoveWithTheirLayers(); + void theExtensionCountsAgainstTheBudgets(); + void recordsKeepEveryField_data(); + void recordsKeepEveryField(); + void anExtensionThatCannotBeAllocatedThrows(); +}; + +void CanvasSizeTests::everyAnchorPreservesSourceAndTransformForExpansionAndShrink() +{ + const ProjectSnapshot source = turned(); + const ProjectLayerRecord &layer = source.manifest.layers[0]; + for (const int delta : {5, -5}) { + for (int anchor = 0; anchor <= 8; ++anchor) { + const ProjectSnapshot result = CanvasResizer::resize(source, {.width = 64 + delta, .height = 32 + delta, .anchor = anchor}); + const ProjectLayerRecord &output = result.manifest.layers[0]; + // Floor: the odd pixel goes right and bottom. + const double expected[] = {0, delta == 5 ? 2.0 : -3.0, double(delta)}; + QCOMPARE(output.transform.origin, layer.transform.origin + QPointF(expected[anchor % 3], expected[anchor / 3])); + QCOMPARE(output.transform.size, layer.transform.size); + QCOMPARE(output.transform.rotation, 37.0); + QVERIFY(output.transform.flipX); + QCOMPARE(output.id, layer.id); + QCOMPARE(result.images.at(layer.id).identity(), source.images.at(layer.id).identity()); + QCOMPARE(result.manifest.width, qint64(64 + delta)); + QCOMPARE(result.manifest.height, qint64(32 + delta)); + } + } +} + +void CanvasSizeTests::relativeRatioAndUnitsUseFinalDimensions() +{ + CanvasSizeDraft draft(1000, 500, 100); + draft.relative = true; + draft.locked = true; + draft.set(200, true); + QCOMPARE(draft.width, 1200.0); + QCOMPARE(draft.height, 600.0); + QCOMPARE(draft.displayed(false), 100.0); + draft.set(-250, false); + QCOMPARE(draft.width, 500.0); + QCOMPARE(draft.height, 250.0); + draft.relative = false; + draft.unit = CanvasUnit::inches; + draft.set(10, true); + QCOMPARE(draft.width, 1000.0); + QCOMPARE(draft.height, 500.0); + QCOMPARE(draft.displayed(true), 10.0); + draft.unit = CanvasUnit::percent; + draft.set(50, true); + QCOMPARE(draft.width, 500.0); + QCOMPARE(draft.height, 250.0); + QCOMPARE(draft.displayed(false), 50.0); + draft.unit = CanvasUnit::centimeters; + QVERIFY(qAbs(draft.displayed(true) - 12.7) < 0.001); + draft.set(25.4, false); + QCOMPARE(draft.height, 1000.0); + QCOMPARE(draft.width, 2000.0); + draft.unit = CanvasUnit::pixels; + QVERIFY(draft.valid()); + draft.set(0, true); + QVERIFY(!draft.valid()); + // Validity rounds first: 0.5 is one pixel, 30,000.4 still fits. + draft.locked = false; + for (const auto &[value, valid] : {std::pair(0.5, true), std::pair(0.49, false), std::pair(30'000.4, true), std::pair(30'000.5, false), + std::pair(std::nan(""), false), std::pair(double(INFINITY), false)}) { + draft.set(value, true); + draft.set(10, false); + QCOMPARE(draft.valid(), valid); + draft.set(10, true); + draft.set(value, false); + QCOMPARE(draft.valid(), valid); + } +} + +void CanvasSizeTests::unitsHaveSwiftsNames() +{ + QCOMPARE(rawValue(CanvasUnit::pixels), QString("Pixels")); + QCOMPARE(rawValue(CanvasUnit::percent), QString("Percent")); + QCOMPARE(rawValue(CanvasUnit::inches), QString("Inches")); + QCOMPARE(rawValue(CanvasUnit::centimeters), QString("Centimeters")); +} + +void CanvasSizeTests::coloredExtensionPreservesOldTransparencyAndRoundTripsWithUndo() +{ + const ProjectSnapshot input = blank(); + QTest::ignoreMessage(QtInfoMsg, "canvas 4 x 4 becomes 8 x 2"); + // Shrinks one way, grows the other: only side bands colour. + const CanvasExtensionColor red{1, 0, 0}; + const ProjectSnapshot output = CanvasResizer::resize(input, {.width = 8, .height = 2, .fill = red}); + QCOMPARE(int(output.manifest.layers.size()), 2); + QCOMPARE(output.manifest.activeLayerID, input.manifest.activeLayerID); + const ProjectLayerRecord &extension = output.manifest.layers[0]; + QCOMPARE(extension.name, QString("Canvas Extension")); + QVERIFY(extension.isVisible); + QCOMPARE(extension.transform, (LayerTransform{.origin = {0, 0}, .size = {8, 2}})); + QCOMPARE(extension.imageFile, std::optional(uuidString(extension.id) + ".png")); + QCOMPARE(output.images.at(extension.id).name, QString("Canvas Extension")); + QCOMPARE(output.images.at(extension.id).image().format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(output.images.at(extension.id).thumbnail.size(), QSize(8, 2)); + // The old canvas, x 2 to 5, stays clear. + const QImage flat = ImageExporter::render(output).image; + QCOMPARE(flat.pixelColor(0, 0), QColor(255, 0, 0)); + QCOMPARE(flat.pixelColor(1, 1), QColor(255, 0, 0)); + QCOMPARE(flat.pixelColor(2, 0).alpha(), 0); + QCOMPARE(flat.pixelColor(3, 0).alpha(), 0); + QCOMPARE(flat.pixelColor(5, 1).alpha(), 0); + QCOMPARE(flat.pixelColor(6, 0), QColor(255, 0, 0)); + QCOMPARE(flat.pixelColor(7, 1), QColor(255, 0, 0)); + QTemporaryDir folder; + ProjectStore::save(output, folder.filePath("Canvas.comp")); + const QImage reopened = ImageExporter::render(ProjectStore::load(folder.filePath("Canvas.comp"))).image; + QCOMPARE(reopened.pixelColor(3, 0).alpha(), 0); + QCOMPARE(reopened.pixelColor(0, 0), QColor(255, 0, 0)); + // In the session: one step, named by its action. + EditorSession session; + session.installProject(input, "blank.comp"); + const std::optional<CanvasDocument> before = session.document(); + session.applyDocumentSize(output, "Canvas Size"); + QCOMPARE(session.history.undoName(), QString("Canvas Size")); + session.undo(); + QCOMPARE(session.document(), before); + session.redo(); + QCOMPARE(session.document().value().size(), QSizeF(8, 2)); + QCOMPARE(int(session.document().value().layers.size()), 2); + // Growth on either side lays a fill; shrinking lays none. + QCOMPARE(int(CanvasResizer::resize(input, {.width = 2, .height = 8, .fill = red}).manifest.layers.size()), 2); + QCOMPARE(int(CanvasResizer::resize(input, {.width = 8, .height = 4, .fill = red}).manifest.layers.size()), 2); + QCOMPARE(int(CanvasResizer::resize(input, {.width = 4, .height = 2, .fill = red}).manifest.layers.size()), 1); + QCOMPARE(int(CanvasResizer::resize(input, {.width = 2, .height = 4, .fill = red}).manifest.layers.size()), 1); + // An old 6x4 canvas clears 6 across, 4 down. + ProjectSnapshot oblong = input; + oblong.manifest.width = 6; + const ProjectSnapshot grown = CanvasResizer::resize(oblong, {.width = 10, .height = 8, .fill = red}); + const QImage around = grown.images.at(grown.manifest.layers[0].id).image(); + QCOMPARE(around.pixelColor(7, 3).alpha(), 0); + QCOMPARE(around.pixelColor(2, 5).alpha(), 0); + QCOMPARE(around.pixelColor(7, 6), QColor(255, 0, 0)); + QCOMPARE(around.pixelColor(8, 3), QColor(255, 0, 0)); + QCOMPARE(around.pixelColor(1, 3), QColor(255, 0, 0)); + QCOMPARE(around.pixelColor(4, 1), QColor(255, 0, 0)); + // A colour between the ends lands on Qt's nearest level. + const ProjectSnapshot gray = CanvasResizer::resize(input, {.width = 8, .height = 4, .fill = CanvasExtensionColor{0.5, 0.25, 1}}); + QCOMPARE(gray.images.at(gray.manifest.layers[0].id).image().pixelColor(0, 0), QColor(128, 64, 255)); +} + +void CanvasSizeTests::transparentResizeIsAllocationFreeAndShrinkDoesNotAddFill() +{ + ProjectSnapshot input = blank(); + input.manifest.layers.clear(); + const ProjectSnapshot large = CanvasResizer::resize(input, {.width = 30'000, .height = 30'000}); + QVERIFY(large.images.empty()); + QCOMPARE(large.manifest.width, qint64(30'000)); + const CanvasExtensionColor white{1, 1, 1}; + const ProjectSnapshot small = CanvasResizer::resize(input, {.width = 2, .height = 2, .fill = white}); + QVERIFY(small.images.empty()); + QVERIFY(small.manifest.layers.empty()); + // 900 megapixels of fill pass the budget. + QCOMPARE(resizeError(input, {.width = 30'000, .height = 30'000, .fill = white}), std::optional(ProjectError::Kind::tooLarge)); +} + +void CanvasSizeTests::limitsAreJudgedAtTheirEdges() +{ + const ProjectSnapshot input = turned(); + const auto tooLarge = std::optional(ProjectError::Kind::tooLarge), invalid = std::optional(ProjectError::Kind::invalid); + QCOMPARE(resizeError(input, {.width = 0, .height = 10}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 0}), tooLarge); + QCOMPARE(resizeError(input, {.width = 30'001, .height = 10}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 30'001}), tooLarge); + QCOMPARE(resizeError(input, {.width = 30'000, .height = 1}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 1, .height = 30'000}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .anchor = -1}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .anchor = 9}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .anchor = 0}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .anchor = 8}), std::nullopt); + // A crop's own offset: finite, a million at most. + for (const double wild : {std::nan(""), double(INFINITY), -double(INFINITY), 1'000'000.5, -1'000'000.5}) { + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .contentOffset = QPointF(wild, 0)}), invalid); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .contentOffset = QPointF(0, wild)}), invalid); + } + // A layer pushed past a million fails its own rule. + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .contentOffset = QPointF(1'000'000, 0)}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .contentOffset = QPointF(999'996, 0)}), std::nullopt); + // Colours: finite, zero to one; judged only with a fill. + for (const double wild : {std::nan(""), -0.01, 1.01, double(INFINITY)}) { + QCOMPARE(resizeError(input, {.width = 70, .height = 40, .fill = CanvasExtensionColor{wild, 0, 0}}), invalid); + QCOMPARE(resizeError(input, {.width = 70, .height = 40, .fill = CanvasExtensionColor{0, wild, 0}}), invalid); + QCOMPARE(resizeError(input, {.width = 70, .height = 40, .fill = CanvasExtensionColor{0, 0, wild}}), invalid); + QCOMPARE(resizeError(input, {.width = 60, .height = 30, .fill = CanvasExtensionColor{wild, 0, 0}}), std::nullopt); + } +} + +void CanvasSizeTests::nothingToDoReturnsTheSnapshot() +{ + const ProjectSnapshot input = turned(); + const ProjectSnapshot same = CanvasResizer::resize(input, {.width = 64, .height = 32, .fill = CanvasExtensionColor{1, 0, 0}}); + QCOMPARE(same.manifest.encoded(), input.manifest.encoded()); + QCOMPARE(same.images.begin()->second.identity(), input.images.begin()->second.identity()); + // One side alone is a change, whatever the anchor moves. + QCOMPARE(CanvasResizer::resize(input, {.width = 70, .height = 32, .anchor = 0}).manifest.width, qint64(70)); + QCOMPARE(CanvasResizer::resize(input, {.width = 64, .height = 40, .anchor = 0}).manifest.height, qint64(40)); + // The same size with an offset is a move. + const ProjectSnapshot moved = CanvasResizer::resize(input, {.width = 64, .height = 32, .contentOffset = QPointF(-7, 9)}); + QCOMPARE(moved.manifest.layers[0].transform.origin, input.manifest.layers[0].transform.origin + QPointF(-7, 9)); +} + +void CanvasSizeTests::masksPlacedApartMoveWithTheirLayers() +{ + ProjectSnapshot input = turned(); + ProjectLayerRecord &layer = input.manifest.layers[0]; + layer.maskFile = uuidString(layer.id) + ".mask.png"; + layer.maskPlacement = LayerTransform{.origin = {10, 20}, .size = {30, 15}, .rotation = 12, .flipY = true}; + input.masks.insert({layer.id, LayerMask::assetFrom(gray(30, 15, 200))}); + const ProjectSnapshot output = CanvasResizer::resize(input, {.width = 74, .height = 42, .anchor = 8}); + const LayerTransform placement = output.manifest.layers[0].maskPlacement.value(); + QCOMPARE(placement, (LayerTransform{.origin = {20, 30}, .size = {30, 15}, .rotation = 12, .flipY = true})); + QCOMPARE(output.masks.at(layer.id).identity(), input.masks.at(layer.id).identity()); +} + +void CanvasSizeTests::theExtensionCountsAgainstTheBudgets() +{ + // A painted 9000 x 9000 layer leaves 19 megapixels. + const QUuid id = QUuid::createUuid(); + const std::shared_ptr<const RasterSnapshot> raster = painted(solid(9000, 9000, qRgba(0, 0, 0, 0)), {}); + const ProjectLayerRecord record{.id = id, .name = "Painted", .isVisible = true, .transform = {.origin = {0, 0}, .size = {9000, 9000}}, + .imageFile = uuidString(id) + ".png"}; + const ProjectSnapshot input{.manifest = {.documentID = QUuid::createUuid(), .width = 100, .height = 100, .activeLayerID = id, .layers = {record}}, + .images = {{id, ImportedImage(raster, solid(1, 1, 0), "Painted")}}}; + const CanvasExtensionColor white{1, 1, 1}; + QVERIFY(!raster->hasMaterializedPixels()); + QCOMPARE(resizeError(input, {.width = 4750, .height = 4001, .fill = white}), std::optional(ProjectError::Kind::tooLarge)); + const ProjectSnapshot output = CanvasResizer::resize(input, {.width = 4750, .height = 4000, .fill = white}); + QCOMPARE(output.images.at(output.manifest.layers[0].id).size(), QSize(4750, 4000)); + // 96 / 4750 of 4000 is 80.8: cut to 80. + QCOMPARE(output.images.at(output.manifest.layers[0].id).thumbnail.size(), QSize(96, 80)); + const ProjectSnapshot strip = CanvasResizer::resize(blank(), {.width = 3000, .height = 5, .fill = white}); + QCOMPARE(strip.images.begin()->second.thumbnail.size(), QSize(96, 1)); + QCOMPARE(strip.images.begin()->second.thumbnail.pixelColor(0, 0), QColor(255, 255, 255)); + // Counting pixels flattens nothing. + QVERIFY(output.images.at(id).raster == raster); + QVERIFY(!raster->hasMaterializedPixels()); + // Ten thousand layers leave no room for one more. + ProjectSnapshot crowded = blank(); + crowded.manifest.layers.resize(10'000, crowded.manifest.layers[0]); + QCOMPARE(resizeError(crowded, {.width = 8, .height = 8, .fill = white}), std::optional(ProjectError::Kind::tooLarge)); + crowded.manifest.layers.resize(9'999); + QCOMPARE(int(CanvasResizer::resize(crowded, {.width = 8, .height = 8, .fill = white}).manifest.layers.size()), 10'000); +} + +void CanvasSizeTests::recordsKeepEveryField_data() +{ + QTest::addColumn<bool>("enabled"); + QTest::addColumn<bool>("linked"); + QTest::newRow("a disabled, linked mask") << false << true; + QTest::newRow("an enabled, unlinked mask") << true << false; +} + +void CanvasSizeTests::recordsKeepEveryField() +{ + QFETCH(bool, enabled); + QFETCH(bool, linked); + ProjectSnapshot input = turned(); + const QUuid folder = QUuid::createUuid(), source = QUuid::createUuid(); + ProjectLayerRecord &layer = input.manifest.layers[0]; + layer.isVisible = false; + layer.parentID = folder; + layer.opacity = 0.25; + layer.blendMode = LayerBlendMode::overlay; + layer.maskFile = "mask.png"; + layer.maskEnabled = enabled; + layer.maskSourceID = source; + layer.maskLinked = linked; + const std::optional<QString> imageFile = layer.imageFile; + input.manifest.layers.push_back({.id = folder, .name = "Folder", .isVisible = true, .transform = {.origin = {0, 0}, .size = {64, 32}}, + .imageFile = std::nullopt, .isGroup = true}); + input.manifest.resolution = 144; + const ProjectSnapshot output = CanvasResizer::resize(input, {.width = 70, .height = 40, .anchor = 0}); + QCOMPARE(output.manifest.resolution, std::optional(144.0)); + QCOMPARE(output.manifest.documentID, input.manifest.documentID); + const ProjectLayerRecord &kept = output.manifest.layers[0]; + QCOMPARE(kept.name, QString("Turned")); + QCOMPARE(kept.isVisible, false); + QCOMPARE(kept.imageFile, imageFile); + QCOMPARE(kept.parentID, std::optional(folder)); + QCOMPARE(kept.isGroup, std::nullopt); + QCOMPARE(kept.opacity, std::optional(0.25)); + QCOMPARE(kept.blendMode, std::optional(LayerBlendMode::overlay)); + QCOMPARE(kept.maskFile, std::optional(QString("mask.png"))); + QCOMPARE(kept.maskEnabled, std::optional(enabled)); + QCOMPARE(kept.maskSourceID, std::optional(source)); + QCOMPARE(kept.maskLinked, std::optional(linked)); + QCOMPARE(output.manifest.layers[1].isGroup, std::optional(true)); + QCOMPARE(output.manifest.layers[1].name, QString("Folder")); +} + +void CanvasSizeTests::anExtensionThatCannotBeAllocatedThrows() +{ + std::optional<ExportError::Kind> kind; + { + // 9000 x 9000 asks for 324 MB. + const AddressSpaceLimit limit(200ll * 1024 * 1024); + try { + CanvasResizer::resize(blank(), {.width = 9000, .height = 9000, .fill = CanvasExtensionColor{1, 1, 1}}); + } catch (const ExportError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(ExportError::Kind::render)); +} + +QTEST_MAIN(CanvasSizeTests) +#include "CanvasSizeTests.moc" diff --git a/tests/CanvasThumbnailTests.cpp b/tests/CanvasThumbnailTests.cpp new file mode 100644 index 0000000..8949e28 --- /dev/null +++ b/tests/CanvasThumbnailTests.cpp @@ -0,0 +1,83 @@ +#include "UI/CanvasThumbnail.h" +#include "Document/LayerMask.h" +#include "RenderFixtures.h" +#include <QtTest> + +// Thumbnails take the canvas's shape; pixels sit where they are. +namespace { +// One pixel's RGBA bytes of a thumbnail, top row first. +QList<int> read(const QPixmap &thumbnail, int x, int y) +{ + const QImage image = thumbnail.toImage().convertToFormat(QImage::Format_RGBA8888); + const uchar *pixel = image.constScanLine(y) + x * 4; + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} +} + +class CanvasThumbnailTests : public QObject { + Q_OBJECT +private slots: + void thumbnailsTakeTheCanvasShape(); + void layerPixelsSitWhereTheyAreOnTheCanvas(); + void masksFillTheCanvasWithTheirEdgeTone(); +}; + +void CanvasThumbnailTests::thumbnailsTakeTheCanvasShape() +{ + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(400, 200), 36), QSize(36, 18)); + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(300, 600), 36), QSize(18, 36)); + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(0, 0), 30), QSize(30, 30)); + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(3000, 7), 36), QSize(36, 1)); + // Rounded, as Swift rounds: 22.5 is 23, not 22. + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(400, 250), 36), QSize(36, 23)); + QCOMPARE(CanvasThumbnail::fittedSize(QSizeF(std::nan(""), 7), 36), QSize(36, 36)); +} + +void CanvasThumbnailTests::layerPixelsSitWhereTheyAreOnTheCanvas() +{ + const QImage red = solid(100, 100, qRgba(255, 0, 0, 255)); + const QPixmap thumbnail = CanvasThumbnail::layer(red, LayerTransform{.origin = {0, 0}, .size = {100, 100}}, QSizeF(400, 200), 36); + QCOMPARE(thumbnail.deviceIndependentSize(), QSizeF(36, 18)); + QCOMPARE(thumbnail.width(), 72); + QCOMPARE(read(thumbnail, 4, 4), (QList<int>{255, 0, 0, 255})); + QCOMPARE(read(thumbnail, 12, 12)[0], 255); + // The rest shows the checkerboard; nothing is flipped. + QVERIFY(read(thumbnail, 60, 30)[0] < 200); + QVERIFY(read(thumbnail, 4, 30)[0] < 200); + const QPixmap blank = CanvasThumbnail::layer(QImage(), LayerTransform{.origin = {0, 0}, .size = {400, 200}}, QSizeF(400, 200), 36); + QCOMPARE(read(blank, 4, 4)[3], 255); + // Two tones of gray, six points a tile. + QCOMPARE(read(blank, 4, 4)[0], 82); + QCOMPARE(read(blank, 16, 4)[0], 56); + QCOMPARE(read(blank, 16, 16)[0], 82); +} + +void CanvasThumbnailTests::masksFillTheCanvasWithTheirEdgeTone() +{ + const LayerTransform transform{.origin = {100, 50}, .size = {100, 100}}; + const QSizeF canvas(400, 200); + const QImage hideAll = LayerMask::solid(false).asset.thumbnail; + const QPixmap hidden = CanvasThumbnail::mask(hideAll, transform, canvas, 30); + QVERIFY(read(hidden, 1, 1)[0] < 10); + QVERIFY(read(hidden, 55, 25)[0] < 10); + // White edges round a black middle: white around, black within. + QImage framed(20, 20, QImage::Format_Grayscale8); + framed.fill(255); + for (int y = 5; y < 15; ++y) { + for (int x = 5; x < 15; ++x) + framed.setPixel(x, y, qRgb(0, 0, 0)); + } + QCOMPARE(CanvasThumbnail::edgeTone(framed), 1.0); + const QPixmap shown = CanvasThumbnail::mask(framed, transform, canvas, 30); + QCOMPARE(shown.deviceIndependentSize(), QSizeF(30, 15)); + QVERIFY(read(shown, 2, 2)[0] > 245); + QVERIFY(read(shown, 22, 15)[0] < 10); + // A mid-gray frame reads as its mean. + QImage half(4, 3, QImage::Format_Grayscale8); + half.fill(128); + half.setPixel(1, 1, qRgb(0, 0, 0)); + QCOMPARE(CanvasThumbnail::edgeTone(half), 128.0 / 255); +} + +QTEST_MAIN(CanvasThumbnailTests) +#include "CanvasThumbnailTests.moc" diff --git a/tests/ClippingStackTests.cpp b/tests/ClippingStackTests.cpp new file mode 100644 index 0000000..b78567a --- /dev/null +++ b/tests/ClippingStackTests.cpp @@ -0,0 +1,135 @@ +#include "LiveMaskFixtures.h" +#include <QtTest> + +class ClippingStackTests : public QObject { + Q_OBJECT +private slots: + void init(); + void clippingColorKeepsTheBasesSoftAlphaWithoutABlackFringe(); + void stacksNeedNeighboursInOneFolder(); + void aStackBlendsOnceWithItsBasesMode(); + void laterChildrenCoverEarlierOnes(); + void aStackInheritsThePaintersOpacity(); +}; + +void ClippingStackTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void ClippingStackTests::clippingColorKeepsTheBasesSoftAlphaWithoutABlackFringe() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[top].source = base; + const QImage clipped = scene.render({base, top}); + QCOMPARE(alphas(clipped), (std::array<int, 4>{255, 128, 32, 0})); + // Full-strength red under the base's alpha: no black shows through. + QCOMPARE(reds(clipped), (std::array<int, 4>{255, 128, 32, 0})); + QCOMPARE(qGreen(clipped.pixel(1, 0)), 0); + QCOMPARE(qBlue(clipped.pixel(1, 0)), 0); + + scene.layers[top].opacity = 0.5; + const QImage translucent = scene.render({base, top}); + QCOMPARE(alphas(translucent), (std::array<int, 4>{255, 128, 32, 0})); + QCOMPARE(reds(translucent), (std::array<int, 4>{127, 64, 16, 0})); + scene.layers[top].opacity = 1; + + // A translucent child blends with the base's full-strength colour. + Scene tinted; + const QUuid red = tinted.add(placed(asset({255, 128, 32, 0}))); + const QUuid grey = tinted.add(placed(solid(2, 2, qRgba(128, 128, 128, 255)))); + tinted.layers[grey].source = red; + tinted.layers[grey].opacity = 0.5; + const QImage mixed = tinted.render({red, grey}); + QCOMPARE(mixed.pixel(0, 0), qRgba(192, 64, 64, 255)); + QCOMPARE(mixed.pixel(1, 0), qRgba(96, 32, 32, 128)); + QCOMPARE(mixed.pixel(0, 1), qRgba(24, 8, 8, 32)); + QCOMPARE(mixed.pixel(1, 1), qRgba(0, 0, 0, 0)); + + const QUuid background = scene.add(placed(solid(2, 2, qRgba(255, 255, 255, 255)))); + const QImage flattened = scene.render({background, base, top}); + QCOMPARE(alphas(flattened), (std::array<int, 4>{255, 255, 255, 255})); + QCOMPARE(reds(flattened), (std::array<int, 4>{255, 255, 255, 255})); + QCOMPARE(qGreen(flattened.pixel(0, 0)), 0); + QCOMPARE(qGreen(flattened.pixel(1, 0)), 127); + QCOMPARE(qGreen(flattened.pixel(0, 1)), 223); + QCOMPARE(qGreen(flattened.pixel(1, 1)), 255); +} + +void ClippingStackTests::stacksNeedNeighboursInOneFolder() +{ + // Outside a stack, base and child both paint soft alpha. + const std::array<int, 4> shared{255, 128, 32, 0}, thickened{255, 192, 60, 0}; + Scene scene; + const QUuid folder = QUuid::createUuid(); + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid other = scene.add(placed(BrushRaster::context(2, 2, false))); + scene.layers[top].source = base; + QCOMPARE(alphas(scene.render({base, top})), shared); + QCOMPARE(alphas(scene.render({base, other, top})), thickened); + scene.layers[top].parent = folder; + QCOMPARE(alphas(scene.render({base, top})), thickened); + scene.layers[base].parent = folder; + QCOMPARE(alphas(scene.render({base, top})), shared); + + // Clipped neighbours all join; a base clipped itself starts none. + const QUuid third = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[third].source = base; + scene.layers[third].parent = folder; + scene.layers[third].opacity = 0.5; + QCOMPARE(alphas(scene.render({base, top, third})), shared); + QCOMPARE(scene.drawn[third] > 0, true); + scene.layers[base].source = other; + QCOMPARE(alphas(scene.render({base, top, third})), (std::array<int, 4>{0, 0, 0, 0})); +} + +void ClippingStackTests::aStackBlendsOnceWithItsBasesMode() +{ + Scene scene; + const QUuid background = scene.add(placed(solid(2, 2, qRgba(200, 100, 50, 255)))); + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(solid(2, 2, qRgba(128, 128, 128, 255)))); + scene.layers[top].source = base; + scene.layers[base].blendMode = LayerBlendMode::multiply; + const QImage drawn = scene.render({background, base, top}); + // Grey covers the base; the group multiplies through its alpha. + QCOMPARE(drawn.pixel(0, 0), qRgba(100, 50, 25, 255)); + QCOMPARE(drawn.pixel(1, 1), qRgba(200, 100, 50, 255)); + QCOMPARE(drawn.pixel(1, 0), qRgba(150, 75, 37, 255)); +} + +void ClippingStackTests::laterChildrenCoverEarlierOnes() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid red = scene.add(placed(solid(2, 2, qRgba(255, 0, 0, 255)))); + const QUuid blue = scene.add(placed(solid(2, 2, qRgba(0, 0, 255, 255)))); + scene.layers[red].source = base; + scene.layers[blue].source = base; + QCOMPARE(scene.render({base, red, blue}).pixel(1, 0), qRgba(0, 0, 128, 128)); + QCOMPARE(scene.render({base, blue, red}).pixel(1, 0), qRgba(128, 0, 0, 128)); +} + +void ClippingStackTests::aStackInheritsThePaintersOpacity() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[top].source = base; + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + painter.setOpacity(0.5); + live.drawComposite(base, painter); + QCOMPARE(painter.opacity(), 0.5); + painter.end(); + QCOMPARE(alphas(surface), (std::array<int, 4>{127, 64, 16, 0})); + QCOMPARE(reds(surface), (std::array<int, 4>{127, 64, 16, 0})); +} + +QTEST_MAIN(ClippingStackTests) +#include "ClippingStackTests.moc" diff --git a/tests/CloneStampCanvasTests.cpp b/tests/CloneStampCanvasTests.cpp new file mode 100644 index 0000000..5d19b03 --- /dev/null +++ b/tests/CloneStampCanvasTests.cpp @@ -0,0 +1,289 @@ +#include "BrushCanvasFixtures.h" + +// Swift's Clone Stamp on the canvas: source, crosshair, preview. +namespace { +// Left half red with a green square, right half blue. +void colors(Canvas &shown) +{ + QImage image = BrushRaster::context(400, 300, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 200, 300), QColor(255, 0, 0)); + painter.fillRect(QRect(200, 0, 200, 300), QColor(0, 0, 255)); + painter.fillRect(QRect(60, 60, 120, 120), QColor(0, 255, 0)); + painter.end(); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Colors"))); + shown.session.selectTool(NavigationTool::cloneStamp); + shown.session.setBrushSettings(BrushSettings{.diameter = 10, .hardness = 1}); + shown.canvas->synchronizeDisplay(); +} + +std::vector<int> at(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *pixel = bytes.constScanLine(y) + x * 4; + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} + +std::vector<int> centre(const QImage &image) +{ + return at(image, image.width() / 2, image.height() / 2); +} + +bool near(QColor colour, int red, int green, int blue) +{ + return std::abs(colour.red() - red) <= 3 && std::abs(colour.green() - green) <= 3 && std::abs(colour.blue() - blue) <= 3; +} + +const std::vector<int> greenPixel = {0, 255, 0, 255}; +} + +class CloneStampCanvasTests : public QObject { + Q_OBJECT +private slots: + void altClickSetsTheSourceAndPaintsNothing(); + void theCrosshairAndPreviewShowTheSource(); + void altAndSpaceBringACursorBack(); + void thePreviewIsKeptUntilSomethingChanges(); + void thePreviewTakesOneClicksSoftness(); + void theBrushKeepsItsAltClicks(); + void theCrosshairAndPreviewFollowTheZoom(); + void thePreviewShowsTheLayerOrEveryLayerAtTheSource(); + void aCancelledStrokeOrAnotherStepRedrawsThePreview(); +}; + +void CloneStampCanvasTests::altClickSetsTheSourceAndPaintsNothing() +{ + Canvas shown; + colors(shown); + const int steps = shown.session.history.undoCount(); + shown.click(QPointF(120, 120), Qt::AltModifier); + QCOMPARE(shown.session.cloneSource().value(), QPointF(120, 120)); + QVERIFY(!shown.session.brushStroke() && !shown.session.brushError()); + QCOMPARE(shown.session.history.undoCount(), steps); + // Without a source, a press asks for one. + Canvas bare; + colors(bare); + bare.click(QPointF(300, 200)); + QCOMPARE(bare.session.brushError().value(), QString("Alt-click where Clone Stamp should copy from first.")); + QVERIFY(!bare.session.cloneSource()); +} + +void CloneStampCanvasTests::theCrosshairAndPreviewShowTheSource() +{ + Canvas shown; + colors(shown); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->brushCursor().marker().value(), QPointF(120, 120)); + QCOMPARE(centre(shown.canvas->brushCursor().preview()), greenPixel); + // A stroke fixes the offset: crosshair follows, preview goes. + shown.press(QPointF(300, 200)); + QVERIFY(shown.session.brushStroke()); + QVERIFY(shown.canvas->brushCursor().preview().isNull()); + shown.move(QPointF(310, 205)); + QCOMPARE(shown.canvas->brushCursor().marker().value(), QPointF(130, 125)); + shown.release(QPointF(310, 205)); + QCOMPARE(pixel(shown.session, 300, 200), greenPixel); + QCOMPARE(shown.session.history.undoName(), QString("Clone Stamp")); + // Aligned, the next hover previews from the kept offset. + shown.hover(QPointF(360, 290)); + QCOMPARE(shown.canvas->brushCursor().marker().value(), QPointF(180, 210)); + QCOMPARE(centre(shown.canvas->brushCursor().preview()), (std::vector<int>{255, 0, 0, 255})); + // Leaving takes the circle and the crosshair with it. + QEvent leave(QEvent::Leave); + QApplication::sendEvent(shown.canvas, &leave); + QVERIFY(!shown.canvas->brushCursor().marker() && !shown.canvas->brushCursor().circle()); +} + +void CloneStampCanvasTests::altAndSpaceBringACursorBack() +{ + Canvas shown; + colors(shown); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::BlankCursor); + QVERIFY(!shown.canvas->brushCursor().preview().isNull()); + // Holding Alt to pick again: the crosshair cursor, no preview. + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); + QVERIFY(shown.canvas->brushCursor().preview().isNull()); + QVERIFY(shown.canvas->brushCursor().marker().has_value()); + QTest::keyRelease(shown.canvas, Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.canvas->cursor().shape(), Qt::BlankCursor); + QVERIFY(!shown.canvas->brushCursor().preview().isNull()); + QTest::keyPress(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.canvas->cursor().shape(), Qt::OpenHandCursor); + QVERIFY(!shown.canvas->brushCursor().marker() && !shown.canvas->brushCursor().circle()); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.canvas->cursor().shape(), Qt::BlankCursor); + // The source is Clone Stamp's: the Brush keeps its crosshair. + shown.session.selectTool(NavigationTool::brush); + shown.hover(QPointF(301, 200)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); +} + +void CloneStampCanvasTests::thePreviewIsKeptUntilSomethingChanges() +{ + Canvas shown; + colors(shown); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + const qint64 first = shown.canvas->brushCursor().preview().cacheKey(); + // Before any stroke it shows the source wherever you point. + shown.hover(QPointF(301, 200)); + QCOMPARE(shown.canvas->brushCursor().preview().cacheKey(), first); + // A step, place, sample, size or layer draws it anew. + shown.drag(QPointF(300, 200), QPointF(302, 200)); + shown.hover(QPointF(300, 200)); + const qint64 stepped = shown.canvas->brushCursor().preview().cacheKey(); + QVERIFY(stepped != first); + shown.hover(QPointF(301, 200)); + const qint64 moved = shown.canvas->brushCursor().preview().cacheKey(); + QVERIFY(moved != stepped); + shown.session.setCloneSettings(CloneSettings{true, true}); + shown.hover(QPointF(301, 200)); + const qint64 all = shown.canvas->brushCursor().preview().cacheKey(); + QVERIFY(all != moved); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 12; + shown.session.setBrushSettings(settings); + shown.hover(QPointF(301, 200)); + const qint64 wider = shown.canvas->brushCursor().preview().cacheKey(); + QVERIFY(wider != all); + QCOMPARE(shown.canvas->brushCursor().preview().size(), QSize(12, 12)); + shown.session.addBlankLayer(); + shown.hover(QPointF(301, 200)); + QVERIFY(shown.canvas->brushCursor().preview().cacheKey() != wider); +} + +void CloneStampCanvasTests::thePreviewTakesOneClicksSoftness() +{ + Canvas shown; + colors(shown); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 60; + shown.session.setBrushSettings(settings); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + // Hard: green to near the rim, over the blue. + QImage view = shown.canvas->grab().toImage(); + QCOMPARE(view.pixelColor(300, 200), QColor(0, 255, 0)); + QCOMPARE(view.pixelColor(324, 200), QColor(0, 255, 0)); + // Soft: one click fades, so the rim shows mostly blue. + settings.hardness = 0; + shown.session.setBrushSettings(settings); + shown.hover(QPointF(300, 200)); + view = shown.canvas->grab().toImage(); + const QColor middle = view.pixelColor(300, 200), rim = view.pixelColor(324, 200); + QVERIFY2(middle.green() > 100 && rim.green() < middle.green() - 60 && rim.blue() > 150, + qPrintable(middle.name() + " " + rim.name())); + // The tip: one click at the brush's size, rounded up. + const QImage soft = shown.canvas->brushCursor().tip(); + QCOMPARE(soft.size(), QSize(60, 60)); + QVERIFY(at(soft, 30, 30)[3] > 240 && at(soft, 54, 30)[3] < 60); + settings.hardness = 1; + settings.diameter = 60.5; + settings.opacity = 0.5; + shown.session.setBrushSettings(settings); + shown.hover(QPointF(300, 200)); + const QImage hard = shown.canvas->brushCursor().tip(); + QCOMPARE(hard.size(), QSize(61, 61)); + QCOMPARE(at(hard, 55, 30)[3], 255); + // At half opacity the preview is half over the blue. + view = shown.canvas->grab().toImage(); + QVERIFY2(near(view.pixelColor(300, 200), 0, 128, 128), qPrintable(view.pixelColor(300, 200).name())); +} + +void CloneStampCanvasTests::theBrushKeepsItsAltClicks() +{ + Canvas shown; + colors(shown); + shown.session.selectTool(NavigationTool::brush); + shown.click(QPointF(120, 120), Qt::AltModifier); + QVERIFY(!shown.session.cloneSource()); +} + +void CloneStampCanvasTests::theCrosshairAndPreviewFollowTheZoom() +{ + Canvas shown; + colors(shown); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->brushCursor().preview().size(), QSize(10, 10)); + shown.session.zoom(2, QPointF(0, 0)); + shown.canvas->synchronizeDisplay(); + const QPointF source = shown.session.viewport.viewPoint(QPointF(120, 120), shown.documentSize()); + QVERIFY(source != QPointF(120, 120)); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->brushCursor().marker().value(), source); + // Screen pixels: ten document pixels at 200% are twenty. + QCOMPARE(shown.canvas->brushCursor().preview().size(), QSize(20, 20)); + BrushSettings settings = shown.session.brushSettings(); + settings.diameter = 2000; + shown.session.setBrushSettings(settings); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.canvas->brushCursor().preview().size(), QSize(1024, 1024)); +} + +void CloneStampCanvasTests::thePreviewShowsTheLayerOrEveryLayerAtTheSource() +{ + Canvas shown; + colors(shown); + const QUuid colorsID = shown.session.activeLayerID().value(); + // Green begins at x 60: red left, green right. + shown.click(QPointF(60, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + const QImage edge = shown.canvas->brushCursor().preview(); + QCOMPARE(at(edge, 2, 5), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(at(edge, 7, 5), greenPixel); + // The blank layer alone shows nothing; every layer shows green. + shown.session.addBlankLayer(); + shown.session.selectTool(NavigationTool::cloneStamp); + shown.hover(QPointF(301, 200)); + QCOMPARE(at(shown.canvas->brushCursor().preview(), 7, 5), (std::vector<int>{0, 0, 0, 0})); + shown.session.setCloneSettings(CloneSettings{true, true}); + shown.hover(QPointF(301, 200)); + QCOMPARE(at(shown.canvas->brushCursor().preview(), 7, 5), greenPixel); + // Choosing another layer, no step taken, draws it anew. + shown.session.setCloneSettings(CloneSettings{true, false}); + shown.hover(QPointF(301, 200)); + const int steps = shown.session.history.undoCount(); + shown.session.selectLayer(colorsID); + QCOMPARE(shown.session.history.undoCount(), steps); + shown.hover(QPointF(301, 200)); + QCOMPARE(at(shown.canvas->brushCursor().preview(), 7, 5), greenPixel); +} + +void CloneStampCanvasTests::aCancelledStrokeOrAnotherStepRedrawsThePreview() +{ + Canvas shown; + colors(shown); + shown.session.addBlankLayer(); + const QUuid blank = shown.session.activeLayerID().value(); + shown.session.selectLayer(shown.session.document().value().layers.front().id); + shown.session.selectTool(NavigationTool::cloneStamp); + shown.click(QPointF(120, 120), Qt::AltModifier); + shown.hover(QPointF(300, 200)); + const qint64 first = shown.canvas->brushCursor().preview().cacheKey(); + // Escape ends a stroke with no step: the revision moved. + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.press(QPointF(300, 200)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.brushStroke()); + shown.release(QPointF(300, 200)); + shown.hover(QPointF(300, 200)); + const qint64 cancelled = shown.canvas->brushCursor().preview().cacheKey(); + QVERIFY(cancelled != first); + // A step elsewhere moves the history alone. + shown.session.toggleLayerVisibility(blank); + shown.hover(QPointF(300, 200)); + QVERIFY(shown.canvas->brushCursor().preview().cacheKey() != cancelled); +} + +QTEST_MAIN(CloneStampCanvasTests) +#include "CloneStampCanvasTests.moc" diff --git a/tests/CloneStampTests.cpp b/tests/CloneStampTests.cpp new file mode 100644 index 0000000..1f2f074 --- /dev/null +++ b/tests/CloneStampTests.cpp @@ -0,0 +1,349 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QSignalSpy> +#include <cmath> + +// Swift's CloneStampTests: a source copied under the brush. +namespace { +// Left half red with a green square, right half blue. +QImage colors() +{ + QImage image = BrushRaster::context(80, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 40, 40), QColor(255, 0, 0)); + painter.fillRect(QRect(40, 0, 40, 40), QColor(0, 0, 255)); + painter.fillRect(QRect(10, 15, 10, 10), QColor(0, 255, 0)); + return image; +} + +// Red left of `edge`, blue from it: a document-sized sample. +QImage edged(int width, int height, int edge) +{ + QImage image = BrushRaster::context(width, height, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, edge, height), QColor(255, 0, 0)); + painter.fillRect(QRect(edge, 0, width - edge, height), QColor(0, 0, 255)); + return image; +} + +std::vector<int> exported(const EditorSession &session, int x, int y) +{ + return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y); +} + +void stroke(EditorSession &session, QPointF point) +{ + session.beginBrush(point); + session.continueBrush(point + QPointF(0.5, 0)); + session.finishBrush(); +} + +void colored(EditorSession &session) +{ + session.createDocument(80, 40); + const QImage image = colors(); + session.insert(ImportedImage(image, image, QStringLiteral("Colors"))); + session.selectTool(NavigationTool::cloneStamp); + session.setBrushSettings(BrushSettings{.diameter = 6, .hardness = 1}); +} + +// Half red, half blue, within a level or two. +bool halfway(const std::vector<int> &pixel) +{ + return std::abs(pixel[0] - 128) <= 2 && pixel[1] == 0 && std::abs(pixel[2] - 128) <= 2 && pixel[3] == 255; +} + +const std::vector<int> redPixel = {255, 0, 0, 255}, bluePixel = {0, 0, 255, 255}, greenPixel = {0, 255, 0, 255}; +} + +class CloneStampTests : public QObject { + Q_OBJECT +private slots: + void copiesTheSourceUnderTheBrushKeepingAlignmentUntilItIsTurnedOff(); + void cloneStampKeepsItsOwnSoftBrushTip(); + void eachFamilyKeepsItsOpacityAndTheSmearItsTip(); + void theSamplePointFollowsTheOffsetOnceAStrokeFixesIt(); + void aSourceThatIsNoNumberIsRefused(); + void theActiveLayerAloneOrEveryVisibleLayerIsSampled(); + void aMaskIsNeverCloned(); + void aScaledLayerSamplesThroughItsGrid(); + void aLayerBetweenPixelsSamplesTheFraction(); + void aQuarterPixelWeighsItsNeighbours(); + void opacityAndTheSelectionLimitTheClone(); +}; + +void CloneStampTests::copiesTheSourceUnderTheBrushKeepingAlignmentUntilItIsTurnedOff() +{ + EditorSession session; + colored(session); + // Nothing to copy until a source is set. + session.beginBrush(QPointF(60, 20)); + QVERIFY(!session.brushStroke()); + QCOMPARE(session.brushError().value(), QString("Alt-click where Clone Stamp should copy from first.")); + session.setBrushError(std::nullopt); + session.setCloneSource(QPointF(15, 20)); + const int count = session.history.undoCount(); + stroke(session, QPointF(60, 20)); + QVERIFY(!session.brushError()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Clone Stamp")); + QCOMPARE(exported(session, 60, 20), greenPixel); + QCOMPARE(exported(session, 70, 5), bluePixel); + // Aligned: (66, 20) copies red from (21, 20). + stroke(session, QPointF(66, 20)); + QCOMPARE(exported(session, 66, 20), redPixel); + // Not aligned: every stroke starts at the source again. + session.setCloneSettings(CloneSettings{false, false}); + stroke(session, QPointF(50, 10)); + QCOMPARE(exported(session, 50, 10), greenPixel); +} + +void CloneStampTests::cloneStampKeepsItsOwnSoftBrushTip() +{ + EditorSession session; + session.createDocument(40, 20); + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 30; + session.setBrushSettings(settings); + QCOMPARE(session.brushSettings().hardness, 1.0); + session.selectTool(NavigationTool::cloneStamp); + QCOMPARE(session.brushSettings().hardness, 0.0); + QCOMPARE(session.brushSettings().diameter, 40.0); + settings = session.brushSettings(); + settings.hardness = 0.5; + session.setBrushSettings(settings); + // The Brush's tip returns for Spot Healing; Clone Stamp's stays. + session.selectTool(NavigationTool::spotHealing); + QCOMPARE(session.brushSettings().hardness, 1.0); + QCOMPARE(session.brushSettings().diameter, 30.0); + session.selectTool(NavigationTool::cloneStamp); + QCOMPARE(session.brushSettings().hardness, 0.5); + QCOMPARE(session.brushSettings().diameter, 40.0); +} + +void CloneStampTests::eachFamilyKeepsItsOpacityAndTheSmearItsTip() +{ + EditorSession session; + session.createDocument(40, 20); + session.selectTool(NavigationTool::cloneStamp); + BrushSettings settings = session.brushSettings(); + settings.opacity = 0.3; + settings.diameter = 12; + session.setBrushSettings(settings); + // The Smear starts soft at 40, as Swift's second family. + session.selectTool(NavigationTool::blur); + QCOMPARE(session.brushSettings().diameter, 40.0); + QCOMPARE(session.brushSettings().hardness, 0.0); + QCOMPARE(session.brushSettings().opacity, 1.0); + settings = session.brushSettings(); + settings.diameter = 70; + session.setBrushSettings(settings); + // Tools outside the brushes share the first family. + session.selectTool(NavigationTool::move); + QCOMPARE(session.brushSettings().diameter, 40.0); + QCOMPARE(session.brushSettings().hardness, 1.0); + session.selectTool(NavigationTool::brush); + QCOMPARE(session.brushSettings().diameter, 40.0); + session.selectTool(NavigationTool::cloneStamp); + QCOMPARE(session.brushSettings().opacity, 0.3); + QCOMPARE(session.brushSettings().diameter, 12.0); + session.selectTool(NavigationTool::blur); + QCOMPARE(session.brushSettings().diameter, 70.0); + // The colour is no tip: it stays across families. + settings = session.brushSettings(); + settings.green = 1; + session.setBrushSettings(settings); + session.selectTool(NavigationTool::brush); + QCOMPARE(session.brushSettings().green, 1.0); +} + +void CloneStampTests::theSamplePointFollowsTheOffsetOnceAStrokeFixesIt() +{ + EditorSession session; + colored(session); + QVERIFY(!session.cloneSamplePoint(QPointF(60, 20))); + QVERIFY(!session.cloneStrokeOffset(QPointF(60, 20))); + session.setCloneSource(QPointF(15.4, 20)); + // Until a stroke, the source itself; the offset rounds. + QCOMPARE(session.cloneSamplePoint(QPointF(60, 20)).value(), QPointF(15.4, 20)); + QCOMPARE(session.cloneStrokeOffset(QPointF(60.2, 21.4)).value(), QSizeF(-45, -1)); + QCOMPARE(session.cloneStrokeOffset(QPointF(59.8, 18.4)).value(), QSizeF(-44, 2)); + stroke(session, QPointF(60, 20)); + QCOMPARE(session.cloneSamplePoint(QPointF(30, 5)).value(), QPointF(-15, 5)); + QCOMPARE(session.cloneStrokeOffset(QPointF(30, 5)).value(), QSizeF(-45, 0)); + // Not aligned: the source, or a live stroke's offset. + session.setCloneSettings(CloneSettings{false, false}); + QCOMPARE(session.cloneSamplePoint(QPointF(30, 5)).value(), QPointF(15.4, 20)); + QCOMPARE(session.cloneStrokeOffset(QPointF(30, 5)).value(), QSizeF(-15, 15)); + session.beginBrush(QPointF(50, 10)); + QCOMPARE(session.cloneSamplePoint(QPointF(52, 10)).value(), QPointF(17, 20)); + session.cancelBrush(); + // A new source starts a new alignment. + session.setCloneSettings(CloneSettings{true, false}); + session.setCloneSource(QPointF(5, 5)); + QCOMPARE(session.cloneSamplePoint(QPointF(30, 5)).value(), QPointF(5, 5)); + QCOMPARE(session.cloneStrokeOffset(QPointF(30, 5)).value(), QSizeF(-25, 0)); +} + +void CloneStampTests::aSourceThatIsNoNumberIsRefused() +{ + EditorSession session; + colored(session); + session.setCloneSource(QPointF(15, 20)); + QSignalSpy changed(&session, &EditorSession::changed); + session.setCloneSource(QPointF(std::nan(""), 3)); + session.setCloneSource(QPointF(3, std::numeric_limits<double>::infinity())); + QCOMPARE(changed.size(), 0); + QCOMPARE(session.cloneSource().value(), QPointF(15, 20)); + session.setCloneSource(QPointF(16, 20)); + QCOMPARE(changed.size(), 1); +} + +void CloneStampTests::theActiveLayerAloneOrEveryVisibleLayerIsSampled() +{ + EditorSession session; + colored(session); + session.addBlankLayer(); + session.selectTool(NavigationTool::cloneStamp); + session.setBrushSettings(BrushSettings{.diameter = 6, .hardness = 1}); + session.setCloneSource(QPointF(15, 20)); + // The blank layer copies nothing over the blue. + stroke(session, QPointF(60, 20)); + QCOMPARE(exported(session, 60, 20), bluePixel); + // Tab samples every visible layer, as the canvas shows them. + session.cycleToolMode(); + QVERIFY(session.cloneSettings().sampleAllLayers); + QVERIFY(session.cloneSettings().aligned); + session.setCloneSource(QPointF(15, 20)); + stroke(session, QPointF(60, 20)); + QCOMPARE(exported(session, 60, 20), greenPixel); + // Hidden layers are not shown, so not sampled. + session.toggleLayerVisibility(session.document().value().layers.front().id); + session.setCloneSource(QPointF(15, 5)); + stroke(session, QPointF(70, 30)); + QCOMPARE(exported(session, 70, 30), (std::vector<int>{0, 0, 0, 0})); + // Tab leaves Aligned as it was. + session.setCloneSettings(CloneSettings{false, true}); + session.cycleToolMode(); + QVERIFY(!session.cloneSettings().sampleAllLayers && !session.cloneSettings().aligned); +} + +void CloneStampTests::aMaskIsNeverCloned() +{ + EditorSession session; + colored(session); + session.setCloneSource(QPointF(15, 20)); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + session.beginBrush(QPointF(60, 20)); + QVERIFY(!session.brushStroke()); + QVERIFY(!session.brushError()); +} + +void CloneStampTests::aScaledLayerSamplesThroughItsGrid() +{ + // Twenty pixels shown forty wide: each grid pixel spans two. + const QImage clear = BrushRaster::context(20, 10, false); + ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0)); + layer.transform.size = QSizeF(40, 20); + BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20)); + paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0)}; + paint.append(QPointF(21, 10)); + const QImage tile = paint.patches().at(0).image; + // Grid pixel 10 sits at document 21, sampling 31. + QCOMPARE(pixel(tile, 9, 5), redPixel); + QVERIFY2(halfway(pixel(tile, 10, 5)), qPrintable(QString::number(pixel(tile, 10, 5)[0]))); + QCOMPARE(pixel(tile, 11, 5), bluePixel); +} + +void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction() +{ + // Half a pixel right: each grid centre falls between two. + ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20)); + layer.transform.origin = QPointF(0.5, 0); + BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20)); + paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0)}; + paint.append(QPointF(28, 10)); + const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5)); + QCOMPARE(centre, QPointF(28, 10.5)); + const QImage tile = paint.patches().at(0).image; + QCOMPARE(pixel(tile, 27, 10), redPixel); + QVERIFY2(halfway(pixel(tile, 28, 10)), qPrintable(QString::number(pixel(tile, 28, 10)[0]))); + QCOMPARE(pixel(tile, 29, 10), bluePixel); + // Up and down too: a row between two. + ImageLayer lowered(QStringLiteral("Blank"), QSizeF(40, 20)); + lowered.transform.origin = QPointF(0, 0.5); + BrushStroke down(lowered, false, brush(20, 1, 0, 0, 0), QSizeF(40, 40)); + QImage rows = BrushRaster::context(40, 40, false); + rows.fill(QColor(255, 0, 0)); + QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255)); + down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)}; + down.append(QPointF(20, 12)); + const QImage lowTile = down.patches().at(0).image; + const int row = int(std::floor(down.pixelToDocument.inverted().map(QPointF(20, 12)).y())); + QVERIFY2(halfway(pixel(lowTile, 20, row)), qPrintable(QString::number(row))); + QCOMPARE(pixel(lowTile, 20, row - 1), redPixel); + QCOMPARE(pixel(lowTile, 20, row + 1), bluePixel); +} + +void CloneStampTests::aQuarterPixelWeighsItsNeighbours() +{ + // The layer sits a quarter right, three quarters down. + ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20)); + layer.transform.origin = QPointF(0.25, 0.75); + const auto near = [](const std::vector<int> &pixel, int red, int blue) { + return std::abs(pixel[0] - red) <= 2 && pixel[1] == 0 && std::abs(pixel[2] - blue) <= 2 && pixel[3] == 255; + }; + BrushStroke across(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40)); + across.clone = BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0)}; + across.append(QPointF(31, 12)); + QCOMPARE(across.pixelToDocument.map(QPointF(31.5, 12.5)), QPointF(30.75, 12.25)); + const std::vector<int> mixed = pixel(across.patches().at(0).image, 31, 12); + QVERIFY2(near(mixed, 191, 64), qPrintable(QString("%1 %2").arg(mixed[0]).arg(mixed[2]))); + // Rows: red above twelve, blue from it. + QImage rows = BrushRaster::context(80, 40, false); + rows.fill(QColor(255, 0, 0)); + QPainter(&rows).fillRect(QRect(0, 12, 80, 28), QColor(0, 0, 255)); + BrushStroke down(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40)); + down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)}; + down.append(QPointF(20, 12)); + const std::vector<int> stacked = pixel(down.patches().at(0).image, 20, 12); + QVERIFY2(near(stacked, 64, 191), qPrintable(QString("%1 %2").arg(stacked[0]).arg(stacked[2]))); +} + +void CloneStampTests::opacityAndTheSelectionLimitTheClone() +{ + QImage under = BrushRaster::context(40, 20, false); + under.fill(QColor(0, 0, 255)); + QImage over = BrushRaster::context(40, 20, false); + over.fill(QColor(255, 0, 0)); + BrushStroke half(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0, 0.5), + QSizeF(40, 20)); + half.clone = BrushStroke::Clone{over, QSizeF(0, 0)}; + half.append(QPointF(10, 10)); + // Each byte is rounded once: 127.5 and 127.5 make 128. + QCOMPARE(pixel(half.patches().at(0).image, 10, 10), (std::vector<int>{128, 0, 128, 255})); + // The selection's edge bounds it; a clear sample clears nothing. + BrushStroke bounded(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0), + QSizeF(40, 20)); + DocumentSelection selection; + selection.path.addRect(QRectF(0, 0, 10, 20)); + bounded.selectionClip = selection.clip(QSizeF(40, 20)); + QImage sample = over.copy(); + { + QPainter painter(&sample); + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent); + } + bounded.clone = BrushStroke::Clone{sample, QSizeF(0, 0)}; + bounded.append(QPointF(10, 8)); + const QImage tile = bounded.patches().at(0).image; + QCOMPARE(pixel(tile, 8, 8), redPixel); + QCOMPARE(pixel(tile, 11, 8), bluePixel); + QCOMPARE(pixel(tile, 8, 4), bluePixel); +} + +QTEST_GUILESS_MAIN(CloneStampTests) +#include "CloneStampTests.moc" diff --git a/tests/ColorPaletteControlsTests.cpp b/tests/ColorPaletteControlsTests.cpp new file mode 100644 index 0000000..79c98e5 --- /dev/null +++ b/tests/ColorPaletteControlsTests.cpp @@ -0,0 +1,179 @@ +#include "ColorPaletteFixtures.h" +#include "ContentView.h" +#include <QScrollArea> + +// Swift's ColorPaletteControls under the rail. +class ColorPaletteControlsTests : public QObject { + Q_OBJECT +private slots: + void swatchesShowThePaletteAndOpenThePicker(); + void swapAndDefaultsFollowTheirButtons(); + void aMaskChoosesBlackOrWhite(); + void aMaskEndsTheChoiceAndThePicker(); + void aBusyProjectRestsThePalette(); + void theRailHoldsThePaletteBelowItsTools(); +}; + +void ColorPaletteControlsTests::swatchesShowThePaletteAndOpenThePicker() +{ + Palette shown; + QAbstractButton &foreground = shown.button("foregroundSwatch"), &background = shown.button("backgroundSwatch"); + QCOMPARE(shown.controls->size(), QSize(42, 42)); + QCOMPARE(foreground.geometry(), QRect(3, 3, 24, 24)); + QCOMPARE(background.geometry(), QRect(15, 15, 24, 24)); + QCOMPARE(shown.button("swapColors").geometry(), QRect(30, 0, 12, 12)); + QCOMPARE(shown.button("defaultColors").geometry(), QRect(2, 30, 12, 12)); + QCOMPARE(middle(foreground), QColor(Qt::black)); + QCOMPARE(middle(background), QColor(Qt::white)); + // A white ring inside a black rim. + QVERIFY(foreground.grab().toImage().pixelColor(12, 1).lightness() > 200); + QCOMPARE(background.grab().toImage().pixelColor(12, 0).lightness(), 0); + QCOMPARE(foreground.toolTip(), QString("Foreground color")); + QCOMPARE(background.toolTip(), QString("Background color")); + // Each colour repaints its whole swatch; the two overlap. + QCoreApplication::processEvents(); + PaintSpy front(foreground), back(background); + shown.session.setForegroundColor(PaletteColor{0, 0, 1}); + QTRY_COMPARE(front.painted, foreground.rect()); + shown.session.setBackgroundColor(PaletteColor{0, 0, 1}); + QTRY_COMPARE(back.painted, background.rect()); + shown.session.resetPaletteColors(); + // A swatch opens the picker; the other takes it over. + QTest::mouseClick(&foreground, Qt::LeftButton); + QWidget *window = panel(); + QVERIFY(window); + QCOMPARE(window->windowTitle(), QString("Color Picker (Foreground Color)")); + QTest::mouseClick(&background, Qt::LeftButton); + QCOMPARE(panel(), window); + QCOMPARE(window->windowTitle(), QString("Color Picker (Background Color)")); + QCOMPARE(shown.session.colorPicker().value().original, PaletteColor::white()); + shown.session.setColorPickerHSB(PickerHSB(0, 1, 1)); + QTest::mouseClick(&inPanel<QPushButton>("pickerCancel"), Qt::LeftButton); + QVERIFY(!shown.session.colorPicker()); + QVERIFY(!panel()); + QCOMPARE(shown.session.backgroundColor(), PaletteColor::white()); + QTest::mouseClick(&foreground, Qt::LeftButton); + shown.session.setColorPickerHSB(PickerHSB(120, 1, 1)); + QTest::mouseClick(&inPanel<QPushButton>("pickerOK"), Qt::LeftButton); + QVERIFY(!panel()); + QCOMPARE(shown.session.foregroundColor(), (PaletteColor{0, 1, 0})); + QCOMPARE(middle(foreground), QColor(Qt::green)); +} + +void ColorPaletteControlsTests::swapAndDefaultsFollowTheirButtons() +{ + Palette shown; + QAbstractButton &swap = shown.button("swapColors"), &defaults = shown.button("defaultColors"); + QCOMPARE(swap.toolTip(), QString("Swap foreground and background (X)")); + QCOMPARE(swap.accessibleName(), QString("Swap colors")); + QCOMPARE(defaults.toolTip(), QString("Default colors (D)")); + QCOMPARE(defaults.accessibleName(), QString("Default colors")); + shown.session.setForegroundColor(PaletteColor{1, 0, 0}); + QTest::mouseClick(&swap, Qt::LeftButton); + QCOMPARE(shown.session.foregroundColor(), PaletteColor::white()); + QCOMPARE(shown.session.backgroundColor(), (PaletteColor{1, 0, 0})); + QCOMPARE(middle(shown.button("backgroundSwatch")), QColor(Qt::red)); + QTest::mouseClick(&defaults, Qt::LeftButton); + QCOMPARE(shown.session.foregroundColor(), PaletteColor::black()); + QCOMPARE(shown.session.backgroundColor(), PaletteColor::white()); + // The glyphs take the secondary ink, dimmed when resting. + QPalette inks = shown.controls->palette(); + inks.setColor(QPalette::Active, QPalette::PlaceholderText, Qt::red); + inks.setColor(QPalette::Inactive, QPalette::PlaceholderText, Qt::red); + inks.setColor(QPalette::Disabled, QPalette::PlaceholderText, Qt::blue); + shown.controls->setPalette(inks); + const auto ink = [](QWidget &glyph, QPoint at) { return glyph.grab().toImage().pixelColor(at).hsvHue(); }; + // The swap's diagonal, the ring and the ring's head. + for (const auto &[glyph, at] : {std::pair(&swap, QPoint(4, 4)), std::pair(&defaults, QPoint(9, 6)), std::pair(&defaults, QPoint(7, 1))}) + QCOMPARE(ink(*glyph, at), 0); + // Turned: the head's corner inked; a flat tip's spot bare. + QCOMPARE(ink(swap, QPoint(4, 2)), 0); + QCOMPARE(ink(swap, QPoint(1, 6)), -1); + shown.session.setIsProjectBusy(true); + QCOMPARE(ink(swap, QPoint(4, 4)), 240); + QCOMPARE(ink(defaults, QPoint(9, 6)), 240); +} + +void ColorPaletteControlsTests::aMaskChoosesBlackOrWhite() +{ + Palette shown; + shown.session.addMask(); + QVERIFY(shown.session.isMaskSelected()); + for (const auto &[name, heading, choice] : {std::tuple("foregroundSwatch", "Mask foreground", "White · Reveal"), + std::tuple("backgroundSwatch", "Mask background", "Black · Hide")}) { + QTest::mouseClick(&shown.button(name), Qt::LeftButton); + QWidget *popup = QApplication::activePopupWidget(); + QVERIFY(popup); + QCOMPARE(popup->objectName(), QString("maskColorChoice")); + // Under the swatches; offscreen adds a frame of two. + QVERIFY((popup->pos() - shown.controls->mapToGlobal(QPoint(0, shown.controls->height()))).manhattanLength() <= 4); + QCOMPARE(popup->findChild<QLabel *>()->text(), QString::fromUtf8(heading)); + QPushButton *button = nullptr; + for (QPushButton *each : popup->findChildren<QPushButton *>()) { + if (each->text() == QString::fromUtf8(choice)) + button = each; + } + QVERIFY(button); + QTest::mouseClick(button, Qt::LeftButton); + QTRY_VERIFY(!QApplication::activePopupWidget()); + QVERIFY(shown.session.maskPaintWhite()); + QVERIFY(!shown.session.colorPicker()); + } + QCOMPARE(shown.session.paletteColor(false), PaletteColor::white()); + QCOMPARE(shown.session.paletteColor(true), PaletteColor::black()); + QCOMPARE(middle(shown.button("foregroundSwatch")), QColor(Qt::white)); +} + +void ColorPaletteControlsTests::aMaskEndsTheChoiceAndThePicker() +{ + Palette shown; + shown.session.addMask(); + const QUuid id = shown.session.activeLayerID().value(); + shown.session.selectLayerTarget(id, false); + // A mask chosen cancels the open picker. + QTest::mouseClick(&shown.button("foregroundSwatch"), Qt::LeftButton); + shown.session.setColorPickerHSB(PickerHSB(0, 1, 1)); + shown.session.selectLayerTarget(id, true); + QVERIFY(!shown.session.colorPicker()); + QVERIFY(!panel()); + QCOMPARE(shown.session.foregroundColor(), PaletteColor::black()); + // The pixels chosen close the mask's choice. + QTest::mouseClick(&shown.button("foregroundSwatch"), Qt::LeftButton); + QVERIFY(QApplication::activePopupWidget()); + shown.session.selectLayerTarget(id, false); + QTRY_VERIFY(!QApplication::activePopupWidget()); +} + +void ColorPaletteControlsTests::aBusyProjectRestsThePalette() +{ + Palette shown; + shown.session.setIsProjectBusy(true); + QVERIFY(!shown.controls->isEnabled()); + shown.session.setIsProjectBusy(false); + QVERIFY(shown.controls->isEnabled()); +} + +void ColorPaletteControlsTests::theRailHoldsThePaletteBelowItsTools() +{ + EditorSession session; + session.createDocument(400, 300, true); + ContentView view(session); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + auto *palette = view.findChild<ColorPaletteControls *>(); + QVERIFY(palette); + QWidget *rail = view.findChild<QScrollArea *>(QStringLiteral("toolRail"))->widget(); + QRect last; + for (const QToolButton *tool : rail->findChildren<QToolButton *>()) { + if (tool->parentWidget() == rail && tool->geometry().bottom() > last.bottom()) + last = tool->geometry(); + } + // Swift's column: 36 across, the swatches 18 below the tools. + const QRect swatch = palette->findChild<QAbstractButton *>(QStringLiteral("foregroundSwatch"))->geometry().translated(palette->pos()); + QCOMPARE(last.left(), 10); + QCOMPARE(swatch.left(), 10); + QCOMPARE(swatch.top() - (last.bottom() + 1), 18); +} + +QTEST_MAIN(ColorPaletteControlsTests) +#include "ColorPaletteControlsTests.moc" diff --git a/tests/ColorPaletteFixtures.h b/tests/ColorPaletteFixtures.h new file mode 100644 index 0000000..1e455f0 --- /dev/null +++ b/tests/ColorPaletteFixtures.h @@ -0,0 +1,69 @@ +#pragma once +#include "SelectionCanvasFixtures.h" +#include "UI/ColorPaletteControls.h" +#include <QApplication> +#include <QLabel> +#include <QPushButton> + +// Shared by the palette's and the picker sheet's tests. +struct Palette { + EditorSession session; + QWidget window; + ColorPaletteControls *const controls; + Palette() : controls((session.createDocument(40, 40, true), new ColorPaletteControls(session, &window))) + { + window.resize(300, 200); + controls->move(20, 20); + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + } + QAbstractButton &button(const char *name) const + { + auto *found = controls->findChild<QAbstractButton *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; + } +}; + +inline QWidget *panel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == ColorPickerPanelController::identifier() && widget->isVisible()) + return widget; + } + return nullptr; +} + +template <typename Widget> Widget &inPanel(const char *name) +{ + auto *found = panel()->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} + +inline QColor middle(QWidget &widget) +{ + return widget.grab().toImage().pixelColor(widget.width() / 2, widget.height() / 2); +} + +inline void type(QWidget &field, const QString &text) +{ + field.setFocus(); + QTest::keyClick(&field, Qt::Key_A, Qt::ControlModifier); + QTest::keyClick(&field, Qt::Key_Backspace); + QTest::keyClicks(&field, text); +} + +// Opens the foreground's picker, the panel holding the keys. +inline void openForeground(Palette &shown) +{ + // Window managers hand the keys back once a panel closes. + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + QTest::mouseClick(&shown.button("foregroundSwatch"), Qt::LeftButton); + // Tool windows read active with their parent: wait for keys. + QTRY_COMPARE(QApplication::activeWindow(), panel()); +} diff --git a/tests/ColorPickerSheetTests.cpp b/tests/ColorPickerSheetTests.cpp new file mode 100644 index 0000000..b328b02 --- /dev/null +++ b/tests/ColorPickerSheetTests.cpp @@ -0,0 +1,262 @@ +#include "ColorPaletteFixtures.h" +#include "UI/ColorPickerSheet.h" +#include <QAccessible> +#include <QMenu> + +// Swift's ColorPickerSheet: field, strip, preview, entries, keys. +class ColorPickerSheetTests : public QObject { + Q_OBJECT +private slots: + void theFieldAndStripSetTheWorkingColour(); + void theFieldStripAndPreviewDrawAsSwifts(); + void theEntriesTypeAndStep(); + void entriesKeepTheirBoundsAndTheirTyping(); + void returnAppliesWhereEscapeAndClosingCancel(); + void everyControlCarriesSwiftsLabel(); +}; + +void ColorPickerSheetTests::theFieldAndStripSetTheWorkingColour() +{ + Palette shown; + openForeground(shown); + QWidget &field = inPanel<QWidget>("saturationBrightness"), &strip = inPanel<QWidget>("hueStrip"); + QCOMPARE(field.size(), QSize(256, 256)); + QCOMPARE(strip.size(), QSize(34, 256)); + QTest::mousePress(&field, Qt::LeftButton, Qt::NoModifier, QPoint(300, -10)); + QTest::mouseRelease(&field, Qt::LeftButton, Qt::NoModifier, QPoint(300, -10)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + QTest::mousePress(&strip, Qt::LeftButton, Qt::NoModifier, QPoint(17, 128)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("00FFFF")); + QMouseEvent drag(QEvent::MouseMove, QPointF(17, 400), QPointF(17, 400), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&strip, &drag); + QCOMPARE(shown.session.colorPicker().value().hsb.hue, 0.0); + QTest::mouseRelease(&strip, Qt::LeftButton, Qt::NoModifier, QPoint(17, 400)); + // The other buttons pick nothing, as Swift's drag. + QTest::mousePress(&strip, Qt::RightButton, Qt::NoModifier, QPoint(17, 128)); + QMouseEvent right(QEvent::MouseMove, QPointF(17, 64), QPointF(17, 64), Qt::NoButton, Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(&strip, &right); + QTest::mouseRelease(&strip, Qt::RightButton, Qt::NoModifier, QPoint(17, 64)); + QTest::mouseClick(&field, Qt::RightButton, Qt::NoModifier, QPoint(0, 255)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + // Half down the field: half as bright. + QTest::mouseClick(&field, Qt::LeftButton, Qt::NoModifier, QPoint(256, 128)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("800000")); + QCOMPARE(middle(inPanel<QWidget>("newColor")), QColor(128, 0, 0)); + // A pick, field or strip, replaces a channel's typing. + QLineEdit &red = inPanel<QLineEdit>("r"), &green = inPanel<QLineEdit>("g"); + type(red, QStringLiteral("12")); + QTest::mouseClick(&field, Qt::LeftButton, Qt::NoModifier, QPoint(256, 0)); + QVERIFY(red.hasFocus() && red.text() == "255" && !red.isModified()); + type(red, QStringLiteral("12")); + QTest::mouseClick(&strip, Qt::LeftButton, Qt::NoModifier, QPoint(17, 128)); + QVERIFY(red.text() == "0" && !red.isModified()); + green.setFocus(); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("00FFFF")); +} + +void ColorPickerSheetTests::theFieldStripAndPreviewDrawAsSwifts() +{ + Palette shown; + openForeground(shown); + // A colour from elsewhere repaints the field, strip and preview. + QWidget &sheet = *panel()->findChild<ColorPickerSheet *>(), &stripWidget = inPanel<QWidget>("hueStrip"); + PaintSpy fieldPaints(inPanel<QWidget>("saturationBrightness")), stripPaints(stripWidget), previewPaints(inPanel<QWidget>("newColor")), + sheetPaints(sheet); + shown.session.setColorPickerHSB(PickerHSB(0, 0.5, 0.25)); + QTRY_VERIFY(fieldPaints.count > 0 && stripPaints.count > 0 && previewPaints.count > 0); + // The arrows' room too: five points past either end. + QTRY_VERIFY(sheetPaints.painted.contains(stripWidget.geometry().adjusted(0, -5, 0, 5))); + // White to the hue across, to black down, framed. + const QImage field = inPanel<QWidget>("saturationBrightness").grab().toImage(); + QVERIFY(field.pixelColor(2, 2).lightness() > 245); + const QColor hue = field.pixelColor(253, 2); + QVERIFY2(hue.red() > 245 && hue.green() < 12 && hue.blue() < 12, qPrintable(hue.name())); + QVERIFY(field.pixelColor(128, 253).lightness() < 10); + QVERIFY(field.pixelColor(128, 0).lightness() < field.pixelColor(128, 2).lightness()); + // The marker: white ring, black ring, at the colour. + QVERIFY(field.pixelColor(133, 192).lightness() > 200); + QVERIFY(field.pixelColor(134, 192).lightness() < field.pixelColor(138, 192).lightness()); + // Red, magenta, cyan, red down the strip; arrows mark hues. + shown.session.setColorPickerHSB(PickerHSB(90, 1, 1)); + const QImage strip = inPanel<QWidget>("hueStrip").grab().toImage(); + for (const auto &[y, turn] : {std::pair(2, 0), std::pair(43, 300), std::pair(128, 180), std::pair(253, 0)}) { + const QColor shade = strip.pixelColor(17, y); + const int off = std::abs(shade.hsvHue() - turn) % 360; + QVERIFY2(std::min(off, 360 - off) <= 8 && shade.hsvSaturation() == 255, qPrintable(shade.name())); + } + // The sheet draws the arrows, past the strip's ends too. + const QColor ink = panel()->palette().color(QPalette::WindowText); + const auto arrows = [&sheet, &stripWidget](QPoint at) { return sheet.grab().toImage().pixelColor(stripWidget.geometry().topLeft() + at); }; + QCOMPARE(arrows(QPoint(2, 192)), ink); + QCOMPARE(arrows(QPoint(31, 192)), ink); + QVERIFY(arrows(QPoint(2, 64)) != ink); + // Antialiased: an edge pixel a quarter covered is neither. + const QColor edge = arrows(QPoint(2, 188)); + QVERIFY2(edge != ink && edge != arrows(QPoint(2, 64)), qPrintable(edge.name())); + QVERIFY(strip.pixelColor(7, 192) != strip.pixelColor(8, 192)); + // The preview is the new colour, rimmed in 60% black. + const QImage preview = inPanel<QWidget>("newColor").grab().toImage(); + QCOMPARE(preview.pixelColor(32, 32), QColor(128, 255, 0)); + const QColor rim = preview.pixelColor(32, 0); + QVERIFY2(std::abs(rim.red() - 51) <= 2 && std::abs(rim.green() - 102) <= 2, qPrintable(rim.name())); + // At either end the arrows reach past the strip. + for (const auto &[turn, y] : {std::pair(360.0, -3), std::pair(0.0, 258)}) { + shown.session.setColorPickerHSB(PickerHSB(turn, 1, 1)); + QCOMPARE(arrows(QPoint(1, y)), ink); + QCOMPARE(arrows(QPoint(32, y)), ink); + } +} + +void ColorPickerSheetTests::theEntriesTypeAndStep() +{ + Palette shown; + openForeground(shown); + QLineEdit &red = inPanel<QLineEdit>("r"), &green = inPanel<QLineEdit>("g"), &hex = inPanel<QLineEdit>("hex"); + QCOMPARE(red.text(), QString("0")); + QCOMPARE(hex.text(), QString("000000")); + // Past 255 is 255; leaving applies it. + type(red, QStringLiteral("300")); + green.setFocus(); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + QCOMPARE(red.text(), QString("255")); + QCOMPARE(hex.text(), QString("FF0000")); + // The arrows step: one, ten with Shift. + QTest::keyClick(&green, Qt::Key_Up); + QTest::keyClick(&green, Qt::Key_Up, Qt::ShiftModifier); + QTest::keyClick(&green, Qt::Key_Down); + QCOMPARE(green.text(), QString("10")); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0A00")); + // Empty or wild text puts the colour's own back. + type(green, QString()); + hex.setFocus(); + QCOMPARE(green.text(), QString("10")); + type(hex, QStringLiteral("zzz")); + red.setFocus(); + QCOMPARE(hex.text(), QString("FF0A00")); + type(hex, QStringLiteral("#00f")); + red.setFocus(); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("0000FF")); + QCOMPARE(red.text(), QString("0")); + // Typing stays while the colour changes elsewhere. + type(red, QStringLiteral("12")); + shown.session.setColorPickerHSB(PickerHSB(120, 1, 1)); + QCOMPARE(red.text(), QString("12")); + QCOMPARE(hex.text(), QString("00FF00")); + type(hex, QStringLiteral("12")); + shown.session.setColorPickerHSB(PickerHSB(240, 1, 1)); + QCOMPARE(hex.text(), QString("12")); + // A step after typing shows the stepped number. + type(red, QStringLiteral("12")); + QTest::keyClick(&red, Qt::Key_Up); + QCOMPARE(red.text(), QString("1")); + // Blue takes its typing too. + QLineEdit &blue = inPanel<QLineEdit>("b"); + type(blue, QStringLiteral("200")); + red.setFocus(); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("0100C8")); +} + +void ColorPickerSheetTests::entriesKeepTheirBoundsAndTheirTyping() +{ + Palette shown; + openForeground(shown); + QLineEdit &red = inPanel<QLineEdit>("r"), &green = inPanel<QLineEdit>("g"), &hex = inPanel<QLineEdit>("hex"); + // More digits than a number holds read as the top. + type(red, QStringLiteral("99999999999999999999")); + green.setFocus(); + QCOMPARE(red.text(), QString("255")); + QTest::keyClick(&red, Qt::Key_Up); + QCOMPARE(red.text(), QString("255")); + QTest::keyClick(&green, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(green.text(), QString("0")); + // A menu or popup borrowing the focus keeps the typing. + type(green, QStringLiteral("200")); + for (const Qt::FocusReason reason : {Qt::PopupFocusReason, Qt::MenuBarFocusReason}) { + QFocusEvent borrowed(QEvent::FocusOut, reason); + QApplication::sendEvent(&green, &borrowed); + QCOMPARE(green.text(), QString("200")); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + } + red.setFocus(); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FFC800")); + // A menu taking the focus keeps the typing through changes. + for (QLineEdit *entry : {&green, &hex}) { + type(*entry, QStringLiteral("99")); + QMenu menu; + menu.addAction(QStringLiteral("Nothing")); + menu.popup(entry->mapToGlobal(QPoint(0, 0))); + QTRY_VERIFY(QApplication::activePopupWidget()); + shown.session.setColorPickerHSB(PickerHSB(240, 1, 1)); + QCOMPARE(entry->text(), QString("99")); + menu.close(); + } + // Letters never reach a channel. + type(red, QStringLiteral("ab")); + QCOMPARE(red.text(), QString()); + green.setFocus(); + QCOMPARE(red.text(), QString("0")); + // An entry left untouched leaves the working colour exact. + const PickerHSB exact(10.3, 0.5, 0.5); + shown.session.setColorPickerHSB(exact); + green.setFocus(); + red.setFocus(); + QVERIFY(shown.session.colorPicker().value().hsb == exact); + // The hex commits on each leave, as Swift's: 8 bits. + hex.setFocus(); + red.setFocus(); + QVERIFY(!(shown.session.colorPicker().value().hsb == exact)); + QCOMPARE(shown.session.colorPicker().value().color(), PickerHSB(exact).rgb().quantized()); +} + +void ColorPickerSheetTests::returnAppliesWhereEscapeAndClosingCancel() +{ + Palette shown; + openForeground(shown); + type(inPanel<QLineEdit>("r"), QStringLiteral("128")); + QTest::keyClick(&inPanel<QLineEdit>("r"), Qt::Key_Return); + QVERIFY(!panel()); + QCOMPARE(shown.session.foregroundColor().hex(), QString("800000")); + openForeground(shown); + type(inPanel<QLineEdit>("hex"), QStringLiteral("00ff00")); + QTest::keyClick(&inPanel<QLineEdit>("hex"), Qt::Key_Escape); + QVERIFY(!panel()); + QVERIFY(!shown.session.colorPicker()); + QCOMPARE(shown.session.foregroundColor().hex(), QString("800000")); + QTest::mouseClick(&shown.button("backgroundSwatch"), Qt::LeftButton); + shown.session.setColorPickerHSB(PickerHSB(240, 1, 1)); + panel()->close(); + QVERIFY(!shown.session.colorPicker()); + QCOMPARE(shown.session.backgroundColor(), PaletteColor::white()); + // Return on a focused Cancel still means OK, the default. + openForeground(shown); + shown.session.setColorPickerHSB(PickerHSB(120, 1, 1)); + QPushButton &cancel = inPanel<QPushButton>("pickerCancel"); + cancel.setFocus(); + QTest::keyClick(&cancel, Qt::Key_Return); + QVERIFY(!panel()); + QCOMPARE(shown.session.foregroundColor(), (PaletteColor{0, 1, 0})); +} + +void ColorPickerSheetTests::everyControlCarriesSwiftsLabel() +{ + Palette shown; + openForeground(shown); + // What a screen reader reads, as Swift labels it. + const auto text = [](const char *name, QAccessible::Text kind) { return QAccessible::queryAccessibleInterface(&inPanel<QWidget>(name))->text(kind); }; + for (const auto &[name, label] : {std::pair("saturationBrightness", "Saturation and brightness"), std::pair("hueStrip", "Hue"), + std::pair("newColor", "New color"), std::pair("r", "Red"), std::pair("g", "Green"), std::pair("b", "Blue"), + std::pair("hex", "Hex color")}) + QCOMPARE(text(name, QAccessible::Name), QString(label)); + // Swift's titles show while an entry is empty. + for (const auto &[name, title] : {std::pair("r", "R"), std::pair("g", "G"), std::pair("b", "B"), std::pair("hex", "Hex")}) + QCOMPARE(inPanel<QLineEdit>(name).placeholderText(), QString(title)); + // The hue's whole degrees, rounded, follow the colour. + QCOMPARE(text("hueStrip", QAccessible::Description), QString("0 degrees")); + shown.session.setColorPickerHSB(PickerHSB(120.5, 1, 1)); + QCOMPARE(text("hueStrip", QAccessible::Description), QString("121 degrees")); + shown.session.setColorPickerHSB(PickerHSB(359.4, 1, 1)); + QCOMPARE(text("hueStrip", QAccessible::Description), QString("359 degrees")); +} + +QTEST_MAIN(ColorPickerSheetTests) +#include "ColorPickerSheetTests.moc" diff --git a/tests/ColorPickerTests.cpp b/tests/ColorPickerTests.cpp new file mode 100644 index 0000000..a6f9456 --- /dev/null +++ b/tests/ColorPickerTests.cpp @@ -0,0 +1,310 @@ +#include "SessionFixtures.h" +#include "UI/ColorPickerSheet.h" +#include <QApplication> +#include <QSignalSpy> +#include <QtTest> +#include <cmath> + +// Swift's ColorPickerTests, and the session's picker. +namespace { +QWidget *shownPanel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == ColorPickerPanelController::identifier() && widget->isVisible()) + return widget; + } + return nullptr; +} + +// Red over its top two rows, blue under. +std::unique_ptr<EditorSession> splitSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(4, 4); + QImage image(4, 4, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::blue); + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 4; ++x) + image.setPixelColor(x, y, Qt::red); + } + session->insert(ImportedImage(image, image, QStringLiteral("Split"))); + return session; +} +} + +class ColorPickerTests : public QObject { + Q_OBJECT +private slots: + void hexParsesFullShorthandAndRejectsInvalid(); + void hsbRoundTripsEightBitColors(); + void graysAndBlackKeepPreviousHueAndSaturation(); + void hueTurnsTheWheelBySixths(); + void canvasSamplingReadsCompositeAndCommitsOnlyOnOK(); + void samplingUndoesPremultiplication(); + void pickersOpenOnlyWhereTheyMay(); + void textPickersColourTheTextAndTheSwatch(); + void pickerReopensWhereItWasLastLeft(); +}; + +void ColorPickerTests::hexParsesFullShorthandAndRejectsInvalid() +{ + QCOMPARE(PaletteColor::fromHex(QStringLiteral("#FF8000")), std::optional(PaletteColor{1, 128.0 / 255, 0})); + QCOMPARE(PaletteColor::fromHex(QStringLiteral("0f0")), std::optional(PaletteColor{0, 1, 0})); + QCOMPARE(PaletteColor::fromHex(QStringLiteral(" 00ff00 ")), std::optional(PaletteColor{0, 1, 0})); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("12345"))); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("GGGGGG"))); + QCOMPARE((PaletteColor{1, 128.0 / 255, 0}).hex(), QString("FF8000")); + // Swift trims tabs and wide spaces, never line breaks. + QCOMPARE(PaletteColor::fromHex(QStringLiteral("\t#abc") + QChar(0x00A0)), std::optional(PaletteColor{170.0 / 255, 187.0 / 255, 204.0 / 255})); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("\n00ff00"))); + // Its UInt32 takes signs, minus for zero only; no 0x. + QCOMPARE(PaletteColor::fromHex(QStringLiteral("+0F800")), std::optional(PaletteColor{0, 248.0 / 255, 0})); + QCOMPARE(PaletteColor::fromHex(QStringLiteral("-00000")), std::optional(PaletteColor::black())); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("-00001"))); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("0x1234"))); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("#"))); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("##abc"))); + QCOMPARE((PaletteColor{0.2, 0.5, 1}).quantized(), (PaletteColor{51.0 / 255, 128.0 / 255, 1})); + // Halves round up, as Swift's; every digit and case reads. + QCOMPARE((PaletteColor{0.5, 0.5, 0.5}).hex(), QString("808080")); + QCOMPARE(PaletteColor::fromHex(QStringLiteral("#99AAbb")), std::optional(PaletteColor{153.0 / 255, 170.0 / 255, 187.0 / 255})); + QVERIFY(!PaletteColor::fromHex(QStringLiteral("gggggg"))); +} + +void ColorPickerTests::hsbRoundTripsEightBitColors() +{ + for (const char *hex : {"000000", "FFFFFF", "FF0000", "00FF00", "0000FF", "FF8000", "7F3FA2", "123456"}) + QCOMPARE(PickerHSB(PaletteColor::fromHex(QString::fromLatin1(hex)).value()).rgb().quantized().hex(), QString::fromLatin1(hex)); +} + +void ColorPickerTests::graysAndBlackKeepPreviousHueAndSaturation() +{ + PickerHSB hsb(PaletteColor::fromHex(QStringLiteral("FF8000")).value()); + const double hue = hsb.hue; + hsb.setRGB(PaletteColor::fromHex(QStringLiteral("808080")).value()); + QVERIFY(hsb.hue == hue && hsb.saturation == 0); + hsb.saturation = 0.5; + hsb.setRGB(PaletteColor::black()); + QVERIFY(hsb.hue == hue && hsb.saturation == 0.5 && hsb.brightness == 0); +} + +void ColorPickerTests::hueTurnsTheWheelBySixths() +{ + // Each sixth, and hues past the circle either way. + const std::vector<std::pair<double, QString>> hues{{0, "FF0000"}, {30, "FF8000"}, {90, "80FF00"}, {150, "00FF80"}, {210, "0080FF"}, + {270, "8000FF"}, {330, "FF0080"}, {360, "FF0000"}, {-60, "FF00FF"}, {420, "FFFF00"}}; + for (const auto &[hue, hex] : hues) + QCOMPARE(PickerHSB(hue, 1, 1).rgb().quantized().hex(), hex); + QCOMPARE(PickerHSB(0, 0.5, 0.5).rgb(), (PaletteColor{0.5, 0.25, 0.25})); + // Hues from each channel on top, as Swift reads them. + QCOMPARE(PickerHSB(PaletteColor{0, 1, 0.5}).hue, 150.0); + QCOMPARE(PickerHSB(PaletteColor{0.5, 0, 1}).hue, 270.0); + QCOMPARE(PickerHSB(PaletteColor{1, 0, 0.5}).hue, 330.0); +} + +void ColorPickerTests::canvasSamplingReadsCompositeAndCommitsOnlyOnOK() +{ + const std::unique_ptr<EditorSession> session = splitSession(); + QCOMPARE(session->sampleCompositeColor(QPointF(1.5, 0.5)).value().hex(), QString("FF0000")); + QCOMPARE(session->sampleCompositeColor(QPointF(1.5, 3.5)).value().hex(), QString("0000FF")); + QVERIFY(!session->sampleCompositeColor(QPointF(-1, 1))); + session->openColorPicker(false); + session->sampleIntoColorPicker(QPointF(2, 3)); + QCOMPARE(session->colorPicker().value().color().hex(), QString("0000FF")); + QCOMPARE(session->foregroundColor(), PaletteColor::black()); + session->closeColorPicker(false); + QVERIFY(session->foregroundColor() == PaletteColor::black() && !session->colorPicker()); + session->openColorPicker(true); + session->sampleIntoColorPicker(QPointF(2, 0)); + session->closeColorPicker(true); + QVERIFY(session->backgroundColor().hex() == QString("FF0000") && session->foregroundColor() == PaletteColor::black()); +} + +void ColorPickerTests::samplingUndoesPremultiplication() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(4, 4); + // Half-clear orange, stored premultiplied, reads as orange. + QImage image(4, 4, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor(255, 128, 0, 128)); + session->insert(ImportedImage(image, image, QStringLiteral("Orange"))); + QCOMPARE(session->sampleCompositeColor(QPointF(0, 0)).value(), (PaletteColor{1, 128.0 / 255, 0})); + // Past the far edges is outside, even over a layer. + QImage wide(6, 6, QImage::Format_RGBA8888_Premultiplied); + wide.fill(Qt::blue); + session->insert(ImportedImage(wide, wide, QStringLiteral("Wide"))); + for (const QPointF point : {QPointF(4, 1), QPointF(1, 4), QPointF(std::nan(""), 1), QPointF(1, std::nan(""))}) + QVERIFY(!session->sampleCompositeColor(point)); + QCOMPARE(session->sampleCompositeColor(QPointF(3.9, 3.9)).value().hex(), QString("0000FF")); + // A clear pixel has no colour; neither does no document. + session->createDocument(4, 4); + QVERIFY(!session->sampleCompositeColor(QPointF(1, 1))); + session->openColorPicker(false); + const QUuid id = session->colorPicker().value().id; + QSignalSpy changed(session.get(), &EditorSession::changed); + session->sampleIntoColorPicker(QPointF(1, 1)); + QCOMPARE(changed.count(), 0); + QCOMPARE(session->colorPicker().value().id, id); + QVERIFY(!EditorSession().sampleCompositeColor(QPointF(0, 0))); +} + +void ColorPickerTests::pickersOpenOnlyWhereTheyMay() +{ + const std::unique_ptr<EditorSession> session = splitSession(); + QSignalSpy changed(session.get(), &EditorSession::changed); + // Closing nothing is silent; so is an unchanged colour. + session->closeColorPicker(true); + QCOMPARE(changed.count(), 0); + session->openColorPicker(true); + QCOMPARE(changed.count(), 1); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Background Color)")); + QCOMPARE(session->colorPicker().value().original, PaletteColor::white()); + session->setColorPickerHSB(session->colorPicker().value().hsb); + QCOMPARE(changed.count(), 1); + session->setColorPickerHSB(PickerHSB(120, 1, 1)); + QCOMPARE(changed.count(), 2); + // Another swatch opens anew, its colour its own. + const QUuid first = session->colorPicker().value().id; + session->openColorPicker(false); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Foreground Color)")); + QVERIFY(session->colorPicker().value().id != first); + QCOMPARE(session->colorPicker().value().color(), PaletteColor::black()); + // Cancel keeps the palette; OK writes the working colour. + session->setColorPickerHSB(PickerHSB(240, 1, 1)); + session->closeColorPicker(false); + QCOMPARE(session->foregroundColor(), PaletteColor::black()); + session->openColorPicker(false); + session->setColorPickerHSB(PickerHSB(240, 1, 1)); + session->closeColorPicker(true); + QCOMPARE(session->foregroundColor(), (PaletteColor{0, 0, 1})); + // A mask's palette is black and white: no picker opens. + const QUuid id = session->activeLayerID().value(); + session->addMask(); + session->selectLayerTarget(id, true); + QVERIFY(session->isMaskSelected()); + changed.clear(); + session->openColorPicker(false); + QVERIFY(!session->colorPicker()); + QCOMPARE(changed.count(), 0); + // A mask chosen meanwhile keeps the palette on OK. + session->selectLayerTarget(id, false); + session->openColorPicker(true); + session->setColorPickerHSB(PickerHSB(0, 1, 1)); + session->selectLayerTarget(id, true); + session->closeColorPicker(true); + QVERIFY(!session->colorPicker()); + QCOMPARE(session->backgroundColor(), PaletteColor::white()); + QVERIFY(!session->maskPaintWhite()); + // The background announces itself. + changed.clear(); + session->selectLayerTarget(id, false); + changed.clear(); + session->setBackgroundColor(PaletteColor{0, 0, 1}); + QCOMPARE(changed.count(), 1); +} + +void ColorPickerTests::textPickersColourTheTextAndTheSwatch() +{ + const std::unique_ptr<EditorSession> session = splitSession(); + const PaletteColor cool = PickerHSB(PaletteColor{0.2, 0.4, 0.6}).rgb().quantized(); + // Every channel apart from cool's, so each write shows. + const PaletteColor warm = PickerHSB(PaletteColor{0.7, 0.5, 0.1}).rgb().quantized(); + // Type's swatch opens only under Type, and only once. + session->openTextColorPicker(); + QVERIFY(!session->colorPicker()); + session->selectTool(NavigationTool::type); + QSignalSpy changed(session.get(), &EditorSession::changed); + session->openTextColorPicker(); + QCOMPARE(changed.count(), 1); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Text Color)")); + const QUuid id = session->colorPicker().value().id; + session->openTextColorPicker(); + QCOMPARE(session->colorPicker().value().id, id); + // Cancelled, it leaves the next text and the swatch. + session->setColorPickerHSB(PickerHSB(cool)); + session->closeColorPicker(false); + QCOMPARE(session->textDefaults().blue, 0.0); + QCOMPARE(session->foregroundColor(), PaletteColor::black()); + // No text open: the next text's colour, and the foreground. + session->openTextColorPicker(); + session->setColorPickerHSB(PickerHSB(cool)); + session->closeColorPicker(true); + const LayerTextStyle defaults = session->textDefaults(); + QCOMPARE((PaletteColor{defaults.red, defaults.green, defaults.blue}), cool); + QCOMPARE(session->foregroundColor(), cool); + // Open text takes it, the swatch showing the text's colour. + session->beginText(QPointF(1, 1), true); + QCOMPARE(session->typeColor(), cool); + session->openTextColorPicker(); + QCOMPARE(session->colorPicker().value().original, cool); + session->setColorPickerHSB(PickerHSB(warm)); + session->closeColorPicker(true); + QCOMPARE(session->typeColor(), warm); + QCOMPARE(session->foregroundColor(), warm); + // Another draft by then, or another tool, takes nothing. + session->openTextColorPicker(); + session->setColorPickerHSB(PickerHSB(120, 1, 1)); + session->cancelText(); + session->closeColorPicker(true); + QCOMPARE(session->textDefaults().blue, cool.blue); + QCOMPARE(session->foregroundColor(), warm); + session->openTextColorPicker(); + session->setColorPickerHSB(PickerHSB(120, 1, 1)); + session->selectTool(NavigationTool::brush); + session->closeColorPicker(true); + QCOMPARE(session->foregroundColor(), warm); + // Beside live text, no draft: the defaults; nothing opens. + session->selectTool(NavigationTool::type); + session->beginText(QPointF(1, 1), true); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Hi"); + session->setTextDraft(draft); + QVERIFY(session->finishText()); + QVERIFY(session->activeLayer().value().liveText()); + // With its mask chosen, the swatch keeps its colour. + session->addMask(); + QVERIFY(session->isMaskSelected()); + session->openTextColorPicker(); + session->setColorPickerHSB(PickerHSB(240, 1, 1)); + session->closeColorPicker(true); + QVERIFY(!session->textDraft()); + QCOMPARE(session->textDefaults().blue, 1.0); + QCOMPARE(session->foregroundColor(), warm); + // Busy, nothing opens. + session->setIsProjectBusy(true); + session->openTextColorPicker(); + session->openColorPicker(false); + QVERIFY(!session->colorPicker()); +} + +void ColorPickerTests::pickerReopensWhereItWasLastLeft() +{ + const std::unique_ptr<EditorSession> session = splitSession(); + QWidget window; + window.resize(400, 300); + window.show(); + ColorPickerPanelController controller(window); + session->openColorPicker(false); + controller.show(session->colorPicker().value(), *session); + QWidget *panel = shownPanel(); + QVERIFY(panel); + const QPoint spot(40, 40); + panel->move(spot); + session->closeColorPicker(false); + controller.close(); + QVERIFY(!panel->isVisible()); + session->openColorPicker(true); + controller.show(session->colorPicker().value(), *session); + QCOMPARE(shownPanel(), panel); + QCOMPARE(panel->pos(), spot); + QCOMPARE(panel->windowTitle(), QString("Color Picker (Background Color)")); + // Switching swatches while open keeps it in place too. + session->openColorPicker(false); + controller.show(session->colorPicker().value(), *session); + QCOMPARE(panel->pos(), spot); + session->closeColorPicker(false); + controller.close(); +} + +QTEST_MAIN(ColorPickerTests) +#include "ColorPickerTests.moc" diff --git a/tests/ColorSwatchTests.cpp b/tests/ColorSwatchTests.cpp new file mode 100644 index 0000000..0ebc7ec --- /dev/null +++ b/tests/ColorSwatchTests.cpp @@ -0,0 +1,161 @@ +#include "SelectionCanvasFixtures.h" +#include "UI/BrushControls.h" +#include "UI/ColorPaletteControls.h" +#include "UI/NativeLayerList.h" +#include "UI/ShapeControls.h" +#include "UI/TypeControls.h" +#include <QLabel> + +// The bars' swatches; X and D on canvas and list. +namespace { +QAbstractButton &swatch(QWidget &bar, const char *name) +{ + auto *found = bar.findChild<QAbstractButton *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} + +QColor middle(QWidget &widget) +{ + return widget.grab().toImage().pixelColor(widget.width() / 2, widget.height() / 2); +} + +std::unique_ptr<EditorSession> sessionWithLayer(NavigationTool tool) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 300, true); + session->selectTool(tool); + return session; +} +} + +class ColorSwatchTests : public QObject { + Q_OBJECT +private slots: + void theBrushBarPicksTheForeground(); + void theShapeBarFillsWithTheForeground(); + void theTypeBarShowsAndPicksTheTextColour(); + void xAndDSwapAndRestoreThePalette(); +}; + +void ColorSwatchTests::theBrushBarPicksTheForeground() +{ + const std::unique_ptr<EditorSession> session = sessionWithLayer(NavigationTool::brush); + BrushControls bar(*session); + bar.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar)); + QAbstractButton &colour = swatch(bar, "brushColor"); + QVERIFY(colour.isVisibleTo(&bar)); + QCOMPARE(colour.toolTip(), QString("Foreground color")); + QCOMPARE(colour.size(), QSize(34, 18)); + QCOMPARE(middle(colour), QColor(Qt::black)); + // A white ring inside the black rim, as Swift's. + QVERIFY(colour.grab().toImage().pixelColor(17, 1).lightness() > 200); + QCOMPARE(colour.grab().toImage().pixelColor(17, 0).lightness(), 0); + QCoreApplication::processEvents(); + PaintSpy repaint(colour); + session->setForegroundColor(PaletteColor{1, 0, 0}); + QTRY_VERIFY(repaint.count > 0); + QCOMPARE(middle(colour), QColor(Qt::red)); + QTest::mouseClick(&colour, Qt::LeftButton); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Foreground Color)")); + session->closeColorPicker(false); + // Clone Stamp, the Smear and masks paint no own colour. + QLabel *label = nullptr; + for (QLabel *each : bar.findChildren<QLabel *>()) { + if (each->text() == QString("Color")) + label = each; + } + QVERIFY(label); + for (const auto &[tool, shows] : {std::pair(NavigationTool::cloneStamp, false), std::pair(NavigationTool::blur, false), std::pair(NavigationTool::spotHealing, true)}) { + session->selectTool(tool); + QCOMPARE(colour.isVisibleTo(&bar), shows); + QCOMPARE(label->isVisibleTo(&bar), shows); + } + session->selectTool(NavigationTool::brush); + session->addMask(); + QVERIFY(session->isMaskSelected()); + QVERIFY(!colour.isVisibleTo(&bar)); + session->selectLayerTarget(session->activeLayerID().value(), false); + // A stroke under way rests it. + session->beginBrush(QPointF(10, 10)); + QVERIFY(session->brushStroke()); + QVERIFY(!colour.isEnabled()); + session->cancelBrush(); + QVERIFY(colour.isEnabled()); +} + +void ColorSwatchTests::theShapeBarFillsWithTheForeground() +{ + const std::unique_ptr<EditorSession> session = sessionWithLayer(NavigationTool::shape); + ShapeControls bar(*session); + bar.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar)); + QAbstractButton &fill = swatch(bar, "shapeFill"); + QCOMPARE(fill.toolTip(), QString("Shapes fill with the foreground color; click to change it")); + QCOMPARE(fill.size(), QSize(36, 18)); + QCoreApplication::processEvents(); + PaintSpy repaint(fill); + session->setForegroundColor(PaletteColor{0, 1, 0}); + QTRY_VERIFY(repaint.count > 0); + QCOMPARE(middle(fill), QColor(Qt::green)); + // Its rim is half black, no white ring. + const QColor rim = fill.grab().toImage().pixelColor(0, 9); + QVERIFY2(rim.red() == 0 && std::abs(rim.green() - 128) <= 1, qPrintable(rim.name())); + QCOMPARE(fill.grab().toImage().pixelColor(18, 1), QColor(Qt::green)); + QTest::mouseClick(&fill, Qt::LeftButton); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Foreground Color)")); +} + +void ColorSwatchTests::theTypeBarShowsAndPicksTheTextColour() +{ + const std::unique_ptr<EditorSession> session = sessionWithLayer(NavigationTool::type); + TypeControls bar(*session); + // Wide enough that the swatch shows in its scroll area. + bar.resize(1400, bar.sizeHint().height()); + bar.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar)); + QAbstractButton &colour = swatch(bar, "typeColor"); + QCOMPARE(colour.toolTip(), QString("Text color")); + // No text open: the foreground, the next text's colour. + session->setForegroundColor(PaletteColor{1, 0, 0}); + QCOMPARE(middle(colour), QColor(Qt::red)); + session->beginText(QPointF(10, 10), true); + QCoreApplication::processEvents(); + PaintSpy repaint(colour); + session->changeTextStyle([](LayerTextStyle &style) { style.blue = 1; style.red = 0; }); + QTRY_VERIFY(repaint.count > 0); + QCOMPARE(middle(colour), QColor(Qt::blue)); + QTest::mouseClick(&colour, Qt::LeftButton); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Text Color)")); + QCOMPARE(session->colorPicker().value().original, (PaletteColor{0, 0, 1})); +} + +void ColorSwatchTests::xAndDSwapAndRestoreThePalette() +{ + Canvas shown; + NativeLayerList list(shown.session, &shown.window); + list.setGeometry(0, 0, 100, 100); + list.show(); + for (QWidget *target : {static_cast<QWidget *>(shown.canvas), static_cast<QWidget *>(&list)}) { + shown.session.setForegroundColor(PaletteColor{1, 0, 0}); + QTest::keyClick(target, Qt::Key_X); + QCOMPARE(shown.session.foregroundColor(), PaletteColor::white()); + QCOMPARE(shown.session.backgroundColor(), (PaletteColor{1, 0, 0})); + // Shift adds nothing; Ctrl and Alt make other keys. + QTest::keyClick(target, Qt::Key_X, Qt::ShiftModifier); + QCOMPARE(shown.session.foregroundColor(), (PaletteColor{1, 0, 0})); + for (const Qt::KeyboardModifier modifier : {Qt::ControlModifier, Qt::AltModifier, Qt::MetaModifier}) { + QTest::keyClick(target, Qt::Key_X, modifier); + QTest::keyClick(target, Qt::Key_D, modifier); + QCOMPARE(shown.session.foregroundColor(), (PaletteColor{1, 0, 0})); + } + QTest::keyClick(target, Qt::Key_D); + QCOMPARE(shown.session.foregroundColor(), PaletteColor::black()); + QCOMPARE(shown.session.backgroundColor(), PaletteColor::white()); + } +} + +QTEST_MAIN(ColorSwatchTests) +#include "ColorSwatchTests.moc" diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp new file mode 100644 index 0000000..877b536 --- /dev/null +++ b/tests/CompositorMenusTests.cpp @@ -0,0 +1,499 @@ +#include "DialogDesk.h" +#include "IO/ProjectStore.h" +#include "MenuFixtures.h" +#include <QDialog> +#include <QLineEdit> +#include <QMenu> +#include <QTemporaryDir> + +// The menu bar: each entry's shortcut, enabling and effect. + +class CompositorMenusTests : public QObject { + Q_OBJECT +private slots: + void everyEntryHasSwiftsShortcutWithCtrlForCommand(); + void fileEntriesFollowTheControllersGate(); + void undoAndRedoCarryTheirNamesAndKeepAFieldsOwn(); + void theMenuBarBorrowsFocusAndTheFieldKeepsUndo(); + void viewEntriesZoomTheFrontSession(); + void thePixelGridEntryTogglesTheSession(); + void layerEntriesFollowTheActiveLayer(); + void duplicateMergeAndFlipEntriesActOnTheLayers(); + void transformEntriesFollowTheMoveTool(); + void deleteMeansTheMaskTheLayerOrTheLayers(); + void theMenusFollowTheFrontTab(); + void imageEntriesSizeAndFlipTheCanvas(); +}; + +void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() +{ + Bar bar; + const std::pair<const char *, const char *> keys[] = { + {"newCanvas", "Ctrl+N"}, {"openProject", "Ctrl+O"}, {"importImages", ""}, {"save", "Ctrl+S"}, {"saveAs", "Ctrl+Shift+S"}, + {"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"}, + {"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"}, + {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, + {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"}, + {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, + {"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""}, + {"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"maskBlackAreas", ""}, + {"expandSelection", ""}, {"contractSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"}, + {"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""}, + {"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""}, + {"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""}, + {"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""}, + }; + for (const auto &[name, key] : keys) + QCOMPARE(bar.action(name).shortcut().toString(), QString::fromLatin1(key)); + // Seven menus in Swift's order, each with its entries. + const QList<QMenu *> menus = bar.window.menuBar()->findChildren<QMenu *>(QString(), Qt::FindDirectChildrenOnly); + QStringList titles; + for (const QMenu *menu : menus) + titles << menu->title(); + QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"})); + QCOMPARE(menus[0]->actions().size(), 11); + QCOMPARE(menus[1]->actions().size(), 12); + QCOMPARE(menus[2]->actions().size(), 6); + QCOMPARE(menus[3]->actions().size(), 8); + QCOMPARE(menus[4]->actions().size(), 13); + QCOMPARE(menus[5]->actions().size(), 5); + QCOMPARE(menus[6]->actions().size(), 22); +} + +void CompositorMenusTests::fileEntriesFollowTheControllersGate() +{ + QTemporaryDir folder; + Bar bar; + QVERIFY(bar.action("newCanvas").isEnabled() && bar.action("openProject").isEnabled() && bar.action("closeProject").isEnabled()); + QVERIFY(bar.action("importImages").isEnabled()); + // Nothing to save without a canvas. + QVERIFY(!bar.action("save").isEnabled() && !bar.action("saveAs").isEnabled()); + bar.session().createDocument(8, 8); + QVERIFY(bar.action("save").isEnabled() && bar.action("saveAs").isEnabled()); + // Busy holds the file entries; a sheet holds Import. + bar.session().setIsProjectBusy(true); + QVERIFY(!bar.action("newCanvas").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("save").isEnabled() + && !bar.action("saveAs").isEnabled() && !bar.action("closeProject").isEnabled()); + QVERIFY(bar.action("importImages").isEnabled()); + QTRY_VERIFY(!bar.action("importImages").isEnabled()); + bar.session().setIsProjectBusy(false); + bar.session().setShowsNewDocument(true); + QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("newCanvas").isEnabled()); + bar.session().setShowsNewDocument(false); + // Importing alone holds Import as well. + bar.session().setIsImporting(true); + QVERIFY(!bar.action("importImages").isEnabled()); + bar.session().setIsImporting(false); + QVERIFY(bar.action("importImages").isEnabled()); + // The entries do what the toolbar and the controller do. + bar.action("newCanvas").trigger(); + QCOMPARE(int(bar.workspace.tabs().size()), 2); + bar.workspace.select(bar.workspace.tabs()[0]->id); + bar.action("importImages").trigger(); + QVERIFY(bar.session().showsImporter()); + bar.session().setShowsImporter(false); + DialogDesk desk; + desk.replies = {folder.filePath("Saved.comp")}; + bar.action("save").trigger(); + QTRY_VERIFY(!bar.session().isModified()); + QCOMPARE(bar.session().projectPath(), std::optional(folder.filePath("Saved.comp"))); + // Saved once, Save asks no more; Save As always does. + bar.session().addBlankLayer(); + bar.action("save").trigger(); + QTRY_VERIFY(!bar.session().isModified()); + QCOMPARE(desk.seen.size(), 1); + desk.replies = {folder.filePath("Copy.comp")}; + bar.action("saveAs").trigger(); + QTRY_COMPARE(bar.session().projectPath(), std::optional(folder.filePath("Copy.comp"))); + QCOMPARE(desk.seen.size(), 2); + desk.replies = {"<cancel>"}; + bar.action("openProject").trigger(); + QTRY_COMPARE(desk.seen.size(), 3); + // Close Project closes this tab, not the window. + bar.window.show(); + bar.action("closeProject").trigger(); + QTRY_COMPARE(int(bar.workspace.tabs().size()), 1); + QVERIFY(bar.window.isVisible()); +} + +void CompositorMenusTests::undoAndRedoCarryTheirNamesAndKeepAFieldsOwn() +{ + Bar bar; + bar.window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); + QAction &undo = bar.action("undo"), &redo = bar.action("redo"); + // The welcome's field holds focus: the entries are its own. + QLineEdit &first = *bar.window.findChild<QLineEdit *>("widthInput"); + QTRY_VERIFY(first.hasFocus()); + QCOMPARE(undo.text(), QString("Undo")); + QVERIFY(undo.isEnabled() && redo.isEnabled()); + first.clearFocus(); + QTRY_VERIFY(!undo.isEnabled() && !redo.isEnabled()); + bar.session().createDocument(8, 8); + bar.session().addBlankLayer(); + QCOMPARE(undo.text(), QString("Undo New Blank Layer")); + QVERIFY(undo.isEnabled() && !redo.isEnabled()); + undo.trigger(); + QCOMPARE(redo.text(), QString("Redo New Blank Layer")); + QCOMPARE(undo.text(), QString("Undo New Canvas")); + redo.trigger(); + QCOMPARE(int(bar.session().document().value().layers.size()), 1); + // Busy, or a rename: the names go, the entries close. + bar.session().setIsProjectBusy(true); + QCOMPARE(undo.text(), QString("Undo")); + QVERIFY(!undo.isEnabled()); + bar.session().setIsProjectBusy(false); + // A rename types in the row: the entries are its. + bar.session().setRenamingLayerID(bar.session().activeLayerID()); + QLineEdit &editor = *bar.window.findChild<QLineEdit *>("layerNameEditor"); + QTRY_VERIFY(editor.hasFocus()); + QVERIFY(undo.isEnabled() && undo.text() == "Undo"); + QTest::keyClick(&editor, Qt::Key_Escape); + QVERIFY(!bar.session().renamingLayerID().has_value()); + QTRY_COMPARE(undo.text(), QString("Undo New Blank Layer")); + // In a field the entries are the field's, by focus. + bar.session().addBlankLayer(); + bar.session().undo(); + bar.session().selectTool(NavigationTool::zoom); + QLineEdit &zoom = *bar.window.findChild<QLineEdit *>("zoomPercentage"); + QCOMPARE(undo.text(), QString("Undo New Blank Layer")); + zoom.setFocus(); + QTRY_VERIFY(zoom.hasFocus()); + QVERIFY(undo.isEnabled() && redo.isEnabled() && bar.session().canUndo() && bar.session().canRedo()); + QCOMPARE(undo.text(), QString("Undo")); + QCOMPARE(redo.text(), QString("Redo")); + zoom.clearFocus(); + QTRY_COMPARE(undo.text(), QString("Undo New Blank Layer")); + QCOMPARE(redo.text(), QString("Redo New Blank Layer")); + bar.workspace.current().session.clearProject(); + // A fresh welcome takes focus: the entries are its field's. + QLineEdit &width = *bar.window.findChild<QLineEdit *>("widthInput"); + QTRY_VERIFY(width.hasFocus()); + QVERIFY(undo.isEnabled() && !bar.session().canUndo()); + width.selectAll(); + QTest::keyClicks(&width, "640"); + undo.trigger(); + QCOMPARE(width.text(), QString("1920")); + redo.trigger(); + QCOMPARE(width.text(), QString("640")); + QVERIFY(!bar.session().document().has_value()); +} + +void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo() +{ + Bar bar; + bar.window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); + QAction &undo = bar.action("undo"); + QWidget *const menuBar = bar.window.menuBar(); + QLineEdit &width = *bar.window.findChild<QLineEdit *>("widthInput"); + QTRY_VERIFY(width.hasFocus()); + QTest::keyClicks(&width, "9"); + QCOMPARE(width.text(), QString("19209")); + // Alt+E opens Edit from the keyboard: the bar takes focus. + QTest::keyClick(&bar.window, Qt::Key_E, Qt::AltModifier); + QTRY_COMPARE(QApplication::focusWidget(), menuBar); + QVERIFY(QApplication::activePopupWidget()); + QVERIFY(undo.isEnabled()); + QCOMPARE(undo.text(), QString("Undo")); + undo.trigger(); + QCOMPARE(width.text(), QString("1920")); + QAction &redo = bar.action("redo"); + QVERIFY(redo.isEnabled()); + redo.trigger(); + QCOMPARE(width.text(), QString("19209")); + // Escape closes the menu, then the bar gives focus back. + QTest::keyClick(QApplication::activePopupWidget(), Qt::Key_Escape); + QTest::keyClick(menuBar, Qt::Key_Escape); + QTRY_VERIFY(width.hasFocus()); + QVERIFY(undo.isEnabled() && undo.text() == "Undo"); + // Over a canvas too, the bar keeps the field's Undo. + bar.session().createDocument(8, 8); + bar.session().addBlankLayer(); + bar.session().selectTool(NavigationTool::zoom); + QLineEdit &zoom = *bar.window.findChild<QLineEdit *>("zoomPercentage"); + zoom.setFocus(); + QTRY_VERIFY(zoom.hasFocus()); + QTest::keyClick(&bar.window, Qt::Key_E, Qt::AltModifier); + QTRY_COMPARE(QApplication::focusWidget(), menuBar); + QVERIFY(undo.isEnabled()); + QCOMPARE(undo.text(), QString("Undo")); + QTest::keyClick(QApplication::activePopupWidget(), Qt::Key_Escape); + QTest::keyClick(menuBar, Qt::Key_Escape); + QTRY_VERIFY(zoom.hasFocus()); + QCOMPARE(undo.text(), QString("Undo")); + zoom.clearFocus(); + QTRY_COMPARE(undo.text(), QString("Undo New Blank Layer")); +} + +void CompositorMenusTests::viewEntriesZoomTheFrontSession() +{ + Bar bar; + for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"}) + QVERIFY(!bar.action(name).isEnabled()); + bar.session().createDocument(400, 300); + for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"}) + QVERIFY(bar.action(name).isEnabled()); + bar.session().zoom(0.5); + bar.action("actualPixels").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 1.0); + bar.action("zoomIn").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 1.25); + bar.action("zoomOut").trigger(); + bar.action("zoomOut").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 0.8); + bar.action("fit").trigger(); + QVERIFY(bar.session().viewport.followsFit()); +} + +void CompositorMenusTests::thePixelGridEntryTogglesTheSession() +{ + Bar bar; + QAction &grid = bar.action("pixelGrid"); + QVERIFY(grid.isCheckable() && grid.isChecked() && grid.isEnabled()); + QCOMPARE(grid.text(), QString("Pixel Grid (800% and above)")); + grid.trigger(); + QVERIFY(!bar.session().showsPixelGrid() && !grid.isChecked()); + grid.trigger(); + QVERIFY(bar.session().showsPixelGrid() && grid.isChecked()); + // The session's own change reaches the mark. + bar.session().setShowsPixelGrid(false); + QVERIFY(!grid.isChecked()); + bar.workspace.newCanvas(); + QVERIFY(grid.isChecked() && grid.isEnabled()); +} + +void CompositorMenusTests::layerEntriesFollowTheActiveLayer() +{ + Bar bar; + for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + QVERIFY2(!bar.action(name).isEnabled(), name); + bar.session().createDocument(8, 8); + QVERIFY(bar.action("groupLayers").isEnabled() && bar.action("newBlankLayer").isEnabled()); + // No active layer: nothing to rename, hide, move or delete. + QVERIFY(!bar.action("renameLayer").isEnabled() && !bar.action("layerVisibility").isEnabled() && !bar.action("deleteLayer").isEnabled()); + bar.action("newBlankLayer").trigger(); + bar.action("newBlankLayer").trigger(); + const QUuid lower = bar.session().document().value().layers[0].id, upper = bar.session().activeLayerID().value(); + QVERIFY(bar.action("renameLayer").isEnabled() && bar.action("layerVisibility").isEnabled() && bar.action("deleteLayer").isEnabled()); + QVERIFY(!bar.action("moveLayerUp").isEnabled() && bar.action("moveLayerDown").isEnabled()); + QCOMPARE(bar.action("layerVisibility").text(), QString("Hide Layer")); + bar.action("layerVisibility").trigger(); + QCOMPARE(bar.action("layerVisibility").text(), QString("Show Layer")); + QVERIFY(!bar.session().activeLayer().value().isVisible); + bar.action("layerVisibility").trigger(); + bar.action("renameLayer").trigger(); + QCOMPARE(bar.session().renamingLayerID(), std::optional(upper)); + bar.session().setRenamingLayerID(std::nullopt); + // Clipping: the entry names what it will do. + QCOMPARE(bar.action("clippingMask").text(), QString("Create Clipping Mask")); + QVERIFY(bar.action("clippingMask").isEnabled()); + bar.action("clippingMask").trigger(); + QCOMPARE(bar.session().activeLayer().value().maskSourceID, std::optional(lower)); + QCOMPARE(bar.action("clippingMask").text(), QString("Release Clipping Mask")); + bar.action("clippingMask").trigger(); + QCOMPARE(bar.session().activeLayer().value().maskSourceID, std::nullopt); + bar.session().selectLayer(lower); + QVERIFY(!bar.action("clippingMask").isEnabled()); + bar.action("moveLayerUp").trigger(); + QCOMPARE(bar.session().document().value().layers[1].id, lower); + bar.action("moveLayerDown").trigger(); + QCOMPARE(bar.session().document().value().layers[0].id, lower); + // Grouping, then out of the folder. + bar.session().selectLayers({lower, upper}, upper); + bar.action("groupLayers").trigger(); + QCOMPARE(int(bar.session().document().value().layers.size()), 3); + bar.session().selectLayer(upper); + QVERIFY(bar.action("moveOutOfFolder").isEnabled()); + bar.action("moveOutOfFolder").trigger(); + QCOMPARE(bar.session().activeLayer().value().parentID, std::nullopt); + QVERIFY(!bar.action("moveOutOfFolder").isEnabled()); + // A busy project closes every layer entry. + bar.session().setIsProjectBusy(true); + for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + QVERIFY2(!bar.action(name).isEnabled(), name); +} + +void CompositorMenusTests::duplicateMergeAndFlipEntriesActOnTheLayers() +{ + Bar bar; + for (const char *name : {"layerViaCopy", "mergeLayers", "flipHorizontal", "flipVertical"}) + QVERIFY2(!bar.action(name).isEnabled(), name); + QCOMPARE(bar.action("mergeLayers").text(), QString("Merge Down")); + bar.session().createDocument(8, 8); + bar.action("newBlankLayer").trigger(); + // A blank layer copies; no pixels flip or merge. + QVERIFY(bar.action("layerViaCopy").isEnabled()); + QVERIFY(!bar.action("mergeLayers").isEnabled() && !bar.action("flipHorizontal").isEnabled() && !bar.action("flipVertical").isEnabled()); + bar.session().insert(white()); + const QUuid lower = bar.session().activeLayerID().value(); + QVERIFY(bar.action("flipHorizontal").isEnabled() && bar.action("flipVertical").isEnabled()); + bar.action("flipHorizontal").trigger(); + QVERIFY(bar.session().activeLayer().value().transform.flipX); + bar.action("flipVertical").trigger(); + QVERIFY(bar.session().activeLayer().value().transform.flipY); + QCOMPARE(bar.session().history.undoName(), QString("Flip Vertical")); + bar.action("layerViaCopy").trigger(); + QCOMPARE(int(bar.session().document().value().layers.size()), 3); + QCOMPARE(bar.session().activeLayer().value().name, QString("White copy")); + // The merge entry names its plan and bakes it. + QCOMPARE(bar.action("mergeLayers").text(), QString("Merge Down")); + QVERIFY(bar.action("mergeLayers").isEnabled()); + bar.session().selectLayers({lower, bar.session().activeLayerID().value()}, lower); + QCOMPARE(bar.action("mergeLayers").text(), QString("Merge Layers")); + bar.action("mergeLayers").trigger(); + QCOMPARE(int(bar.session().document().value().layers.size()), 2); + QCOMPARE(bar.session().history.undoName(), QString("Merge Layers")); + const QUuid merged = bar.session().activeLayerID().value(); + bar.session().addGroup(); + QVERIFY(!bar.action("layerViaCopy").isEnabled()); + bar.session().selectLayer(merged); + QVERIFY(bar.action("layerViaCopy").isEnabled()); + bar.session().setIsProjectBusy(true); + for (const char *name : {"layerViaCopy", "mergeLayers", "flipHorizontal", "flipVertical"}) + QVERIFY2(!bar.action(name).isEnabled(), name); +} + +void CompositorMenusTests::transformEntriesFollowTheMoveTool() +{ + Bar bar; + QAction &transform = bar.action("transformLayer"); + QAction &controls = bar.action("transformControls"); + QVERIFY(!transform.isEnabled() && !controls.isEnabled() && controls.isCheckable() && controls.isChecked()); + bar.session().createDocument(8, 8); + QVERIFY(!transform.isEnabled() && controls.isEnabled()); + bar.session().insert(white()); + QVERIFY(transform.isEnabled()); + // Ctrl+T begins the edit that waits for Apply. + transform.trigger(); + QVERIFY(bar.session().transformEdit().value().persistent); + QVERIFY(!transform.isEnabled()); + bar.session().cancelTransform(); + // Ctrl+H toggles the box, in the Move tool alone. + controls.trigger(); + QVERIFY(!bar.session().showsTransformControls() && !controls.isChecked()); + bar.session().setShowsTransformControls(true); + QVERIFY(controls.isChecked()); + bar.session().selectTool(NavigationTool::hand); + QVERIFY(!controls.isEnabled()); + bar.session().selectTool(NavigationTool::move); + bar.session().setIsProjectBusy(true); + QVERIFY(!transform.isEnabled()); +} + +void CompositorMenusTests::deleteMeansTheMaskTheLayerOrTheLayers() +{ + Bar bar; + bar.session().createDocument(8, 8); + bar.session().insert(white()); + const QUuid first = bar.session().activeLayerID().value(); + bar.session().insert(white()); + const QUuid second = bar.session().activeLayerID().value(); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Layer")); + bar.session().addLayerMask(); + bar.session().selectLayerTarget(second, true); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Layer Mask")); + bar.action("deleteLayer").trigger(); + QVERIFY(!bar.session().activeLayer().value().mask.has_value()); + QCOMPARE(int(bar.session().document().value().layers.size()), 2); + // Swift's title says mask; its action deletes the layers. + bar.session().addLayerMask(); + bar.session().selectLayers({first, second}, second); + bar.session().selectLayerTarget(second, true); + bar.session().selectLayers({first, second}, second); + QVERIFY(bar.session().isMaskSelected() && bar.session().selectedLayerIDs().size() == 2); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Layer Mask")); + bar.action("deleteLayer").trigger(); + QVERIFY(bar.session().document().value().layers.empty()); + QCOMPARE(bar.session().history.undoName(), QString("Delete Layers")); + // Several selected, no mask chosen: the plural title. + bar.session().insert(white()); + const QUuid third = bar.session().activeLayerID().value(); + bar.session().insert(white()); + bar.session().selectLayers({third, bar.session().activeLayerID().value()}, third); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Layers")); + bar.action("deleteLayer").trigger(); + QVERIFY(bar.session().document().value().layers.empty()); + // One layer, its mask not chosen: the layer goes. + bar.session().insert(white()); + bar.session().addLayerMask(); + bar.session().selectLayerTarget(bar.session().activeLayerID().value(), false); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Layer")); + bar.action("deleteLayer").trigger(); + QVERIFY(bar.session().document().value().layers.empty()); +} + +void CompositorMenusTests::theMenusFollowTheFrontTab() +{ + Bar bar; + bar.session().createDocument(8, 8); + bar.session().addBlankLayer(); + QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer")); + bar.workspace.newCanvas(); + QCOMPARE(bar.action("undo").text(), QString("Undo")); + QVERIFY(!bar.action("fit").isEnabled() && !bar.action("save").isEnabled()); + // The old tab's news no longer reaches the menus. + bar.workspace.tabs()[0]->session.addBlankLayer(); + QCOMPARE(bar.action("undo").text(), QString("Undo")); + bar.workspace.select(bar.workspace.tabs()[0]->id); + QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer")); + QVERIFY(bar.action("fit").isEnabled() && bar.action("save").isEnabled()); + // Back and forth: only the front tab's news counts. + bar.workspace.select(bar.workspace.tabs()[1]->id); + bar.workspace.tabs()[0]->session.addBlankLayer(); + QCOMPARE(bar.action("undo").text(), QString("Undo")); + bar.workspace.tabs()[1]->session.createDocument(4, 4); + QCOMPARE(bar.action("undo").text(), QString("Undo New Canvas")); + bar.workspace.select(bar.workspace.tabs()[0]->id); + bar.workspace.tabs()[1]->session.addBlankLayer(); + QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer")); + bar.workspace.tabs()[0]->session.undo(); + QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer")); + QCOMPARE(bar.action("redo").text(), QString("Redo New Blank Layer")); + // A tab left behind drives the menus no more. + bar.action("undo").setText(QString("stale")); + bar.workspace.tabs()[1]->session.addBlankLayer(); + QCOMPARE(bar.action("undo").text(), QString("stale")); + bar.workspace.tabs()[0]->session.notify(); + QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer")); +} + +void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas() +{ + Bar bar; + bar.window.show(); + const char *entries[] = {"canvasSize", "imageSize", "flipCanvasHorizontal", "flipCanvasVertical"}; + for (const char *name : entries) + QVERIFY2(!bar.action(name).isEnabled(), name); + bar.session().createDocument(100, 50); + bar.session().insert(white(), QPointF(20.5, 10.5)); + for (const char *name : entries) + QVERIFY2(bar.action(name).isEnabled(), name); + // Busy holds them all; a drawn frame holds the flips. + bar.session().setIsProjectBusy(true); + for (const char *name : entries) + QVERIFY2(!bar.action(name).isEnabled(), name); + bar.session().setIsProjectBusy(false); + bar.session().selectTool(NavigationTool::crop); + QVERIFY(bar.action("canvasSize").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled()); + bar.session().selectTool(NavigationTool::move); + bar.action("flipCanvasHorizontal").trigger(); + QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 10)); + QCOMPARE(bar.session().history.undoName(), QString("Flip Canvas Horizontal")); + bar.action("flipCanvasVertical").trigger(); + QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39)); + // The size entries open their sheets over the window. + for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size")}) { + bar.action(name).trigger(); + auto *sheet = bar.window.findChild<QDialog *>(); + QVERIFY(sheet && sheet->isVisible()); + QCOMPARE(sheet->windowTitle(), QString::fromLatin1(title)); + QVERIFY(!bar.action(name).isEnabled()); + QTest::keyClick(sheet, Qt::Key_Escape); + QTRY_VERIFY(!bar.session().isProjectBusy()); + QTRY_VERIFY(!bar.window.findChild<QDialog *>()); + } +} + +QTEST_MAIN(CompositorMenusTests) +#include "CompositorMenusTests.moc" diff --git a/tests/CompositorTests.cpp b/tests/CompositorTests.cpp new file mode 100644 index 0000000..2993d1b --- /dev/null +++ b/tests/CompositorTests.cpp @@ -0,0 +1,199 @@ +#include "Document/EditorSession.h" +#include "Rendering/CanvasViewport.h" +#include <QtTest> + +class CompositorTests : public QObject { + Q_OBJECT + const QSizeF document{1920, 1080}; +private slots: + void dimensionValidation(); + void actualPixelsAndRoundTrip_data(); + void actualPixelsAndRoundTrip(); + void zoomKeepsCursorPixelFixed(); + void fitAndResizeModes(); + void limitsAndNewDocumentReset(); + void fitWaitsForAViewSize(); + void backingScaleNeverDropsBelowOne(); + void viewportsCompareExactly(); +}; + +void CompositorTests::dimensionValidation() +{ + QCOMPARE(CanvasDocument::validDimension("0"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("-1"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("1.5"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("30001"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("9999999999999999999999"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension(" 1920 "), std::optional<int>(1920)); + QCOMPARE(CanvasDocument::validDimension("30000"), std::optional<int>(30000)); + QCOMPARE(CanvasDocument::validDimension("\t+64\u00a0"), std::optional<int>(64)); + QCOMPARE(CanvasDocument::validDimension("1920\n"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension(QString::fromUtf8("1920\0px", 7)), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("19 20"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension("+"), std::nullopt); + QCOMPARE(CanvasDocument::validDimension(""), std::nullopt); + QCOMPARE(CanvasDocument::validDimension(QString::fromUtf8("\u0661\u0662")), std::nullopt); +} + +void CompositorTests::actualPixelsAndRoundTrip_data() +{ + QTest::addColumn<double>("backing"); + QTest::newRow("1x") << 1.0; + QTest::newRow("2x") << 2.0; +} + +void CompositorTests::actualPixelsAndRoundTrip() +{ + QFETCH(double, backing); + CanvasViewport viewport; + viewport.resize(QSizeF(800, 600), backing, std::nullopt); + for (double zoom : {0.25, 1.0, 3.75}) { + viewport.setZoom(zoom, viewport.center(), document); + viewport.translate(QSizeF(73.5, -44.25)); + const QPointF pixel(183.25, 837.5); + const QPointF viewPoint = viewport.viewPoint(pixel, document); + const QPointF result = viewport.documentPoint(viewPoint, document); + QVERIFY(std::abs(result.x() - pixel.x()) < 0.000001); + QVERIFY(std::abs(result.y() - pixel.y()) < 0.000001); + QVERIFY(std::abs(viewport.documentRect(document).width() * backing - document.width() * zoom) < 0.000001); + } +} + +void CompositorTests::zoomKeepsCursorPixelFixed() +{ + CanvasViewport viewport; + viewport.resize(QSizeF(1000, 700), 2, document); + const QPointF anchor(157, 221); + const QPointF before = viewport.documentPoint(anchor, document); + QVERIFY(viewport.followsFit()); + viewport.setZoom(4, anchor, document); + QCOMPARE(viewport.zoom(), 4.0); + QVERIFY(!viewport.followsFit()); + const QPointF after = viewport.documentPoint(anchor, document); + QVERIFY(std::abs(before.x() - after.x()) < 0.000001); + QVERIFY(std::abs(before.y() - after.y()) < 0.000001); +} + +void CompositorTests::fitAndResizeModes() +{ + CanvasViewport viewport; + viewport.resize(QSizeF(800, 600), 2, document); + const QRectF rect = viewport.documentRect(document); + QVERIFY(rect.width() <= 704.000001); + QVERIFY(rect.height() <= 504.000001); + QCOMPARE(rect.center().x(), 400.0); + QCOMPARE(rect.center().y(), 300.0); + viewport.translate(QSizeF(60, -35)); + QCOMPARE(viewport.pan, QSizeF(60, -35)); + QVERIFY(!viewport.followsFit()); + const QPointF before = viewport.documentPoint(viewport.center(), document); + const double zoom = viewport.zoom(); + viewport.resize(QSizeF(1200, 800), 1, document); + const QPointF after = viewport.documentPoint(viewport.center(), document); + QCOMPARE(viewport.zoom(), zoom); + QVERIFY(std::abs(before.x() - after.x()) < 0.000001); + QVERIFY(std::abs(before.y() - after.y()) < 0.000001); + viewport.fit(document); + QCOMPARE(viewport.pan, QSizeF(0, 0)); + QVERIFY(viewport.followsFit()); +} + +void CompositorTests::limitsAndNewDocumentReset() +{ + EditorSession session; + session.viewport.resize(QSizeF(800, 600), 2, std::nullopt); + session.zoom(5); + QCOMPARE(session.viewport.zoom(), 1.0); + session.createDocument(1920, 1080); + session.zoom(1000); + QCOMPARE(session.viewport.zoom(), CanvasViewport::maximumZoom); + session.zoom(0); + QCOMPARE(session.viewport.zoom(), CanvasViewport::minimumZoom); + session.zoom(std::nan("")); + QCOMPARE(session.viewport.zoom(), CanvasViewport::minimumZoom); + session.viewport.translate(QSizeF(999, 888)); + session.createDocument(400, 300); + QVERIFY(session.viewport.followsFit()); + QCOMPARE(session.viewport.pan, QSizeF(0, 0)); + QCOMPARE(session.document().value().width, 400); + session.createDocument(0, 200); + session.createDocument(200, 0); + session.createDocument(30'001, 200); + session.createDocument(200, 30'001); + QCOMPARE(session.document().value().width, 400); + session.createDocument(30'000, 1); + QCOMPARE(session.document().value().size(), QSizeF(30'000, 1)); + session.createDocument(1, 30'000); + QCOMPARE(session.document().value().size(), QSizeF(1, 30'000)); + // Zooming about a point keeps that document pixel under it. + session.createDocument(1920, 1080); + const QPointF anchor(157, 221); + const QPointF before = session.viewport.documentPoint(anchor, session.document().value().size()); + session.zoom(4, anchor); + const QPointF after = session.viewport.documentPoint(anchor, session.document().value().size()); + QVERIFY(std::abs(before.x() - after.x()) < 0.000001 && std::abs(before.y() - after.y()) < 0.000001); + session.viewport.translate(QSizeF(10, 10)); + session.fit(); + QVERIFY(session.viewport.followsFit()); +} + +void CompositorTests::fitWaitsForAViewSize() +{ + CanvasViewport viewport; + viewport.setZoom(2, QPointF(0, 0), document); + viewport.fit(document); + QVERIFY(viewport.followsFit()); + QCOMPARE(viewport.zoom(), 2.0); + viewport.resize(QSizeF(1056, 636), 1, document); + QCOMPARE(viewport.zoom(), 0.5); + QCOMPARE(viewport.pan, QSizeF(0, 0)); +} + +void CompositorTests::backingScaleNeverDropsBelowOne() +{ + CanvasViewport viewport; + viewport.resize(QSizeF(800, 600), 0.5, std::nullopt); + QCOMPARE(viewport.backingScale, 1.0); + QCOMPARE(viewport.pointsPerPixel(), 1.0); +} + +void CompositorTests::viewportsCompareExactly() +{ + CanvasViewport viewport; + viewport.resize(QSizeF(800, 600), 2, std::nullopt); + viewport.setZoom(1.5, viewport.center(), document); + QVERIFY(viewport == CanvasViewport(viewport)); + + CanvasViewport other = viewport; + other.viewSize = QSizeF(800 + 1e-10, 600); + QVERIFY(!(viewport == other)); + other = viewport; + other.viewSize = QSizeF(800, 600 + 1e-10); + QVERIFY(!(viewport == other)); + other = viewport; + other.backingScale = 2 + 1e-13; + QVERIFY(!(viewport == other)); + other = viewport; + other.pan = QSizeF(1e-13, 0); + QVERIFY(!(viewport == other)); + other = viewport; + other.pan = QSizeF(0, 1e-13); + QVERIFY(!(viewport == other)); + + CanvasViewport zoomed = viewport; + zoomed.setZoom(1.5 + 1e-13, zoomed.center(), document); + QVERIFY(zoomed.pan.width() == viewport.pan.width() && zoomed.pan.height() == viewport.pan.height()); + QCOMPARE(zoomed.followsFit(), viewport.followsFit()); + QVERIFY(!(viewport == zoomed)); + + CanvasViewport unsized; + unsized.translate(QSizeF(0, 0)); + CanvasViewport following = unsized; + following.fit(document); + QCOMPARE(following.zoom(), unsized.zoom()); + QVERIFY(following.followsFit() && !unsized.followsFit()); + QVERIFY(!(unsized == following)); +} + +QTEST_GUILESS_MAIN(CompositorTests) +#include "CompositorTests.moc" diff --git a/tests/CompositorUITestsLaunchTests.cpp b/tests/CompositorUITestsLaunchTests.cpp new file mode 100644 index 0000000..bb8dbda --- /dev/null +++ b/tests/CompositorUITestsLaunchTests.cpp @@ -0,0 +1,35 @@ +#include "UI/ProjectWorkspaceView.h" +#include <QtTest> + +class CompositorUITestsLaunchTests : public QObject { + Q_OBJECT +private slots: + void testLaunch(); + void testWindowKeepsItsMinimumSize(); +}; + +void CompositorUITestsLaunchTests::testLaunch() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + // Swift titles the window by its project. + QCOMPARE(window.windowTitle(), QStringLiteral("Untitled[*]")); + QCOMPARE(window.size(), QSize(1180, 780)); + // The welcome is up: Swift's launch test waits for it. + QVERIFY(window.findChild<QWidget *>("createCanvas")->isVisible()); + QCoreApplication::processEvents(); + QVERIFY(window.grab().save(QStringLiteral("LaunchScreen.png"))); +} + +void CompositorUITestsLaunchTests::testWindowKeepsItsMinimumSize() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.resize(100, 100); + QCOMPARE(window.size(), QSize(800, 520)); +} + +QTEST_MAIN(CompositorUITestsLaunchTests) +#include "CompositorUITestsLaunchTests.moc" diff --git a/tests/ContentViewStatusTests.cpp b/tests/ContentViewStatusTests.cpp new file mode 100644 index 0000000..9bbf324 --- /dev/null +++ b/tests/ContentViewStatusTests.cpp @@ -0,0 +1,191 @@ +#include "ContentView.h" +#include <QPainter> +#include <QProgressBar> +#include <QtTest> + +// The status bar: numbers, colour space, and each tool's words. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} +} + +class ContentViewStatusTests : public QObject { + Q_OBJECT +private slots: + void theStatusBarSaysWhatIsGoingOn(); + void everyToolHasItsHint(); + void zoomPrintsAsSwiftPrintsIt(); +}; + +void ContentViewStatusTests::theStatusBarSaysWhatIsGoingOn() +{ + EditorSession session; + ContentView view(session); + view.show(); + auto &zoom = find<QLabel>(view, "zoomStatus"), &size = find<QLabel>(view, "canvasDimensions"); + auto &colour = find<QLabel>(view, "colourStatus"), &activity = find<QLabel>(view, "activityStatus"); + auto &spinner = *view.findChild<QProgressBar *>(); + QVERIFY(!zoom.isVisible() && !size.isVisible() && !spinner.isVisible()); + QCOMPARE(colour.text(), QString("Ready when you are")); + session.createDocument(30000, 12345); + QVERIFY(zoom.isVisible() && size.isVisible()); + QCOMPARE(size.text(), QString("30,000 × 12,345 px")); + QCOMPARE(colour.text(), QString("sRGB · Transparent")); + QCOMPARE(zoom.width(), 62); + // Importing shows at once; busy only after its quarter second. + session.setIsImporting(true); + QVERIFY(spinner.isVisible()); + QCOMPARE(activity.text(), QString("Importing images…")); + session.setIsProjectBusy(true); + QCOMPARE(activity.text(), QString("Importing images…")); + QTRY_COMPARE(activity.text(), QString("Working…")); + QVERIFY(spinner.isVisible()); + session.setIsProjectBusy(false); + session.setIsImporting(false); + QVERIFY(!spinner.isVisible()); + QCOMPARE(activity.text(), ContentView::hint(session.tool())); + // The Marquee's and the Lasso's words follow their kinds. + session.selectTool(NavigationTool::marquee); + session.setMarqueeKind(LassoKind::ellipse); + QCOMPARE(activity.text(), ContentView::hint(NavigationTool::marquee, ToolIconKind::ellipse)); + QVERIFY(activity.text().startsWith("Drag an ellipse")); + session.selectTool(NavigationTool::lasso); + session.setLassoKind(LassoKind::polygonal); + QCOMPARE(activity.text(), ContentView::hint(NavigationTool::lasso, ToolIconKind::polygonal)); + QVERIFY(activity.text().startsWith("Click corners")); + session.setLassoKind(LassoKind::freehand); + QCOMPARE(activity.text(), ContentView::hint(NavigationTool::lasso)); + // The brush's words follow its mode. + session.selectTool(NavigationTool::brush); + session.setBrushMode(BrushToolMode::erase); + QCOMPARE(activity.text(), QString("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan")); + session.setBrushMode(BrushToolMode::paint); + QCOMPARE(activity.text(), ContentView::hint(NavigationTool::brush)); + // The Smear's words follow its mode. + session.selectTool(NavigationTool::blur); + session.setBlurMode(BlurToolMode::blur); + QCOMPARE(activity.text(), QString("Drag to soften · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan")); + session.setBlurMode(BlurToolMode::smudge); + QCOMPARE(activity.text(), QString("Drag to smudge · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan")); + QCOMPARE(ContentView::hint(NavigationTool::brush, ToolIconKind::plain, BlurToolMode::smudge), ContentView::hint(NavigationTool::brush)); + session.setBlurMode(BlurToolMode::liquify); + QCOMPARE(activity.text(), ContentView::hint(NavigationTool::blur)); + // Busy alone turns the spinner as well. + session.setIsProjectBusy(true); + QTRY_COMPARE(activity.text(), QString("Working…")); + QVERIFY(spinner.isVisible()); + session.setIsProjectBusy(false); + QVERIFY(!spinner.isVisible()); + QCOMPARE(view.findChild<QWidget *>("statusBar")->height(), 30); + // At the narrowest the hint gives way, not the numbers. + session.selectTool(NavigationTool::marquee); + view.resize(800, 520); + QTest::qWait(30); + QVERIFY(size.width() >= size.sizeHint().width() && colour.width() >= colour.sizeHint().width()); + QVERIFY(activity.width() < activity.sizeHint().width()); + QVERIFY(activity.geometry().right() <= view.width() - 18); + // The hint keeps its dim tone under any palette. + QPalette loud = activity.palette(); + loud.setColor(QPalette::PlaceholderText, QColor(200, 20, 20)); + loud.setColor(QPalette::WindowText, QColor(20, 20, 200)); + activity.setPalette(loud); + const QImage tinted = activity.grab().toImage(); + int red = 0, blue = 0; + for (int y = 0; y < tinted.height(); ++y) { + for (int x = 0; x < tinted.width(); ++x) { + const QColor pixel = tinted.pixelColor(x, y); + red += pixel.red() > 150 && pixel.blue() < 100; + blue += pixel.blue() > 150 && pixel.red() < 100; + } + } + QVERIFY2(red > 100 && blue == 0, qPrintable(QString("%1 %2").arg(red).arg(blue))); + activity.setPalette(QPalette()); + // What it paints there is the hint ending in dots. + const auto painted = [&](const QString &words) { + QImage image(activity.size(), QImage::Format_RGB32); + image.fill(activity.palette().color(QPalette::Window)); + QPainter painter(&image); + painter.setFont(activity.font()); + painter.setPen(activity.palette().color(QPalette::PlaceholderText)); + painter.drawText(image.rect(), Qt::AlignRight | Qt::AlignVCenter, words); + return image; + }; + // Tones differ a little between paint paths; letters do not. + const auto apart = [&](const QImage &one, const QImage &other) { + int count = 0; + for (int y = 0; y < one.height(); ++y) { + for (int x = 0; x < one.width(); ++x) + count += std::abs(qRed(one.pixel(x, y)) - qRed(other.pixel(x, y))) > 64; + } + return count; + }; + const QString cut = activity.fontMetrics().elidedText(activity.text(), Qt::ElideRight, activity.width()); + QVERIFY(cut.endsWith(QChar(0x2026)) && cut != activity.text()); + const QImage narrow = activity.grab().toImage().convertToFormat(QImage::Format_RGB32); + QVERIFY2(apart(narrow, painted(cut)) < 20 && apart(narrow, painted(activity.text())) > 200, + qPrintable(QString("%1 %2").arg(apart(narrow, painted(cut))).arg(apart(narrow, painted(activity.text()))))); + view.resize(2000, 520); + QTest::qWait(30); + QVERIFY(activity.width() >= activity.sizeHint().width()); + QVERIFY(apart(activity.grab().toImage().convertToFormat(QImage::Format_RGB32), painted(activity.text())) < 20); +} + +void ContentViewStatusTests::everyToolHasItsHint() +{ + const std::pair<NavigationTool, const char *> hints[] = { + {NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"}, + {NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"}, + {NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"}, + {NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"}, + {NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"}, + {NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"}, + {NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"}, + {NavigationTool::blur, "Drag to push pixels · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan"}, + {NavigationTool::gradient, "Drag to draw · Drag ends to adjust · Shift 45° · 1–0 opacity · Enter apply · Escape cancel"}, + {NavigationTool::shape, "Drag to draw a shape on a new layer · Shift square · Alt from center · Shift-U or Tab for the next shape · Escape cancel · Space to pan"}, + {NavigationTool::type, "Drag a text box · Click text to edit · Drag box handles to resize · Ctrl+Return finish · Escape cancel"}, + {NavigationTool::eyedropper, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"}, + {NavigationTool::hand, "Drag to pan · Pinch to zoom"}, + {NavigationTool::zoom, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"}, + {NavigationTool::idle, "No tool selected · Press a tool's key to pick one · Space to pan"}, + }; + // Another tool's kind leaves a tool's words alone. + QCOMPARE(ContentView::hint(NavigationTool::move, ToolIconKind::eraser), ContentView::hint(NavigationTool::move)); + QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::ellipse), ContentView::hint(NavigationTool::lasso)); + QCOMPARE(ContentView::hint(NavigationTool::marquee, ToolIconKind::polygonal), ContentView::hint(NavigationTool::marquee)); + EditorSession session; + session.createDocument(8, 8); + ContentView view(session); + for (const auto &[tool, words] : hints) { + session.selectTool(tool); + QCOMPARE(find<QLabel>(view, "activityStatus").text(), QString::fromUtf8(words)); + } + // The Shape's Shift follows its kind. + session.selectTool(NavigationTool::shape); + session.setShapeKind(ShapeKind::ellipse); + QCOMPARE(find<QLabel>(view, "activityStatus").text(), + QString("Drag to draw a shape on a new layer · Shift circle · Alt from center · Shift-U or Tab for the next shape · Escape cancel · Space to pan")); + session.setShapeKind(ShapeKind::line); + QVERIFY(find<QLabel>(view, "activityStatus").text().contains(QString("· Shift 45° ·"))); + QCOMPARE(ContentView::hint(NavigationTool::move, ToolIconKind::plain, BlurToolMode::liquify, ShapeKind::line), ContentView::hint(NavigationTool::move)); +} + +void ContentViewStatusTests::zoomPrintsAsSwiftPrintsIt() +{ + QCOMPARE(ContentView::percent(1), QString("100%")); + QCOMPARE(ContentView::percent(0.125), QString("12.5%")); + QCOMPARE(ContentView::percent(1.0 / 3), QString("33.3%")); + QCOMPARE(ContentView::percent(0.001), QString("0.1%")); + QCOMPARE(ContentView::percent(16), QString("1,600%")); + QCOMPARE(ContentView::percent(32), QString("3,200%")); + QCOMPARE(ContentView::percent(0.9996), QString("100%")); +} + +QTEST_MAIN(ContentViewStatusTests) +#include "ContentViewStatusTests.moc" diff --git a/tests/ContentViewTests.cpp b/tests/ContentViewTests.cpp new file mode 100644 index 0000000..8aded5e --- /dev/null +++ b/tests/ContentViewTests.cpp @@ -0,0 +1,404 @@ +#include "ContentView.h" +#include "DialogDesk.h" +#include "UI/LayersPanel.h" +#include "UI/ToolHeaderStyle.h" +#include <QBuffer> +#include <QCheckBox> +#include <QClipboard> +#include <QComboBox> +#include <QLineEdit> +#include <QPushButton> +#include <QTemporaryDir> +#include <QtTest> + +// One project's editor area, driven as a user drives it. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +void type(QLineEdit &field, const QString &text) +{ + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); +} + +const std::pair<NavigationTool, const char *> labelled[] = { + {NavigationTool::move, "Move / Transform (V)"}, + {NavigationTool::marquee, "Marquee (M)"}, + {NavigationTool::lasso, "Lasso (L)"}, + {NavigationTool::wand, "Magic Wand (W)"}, + {NavigationTool::crop, "Crop (C)"}, + {NavigationTool::brush, "Brush (B) · Eraser (E)"}, + {NavigationTool::spotHealing, "Spot Healing Brush (J)"}, + {NavigationTool::cloneStamp, "Clone Stamp (S) · Alt-click sets the source"}, + {NavigationTool::blur, "Smear (R)"}, + {NavigationTool::gradient, "Gradient (G)"}, + {NavigationTool::shape, "Shape (U) · Shift-U switches Rectangle/Ellipse"}, + {NavigationTool::type, "Type (T)"}, + {NavigationTool::eyedropper, "Eyedropper (I)"}, + {NavigationTool::hand, "Hand (H)"}, + {NavigationTool::zoom, "Zoom (Z)"}, +}; +} + +class ContentViewTests : public QObject { + Q_OBJECT +private slots: + void testCreateCanvasAndNavigation(); + void theRailHoldsEveryToolInSwiftsOrder(); + void theBarFollowsTheTool(); + void anImportErrorIsShownUntilItIsDismissed(); + void theImporterPicksFilesAndClearsItsFlag(); + void openProjectGoesToTheController(); + void theCanvasSitsUnderTheWelcomeAndTakesTheKeysWithADocument(); + void aShownViewClosesWithoutHearingItsSession(); +}; + +void ContentViewTests::testCreateCanvasAndNavigation() +{ + EditorSession session; + ContentView view(session); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + auto &width = find<QLineEdit>(view, "widthInput"), &height = find<QLineEdit>(view, "heightInput"); + auto &create = find<QPushButton>(view, "createCanvas"); + QVERIFY(width.isVisible() && width.hasFocus()); + type(width, "0"); + QVERIFY(!create.isEnabled()); + type(width, "1200"); + type(height, "800"); + QTest::mouseClick(&create, Qt::LeftButton); + QCOMPARE(find<QLabel>(view, "canvasDimensions").text(), QString("1,200 × 800 px")); + // The welcome is gone, fields and all. + QVERIFY(view.findChildren<QLineEdit *>("widthInput").isEmpty()); + // Swift's welcome makes a project with its first layer. + QCOMPARE(int(session.document().value().layers.size()), 1); + QCOMPARE(session.document().value().layers.front().name, QString("Layer 1")); + session.zoom(1); + QCOMPARE(find<QLabel>(view, "zoomStatus").text(), QString("100%")); + session.zoom(session.viewport.zoom() * 1.25); + QCOMPARE(find<QLabel>(view, "zoomStatus").text(), QString("125%")); + // Layouts settle in the event loop; then the picture holds. + QCoreApplication::processEvents(); + QVERIFY(view.grab().save(QStringLiteral("EditorFoundation.png"))); + // A project that is cleared brings a fresh welcome back. + auto *copied = new QMimeData; + QImage small(400, 300, QImage::Format_RGBA8888); + QByteArray png; + QBuffer buffer(&png); + small.save(&buffer, "png"); + copied->setData("image/png", png); + QApplication::clipboard()->setMimeData(copied); + session.clearProject(); + auto &fresh = find<QLineEdit>(view, "widthInput"); + QVERIFY(fresh.isVisible()); + QCOMPARE(fresh.text(), QString("400")); + QCOMPARE(find<QLineEdit>(view, "heightInput").text(), QString("300")); + QCOMPARE(find<QLabel>(view, "colourStatus").text(), QString("Ready when you are")); + QApplication::clipboard()->clear(); +} + +void ContentViewTests::theRailHoldsEveryToolInSwiftsOrder() +{ + EditorSession session; + session.createDocument(8, 8); + ContentView view(session); + view.show(); + const QList<QToolButton *> buttons = find<QWidget>(view, "toolRail").findChildren<QToolButton *>(); + QCOMPARE(buttons.size(), 15); + QCOMPARE(find<QWidget>(view, "toolRail").width(), 56); + int above = -1; + for (int index = 0; index < 15; ++index) { + QCOMPARE(buttons[index]->toolTip(), QString::fromUtf8(labelled[index].second)); + QCOMPARE(buttons[index]->accessibleName(), QString::fromUtf8(labelled[index].second)); + QCOMPARE(buttons[index]->size(), QSize(36, 36)); + // Top to bottom, in that order. + const int y = buttons[index]->mapTo(&view, QPoint(0, 0)).y(); + QVERIFY(y > above); + above = y; + } + // One button is down: the session's tool, however chosen. + for (int index = 0; index < 15; ++index) { + QTest::mouseClick(buttons[index], Qt::LeftButton); + QCOMPARE(session.tool(), labelled[index].first); + for (int other = 0; other < 15; ++other) + QCOMPARE(buttons[other]->isChecked(), other == index); + } + session.selectTool(NavigationTool::crop); + QVERIFY(buttons[4]->isChecked() && !buttons[14]->isChecked()); + session.selectTool(NavigationTool::idle); + for (const QToolButton *button : buttons) + QVERIFY(!button->isChecked()); + // A refused click leaves the old button down, alone. + session.selectTool(NavigationTool::move); + session.setIsProjectBusy(true); + QTest::mouseClick(buttons[1], Qt::LeftButton); + QCOMPARE(session.tool(), NavigationTool::move); + for (int other = 0; other < 15; ++other) + QCOMPARE(buttons[other]->isChecked(), other == 0); + session.setIsProjectBusy(false); + // A chosen button looks chosen: its plate is painted. + session.selectTool(NavigationTool::move); + const QImage chosen = buttons[0]->grab().toImage(), plain = buttons[1]->grab().toImage(); + QVERIFY(chosen.pixelColor(18, 2) != plain.pixelColor(18, 2)); + // The Marquee's and the Lasso's icons follow their kinds. + session.setMarqueeKind(LassoKind::ellipse); + QVERIFY(buttons[1]->grab().toImage() != plain); + session.setMarqueeKind(LassoKind::rectangle); + QCOMPARE(buttons[1]->grab().toImage(), plain); + const QImage freehand = buttons[2]->grab().toImage(); + session.setLassoKind(LassoKind::polygonal); + QVERIFY(buttons[2]->grab().toImage() != freehand); + // The brush's shows the eraser while it erases. + QToolButton *brush = nullptr; + for (int index = 0; index < 15; ++index) { + if (labelled[index].first == NavigationTool::brush) + brush = buttons[index]; + } + QVERIFY(brush); + const QImage painting = brush->grab().toImage(); + session.setBrushMode(BrushToolMode::erase); + QVERIFY(brush->grab().toImage() != painting); + session.setBrushMode(BrushToolMode::paint); + QCOMPARE(brush->grab().toImage(), painting); +} + +void ContentViewTests::theBarFollowsTheTool() +{ + EditorSession session; + session.createDocument(8, 8); + ContentView view(session); + view.show(); + const auto bar = [&]() -> ToolHeaderBar & { return find<ToolHeaderBar>(view, "toolHeader"); }; + // The first tool is Move: its inspector. + QCOMPARE(bar().height(), 42); + QCOMPARE(bar().title->text(), QString("Transform")); + QVERIFY(find<QCheckBox>(view, "transformAutoSelect").isVisible()); + QCOMPARE(bar().mapTo(&view, QPoint(0, 0)), QPoint(0, 0)); + session.selectTool(NavigationTool::idle); + QCOMPARE(bar().title->text(), QString("Select a tool")); + QCOMPARE(bar().title->font().pixelSize(), 13); + QCOMPARE(bar().height(), 42); + session.selectTool(NavigationTool::eyedropper); + QCOMPARE(bar().title->text(), QString("Eyedropper")); + session.selectTool(NavigationTool::hand); + QCOMPARE(bar().title->text(), QString("Pan")); + QVERIFY(!find<QLineEdit>(view, "zoomPercentage").isVisible()); + // Hand and Zoom share one bar, never rebuilt between them. + bar().setProperty("kept", true); + session.selectTool(NavigationTool::zoom); + QVERIFY(bar().property("kept").toBool()); + QCOMPARE(bar().title->text(), QString("Zoom")); + QVERIFY(find<QLineEdit>(view, "zoomPercentage").isVisible()); + session.selectTool(NavigationTool::brush); + QVERIFY(!bar().property("kept").toBool()); + QCOMPARE(bar().title->text(), QString("Brush")); + QVERIFY(find<QLineEdit>(view, "brushSize").isVisible()); + // Every brush tool keeps that bar under its own title. + bar().setProperty("kept", true); + session.selectTool(NavigationTool::spotHealing); + QCOMPARE(bar().title->text(), QString("Spot Healing")); + QVERIFY(find<QLineEdit>(view, "brushSize").isVisible()); + session.selectTool(NavigationTool::cloneStamp); + QCOMPARE(bar().title->text(), QString("Clone Stamp")); + session.selectTool(NavigationTool::blur); + QCOMPARE(bar().title->text(), QString("Smear")); + QVERIFY(bar().property("kept").toBool()); + // Gradient, Shape, Type and Crop have bars of their own. + session.selectTool(NavigationTool::gradient); + QCOMPARE(bar().title->text(), QString("Gradient")); + QVERIFY(find<QToolButton>(view, "gradientLinear").isVisible()); + session.selectTool(NavigationTool::shape); + QCOMPARE(bar().title->text(), QString("Shape")); + QVERIFY(find<QToolButton>(view, "shapeRectangle").isVisible()); + session.selectTool(NavigationTool::type); + QCOMPARE(bar().title->text(), QString("Type")); + session.selectTool(NavigationTool::crop); + QCOMPARE(bar().title->text(), QString("Crop")); + QVERIFY(find<QComboBox>(view, "cropRatio").isVisible()); + QCOMPARE(bar().height(), 42); + QCOMPARE(view.findChildren<ToolHeaderBar *>("toolHeader").size(), 1); + session.selectTool(NavigationTool::blur); + QCOMPARE(bar().title->text(), QString("Smear")); + // The three selection tools share the lasso bar. + session.selectTool(NavigationTool::marquee); + QCOMPARE(bar().title->text(), QString("Marquee")); + QVERIFY(find<QToolButton>(view, "marqueeRectangle").isVisible()); + bar().setProperty("kept", true); + session.selectTool(NavigationTool::lasso); + QVERIFY(bar().property("kept").toBool()); + QCOMPARE(bar().title->text(), QString("Lasso")); + session.selectTool(NavigationTool::wand); + QVERIFY(bar().property("kept").toBool()); + QCOMPARE(bar().title->text(), QString("Magic Wand")); + QCOMPARE(bar().height(), 42); + QCOMPARE(bar().height(), 42); +} + +void ContentViewTests::anImportErrorIsShownUntilItIsDismissed() +{ + EditorSession session; + ContentView view(session); + view.show(); + DialogDesk desk; + desk.replies = {"OK", "OK", "OK"}; + // Other news meanwhile opens no second alert. + desk.note = [&] { + session.selectTool(NavigationTool::hand); + return QString::number(view.findChildren<QMessageBox *>().size()); + }; + session.setImportError(QString("two files could not be read")); + QTRY_COMPARE(desk.seen, (QStringList{"alert|2|Import couldn’t finish|two files could not be read|OK|1"})); + // OK clears the error: file requests may start again. + QTRY_COMPARE(session.importError(), std::nullopt); + session.setBrushError(QString("no pixels")); + QTRY_COMPARE(desk.seen.size(), 2); + QCOMPARE(desk.seen.last(), QString("alert|2|Couldn’t paint|no pixels|OK|1")); + QTRY_COMPARE(session.brushError(), std::nullopt); + session.setCropError(QString("the frame is too large")); + QTRY_COMPARE(desk.seen.size(), 3); + QCOMPARE(desk.seen.last(), QString("alert|2|Couldn’t crop|the frame is too large|OK|1")); + QTRY_COMPARE(session.cropError(), std::nullopt); + // Cleared from elsewhere, the alert goes without being asked. + desk.replies.clear(); + session.setImportError(QString("soon gone")); + session.setImportError(std::nullopt); + QTest::qWait(50); + QCOMPARE(desk.seen.size(), 3); + QCOMPARE(view.findChildren<QMessageBox *>().size(), 0); +} + +void ContentViewTests::theImporterPicksFilesAndClearsItsFlag() +{ + QTemporaryDir folder; + QImage picture(4, 2, QImage::Format_RGBA8888); + picture.fill(Qt::green); + QVERIFY(picture.save(folder.filePath("Picked.png"))); + EditorSession session; + session.createDocument(8, 8); + ContentView view(session); + view.show(); + DialogDesk desk; + QImage second(2, 2, QImage::Format_RGBA8888); + second.fill(Qt::blue); + QVERIFY(second.save(folder.filePath("Also.png"))); + desk.replies = {"<cancel>", folder.filePath("Picked.png") + "|" + folder.filePath("Also.png")}; + // One panel however much else changes; it takes several files. + desk.note = [&] { + session.selectTool(NavigationTool::hand); + const QList<QFileDialog *> panels = view.findChildren<QFileDialog *>(); + return QString("%1 %2").arg(panels.size()).arg(panels.value(0) && panels.value(0)->fileMode() == QFileDialog::ExistingFiles ? "many" : "one"); + }; + session.setShowsImporter(true); + QTRY_COMPARE(desk.seen.size(), 1); + QTRY_VERIFY(!session.showsImporter()); + QVERIFY(session.document().value().layers.empty()); + session.setShowsImporter(true); + QTRY_COMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.document().value().layers.front().name, QString("Picked")); + QCOMPARE(session.document().value().layers.back().name, QString("Also")); + QVERIFY(!session.showsImporter()); + // A cancelled panel with a file typed imports nothing. + desk.replies = {"<cancel>"}; + desk.typeBeforeCancel = folder.filePath("Picked.png"); + session.setShowsImporter(true); + QTRY_VERIFY(!session.showsImporter()); + QTest::qWait(50); + QCOMPARE(int(session.document().value().layers.size()), 2); + desk.typeBeforeCancel.clear(); + QCOMPARE(desk.seen.mid(0, 2), (QStringList{"panel|Open|open|file|||1 many", "panel|Open|open|file|||1 many"})); + QCOMPARE(desk.seen.size(), 3); + // The flag cleared from elsewhere takes the panel down. + desk.replies.clear(); + session.setShowsImporter(true); + session.setShowsImporter(false); + QTest::qWait(50); + QCOMPARE(desk.seen.size(), 3); +} + +void ContentViewTests::openProjectGoesToTheController() +{ + EditorSession session; + ProjectController projects(session); + ContentView view(session, &projects); + view.show(); + DialogDesk desk; + desk.replies = {"<cancel>"}; + QTest::mouseClick(&find<QPushButton>(view, "openProject"), Qt::LeftButton); + QTRY_COMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder||"})); + // Without a controller the button has nowhere to go. + EditorSession lone; + ContentView bare(lone); + bare.show(); + QTest::mouseClick(&find<QPushButton>(bare, "openProject"), Qt::LeftButton); + QTest::qWait(30); + QCOMPARE(desk.seen.size(), 1); + QTest::mouseClick(&find<QPushButton>(bare, "importImage"), Qt::LeftButton); + QVERIFY(lone.showsImporter()); + lone.setShowsImporter(false); +} + +void ContentViewTests::theCanvasSitsUnderTheWelcomeAndTakesTheKeysWithADocument() +{ + EditorSession session; + ContentView view(session); + view.show(); + QVERIFY(QTest::qWaitForWindowActive(&view)); + auto &canvas = find<CanvasView>(view, "editorCanvas"); + QVERIFY(canvas.isVisible()); + // The rail and the Layers panel take theirs; 480 stays. + QVERIFY(canvas.width() >= 480 && canvas.height() >= 300); + QCOMPARE(canvas.parentWidget()->x() + canvas.parentWidget()->width(), view.layersPanel().x() - 8); + QCOMPARE(canvas.geometry(), canvas.parentWidget()->rect()); + QTRY_VERIFY(find<QLineEdit>(view, "widthInput").hasFocus()); + session.createNewProject(300, 200); + QTRY_VERIFY(canvas.hasFocus()); + // The viewport follows the canvas; the status shows the fit. + QTRY_COMPARE(session.viewport.viewSize, QSizeF(canvas.size())); + QVERIFY(session.viewport.followsFit()); + QCOMPARE(find<QLabel>(view, "zoomStatus").text(), ContentView::percent(session.viewport.zoom())); + // The view syncs the canvas: its cursor follows the tool. + session.selectTool(NavigationTool::hand); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + // A field keeps the keys through changes; Return returns them. + session.selectTool(NavigationTool::zoom); + auto &zoom = find<QLineEdit>(view, "zoomPercentage"); + zoom.setFocus(); + QTRY_VERIFY(zoom.hasFocus()); + session.zoom(1.5); + QTest::qWait(50); + QVERIFY(zoom.hasFocus()); + QTest::keyClick(&zoom, Qt::Key_Return); + QTRY_VERIFY(canvas.hasFocus()); + // A cleared project brings the welcome over the canvas again. + session.clearProject(); + QVERIFY(canvas.isVisible()); + QTRY_VERIFY(find<QLineEdit>(view, "widthInput").hasFocus()); + session.createNewProject(300, 200); + QTRY_VERIFY(canvas.hasFocus()); +} + +void ContentViewTests::aShownViewClosesWithoutHearingItsSession() +{ + EditorSession session; + session.createDocument(8, 8); + QSignalSpy changes(&session, &EditorSession::changed); + { + ContentView view(session); + view.show(); + QVERIFY(QTest::qWaitForWindowActive(&view)); + QTRY_VERIFY(find<CanvasView>(view, "editorCanvas").hasFocus()); + changes.clear(); + } + // Closing cancelled the brush after the view's members went. + QCOMPARE(changes.count(), 1); +} + +QTEST_MAIN(ContentViewTests) +#include "ContentViewTests.moc" diff --git a/tests/CropCanvasTests.cpp b/tests/CropCanvasTests.cpp new file mode 100644 index 0000000..3fdea03 --- /dev/null +++ b/tests/CropCanvasTests.cpp @@ -0,0 +1,408 @@ +#include "SelectionCanvasFixtures.h" +#include <QNativeGestureEvent> + +// The Crop tool on the canvas: drags, keys, cursors, frame. +class CropCanvasTests : public QObject { + Q_OBJECT +private slots: + void aDragDrawsMovesAndResizesTheFrame(); + void altAndCtrlShapeTheDragAsTheyChange(); + void snappingReachesEightViewPoints(); + void theKeysAndLostReleasesEndTheDrag(); + void theCursorShowsWhatAPressTakes(); + void theFrameDimsWhatItCutsAway(); + void aFramePastTheCanvasShowsWhatItTakesIn(); + void cropDraggingRedrawsOnlyTheOverlay(); +}; + +namespace { +struct CropCanvas : Canvas { + CropCanvas() + { + // Zoomed about the middle, the pan keeps a float's dust. + session.viewport.pan = QSizeF(0, 0); + session.selectTool(NavigationTool::crop); + canvas->synchronizeDisplay(); + } + std::optional<QRectF> frame() const { return session.cropRect(); } + Qt::CursorShape shape() const { return canvas->cursor().shape(); } +}; +} + +void CropCanvasTests::aDragDrawsMovesAndResizesTheFrame() +{ + CropCanvas shown; + // On the whole canvas's frame a press draws anew. + shown.press(QPointF(100, 100)); + QCOMPARE(shown.frame(), std::nullopt); + shown.move(QPointF(200, 160)); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 100, 100, 60))); + shown.release(QPointF(200, 160)); + // Inside a drawn frame a press moves it. + shown.drag(QPointF(150, 130), QPointF(160, 145)); + QCOMPARE(shown.frame(), std::optional(QRectF(110, 115, 100, 60))); + // Eight points from the canvas's edge, an edge snaps. + shown.drag(QPointF(150, 130), QPointF(45, 130)); + QCOMPARE(shown.frame(), std::optional(QRectF(0, 115, 100, 60))); + // Corner grips resize; so do edges far from their middle. + shown.drag(QPointF(100, 175), QPointF(150, 200)); + QCOMPARE(shown.frame(), std::optional(QRectF(0, 115, 150, 85))); + shown.drag(QPointF(20, 115), QPointF(20, 95)); + QCOMPARE(shown.frame(), std::optional(QRectF(0, 95, 150, 105))); + // A fixed ratio shapes a new frame. + shown.session.setCropRatioChoice("1:1"); + shown.drag(QPointF(250, 20), QPointF(310, 50)); + QCOMPARE(shown.frame(), std::optional(QRectF(250, 20, 60, 60))); + // Dragged past the bounds, a frame keeps its last shape. + shown.session.setCropRatioChoice("Free"); + shown.session.zoom(0.01); + shown.canvas->synchronizeDisplay(); + shown.press(QPointF(1, 1)); + shown.move(QPointF(100, 100)); + const QRectF good = shown.frame().value(); + shown.move(QPointF(399, 299)); + QCOMPARE(shown.frame(), std::optional(good)); + shown.release(QPointF(399, 299)); +} + +void CropCanvasTests::altAndCtrlShapeTheDragAsTheyChange() +{ + CropCanvas shown; + // Alt grows the frame out from the press. + shown.press(QPointF(300, 50), Qt::AltModifier); + shown.move(QPointF(320, 60), Qt::AltModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(280, 40, 40, 20))); + shown.release(QPointF(320, 60), Qt::AltModifier); + // Ctrl leaves the edges where the pointer puts them. + shown.drag(QPointF(100, 100), QPointF(395, 200)); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 100, 300, 100))); + shown.session.cancelCrop(); + shown.drag(QPointF(100, 100), QPointF(395, 200), Qt::ControlModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 100, 295, 100))); + // A key pressed or let go mid-drag reshapes at once. + shown.session.cancelCrop(); + shown.press(QPointF(200, 150)); + shown.move(QPointF(220, 160)); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 150, 20, 10))); + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(180, 140, 40, 20))); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 150, 20, 10))); + shown.move(QPointF(395, 160)); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 150, 200, 10))); + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Control, Qt::ControlModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 150, 195, 10))); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Control, Qt::NoModifier); + shown.release(QPointF(395, 160)); + // Without a drag a key leaves the frame be. + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 150, 200, 10))); +} + +void CropCanvasTests::theKeysAndLostReleasesEndTheDrag() +{ + CropCanvas shown; + EditorSession &session = shown.session; + // The canvas takes the frame's keys: nothing above sees them. + QKeyEvent escape(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); + QApplication::sendEvent(shown.canvas, &escape); + QVERIFY(escape.isAccepted()); + // Escape drops the frame, mid-drag too, ending the drag. + shown.move(QPointF(150, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QCOMPARE(shown.frame(), std::nullopt); + shown.move(QPointF(200, 200)); + QCOMPARE(shown.frame(), std::nullopt); + shown.release(QPointF(200, 200)); + QCOMPARE(session.visibleCropRect(), std::optional(QRectF(0, 0, 400, 300))); + // Return without a frame crops nothing. + QTest::keyClick(shown.canvas, Qt::Key_Return); + QTest::qWait(20); + QCOMPARE(session.document().value().width, 400); + // Losing the keys, or the left button, ends a drag. + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 150)); + QFocusEvent out(QEvent::FocusOut, Qt::ActiveWindowFocusReason); + QApplication::sendEvent(shown.canvas, &out); + shown.move(QPointF(200, 200)); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 100, 50, 50))); + shown.canvas->setFocus(); + shown.press(QPointF(10, 250)); + shown.move(QPointF(20, 260)); + shown.hover(QPointF(30, 270)); + shown.move(QPointF(40, 280)); + QCOMPARE(shown.frame(), std::optional(QRectF(10, 250, 10, 10))); + shown.release(QPointF(40, 280)); + // Mid-drag, wheels and pinches neither pan nor zoom. + const CanvasViewport before = session.viewport; + shown.press(QPointF(200, 50)); + wheel(*shown.canvas, QPointF(200, 50), QPoint(0, 40), QPoint(0, 120), Qt::NoModifier); + QNativeGestureEvent pinch(Qt::ZoomNativeGesture, QPointingDevice::primaryPointingDevice(), 2, QPointF(200, 50), QPointF(200, 50), + shown.canvas->mapToGlobal(QPoint(200, 50)), 0.5, QPointF()); + QApplication::sendEvent(shown.canvas, &pinch); + QVERIFY(session.viewport == before); + // Busy, or another tool, and a drag changes nothing. + shown.move(QPointF(250, 80)); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 50, 50, 30))); + session.setIsProjectBusy(true); + shown.move(QPointF(260, 90)); + QCOMPARE(shown.frame(), std::optional(QRectF(200, 50, 50, 30))); + session.setIsProjectBusy(false); + session.selectTool(NavigationTool::brush); + shown.move(QPointF(270, 100)); + // The Brush's circle follows the pointer meanwhile. + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(270, 100)); + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.frame(), std::nullopt); + shown.release(QPointF(270, 100)); + // Return crops to the frame, Enter alike; one step each. + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(10, 20, 200, 100)); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QTRY_COMPARE(session.document().value().width, 200); + QCOMPARE(session.document().value().height, 100); + QCOMPARE(session.history.undoName(), QString("Crop")); + session.setCropRect(QRectF(0, 0, 50, 40)); + QTest::keyClick(shown.canvas, Qt::Key_Enter, Qt::KeypadModifier); + QTRY_COMPARE(session.document().value().width, 50); + // Off the Crop tool, Return goes to a transform. + session.selectTool(NavigationTool::move); + session.insert(filled(10, 10, qRgba(0, 0, 255, 255), "Blue")); + session.beginTransform(); + QVERIFY(session.transformEdit()); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QVERIFY(!session.transformEdit()); + QCOMPARE(session.document().value().width, 50); + // A document gone mid-drag leaves the drag nothing to do. + session.selectTool(NavigationTool::crop); + shown.press(QPointF(10, 10)); + session.clearProject(); + shown.move(QPointF(30, 30)); + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.frame(), std::nullopt); + shown.release(QPointF(30, 30)); +} + +void CropCanvasTests::snappingReachesEightViewPoints() +{ + CropCanvas shown; + // At 100% eight points are eight pixels: nine stay out. + shown.drag(QPointF(100, 100), QPointF(391, 200)); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 100, 291, 100))); + // At 50% the same eight points reach sixteen pixels. + shown.session.zoom(0.5); + shown.canvas->synchronizeDisplay(); + const QSizeF size = shown.documentSize(); + const QPointF from = shown.session.viewport.viewPoint(QPointF(100, 50), size), to = shown.session.viewport.viewPoint(QPointF(390, 170), size); + shown.drag(from, to); + QCOMPARE(shown.frame(), std::optional(QRectF(100, 50, 300, 120))); +} + +void CropCanvasTests::theCursorShowsWhatAPressTakes() +{ + CropCanvas shown; + // Before any hover, the crosshair. + QCOMPARE(shown.shape(), Qt::CrossCursor); + // A narrow frame's corners overlap: the first corner wins. + shown.session.setCropRect(QRectF(100, 100, 10, 100)); + shown.canvas->synchronizeDisplay(); + shown.hover(QPointF(105, 100)); + QCOMPARE(shown.shape(), Qt::SizeFDiagCursor); + shown.session.setCropRect(QRectF(100, 100, 200, 100)); + shown.canvas->synchronizeDisplay(); + shown.hover(QPointF(200, 150)); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.hover(QPointF(50, 50)); + QCOMPARE(shown.shape(), Qt::CrossCursor); + const std::pair<QPointF, Qt::CursorShape> grips[] = { + {{100, 100}, Qt::SizeFDiagCursor}, {{200, 100}, Qt::SizeVerCursor}, {{300, 100}, Qt::SizeBDiagCursor}, {{300, 150}, Qt::SizeHorCursor}, + {{300, 200}, Qt::SizeFDiagCursor}, {{200, 200}, Qt::SizeVerCursor}, {{100, 200}, Qt::SizeBDiagCursor}, {{100, 150}, Qt::SizeHorCursor}, + {{115, 91}, Qt::SizeVerCursor}, {{109, 185}, Qt::SizeHorCursor}, {{109.5, 109.5}, Qt::SizeFDiagCursor}, {{110, 110}, Qt::CrossCursor}, + {{90, 95}, Qt::SizeFDiagCursor}, {{95, 90}, Qt::SizeFDiagCursor}}; + for (const auto &[at, cursor] : grips) { + shown.hover(at); + QCOMPARE(shown.shape(), cursor); + } + // Space pans. + QTest::keyPress(shown.canvas, Qt::Key_Space); + shown.hover(QPointF(300, 200)); + QCOMPARE(shown.shape(), Qt::OpenHandCursor); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(shown.shape(), Qt::SizeFDiagCursor); + // A new frame keeps its press's cursor: the old frame's. + shown.hover(QPointF(5, 150)); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.press(QPointF(5, 150)); + QCOMPARE(shown.frame(), std::nullopt); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.move(QPointF(30, 170)); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.release(QPointF(30, 170)); + shown.session.setCropRect(QRectF(100, 100, 200, 100)); + shown.canvas->synchronizeDisplay(); + // A drag keeps its press's cursor until the release. + shown.session.cancelCrop(); + shown.press(QPointF(50, 50)); + shown.move(QPointF(150, 150)); + QCOMPARE(shown.frame(), std::optional(QRectF(50, 50, 100, 100))); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.release(QPointF(150, 150)); + QCOMPARE(shown.shape(), Qt::SizeFDiagCursor); + shown.press(QPointF(150, 100)); + QCOMPARE(shown.shape(), Qt::SizeHorCursor); + shown.move(QPointF(120, 60)); + QCOMPARE(shown.shape(), Qt::SizeHorCursor); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shown.shape(), Qt::CrossCursor); + shown.release(QPointF(120, 60)); + // Escape without a crop drag leaves another drag's cursor be. + shown.session.selectTool(NavigationTool::move); + shown.session.insert(filled(100, 100, qRgba(0, 0, 255, 255), "Blue")); + shown.press(QPointF(200, 150)); + const QImage dragging = image(shown.canvas->cursor()); + QVERIFY(!dragging.isNull()); + QTest::keyClick(shown.canvas, Qt::Key_C); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QCOMPARE(image(shown.canvas->cursor()), dragging); + shown.release(QPointF(200, 150)); + // Return frees the drag cursor at once, before the crop. + shown.press(QPointF(50, 50)); + shown.move(QPointF(150, 150)); + QCOMPARE(shown.shape(), Qt::CrossCursor); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QCOMPARE(shown.shape(), Qt::SizeFDiagCursor); + shown.release(QPointF(150, 150)); + QTRY_COMPARE(shown.session.document().value().width, 100); +} + +void CropCanvasTests::theFrameDimsWhatItCutsAway() +{ + CropCanvas shown; + EditorSession &session = shown.session; + session.setCropRect(QRectF(100, 100, 200, 100)); + QImage image(400, 300, QImage::Format_RGB32); + image.fill(QColor(200, 200, 200)); + { + QPainter painter(&image); + TransformOverlay(session).draw(painter, shown.canvas->palette()); + } + // Outside, 60% black; inside, clear but for lines and grips. + QCOMPARE(qRed(image.pixel(50, 50)), 80); + QCOMPARE(qRed(image.pixel(150, 150)), 200); + QCOMPARE(qRed(image.pixel(150, 120)), 200); + // The frame straddles the edge: half white, half beneath. + QVERIFY(std::abs(qRed(image.pixel(150, 100)) - 228) <= 1); + QVERIFY(std::abs(qRed(image.pixel(150, 99)) - 168) <= 1); + // A third's line: 40% white over its pixels, antialiased. + QVERIFY(qRed(image.pixel(166, 120)) > 210 && qRed(image.pixel(166, 120)) < 221); + QCOMPARE(qRed(image.pixel(165, 120)), 200); + QVERIFY(qRed(image.pixel(150, 133)) > 205); + // Both thirds each way, end to end, flat-capped. + for (const QPoint third : {QPoint(233, 120), QPoint(150, 166), QPoint(166, 101), QPoint(166, 198), QPoint(298, 133)}) + QVERIFY2(qRed(image.pixel(third)) > 205, qPrintable(QString("%1 %2").arg(third.x()).arg(third.y()))); + QVERIFY(std::abs(qRed(image.pixel(166, 99)) - 168) <= 2); + // White grips with black rims, corners and middles alike. + for (const QPoint grip : {QPoint(102, 102), QPoint(202, 102), QPoint(298, 198), QPoint(102, 152)}) + QCOMPARE(image.pixel(grip), qRgb(255, 255, 255)); + QVERIFY(qRed(image.pixel(96, 101)) < 200); + // At twice the scale a grip's corner is mitred. + QImage twice(800, 600, QImage::Format_RGB32); + twice.fill(QColor(200, 200, 200)); + { + QPainter painter(&twice); + painter.scale(2, 2); + TransformOverlay(session).draw(painter, shown.canvas->palette()); + } + QCOMPARE(qRed(twice.pixel(191, 191)), 0); + QCOMPARE(qRed(twice.pixel(204, 204)), 255); + // It repaints the frames, the grips' five points around. + QCOMPARE(TransformOverlay(session).drawnRect(QRect(0, 0, 400, 300)), QRect(95, 95, 210, 110)); + session.zoom(2); + QCOMPARE(TransformOverlay(session).cropViewRect().value().size(), QSizeF(400, 200)); + QCOMPARE(TransformOverlay(session).cropViewRect().value().topLeft(), session.viewport.viewPoint(QPointF(100, 100), QSizeF(400, 300))); + session.zoom(1); + session.selectTool(NavigationTool::hand); + QVERIFY(TransformOverlay(session).drawnRect(QRect(0, 0, 400, 300)).isEmpty()); + QVERIFY(TransformOverlay(session).cropResizeRegions().empty()); +} + +void CropCanvasTests::aFramePastTheCanvasShowsWhatItTakesIn() +{ + Shown shown; + shown.settle(); + shown.session.zoom(1); + shown.canvas->synchronizeDisplay(); + // The document sits at 150–250 × 100–200 in view points. + QCOMPARE(shown.session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100)); + const QColor surround = shown.canvas->palette().color(QPalette::Base); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(120, 150), surround); + shown.session.selectTool(NavigationTool::crop); + shown.session.setCropRect(QRectF(-50, 0, 100, 100)); + shown.canvas->synchronizeDisplay(); + const QImage shot = shown.canvas->grab().toImage(); + // The checkerboard's squares: the frame takes this in. + QCOMPARE(qGray(shot.pixel(120, 150)), 77); + QCOMPARE(qGray(shot.pixel(106, 106)), 89); + // The canvas still shows beside it, dimmed. + QVERIFY(std::abs(qGray(shot.pixel(230, 150)) - 36) <= 1); + // Beyond the frame the surround shows, dimmed. + QVERIFY(std::abs(qRed(shot.pixel(20, 20)) - qRound(surround.red() * 0.4)) <= 1); + shown.session.selectTool(NavigationTool::hand); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(120, 150), surround); + // Coming and going, the dimming repaints the canvas. + shown.session.viewport.pan = QSizeF(0, 0); + shown.canvas->synchronizeDisplay(); + QApplication::processEvents(); + PaintSpy spy(*shown.canvas); + const auto shows = [&](const std::function<void()> &change) { + spy.painted = QRect(); + change(); + shown.canvas->synchronizeDisplay(); + QApplication::processEvents(); + return spy.painted; + }; + QCOMPARE(shows([&] { shown.session.selectTool(NavigationTool::crop); }), shown.canvas->rect()); + // A changed frame repaints the old and the new alone. + QCOMPARE(shows([&] { shown.session.setCropRect(QRectF(10, 0, 20, 20)); }), QRect(145, 95, 110, 110)); + QCOMPARE(shows([&] { shown.session.setCropRect(QRectF(20, 0, 20, 20)); }), QRect(155, 95, 40, 30)); + QCOMPARE(shows([&] { shown.session.selectTool(NavigationTool::hand); }), shown.canvas->rect()); +} + +// Swift's test: inside the canvas a drag repaints frames alone. +void CropCanvasTests::cropDraggingRedrawsOnlyTheOverlay() +{ + Shown shown(QSize(800, 600), QSize(600, 450)); + shown.settle(); + EditorSession &session = shown.session; + session.insert(filled(400, 300, qRgba(255, 0, 0, 255), "Red")); + session.selectTool(NavigationTool::crop); + shown.canvas->synchronizeDisplay(); + QApplication::processEvents(); + PaintSpy spy(*shown.canvas); + QTest::mousePress(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(150, 120)); + QApplication::processEvents(); + const auto viewRect = [&] { return TransformOverlay(session).cropViewRect().value(); }; + QRectF shown_ = viewRect(); + for (int step = 0; step < 10; ++step) { + spy.painted = QRect(); + drag(*shown.canvas, QPointF(170 + step * 10, 140 + step * 8)); + QApplication::processEvents(); + const QRectF next = viewRect(); + // Old and new frames together, grips and all. + QVERIFY((shown_ | next).adjusted(-5, -5, 5, 5).toAlignedRect().contains(spy.painted)); + QVERIFY(spy.painted.contains(next.toAlignedRect())); + QVERIFY(step == 0 || spy.painted.width() < 150); + QVERIFY(!shown.canvas->synchronizeDisplay()); + shown_ = next; + } + QTest::mouseRelease(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(260, 212)); + QVERIFY(session.cropRect().has_value()); +} + +QTEST_MAIN(CropCanvasTests) +#include "CropCanvasTests.moc" diff --git a/tests/CropControlsTests.cpp b/tests/CropControlsTests.cpp new file mode 100644 index 0000000..e80cefd --- /dev/null +++ b/tests/CropControlsTests.cpp @@ -0,0 +1,111 @@ +#include "Document/EditorSession.h" +#include "UI/CropControls.h" +#include <QComboBox> +#include <QPushButton> +#include <QtTest> + +// Swift's CropControls: ratio, size, Cancel and Apply Crop. +class CropControlsTests : public QObject { + Q_OBJECT +private slots: + void theBarShowsTheFrameAndItsRatio(); + void itsButtonsCancelAndCrop(); + void itRestsWhileBusyOrEmpty(); +}; + +namespace { +template <typename Widget> Widget &control(const QWidget &bar, const char *name) +{ + Widget *found = bar.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no control named ") + name); + return *found; +} +} + +void CropControlsTests::theBarShowsTheFrameAndItsRatio() +{ + EditorSession session; + session.createDocument(1200, 800); + session.selectTool(NavigationTool::crop); + CropControls bar(session); + bar.show(); + QCOMPARE(bar.title->text(), QString("Crop")); + QComboBox &ratio = control<QComboBox>(bar, "cropRatio"); + QStringList choices; + for (int index = 0; index < ratio.count(); ++index) + choices << ratio.itemText(index); + QCOMPARE(choices, (QStringList{"Free", "Original", "1:1", "4:3", "16:9"})); + QCOMPARE(ratio.currentText(), QString("Free")); + // Labelled as Swift's picker, the two 170 points wide. + const QList<QLabel *> labels = bar.findChildren<QLabel *>(); + const auto named = std::find_if(labels.begin(), labels.end(), [](const QLabel *label) { return label->text() == "Ratio"; }); + QVERIFY(named != labels.end()); + QCOMPARE((*named)->buddy(), &ratio); + QCOMPARE(ratio.parentWidget()->width(), 170); + QCOMPARE((*named)->x(), 0); + QCOMPARE((*named)->width(), (*named)->sizeHint().width()); + QCOMPARE(ratio.geometry().right(), 169); + QCOMPARE(bar.row->spacing(), 14); + QLabel &size = control<QLabel>(bar, "cropSize"); + QCOMPARE(size.text(), QString("1,200 × 800 px")); + QVERIFY(size.isVisible()); + // A new ratio takes the frame about its middle, once. + emit ratio.activated(2); + QCOMPARE(session.cropRatioChoice(), QString("1:1")); + QCOMPARE(session.cropRect(), std::optional(QRectF(0, -200, 1200, 1200))); + QCOMPARE(size.text(), QString("1,200 × 1,200 px")); + session.setCropRect(QRectF(0, 0, 1200, 600)); + emit ratio.activated(2); + QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 1200, 600))); + // The session's choice shows; whole pixels, truncated. + session.setCropRatioChoice("16:9"); + QCOMPARE(ratio.currentText(), QString("16:9")); + session.setCropRect(QRectF(0.4, 0, 99.9, 50.2)); + QCOMPARE(size.text(), QString("99 × 50 px")); + session.cancelCrop(); + QVERIFY(!size.isVisible()); +} + +void CropControlsTests::itsButtonsCancelAndCrop() +{ + EditorSession session; + session.createDocument(100, 100); + session.selectTool(NavigationTool::crop); + CropControls bar(session); + bar.show(); + QPushButton &cancel = control<QPushButton>(bar, "cropCancel"); + QPushButton &apply = control<QPushButton>(bar, "cropApply"); + QCOMPARE(apply.text(), QString("Apply Crop")); + QVERIFY(cancel.x() < apply.x()); + session.setCropRect(QRectF(10, 10, 40, 30)); + QTest::mouseClick(&cancel, Qt::LeftButton); + QCOMPARE(session.cropRect(), std::nullopt); + // Without a frame drawn, neither has work to do. + QVERIFY(!cancel.isEnabled() && !apply.isEnabled()); + session.setCropRect(QRectF(10, 10, 40, 30)); + QVERIFY(cancel.isEnabled() && apply.isEnabled()); + QTest::mouseClick(&apply, Qt::LeftButton); + QTRY_COMPARE(session.document().value().width, 40); + QCOMPARE(session.document().value().height, 30); + QVERIFY(!apply.isEnabled()); +} + +void CropControlsTests::itRestsWhileBusyOrEmpty() +{ + EditorSession session; + CropControls bar(session); + QVERIFY(!bar.isEnabled()); + session.createDocument(100, 100); + QVERIFY(bar.isEnabled()); + // Busy past its delay, the bar rests with the rest. + session.setIsProjectBusy(true); + QVERIFY(bar.isEnabled()); + QTRY_VERIFY(session.showsBusy()); + QVERIFY(!bar.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(bar.isEnabled()); +} + +QTEST_MAIN(CropControlsTests) +#include "CropControlsTests.moc" diff --git a/tests/CropFailureTests.cpp b/tests/CropFailureTests.cpp new file mode 100644 index 0000000..a6dfb34 --- /dev/null +++ b/tests/CropFailureTests.cpp @@ -0,0 +1,185 @@ +#include "AddressSpaceLimit.h" +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include <QSemaphore> +#include <QtConcurrent> +#include <QtTest> +#include <malloc.h> + +// Crops when memory runs out, in a binary alone. +class CropFailureTests : public QObject { + Q_OBJECT +private slots: + void aResizeThatFindsNoMemoryIsARenderError(); + void aCropThatFindsNoMemorySaysWhyAndKeepsTheProject(); + void aCropThatCannotTakeItsSnapshotSaysWhy(); + void aCropThatCannotLandSaysWhyAndKeepsTheProject(); + void aNewSizeLandsWholeOrNotAtAll(); +}; + +namespace { +QString renderError() +{ + return QString::fromUtf8(ExportError(ExportError::Kind::render).what()); +} + +// A thousand layers: the resize's records need a megabyte. +ProjectSnapshot manyLayers() +{ + ProjectSnapshot snapshot{.manifest = {.documentID = QUuid::createUuid(), .width = 400, .height = 300, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + for (int index = 0; index < 1024; ++index) + snapshot.manifest.layers.push_back({.id = QUuid::createUuid(), .name = QStringLiteral("Blank"), .isVisible = true, + .transform = {.origin = {0, 0}, .size = {400, 300}}, .imageFile = std::nullopt}); + snapshot.manifest.activeLayerID = snapshot.manifest.layers.front().id; + return snapshot; +} +} + +void CropFailureTests::aResizeThatFindsNoMemoryIsARenderError() +{ + const ProjectSnapshot snapshot = manyLayers(); + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, CanvasResizer::resize(snapshot, {.width = 200, .height = 100})); +} + +void CropFailureTests::aCropThatFindsNoMemorySaysWhyAndKeepsTheProject() +{ + EditorSession session; + session.installProject(manyLayers(), QStringLiteral("Many.comp")); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(10, 10, 200, 100)); + const CanvasDocument before = session.document().value(); + const int steps = session.history.undoCount(); + // The worker waits until the limit is in place. + QThreadPool *pool = QThreadPool::globalInstance(); + pool->setMaxThreadCount(1); + QSemaphore entered, release; + const QFuture<void> held = QtConcurrent::run([&] { + entered.release(); + release.acquire(); + }); + entered.acquire(); + bool done = false; + session.commitCrop([&] { done = true; }); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + release.release(); + pool->waitForDone(); + } + QTRY_VERIFY(done); + QCOMPARE(session.cropError(), std::optional(renderError())); + QVERIFY(!session.isProjectBusy()); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, 10, 200, 100))); + QVERIFY(session.document().value() == before); + QCOMPARE(session.history.undoCount(), steps); + // With room again, the same frame crops. + session.setCropError(std::nullopt); + done = false; + session.commitCrop([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session.document().value().width, 200); + QCOMPARE(session.history.undoCount(), steps + 1); +} + +void CropFailureTests::aCropThatCannotTakeItsSnapshotSaysWhy() +{ + EditorSession session; + session.installProject(manyLayers(), QStringLiteral("Many.comp")); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(10, 10, 200, 100)); + const CanvasDocument before = session.document().value(); + bool done = false; + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + session.commitCrop([&] { done = true; }); + } + // Refused at once: nothing started, nothing is busy. + QCOMPARE(session.cropError(), std::optional(renderError())); + QVERIFY(!session.isProjectBusy()); + QTRY_VERIFY(done); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, 10, 200, 100))); + QVERIFY(session.document().value() == before); +} + +void CropFailureTests::aCropThatCannotLandSaysWhyAndKeepsTheProject() +{ + EditorSession session; + session.installProject(manyLayers(), QStringLiteral("Many.comp")); + // A step before: a failed landing leaves it undoable. + session.renameLayer(session.activeLayerID().value(), QStringLiteral("Renamed")); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(10, 10, 200, 100)); + const CanvasDocument before = session.document().value(); + const int steps = session.history.undoCount(); + bool done = false; + session.commitCrop([&] { done = true; }); + // The worker finishes with room; its result lands without. + QThreadPool::globalInstance()->waitForDone(); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QTRY_VERIFY(done); + } + QCOMPARE(session.cropError(), std::optional(renderError())); + QVERIFY(!session.isProjectBusy()); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, 10, 200, 100))); + QVERIFY(session.document().value() == before); + QCOMPARE(session.history.undoCount(), steps); + QCOMPARE(session.history.undoName(), QString("Rename Layer")); + QVERIFY(session.canUndo()); + session.setCropError(std::nullopt); + done = false; + session.commitCrop([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session.document().value().width, 200); + QCOMPARE(session.history.undoName(), QString("Crop")); + session.undo(); + QVERIFY(session.document().value() == before); +} + +void CropFailureTests::aNewSizeLandsWholeOrNotAtAll() +{ + const ProjectSnapshot source = manyLayers(); + const ProjectSnapshot smaller = CanvasResizer::resize(source, {.width = 200, .height = 100}); + // Each room: it lands with its step, or nothing moves. + bool refused = false; + for (qint64 room = 0; room <= 16ll * 1024 * 1024; room += 64 * 1024) { + EditorSession session; + session.installProject(source, QStringLiteral("Many.comp")); + session.renameLayer(session.activeLayerID().value(), QStringLiteral("Renamed")); + const CanvasDocument before = session.document().value(); + try { + malloc_trim(0); + const AddressSpaceLimit limit(room); + session.applyDocumentSize(smaller, QStringLiteral("Canvas Size")); + } catch (const std::bad_alloc &) { + refused = true; + QVERIFY(session.document().value() == before); + QCOMPARE(session.history.undoName(), QString("Rename Layer")); + QVERIFY(session.canUndo()); + continue; + } + QVERIFY(refused); + QCOMPARE(session.document().value().width, 200); + QCOMPARE(session.history.undoName(), QString("Canvas Size")); + return; + } + QFAIL("the size never landed"); +} + +int main(int argc, char **argv) +{ + // Before any thread: workers share the heap the limit counts. + mallopt(M_ARENA_MAX, 1); + // Large asks always map memory. + mallopt(M_MMAP_THRESHOLD, 64 * 1024); + QCoreApplication app(argc, argv); + CropFailureTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "CropFailureTests.moc" diff --git a/tests/CropFixtures.h b/tests/CropFixtures.h new file mode 100644 index 0000000..f125533 --- /dev/null +++ b/tests/CropFixtures.h @@ -0,0 +1,14 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QtTest> +#include <stdexcept> + +// Commits the frame and waits: the canvas the crop leaves. +inline QRectF committed(EditorSession &session) +{ + bool done = false; + session.commitCrop([&] { done = true; }); + if (!QTest::qWaitFor([&] { return done; })) + throw std::runtime_error("the crop never finished"); + return QRectF(QPointF(0, 0), session.document().value().size()); +} diff --git a/tests/CropSessionTests.cpp b/tests/CropSessionTests.cpp new file mode 100644 index 0000000..7d37018 --- /dev/null +++ b/tests/CropSessionTests.cpp @@ -0,0 +1,190 @@ +#include "CanvasFixtures.h" +#include "CropFixtures.h" +#include "IO/ProjectController.h" +#include <QTemporaryDir> +#include <QtTest> + +// The crop in the session: failures, turns, signals, hooks, steps. +class CropSessionTests : public QObject { + Q_OBJECT +private slots: + void aFailedCropSaysWhy(); + void aCropWaitsItsTurn(); + void theSettersAnnounceChangesAlone(); + void otherWorkEndsTheFrame(); + void aFlippedFrameCropsAsCoreGraphicsReadsIt(); + void aCropIsOneStepThatEndsRedoAndMarksTheProject(); +}; + +void CropSessionTests::aFailedCropSaysWhy() +{ + EditorSession session; + session.createDocument(100, 100); + session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red")); + LayerTransform far = session.activeLayer().value().transform; + far.origin = QPointF(999'000, 0); + session.beginTransform(); + session.previewTransform(far); + session.commitTransform(); + const std::optional<CanvasDocument> before = session.document(); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(-5'000, 0, 50, 50)); + committed(session); + QVERIFY(session.document() == before); + QCOMPARE(session.cropError(), std::optional(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()))); + // The frame stays for another try; the project is free. + QCOMPARE(session.cropRect(), std::optional(QRectF(-5'000, 0, 50, 50))); + QVERIFY(!session.isProjectBusy()); + QVERIFY(session.canStartProjectOperation()); +} + +void CropSessionTests::aCropWaitsItsTurn() +{ + EditorSession session; + session.createDocument(100, 100); + session.selectTool(NavigationTool::crop); + // Nothing drawn, or busy: refused, the callback still comes. + session.cancelCrop(); + const QString step = session.history.undoName(); + QCOMPARE(committed(session), QRectF(0, 0, 100, 100)); + QCOMPARE(session.history.undoName(), step); + session.setCropRect(QRectF(0, 0, 0.5, 10)); + QCOMPARE(committed(session), QRectF(0, 0, 100, 100)); + QCOMPARE(session.cropError(), std::nullopt); + session.setCropRect(QRectF(0, 0, 50, 50)); + session.setIsProjectBusy(true); + bool done = false; + session.commitCrop([&] { done = true; }); + QVERIFY(!done); + QTRY_VERIFY(done); + QCOMPARE(session.document().value().width, 100); + session.setIsProjectBusy(false); + // Running, it holds the project; the step lands before `done`. + done = false; + session.commitCrop([&] { + QCOMPARE(session.document().value().width, 50); + QVERIFY(!session.isProjectBusy()); + done = true; + }); + QVERIFY(session.isProjectBusy()); + QTRY_VERIFY(done); + QCOMPARE(session.cropRect(), std::nullopt); + QCOMPARE(session.visibleCropRect(), std::optional(QRectF(0, 0, 50, 50))); + QVERIFY(session.canEditLayers()); +} + +void CropSessionTests::theSettersAnnounceChangesAlone() +{ + EditorSession session; + session.createDocument(10, 10); + int announced = 0; + QObject::connect(&session, &EditorSession::changed, [&] { ++announced; }); + session.setCropRect(QRectF(1, 1, 5, 5)); + session.setCropRect(QRectF(1, 1, 5, 5)); + session.setCropRatioChoice("1:1"); + session.setCropRatioChoice("1:1"); + session.setCropError(QString("no")); + session.setCropError(QString("no")); + QCOMPARE(announced, 3); + session.cancelCrop(); + session.cancelCrop(); + session.setCropError(std::nullopt); + QCOMPARE(announced, 5); + QCOMPARE(session.cropRect(), std::nullopt); + QCOMPARE(session.cropError(), std::nullopt); +} + +void CropSessionTests::otherWorkEndsTheFrame() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(100, 100); + session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red")); + // The tool opens freely on the whole canvas; layers rest. + session.setCropRatioChoice("1:1"); + session.selectTool(NavigationTool::crop); + QCOMPARE(session.cropRatioChoice(), QString("Free")); + QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 100, 100))); + QVERIFY(!session.canEditLayers()); + // Chosen again, a drawn frame and its ratio stay. + session.setCropRect(QRectF(5, 5, 20, 20)); + session.setCropRatioChoice("4:3"); + session.selectTool(NavigationTool::crop); + QCOMPARE(session.cropRect(), std::optional(QRectF(5, 5, 20, 20))); + QCOMPARE(session.cropRatioChoice(), QString("4:3")); + const auto ends = [&](const char *what, const std::function<void()> &work) { + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(5, 5, 20, 20)); + work(); + QVERIFY2(!session.cropRect(), what); + }; + ends("a transform", [&] { + session.beginTransform(); + session.cancelTransform(); + }); + ends("Levels", [&] { + session.beginLevels(); + QVERIFY(session.levels()); + session.cancelLevels(); + }); + ends("a filter", [&] { + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(session.filterEdit()); + session.cancelFilter(); + }); + QImage picture(4, 4, QImage::Format_RGBA8888); + picture.fill(Qt::blue); + QVERIFY(picture.save(folder.filePath("Blue.png"))); + ends("an import", [&] { + bool done = false; + session.importImages({QUrl::fromLocalFile(folder.filePath("Blue.png"))}, std::nullopt, [&] { done = true; }); + QVERIFY(QTest::qWaitFor([&] { return done; })); + }); + ProjectController controller(session); + session.setProjectPath(folder.filePath("Crop.comp")); + ends("a save", [&] { + std::optional<bool> saved; + controller.save(false, [&](bool value) { saved = value; }); + QVERIFY(QTest::qWaitFor([&] { return saved.has_value(); })); + QVERIFY(saved.value()); + }); + ends("an undo", [&] { session.undo(); }); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + ends("an install", [&] { session.installProject(snapshot, folder.filePath("Crop.comp")); }); + ends("a close", [&] { session.clearProject(); }); +} + +void CropSessionTests::aFlippedFrameCropsAsCoreGraphicsReadsIt() +{ + EditorSession session; + session.createDocument(64, 32); + session.addBlankLayer(); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(40, 20, -32, -16)); + QCOMPARE(committed(session), QRectF(0, 0, 32, 16)); + QCOMPARE(session.document().value().layers.front().origin(), QPointF(-8, -4)); +} + +void CropSessionTests::aCropIsOneStepThatEndsRedoAndMarksTheProject() +{ + EditorSession session; + session.createDocument(64, 32); + session.addBlankLayer(); + const CanvasDocument before = session.document().value(); + session.renameLayer(session.activeLayerID().value(), QStringLiteral("Renamed")); + session.undo(); + session.history.markSaved(); + QVERIFY(session.canRedo() && !session.history.isModified()); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(8, 4, 32, 16)); + committed(session); + QVERIFY(!session.canRedo()); + QVERIFY(session.history.isModified()); + QCOMPARE(session.history.undoName(), QString("Crop")); + session.undo(); + QVERIFY(session.document().value() == before); + QVERIFY(!session.history.isModified()); +} + +QTEST_GUILESS_MAIN(CropSessionTests) +#include "CropSessionTests.moc" diff --git a/tests/CropTests.cpp b/tests/CropTests.cpp new file mode 100644 index 0000000..c4d7af0 --- /dev/null +++ b/tests/CropTests.cpp @@ -0,0 +1,320 @@ +#include "CanvasFixtures.h" +#include "CropFixtures.h" +#include "Document/Crop.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <QTemporaryDir> +#include <QtTest> +#include <set> + +// Swift's CropTests: geometry, snapping and the crop itself. +class CropTests : public QObject { + Q_OBJECT +private slots: + void dragGeometrySupportsReverseRatioMoveAndEveryHandle(); + void cropTranslatesWithoutResamplingAndUndoRestoresBounds(); + void sameSizeOffsetCropAndExpansionUseExactBounds(); + void cancellationAndViewportMappingDoNotEditDocument(); + void cropEdgesSnapToNearbyEdges(); + void snapTargetsAreTheCanvasAndLayerBounds(); + void aSymmetricDragKeepsItsMiddle(); + void snappingKeepsToTheDraggedSide(); + void framesStayOnWholePixelsWithinBounds(); + void ratiosReshapeTheFrameAboutItsMiddle(); +}; + + +void CropTests::dragGeometrySupportsReverseRatioMoveAndEveryHandle() +{ + const QRectF rect = CropGeometry::create(QPointF(100, 100), QPointF(20, 60), 2); + QCOMPARE(rect, QRectF(20, 60, 80, 40)); + const CropDrag move{QPointF(40, 70), rect, {CropDrag::Kind::move}}; + QCOMPARE(move.updated(QPointF(30, 40), std::nullopt), rect.translated(-10, -30)); + for (int index = 0; index < 8; ++index) { + const QPointF unit = LayerTransform::handles[size_t(index)]; + const QPointF start(rect.left() + unit.x() * rect.width(), rect.top() + unit.y() * rect.height()); + const CropDrag drag{start, rect, {CropDrag::Kind::resize, index}}; + const QRectF next = drag.updated(QPointF(start.x() + (unit.x() * 2 - 1) * 20, start.y() + (unit.y() * 2 - 1) * 10), 2); + QVERIFY(CropGeometry::valid(next)); + QVERIFY(std::abs(next.width() / next.height() - 2) < 0.05); + QVERIFY(next != rect); + } +} + +void CropTests::cropTranslatesWithoutResamplingAndUndoRestoresBounds() +{ + // Swift's fixture: 64 by 32, its left half red. + QTemporaryDir folder; + QImage fixture(64, 32, QImage::Format_RGBA8888_Premultiplied); + fixture.fill(Qt::transparent); + QPainter(&fixture).fillRect(0, 0, 32, 32, Qt::red); + QVERIFY(fixture.save(folder.filePath("fixture.png"), "PNG")); + EditorSession session; + bool imported = false; + session.importImages({QUrl::fromLocalFile(folder.filePath("fixture.png"))}, std::nullopt, [&] { imported = true; }); + QTRY_VERIFY(imported); + const CanvasDocument before = session.document().value(); + const ImageIdentity image = before.layers.front().asset.value().identity(); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(8, 4, 32, 16)); + QCOMPARE(committed(session), QRectF(0, 0, 32, 16)); + QCOMPARE(session.cropRect(), std::nullopt); + QCOMPARE(session.visibleCropRect(), std::optional(QRectF(0, 0, 32, 16))); + QCOMPARE(session.document().value().layers.front().origin(), QPointF(-8, -4)); + QVERIFY(session.document().value().layers.front().asset.value().identity() == image); + QCOMPARE(session.history.undoName(), QString("Crop")); + session.undo(); + QVERIFY(session.document() == before); + session.redo(); + QCOMPARE(session.document().value().width, 32); +} + +void CropTests::sameSizeOffsetCropAndExpansionUseExactBounds() +{ + EditorSession session; + session.createDocument(100, 50); + session.addBlankLayer(); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(-20, 10, 100, 50)); + committed(session); + QCOMPARE(session.document().value().layers.front().origin(), QPointF(20, -10)); + session.setCropRect(QRectF(-10, -10, 140, 80)); + QCOMPARE(committed(session), QRectF(0, 0, 140, 80)); + QCOMPARE(session.document().value().layers.front().origin(), QPointF(30, 0)); + const QImage output = QImage::fromData(ImageExporter::pngData(session.projectSnapshot().value()), "PNG"); + QCOMPARE(output.size(), QSize(140, 80)); + QCOMPARE(output.pixelColor(0, 0).alpha(), 0); +} + +void CropTests::cancellationAndViewportMappingDoNotEditDocument() +{ + EditorSession session; + session.createDocument(1000, 500); + const std::optional<CanvasDocument> original = session.document(); + session.selectTool(NavigationTool::crop); + QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 1000, 500))); + QVERIFY(session.document() == original); + session.setCropRect(QRectF(25, 20, 200, 100)); + session.cancelCrop(); + QCOMPARE(session.visibleCropRect(), std::optional(QRectF(0, 0, 1000, 500))); + QVERIFY(session.document() == original); + session.setCropRect(QRectF(0, 0, 200, 100)); + session.selectTool(NavigationTool::move); + QVERIFY(!session.cropRect() && session.document() == original); + QCOMPARE(session.visibleCropRect(), std::nullopt); + for (const double scale : {1.0, 2.0}) { + session.viewport.resize(QSizeF(800, 600), scale, original.value().size()); + session.viewport.translate(QSizeF(37, -19)); + const QPointF point(-20, 135); + const QPointF result = session.viewport.documentPoint(session.viewport.viewPoint(point, original.value().size()), original.value().size()); + QVERIFY(std::abs(result.x() - point.x()) < 0.001 && std::abs(result.y() - point.y()) < 0.001); + } +} + +void CropTests::cropEdgesSnapToNearbyEdges() +{ + const CropSnap snap{{0, 200, 50, 150}, {0, 100, 20, 80}, 6}; + const QRectF rect(10, 10, 60, 40); + // A move lands each axis's nearest edge; its size stays. + const CropDrag move{QPointF(30, 30), rect, {CropDrag::Kind::move}}; + const QPointF movedTo(26, 34); + QCOMPARE(snap.apply(move.updated(movedTo, std::nullopt), move, movedTo, std::nullopt), QRectF(0, 20, 60, 40)); + // The bottom right corner snaps those two edges alone. + const CropDrag resize{QPointF(70, 50), rect, {CropDrag::Kind::resize, 4}}; + QCOMPARE(LayerTransform::handles[4], QPointF(1, 1)); + const QPointF near(146, 83); + QCOMPARE(snap.apply(resize.updated(near, std::nullopt), resize, near, std::nullopt), QRectF(10, 10, 140, 70)); + const QPointF far(120, 60); + QCOMPARE(snap.apply(resize.updated(far, std::nullopt), resize, far, std::nullopt), QRectF(10, 10, 110, 50)); + // A fixed ratio snaps moves alone, so it stays exact. + const QRectF ratioRect(10, 10, 138, 69); + QCOMPARE(snap.apply(ratioRect, resize, QPointF(148, 79), 2), ratioRect); + // A new frame snaps its dragged corner, never its anchor. + const CropDrag create{QPointF(52, 18), QRectF(), {CropDrag::Kind::create}}; + const QPointF dragged(147, 77); + QCOMPARE(snap.apply(create.updated(dragged, std::nullopt), create, dragged, std::nullopt), QRectF(52, 18, 98, 62)); +} + +void CropTests::snapTargetsAreTheCanvasAndLayerBounds() +{ + EditorSession session; + session.createDocument(400, 300); + session.addBlankLayer(); + session.insert(filled(100, 60, qRgba(255, 0, 0, 255), "Red")); + const SnapGuides targets = session.cropSnapTargets(); + QCOMPARE(std::set<double>(targets.xs.begin(), targets.xs.end()), (std::set<double>{0, 400, 150, 250})); + QCOMPARE(std::set<double>(targets.ys.begin(), targets.ys.end()), (std::set<double>{0, 300, 120, 180})); + // The canvas's edges first; a layer without pixels gives none. + QCOMPARE(targets.xs, (std::vector<double>{0, 400, 150, 250})); + QCOMPARE(targets.ys, (std::vector<double>{0, 300, 120, 180})); + // A layer between pixels snaps on whole ones. + LayerTransform shifted = session.activeLayer().value().transform; + shifted.origin = QPointF(150.4, 120.6); + session.beginTransform(); + session.previewTransform(shifted); + session.commitTransform(); + QCOMPARE(session.cropSnapTargets().xs, (std::vector<double>{0, 400, 150, 250})); + QCOMPARE(session.cropSnapTargets().ys, (std::vector<double>{0, 300, 121, 181})); + shifted.origin = QPointF(150, 120); + session.beginTransform(); + session.previewTransform(shifted); + session.commitTransform(); + // A previewing blur snaps to its grown box, as shown. + session.beginFilter(FilterKind::gaussianBlur); + session.updateFilter(FilterSettings{.radius = 10}, true); + QTRY_VERIFY(session.displayedTransform(session.activeLayer().value()).origin.x() < 150); + const LayerTransform grown = session.displayedTransform(session.activeLayer().value()); + QCOMPARE(session.cropSnapTargets().xs, (std::vector<double>{0, 400, std::round(grown.origin.x()), std::round(grown.origin.x() + grown.size.width())})); + session.cancelFilter(); + // A turned layer gives its upright box; hidden ones none. + LayerTransform turned = session.activeLayer().value().transform; + turned.rotation = 90; + session.beginTransform(); + session.previewTransform(turned); + session.commitTransform(); + const SnapGuides upright = session.cropSnapTargets(); + QCOMPARE(std::set<double>(upright.xs.begin(), upright.xs.end()), (std::set<double>{0, 400, 170, 230})); + QCOMPARE(std::set<double>(upright.ys.begin(), upright.ys.end()), (std::set<double>{0, 300, 100, 200})); + session.toggleLayerVisibility(session.activeLayerID().value()); + QCOMPARE(session.cropSnapTargets().xs, (std::vector<double>{0, 400})); + QCOMPARE(EditorSession().cropSnapTargets().xs, std::vector<double>()); +} + +void CropTests::aSymmetricDragKeepsItsMiddle() +{ + // Alt grows a new frame out from the press. + QCOMPARE(CropGeometry::create(QPointF(50, 50), QPointF(60, 45), std::nullopt, true), QRectF(40, 45, 20, 10)); + QCOMPARE(CropGeometry::create(QPointF(50, 50), QPointF(60, 45), 1, true), QRectF(40, 40, 20, 20)); + QCOMPARE((CropDrag{QPointF(50, 50), QRectF(), {CropDrag::Kind::create}}.updated(QPointF(60, 45), std::nullopt, true)), QRectF(40, 45, 20, 10)); + // A resize keeps the frame's middle where it was. + const QRectF rect(10, 10, 40, 20); + const CropDrag right{QPointF(50, 20), rect, {CropDrag::Kind::resize, 3}}; + QCOMPARE(right.updated(QPointF(60, 20), std::nullopt, true), QRectF(0, 10, 60, 20)); + // A snapped edge sets the half; the other mirrors it. + const CropSnap snap{{63}, {}, 4}; + QCOMPARE(snap.apply(QRectF(0, 10, 60, 20), right, QPointF(60, 20), std::nullopt, true), QRectF(-3, 10, 66, 20)); + const CropDrag create{QPointF(50, 50), QRectF(), {CropDrag::Kind::create}}; + QCOMPARE(snap.apply(QRectF(40, 45, 20, 10), create, QPointF(60, 45), std::nullopt, true), QRectF(37, 45, 26, 10)); + // Left of the middle, the left edge sets the half. + const CropSnap left{{38}, {}, 4}; + QCOMPARE(left.apply(QRectF(40, 45, 20, 10), create, QPointF(40, 45), std::nullopt, true), QRectF(38, 45, 24, 10)); +} + +void CropTests::snappingKeepsToTheDraggedSide() +{ + const CropSnap snap{{0, 100}, {0, 100}, 5}; + const QRectF rect(20, 20, 40, 40); + // An edge's grip snaps its own axis alone. + const CropDrag top{QPointF(40, 20), rect, {CropDrag::Kind::resize, 1}}; + QCOMPARE(snap.apply(QRectF(3, 3, 40, 57), top, QPointF(10, 3), std::nullopt), QRectF(3, 0, 40, 60)); + const CropDrag left{QPointF(20, 40), rect, {CropDrag::Kind::resize, 7}}; + QCOMPARE(snap.apply(QRectF(3, 3, 57, 40), left, QPointF(3, 10), std::nullopt), QRectF(0, 3, 60, 40)); + // The pointer's edge snaps, but never past the far edge. + const CropDrag create{QPointF(98, 20), QRectF(), {CropDrag::Kind::create}}; + QCOMPARE(CropSnap({{0}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(90, 28), std::nullopt), QRectF(0, 20, 98, 8)); + QCOMPARE(CropSnap({{0, 100}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(90, 28), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{0, 100}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 10, 8)); + QCOMPARE(CropSnap({{80}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{}, {0}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 20), std::nullopt), QRectF(90, 0, 8, 28)); + QCOMPARE(CropSnap({{}, {0, 30}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 20), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{}, {100}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 8, 80)); + QCOMPARE(CropSnap({{}, {10}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 8, 8)); + // Two targets as near: the first holds. Zero tolerance: none. + const CropSnap tie{{8, 12}, {}, 5}; + QCOMPARE(tie.apply(QRectF(10, 0, 20, 20), create, QPointF(10, 0), std::nullopt), QRectF(8, 0, 22, 20)); + const CropSnap none{{0, 100}, {0, 100}, 0}; + QCOMPARE(none.apply(QRectF(3, 3, 40, 40), create, QPointF(3, 3), std::nullopt), QRectF(3, 3, 40, 40)); + const CropDrag centred{QPointF(50, 50), QRectF(), {CropDrag::Kind::create}}; + QCOMPARE(CropSnap({{0}, {}, 0}).apply(QRectF(40, 45, 23, 10), centred, QPointF(63, 50), std::nullopt, true), QRectF(40, 45, 23, 10)); + // Far-edge targets never snap; halfway, the near side wins. + QCOMPARE(CropSnap({{98}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(90, 28), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{90}, {}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{}, {28}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 20), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{}, {20}, 200}).apply(QRectF(90, 20, 8, 8), create, QPointF(98, 28), std::nullopt), QRectF(90, 20, 8, 8)); + QCOMPARE(CropSnap({{-1, 12}, {}, 3}).apply(QRectF(0, 0, 10, 10), create, QPointF(5, 20), std::nullopt), QRectF(-1, 0, 11, 10)); + QCOMPARE(CropSnap({{}, {-1, 12}, 3}).apply(QRectF(0, 0, 10, 10), create, QPointF(20, 5), std::nullopt), QRectF(0, -1, 10, 11)); + // A new frame with a ratio stays exact too. + QCOMPARE(CropSnap({{150}, {}, 6}).apply(QRectF(52, 18, 95, 95), create, QPointF(147, 113), 1), QRectF(52, 18, 95, 95)); + // Mirrored on the pointer's side; at the middle, the right. + QCOMPARE(CropSnap({{}, {}, 1}).apply(QRectF(50, 40, 1, 20), centred, QPointF(50, 60), std::nullopt, true), QRectF(49, 40, 2, 20)); + QCOMPARE(CropSnap({{}, {}, 1}).apply(QRectF(50, 40, 1, 20), centred, QPointF(49, 60), std::nullopt, true), QRectF(50, 40, 1, 20)); + QCOMPARE(CropSnap({{}, {}, 1}).apply(QRectF(40, 50, 20, 1), centred, QPointF(60, 50), std::nullopt, true), QRectF(40, 49, 20, 2)); + QCOMPARE(CropSnap({{}, {}, 1}).apply(QRectF(40, 50, 20, 1), centred, QPointF(60, 49), std::nullopt, true), QRectF(40, 50, 20, 1)); + // A move takes the smaller shift of its two edges. + const CropDrag move{QPointF(0, 0), rect, {CropDrag::Kind::move}}; + QCOMPARE(CropSnap({{0, 100}, {}, 10}).apply(QRectF(4, 0, 94, 10), move, QPointF(), std::nullopt), QRectF(6, 0, 94, 10)); + QCOMPARE(CropSnap({{}, {0, 100}, 10}).apply(QRectF(0, 4, 10, 94), move, QPointF(), std::nullopt), QRectF(0, 6, 10, 94)); + // Two edges as near: the first edge's shift holds. + QCOMPARE(CropSnap({{0, 100}, {}, 5}).apply(QRectF(2, 0, 96, 10), move, QPointF(), std::nullopt), QRectF(0, 0, 96, 10)); +} + +void CropTests::framesStayOnWholePixelsWithinBounds() +{ + QCOMPARE(CropGeometry::snapped(QRectF(10.6, 10.6, -4.8, 0.1)), QRectF(6, 11, 5, 1)); + QCOMPARE(CropGeometry::snapped(QRectF(5.6, 5, 0.2, 5)), QRectF(6, 5, 1, 5)); + const CropDrag corner{QPointF(10, 10), QRectF(0, 0, 10, 10), {CropDrag::Kind::resize, 4}}; + QCOMPARE(corner.updated(QPointF(15.4, 15.6), std::nullopt), QRectF(0, 0, 15, 16)); + QVERIFY(CropGeometry::valid(QRectF(-1'000'000, 1'000'000, 30'000, 1))); + QVERIFY(CropGeometry::valid(QRectF(0, 0, 1, 30'000))); + QVERIFY(!CropGeometry::valid(QRectF(0, 0, 30'001, 1))); + QVERIFY(!CropGeometry::valid(QRectF(0, 0, 1, 30'001))); + QVERIFY(!CropGeometry::valid(QRectF(0, 0, 0.5, 1))); + QVERIFY(!CropGeometry::valid(QRectF(0, 0, 1, 0.5))); + QVERIFY(!CropGeometry::valid(QRectF(-1'000'001, 0, 1, 1))); + QVERIFY(!CropGeometry::valid(QRectF(0, 1'000'001, 1, 1))); + QVERIFY(!CropGeometry::valid(QRectF(qQNaN(), 0, 1, 1))); + QVERIFY(!CropGeometry::valid(QRectF(0, 0, qInf(), 1))); + QVERIFY(CropGeometry::valid(QRectF(10, 10, -5, -5))); + // Either way round, a ratio keeps the drag's direction. + QCOMPARE(CropGeometry::create(QPointF(100, 100), QPointF(20, 90), 2), QRectF(20, 60, 80, 40)); + QCOMPARE(CropGeometry::create(QPointF(100, 100), QPointF(110, 20), 2), QRectF(100, 20, 160, 80)); + QCOMPARE(CropGeometry::create(QPointF(0, 0), QPointF(30, 20), 2), QRectF(0, 0, 40, 20)); +} + +void CropTests::ratiosReshapeTheFrameAboutItsMiddle() +{ + EditorSession session; + session.createDocument(300, 200); + QCOMPARE(session.cropRatio(), std::nullopt); + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(10, 20, 120, 60)); + const std::pair<const char *, std::optional<double>> ratios[] = { + {"Original", 1.5}, {"1:1", 1.0}, {"4:3", 4.0 / 3}, {"16:9", 16.0 / 9}, {"Free", std::nullopt}}; + for (const auto &[choice, ratio] : ratios) { + session.setCropRatioChoice(QString::fromLatin1(choice)); + QCOMPARE(session.cropRatio(), ratio); + } + session.setCropRatioChoice("1:1"); + session.changeCropRatio(); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, -10, 120, 120))); + session.setCropRatioChoice("16:9"); + session.changeCropRatio(); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, 16, 120, 68))); + // Free changes nothing; a frame past the bounds is refused. + session.setCropRatioChoice("Free"); + session.changeCropRatio(); + QCOMPARE(session.cropRect(), std::optional(QRectF(10, 16, 120, 68))); + EditorSession tall; + tall.createDocument(1, 30'000); + tall.selectTool(NavigationTool::crop); + tall.setCropRect(QRectF(0, 0, 2, 2)); + tall.setCropRatioChoice("Original"); + tall.changeCropRatio(); + QCOMPARE(tall.cropRect(), std::optional(QRectF(0, 0, 2, 2))); + // Without the tool there is no frame to reshape. + EditorSession idle; + idle.createDocument(10, 10); + idle.setCropRatioChoice("1:1"); + idle.changeCropRatio(); + QCOMPARE(idle.cropRect(), std::nullopt); + QCOMPARE(EditorSession().cropRatio(), std::nullopt); + EditorSession empty; + empty.setCropRatioChoice("Original"); + QCOMPARE(empty.cropRatio(), std::nullopt); + empty.selectTool(NavigationTool::crop); + QCOMPARE(empty.cropRect(), std::nullopt); + QCOMPARE(empty.visibleCropRect(), std::nullopt); +} + +QTEST_GUILESS_MAIN(CropTests) +#include "CropTests.moc" diff --git a/tests/CursorTests.cpp b/tests/CursorTests.cpp new file mode 100644 index 0000000..2533621 --- /dev/null +++ b/tests/CursorTests.cpp @@ -0,0 +1,231 @@ +#include "CanvasFixtures.h" + +// The Move tool's pointer: move, duplicate, the handles, the grip. +namespace { +QImage image(const QCursor &cursor) +{ + return cursor.pixmap().toImage(); +} + +// A hover with these modifiers, no button held. +void hover(QWidget &widget, QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) +{ + QMouseEvent event(QEvent::MouseMove, at, at, widget.mapToGlobal(at.toPoint()), Qt::NoButton, Qt::NoButton, modifiers); + QApplication::sendEvent(&widget, &event); +} + +struct Canvas : Shown { + Canvas() : Shown(QSize(400, 300), QSize(400, 300)) + { + settle(); + session.zoom(1); + session.insert(filled(100, 100, qRgba(255, 0, 0, 255), "Red")); + session.selectTool(NavigationTool::move); + canvas->synchronizeDisplay(); + } + bool shows(const QCursor &cursor) const { return image(canvas->cursor()) == image(cursor); } +}; +} + +class CursorTests : public QObject { + Q_OBJECT +private slots: + void duplicateAndResizeCursors(); + void hiddenTransformControlsLeaveOnlyMoving(); + void aDragKeepsItsCursorAndBusySpaceAndOtherToolsTheirs(); + void ctrlOverTheSelectionShowsTheScissors(); +}; + +void CursorTests::duplicateAndResizeCursors() +{ + Canvas shown; + CanvasView &canvas = *shown.canvas; + const double ratio = canvas.devicePixelRatio(); + hover(canvas, QPointF(200, 150)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + hover(canvas, QPointF(200, 150), Qt::AltModifier); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + // A press anywhere drags the active layer: Alt anywhere duplicates. + hover(canvas, QPointF(20, 20), Qt::AltModifier); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + hover(canvas, QPointF(20, 20)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + // Alt from the keyboard counts too. + QTest::keyPress(&canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + QTest::keyRelease(&canvas, Qt::Key_Alt); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + // Corners and edges resize with the box; the grip rotates. + hover(canvas, QPointF(150, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + hover(canvas, QPointF(250, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeBDiagCursor); + hover(canvas, QPointF(250, 150)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeHorCursor); + hover(canvas, QPointF(200, 200)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeVerCursor); + hover(canvas, QPointF(200, 72)); + QVERIFY(shown.shows(CanvasView::rotationCursor(ratio))); + // The move pointer carries a badge the plain arrow lacks. + QVERIFY(image(CanvasView::moveCursor(1)).pixelColor(19, 21).lightness() < 128); + QVERIFY(image(CanvasView::rotationCursor(1)).pixelColor(12, 5).lightness() < 128); + QVERIFY(image(CanvasView::rotationCursor(1)).pixelColor(12, 12).alpha() == 0); + // A hidden active layer moves nothing; Ctrl picks red. + QVERIFY(canvas.hasMouseTracking()); + const QUuid red = shown.session.activeLayerID().value(); + shown.session.insert(filled(10, 10, qRgba(0, 0, 255, 255), "Blue"), QPointF(50, 250)); + const QUuid blue = shown.session.activeLayerID().value(); + shown.session.toggleLayerVisibility(blue); + canvas.synchronizeDisplay(); + hover(canvas, QPointF(200, 150)); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + hover(canvas, QPointF(200, 150), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + hover(canvas, QPointF(200, 150)); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + QTest::keyPress(&canvas, Qt::Key_Control, Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + QTest::keyRelease(&canvas, Qt::Key_Control); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + // Released while hidden, Ctrl is read again on show. + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Control, Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + canvas.hide(); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Control); + canvas.show(); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + // With no layer at all, the plain arrow. + shown.session.selectLayers({red, blue}, red); + shown.session.deleteSelectedLayers(); + QVERIFY(shown.session.document().value().layers.empty()); + hover(canvas, QPointF(200, 150), Qt::ControlModifier); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); +} + +void CursorTests::hiddenTransformControlsLeaveOnlyMoving() +{ + Canvas shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + const double ratio = canvas.devicePixelRatio(); + QVERIFY(session.showsTransformControls()); + hover(canvas, QPointF(150, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + session.setShowsTransformControls(false); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + hover(canvas, QPointF(150, 100), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + hover(canvas, QPointF(150, 100)); + // A pending Ctrl+T transform shows its box again. + session.beginTransform(true); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + session.cancelTransform(); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); +} + +void CursorTests::aDragKeepsItsCursorAndBusySpaceAndOtherToolsTheirs() +{ + Canvas shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + const double ratio = canvas.devicePixelRatio(); + // Over a handle mid-drag, the drag's own cursor stays. + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(20, 20)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + drag(canvas, QPointF(150, 100)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(150, 100)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::AltModifier, QPoint(20, 20)); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::AltModifier, QPoint(20, 20)); + hover(canvas, QPointF(20, 20)); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + // The grip's drag keeps the rotation pointer off the grip. + session.undo(); + canvas.synchronizeDisplay(); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(200, 72)); + QVERIFY(shown.shows(CanvasView::rotationCursor(ratio))); + drag(canvas, QPointF(300, 150)); + QVERIFY(shown.shows(CanvasView::rotationCursor(ratio))); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(300, 150)); + QVERIFY(!shown.shows(CanvasView::rotationCursor(ratio))); + session.undo(); + // Busy shows the arrow, edit or not; Space the hand. + session.beginTransform(); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + session.setIsProjectBusy(true); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + session.setIsProjectBusy(false); + session.cancelTransform(); + canvas.synchronizeDisplay(); + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + QTest::keyRelease(&canvas, Qt::Key_Space); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + session.selectTool(NavigationTool::brush); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::CrossCursor); + // Space mid-resize leaves the drag its cursor. + session.selectTool(NavigationTool::move); + canvas.synchronizeDisplay(); + QTest::mousePress(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(250, 200)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + QTest::keyRelease(&canvas, Qt::Key_Space); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(250, 200)); +} + +void CursorTests::ctrlOverTheSelectionShowsTheScissors() +{ + Canvas shown; + QWidget &canvas = *shown.canvas; + const double ratio = canvas.devicePixelRatioF(); + shown.session.selectTool(NavigationTool::marquee); + shown.session.selectAll(); + shown.canvas->synchronizeDisplay(); + hover(canvas, QPointF(200, 150), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::movePixelsCursor(ratio))); + hover(canvas, QPointF(200, 150), Qt::ControlModifier | Qt::AltModifier); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + hover(canvas, QPointF(200, 150)); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); + // Scissors hang below the arrow, unlike the move badge. + QVERIFY(image(CanvasView::movePixelsCursor(1)) != image(CanvasView::moveCursor(1))); + const QColor scissors = image(CanvasView::movePixelsCursor(1)).pixelColor(13, 23); + QVERIFY(scissors.alpha() > 128 && scissors.lightness() < 128); + // Mid-drag the scissors stay, Ctrl held or not. + QTest::mousePress(&canvas, Qt::LeftButton, Qt::ControlModifier, QPoint(200, 150)); + QVERIFY(shown.session.pixelMove() && !shown.session.pixelMove()->duplicate); + QMouseEvent drag(QEvent::MouseMove, QPointF(220, 150), QPointF(220, 150), canvas.mapToGlobal(QPoint(220, 150)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&canvas, &drag); + QVERIFY(shown.shows(CanvasView::movePixelsCursor(ratio))); + // Space mid-drag keeps the drag's own cursor. + QTest::keyPress(&canvas, Qt::Key_Space); + QVERIFY(shown.shows(CanvasView::movePixelsCursor(ratio))); + QTest::keyRelease(&canvas, Qt::Key_Space); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(220, 150)); + QTRY_VERIFY(!shown.session.pixelMove()); + QCOMPARE(shown.session.history.undoName(), QString("Move Pixels")); + // Released, the plain pointer moves the outline again. + hover(canvas, QPointF(220, 150)); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); + // A copy drag keeps its cursor under Space too. + QTest::mousePress(&canvas, Qt::LeftButton, Qt::ControlModifier | Qt::AltModifier, QPoint(220, 150)); + QVERIFY(shown.session.pixelMove() && shown.session.pixelMove()->duplicate); + QTest::keyPress(&canvas, Qt::Key_Space); + QVERIFY(shown.shows(CanvasView::duplicateCursor(ratio))); + QTest::keyRelease(&canvas, Qt::Key_Space); + QTest::mouseRelease(&canvas, Qt::LeftButton, Qt::NoModifier, QPoint(220, 150)); + QTRY_VERIFY(!shown.session.pixelMove()); +} + +QTEST_MAIN(CursorTests) +#include "CursorTests.moc" diff --git a/tests/CurvesControlsTests.cpp b/tests/CurvesControlsTests.cpp new file mode 100644 index 0000000..4a7ef5f --- /dev/null +++ b/tests/CurvesControlsTests.cpp @@ -0,0 +1,306 @@ +#include "UI/CurvesControls.h" +#include <QAccessible> +#include <QApplication> +#include <QDialog> +#include <QPainter> +#include <QtTest> + +// Swift's CurvesControls: add, drag, remove, reset, channel. +namespace { +// Controls over their own settings, a pixel a unit. +struct Curves { + CurvesSettings settings; + CurvesControls controls{[this] { return settings; }, [this](const CurvesSettings &changed) { settings = changed; }}; + Curves() + { + controls.resize(255, 420); + controls.show(); + if (!QTest::qWaitForWindowExposed(&controls)) + throw std::runtime_error("the controls never showed"); + } + QWidget &graph() const { return *controls.findChild<QWidget *>(QStringLiteral("curvesGraph")); } + QPushButton &button(const QString &text) const + { + for (QPushButton *button : controls.findChildren<QPushButton *>()) { + if (button->text() == text) + return *button; + } + throw std::runtime_error(text.toStdString()); + } + QLabel &readout() const { return *controls.findChild<QLabel *>(QStringLiteral("curvesReadout")); } + const std::vector<CurvePoint> &points() const { return settings.channels.at(size_t(settings.channel)); } + void press(QPoint at, Qt::MouseButton button = Qt::LeftButton) { QTest::mousePress(&graph(), button, Qt::NoModifier, at); } + void move(QPoint to, Qt::MouseButtons buttons = Qt::LeftButton) + { + QMouseEvent event(QEvent::MouseMove, to, graph().mapToGlobal(to), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(&graph(), &event); + } + void release(QPoint at, Qt::MouseButton button = Qt::LeftButton) { QTest::mouseRelease(&graph(), button, Qt::NoModifier, at); } + void click(QPoint at) + { + press(at); + release(at); + } +}; + +// A curve with points at even x, 32 in all. +std::vector<CurvePoint> full() +{ + std::vector<CurvePoint> points{{0, 0}}; + for (int index = 1; index < 31; ++index) + points.push_back({double(index * 8), double(index * 8)}); + points.push_back({255, 255}); + return points; +} +} + +class CurvesControlsTests : public QObject { + Q_OBJECT +private slots: + void theControlsShowSwiftsParts(); + void aClickAddsAPointThatDragsBetweenItsNeighbours(); + void aPressNearAPointTakesItAndNewOnesNeedRoom(); + void removeAndResetTheChannelsCurve(); + void anotherChannelDropsTheSelection(); + void theGraphDrawsGridCurveAndPoints(); +}; + +void CurvesControlsTests::theControlsShowSwiftsParts() +{ + Curves shown; + QComboBox &channel = *shown.controls.findChild<QComboBox *>(); + QCOMPARE(channel.count(), 4); + QCOMPARE(channel.itemText(1), QString("Red")); + const auto labels = QAccessible::queryAccessibleInterface(&channel)->relations(QAccessible::Label); + QCOMPARE(labels.size(), 1); + QCOMPARE(labels.first().first->text(QAccessible::Name), QString("Channel")); + // Rows twelve apart: picker, graph, hint, readout, reset. + QWidget &graph = shown.graph(); + QCOMPARE(graph.geometry(), QRect(0, channel.geometry().bottom() + 13, 255, 260)); + QLabel *hint = nullptr; + for (QLabel *label : shown.controls.findChildren<QLabel *>()) { + if (label->text() == QStringLiteral("Click to add a point. Drag to adjust.")) + hint = label; + } + QVERIFY(hint && hint->y() == graph.geometry().bottom() + 13); + QCOMPARE(hint->font().pixelSize(), 10); + QCOMPARE(hint->foregroundRole(), QPalette::PlaceholderText); + QPushButton &remove = shown.button("Remove point"), &reset = shown.button("Reset curve"); + QVERIFY(shown.readout().isHidden() && !remove.isEnabled()); + QCOMPARE(remove.geometry().right(), 254); + QCOMPARE(remove.width(), remove.sizeHint().width()); + QCOMPARE(reset.x(), 0); + QCOMPARE(reset.width(), reset.sizeHint().width()); + QCOMPARE(reset.y(), remove.geometry().bottom() + 13); + // In a panel, Return still reaches the panel's default. + QDialog panel; + CurvesSettings settings; + CurvesControls inside([&] { return settings; }, [&](const CurvesSettings &changed) { settings = changed; }, &panel); + for (QPushButton *button : inside.findChildren<QPushButton *>()) + QVERIFY(!button->autoDefault()); +} + +void CurvesControlsTests::aClickAddsAPointThatDragsBetweenItsNeighbours() +{ + Curves shown; + shown.press(QPoint(100, 104)); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 0}, {100, 153}, {255, 255}})); + QVERIFY(!shown.readout().isHidden()); + QCOMPARE(shown.readout().text(), QString("Input 100 · Output 153")); + QVERIFY(shown.button("Remove point").isEnabled()); + // Dragged, it keeps a unit from each neighbour. + shown.move(QPoint(400, -20)); + QCOMPARE(shown.points()[1], (CurvePoint{254, 255})); + shown.move(QPoint(-50, 300)); + QCOMPARE(shown.points()[1], (CurvePoint{1, 0})); + QCOMPARE(shown.readout().text(), QString("Input 1 · Output 0")); + // Other buttons move nothing. + shown.move(QPoint(100, 104), Qt::RightButton); + QCOMPARE(shown.points()[1], (CurvePoint{1, 0})); + shown.release(QPoint(-50, 300)); + // Released: the next press away adds another. + shown.click(QPoint(200, 51)); + QCOMPARE(shown.points().size(), size_t(4)); + QVERIFY(shown.points()[2].x == 200 && shown.points()[2].y > 204.9); + // The readout cuts fractions, as Swift's Int(). + QCOMPARE(shown.readout().text(), QString("Input 200 · Output 204")); + // Only the left button presses. + shown.press(QPoint(150, 104), Qt::MiddleButton); + shown.release(QPoint(150, 104), Qt::MiddleButton); + QCOMPARE(shown.points().size(), size_t(4)); +} + +void CurvesControlsTests::aPressNearAPointTakesItAndNewOnesNeedRoom() +{ + Curves shown; + // Within fourteen units the end moves, up and down only. + shown.press(QPoint(5, 255)); + shown.move(QPoint(9, 208)); + shown.release(QPoint(9, 208)); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 51}, {255, 255}})); + QCOMPARE(shown.readout().text(), QString("Input 0 · Output 51")); + QVERIFY(!shown.button("Remove point").isEnabled()); + // A press past an edge reads as the edge. + shown.press(QPoint(300, 3)); + shown.move(QPoint(300, 26)); + shown.release(QPoint(300, 26)); + QCOMPARE(shown.points().back(), (CurvePoint{255, 229.5})); + shown.press(QPoint(-30, 195)); + shown.release(QPoint(-30, 195)); + QCOMPARE(shown.points().front(), (CurvePoint{0, 63.75})); + // Fourteen units off is too far to take. + shown.settings.channels[0] = {{0, 51}, {255, 255}}; + shown.click(QPoint(14, 208)); + QCOMPARE(shown.points().size(), size_t(3)); + shown.settings.channels[0] = {{0, 51}, {255, 255}}; + shown.controls.synchronize(); + // Farther, a point is added; none at the edges. + shown.click(QPoint(20, 260)); + QCOMPARE(shown.points().size(), size_t(3)); + for (const QPoint edge : {QPoint(1, 104), QPoint(254, 104), QPoint(21, 104)}) + shown.click(edge); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 51}, {20, 0}, {255, 255}})); + shown.click(QPoint(22, 104)); + QCOMPARE(shown.points().size(), size_t(4)); + // Thirty-two is the most; the right button adds none. + shown.settings.channels[0] = full(); + shown.controls.synchronize(); + shown.click(QPoint(100, 52)); + QCOMPARE(shown.points().size(), size_t(32)); + shown.settings.channels[0] = {{0, 0}, {255, 255}}; + shown.press(QPoint(100, 104), Qt::RightButton); + shown.release(QPoint(100, 104), Qt::RightButton); + QCOMPARE(shown.points().size(), size_t(2)); +} + +void CurvesControlsTests::removeAndResetTheChannelsCurve() +{ + Curves shown; + shown.settings.channels[2] = {{0, 20}, {255, 90}}; + shown.click(QPoint(100, 104)); + shown.click(QPoint(200, 52)); + shown.click(QPoint(100, 104)); + QCOMPARE(shown.readout().text(), QString("Input 100 · Output 153")); + shown.button("Remove point").click(); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 0}, {200, 204}, {255, 255}})); + QVERIFY(shown.readout().isHidden() && !shown.button("Remove point").isEnabled()); + // The last point stays: Remove rests there. + shown.click(QPoint(250, 0)); + QCOMPARE(shown.readout().text(), QString("Input 255 · Output 255")); + QVERIFY(!shown.button("Remove point").isEnabled()); + shown.click(QPoint(200, 52)); + QCOMPARE(shown.readout().text(), QString("Input 200 · Output 204")); + shown.click(QPoint(100, 104)); + QCOMPARE(shown.readout().text(), QString("Input 100 · Output 153")); + // Reset drops the selection, which would read the new end. + shown.button("Reset curve").click(); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 0}, {255, 255}})); + QVERIFY(shown.readout().isHidden()); + QCOMPARE(shown.settings.channels[2], (std::vector<CurvePoint>{{0, 20}, {255, 90}})); + // A selection gone from elsewhere removes nothing. + shown.click(QPoint(100, 104)); + shown.click(QPoint(200, 52)); + QCOMPARE(shown.readout().text(), QString("Input 200 · Output 204")); + shown.settings.channels[0] = {{0, 0}, {255, 255}}; + shown.controls.synchronize(); + QVERIFY(shown.readout().isHidden() && shown.button("Remove point").isEnabled()); + shown.button("Remove point").click(); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 0}, {255, 255}})); +} + +void CurvesControlsTests::anotherChannelDropsTheSelection() +{ + Curves shown; + shown.click(QPoint(100, 104)); + QVERIFY(shown.button("Remove point").isEnabled()); + QComboBox &channel = *shown.controls.findChild<QComboBox *>(); + channel.setFocus(); + QTest::keyClick(&channel, Qt::Key_Down); + QCOMPARE(shown.settings.channel, LevelsChannel::red); + QVERIFY(shown.readout().isHidden() && !shown.button("Remove point").isEnabled()); + // Each channel keeps its own points. + shown.click(QPoint(50, 156)); + QCOMPARE(shown.settings.channels[1].size(), size_t(3)); + QCOMPARE(shown.settings.channels[0].size(), size_t(3)); + // A change from elsewhere drops it too, mid-drag included. + shown.press(QPoint(50, 156)); + shown.settings.channel = LevelsChannel::blue; + shown.controls.synchronize(); + QCOMPARE(channel.currentText(), QString("Blue")); + QVERIFY(shown.readout().isHidden()); + // The drag starts over: far from both ends, it adds. + shown.move(QPoint(3, 3)); + QCOMPARE(shown.points().size(), size_t(3)); + QCOMPARE(shown.points().back(), (CurvePoint{255, 255})); + shown.release(QPoint(3, 3)); + // A point taken, then gone from elsewhere, drags nothing. + shown.press(QPoint(128, 130)); + QCOMPARE(shown.points().size(), size_t(4)); + shown.settings.channels[3] = {{0, 0}, {255, 255}}; + shown.controls.synchronize(); + shown.move(QPoint(200, 0)); + QCOMPARE(shown.points(), (std::vector<CurvePoint>{{0, 0}, {255, 255}})); + shown.release(QPoint(200, 0)); +} + +void CurvesControlsTests::theGraphDrawsGridCurveAndPoints() +{ + Curves shown; + const QColor window = shown.controls.palette().color(QPalette::Window); + QImage blended(1, 1, QImage::Format_ARGB32_Premultiplied); + blended.fill(window); + QPainter(&blended).fillRect(0, 0, 1, 1, QColor(0, 0, 0, 89)); + const QColor background = blended.pixelColor(0, 0); + QImage drawn = shown.graph().grab().toImage(); + QCOMPARE(drawn.pixelColor(30, 20), background); + // Quarter lines a pixel wide at 12%, the edges' too. + QCOMPARE(drawn.pixelColor(63, 20), QColor(0xa5, 0xa5, 0xa5)); + QCOMPARE(drawn.pixelColor(64, 20), QColor(0x9f, 0x9f, 0x9f)); + for (const QPoint half : {QPoint(0, 20), QPoint(254, 20), QPoint(30, 0), QPoint(30, 64), QPoint(30, 259)}) + QCOMPARE(drawn.pixelColor(half), QColor(0xa2, 0xa2, 0xa2)); + // The whole curve, two wide, antialiased. + QCOMPARE(drawn.pixelColor(128, 127), QColor(0xca, 0xca, 0xca)); + QCOMPARE(drawn.pixelColor(128, 130), QColor(0xc9, 0xc9, 0xc9)); + QCOMPARE(drawn.pixelColor(200, 55), QColor(Qt::white)); + QCOMPARE(drawn.pixelColor(200, 57), QColor(0xa9, 0xa9, 0xa9)); + // Each channel draws its own curve. + shown.settings.channel = LevelsChannel::red; + shown.settings.channels[1] = {{0, 128}, {255, 128}}; + shown.controls.synchronize(); + drawn = shown.graph().grab().toImage(); + QVERIFY(drawn.pixelColor(30, 129).red() == 255 && drawn.pixelColor(30, 229) == background); + shown.settings.channel = LevelsChannel::rgb; + // The quarter lines, a twelfth white, over it. + QVERIFY(drawn.pixelColor(64, 20).red() > background.red() && drawn.pixelColor(64, 20).red() < background.red() + 40); + QVERIFY(drawn.pixelColor(30, 65).red() > background.red()); + // The curve, white and two wide; points are white dots. + QVERIFY(drawn.pixelColor(128, 129).red() >= 250); + shown.settings.channels[0] = {{0, 0}, {100, 204}, {255, 255}}; + shown.controls.synchronize(); + drawn = shown.graph().grab().toImage(); + QCOMPARE(drawn.pixelColor(97, 50), QColor(Qt::white)); + QCOMPARE(drawn.pixelColor(95, 50), background); + // A change repaints the graph whole. + QRegion painted; + struct Spy : QObject { + QRegion ®ion; + explicit Spy(QRegion ®ion) : region(region) {} + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::Paint) + region += static_cast<QPaintEvent *>(event)->region(); + return false; + } + } spy(painted); + shown.graph().installEventFilter(&spy); + shown.settings.channels[0] = {{0, 0}, {100, 200}, {255, 255}}; + shown.controls.synchronize(); + QTRY_VERIFY(painted.contains(shown.graph().rect())); + shown.graph().removeEventFilter(&spy); + // The chosen one takes the accent. + shown.click(QPoint(100, 52)); + QCOMPARE(shown.graph().grab().toImage().pixelColor(97, 50), shown.controls.palette().color(QPalette::Highlight)); +} + +QTEST_MAIN(CurvesControlsTests) +#include "CurvesControlsTests.moc" diff --git a/tests/DesktopIntegrationTests.cpp b/tests/DesktopIntegrationTests.cpp new file mode 100644 index 0000000..07e7169 --- /dev/null +++ b/tests/DesktopIntegrationTests.cpp @@ -0,0 +1,149 @@ +#include <QDirIterator> +#include <QProcess> +#include <QTemporaryDir> +#include <QXmlStreamReader> +#include <QtTest> + +// The desktop entry, the project type, and an install. +class DesktopIntegrationTests : public QObject { + Q_OBJECT +private slots: + void theEntryOpensSwiftsDocumentTypes(); + void aProjectFolderIsAType(); + void anInstallLaysDownTheAppItsEntryIconsAndLicense(); + void aDirectInstallRegistersTheProjectType(); +}; + +namespace { +const QString id = QStringLiteral("io.github.ZacharyZhang_NY.OmaPhoto"); + +QString packaged(const QString &name) +{ + QFile file(QStringLiteral(QT_TESTCASE_SOURCEDIR "/packaging/") + name); + if (!file.open(QIODevice::ReadOnly)) + throw std::runtime_error("no packaging file " + name.toStdString()); + return QString::fromUtf8(file.readAll()); +} + +void install(const QString &root, const QString &prefix, const QList<QPair<QString, QString>> &environment) +{ + QProcess process; + QProcessEnvironment variables = QProcessEnvironment::systemEnvironment(); + // An empty value clears what the caller's environment holds. + for (const auto &[name, value] : environment) { + if (value.isEmpty()) + variables.remove(name); + else + variables.insert(name, value); + } + process.setProcessEnvironment(variables); + process.start("cmake", {"--install", QStringLiteral(QT_TESTCASE_BUILDDIR), "--prefix", prefix}); + if (!process.waitForFinished(120'000) || process.exitCode() != 0) + throw std::runtime_error("install into " + root.toStdString() + " failed: " + process.readAllStandardError().toStdString()); +} + +QStringList filesUnder(const QString &root) +{ + QStringList files; + QDirIterator walk(root, QDir::Files, QDirIterator::Subdirectories); + while (walk.hasNext()) + files << walk.next().mid(root.size() + 1); + files.sort(); + return files; +} +} + +void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes() +{ + const QStringList lines = packaged(id + ".desktop").split(QLatin1Char('\n'), Qt::SkipEmptyParts); + QCOMPARE(lines.value(0), QString("[Desktop Entry]")); + QMap<QString, QString> keys; + for (const QString &line : lines.mid(1)) + keys.insert(line.section(QLatin1Char('='), 0, 0), line.section(QLatin1Char('='), 1)); + QCOMPARE(keys.value("Type"), QString("Application")); + QCOMPARE(keys.value("Name"), QString("OmaPhoto")); + // Files named at launch reach the workspace, as Open With. + QCOMPARE(keys.value("Exec"), QString("omaphoto %F")); + QCOMPARE(keys.value("Icon"), id); + // X11 names the window class after the application. + QCOMPARE(keys.value("StartupWMClass"), QString("OmaPhoto")); + QCOMPARE(keys.value("Terminal"), QString("false")); + QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics")); + // Swift's two document types: its projects, then four pictures. + QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;")); +} + +void DesktopIntegrationTests::aProjectFolderIsAType() +{ + QXmlStreamReader xml(packaged(id + ".xml")); + QStringList seen; + while (!xml.atEnd()) { + if (xml.readNext() != QXmlStreamReader::StartElement) + continue; + const QString name = xml.name().toString(); + if (name == QLatin1String("mime-type") || name == QLatin1String("sub-class-of")) + seen << name + ":" + xml.attributes().value("type").toString(); + else if (name == QLatin1String("glob")) + seen << "glob:" + xml.attributes().value("pattern").toString(); + else if (name == QLatin1String("comment")) + seen << "comment:" + xml.readElementText(); + } + QVERIFY2(!xml.hasError(), qPrintable(xml.errorString())); + QCOMPARE(seen, (QStringList{"mime-type:application/x-compositor-project", "comment:OmaPhoto Project", "sub-class-of:inode/directory", "glob:*.comp"})); +} + +void DesktopIntegrationTests::anInstallLaysDownTheAppItsEntryIconsAndLicense() +{ + // Staged, as packages install: their hooks refresh the databases. + QTemporaryDir stage; + QStringList expected = {"usr/bin/omaphoto", "usr/share/applications/" + id + ".desktop", "usr/share/licenses/omaphoto/U-2-Net.txt", "usr/share/licenses/omaphoto/LICENSE", + "usr/share/mime/packages/" + id + ".xml"}; + for (const int size : {16, 32, 64, 128, 256, 512}) + expected << QStringLiteral("usr/share/icons/hicolor/%1x%1/apps/%2.png").arg(size).arg(id); + // Given the model, a build installs it as looked for. + const QString model = QStringLiteral(OMAPHOTO_MODEL_PATH); + if (!model.isEmpty()) + expected << "usr/share/omaphoto/u2net.onnx"; + // A bundled runtime: its library, the version's link, its notices. + const bool bundled = OMAPHOTO_BUNDLED; + const QString runtime = QStringLiteral("usr/" OMAPHOTO_LIBDIR "/omaphoto"); + if (bundled) + expected << "usr/share/licenses/omaphoto/onnxruntime/LICENSE" << "usr/share/licenses/omaphoto/onnxruntime/ThirdPartyNotices.txt"; + expected.sort(); + install(stage.path(), "/usr", {{"DESTDIR", stage.path()}}); + if (bundled) { + const QStringList files = QDir(stage.filePath(runtime)).entryList(QDir::Files | QDir::System); + QCOMPARE(files.size(), 2); + const QFileInfo link(stage.filePath(runtime + "/libonnxruntime.so.1")); + QVERIFY(link.isSymLink() && QFileInfo(link.symLinkTarget()).fileName().startsWith("libonnxruntime.so.1.")); + for (const QString &file : files) + expected << runtime + "/" + file; + expected.sort(); + } + QCOMPARE(filesUnder(stage.path()), expected); + QCOMPARE(QImage(stage.filePath("usr/share/icons/hicolor/128x128/apps/" + id + ".png")).size(), QSize(128, 128)); + QVERIFY(QFileInfo(stage.filePath("usr/bin/omaphoto")).isExecutable()); + if (!model.isEmpty()) { + const QFileInfo installed(stage.filePath("usr/share/omaphoto/u2net.onnx")); + QVERIFY(!installed.isSymLink()); + QCOMPARE(installed.size(), QFileInfo(model).size()); + } +} + +void DesktopIntegrationTests::aDirectInstallRegistersTheProjectType() +{ + // A stand-in for shared-mime-info's tool, first on the path. + QTemporaryDir tools, prefix; + QFile fake(tools.filePath("update-mime-database")); + QVERIFY(fake.open(QIODevice::WriteOnly)); + fake.write("#!/bin/sh\necho \"$@\" > \"$0.called\"\n"); + fake.close(); + QVERIFY(fake.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + install(prefix.path(), prefix.path(), {{"PATH", tools.path() + ":" + qEnvironmentVariable("PATH")}, {"DESTDIR", QString()}}); + QFile called(tools.filePath("update-mime-database.called")); + QVERIFY(called.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(called.readAll()).trimmed(), prefix.filePath("share/mime")); +} + +QTEST_GUILESS_MAIN(DesktopIntegrationTests) +#include "DesktopIntegrationTests.moc" diff --git a/tests/DialogDesk.h b/tests/DialogDesk.h new file mode 100644 index 0000000..b8151c7 --- /dev/null +++ b/tests/DialogDesk.h @@ -0,0 +1,97 @@ +#pragma once +#include <QAbstractButton> +#include <QApplication> +#include <QFileDialog> +#include <QLineEdit> +#include <QMessageBox> +#include <QDir> +#include <QTemporaryDir> +#include <QTest> +#include <QTimer> +#include <functional> + +// Answers, in order, the dialogs the code under test opens. +class DialogDesk : public QObject { +public: + // A button's text, a path, "<cancel>", "<escape>" or "<return>". + QStringList replies; + // What each dialog showed when it was answered. + QStringList seen; + // A state worth noting at that moment; may stay empty. + std::function<QString()> note; + // Typed into a panel's name field before "<cancel>". + QString typeBeforeCancel; + + DialogDesk() + { + // Panels start in their own folder, never the tree. + QDir::setCurrent(m_folder.path()); + connect(&m_timer, &QTimer::timeout, this, &DialogDesk::look); + m_timer.start(0); + } + ~DialogDesk() override { QDir::setCurrent(QDir::tempPath()); } + +private: + // One dialog a tick: an answer may delete listed widgets. + void look() + { + for (QWidget *widget : QApplication::topLevelWidgets()) { + auto *alert = qobject_cast<QMessageBox *>(widget); + auto *panel = qobject_cast<QFileDialog *>(widget); + if ((!alert && !panel) || !widget->isVisible() || widget->property("answered").toBool()) + continue; + widget->setProperty("answered", true); + const QString reply = replies.isEmpty() ? QStringLiteral("<unexpected>") : replies.takeFirst(); + const QString state = note ? QStringLiteral("|") + note() : QString(); + if (alert) + answer(alert, reply, state); + else + answer(panel, reply, state); + return; + } + } + + void answer(QMessageBox *alert, const QString &reply, const QString &state) + { + QStringList buttons; + for (const QAbstractButton *button : alert->buttons()) + buttons << button->text(); + seen << QStringLiteral("alert|%1|%2|%3|%4").arg(alert->icon()).arg(alert->text(), alert->informativeText(), buttons.join(',')) + state; + // Keys reach the button whose role takes them. + if (reply == QLatin1String("<escape>") || reply == QLatin1String("<return>")) { + QTest::keyClick(alert, reply == QLatin1String("<escape>") ? Qt::Key_Escape : Qt::Key_Return); + return; + } + for (QAbstractButton *button : alert->buttons()) { + if (button->text() == reply) { + button->click(); + return; + } + } + seen << QStringLiteral("no button named ") + reply; + alert->reject(); + } + + void answer(QFileDialog *panel, const QString &reply, const QString &state) + { + auto *name = panel->findChild<QLineEdit *>(QStringLiteral("fileNameEdit")); + seen << QStringLiteral("panel|%1|%2|%3|%4|%5") + .arg(panel->windowTitle(), panel->acceptMode() == QFileDialog::AcceptSave ? "save" : "open", + panel->fileMode() == QFileDialog::Directory ? "folder" : "file", name ? name->text() : QStringLiteral("<no field>"), + panel->defaultSuffix()) + + state; + if (!name || reply == QLatin1String("<cancel>") || reply == QLatin1String("<unexpected>")) { + if (name && !typeBeforeCancel.isEmpty()) + name->setText(typeBeforeCancel); + panel->reject(); + return; + } + // Typed like a user; several files quoted, as panels read. + const QStringList paths = reply.split(QLatin1Char('|')); + name->setText(paths.size() == 1 ? reply : QStringLiteral("\"") + paths.join(QStringLiteral("\" \"")) + QStringLiteral("\"")); + static_cast<QDialog *>(panel)->accept(); + } + + QTemporaryDir m_folder; + QTimer m_timer; +}; diff --git a/tests/DistortCanvasTests.cpp b/tests/DistortCanvasTests.cpp new file mode 100644 index 0000000..4744e15 --- /dev/null +++ b/tests/DistortCanvasTests.cpp @@ -0,0 +1,235 @@ +#include "CanvasFixtures.h" +#include "Rendering/TransformOverlay.h" +#include "UI/TransformInspector.h" + +// A distortion on the canvas: box, drags, cursor, preview. +namespace { +QImage image(const QCursor &cursor) +{ + return cursor.pixmap().toImage(); +} + +void hover(QWidget &widget, QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) +{ + QMouseEvent event(QEvent::MouseMove, at, at, widget.mapToGlobal(at.toPoint()), Qt::NoButton, Qt::NoButton, modifiers); + QApplication::sendEvent(&widget, &event); +} + +// The document fills the canvas: view points are pixels. +struct Canvas : Shown { + QUuid red; + Canvas() : Shown(QSize(400, 300), QSize(400, 300)) + { + settle(); + session.zoom(1); + session.insert(filled(100, 100, qRgba(255, 0, 0, 255), "Red")); + red = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + canvas->synchronizeDisplay(); + } + void press(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mousePress(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void move(QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QMouseEvent event(QEvent::MouseMove, to, to, canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, modifiers); + QApplication::sendEvent(canvas, &event); + } + void release(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mouseRelease(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + bool shows(const QCursor &cursor) const { return image(canvas->cursor()) == image(cursor); } +}; +} + +class DistortCanvasTests : public QObject { + Q_OBJECT +private slots: + void aDistortionsGeometryHasNoGripAndHitsItsCornersAndEdges(); + void ctrlDraggingAHandleDistortsAndKeepsDistorting(); + void theDistortCursorShowsWithCtrlOrOnceDistorted(); + void thePreviewIsDrawnAndTheNumbersRest(); + void aDistortedLayerShowsThroughItsMask(); +}; + +void DistortCanvasTests::aDistortionsGeometryHasNoGripAndHitsItsCornersAndEdges() +{ + CanvasViewport viewport; + const QSizeF size(400, 300); + viewport.resize(QSizeF(400, 300), 1, size); + viewport.setZoom(1, QPointF(200, 150), size); + QCOMPARE(viewport.viewPoint(QPointF(0, 0), size), QPointF(0, 0)); + const Corners corners = {QPointF(100, 100), QPointF(200, 100), QPointF(180, 160), QPointF(100, 160)}; + const TransformOverlayGeometry geometry(corners, viewport, size); + QVERIFY(!geometry.showsRotation); + QCOMPARE(geometry.handles[0], QPointF(100, 100)); + QCOMPARE(geometry.handles[1], QPointF(150, 100)); + QCOMPARE(geometry.handles[3], QPointF(190, 130)); + QCOMPARE(geometry.handles[6], QPointF(100, 160)); + QCOMPARE(geometry.handles[7], QPointF(100, 130)); + QCOMPARE(geometry.rotationHandle, geometry.handles[1]); + // The top's middle is a handle, not the grip. + const TransformDrag::Mode top = geometry.hit(QPointF(150, 100)).value(); + QVERIFY(top.kind == TransformDrag::Kind::resize); + QCOMPARE(top.index, 1); + QCOMPARE(geometry.hit(QPointF(185, 145)).value().index, 3); + QVERIFY(!geometry.hit(QPointF(150, 130)).has_value()); + // A box's geometry keeps its grip; the two differ. + const TransformOverlayGeometry box({.origin = {100, 100}, .size = {100, 60}}, viewport, size); + QVERIFY(box.showsRotation); + QVERIFY(!(box == geometry)); +} + +void DistortCanvasTests::ctrlDraggingAHandleDistortsAndKeepsDistorting() +{ + Canvas shown; + EditorSession &session = shown.session; + const double ratio = shown.canvas->devicePixelRatio(); + // Ctrl on the bottom right corner: that corner alone follows. + shown.press(QPointF(250, 200), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::distortCursor(ratio))); + shown.move(QPointF(260, 210), Qt::ControlModifier); + shown.move(QPointF(270, 230), Qt::ControlModifier); + const TransformEdit edit = session.transformEdit().value(); + QVERIFY(edit.persistent); + QCOMPARE(edit.corners.value(), (Corners{QPointF(150, 100), QPointF(250, 100), QPointF(270, 230), QPointF(150, 200)})); + QCOMPARE(edit.draft, layerWith(session, shown.red).transform); + shown.release(QPointF(270, 230), Qt::ControlModifier); + // The edit waits for Apply; its box has no grip. + QVERIFY(session.transformEdit().has_value()); + const TransformOverlay overlay(session); + QVERIFY(!overlay.geometry().value().showsRotation); + QCOMPARE(overlay.geometry().value().handles[4], QPointF(270, 230)); + // Once distorted, plain drags distort too; Shift keeps an axis. + shown.press(QPointF(150, 100)); + QVERIFY(shown.shows(CanvasView::distortCursor(ratio))); + shown.move(QPointF(140, 105), Qt::ShiftModifier); + shown.release(QPointF(140, 105), Qt::ShiftModifier); + QCOMPARE(session.transformEdit().value().corners.value()[0], QPointF(140, 100)); + // An edge handle moves both its corners. + shown.press(QPointF(198, 100)); + shown.move(QPointF(198, 90)); + shown.release(QPointF(198, 90)); + QCOMPARE(session.transformEdit().value().corners.value()[0], QPointF(140, 90)); + QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(250, 90)); + // Return applies: the pixels are resampled into the shape's bounds. + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.history.undoName(), QString("Distort")); + QCOMPARE(layerWith(session, shown.red).transform, (LayerTransform{.origin = {140, 90}, .size = {130, 140}})); + QCOMPARE(layerWith(session, shown.red).asset.value().size(), QSize(130, 140)); + session.undo(); + QCOMPARE(layerWith(session, shown.red).transform.size, QSizeF(100, 100)); +} + +void DistortCanvasTests::theDistortCursorShowsWithCtrlOrOnceDistorted() +{ + Canvas shown; + CanvasView &canvas = *shown.canvas; + const double ratio = canvas.devicePixelRatio(); + hover(canvas, QPointF(150, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + hover(canvas, QPointF(150, 100), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::distortCursor(ratio))); + // Ctrl off a handle picks: the move pointer stays. + hover(canvas, QPointF(200, 150), Qt::ControlModifier); + QVERIFY(shown.shows(CanvasView::moveCursor(ratio))); + hover(canvas, QPointF(150, 100)); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); + shown.session.beginTransform(); + shown.session.beginDistort(); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::distortCursor(ratio))); + // The white arrow: light where the duplicate pointer is dark. + QVERIFY(image(CanvasView::distortCursor(1)).pixelColor(8, 12).lightness() > 128); + QVERIFY(image(CanvasView::duplicateCursor(1)).pixelColor(8, 12).lightness() < 128); + shown.session.cancelTransform(); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::SizeFDiagCursor); +} + +void DistortCanvasTests::thePreviewIsDrawnAndTheNumbersRest() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"), QPointF(50, 50)); + QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100)); + QVERIFY(canvas.synchronizeDisplay()); + TransformInspector inspector(session); + QWidget &numbers = *inspector.findChild<QWidget *>("transformFields"); + QVERIFY(numbers.isEnabled()); + session.beginTransform(); + session.beginDistort(); + QVERIFY(!numbers.isEnabled()); + // The top right corner pulled out: the preview follows. + session.previewCorners({QPointF(40, 40), QPointF(80, 40), QPointF(60, 60), QPointF(40, 60)}); + QVERIFY(canvas.synchronizeDisplay()); + QImage shot = canvas.grab().toImage(); + QCOMPARE(shot.pixelColor(215, 145), QColor(Qt::red)); + QVERIFY(shot.pixelColor(225, 155) != QColor(Qt::red)); + QCOMPARE(shot.pixelColor(197, 147), QColor(Qt::red)); + // The same corners again change nothing. + QVERIFY(!canvas.synchronizeDisplay()); + session.cancelTransform(); + QVERIFY(canvas.synchronizeDisplay()); + QVERIFY(numbers.isEnabled()); + shot = canvas.grab().toImage(); + QVERIFY(shot.pixelColor(215, 145) != QColor(Qt::red)); + // An unlinked mask distorted alone shows in the layer's grid. + const QUuid red = session.activeLayerID().value(); + QImage block(20, 20, QImage::Format_Grayscale8); + block.fill(0); + for (int y = 2; y < 18; ++y) + std::fill_n(block.scanLine(y) + 2, 8, uchar(255)); + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, red, LayerMask::assetFrom(block)); + record(snapshot, red).maskLinked = false; + }); + // Installing the snapshot fits the view: back to 100%. + session.zoom(1); + QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100)); + session.selectTool(NavigationTool::move); + session.selectLayerTarget(red, true); + session.beginTransform(); + session.beginDistort(); + // The bottom left corner pulled out: the block leans left. + session.previewCorners({QPointF(40, 40), QPointF(60, 40), QPointF(60, 60), QPointF(30, 60)}); + QVERIFY(session.transformEdit().value().mask); + const std::optional<QImage> preview = session.maskDistortPreview(layerWith(session, red)); + QCOMPARE(int(preview.value().constScanLine(5)[5]), 255); + QCOMPARE(int(preview.value().constScanLine(16)[16]), 0); + canvas.synchronizeDisplay(); + shot = canvas.grab().toImage(); + QCOMPARE(shot.pixelColor(195, 145), QColor(Qt::red)); + QVERIFY(shot.pixelColor(199, 154) != QColor(Qt::red)); +} + +void DistortCanvasTests::aDistortedLayerShowsThroughItsMask() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"), QPointF(50, 50)); + const QUuid red = session.activeLayerID().value(); + // A linked mask hides the left half. + QImage half(20, 20, QImage::Format_Grayscale8); + half.fill(255); + for (int y = 0; y < 20; ++y) + std::fill_n(half.scanLine(y), 10, uchar(0)); + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, LayerMask::assetFrom(half)); }); + session.zoom(1); + session.selectTool(NavigationTool::move); + session.selectLayer(red); + session.beginTransform(); + session.beginDistort(); + session.previewCorners({QPointF(40, 40), QPointF(62, 40), QPointF(60, 60), QPointF(40, 60)}); + canvas.synchronizeDisplay(); + // The document sits at (150, 100); probes miss the handles. + const QImage shot = canvas.grab().toImage(); + QVERIFY(shot.pixelColor(196, 154) != QColor(Qt::red)); + QCOMPARE(shot.pixelColor(205, 146), QColor(Qt::red)); +} + +QTEST_MAIN(DistortCanvasTests) +#include "DistortCanvasTests.moc" diff --git a/tests/DistortMaskTests.cpp b/tests/DistortMaskTests.cpp new file mode 100644 index 0000000..24b78f7 --- /dev/null +++ b/tests/DistortMaskTests.cpp @@ -0,0 +1,239 @@ +#include "Document/Distort.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include <QtTest> + +// Masks under a distortion: along with the layer, or alone. +namespace { +// The next thumbnail fails, as an allocation might. +bool failNextScale = false; +} + +// Takes libQt6Gui's place, as ProjectReplaceTests takes libc's. +QImage QImage::scaled(const QSize &size, Qt::AspectRatioMode aspectMode, Qt::TransformationMode mode) const +{ + if (failNextScale) { + failNextScale = false; + return QImage(); + } + if (size == this->size() && aspectMode == Qt::IgnoreAspectRatio && mode == Qt::SmoothTransformation) + return *this; + // Every scale these tests make is the image's own size. + throw std::logic_error("DistortTests scale nothing"); +} + +namespace { +const Corners shape = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(10, 30)}; + +QImage red(int width, int height) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + return image; +} + +QUuid insertAt(EditorSession &session, const QImage &image, QPointF origin) +{ + session.insert(ImportedImage(image, image, "Image")); + const QUuid id = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = origin; }); + return id; +} + +int alphaAt(const EditorSession &session, int x, int y) +{ + return ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(x, y).alpha(); +} + +} + +class DistortMaskTests : public QObject { + Q_OBJECT +private slots: + void masksFollowADistortionOrStayPut(); + void anUnlinkedMaskDistortsOnItsOwn(); +}; + +void DistortMaskTests::masksFollowADistortionOrStayPut() +{ + EditorSession session; + session.createDocument(100, 60); + const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10)); + QImage half(20, 20, QImage::Format_Grayscale8); + half.fill(0); + for (int y = 0; y < 20; ++y) + std::fill_n(half.scanLine(y), 10, uchar(255)); + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(half)); }); + session.selectTool(NavigationTool::move); + session.beginTransform(false); + session.beginDistort(); + session.previewCorners(shape); + // The preview carries the linked mask, cached while unchanged. + const std::optional<DistortPreview> preview = session.distortPreview(layerWith(session, id)); + QCOMPARE(preview.value().transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}})); + QCOMPARE(preview.value().image.size(), QSize(50, 20)); + QCOMPARE(preview.value().mask.value().size(), QSize(50, 20)); + QCOMPARE(int(preview.value().mask.value().constScanLine(2)[5]), 255); + QCOMPARE(int(preview.value().mask.value().constScanLine(2)[45]), 0); + QCOMPARE(session.distortPreview(layerWith(session, id)).value().image.cacheKey(), preview.value().image.cacheKey()); + QVERIFY(!session.displayedMaskPlacement(layerWith(session, id)).has_value()); + QVERIFY(!session.maskDistortPreview(layerWith(session, id)).has_value()); + session.previewCorners({shape[0], QPointF(70, 10), shape[2], shape[3]}); + QVERIFY(session.distortPreview(layerWith(session, id)).value().image.cacheKey() != preview.value().image.cacheKey()); + session.previewCorners(shape); + session.commitTransform(); + const ImageLayer distorted = layerWith(session, id); + QCOMPARE(distorted.mask.value().asset.size(), QSize(50, 20)); + QVERIFY(!distorted.mask.value().placement.has_value()); + QCOMPARE(alphaAt(session, 15, 15), 255); + QCOMPARE(alphaAt(session, 55, 12), 0); + session.undo(); + // An unlinked mask keeps its place on the document. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskLinked = false; }); + session.beginTransform(false); + session.beginDistort(); + QCOMPARE(session.displayedMaskPlacement(layerWith(session, id)), std::optional(layerWith(session, id).transform)); + session.previewCorners(shape); + QCOMPARE(session.distortPreview(layerWith(session, id)).value().mask.value().size(), QSize(50, 20)); + session.commitTransform(); + QCOMPARE(layerWith(session, id).mask.value().placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {20, 20}})); + QCOMPARE(layerWith(session, id).mask.value().asset.size(), QSize(20, 20)); + session.undo(); + // A linked mask apart under a fold keeps its place. + const LayerTransform apart{.origin = {12, 12}, .size = {10, 10}}; + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).maskLinked = true; + record(snapshot, id).maskPlacement = apart; + }); + session.beginTransform(); + session.previewTransform({.origin = {20, 10}, .size = {20, 20}}); + session.beginDistort(); + session.previewCorners({QPointF(20, 10), QPointF(40, 10), QPointF(22, 12), QPointF(20, 30)}); + const DistortPreview folded = session.distortPreview(layerWith(session, id)).value(); + const LayerMask owned = layerWith(session, id).mask.value(); + QCOMPARE(folded.mask.value(), owned.clipImage(apart, folded.transform, folded.image.width(), folded.image.height(), 2048).value()); + session.cancelTransform(); + // A convex shape carries a placed linked mask along, resampled. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskPlacement = apart; }); + session.beginTransform(); + session.beginDistort(); + session.previewCorners(shape); + const ImageLayer before = layerWith(session, id); + const Corners carried = DistortWarp::carried(apart, before.transform, shape); + QVERIFY(DistortWarp::isConvex(carried)); + const DistortWarp::Warped expected = DistortWarp::warpMask(before.mask.value().asset.image(), apart, carried, LayerMask::background(before.mask.value().asset.thumbnail)); + const DistortPreview carriedPreview = session.distortPreview(before).value(); + QCOMPARE(carriedPreview.mask.value(), LayerMask(ImportedImage(expected.image, before.mask.value().asset.thumbnail, "m")).clipImage(expected.transform, carriedPreview.transform, 50, 20, 2048).value()); + QVERIFY(carriedPreview.mask.value() != before.mask.value().clipImage(apart, carriedPreview.transform, 50, 20, 2048).value()); + session.commitTransform(); + const LayerMask moved = layerWith(session, id).mask.value(); + QCOMPARE(moved.placement, std::optional(expected.transform)); + QCOMPARE(moved.asset.image(), expected.image); + QVERIFY(moved.isLinked); + QVERIFY(expected.transform.origin != apart.origin); + session.undo(); + // A placement too wide to warp: the layer still previews. + const LayerTransform wide{.origin = {12, 12}, .size = {40'000, 10}}; + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskPlacement = wide; }); + session.beginTransform(); + session.beginDistort(); + session.previewCorners(shape); + const DistortPreview kept = session.distortPreview(layerWith(session, id)).value(); + QCOMPARE(kept.image.size(), QSize(50, 20)); + QCOMPARE(kept.mask.value(), layerWith(session, id).mask.value().clipImage(wide, kept.transform, 50, 20, 2048).value()); +} + +void DistortMaskTests::anUnlinkedMaskDistortsOnItsOwn() +{ + EditorSession session; + session.createDocument(100, 60); + const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10)); + // A white block in a black border: black background. + QImage block(20, 20, QImage::Format_Grayscale8); + block.fill(0); + for (int y = 2; y < 18; ++y) + std::fill_n(block.scanLine(y) + 2, 8, uchar(255)); + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(block)); + record(snapshot, id).maskLinked = false; + }); + session.selectLayerTarget(id, true); + QVERIFY(session.transformTargetsMask()); + session.beginTransform(); + session.beginDistort(); + QVERIFY(session.transformEdit().value().mask && session.transformEdit().value().corners.has_value()); + // The mask shrunk into the layer's top left quarter. + const Corners quarter = {QPointF(10, 10), QPointF(20, 10), QPointF(20, 20), QPointF(10, 20)}; + session.previewCorners(quarter); + // The preview is the warped mask in the layer's grid. + const std::optional<QImage> preview = session.maskDistortPreview(layerWith(session, id)); + QCOMPARE(preview.value().size(), QSize(20, 20)); + QCOMPARE(int(preview.value().constScanLine(3)[3]), 255); + QCOMPARE(int(preview.value().constScanLine(15)[15]), 0); + QCOMPARE(session.maskDistortPreview(layerWith(session, id)).value().cacheKey(), preview.value().cacheKey()); + QVERIFY(!session.distortPreview(layerWith(session, id)).has_value()); + session.previewCorners({QPointF(10, 10), QPointF(22, 10), QPointF(20, 20), QPointF(10, 20)}); + QVERIFY(session.maskDistortPreview(layerWith(session, id)).value().cacheKey() != preview.value().cacheKey()); + session.previewCorners(quarter); + const int count = session.history.undoCount(); + session.commitTransform(); + QCOMPARE(session.history.undoName(), QString("Distort Layer Mask")); + QCOMPARE(session.history.undoCount(), count + 1); + const LayerMask mask = layerWith(session, id).mask.value(); + QCOMPARE(mask.asset.size(), QSize(10, 10)); + QCOMPARE(mask.placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {10, 10}})); + QVERIFY(!mask.isLinked); + QCOMPARE(layerWith(session, id).transform.size, QSizeF(20, 20)); + // Past the mask, its black background hides the layer. + QCOMPARE(alphaAt(session, 13, 13), 255); + QCOMPARE(alphaAt(session, 25, 25), 0); + // A mask apart keeps its place when its corners stay. + session.beginTransform(); + session.beginDistort(); + session.commitTransform(); + QCOMPARE(session.history.undoName(), QString("Distort Layer Mask")); + QCOMPARE(layerWith(session, id).mask.value().placement, std::optional(LayerTransform{.origin = {10, 10}, .size = {10, 10}})); + // A warp that fails opens no transaction: undo stays usable. + const int steps = session.history.undoCount(); + session.beginTransform(); + session.beginDistort(); + session.previewCorners({QPointF(10, 10), QPointF(40'010, 10), QPointF(40'010, 20), QPointF(10, 20)}); + session.commitTransform(); + QVERIFY(session.brushError().has_value()); + QVERIFY(!session.transformEdit().has_value() && session.canUndo()); + QCOMPARE(session.history.undoCount(), steps); + session.setBrushError(std::nullopt); + // A thumbnail failing after the warp opens no transaction. + session.beginTransform(); + session.beginDistort(); + session.previewCorners({QPointF(10, 10), QPointF(22, 10), QPointF(20, 20), QPointF(10, 20)}); + failNextScale = true; + session.commitTransform(); + QVERIFY(!failNextScale); + QVERIFY(session.brushError().has_value()); + QVERIFY(!session.transformEdit().has_value() && session.canUndo()); + QCOMPARE(session.history.undoCount(), steps); + QCOMPARE(layerWith(session, id).mask.value().asset.size(), QSize(10, 10)); + session.setBrushError(std::nullopt); + session.selectLayer(id); + session.toggleLayerVisibility(id); + QCOMPARE(session.history.undoCount(), steps + 1); + session.toggleLayerVisibility(id); + // One covering its layer stays covering it, resampled. + const QUuid other = insertAt(session, red(20, 20), QPointF(50, 30)); + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, other, LayerMask::assetFrom(block)); + record(snapshot, other).maskLinked = false; + }); + session.selectLayerTarget(other, true); + session.beginTransform(); + session.beginDistort(); + session.commitTransform(); + QCOMPARE(session.history.undoName(), QString("Distort Layer Mask")); + QCOMPARE(layerWith(session, other).mask.value().placement, std::nullopt); + QCOMPARE(layerWith(session, other).mask.value().asset.size(), QSize(20, 20)); + QVERIFY(layerWith(session, other).mask.value().asset.identity() != LayerMask::assetFrom(block).identity()); +} + +QTEST_GUILESS_MAIN(DistortMaskTests) +#include "DistortMaskTests.moc" diff --git a/tests/DistortTests.cpp b/tests/DistortTests.cpp new file mode 100644 index 0000000..13c19c1 --- /dev/null +++ b/tests/DistortTests.cpp @@ -0,0 +1,353 @@ +#include "Document/BrushStroke.h" +#include "Document/Distort.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include <QtTest> + +// Free distortion: the warp, the session's edit, masks along. +namespace { +const Corners shape = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(10, 30)}; + +QImage red(int width, int height) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + return image; +} + +QUuid insertAt(EditorSession &session, const QImage &image, QPointF origin) +{ + session.insert(ImportedImage(image, image, "Image")); + const QUuid id = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = origin; }); + return id; +} + +int alphaAt(const EditorSession &session, int x, int y) +{ + return ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(x, y).alpha(); +} + +bool near(QPointF a, QPointF b) +{ + return std::abs(a.x() - b.x()) < 1e-6 && std::abs(a.y() - b.y()) < 1e-6; +} +} + +class DistortTests : public QObject { + Q_OBJECT +private slots: + void cornersRunClockwiseFromTheTopLeft(); + void perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused(); + void distortingWarpsTheLayerIntoTheShapeAsOneUndoStep(); + void distortedLayerIsTrimmedToItsVisiblePixels(); + void foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners(); + void carriedCornersAndWarpedMasks(); + void nudgesGroupsAndRefusals(); + void mapPathCarriesCurvesFlipsAndTheFillRule(); +}; + +void DistortTests::cornersRunClockwiseFromTheTopLeft() +{ + const LayerTransform upright{.origin = {10, 20}, .size = {40, 30}}; + QCOMPARE(DistortWarp::corners(upright), (Corners{QPointF(10, 20), QPointF(50, 20), QPointF(50, 50), QPointF(10, 50)})); + // Turned, each corner is where the transform puts it. + const LayerTransform turned{.origin = {10, 20}, .size = {40, 30}, .rotation = 37, .flipX = true}; + const Corners corners = DistortWarp::corners(turned); + const std::array<QPointF, 4> unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}; + for (size_t index = 0; index < 4; ++index) + QVERIFY(near(corners[index], turned.point(unit[index]))); + QVERIFY(!near(corners[1], corners[0])); +} + +void DistortTests::perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused() +{ + const QTransform map = DistortWarp::homography(shape); + const Corners unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}; + for (size_t index = 0; index < 4; ++index) + QVERIFY2(near(map.map(unit[index]), shape[index]), qPrintable(QString("corner %1").arg(index))); + // The middle lands where the diagonals cross, a perspective. + const QPointF middle = map.map(QPointF(0.5, 0.5)); + QVERIFY(std::abs(middle.x() - 170.0 / 7) < 1e-9 && std::abs(middle.y() - 170.0 / 7) < 1e-9); + // A slanted bottom too: both perspective terms at work. + const Corners slanted = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 40), QPointF(10, 30)}; + const QTransform slant = DistortWarp::homography(slanted); + for (size_t index = 0; index < 4; ++index) + QVERIFY(near(slant.map(unit[index]), slanted[index])); + const QPointF crossing = slant.map(QPointF(0.5, 0.5)); + QVERIFY(std::abs(crossing.x() - 390.0 / 19) < 1e-9 && std::abs(crossing.y() - 490.0 / 19) < 1e-9); + QVERIFY(DistortWarp::isUsable(shape) && DistortWarp::isConvex(shape)); + // A bow tie folds: usable as two triangles, no perspective. + const Corners bowTie = {shape[0], shape[2], shape[1], shape[3]}; + const Corners collapsed = {shape[0], shape[0], shape[2], shape[3]}; + QVERIFY(DistortWarp::isUsable(bowTie) && !DistortWarp::isConvex(bowTie)); + QVERIFY(!DistortWarp::isUsable(collapsed)); + // A flat half is refused; a straight side, no perspective. + QVERIFY(!DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(20, 20)})); + QVERIFY(DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)})); + QVERIFY(!DistortWarp::isConvex({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)})); + QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(std::nan(""), 10), shape[2], shape[3]})); + QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(2'000'000, 10), shape[2], shape[3]})); + // A dent is usable but no perspective: two triangles. + const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)}; + QVERIFY(DistortWarp::isUsable(dented) && !DistortWarp::isConvex(dented)); + // Mirrored winding still counts as convex. + QVERIFY(DistortWarp::isConvex({shape[1], shape[0], shape[3], shape[2]})); +} + +void DistortTests::distortingWarpsTheLayerIntoTheShapeAsOneUndoStep() +{ + EditorSession session; + session.createDocument(100, 60); + const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10)); + session.selectTool(NavigationTool::move); + session.beginTransform(false); + session.beginDistort(); + QVERIFY(session.transformEdit().value().corners.has_value() && session.transformEdit().value().persistent); + // A collapsed corner: ignored. + session.previewCorners({shape[0], shape[0], shape[2], shape[3]}); + QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(30, 10)); + session.previewCorners(shape); + QCOMPARE(session.transformEdit().value().corners.value(), shape); + const int count = session.history.undoCount(); + session.commitTransform(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Distort")); + const LayerTransform transform = layerWith(session, id).transform; + QCOMPARE(transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}})); + QCOMPARE(layerWith(session, id).asset.value().size(), QSize(50, 20)); + QCOMPARE(alphaAt(session, 50, 12), 255); + QCOMPARE(alphaAt(session, 15, 25), 255); + QCOMPARE(alphaAt(session, 50, 28), 0); + QCOMPARE(alphaAt(session, 80, 12), 0); + session.undo(); + QCOMPARE(layerWith(session, id).transform.size, QSizeF(20, 20)); + QCOMPARE(layerWith(session, id).asset.value().size(), QSize(20, 20)); +} + +void DistortTests::distortedLayerIsTrimmedToItsVisiblePixels() +{ + EditorSession session; + session.createDocument(100, 60); + // A 40 by 20 layer, clear except a red square. + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.fillRect(QRect(15, 5, 10, 10), Qt::red); + painter.end(); + const QUuid id = insertAt(session, image, QPointF(10, 10)); + session.selectTool(NavigationTool::move); + session.beginTransform(false); + session.beginDistort(); + session.previewCorners({QPointF(10, 10), QPointF(60, 10), QPointF(50, 30), QPointF(10, 30)}); + session.commitTransform(); + const LayerTransform transform = layerWith(session, id).transform; + // Bounds of 50 by 20; the square's warp is smaller. + QVERIFY2(transform.size.width() < 20 && transform.size.height() <= 12, qPrintable(QString("%1 x %2").arg(transform.size.width()).arg(transform.size.height()))); + QVERIFY2(transform.origin.x() >= 20 && transform.origin.y() >= 14, qPrintable(QString("%1, %2").arg(transform.origin.x()).arg(transform.origin.y()))); + QCOMPARE(alphaAt(session, 30, 20), 255); + // The trimmed asset is the layer's own size. + QCOMPARE(QSizeF(layerWith(session, id).asset.value().size()), transform.size); +} + +void DistortTests::foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners() +{ + const LayerTransform placed{.origin = {10, 10}, .size = {20, 20}}; + QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {shape[0], shape[0], shape[2], shape[3]}, false)); + // A shape too large for a warp. + QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {QPointF(0, 0), QPointF(40'000, 0), QPointF(40'000, 10), QPointF(0, 10)}, false)); + const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)}; + const DistortWarp::Warped folded = DistortWarp::warp(red(20, 20), placed, dented, false); + // Copied without interpolation: a colour boundary stays sharp. + QImage twoTone = red(20, 20); + QPainter painter(&twoTone); + painter.fillRect(QRect(0, 0, 10, 20), Qt::blue); + painter.end(); + const Corners wide = {QPointF(10, 10), QPointF(110, 10), QPointF(20, 20), QPointF(10, 110)}; + const DistortWarp::Warped sharp = DistortWarp::warp(twoTone, placed, wide, false); + // The half is sheared: its colour boundary sits at 45.5. + QCOMPARE(sharp.image.pixelColor(43, 0), QColor(Qt::blue)); + QCOMPARE(sharp.image.pixelColor(47, 0), QColor(Qt::red)); + QCOMPARE(folded.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 50}})); + QCOMPARE(folded.image.size(), QSize(50, 50)); + QCOMPARE(folded.image.pixelColor(20, 2).alpha(), 255); + QCOMPARE(folded.image.pixelColor(2, 20).alpha(), 255); + QCOMPARE(folded.image.pixelColor(40, 4).alpha(), 0); + QCOMPARE(folded.image.pixelColor(45, 45).alpha(), 0); + // The halves meet along the diagonal without a seam. + const DistortWarp::Warped seam = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(0, 100)}, false); + for (const int along : {10, 15, 25}) + QCOMPARE(seam.image.pixelColor(along, along).alpha(), 255); + QCOMPARE(seam.image.pixelColor(50, 5).alpha(), 255); + QCOMPARE(seam.image.pixelColor(5, 50).alpha(), 255); + QCOMPARE(seam.image.pixelColor(60, 60).alpha(), 0); + // Past a half's edge nothing draws, though its map would. + const DistortWarp::Warped spill = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(10, 5)}, false); + QCOMPARE(spill.image.pixelColor(40, 10).alpha(), 255); + QCOMPARE(spill.image.pixelColor(5, 20).alpha(), 0); + QCOMPARE(spill.image.pixelColor(10, 20).alpha(), 0); + // A centre a sloping edge holds is kept; fractions too. + const DistortWarp::Warped sloping = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false); + QVERIFY(sloping.image.pixelColor(5, 0).alpha() > 200); + QVERIFY(sloping.image.pixelColor(2, 40).alpha() > 200); + // Beside the diagonal each pixel comes from its own half. + QImage patterned(20, 20, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 20; ++y) { + for (int x = 0; x < 20; ++x) + patterned.setPixelColor(x, y, QColor(12 * x, 12 * y, 0)); + } + const DistortWarp::Warped beside = DistortWarp::warp(patterned, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false); + QCOMPARE(beside.image.pixelColor(20, 20), QColor(180, 180, 0)); + // A fractional shared edge, met both ways, has no hole. + const DistortWarp::Warped shared = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.1, 0.9), QPointF(100, 0.9), QPointF(20.1, 30.9), QPointF(0.1, 100)}, false); + for (const QPoint hole : {QPoint(4, 7), QPoint(6, 10), QPoint(8, 13)}) + QCOMPARE(shared.image.pixelColor(hole).alpha(), 255); + const DistortWarp::Warped fractional = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.4, 0.4), QPointF(60.6, 0.4), QPointF(20.5, 20.5), QPointF(0.4, 60.6)}, false); + QCOMPARE(fractional.image.pixelColor(30, 5).alpha(), 255); + QCOMPARE(fractional.image.pixelColor(5, 30).alpha(), 255); + QCOMPARE(fractional.image.pixelColor(40, 40).alpha(), 0); + for (const int along : {8, 12, 16}) + QCOMPARE(fractional.image.pixelColor(along, along).alpha(), 255); + QVERIFY(fractional.image.pixelColor(30, 0).alpha() > 100); + // A row centred on a horizontal top edge is drawn. + const DistortWarp::Warped topEdge = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.5, 0.5), QPointF(100.5, 0.5), QPointF(30, 30), QPointF(0, 100)}, false); + QVERIFY(topEdge.image.pixelColor(50, 0).alpha() > 100); + // Where the halves overlap the second replaces the first. + QImage faint(20, 20, QImage::Format_RGBA8888_Premultiplied); + faint.fill(QColor(128, 0, 0, 128)); + const DistortWarp::Warped bowTie = DistortWarp::warp(faint, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(40, 40), QPointF(40, 0), QPointF(0, 40)}, false); + QCOMPARE(bowTie.image.pixelColor(20, 10).alpha(), 128); + QCOMPARE(qAlpha(bowTie.image.pixel(20, 10)), 128); + // A limit shrinks the warp; a flip sends pixels across. + const DistortWarp::Warped small = DistortWarp::warp(twoTone, placed, shape, false, 25); + QCOMPARE(small.image.size(), QSize(25, 10)); + QCOMPARE(small.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}})); + QCOMPARE(small.image.pixelColor(2, 2), QColor(Qt::blue)); + LayerTransform flipped = placed; + flipped.flipX = true; + const DistortWarp::Warped mirrored = DistortWarp::warp(twoTone, flipped, shape, false); + QCOMPARE(mirrored.image.pixelColor(2, 2), QColor(Qt::red)); + QCOMPARE(mirrored.image.pixelColor(40, 2), QColor(Qt::blue)); + // The sampling travels with the placement. + LayerTransform nearest = placed; + nearest.sampling = LayerSampling::nearest; + QCOMPARE(DistortWarp::warp(twoTone, nearest, shape, false).transform.sampling, LayerSampling::nearest); +} + +void DistortTests::carriedCornersAndWarpedMasks() +{ + const LayerTransform box{.origin = {10, 10}, .size = {20, 20}}; + // The box lands on the shape; one moved aside, aside. + const Corners same = DistortWarp::carried(box, box, shape); + for (size_t index = 0; index < 4; ++index) + QVERIFY(near(same[index], shape[index])); + const LayerTransform turned{.origin = {10, 10}, .size = {20, 20}, .rotation = 30}; + const Corners turnedSame = DistortWarp::carried(turned, turned, shape); + for (size_t index = 0; index < 4; ++index) + QVERIFY(near(turnedSame[index], shape[index])); + const Corners moved = {QPointF(20, 20), QPointF(40, 20), QPointF(40, 40), QPointF(20, 40)}; + const Corners aside = DistortWarp::carried({.origin = {15, 15}, .size = {20, 20}}, box, moved); + for (size_t index = 0; index < 4; ++index) + QVERIFY(near(aside[index], moved[index] + QPointF(5, 5))); + // A mask's background fills the shape's surround. + QImage mask(4, 4, QImage::Format_Grayscale8); + mask.fill(200); + const Corners half = {QPointF(0, 0), QPointF(8, 0), QPointF(4, 4), QPointF(0, 4)}; + const LayerTransform grid{.origin = {0, 0}, .size = {4, 4}}; + const DistortWarp::Warped lit = DistortWarp::warpMask(mask, grid, half, 1); + QCOMPARE(lit.image.size(), QSize(8, 4)); + QCOMPARE(lit.image.format(), QImage::Format_Grayscale8); + QCOMPARE(int(lit.image.constScanLine(3)[7]), 255); + QVERIFY(std::abs(int(lit.image.constScanLine(1)[1]) - 200) <= 2); + const DistortWarp::Warped dark = DistortWarp::warpMask(mask, grid, half, 0); + QCOMPARE(int(dark.image.constScanLine(3)[7]), 0); + // A uniform mask covers any shape as it is. + QImage uniform(1, 1, QImage::Format_Grayscale8); + uniform.fill(255); + const DistortWarp::Warped kept = DistortWarp::warpMask(uniform, grid, half, 1); + QCOMPARE(kept.image.cacheKey(), uniform.cacheKey()); + QCOMPARE(kept.transform, (LayerTransform{.origin = {0, 0}, .size = {8, 4}})); +} + +void DistortTests::nudgesGroupsAndRefusals() +{ + EditorSession session; + session.beginDistort(); + session.previewCorners(shape); + session.createDocument(100, 60); + const QUuid a = insertAt(session, red(20, 20), QPointF(10, 10)); + const QUuid b = insertAt(session, red(10, 10), QPointF(50, 30)); + session.selectLayer(a); + // Without an edit or corners, nothing begins or moves. + session.beginDistort(); + QVERIFY(!session.transformEdit().has_value()); + session.beginTransform(); + session.previewCorners(shape); + QVERIFY(!session.transformEdit().value().corners.has_value()); + session.beginDistort(); + session.beginDistort(); + const Corners own = DistortWarp::corners(layerWith(session, a).transform); + QCOMPARE(session.transformEdit().value().corners.value(), own); + // A nudge moves the corners; a new begin keeps them. + session.nudgeLayer(3, -2); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(13, 8)); + QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2)); + session.beginDistort(); + QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2)); + session.cancelTransform(); + QCOMPARE(layerWith(session, a).transform.origin, QPointF(10, 10)); + // A group takes the box's perspective; an unlinked mask stays. + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, b, coverage()); + record(snapshot, b).maskLinked = false; + }); + session.selectLayers({a, b}, a); + session.beginTransform(); + session.beginDistort(); + QCOMPARE(session.displayedMaskPlacement(layerWith(session, b)), std::optional(layerWith(session, b).transform)); + const Corners box = session.transformEdit().value().corners.value(); + QCOMPARE(box[0], QPointF(10, 10)); + QCOMPARE(box[2], QPointF(60, 40)); + session.previewCorners({box[0], box[1] + QPointF(20, 0), box[2], box[3]}); + QVERIFY(session.distortPreview(layerWith(session, b)).has_value()); + session.commitTransform(); + QCOMPARE(session.history.undoName(), QString("Distort Layers")); + QVERIFY(layerWith(session, a).transform.size.width() > 20); + QVERIFY(layerWith(session, b).transform.size.width() > 10); + QCOMPARE(layerWith(session, b).transform.origin.x(), 50.0); +} + +void DistortTests::mapPathCarriesCurvesFlipsAndTheFillRule() +{ + // A 10-pixel layer at 10,10, its box the shape. + const LayerTransform transform{.origin = {10, 10}, .size = {10, 10}}; + const QTransform placement = BrushRaster::pixelToDocument(transform, 10, 10); + const Corners box = DistortWarp::corners(transform); + QPainterPath ellipse; + ellipse.addEllipse(QRectF(11, 11, 4, 4)); + ellipse.setFillRule(Qt::WindingFill); + const QPainterPath carried = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, box).value(); + QCOMPARE(carried.fillRule(), Qt::WindingFill); + QCOMPARE(carried.elementCount(), ellipse.elementCount()); + for (int i = 0; i < ellipse.elementCount(); ++i) { + QCOMPARE(carried.elementAt(i).type, ellipse.elementAt(i).type); + QVERIFY(near(carried.elementAt(i), ellipse.elementAt(i))); + } + // A flipped draft mirrors it inside the shape. + LayerTransform flipped = transform; + flipped.flipX = true; + QRectF bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect(); + QVERIFY(near(bounds.topLeft(), QPointF(15, 11)) && near(bounds.bottomRight(), QPointF(19, 15))); + flipped = transform; + flipped.flipY = true; + bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect(); + QVERIFY(near(bounds.topLeft(), QPointF(11, 15)) && near(bounds.bottomRight(), QPointF(15, 19))); + // A folded shape or an empty grid carries nothing. + const Corners folded = {QPointF(10, 10), QPointF(20, 20), QPointF(20, 10), QPointF(10, 20)}; + QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, folded).has_value()); + QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(0, 10), transform, box).has_value()); +} + +QTEST_GUILESS_MAIN(DistortTests) +#include "DistortTests.moc" diff --git a/tests/DownsampleTests.cpp b/tests/DownsampleTests.cpp new file mode 100644 index 0000000..dfe5f28 --- /dev/null +++ b/tests/DownsampleTests.cpp @@ -0,0 +1,496 @@ +#include "Document/BrushStroke.h" +#include "Rendering/DownsampleCache.h" +#include "Rendering/LayerRenderer.h" +#include "AddressSpaceLimit.h" +#include <QtTest> +#include <atomic> +#include <cstdlib> +#include <functional> +#include <new> +#include <latch> +#include <thread> + +// operator new fails from this size up; QImage uses malloc. +static std::atomic<std::size_t> failingAllocationSize{SIZE_MAX}; +// And at exactly this size, on any thread. +static std::atomic<std::size_t> failingExactSize{0}; + +void *operator new(std::size_t size) +{ + if (size >= failingAllocationSize || size == failingExactSize) + throw std::bad_alloc(); + if (void *memory = std::malloc(size)) + return memory; + throw std::bad_alloc(); +} + +void *operator new[](std::size_t size) +{ + return operator new(size); +} + +// New is malloc, so free pairs; GCC 16 cannot tell. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wmismatched-new-delete" +void operator delete(void *memory) noexcept +{ + std::free(memory); +} + +void operator delete(void *memory, std::size_t) noexcept +{ + std::free(memory); +} + +void operator delete[](void *memory) noexcept +{ + std::free(memory); +} + +void operator delete[](void *memory, std::size_t) noexcept +{ + std::free(memory); +} +#pragma GCC diagnostic pop + +namespace { +// An opaque RGBA image whose gray comes from gray(x, y). +QImage image(int width, int height, const std::function<int(int, int)> &gray) +{ + QImage result = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + uchar *row = result.scanLine(y); + for (int x = 0; x < width; ++x) { + const uchar value = uchar(gray(x, y)); + row[x * 4] = row[x * 4 + 1] = row[x * 4 + 2] = value; + row[x * 4 + 3] = 255; + } + } + return result; +} + +// The image drawn by the layer renderer, High quality. +QImage drawn(const QImage &source, int width, int height) +{ + QImage surface = BrushRaster::context(width, height, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {0, 0}, .size = QSizeF(source.size()), .sampling = LayerSampling::high}; + const double scale = double(width) / source.width(); + LayerRenderer::draw(source, transform, transform.center() * scale, painter, {.scale = scale}); + painter.end(); + return surface; +} + +QImage grayMask(int width, int height, int value) +{ + QImage result = BrushRaster::context(width, height, true); + result.fill(value); + return result; +} +} + +class DownsampleTests : public QObject { + Q_OBJECT +private slots: + void halvingsAreReusedAndOnlyUsedForLargeReductions(); + void aHardEdgeStaysSharpShrunkEightTimes(); + void fineStripesAverageToFlatGrayWithoutShimmer(); + void translucentEdgesStayValidAndMasksStayGray(); + void levelsFollowTheReductionFactor(); + void halvingRoundsOddSizesUp(); + void flatColorStaysFlatAndEdgesFadeIntoTransparency(); + void fineStripesFlattenAndAHardEdgeStaysSharp(); + void ringingAlphaNeverDropsBelowItsColor(); + void everyChannelKeepsItsOwnValues(); + void aFailedScratchAllocationIsNil(); + void bandsTakeTheirScratchFromTheCaller(); + void masksRepeatTheirOddLastColumnAndRow(); + void aMaskHalvesAsItsColorTwin(); + void aMaskEdgeRepeatsItsLastColumn(); + void aPieceLinesUpWithTheWholeImage(); + void aSymmetricImageStaysSymmetric(); + void deeperRequestsExtendTheChain(); + void onePixelImagesAreNeverHalved(); + void leastRecentlyUsedCopiesLeaveFirst(); + void theRequestedCopyOutlivesItsBudget(); + void concurrentRequestsAgree(); + void aFailedHalvingStopsTheChain(); +}; + +void DownsampleTests::halvingsAreReusedAndOnlyUsedForLargeReductions() +{ + const QImage source = image(1024, 512, [](int x, int) { return x % 2 == 0 ? 0 : 255; }); + DownsampleCache &cache = DownsampleCache::shared(); + QCOMPARE(cache.imageDrawnAt(source, 0.6).cacheKey(), source.cacheKey()); + const QImage eighth = cache.imageDrawnAt(source, 0.125); + QCOMPARE(eighth.size(), QSize(128, 64)); + QCOMPARE(cache.imageDrawnAt(source, 0.3).width(), 512); + QCOMPARE(cache.imageDrawnAt(source, 0.125).cacheKey(), eighth.cacheKey()); +} + +void DownsampleTests::aHardEdgeStaysSharpShrunkEightTimes() +{ + const QImage source = image(4096, 64, [](int x, int) { return x < 2048 ? 0 : 255; }); + const QImage pixels = drawn(source, 512, 8); + int soft = 0; + for (int x = 0; x < 512; ++x) { + const int value = pixels.constScanLine(4)[x * 4]; + soft += value > 40 && value < 215; + } + QVERIFY2(soft <= 3, qPrintable(QString("the edge smears across %1 pixels").arg(soft))); + QVERIFY(pixels.constScanLine(4)[250 * 4] < 10); + QVERIFY(pixels.constScanLine(4)[262 * 4] > 245); +} + +void DownsampleTests::fineStripesAverageToFlatGrayWithoutShimmer() +{ + const QImage source = image(2048, 256, [](int x, int) { return x % 2 == 0 ? 0 : 255; }); + const QImage pixels = drawn(source, 256, 32); + // The outermost pixels fade into the transparent edge. + double sum = 0, squares = 0; + for (int x = 4; x < 252; ++x) + sum += pixels.constScanLine(16)[x * 4]; + const double mean = sum / 248; + for (int x = 4; x < 252; ++x) + squares += std::pow(pixels.constScanLine(16)[x * 4] - mean, 2); + const double spread = std::sqrt(squares / 248); + QVERIFY2(std::abs(mean - 127.5) < 8, qPrintable(QString("mean %1").arg(mean))); + QVERIFY2(spread < 6, qPrintable(QString("stripes shimmer after shrinking: spread %1").arg(spread))); +} + +void DownsampleTests::translucentEdgesStayValidAndMasksStayGray() +{ + QImage translucent = BrushRaster::context(512, 512, false); + QPainter painter(&translucent); + painter.fillRect(128, 128, 256, 256, QColor(255, 255, 255, 128)); + painter.end(); + const QImage shrunk = DownsampleCache::shared().imageDrawnAt(translucent, 0.25); + QCOMPARE(shrunk.size(), QSize(128, 128)); + QCOMPARE(shrunk.format(), QImage::Format_RGBA8888_Premultiplied); + int ringing = 0; + for (int y = 0; y < shrunk.height(); ++y) { + const uchar *row = shrunk.constScanLine(y); + for (int x = 0; x < shrunk.width(); ++x) + ringing += row[x * 4] > row[x * 4 + 3] || row[x * 4 + 1] > row[x * 4 + 3] || row[x * 4 + 2] > row[x * 4 + 3]; + } + QCOMPARE(ringing, 0); + QCOMPARE(shrunk.constScanLine(64)[64 * 4 + 3], uchar(128)); + + QImage mask = grayMask(512, 512, 0); + QPainter masking(&mask); + masking.fillRect(0, 0, 256, 512, Qt::white); + masking.end(); + const QImage level = DownsampleCache::shared().imageDrawnAt(mask, 0.25); + QCOMPARE(level.size(), QSize(128, 128)); + QCOMPARE(level.format(), QImage::Format_Grayscale8); + QCOMPARE(level.constScanLine(64)[20], uchar(255)); + QCOMPARE(level.constScanLine(64)[108], uchar(0)); +} + +void DownsampleTests::levelsFollowTheReductionFactor() +{ + QCOMPARE(DownsampleCache::level(1), 0); + QCOMPARE(DownsampleCache::level(0.5), 0); + QCOMPARE(DownsampleCache::level(0.499), 1); + QCOMPARE(DownsampleCache::level(0.26), 1); + QCOMPARE(DownsampleCache::level(0.25), 2); + QCOMPARE(DownsampleCache::level(0.125), 3); + QCOMPARE(DownsampleCache::level(1.0 / 64), 6); + QCOMPARE(DownsampleCache::level(0.0001), 6); + QCOMPARE(DownsampleCache::level(0), 0); + QCOMPARE(DownsampleCache::level(-0.25), 0); + QCOMPARE(DownsampleCache::level(std::nan("")), 0); + QCOMPARE(DownsampleCache::level(INFINITY), 0); +} + +void DownsampleTests::halvingRoundsOddSizesUp() +{ + QCOMPARE(DownsampleCache::halve(image(9, 5, [](int, int) { return 200; })).value().size(), QSize(5, 3)); + QCOMPARE(DownsampleCache::halve(grayMask(9, 5, 200)).value().size(), QSize(5, 3)); + QCOMPARE(DownsampleCache::halve(image(8, 4, [](int, int) { return 200; })).value().size(), QSize(4, 2)); + QCOMPARE(DownsampleCache::halve(image(1, 8, [](int, int) { return 200; })).value().size(), QSize(1, 4)); +} + +void DownsampleTests::flatColorStaysFlatAndEdgesFadeIntoTransparency() +{ + const QImage half = DownsampleCache::halve(image(64, 64, [](int, int) { return 200; })).value(); + QCOMPARE(half.format(), QImage::Format_RGBA8888_Premultiplied); + const uchar *middle = half.constScanLine(16) + 16 * 4; + QCOMPARE(middle[0], uchar(200)); + QCOMPARE(middle[1], uchar(200)); + QCOMPARE(middle[2], uchar(200)); + QCOMPARE(middle[3], uchar(255)); + const uchar *corner = half.constScanLine(0); + QVERIFY(corner[3] > 100 && corner[3] < 255); + QVERIFY(corner[0] <= corner[3]); +} + +void DownsampleTests::fineStripesFlattenAndAHardEdgeStaysSharp() +{ + const QImage stripes = DownsampleCache::halve(image(256, 32, [](int x, int) { return x % 2 == 0 ? 0 : 255; })).value(); + for (int x = 8; x < 120; ++x) + QVERIFY(std::abs(stripes.constScanLine(8)[x * 4] - 127) <= 3); + const QImage edge = DownsampleCache::halve(image(256, 32, [](int x, int) { return x < 128 ? 0 : 255; })).value(); + int soft = 0; + for (int x = 0; x < 128; ++x) { + const int value = edge.constScanLine(8)[x * 4]; + soft += value > 40 && value < 215; + } + QVERIFY(soft <= 2); + QVERIFY(edge.constScanLine(8)[60 * 4] < 10); + QVERIFY(edge.constScanLine(8)[67 * 4] > 245); +} + +void DownsampleTests::ringingAlphaNeverDropsBelowItsColor() +{ + QImage step = BrushRaster::context(64, 16, false); + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 64; ++x) + step.setPixel(x, y, qRgba(100, 100, 100, x < 32 ? 255 : 100)); + } + const QImage half = DownsampleCache::halve(step).value(); + int lowestAlpha = 255, brightest = 0; + for (int x = 12; x < 28; ++x) { + const uchar *pixel = half.constScanLine(4) + x * 4; + QVERIFY(pixel[0] <= pixel[3] && pixel[1] <= pixel[3] && pixel[2] <= pixel[3]); + lowestAlpha = std::min<int>(lowestAlpha, pixel[3]); + brightest = std::max<int>(brightest, pixel[0]); + } + QVERIFY(lowestAlpha < 100); + QCOMPARE(brightest, 100); +} + +void DownsampleTests::everyChannelKeepsItsOwnValues() +{ + QImage colored = BrushRaster::context(64, 64, false); + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) + colored.setPixel(x, y, x < 32 ? qRgba(10, 120, 200, 255) : qRgba(90, 30, 60, 240)); + } + const QImage half = DownsampleCache::halve(colored).value(); + const uchar *left = half.constScanLine(16) + 6 * 4, *right = half.constScanLine(16) + 26 * 4; + QCOMPARE(left[0], uchar(10)); + QCOMPARE(left[1], uchar(120)); + QCOMPARE(left[2], uchar(200)); + QCOMPARE(left[3], uchar(255)); + QCOMPARE(right[0], uchar(90)); + QCOMPARE(right[1], uchar(30)); + QCOMPARE(right[2], uchar(60)); + QCOMPARE(right[3], uchar(240)); +} + +void DownsampleTests::aFailedScratchAllocationIsNil() +{ + const QImage strip = grayMask(30000, 2, 255); + QCOMPARE(DownsampleCache::halve(strip).value().size(), QSize(15000, 1)); + failingAllocationSize = 12 * 15000 * sizeof(float); + bool halved = true, threw = false; + try { + halved = DownsampleCache::halve(strip).has_value(); + } catch (...) { + threw = true; + } + failingAllocationSize = SIZE_MAX; + QVERIFY(!threw); + QVERIFY(!halved); + DownsampleCache cache; + failingAllocationSize = 12 * 15000 * sizeof(float); + const DownsampleCache::Reduced kept = cache.imageAtLevel(strip, 1); + failingAllocationSize = SIZE_MAX; + QCOMPARE(kept.level, 0); + QCOMPARE(kept.image.cacheKey(), strip.cacheKey()); +} + +void DownsampleTests::bandsTakeTheirScratchFromTheCaller() +{ + // Ten threaded bands; any lone row of sums is refused. + const QImage colour = image(2006, 640, [](int x, int y) { return (x * 7 + y * 3) % 256; }); + failingExactSize = 1011 * 4 * sizeof(float); + std::optional<QImage> half; + bool threw = false; + try { + half = DownsampleCache::halve(colour, true); + } catch (...) { + threw = true; + } + failingExactSize = 0; + QVERIFY(!threw); + QCOMPARE(half.value().size(), QSize(1003, 320)); +} + +void DownsampleTests::masksRepeatTheirOddLastColumnAndRow() +{ + const QImage half = DownsampleCache::halve(grayMask(9, 7, 255)).value(); + QCOMPARE(half.format(), QImage::Format_Grayscale8); + QCOMPARE(half.size(), QSize(5, 4)); + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 5; ++x) + QCOMPARE(half.constScanLine(y)[x], uchar(255)); + } +} + +void DownsampleTests::aMaskHalvesAsItsColorTwin() +{ + // Uneven columns, alike on every row, gray and colour. + QImage gray(66, 20, QImage::Format_Grayscale8); + QImage color(66, 20, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < gray.height(); ++y) { + for (int x = 0; x < gray.width(); ++x) { + const int value = (x * 37) % 200 + 20; + gray.scanLine(y)[x] = uchar(value); + color.setPixelColor(x, y, QColor(value, value, value)); + } + } + for (const bool threaded : {false, true}) { + const QImage halfGray = DownsampleCache::halve(gray, threaded).value(); + const QImage halfColor = DownsampleCache::halve(color, threaded).value(); + QCOMPARE(halfGray.size(), QSize(33, 10)); + // Nothing carries from one row into the next. + for (int y = 1; y < 10; ++y) + QVERIFY(std::equal(halfGray.constScanLine(0), halfGray.constScanLine(0) + 33, halfGray.constScanLine(y))); + // Inside, where colour's padding never reaches, both agree. + for (int y = 3; y <= 6; ++y) { + for (int x = 3; x <= 29; ++x) + QCOMPARE(int(halfGray.constScanLine(y)[x]), int(halfColor.constScanLine(y)[x * 4])); + } + } +} + +void DownsampleTests::aMaskEdgeRepeatsItsLastColumn() +{ + // Black, a white last column: half the kernel lands there. + QImage mask(16, 4, QImage::Format_Grayscale8); + mask.fill(0); + for (int y = 0; y < mask.height(); ++y) + mask.scanLine(y)[15] = 255; + const QImage half = DownsampleCache::halve(mask).value(); + QCOMPARE(half.size(), QSize(8, 2)); + for (int y = 0; y < 2; ++y) { + QVERIFY2(half.constScanLine(y)[7] >= 127 && half.constScanLine(y)[7] <= 128, qPrintable(QString::number(half.constScanLine(y)[7]))); + QCOMPARE(half.constScanLine(y)[0], uchar(0)); + } +} + +void DownsampleTests::aPieceLinesUpWithTheWholeImage() +{ + const auto pattern = [](int x, int y) { return (x * 37 + y * 101 + (x / 8) * (y / 8) * 53) % 256; }; + const QImage whole = DownsampleCache::halve(image(128, 128, pattern)).value(); + const QImage piece = DownsampleCache::halve(image(64, 64, [&](int x, int y) { return pattern(x + 32, y + 32); })).value(); + for (int y = 8; y < 24; ++y) { + for (int x = 8; x < 24; ++x) + QCOMPARE(piece.pixel(x, y), whole.pixel(x + 16, y + 16)); + } +} + +void DownsampleTests::aSymmetricImageStaysSymmetric() +{ + const auto bars = [](int x, int y) { return (x >= 20 && x < 44) != (y >= 28 && y < 36) ? 255 : 0; }; + const QImage half = DownsampleCache::halve(image(64, 64, bars)).value(); + QCOMPARE(half.size(), QSize(32, 32)); + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 32; ++x) { + QCOMPARE(half.pixel(x, y), half.pixel(31 - x, y)); + QCOMPARE(half.pixel(x, y), half.pixel(x, 31 - y)); + } + } + QVERIFY(qRed(half.pixel(16, 4)) > 245); + QVERIFY(qRed(half.pixel(4, 16)) > 245); + QVERIFY(qRed(half.pixel(4, 4)) < 10); +} + +void DownsampleTests::deeperRequestsExtendTheChain() +{ + DownsampleCache cache; + const QImage source = image(64, 64, [](int x, int y) { return (x * 3 + y * 5) % 256; }); + const DownsampleCache::Reduced half = cache.imageAtLevel(source, 1); + QCOMPARE(half.level, 1); + QCOMPARE(half.image.size(), QSize(32, 32)); + const DownsampleCache::Reduced eighth = cache.imageAtLevel(source, 3); + QCOMPARE(eighth.level, 3); + QCOMPARE(eighth.image.size(), QSize(8, 8)); + QCOMPARE(eighth.image, DownsampleCache::halve(DownsampleCache::halve(half.image).value()).value()); + QCOMPARE(cache.imageAtLevel(source, 1).image.cacheKey(), half.image.cacheKey()); + QCOMPARE(cache.imageAtLevel(source, 3).image.cacheKey(), eighth.image.cacheKey()); + const DownsampleCache::Reduced capped = cache.imageAtLevel(source, 9); + QCOMPARE(capped.level, 6); + QCOMPARE(capped.image.size(), QSize(1, 1)); +} + +void DownsampleTests::onePixelImagesAreNeverHalved() +{ + DownsampleCache cache; + const QImage dot = image(1, 1, [](int, int) { return 9; }); + const DownsampleCache::Reduced same = cache.imageAtLevel(dot, 3); + QCOMPARE(same.level, 0); + QCOMPARE(same.image.cacheKey(), dot.cacheKey()); + const DownsampleCache::Reduced stopped = cache.imageAtLevel(image(4, 1, [](int, int) { return 9; }), 5); + QCOMPARE(stopped.level, 2); + QCOMPARE(stopped.image.size(), QSize(1, 1)); + const QImage wide = image(8, 8, [](int, int) { return 9; }); + const DownsampleCache::Reduced none = cache.imageAtLevel(wide, 0); + QCOMPARE(none.level, 0); + QCOMPARE(none.image.cacheKey(), wide.cacheKey()); +} + +void DownsampleTests::leastRecentlyUsedCopiesLeaveFirst() +{ + DownsampleCache cache(150); + const QImage a = image(16, 16, [](int, int) { return 1; }), b = image(16, 16, [](int, int) { return 2; }); + const QImage c = image(16, 16, [](int, int) { return 3; }); + const qint64 firstA = cache.imageAtLevel(a, 1).image.cacheKey(); + const qint64 firstB = cache.imageAtLevel(b, 1).image.cacheKey(); + QCOMPARE(cache.imageAtLevel(a, 1).image.cacheKey(), firstA); + cache.imageAtLevel(c, 1); + QCOMPARE(cache.imageAtLevel(a, 1).image.cacheKey(), firstA); + QVERIFY(cache.imageAtLevel(b, 1).image.cacheKey() != firstB); +} + +void DownsampleTests::theRequestedCopyOutlivesItsBudget() +{ + DownsampleCache cache(10); + const QImage large = image(16, 16, [](int, int) { return 1; }); + const DownsampleCache::Reduced first = cache.imageAtLevel(large, 2); + QCOMPARE(first.level, 2); + QCOMPARE(first.image.size(), QSize(4, 4)); + QCOMPARE(cache.imageAtLevel(large, 2).image.cacheKey(), first.image.cacheKey()); + QCOMPARE(cache.imageAtLevel(large, 1).image.size(), QSize(8, 8)); +} + +void DownsampleTests::concurrentRequestsAgree() +{ + DownsampleCache cache; + const QImage source = image(512, 512, [](int x, int y) { return (x ^ y) % 256; }); + const QImage expected = DownsampleCache::halve(DownsampleCache::halve(source).value()).value(); + constexpr int workers = 8; + std::latch start(1); + std::vector<QImage> results(workers); + std::vector<std::thread> threads; + for (int index = 0; index < workers; ++index) { + threads.emplace_back([&, index] { + start.wait(); + results[index] = cache.imageAtLevel(source, 2).image; + }); + } + start.count_down(); + for (std::thread &thread : threads) + thread.join(); + for (const QImage &result : results) + QCOMPARE(result, expected); + QCOMPARE(cache.imageAtLevel(source, 2).image, expected); +} + +void DownsampleTests::aFailedHalvingStopsTheChain() +{ + const QImage large = BrushRaster::context(8192, 8192, false); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + const bool halved = DownsampleCache::halve(large).has_value(); + DownsampleCache cache; + const DownsampleCache::Reduced reduced = cache.imageAtLevel(large, 2); + limit.reset(); + QVERIFY(!halved); + QCOMPARE(reduced.level, 0); + QCOMPARE(reduced.image.cacheKey(), large.cacheKey()); +} + +QTEST_APPLESS_MAIN(DownsampleTests) +#include "DownsampleTests.moc" diff --git a/tests/DrawCoverageTests.cpp b/tests/DrawCoverageTests.cpp new file mode 100644 index 0000000..89ff7db --- /dev/null +++ b/tests/DrawCoverageTests.cpp @@ -0,0 +1,340 @@ +#include "AddressSpaceLimit.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include "Rendering/LayerRenderer.h" +#include <QtTest> +#include <cstring> + +namespace { +// A mask of four columns: 0, 64, 128 and 255. +QImage tones() +{ + QImage mask(4, 1, QImage::Format_Grayscale8); + const uchar values[] = {0, 64, 128, 255}; + std::copy(std::begin(values), std::end(values), mask.scanLine(0)); + return mask; +} + +// White on its left half, black on its right. +QImage halves(int width, int height) +{ + QImage mask = gray(width, height, 0); + for (int y = 0; y < height; ++y) + std::fill_n(mask.scanLine(y), width / 2, uchar(255)); + return mask; +} +} + +class DrawCoverageTests : public QObject { + Q_OBJECT +private slots: + void coverageOnlyAddsWhite_data(); + void coverageOnlyAddsWhite(); + void thePaintersModeAndOpacityAreInherited(); + void everyModeBlendsAsUnderAClip_data(); + void everyModeBlendsAsUnderAClip(); + void rasterOperationsHaveNoCoverage(); + void aWildAngleTurnsAsItsRemainder(); + void onlyGrayMasksAreCoverage(); + void aMaskTooLargeToWhitenThrows(); + void thePlacementTurnsAndFlipsTheMask(); + void nearestKeepsEdgesHardAndSmoothSoftensThem(); + void smoothSamplingBlendsBetweenMaskPixels(); + void largeReductionsDoNotAlias(); + void oddMasksKeepTheirOverhang(); + void thePainterComesBackAsItWas(); +}; + +void DrawCoverageTests::coverageOnlyAddsWhite_data() +{ + QTest::addColumn<int>("background"); + // Each tone t gives 255 t + background(1 - t). + QTest::addColumn<QList<int>>("expected"); + QTest::newRow("black") << 0 << QList<int>{0, 64, 128, 255}; + QTest::newRow("gray") << 100 << QList<int>{100, 139, 178, 255}; + QTest::newRow("white") << 255 << QList<int>{255, 255, 255, 255}; +} + +void DrawCoverageTests::coverageOnlyAddsWhite() +{ + QFETCH(int, background); + QFETCH(QList<int>, expected); + for (const QImage::Format format : {QImage::Format_Grayscale8, QImage::Format_RGBA8888_Premultiplied}) { + QImage device(4, 1, format); + device.fill(QColor(background, background, background)); + QPainter context(&device); + LayerRenderer::drawCoverage(tones(), {.origin = {0, 0}, .size = {4, 1}, .sampling = LayerSampling::nearest}, context); + context.end(); + for (int x = 0; x < 4; ++x) { + QCOMPARE(device.pixelColor(x, 0).red(), expected[x]); + QCOMPARE(device.pixelColor(x, 0).alpha(), 255); + } + } +} + +void DrawCoverageTests::thePaintersModeAndOpacityAreInherited() +{ + const auto drawn = [](QPainter::CompositionMode mode, double opacity) { + QImage device = gray(4, 1, 100); + QPainter context(&device); + context.setCompositionMode(mode); + context.setOpacity(opacity); + LayerRenderer::drawCoverage(tones(), {.origin = {0, 0}, .size = {4, 1}, .sampling = LayerSampling::nearest}, context); + context.end(); + return QList<int>{device.constScanLine(0)[0], device.constScanLine(0)[1], device.constScanLine(0)[2], device.constScanLine(0)[3]}; + }; + // White under Multiply changes nothing, as Swift's fill. + QCOMPARE(drawn(QPainter::CompositionMode_Multiply, 1), (QList<int>{100, 100, 100, 100})); + // Half the painter's opacity is half the coverage. + QCOMPARE(drawn(QPainter::CompositionMode_SourceOver, 0.5), (QList<int>{100, 119, 139, 177})); + QCOMPARE(drawn(QPainter::CompositionMode_SourceOver, 1), (QList<int>{100, 139, 178, 255})); +} + +void DrawCoverageTests::everyModeBlendsAsUnderAClip_data() +{ + QTest::addColumn<int>("mode"); + QTest::addColumn<double>("opacity"); + for (int mode = QPainter::CompositionMode_SourceOver; mode <= QPainter::CompositionMode_Exclusion; ++mode) { + QTest::addRow("mode %d", mode) << mode << 1.0; + QTest::addRow("mode %d at half opacity", mode) << mode << 0.5; + } +} + +void DrawCoverageTests::everyModeBlendsAsUnderAClip() +{ + QFETCH(int, mode); + QFETCH(double, opacity); + // Opaque, half clear and clear rows under four mask tones. + QImage device(4, 3, QImage::Format_RGBA8888_Premultiplied); + const QRgb rows[] = {qRgba(100, 150, 200, 255), qRgba(50, 75, 100, 128), qRgba(0, 0, 0, 0)}; + QImage mask = gray(4, 3, 0); + for (int y = 0; y < 3; ++y) { + std::copy_n(tones().constScanLine(0), 4, mask.scanLine(y)); + for (int x = 0; x < 4; ++x) + std::memcpy(device.scanLine(y) + x * 4, &rows[y], 4); + } + // The oracle: opaque white under the mode, mixed by coverage. + QImage applied = device; + { + QPainter whole(&applied); + whole.setCompositionMode(QPainter::CompositionMode(mode)); + whole.setOpacity(opacity); + whole.fillRect(applied.rect(), Qt::white); + } + QImage drawn = device; + const LayerTransform whole{.origin = {0, 0}, .size = {4, 3}, .sampling = LayerSampling::nearest}; + // Seven modes are not linear in the source: no opacity. + const QList<int> erasing{QPainter::CompositionMode_Clear, QPainter::CompositionMode_Source, QPainter::CompositionMode_SourceIn, + QPainter::CompositionMode_DestinationIn, QPainter::CompositionMode_SourceOut, + QPainter::CompositionMode_DestinationAtop, QPainter::CompositionMode_Plus}; + { + QPainter context(&drawn); + context.setCompositionMode(QPainter::CompositionMode(mode)); + context.setOpacity(opacity); + if (opacity < 1 && erasing.contains(mode)) { + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawCoverage(mask, whole, context), std::logic_error); + return; + } + LayerRenderer::drawCoverage(mask, whole, context); + } + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 4; ++x) { + const int coverage = mask.constScanLine(y)[x]; + for (int channel = 0; channel < 4; ++channel) { + const int before = device.constScanLine(y)[x * 4 + channel], after = applied.constScanLine(y)[x * 4 + channel]; + const int expected = qRound((before * (255 - coverage) + after * coverage) / 255.0); + const int actual = drawn.constScanLine(y)[x * 4 + channel]; + QVERIFY2(qAbs(actual - expected) <= 2, + qPrintable(QStringLiteral("row %1 tone %2 channel %3: %4, not %5").arg(y).arg(coverage).arg(channel).arg(actual).arg(expected))); + } + } + } +} + +void DrawCoverageTests::rasterOperationsHaveNoCoverage() +{ + // A refusal must leave no saved state behind. + QTest::failOnWarning(QRegularExpression("saved states")); + QImage device = solid(4, 1, qRgba(0, 0, 0, 255)); + QPainter context(&device); + context.setCompositionMode(QPainter::RasterOp_SourceXorDestination); + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawCoverage(tones(), {.origin = {0, 0}, .size = {4, 1}}, context), std::logic_error); + context.setCompositionMode(QPainter::RasterOp_SourceOrDestination); + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawCoverage(tones(), {.origin = {0, 0}, .size = {4, 1}}, context), std::logic_error); + context.end(); +} + +void DrawCoverageTests::aWildAngleTurnsAsItsRemainder() +{ + const auto drawn = [](double rotation) { + QImage device = gray(40, 40, 0); + QPainter context(&device); + LayerRenderer::drawCoverage(halves(20, 10), {.origin = {10, 15}, .size = {20, 10}, .rotation = rotation}, context); + context.end(); + return device; + }; + // LayerTransform::radians takes the remainder first, as draw does. + QCOMPARE(drawn(1e20), drawn(std::fmod(1e20, 360))); + QCOMPARE(drawn(450), drawn(90)); + QVERIFY(drawn(1e20) != drawn(0)); +} + +void DrawCoverageTests::onlyGrayMasksAreCoverage() +{ + QImage device = gray(4, 1, 0); + QPainter context(&device); + QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawCoverage(solid(4, 1, qRgba(255, 255, 255, 255)), {.origin = {0, 0}, .size = {4, 1}}, context), + std::logic_error); +} + +void DrawCoverageTests::aMaskTooLargeToWhitenThrows() +{ + QImage device = gray(4, 4, 0); + QPainter context(&device); + // 9000 x 9000: 81 MB of gray, 324 of white. + const QImage mask = gray(9000, 9000, 255); + std::optional<ExportError::Kind> kind; + { + const AddressSpaceLimit limit(200ll * 1024 * 1024); + try { + LayerRenderer::drawCoverage(mask, {.origin = {0, 0}, .size = {9000, 9000}, .sampling = LayerSampling::nearest}, context); + } catch (const ExportError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(ExportError::Kind::render)); +} + +void DrawCoverageTests::thePlacementTurnsAndFlipsTheMask() +{ + const auto drawn = [](const LayerTransform &transform) { + QImage device = gray(40, 40, 0); + QPainter context(&device); + LayerRenderer::drawCoverage(halves(20, 10), transform, context); + return device; + }; + const LayerTransform upright{.origin = {10, 15}, .size = {20, 10}, .sampling = LayerSampling::nearest}; + const QImage plain = drawn(upright); + QCOMPARE(int(plain.constScanLine(20)[12]), 255); + QCOMPARE(int(plain.constScanLine(20)[28]), 0); + QCOMPARE(int(plain.constScanLine(14)[12]), 0); + QCOMPARE(int(plain.constScanLine(24)[19]), 255); + LayerTransform flipped = upright; + flipped.flipX = true; + QCOMPARE(int(drawn(flipped).constScanLine(20)[12]), 0); + QCOMPARE(int(drawn(flipped).constScanLine(20)[28]), 255); + // A quarter turn clockwise stands the white half on top. + LayerTransform turned = upright; + turned.rotation = 90; + QCOMPARE(int(drawn(turned).constScanLine(12)[20]), 255); + QCOMPARE(int(drawn(turned).constScanLine(28)[20]), 0); + QCOMPARE(int(drawn(turned).constScanLine(20)[12]), 0); + LayerTransform upsideDown = upright; + upsideDown.flipY = true; + upsideDown.size = {20, 10}; + QImage device = gray(40, 40, 0); + QPainter context(&device); + QImage topOnly = gray(20, 10, 0); + std::fill_n(topOnly.scanLine(0), 20, uchar(255)); + LayerRenderer::drawCoverage(topOnly, upsideDown, context); + context.end(); + QCOMPARE(int(device.constScanLine(24)[20]), 255); + QCOMPARE(int(device.constScanLine(15)[20]), 0); +} + +void DrawCoverageTests::nearestKeepsEdgesHardAndSmoothSoftensThem() +{ + // Twice the size: the mask's edge falls at device 10.5. + const auto edge = [](LayerSampling sampling) { + QImage device = gray(60, 40, 0); + QPainter context(&device); + context.scale(2, 2); + LayerRenderer::drawCoverage(gray(20, 10, 255), {.origin = {5.25, 5}, .size = {20, 10}, .sampling = sampling}, context); + context.end(); + return int(device.constScanLine(20)[10]); + }; + QVERIFY(edge(LayerSampling::nearest) == 0 || edge(LayerSampling::nearest) == 255); + QVERIFY2(qAbs(edge(LayerSampling::smooth) - 128) <= 2, qPrintable(QString::number(edge(LayerSampling::smooth)))); + QVERIFY2(qAbs(edge(LayerSampling::high) - 128) <= 2, qPrintable(QString::number(edge(LayerSampling::high)))); +} + +void DrawCoverageTests::smoothSamplingBlendsBetweenMaskPixels() +{ + // Two mask pixels, black and white, drawn sixteen wide. + QImage mask = gray(2, 1, 0); + mask.scanLine(0)[1] = 255; + const auto middle = [&](LayerSampling sampling) { + QImage device = gray(16, 4, 0); + QPainter context(&device); + LayerRenderer::drawCoverage(mask, {.origin = {0, 0}, .size = {16, 4}, .sampling = sampling}, context); + context.end(); + return int(device.constScanLine(2)[7]); + }; + QCOMPARE(middle(LayerSampling::nearest), 0); + QVERIFY2(middle(LayerSampling::smooth) > 80 && middle(LayerSampling::smooth) < 140, qPrintable(QString::number(middle(LayerSampling::smooth)))); + QVERIFY2(middle(LayerSampling::high) > 80 && middle(LayerSampling::high) < 140, qPrintable(QString::number(middle(LayerSampling::high)))); +} + +void DrawCoverageTests::largeReductionsDoNotAlias() +{ + // One white column in three, drawn eight times smaller. + QImage stripes = gray(510, 8, 0); + for (int y = 0; y < 8; ++y) { + for (int x = 0; x < 510; x += 3) + stripes.scanLine(y)[x] = 255; + } + QImage device = gray(64, 8, 0); + QPainter context(&device); + LayerRenderer::drawCoverage(stripes, {.origin = {0, 0}, .size = {63.75, 8}, .sampling = LayerSampling::high}, context); + context.end(); + // A third of white is 85; bilinear gives 0, 128. + for (int x = 4; x < 58; ++x) + QVERIFY2(qAbs(int(device.constScanLine(4)[x]) - 85) <= 25, qPrintable(QStringLiteral("%1: %2").arg(x).arg(device.constScanLine(4)[x]))); +} + +void DrawCoverageTests::oddMasksKeepTheirOverhang() +{ + // A ramp of 101 columns halves to 51, then 26. + const auto ramp = [](int width) { + QImage mask = gray(width, 8, 0); + for (int y = 0; y < 8; ++y) { + for (int x = 0; x < width; ++x) + mask.scanLine(y)[x] = uchar(std::min(x, 100) * 2.5); + } + return mask; + }; + const auto drawn = [](const QImage &mask, double width) { + QImage device = gray(26, 8, 0); + QPainter context(&device); + LayerRenderer::drawCoverage(mask, {.origin = {0, 0}, .size = {width, 8}, .sampling = LayerSampling::high}, context); + context.end(); + return device; + }; + // 104 columns halve evenly; both land on the same pixels. + const QImage odd = drawn(ramp(101), 25.25), even = drawn(ramp(104), 26); + for (int x = 0; x < 26; ++x) + QVERIFY2(qAbs(int(odd.constScanLine(4)[x]) - int(even.constScanLine(4)[x])) <= 1, qPrintable(QString::number(x))); + QVERIFY(int(even.constScanLine(4)[20]) > 180); +} + +void DrawCoverageTests::thePainterComesBackAsItWas() +{ + QImage device = gray(40, 40, 0); + QPainter context(&device); + context.translate(3, 4); + context.setCompositionMode(QPainter::CompositionMode_Plus); + const QTransform before = context.transform(); + LayerRenderer::drawCoverage(tones(), {.origin = {0, 0}, .size = {4, 1}}, context); + QCOMPARE(context.transform(), before); + QCOMPARE(context.compositionMode(), QPainter::CompositionMode_Plus); + QVERIFY(!context.testRenderHint(QPainter::Antialiasing)); + QVERIFY(!context.testRenderHint(QPainter::SmoothPixmapTransform)); + // The painter's own transform placed the mask. + context.end(); + QCOMPARE(int(device.constScanLine(4)[6]), 255); + QCOMPARE(int(device.constScanLine(4)[3]), 0); + QCOMPARE(int(device.constScanLine(0)[3]), 0); +} + +QTEST_MAIN(DrawCoverageTests) +#include "DrawCoverageTests.moc" diff --git a/tests/EditorCanvasTests.cpp b/tests/EditorCanvasTests.cpp new file mode 100644 index 0000000..0ab83a9 --- /dev/null +++ b/tests/EditorCanvasTests.cpp @@ -0,0 +1,437 @@ +#include "CanvasFixtures.h" +#include <QDialog> +#include <QLineEdit> +#include <QMenu> +#include <QPushButton> +#include <QSignalSpy> + +// The canvas: redraws, keys and focus. +class EditorCanvasTests : public QObject { + Q_OBJECT +private slots: + void selectionAndRenameDoNotInvalidateCanvasButPixelChangesDo(); + void mountingCanvasGivesItKeyboardFocus(); + void focusIsLeftWithASheetOrADialog(); + void geometryReachesTheViewportAfterTheEventLoop(); + void toolKeysSelectToolsAndSpaceHoldsTheHand(); + void transformKeysCommitCancelAndNudge(); + void shiftPlusAndMinusCycleTheBlendModeAcrossTheWindow(); + void altReachesTheZoomCursorFromAnyControl(); + void altHeldWhileMountingOrHiddenIsReadOnShow(); +}; + +void EditorCanvasTests::selectionAndRenameDoNotInvalidateCanvasButPixelChangesDo() +{ + EditorSession session; + const ImportedImage asset = filled(8, 8, qRgba(255, 0, 0, 255), "Test"); + session.insert(asset); + session.insert(asset); + CanvasView view(session); + QVERIFY(view.synchronizeDisplay()); + const QUuid first = session.document().value().layers.front().id; + const QUuid second = session.document().value().layers.back().id; + session.setActiveLayerID(first); + session.renameLayer(first, "Renamed"); + QVERIFY(!view.synchronizeDisplay()); + session.moveActiveLayer(1); + QVERIFY(view.synchronizeDisplay()); + session.toggleLayerVisibility(first); + QVERIFY(view.synchronizeDisplay()); + session.zoom(2); + QVERIFY(view.synchronizeDisplay()); + // Beyond Swift's: the selection shows nothing, each displayed thing does. + session.selectLayer(second); + QVERIFY(!view.synchronizeDisplay()); + session.setShowsPixelGrid(false); + QVERIFY(view.synchronizeDisplay()); + session.setLayerOpacity(0.5); + QVERIFY(view.synchronizeDisplay()); + session.previewBlendMode(LayerBlendMode::multiply, second); + QVERIFY(view.synchronizeDisplay()); + session.previewBlendMode(std::nullopt, std::nullopt); + QVERIFY(view.synchronizeDisplay()); + session.beginTransform(); + QVERIFY(!view.synchronizeDisplay()); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(3, 0); + session.previewTransform(draft); + QVERIFY(view.synchronizeDisplay()); + session.cancelTransform(); + QVERIFY(view.synchronizeDisplay()); + session.addLayerMask(false); + QVERIFY(view.synchronizeDisplay()); + session.toggleLayerMask(); + QVERIFY(view.synchronizeDisplay()); + // Hidden layers change unseen until a live mask reads them. + session.selectLayer(first); + session.setLayerOpacity(0.25); + QCOMPARE(layerWith(session, first).opacity, 0.25); + QVERIFY(!view.synchronizeDisplay()); + QVERIFY(session.linkMask(first, second)); + QVERIFY(view.synchronizeDisplay()); + session.setLayerOpacity(0.75); + QCOMPARE(layerWith(session, first).opacity, 0.75); + QVERIFY(view.synchronizeDisplay()); + session.toggleLayerVisibility(first); + QVERIFY(view.synchronizeDisplay()); + session.toggleLayerVisibility(first); + QVERIFY(view.synchronizeDisplay()); + session.removeLiveMask(second); + QVERIFY(view.synchronizeDisplay()); + // A folder changes the clip; its mask counts too. + session.selectLayers({first, second}, second); + session.groupSelectedLayers(); + QVERIFY(view.synchronizeDisplay()); + session.addLayerMask(false); + QVERIFY(session.activeLayer().value().isGroup); + QVERIFY(view.synchronizeDisplay()); + session.toggleLayerMask(); + QVERIFY(view.synchronizeDisplay()); + session.toggleGroupExpansion(session.activeLayerID().value()); + QVERIFY(!view.synchronizeDisplay()); +} + +void EditorCanvasTests::mountingCanvasGivesItKeyboardFocus() +{ + Shown shown; + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.canvas->clearFocus(); + QVERIFY(!shown.canvas->hasFocus()); + shown.canvas->consumeFocusRequest(1); + QTRY_VERIFY(shown.canvas->hasFocus()); + // The same request again asks nothing. + shown.canvas->clearFocus(); + shown.canvas->consumeFocusRequest(1); + QTest::qWait(50); + QVERIFY(!shown.canvas->hasFocus()); + shown.canvas->consumeFocusRequest(2); + QTRY_VERIFY(shown.canvas->hasFocus()); + // Without a document the welcome's field keeps the keys. + EditorSession bare; + QWidget window; + auto *field = new QLineEdit(&window); + field->setFocus(); + auto *canvas = new CanvasView(bare, &window); + canvas->setGeometry(0, 30, 200, 170); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + QTest::qWait(50); + QVERIFY(field->hasFocus() && !canvas->hasFocus()); +} + +void EditorCanvasTests::focusIsLeftWithASheetOrADialog() +{ + EditorSession session; + session.createDocument(10, 10); + session.setShowsImporter(true); + QWidget window; + auto *field = new QLineEdit(&window); + field->setFocus(); + auto *canvas = new CanvasView(session, &window); + window.resize(200, 200); + canvas->setGeometry(0, 30, 200, 170); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + QTest::qWait(50); + QVERIFY(field->hasFocus() && !canvas->hasFocus()); + session.setShowsImporter(false); + // The new-canvas sheet keeps the keys as well. + session.setShowsNewDocument(true); + canvas->hide(); + canvas->show(); + QTest::qWait(50); + QVERIFY(field->hasFocus() && !canvas->hasFocus()); + session.setShowsNewDocument(false); + canvas->hide(); + canvas->show(); + QTRY_VERIFY(canvas->hasFocus()); + field->setFocus(); + // A dialog over the window keeps a request's focus too. + QDialog dialog(&window); + dialog.open(); + QTRY_VERIFY(dialog.isVisible()); + canvas->consumeFocusRequest(1); + canvas->hide(); + canvas->show(); + QTest::qWait(50); + QVERIFY(!canvas->hasFocus()); + dialog.close(); + // Back, the field keeps focus; a new request lands. + window.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + QTest::qWait(50); + QVERIFY(field->hasFocus() && !canvas->hasFocus()); + canvas->consumeFocusRequest(2); + QTRY_VERIFY(canvas->hasFocus()); +} + +void EditorCanvasTests::geometryReachesTheViewportAfterTheEventLoop() +{ + EditorSession session; + session.createDocument(100, 50); + QWidget window; + auto *canvas = new CanvasView(session, &window); + window.resize(400, 300); + canvas->setGeometry(0, 0, 400, 300); + QSignalSpy changes(&session, &EditorSession::changed); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + QTRY_COMPARE(session.viewport.viewSize, QSizeF(400, 300)); + QCOMPARE(session.viewport.backingScale, 1.0); + QVERIFY(session.viewport.followsFit()); + QCOMPARE(session.viewport.zoom(), 3.04); + // Shown and resized: one announcement; the same size, none. + QTest::qWait(30); + QCOMPARE(changes.count(), 1); + canvas->resize(600, 300); + QTRY_COMPARE(session.viewport.viewSize, QSizeF(600, 300)); + QCOMPARE(session.viewport.zoom(), 4.08); + QCOMPARE(changes.count(), 2); + // Zoomed by hand, the view keeps its zoom when resized. + session.zoom(1); + canvas->resize(500, 300); + QTRY_COMPARE(session.viewport.viewSize, QSizeF(500, 300)); + QCOMPARE(session.viewport.zoom(), 1.0); +} + +void EditorCanvasTests::toolKeysSelectToolsAndSpaceHoldsTheHand() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + QTRY_VERIFY(canvas.hasFocus()); + const std::pair<Qt::Key, NavigationTool> keys[] = { + {Qt::Key_I, NavigationTool::eyedropper}, {Qt::Key_B, NavigationTool::brush}, {Qt::Key_V, NavigationTool::move}, + {Qt::Key_E, NavigationTool::brush}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp}, + {Qt::Key_T, NavigationTool::type}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_U, NavigationTool::shape}, + {Qt::Key_M, NavigationTool::marquee}, {Qt::Key_W, NavigationTool::wand}, {Qt::Key_L, NavigationTool::lasso}, + {Qt::Key_A, NavigationTool::idle}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_C, NavigationTool::crop}, + {Qt::Key_H, NavigationTool::hand}, {Qt::Key_Z, NavigationTool::zoom}, + }; + for (const auto &[key, tool] : keys) { + QTest::keyClick(&canvas, key); + QCOMPARE(session.tool(), tool); + } + // With Ctrl or Alt the letters are shortcuts, not tools. + session.selectTool(NavigationTool::move); + QTest::keyClick(&canvas, Qt::Key_I, Qt::ControlModifier); + QTest::keyClick(&canvas, Qt::Key_I, Qt::AltModifier); + QCOMPARE(session.tool(), NavigationTool::move); + QTest::keyClick(&canvas, Qt::Key_M, Qt::ShiftModifier); + QCOMPARE(session.tool(), NavigationTool::marquee); + // Space holds the hand; letting go restores the tool's cursor. + session.selectTool(NavigationTool::brush); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::CrossCursor); + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + // A held key repeats: its releases are no release. + QKeyEvent repeated(QEvent::KeyRelease, Qt::Key_Space, Qt::NoModifier, QString(), true); + QApplication::sendEvent(&canvas, &repeated); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + QTest::keyRelease(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::CrossCursor); + // Each tool has its cursor; Alt turns the magnifier's sign. + session.selectTool(NavigationTool::type); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::IBeamCursor); + session.selectTool(NavigationTool::idle); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::ArrowCursor); + session.selectTool(NavigationTool::hand); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + session.selectTool(NavigationTool::zoom); + canvas.synchronizeDisplay(); + QCOMPARE(canvas.cursor().shape(), Qt::BitmapCursor); + const QImage plus = canvas.cursor().pixmap().toImage(); + QTest::keyPress(&canvas, Qt::Key_Alt, Qt::AltModifier); + const QImage minus = canvas.cursor().pixmap().toImage(); + QVERIFY(plus != minus); + QTest::keyRelease(&canvas, Qt::Key_Alt); + QCOMPARE(canvas.cursor().pixmap().toImage(), plus); + // Focus lost while Space is held: the hand goes. + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + canvas.clearFocus(); + QCOMPARE(canvas.cursor().shape(), Qt::BitmapCursor); +} + +void EditorCanvasTests::transformKeysCommitCancelAndNudge() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + session.insert(filled(10, 10, qRgba(0, 0, 255, 255), "Blue")); + const QUuid id = session.activeLayerID().value(); + const QPointF origin = layerWith(session, id).transform.origin; + session.selectTool(NavigationTool::move); + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyClick(&canvas, Qt::Key_Right); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(1, 0)); + QTest::keyClick(&canvas, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(1, 10)); + QTest::keyClick(&canvas, Qt::Key_Left, Qt::ControlModifier); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(1, 10)); + // Arrows nudge in the Move tool only. + session.selectTool(NavigationTool::brush); + QTest::keyClick(&canvas, Qt::Key_Up); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(1, 10)); + session.selectTool(NavigationTool::move); + // Escape cancels a pending transform, Return and Enter commit it. + const auto previewMoved = [&](double by) { + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(by, 0); + session.previewTransform(draft); + }; + previewMoved(5); + QTest::keyClick(&canvas, Qt::Key_Escape); + QVERIFY(!session.transformEdit()); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(1, 10)); + previewMoved(5); + QTest::keyClick(&canvas, Qt::Key_Return); + QVERIFY(!session.transformEdit()); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(6, 10)); + previewMoved(2); + QTest::keyClick(&canvas, Qt::Key_Enter); + QVERIFY(!session.transformEdit()); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(8, 10)); + session.undo(); + QCOMPARE(layerWith(session, id).transform.origin, origin + QPointF(6, 10)); +} + +void EditorCanvasTests::shiftPlusAndMinusCycleTheBlendModeAcrossTheWindow() +{ + EditorSession session; + session.createDocument(10, 10); + session.insert(filled(4, 4, qRgba(0, 0, 255, 255), "Blue")); + QWidget window; + auto *button = new QPushButton(&window); + auto *field = new QLineEdit(&window); + field->move(120, 0); + auto *canvas = new CanvasView(session, &window); + window.resize(300, 200); + canvas->setGeometry(0, 40, 300, 160); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + QTRY_VERIFY(canvas->hasFocus()); + const auto mode = [&] { return session.activeLayer().value().blendMode; }; + QCOMPARE(mode(), LayerBlendMode::normal); + QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::multiply); + QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::normal); + QTest::keyClick(canvas, Qt::Key_Underscore, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + // From any control of the window, unshifted keys too. + button->setFocus(); + QTRY_VERIFY(button->hasFocus()); + QTest::keyClick(button, Qt::Key_Equal, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::normal); + QTest::keyClick(button, Qt::Key_Minus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + // Not while typing, without Shift, or with Ctrl or Alt. + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + QTest::keyClick(field, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + QCOMPARE(field->text(), QString("+")); + canvas->setFocus(); + QTest::keyClick(canvas, Qt::Key_Plus); + QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier | Qt::ControlModifier); + QTest::keyClick(canvas, Qt::Key_Plus, Qt::ShiftModifier | Qt::AltModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + // Another window's keys are its own. + QWidget other; + auto *elsewhere = new QPushButton(&other); + other.show(); + QVERIFY(QTest::qWaitForWindowActive(&other)); + QTest::keyClick(elsewhere, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + other.hide(); + window.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + // Hidden, the canvas listens no more; shown, again. + canvas->hide(); + button->setFocus(); + QTest::keyClick(button, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::luminosity); + canvas->show(); + QTest::keyClick(button, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(mode(), LayerBlendMode::normal); +} + +void EditorCanvasTests::altReachesTheZoomCursorFromAnyControl() +{ + EditorSession session; + session.createDocument(10, 10); + session.selectTool(NavigationTool::zoom); + QWidget window; + auto *field = new QLineEdit(&window); + auto *canvas = new CanvasView(session, &window); + window.resize(300, 200); + canvas->setGeometry(0, 40, 300, 160); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + canvas->synchronizeDisplay(); + const QImage plus = canvas->cursor().pixmap().toImage(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + QTest::keyPress(field, Qt::Key_Alt, Qt::AltModifier); + const QImage minus = canvas->cursor().pixmap().toImage(); + QVERIFY(minus != plus); + // Alt repeats while held; only its release restores the plus. + QKeyEvent repeated(QEvent::KeyRelease, Qt::Key_Alt, Qt::AltModifier, QString(), true); + QApplication::sendEvent(field, &repeated); + QCOMPARE(canvas->cursor().pixmap().toImage(), minus); + QTest::keyRelease(field, Qt::Key_Alt); + QCOMPARE(canvas->cursor().pixmap().toImage(), plus); +} + +void EditorCanvasTests::altHeldWhileMountingOrHiddenIsReadOnShow() +{ + EditorSession session; + session.createDocument(10, 10); + session.selectTool(NavigationTool::zoom); + QWidget window; + auto *field = new QLineEdit(&window); + window.resize(300, 200); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + // Alt held before the canvas mounts: the minus shows. + QTest::keyPress(window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QVERIFY(QApplication::keyboardModifiers().testFlag(Qt::AltModifier)); + auto *canvas = new CanvasView(session, &window); + canvas->setGeometry(0, 40, 300, 160); + canvas->show(); + canvas->synchronizeDisplay(); + const QImage minus = canvas->cursor().pixmap().toImage(); + QTest::keyRelease(window.windowHandle(), Qt::Key_Alt); + const QImage plus = canvas->cursor().pixmap().toImage(); + QVERIFY(minus != plus); + // Released while hidden, Alt is read again on show. + QTest::keyPress(window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QCOMPARE(canvas->cursor().pixmap().toImage(), minus); + canvas->hide(); + QTest::keyRelease(window.windowHandle(), Qt::Key_Alt); + canvas->show(); + QCOMPARE(canvas->cursor().pixmap().toImage(), plus); + // A menu is its own window; Alt's release reaches it. + QMenu menu; + menu.addAction(QStringLiteral("Undo")); + QTest::keyPress(window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QCOMPARE(canvas->cursor().pixmap().toImage(), minus); + menu.popup(window.mapToGlobal(QPoint(20, 20))); + QTRY_COMPARE(QApplication::activePopupWidget(), static_cast<QWidget *>(&menu)); + QTest::keyRelease(&menu, Qt::Key_Alt); + QCOMPARE(canvas->cursor().pixmap().toImage(), plus); + QTest::keyClick(&menu, Qt::Key_Escape); + QTRY_VERIFY(!menu.isVisible()); + QCOMPARE(canvas->cursor().pixmap().toImage(), plus); +} + +QTEST_MAIN(EditorCanvasTests) +#include "EditorCanvasTests.moc" diff --git a/tests/EditorSessionSignalTests.cpp b/tests/EditorSessionSignalTests.cpp new file mode 100644 index 0000000..45b78cc --- /dev/null +++ b/tests/EditorSessionSignalTests.cpp @@ -0,0 +1,457 @@ +#include "SessionRecord.h" +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> + +// `changed()` stands in for Swift's observation: its contract. +namespace { +ImportedImage asset(int width, int height, const QString &name) +{ + return ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), name); +} + +} + +class EditorSessionSignalTests : public QObject { + Q_OBJECT +private slots: + void everyChangeIsAnnouncedAndRefusalsAreSilent(); + void observersSeeFinishedStates(); + void groupingAndPlacingAnnounceAndRefuseInSilence(); + void transformsAnnounceAndRefuseInSilence(); + void appearanceAnnouncesAndRefusesInSilence(); + void theLastSignalOfEveryChangeSeesWhatItLeaves(); + void theLastSignalOfAnImportAndOfALongWaitSeeTheirResults(); + void projectsAnnounceAndTheirLastSignalSeesTheResult(); +}; + +void EditorSessionSignalTests::everyChangeIsAnnouncedAndRefusalsAreSilent() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + // Nothing to act on yet: requests are refused in silence. + QVERIFY(!announced([&] { session.addBlankLayer(); })); + QVERIFY(!announced([&] { session.addGroup(); })); + QVERIFY(!announced([&] { session.fit(); })); + QVERIFY(!announced([&] { session.zoom(2); })); + QVERIFY(!announced([&] { session.undo(); })); + QVERIFY(announced([&] { session.createDocument(64, 64); })); + QVERIFY(announced([&] { session.addBlankLayer(); })); + const QUuid id = session.activeLayerID().value(); + QVERIFY(announced([&] { session.renameLayer(id, "Named"); })); + QVERIFY(announced([&] { session.toggleLayerVisibility(id); })); + QVERIFY(announced([&] { session.setVisibilityInSwipe(id, true); })); + QVERIFY(!announced([&] { session.setVisibilityInSwipe(id, true); })); + QVERIFY(announced([&] { session.addGroup(); })); + QVERIFY(announced([&] { session.toggleGroupExpansion(session.activeLayerID().value()); })); + QVERIFY(!announced([&] { session.toggleGroupExpansion(id); })); + QVERIFY(announced([&] { session.selectLayer(id); })); + QVERIFY(announced([&] { session.selectTool(NavigationTool::hand); })); + QVERIFY(announced([&] { session.zoom(2); })); + // A zoom the viewport refuses or already has says nothing. + QVERIFY(!announced([&] { session.zoom(std::nan("")); })); + QVERIFY(!announced([&] { session.zoom(2); })); + QVERIFY(announced([&] { session.fit(); })); + QVERIFY(!announced([&] { session.fit(); })); + // An open transaction closes undo: observers hear both ends. + QVERIFY(session.canUndo()); + QVERIFY(announced([&] { session.beginEdit("Gesture"); })); + QVERIFY(!session.canUndo()); + QVERIFY(announced([&] { session.endEdit(); })); + QVERIFY(session.canUndo()); + QVERIFY(announced([&] { session.undo(); })); + QVERIFY(announced([&] { session.redo(); })); + session.selectLayer(id); + QVERIFY(announced([&] { session.insert(asset(4, 4, "Image")); })); + QVERIFY(announced([&] { session.moveActiveLayer(-1); })); + QVERIFY(announced([&] { session.reorderLayers({0}, 2); })); + QVERIFY(announced([&] { session.deleteActiveLayer(); })); + QVERIFY(announced([&] { session.setIsProjectBusy(true); })); + QVERIFY(!announced([&] { session.selectTool(NavigationTool::zoom); })); + QVERIFY(announced([&] { session.setIsProjectBusy(false); })); + QVERIFY(announced([&] { session.setShowsNewDocument(true); })); + QVERIFY(announced([&] { session.setShowsImporter(true); })); + QVERIFY(announced([&] { session.setShowsPixelGrid(false); })); + QVERIFY(announced([&] { session.requestCanvasFocus(); })); + QVERIFY(announced([&] { session.setIsImporting(true); })); + QVERIFY(announced([&] { session.setImportError(QString("failed")); })); + QVERIFY(announced([&] { session.setRenamingLayerID(id); })); + QVERIFY(announced([&] { session.notify(); })); +} + +void EditorSessionSignalTests::observersSeeFinishedStates() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + // What observers read at the last signal of a change. + QSizeF size; + bool fits = false; + std::optional<QUuid> renaming; + connect(&session, &EditorSession::changed, this, [&] { + size = session.document().has_value() ? session.document().value().size() : QSizeF(); + fits = session.viewport.followsFit(); + renaming = session.renamingLayerID(); + }); + session.createDocument(64, 64); + session.zoom(3); + QVERIFY(!fits); + session.setRenamingLayerID(QUuid::createUuid()); + session.createDocument(32, 16, true); + QCOMPARE(size, QSizeF(32, 16)); + QVERIFY(fits && !renaming.has_value()); + session.zoom(3); + // Another canvas comes back: the view fits before that signal. + session.undo(); + QCOMPARE(size, QSizeF(64, 64)); + QVERIFY(fits); +} + +void EditorSessionSignalTests::groupingAndPlacingAnnounceAndRefuseInSilence() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + // Without a document nothing is grouped, placed or deleted. + QVERIFY(!announced([&] { session.groupSelectedLayers(); })); + QVERIFY(!announced([&] { session.extendSelection(QUuid::createUuid()); })); + QVERIFY(!announced([&] { session.placeLayer(QUuid::createUuid(), std::nullopt); })); + QVERIFY(!announced([&] { session.moveActiveLayerOutOfGroup(); })); + QVERIFY(!announced([&] { session.deleteSelectedLayers(); })); + QVERIFY(!announced([&] { session.finishDeletingLayers({}, {}); })); + session.createDocument(64, 64, true); + const QUuid first = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid second = session.activeLayerID().value(); + QVERIFY(announced([&] { session.selectLayers({first, second}, first); })); + QVERIFY(announced([&] { session.extendSelection(second); })); + QVERIFY(!announced([&] { session.extendSelection(QUuid::createUuid()); })); + QVERIFY(announced([&] { session.groupSelectedLayers(); })); + const QUuid folder = session.activeLayerID().value(); + // A folder takes neither itself nor a layer above itself. + QVERIFY(!announced([&] { session.placeLayer(folder, folder); })); + QVERIFY(!announced([&] { session.placeLayer(second, std::nullopt, second); })); + QVERIFY(!announced([&] { session.moveActiveLayerOutOfGroup(); })); + QVERIFY(announced([&] { session.placeLayer(second, folder); })); + QVERIFY(announced([&] { session.moveActiveLayerOutOfGroup(); })); + session.selectLayers({first, second}, first); + session.setIsImporting(true); + QVERIFY(!announced([&] { session.deleteSelectedLayers(); })); + QVERIFY(!announced([&] { session.groupSelectedLayers(); })); + QVERIFY(!announced([&] { session.extendSelection(second); })); + session.setIsImporting(false); + QVERIFY(announced([&] { session.deleteSelectedLayers(); })); + QCOMPARE(int(session.document().value().layers.size()), 1); +} + +void EditorSessionSignalTests::transformsAnnounceAndRefuseInSilence() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + const LayerTransform valid{.origin = {1, 2}, .size = {3, 4}}; + // No canvas, no pixels, no edit: nothing to say. + QVERIFY(!announced([&] { session.beginTransform(); })); + QVERIFY(!announced([&] { session.nudgeLayer(1, 1); })); + session.createDocument(64, 64, true); + QVERIFY(!announced([&] { session.beginTransform(); })); + QVERIFY(!announced([&] { session.previewTransform(valid); })); + QVERIFY(!announced([&] { session.commitTransform(); })); + QVERIFY(!announced([&] { session.cancelTransform(); })); + session.insert(asset(8, 8, "Image")); + QVERIFY(announced([&] { session.beginTransform(); })); + QVERIFY(!announced([&] { session.beginTransform(); })); + QVERIFY(announced([&] { session.previewTransform(valid); })); + LayerTransform invalid = valid; + invalid.size.setHeight(0); + QVERIFY(!announced([&] { session.previewTransform(invalid); })); + QVERIFY(announced([&] { session.cancelTransform(); })); + QVERIFY(announced([&] { session.beginTransform(); })); + // An edit that changes nothing still ends aloud. + QVERIFY(announced([&] { session.commitTransform(); })); + QVERIFY(announced([&] { session.nudgeLayer(1, 0); })); + // Flips, merges and duplicate transforms speak when they act. + QVERIFY(announced([&] { session.flipLayers(true); })); + QVERIFY(announced([&] { session.setTransformAutoSelect(true); })); + QVERIFY(announced([&] { session.setShowsTransformControls(false); })); + QVERIFY(announced([&] { session.setLocksTransformRatio(false); })); + QVERIFY(announced([&] { session.beginDuplicateTransform(); })); + QVERIFY(!announced([&] { session.beginDuplicateTransform(); })); + QVERIFY(announced([&] { session.cancelTransform(); })); + QVERIFY(announced([&] { session.beginDuplicateTransform(); })); + QVERIFY(announced([&] { session.commitTransform(); })); + QVERIFY(announced([&] { session.mergeLayers(); })); + session.setIsProjectBusy(true); + QVERIFY(!announced([&] { session.flipLayers(false); })); + QVERIFY(!announced([&] { session.mergeLayers(); })); + QVERIFY(!announced([&] { session.beginDuplicateTransform(); })); +} + +void EditorSessionSignalTests::appearanceAnnouncesAndRefusesInSilence() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + // No layer, no appearance: nothing to say. + QVERIFY(!announced([&] { session.setLayerOpacity(0.5); })); + QVERIFY(!announced([&] { session.setSelectedLayersOpacity(0.5); })); + QVERIFY(!announced([&] { session.setLayerBlendMode(LayerBlendMode::screen); })); + QVERIFY(!announced([&] { session.cycleBlendMode(true); })); + QVERIFY(!announced([&] { session.beginOpacityEdit(); })); + QVERIFY(!announced([&] { session.finishOpacityEdit(); })); + QVERIFY(!announced([&] { session.previewBlendMode(LayerBlendMode::screen, QUuid::createUuid()); })); + session.createDocument(8, 8); + session.insert(asset(4, 4, "Image")); + const QUuid id = session.activeLayerID().value(); + QVERIFY(announced([&] { session.previewBlendMode(LayerBlendMode::screen, id); })); + QVERIFY(!announced([&] { session.previewBlendMode(LayerBlendMode::screen, id); })); + QVERIFY(announced([&] { session.previewBlendMode(LayerBlendMode::overlay, id); })); + QVERIFY(announced([&] { session.previewBlendMode(std::nullopt, std::nullopt); })); + QVERIFY(!announced([&] { session.previewBlendMode(std::nullopt, std::nullopt); })); + QVERIFY(announced([&] { session.beginOpacityEdit(); })); + QVERIFY(!announced([&] { session.beginOpacityEdit(); })); + QVERIFY(announced([&] { session.setLayerOpacity(0.5); })); + QVERIFY(!announced([&] { session.setLayerOpacity(std::nan("")); })); + QVERIFY(announced([&] { session.finishOpacityEdit(); })); + QVERIFY(!announced([&] { session.finishOpacityEdit(); })); + QVERIFY(announced([&] { session.setLayerOpacity(0.25); })); + QVERIFY(announced([&] { session.setSelectedLayersOpacity(0.75); })); + QVERIFY(!announced([&] { session.setSelectedLayersOpacity(0.75); })); + QVERIFY(announced([&] { session.cycleBlendMode(false); })); + QVERIFY(announced([&] { session.setLayerBlendMode(LayerBlendMode::normal); })); + // A commit with only a preview open drops it aloud. + session.previewBlendMode(LayerBlendMode::screen, id); + bool sawItCleared = false; + const QMetaObject::Connection watch = connect(&session, &EditorSession::changed, this, [&] { sawItCleared = !session.blendPreview().has_value(); }); + QVERIFY(announced([&] { session.commitTransform(); })); + QVERIFY(sawItCleared); + QVERIFY(!announced([&] { session.commitTransform(); })); + disconnect(watch); + // A refused mode still drops a preview, aloud. + session.previewBlendMode(LayerBlendMode::screen, id); + session.setIsImporting(true); + QVERIFY(announced([&] { session.setLayerBlendMode(LayerBlendMode::screen); })); + QVERIFY(!announced([&] { session.setLayerBlendMode(LayerBlendMode::screen); })); +} + +void EditorSessionSignalTests::theLastSignalOfEveryChangeSeesWhatItLeaves() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + QStringList seen; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + // Empty when the last signal saw the state left behind. + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session.setShowsNewDocument(true); }), QString()); + QCOMPARE(stale([&] { session.createDocument(64, 64, true); }), QString()); + const QUuid first = session.activeLayerID().value(); + QCOMPARE(stale([&] { session.addBlankLayer(); }), QString()); + const QUuid layer = session.activeLayerID().value(); + QCOMPARE(stale([&] { session.renameLayer(layer, "Named"); }), QString()); + QCOMPARE(stale([&] { session.toggleLayerVisibility(layer); }), QString()); + QCOMPARE(stale([&] { QCOMPARE(session.beginVisibilitySwipe(layer), std::optional(true)); }), QString()); + QCOMPARE(stale([&] { session.setVisibilityInSwipe(first, false); }), QString()); + QCOMPARE(stale([&] { session.endVisibilitySwipe(); }), QString()); + QCOMPARE(stale([&] { session.addGroup(); }), QString()); + const QUuid folder = session.activeLayerID().value(); + QCOMPARE(stale([&] { session.addBlankLayer(); }), QString()); + const QUuid inside = session.activeLayerID().value(); + // Folding moves the active layer out of what hides. + QCOMPARE(stale([&] { session.toggleGroupExpansion(folder); }), QString()); + QCOMPARE(session.activeLayerID(), std::optional(folder)); + // A folder made inside a folded one opens it. + QCOMPARE(stale([&] { session.addGroup(); }), QString()); + const QUuid nested = session.activeLayerID().value(); + QVERIFY(session.collapsedGroupIDs().isEmpty()); + QCOMPARE(stale([&] { session.toggleGroupExpansion(folder); }), QString()); + QCOMPARE(stale([&] { session.toggleGroupExpansion(folder); }), QString()); + QCOMPARE(stale([&] { session.selectLayer(layer); }), QString()); + QCOMPARE(stale([&] { session.selectTool(NavigationTool::hand); }), QString()); + QCOMPARE(stale([&] { session.zoom(2); }), QString()); + QCOMPARE(stale([&] { session.fit(); }), QString()); + // An open transaction closes undo until it ends. + QCOMPARE(stale([&] { session.beginEdit("Gesture"); }), QString()); + QVERIFY(seen.contains("history 00")); + QCOMPARE(stale([&] { session.endEdit(); }), QString()); + QVERIFY(seen.contains("history 10")); + QCOMPARE(stale([&] { session.undo(); }), QString()); + QVERIFY(seen.contains("history 11")); + QCOMPARE(stale([&] { session.redo(); }), QString()); + // Beside a root layer the image has a sibling below. + QCOMPARE(stale([&] { session.selectLayer(layer); }), QString()); + QCOMPARE(stale([&] { session.insert(asset(4, 4, "Image")); }), QString()); + QCOMPARE(stale([&] { session.moveActiveLayer(-1); }), QString()); + QCOMPARE(stale([&] { session.reorderLayers({0}, 2); }), QString()); + QCOMPARE(stale([&] { session.deleteActiveLayer(); }), QString()); + // An appearance: tried on, dragged, set, cycled. + QCOMPARE(stale([&] { session.selectLayer(layer); }), QString()); + QCOMPARE(stale([&] { session.previewBlendMode(LayerBlendMode::screen, layer); }), QString()); + QVERIFY(seen.filter("preview ").size() == 1); + QCOMPARE(stale([&] { session.beginOpacityEdit(); }), QString()); + QVERIFY(seen.contains("history 00")); + QCOMPARE(stale([&] { session.setLayerOpacity(0.5); }), QString()); + QCOMPARE(stale([&] { session.finishOpacityEdit(); }), QString()); + QVERIFY(seen.contains("history 10") && seen.contains("names Layer Opacity/")); + QCOMPARE(stale([&] { session.setLayerBlendMode(LayerBlendMode::multiply); }), QString()); + QVERIFY(seen.filter("preview ").isEmpty()); + QCOMPARE(stale([&] { session.cycleBlendMode(true); }), QString()); + QCOMPARE(stale([&] { session.setLayerOpacity(0.25); }), QString()); + QCOMPARE(stale([&] { session.setSelectedLayersOpacity(1); }), QString()); + // Choosing another layer ends a drag and a preview. + QCOMPARE(stale([&] { session.beginOpacityEdit(); }), QString()); + QCOMPARE(stale([&] { session.previewBlendMode(LayerBlendMode::screen, layer); }), QString()); + QCOMPARE(stale([&] { session.selectLayer(first); }), QString()); + QVERIFY(seen.contains("history 10") && seen.filter("preview ").isEmpty()); + // A transform: begun, previewed, dropped, committed, nudged. + QCOMPARE(stale([&] { session.insert(asset(6, 4, "Moved")); }), QString()); + const QUuid movedLayer = session.activeLayerID().value(); + QCOMPARE(stale([&] { session.selectTool(NavigationTool::hand); }), QString()); + QCOMPARE(stale([&] { session.beginTransform(); }), QString()); + QVERIFY(seen.contains("tool 0") && seen.contains("history 00")); + const LayerTransform draft{.origin = {5, 7}, .size = {12, 8}}; + QCOMPARE(stale([&] { session.previewTransform(draft); }), QString()); + QCOMPARE(stale([&] { session.cancelTransform(); }), QString()); + QCOMPARE(stale([&] { session.beginTransform(); }), QString()); + QCOMPARE(stale([&] { session.previewTransform(draft); }), QString()); + QCOMPARE(stale([&] { session.commitTransform(); }), QString()); + QVERIFY(seen.contains("history 10") && seen.contains("names Transform Layer/")); + QCOMPARE(stale([&] { session.nudgeLayer(2, 3); }), QString()); + // Another layer, tool or selection commits the open edit. + QCOMPARE(stale([&] { session.beginTransform(); }), QString()); + QCOMPARE(stale([&] { session.nudgeLayer(1, 1); }), QString()); + QCOMPARE(stale([&] { session.selectLayer(layer); }), QString()); + QCOMPARE(stale([&] { session.selectLayers({movedLayer, layer}, movedLayer); }), QString()); + QCOMPARE(stale([&] { session.beginTransform(); }), QString()); + QVERIFY(seen.filter("group").size() == 1); + QCOMPARE(stale([&] { session.nudgeLayer(1, 1); }), QString()); + QCOMPARE(stale([&] { session.selectTool(NavigationTool::hand); }), QString()); + QCOMPARE(stale([&] { session.selectLayer(movedLayer); }), QString()); + QCOMPARE(stale([&] { session.deleteActiveLayer(); }), QString()); + // Several layers: chosen, wrapped, placed, taken out, deleted. + QCOMPARE(stale([&] { session.selectLayers({first, layer}, first); }), QString()); + QCOMPARE(stale([&] { session.extendSelection(folder); }), QString()); + QCOMPARE(stale([&] { session.extendSelection(folder); }), QString()); + QCOMPARE(stale([&] { session.groupSelectedLayers(); }), QString()); + const QUuid wrapper = session.activeLayerID().value(); + // Wrapping inside a folded folder opens it, as placing does. + QCOMPARE(stale([&] { session.toggleGroupExpansion(folder); }), QString()); + QCOMPARE(stale([&] { session.selectLayers({inside, nested}, inside); }), QString()); + QCOMPARE(stale([&] { session.groupSelectedLayers(); }), QString()); + QVERIFY(session.collapsedGroupIDs().isEmpty()); + QCOMPARE(stale([&] { session.toggleGroupExpansion(folder); }), QString()); + QCOMPARE(stale([&] { QVERIFY(session.placeLayer(wrapper, folder)); }), QString()); + QVERIFY(session.collapsedGroupIDs().isEmpty()); + QCOMPARE(stale([&] { session.moveActiveLayerOutOfGroup(); }), QString()); + QCOMPARE(stale([&] { session.selectLayers({first, layer}, layer); }), QString()); + QCOMPARE(stale([&] { session.deleteSelectedLayers(); }), QString()); + QCOMPARE(stale([&] { session.finishDeletingLayers({wrapper, folder}, {}); }), QString()); + QCOMPARE(stale([&] { session.setIsProjectBusy(true); }), QString()); + QCOMPARE(stale([&] { session.setIsProjectBusy(false); }), QString()); + QCOMPARE(stale([&] { session.setShowsImporter(true); }), QString()); + QCOMPARE(stale([&] { session.setShowsImporter(false); }), QString()); + QCOMPARE(stale([&] { session.setShowsPixelGrid(false); }), QString()); + QCOMPARE(stale([&] { session.requestCanvasFocus(); }), QString()); + QCOMPARE(stale([&] { session.setIsImporting(true); }), QString()); + QCOMPARE(stale([&] { session.setIsImporting(false); }), QString()); + QCOMPARE(stale([&] { session.setImportError(QString("failed")); }), QString()); + QCOMPARE(stale([&] { session.setImportError(std::nullopt); }), QString()); + QCOMPARE(stale([&] { session.setRenamingLayerID(layer); }), QString()); + QCOMPARE(stale([&] { session.setRenamingLayerID(std::nullopt); }), QString()); + // Another canvas, then back: the view fits before the signal. + QCOMPARE(stale([&] { session.zoom(3); }), QString()); + QCOMPARE(stale([&] { session.createDocument(32, 16); }), QString()); + QCOMPARE(stale([&] { session.zoom(3); }), QString()); + QCOMPARE(stale([&] { session.undo(); }), QString()); + QVERIFY(session.viewport.followsFit()); +} + +void EditorSessionSignalTests::theLastSignalOfAnImportAndOfALongWaitSeeTheirResults() +{ + QTemporaryDir folder; + QImage image(16, 8, QImage::Format_RGBA8888); + image.fill(Qt::blue); + QVERIFY(image.save(folder.filePath("blue.png"), "PNG")); + const QList<QUrl> urls{QUrl::fromLocalFile(folder.filePath("blue.png")), QUrl::fromLocalFile(folder.filePath("none.png"))}; + EditorSession session; + QStringList seen; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + bool done = false; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*none\\.png: .*")); + session.importImages(urls, std::nullopt, [&] { done = true; }); + QVERIFY(seen.contains("flags 0001")); + QVERIFY(QTest::qWaitFor([&] { return done && !session.isImporting(); }, 10'000)); + // The error shows last, with the import already over. + QCOMPARE(seen, described(session)); + QVERIFY(seen.contains("flags 0000") && seen.filter("error none.png: ").size() == 1); + // The indicator comes on by itself, and says so. + session.setImportError(std::nullopt); + session.setIsProjectBusy(true); + QVERIFY(seen.contains("dimmed 0 startable 0")); + QVERIFY(QTest::qWaitFor([&] { return session.showsBusy(); }, 5'000)); + QCOMPARE(seen, described(session)); + QVERIFY(seen.contains("dimmed 1 startable 0")); + session.setIsProjectBusy(false); + QCOMPARE(seen, described(session)); + QVERIFY(seen.contains("dimmed 0 startable 1")); +} + +void EditorSessionSignalTests::projectsAnnounceAndTheirLastSignalSeesTheResult() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + QStringList seen; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session.createNewProject(64, 48); }), QString()); + QCOMPARE(stale([&] { session.insert(asset(8, 8, "Image")); }), QString()); + QCOMPARE(stale([&] { session.zoom(3); }), QString()); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + // A canvas half as large under the same id. + ProjectSnapshot smaller = snapshot; + smaller.manifest.width = 32; + smaller.manifest.height = 24; + smaller.manifest.resolution = 144; + QCOMPARE(stale([&] { session.applyDocumentSize(smaller, "Canvas Size"); }), QString()); + QVERIFY(seen.filter("canvas ").value(0).endsWith(" 32x24 at 144") && seen.contains("names Canvas Size/")); + QCOMPARE(stale([&] { session.applyImageSize(snapshot); }), QString()); + QCOMPARE(stale([&] { session.setRenamingLayerID(QUuid::createUuid()); }), QString()); + QCOMPARE(stale([&] { session.zoom(3); }), QString()); + QCOMPARE(stale([&] { session.installProject(smaller, "/projects/small.comp"); }), QString()); + QVERIFY(seen.contains("path /projects/small.comp") && seen.contains("renaming none") && seen.contains("undos 0 modified 0")); + QCOMPARE(stale([&] { session.clearProject(); }), QString()); + QVERIFY(seen.contains("path none") && seen.contains("active none") && seen.filter("canvas ").isEmpty()); + // A refused project or size says nothing. + seen.clear(); + session.createNewProject(0, 10); + session.applyImageSize(snapshot); + QVERIFY(seen.isEmpty()); +} + +QTEST_GUILESS_MAIN(EditorSessionSignalTests) +#include "EditorSessionSignalTests.moc" diff --git a/tests/EditorSessionTests.cpp b/tests/EditorSessionTests.cpp new file mode 100644 index 0000000..a0df384 --- /dev/null +++ b/tests/EditorSessionTests.cpp @@ -0,0 +1,494 @@ +#include "Document/EditorSession.h" +#include "SessionFixtures.h" +#include <QtTest> + +namespace { +ImportedImage asset(int width, int height, const QString &name) +{ + return ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), name); +} + +QStringList names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result; +} + +ImageLayer layerNamed(const EditorSession &session, const QString &name) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.name == name) + return layer; + } + throw std::runtime_error("no layer named " + name.toStdString()); +} +} + +class EditorSessionTests : public QObject { + Q_OBJECT +private slots: + void settersKeepExactlyWhatTheyAreGiven(); + void theSelectionFollowsTheActiveLayer(); + void aBusyProjectKeepsItsTool(); + void tabSwitchesEachToolsShapeOrMode(); + void everyGateClosesLayerEdits_data(); + void everyGateClosesLayerEdits(); + void theFirstImageMakesTheCanvas(); + void imagesLandCentredOnWholePixels(); + void newLayersJoinTheActiveFolder(); + void foldersAreNamedPlacedAndLimited(); + void aWideFolderIsWalkedOnce(); + void foldingAFolderTakesTheSelectionFromItsContents(); + void deletingAFolderTakesItsContents(); + void movingStaysAmongSiblings(); + void duplicatingALayerByDraggingPlacesTheCopyAsOneStep(); + void aNewCanvasCanStartWithALayer(); + void sessionChangesAreLogged(); +}; + +void EditorSessionTests::settersKeepExactlyWhatTheyAreGiven() +{ + EditorSession session; + const auto flags = [&] { + return QList<bool>{session.isProjectBusy(), session.showsNewDocument(), session.showsImporter(), session.isImporting()}; + }; + QCOMPARE(flags(), (QList<bool>{false, false, false, false})); + QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value()); + // Each flag is its own. + session.setIsProjectBusy(true); + QCOMPARE(flags(), (QList<bool>{true, false, false, false})); + session.setIsProjectBusy(false); + session.setShowsNewDocument(true); + QCOMPARE(flags(), (QList<bool>{false, true, false, false})); + session.setShowsNewDocument(false); + session.setShowsImporter(true); + QCOMPARE(flags(), (QList<bool>{false, false, true, false})); + session.setShowsImporter(false); + session.setIsImporting(true); + QCOMPARE(flags(), (QList<bool>{false, false, false, true})); + session.setIsImporting(false); + QCOMPARE(flags(), (QList<bool>{false, false, false, false})); + const QUuid first = QUuid::createUuid(), second = QUuid::createUuid(); + session.setRenamingLayerID(first); + session.setImportError(QString("first.png: unreadable")); + QCOMPARE(session.renamingLayerID(), std::optional(first)); + QCOMPARE(session.importError(), std::optional(QString("first.png: unreadable"))); + session.setRenamingLayerID(second); + session.setImportError(QString("second.png: too large")); + QCOMPARE(session.renamingLayerID(), std::optional(second)); + QCOMPARE(session.importError(), std::optional(QString("second.png: too large"))); + session.setRenamingLayerID(std::nullopt); + session.setImportError(std::nullopt); + QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value()); + QCOMPARE(flags(), (QList<bool>{false, false, false, false})); +} + +void EditorSessionTests::theSelectionFollowsTheActiveLayer() +{ + EditorSession session; + session.createDocument(64, 64); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>()); + QVERIFY(!session.activeLayer().has_value()); + session.addBlankLayer(); + const QUuid first = session.activeLayerID().value(); + session.addBlankLayer(); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{session.activeLayerID().value()}); + session.selectLayer(first); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{first}); + QCOMPARE(session.activeLayer().value().name, QString("Layer 1")); + session.selectLayer(std::nullopt); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>()); + // An id the document does not hold selects no layer. + session.setActiveLayerID(QUuid::createUuid()); + QVERIFY(!session.activeLayer().has_value()); +} + +void EditorSessionTests::aBusyProjectKeepsItsTool() +{ + EditorSession session; + QCOMPARE(session.tool(), NavigationTool::move); + session.selectTool(NavigationTool::hand); + QCOMPARE(session.tool(), NavigationTool::hand); + session.setIsProjectBusy(true); + session.selectTool(NavigationTool::zoom); + QCOMPARE(session.tool(), NavigationTool::hand); +} + +void EditorSessionTests::everyGateClosesLayerEdits_data() +{ + QTest::addColumn<int>("gate"); + QTest::newRow("a busy project") << 0; + QTest::newRow("an import") << 1; + QTest::newRow("the new canvas sheet") << 2; + QTest::newRow("the importer") << 3; + QTest::newRow("a rename") << 4; +} + +void EditorSessionTests::everyGateClosesLayerEdits() +{ + QFETCH(int, gate); + EditorSession session; + QVERIFY(!session.canEditLayers()); + session.createDocument(64, 64); + session.addBlankLayer(); + QVERIFY(session.canEditLayers()); + const auto set = [&](bool on) { + if (gate == 0) + session.setIsProjectBusy(on); + else if (gate == 1) + session.setIsImporting(on); + else if (gate == 2) + session.setShowsNewDocument(on); + else if (gate == 3) + session.setShowsImporter(on); + else + session.setRenamingLayerID(on ? std::optional(QUuid::createUuid()) : std::nullopt); + }; + set(true); + QVERIFY(!session.canEditLayers()); + session.addBlankLayer(); + QCOMPARE(int(session.document().value().layers.size()), 1); + set(false); + QVERIFY(session.canEditLayers()); + // An import error stops history, not layer edits. + session.setImportError(QString("failed")); + QVERIFY(session.canEditLayers() && !session.canUseHistory()); +} + +void EditorSessionTests::tabSwitchesEachToolsShapeOrMode() +{ + EditorSession session; + session.createDocument(20, 20); + session.addBlankLayer(); + session.selectTool(NavigationTool::marquee); + session.cycleToolMode(); + QCOMPARE(session.marqueeKind(), LassoKind::ellipse); + session.selectTool(NavigationTool::lasso); + session.cycleToolMode(); + QCOMPARE(session.lassoKind(), LassoKind::polygonal); + session.cycleToolMode(); + QCOMPARE(session.lassoKind(), LassoKind::freehand); + QCOMPARE(session.marqueeKind(), LassoKind::ellipse); + session.selectTool(NavigationTool::brush); + session.cycleToolMode(); + QCOMPARE(session.brushMode(), BrushToolMode::erase); + // Busy or mid-stroke, nothing switches, and nothing is said. + QSignalSpy changes(&session, &EditorSession::changed); + session.setIsProjectBusy(true); + changes.clear(); + session.cycleToolMode(); + session.setIsProjectBusy(false); + session.beginBrush(QPointF(5, 5)); + changes.clear(); + session.cycleToolMode(); + QCOMPARE(changes.count(), 0); + QCOMPARE(session.brushMode(), BrushToolMode::erase); + session.cancelBrush(); + // A tool without modes stays silent. + session.selectTool(NavigationTool::hand); + changes.clear(); + session.cycleToolMode(); + QCOMPARE(changes.count(), 0); +} + +void EditorSessionTests::theFirstImageMakesTheCanvas() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + session.viewport.setZoom(3, session.viewport.center(), QSizeF(64, 32)); + session.insert(asset(64, 32, "First"), QPointF(999, 999)); + const CanvasDocument document = session.document().value(); + QCOMPARE(document.size(), QSizeF(64, 32)); + QVERIFY(session.viewport.followsFit()); + QCOMPARE(session.history.undoName(), QString("Import Image")); + // The drop point still places the layer. + QCOMPARE(document.layers[0].origin(), QPointF(967, 983)); + QCOMPARE(session.activeLayerID(), std::optional(document.layers[0].id)); + session.undo(); + QVERIFY(!session.document().has_value()); +} + +void EditorSessionTests::imagesLandCentredOnWholePixels() +{ + EditorSession session; + session.createDocument(101, 51); + session.insert(asset(64, 32, "Centred")); + // Half pixels fall to the left and up. + QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9)); + QCOMPARE(session.activeLayer().value().size(), QSizeF(64, 32)); + // Odd in odd centres exactly: both halves count. + session.insert(asset(65, 33, "Odd")); + QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9)); + session.insert(asset(64, 33, "Dropped"), QPointF(10.25, -4.5)); + QCOMPARE(session.activeLayer().value().origin(), QPointF(-22, -21)); + QCOMPARE(names(session), (QStringList{"Centred", "Odd", "Dropped"})); + QCOMPARE(session.document().value().size(), QSizeF(101, 51)); +} + +void EditorSessionTests::newLayersJoinTheActiveFolder() +{ + EditorSession session; + session.createDocument(64, 64); + session.addBlankLayer(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + // Beside a layer in the folder, the next joins it. + session.addBlankLayer(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + session.insert(asset(4, 4, "Image")); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + session.selectLayer(folder); + session.toggleGroupExpansion(folder); + QVERIFY(session.collapsedGroupIDs().contains(folder)); + // Filling a folded folder opens it, above all it holds. + session.addBlankLayer(); + QVERIFY(!session.collapsedGroupIDs().contains(folder)); + QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Image", "Layer 4"})); + session.selectLayer(folder); + session.toggleGroupExpansion(folder); + session.insert(asset(4, 4, "Inside")); + QVERIFY(!session.collapsedGroupIDs().contains(folder)); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + // A nested folder's contents count as the outer folder's. + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + session.addBlankLayer(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(inner)); + session.selectLayer(folder); + session.addBlankLayer(); + QCOMPARE(names(session).last(), QString("Layer 6")); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + session.selectLayer(std::nullopt); + session.insert(asset(4, 4, "Outside")); + QCOMPARE(session.activeLayer().value().parentID, std::nullopt); +} + +void EditorSessionTests::foldersAreNamedPlacedAndLimited() +{ + EditorSession session; + session.createDocument(64, 64); + session.addBlankLayer(); + session.addBlankLayer(); + session.selectLayer(session.document().value().layers[0].id); + session.addGroup(); + QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2"})); + QCOMPARE(session.history.undoName(), QString("New Folder")); + const ImageLayer folder = session.activeLayer().value(); + QVERIFY(folder.isGroup && !folder.asset.has_value()); + QCOMPARE(folder.size(), QSizeF(64, 64)); + QCOMPARE(folder.parentID, std::nullopt); + // Each new folder nests in the active one, 64 deep. + for (int depth = 0; depth < 70; ++depth) + session.addGroup(); + QCOMPARE(int(session.document().value().layers.size()), 2 + 64); + QCOMPARE(session.activeLayer().value().name, QString("Folder 64")); + session.toggleGroupExpansion(folder.id); + QCOMPARE(session.activeLayerID(), std::optional(folder.id)); + session.addGroup(); + QVERIFY(!session.collapsedGroupIDs().contains(folder.id)); + // Ten thousand layers leave no room for a folder. + EditorSession crowded; + crowded.createDocument(8, 8); + crowded.beginEdit("Fill"); + const ImportedImage dot = asset(1, 1, "Dot"); + for (int index = 0; index < 9'999; ++index) + crowded.insert(dot); + crowded.endEdit(); + crowded.addGroup(); + QCOMPARE(int(crowded.document().value().layers.size()), 10'000); + crowded.addGroup(); + QCOMPARE(int(crowded.document().value().layers.size()), 10'000); +} + +void EditorSessionTests::aWideFolderIsWalkedOnce() +{ + EditorSession session; + session.createDocument(8, 8); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.beginEdit("Fill"); + const ImportedImage dot = asset(1, 1, "Dot"); + for (int index = 0; index < 9'999; ++index) { + session.selectLayer(folder); + session.insert(dot); + } + session.endEdit(); + // Copying the layers for each child would take minutes. + QElapsedTimer timer; + timer.start(); + QCOMPARE(int(session.descendantIDs(folder).size()), 9'999); + QVERIFY2(timer.elapsed() < 2'000, qPrintable(QString::number(timer.elapsed()))); +} + +void EditorSessionTests::foldingAFolderTakesTheSelectionFromItsContents() +{ + EditorSession session; + session.createDocument(64, 64); + session.addBlankLayer(); + const QUuid outside = session.activeLayerID().value(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid deep = session.activeLayerID().value(); + QCOMPARE(session.descendantIDs(folder), (QSet<QUuid>{inner, deep})); + QCOMPARE(session.descendantIDs(inner), QSet<QUuid>{deep}); + QCOMPARE(session.descendantIDs(deep), QSet<QUuid>()); + QCOMPARE(session.descendantIDs(QUuid::createUuid()), QSet<QUuid>()); + QCOMPARE(EditorSession().descendantIDs(folder), QSet<QUuid>()); + session.toggleGroupExpansion(folder); + QCOMPARE(session.activeLayerID(), std::optional(folder)); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder}); + session.toggleGroupExpansion(folder); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>()); + // A selection outside stays; layers and strangers do not fold. + session.selectLayer(outside); + session.toggleGroupExpansion(inner); + QCOMPARE(session.activeLayerID(), std::optional(outside)); + session.toggleGroupExpansion(outside); + session.toggleGroupExpansion(QUuid::createUuid()); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{inner}); + session.setIsProjectBusy(true); + session.toggleGroupExpansion(inner); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{inner}); +} + +void EditorSessionTests::deletingAFolderTakesItsContents() +{ + EditorSession session; + session.createDocument(64, 64); + session.addBlankLayer(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + session.addGroup(); + session.addBlankLayer(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Folder 2", "Layer 3", "Layer 4"})); + session.selectLayer(layerNamed(session, "Layer 3").id); + const int count = session.history.undoCount(); + session.deleteLayer(folder); + QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4"})); + // The folder sat at index 1; its successor is selected. + QCOMPARE(session.activeLayer().value().name, QString("Layer 4")); + QCOMPARE(session.history.undoCount(), count + 1); + session.undo(); + QCOMPARE(int(session.document().value().layers.size()), 6); + QCOMPARE(session.activeLayer().value().name, QString("Layer 3")); + // Deleting something else leaves the selection alone. + session.deleteLayer(layerNamed(session, "Layer 4").id); + QCOMPARE(session.activeLayer().value().name, QString("Layer 3")); +} + +void EditorSessionTests::movingStaysAmongSiblings() +{ + EditorSession session; + session.createDocument(64, 64); + session.addBlankLayer(); + session.addGroup(); + session.addBlankLayer(); + session.addBlankLayer(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Layer 4"})); + // Layer 4's siblings: Layer 1 and the folder, both below. + QVERIFY(session.canMoveActiveLayer(-2) && !session.canMoveActiveLayer(-3) && !session.canMoveActiveLayer(1)); + session.moveActiveLayer(-1); + QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 2", "Layer 3", "Folder 1"})); + // Inside the folder two layers trade places and no more. + session.selectLayer(layerNamed(session, "Layer 2").id); + QVERIFY(session.canMoveActiveLayer(1) && !session.canMoveActiveLayer(-1) && !session.canMoveActiveLayer(2)); + session.moveActiveLayer(1); + QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 3", "Layer 2", "Folder 1"})); + session.selectLayer(std::nullopt); + QVERIFY(!session.canMoveActiveLayer(0)); +} + +void EditorSessionTests::aNewCanvasCanStartWithALayer() +{ + EditorSession session; + session.setShowsNewDocument(true); + session.setRenamingLayerID(QUuid::createUuid()); + session.createDocument(640, 480, true); + const CanvasDocument document = session.document().value(); + QCOMPARE(int(document.layers.size()), 1); + QCOMPARE(session.activeLayer().value().name, QString("Layer 1")); + QVERIFY(!session.activeLayer().value().asset.has_value()); + QCOMPARE(session.activeLayer().value().size(), QSizeF(640, 480)); + QVERIFY(!session.showsNewDocument() && !session.renamingLayerID().has_value()); + QCOMPARE(document.resolution, 72.0); + // A plain canvas selects nothing; busy or importing makes none. + session.createDocument(10, 10); + QVERIFY(!session.activeLayerID().has_value() && session.document().value().layers.empty()); + session.setIsProjectBusy(true); + session.createDocument(20, 20); + session.setIsProjectBusy(false); + session.setIsImporting(true); + session.createDocument(30, 30); + QCOMPARE(session.document().value().size(), QSizeF(10, 10)); +} + +void EditorSessionTests::sessionChangesAreLogged() +{ + EditorSession session; + QTest::ignoreMessage(QtInfoMsg, "new canvas 12 x 34"); + session.createDocument(12, 34); + session.addBlankLayer(); + QTest::ignoreMessage(QtInfoMsg, "restored a history snapshot of 0 layers"); + session.undo(); +} + +// Alt-dragging a row drops a duplicate, one step. +void EditorSessionTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep() +{ + EditorSession session; + session.createDocument(800, 600); + for (int each = 0; each < 3; ++each) + session.addBlankLayer(); + const std::vector<LayerHierarchy::Entry> rows = session.layerRows(); + const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer; + const std::optional<CanvasDocument> before = session.document(); + const int count = session.history.undoCount(); + QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id)); + const std::vector<LayerHierarchy::Entry> after = session.layerRows(); + QCOMPARE(after.size(), size_t(4)); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Duplicate Layer")); + QCOMPARE(after.front().layer.name, bottom.name + " copy"); + QCOMPARE(after.front().layer.id, session.activeLayerID()); + QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0); + session.undo(); + QCOMPARE(session.document(), before); + // Folders are not duplicated this way; neither is a stranger. + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true)); + QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt)); + QVERIFY(!session.duplicateLayer(bottom.id, bottom.id)); + QCOMPARE(session.layerRows().size(), size_t(4)); + // Into a folder, at its top. + QVERIFY(session.duplicateLayer(bottom.id, folder)); + QCOMPARE(layerWith(session, session.activeLayerID().value()).parentID, std::optional(folder)); + session.setIsImporting(true); + QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt)); + session.setIsImporting(false); + // The copy sits right above; a folder makes none. + session.selectLayer(top.id); + session.duplicateActiveLayer(); + const int original = indexOf(session.document().value().layers, top.id); + QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy"); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id)); + const size_t kept = session.document().value().layers.size(); + session.selectLayer(folder); + session.duplicateActiveLayer(); + QCOMPARE(session.document().value().layers.size(), kept); +} + +QTEST_GUILESS_MAIN(EditorSessionTests) +#include "EditorSessionTests.moc" diff --git a/tests/EffectsCanvasTests.cpp b/tests/EffectsCanvasTests.cpp new file mode 100644 index 0000000..d06668f --- /dev/null +++ b/tests/EffectsCanvasTests.cpp @@ -0,0 +1,422 @@ +#include "Document/LayerEffects+Renderer.h" +#include "IO/ImageExporter.h" +#include "Rendering/EditorCanvas.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include <QtTest> + +// Swift's effects on the canvas: previews, the paint's surface, distortions. +namespace { +const QColor yellow(255, 255, 0); + +LayerEffects outline(double size) +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + return effects; +} + +// White document, a square 25..35 by 15..25, at 100%. +struct Scene { + EditorSession session; + CanvasView canvas{session}; + QUuid square; + explicit Scene(QRgb colour = qRgba(0, 0, 255, 255)) + { + session.createDocument(60, 40); + session.setShowsTransformControls(false); + canvas.resize(300, 200); + session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40)); + session.zoom(1); + const QImage white = solid(60, 40, qRgba(255, 255, 255, 255)); + session.insert(ImportedImage(white, white, QStringLiteral("White"))); + const QImage pixels = solid(10, 10, colour); + session.insert(ImportedImage(pixels, pixels, QStringLiteral("Square")), QPointF(30, 20)); + square = session.activeLayerID().value(); + } + QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); } + // The colour shown at a document pixel's centre. + QColor at(QPoint pixel) + { + return shot().pixelColor(shown(QPointF(pixel) + QPointF(0.5, 0.5))); + } + // The view pixel holding a document point. + QPoint shown(QPointF point) + { + const QPointF view = session.viewport.viewPoint(point, QSizeF(60, 40)); + return QPoint(int(std::floor(view.x())), int(std::floor(view.y()))); + } + // Drawn once, then the worker's preview awaited. + EffectsPreviewCache::Result landed() + { + shot(); + if (!QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 5000)) + throw std::runtime_error("no preview landed"); + return session.effectsPreviews.rendered(square).value(); + } + // The worst channel gap to the export, pixel by pixel. + int gapToExport() + { + const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); + const QImage image = shot(); + int worst = 0; + for (int y = 0; y < 40; ++y) { + for (int x = 0; x < 60; ++x) { + const QRgb expected = exported.pixel(x, y); + const QRgb actual = image.pixel(shown(QPointF(x + 0.5, y + 0.5))); + worst = std::max({worst, std::abs(qRed(expected) - qRed(actual)), std::abs(qGreen(expected) - qGreen(actual)), + std::abs(qBlue(expected) - qBlue(actual))}); + } + } + return worst; + } +}; + +// Counts the paints a widget receives. +struct Painted : QObject { + int &count; + explicit Painted(int &count) : count(count) {} + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +// A pixel only the warp makes yellow, clear of handles. +std::optional<QPoint> warpedOnly(const DistortWarp::Warped &warped, const QRectF &unwarped, const std::vector<QPointF> &handles) +{ + const QImage image = warped.image.convertToFormat(QImage::Format_ARGB32); + const QTransform toImage = BrushRaster::pixelToDocument(warped.transform, image.width(), image.height()).inverted(); + const auto yellowAt = [&](QPointF point) { + const QPointF mapped = toImage.map(point); + const QPoint pixel(int(std::floor(mapped.x())), int(std::floor(mapped.y()))); + return image.rect().contains(pixel) && image.pixel(pixel) == qRgba(255, 255, 0, 255); + }; + for (int y = 0; y < 40; ++y) { + for (int x = 0; x < 60; ++x) { + const QPointF centre(x + 0.5, y + 0.5); + const bool nearHandle = std::any_of(handles.begin(), handles.end(), [&](QPointF handle) { + return std::abs(handle.x() - centre.x()) < 7 && std::abs(handle.y() - centre.y()) < 7; + }); + if (nearHandle || unwarped.adjusted(-1, -1, 1, 1).contains(centre)) + continue; + if (yellowAt(centre) && yellowAt(centre + QPointF(1, 0)) && yellowAt(centre - QPointF(1, 0)) && yellowAt(centre + QPointF(0, 1)) + && yellowAt(centre - QPointF(0, 1))) + return QPoint(x, y); + } + } + return std::nullopt; +} +} + +class EffectsCanvasTests : public QObject { + Q_OBJECT +private slots: + void theCanvasDrawsALayersEffectsOnceTheyLand(); + void aLandedPreviewRepaintsTheView(); + void aPlacedMaskClipsThePreviewAsItClipsTheExport(); + void changingEffectsRepaintsTheView(); + void aDeletedLayersPreviewGoesWithIt(); + void aPendingDistortionWarpsTheEffectsAndApplyingKeepsThem(); + void paintKeepsItsEffectsAndHandsThemOn(); + void aSurfaceIsMadeAnewForOtherSettings(); + void paintOnAMaskedLayerKeepsItsMask(); + void paintPastTheSurfacesBudgetShowsTheLastEffects(); + void invalidEffectsShowNothing(); + void effectsDrawAheadOfAColourEditsPreview(); +}; + +void EffectsCanvasTests::theCanvasDrawsALayersEffectsOnceTheyLand() +{ + Scene scene; + LayerEffects effects = outline(2); + effects.shadow = ShadowEffect{.angle = 90, .distance = 6, .blur = 0, .red = 1, .green = 0, .blue = 0, .opacity = 1}; + scene.session.setEffects(effects); + // Until the worker lands, the layer alone. + QCOMPARE(scene.at(QPoint(23, 20)), QColor(Qt::white)); + QTRY_COMPARE(scene.at(QPoint(23, 20)), yellow); + QCOMPARE(scene.at(QPoint(30, 29)), QColor(Qt::red)); + QCOMPARE(scene.at(QPoint(30, 20)), QColor(Qt::blue)); + QCOMPARE(scene.gapToExport(), 0); +} + +void EffectsCanvasTests::aLandedPreviewRepaintsTheView() +{ + Scene scene; + QWidget window; + CanvasView &canvas = *new CanvasView(scene.session, &window); + canvas.resize(300, 200); + int paints = 0; + Painted counter(paints); + canvas.installEventFilter(&counter); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTest::qWait(100); + const int shown = paints; + scene.session.setEffects(outline(2)); + canvas.synchronizeDisplay(); + QVERIFY(QTest::qWaitFor([&] { return paints > shown; }, 5000)); + // The worker's result arrives later and asks for a paint. + const int before = paints; + QVERIFY(!scene.session.effectsPreviews.rendered(scene.square)); + QTRY_VERIFY(scene.session.effectsPreviews.rendered(scene.square) && paints > before); +} + +void EffectsCanvasTests::aPlacedMaskClipsThePreviewAsItClipsTheExport() +{ + Scene scene; + // Left half hidden, the mask placed two pixels right. + QImage half = gray(10, 10, 255); + for (int y = 0; y < 10; ++y) + std::fill_n(half.scanLine(y), 5, uchar(0)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, scene.square, LayerMask::assetFrom(half)); + record(snapshot, scene.square).maskPlacement = placedAt(QPointF(27, 15), QSizeF(10, 10)); + record(snapshot, scene.square).maskLinked = false; + record(snapshot, scene.square).effects = outline(2); + }); + scene.session.zoom(1); + scene.landed(); + // Swift asks first with the mask unplaced; the twin once. + QCOMPARE(scene.gapToExport(), 0); + QCOMPARE(scene.at(QPoint(30, 20)), yellow); + QCOMPARE(scene.at(QPoint(33, 20)), QColor(Qt::blue)); +} + +void EffectsCanvasTests::changingEffectsRepaintsTheView() +{ + Scene scene; + QVERIFY(scene.canvas.synchronizeDisplay()); + QVERIFY(!scene.canvas.synchronizeDisplay()); + scene.session.setEffects(outline(2)); + QVERIFY(scene.canvas.synchronizeDisplay()); +} + +void EffectsCanvasTests::aDeletedLayersPreviewGoesWithIt() +{ + Scene scene; + scene.session.setEffects(outline(2)); + scene.landed(); + scene.session.deleteLayerOrMask(); + scene.shot(); + QVERIFY(!scene.session.effectsPreviews.rendered(scene.square)); +} + +void EffectsCanvasTests::aPendingDistortionWarpsTheEffectsAndApplyingKeepsThem() +{ + Scene scene; + scene.session.setEffects(outline(2)); + const EffectsPreviewCache::Result effects = scene.landed(); + EditorSession &session = scene.session; + session.beginTransform(); + session.beginDistort(); + // The top right corner pulled far up and out. + const Corners corners{QPointF(25, 15), QPointF(55, 5), QPointF(35, 25), QPointF(25, 25)}; + session.previewCorners(corners); + const ImageLayer layer = layerWith(session, scene.square); + const std::optional<DistortWarp::Warped> warped = session.distortedEffects(layer, effects.image, effects.inset); + // The effects' grown box takes the layer's perspective. + const LayerTransform grown = LayerEffectsRenderer::placed(layer.transform, effects.image, effects.inset); + const DistortWarp::Warped expected = DistortWarp::warp(effects.image, grown, DistortWarp::carried(grown, layer.transform, corners), false, 2048); + QVERIFY(warped.value().image == expected.image && warped.value().transform == expected.transform); + QCOMPARE(session.distortedEffects(layer, effects.image, effects.inset).value().image.cacheKey(), warped.value().image.cacheKey()); + const std::vector<QPointF> handles{corners[0], corners[1], corners[2], corners[3], QPointF(40, 10), QPointF(45, 15), QPointF(30, 25), QPointF(25, 20)}; + const QPoint outside = warpedOnly(warped.value(), QRectF(21, 11, 18, 18), handles).value(); + QCOMPARE(scene.at(outside), yellow); + // A layer outside the edit keeps its own. + QVERIFY(!session.distortedEffects(session.document().value().layers.front(), effects.image, effects.inset)); + // Past 2048 pixels a side the warp is drawn smaller. + session.previewCorners({QPointF(25, 15), QPointF(2500, 15), QPointF(2500, 25), QPointF(25, 25)}); + const DistortWarp::Warped far = session.distortedEffects(layer, effects.image, effects.inset).value(); + QVERIFY(far.image.size() == QSize(2048, 9) && far.transform.size == QSizeF(4456, 19)); + // New corners or other effects warp anew. + session.previewCorners({QPointF(25, 15), QPointF(54, 5), QPointF(35, 25), QPointF(25, 25)}); + const qint64 moved = session.distortedEffects(layer, effects.image, effects.inset).value().image.cacheKey(); + QVERIFY(moved != warped.value().image.cacheKey()); + QVERIFY(session.distortedEffects(layer, effects.image.copy(), effects.inset).value().image.cacheKey() != moved); + session.previewCorners(corners); + const std::optional<DistortWarp::Warped> again = session.distortedEffects(layer, effects.image, effects.inset); + // Applied: the warped effects show until new ones land. + session.commitTransform(); + const EffectsPreviewCache::Result seeded = session.effectsPreviews.rendered(scene.square).value(); + QVERIFY(seeded.image.cacheKey() == again.value().image.cacheKey() && seeded.placement == warped.value().transform); + QCOMPARE(scene.at(outside), yellow); + QTRY_VERIFY(!session.effectsPreviews.rendered(scene.square).value().placement); + // A plain transform, or a mask's distortion, warps nothing. + session.beginTransform(); + QVERIFY(!session.distortedEffects(layerWith(session, scene.square), effects.image, effects.inset)); + session.cancelTransform(); + session.addLayerMask(true); + session.toggleMaskLink(scene.square); + session.selectLayerTarget(scene.square, true); + session.beginTransform(); + session.beginDistort(); + session.previewCorners(corners); + QVERIFY(session.transformEdit().value().mask && session.transformEdit().value().corners); + QVERIFY(!session.distortedEffects(layerWith(session, scene.square), effects.image, effects.inset)); + session.cancelTransform(); +} + +void EffectsCanvasTests::paintKeepsItsEffectsAndHandsThemOn() +{ + Scene scene; + EditorSession &session = scene.session; + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + settings.red = 1; + session.setBrushSettings(settings); + // Without effects a stroke makes no surface to hand on. + session.beginBrush(QPointF(45, 10)); + QCOMPARE(scene.at(QPoint(45, 10)), QColor(Qt::red)); + QVERIFY(session.finishBrushImmediately()); + scene.canvas.synchronizeDisplay(); + QVERIFY(!session.effectsPreviews.rendered(scene.square)); + // An overlay covers the wet paint too, as it will. + LayerEffects effects = outline(2); + effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 1}; + session.setEffects(effects); + scene.landed(); + session.beginBrush(QPointF(45, 20)); + session.continueBrush(QPointF(55, 20)); + QCOMPARE(scene.at(QPoint(50, 16)), yellow); + QCOMPARE(scene.at(QPoint(50, 20)), QColor(Qt::green)); + // Nothing is handed on while the stroke lasts. + scene.canvas.synchronizeDisplay(); + QVERIFY(!session.effectsPreviews.rendered(scene.square).value().placement); + QVERIFY(session.finishBrushImmediately()); + // The surface is handed on once, so nothing blinks. + scene.canvas.synchronizeDisplay(); + QVERIFY(session.effectsPreviews.rendered(scene.square).value().placement); + QCOMPARE(scene.at(QPoint(50, 16)), yellow); + QTRY_VERIFY(!session.effectsPreviews.rendered(scene.square).value().placement); + scene.canvas.synchronizeDisplay(); + QVERIFY(!session.effectsPreviews.rendered(scene.square).value().placement); + QCOMPARE(scene.gapToExport(), 0); +} + +void EffectsCanvasTests::aSurfaceIsMadeAnewForOtherSettings() +{ + Scene scene; + EditorSession &session = scene.session; + session.setEffects(outline(2)); + scene.landed(); + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + settings.red = 1; + session.setBrushSettings(settings); + session.beginBrush(QPointF(45, 20)); + session.continueBrush(QPointF(55, 20)); + QCOMPARE(scene.at(QPoint(50, 16)), yellow); + QVERIFY(session.finishBrushImmediately()); + // A canvas alone follows nothing: the old surface stays. + LayerEffects cyan = outline(2); + cyan.stroke->red = 0; + cyan.stroke->blue = 1; + session.setEffects(cyan); + session.beginBrush(QPointF(45, 32)); + session.continueBrush(QPointF(55, 32)); + QCOMPARE(scene.at(QPoint(50, 28)), QColor(0, 255, 255)); + session.cancelBrush(); +} + +void EffectsCanvasTests::paintOnAMaskedLayerKeepsItsMask() +{ + Scene scene; + QImage half = gray(10, 10, 255); + for (int y = 0; y < 10; ++y) + std::fill_n(half.scanLine(y), 5, uchar(0)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, scene.square, LayerMask::assetFrom(half)); + record(snapshot, scene.square).effects = outline(2); + }); + scene.session.zoom(1); + scene.landed(); + EditorSession &session = scene.session; + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + settings.red = 1; + session.setBrushSettings(settings); + session.beginBrush(QPointF(45, 20)); + session.continueBrush(QPointF(55, 20)); + // Hidden half hidden, unoutlined; paint past the layer shows. + QCOMPARE(scene.at(QPoint(27, 20)), QColor(Qt::white)); + QCOMPARE(scene.at(QPoint(23, 20)), QColor(Qt::white)); + QCOMPARE(scene.at(QPoint(32, 20)), QColor(Qt::blue)); + QCOMPARE(scene.at(QPoint(50, 16)), yellow); + session.cancelBrush(); +} + +void EffectsCanvasTests::paintPastTheSurfacesBudgetShowsTheLastEffects() +{ + Scene scene; + // Scaled down 200 times, the stroke's grid passes 80 million. + const QImage fine = solid(2000, 2000, qRgba(0, 0, 255, 255)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + snapshot.images.insert_or_assign(scene.square, ImportedImage(fine, fine, QStringLiteral("Square"))); + record(snapshot, scene.square).transform = placedAt(QPointF(25, 15), QSizeF(10, 10)); + record(snapshot, scene.square).effects = outline(400); + }); + scene.session.zoom(1); + scene.landed(); + EditorSession &session = scene.session; + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + settings.red = 1; + session.setBrushSettings(settings); + session.beginBrush(QPointF(45, 20)); + session.continueBrush(QPointF(55, 20)); + // No surface: the last effects show, the paint without them. + QCOMPARE(scene.at(QPoint(23, 20)), yellow); + QCOMPARE(scene.at(QPoint(50, 20)), QColor(Qt::red)); + QCOMPARE(scene.at(QPoint(50, 16)), QColor(Qt::white)); + session.cancelBrush(); + // A seed stands in where it belongs. + session.effectsPreviews.seed(scene.square, solid(8, 8, qRgba(0, 255, 0, 255)), placedAt(QPointF(2, 2), QSizeF(8, 8))); + session.beginBrush(QPointF(45, 20)); + QCOMPARE(scene.at(QPoint(5, 5)), QColor(Qt::green)); + QCOMPARE(scene.at(QPoint(23, 20)), QColor(Qt::white)); + session.cancelBrush(); +} + +void EffectsCanvasTests::invalidEffectsShowNothing() +{ + Scene scene; + // A stroke past Swift's 500 pixels, which files may hold. + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, scene.square).effects = outline(600); }); + scene.session.zoom(1); + QCOMPARE(scene.at(QPoint(23, 20)), QColor(Qt::white)); + QTest::qWait(150); + QVERIFY(!scene.session.effectsPreviews.rendered(scene.square)); + // Painting makes no surface for them either. + EditorSession &session = scene.session; + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + session.setBrushSettings(settings); + session.beginBrush(QPointF(45, 20)); + QCOMPARE(scene.at(QPoint(45, 16)), QColor(Qt::white)); + session.cancelBrush(); +} + +void EffectsCanvasTests::effectsDrawAheadOfAColourEditsPreview() +{ + Scene scene(qRgba(100, 100, 100, 255)); + scene.session.setEffects(outline(2)); + scene.landed(); + LevelsSettings inverted; + inverted.setCurrent(LevelRange{0, 1, 255, 255, 0}); + scene.session.beginLevels(); + scene.session.updateLevels(inverted, true); + QTRY_VERIFY(scene.session.levels().value().preparedPreview); + // Swift draws the effects first: the layer shows unadjusted. + QCOMPARE(scene.at(QPoint(30, 20)), QColor(100, 100, 100)); + QCOMPARE(scene.at(QPoint(23, 20)), yellow); +} + +QTEST_MAIN(EffectsCanvasTests) +#include "EffectsCanvasTests.moc" diff --git a/tests/EffectsFailureTests.cpp b/tests/EffectsFailureTests.cpp new file mode 100644 index 0000000..c94945e --- /dev/null +++ b/tests/EffectsFailureTests.cpp @@ -0,0 +1,93 @@ +#include "AddressSpaceLimit.h" +#include "Document/LayerEffects+Renderer.h" +#include "Rendering/EditorCanvas.h" +#include "Rendering/LayerEffectsSurface.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include <QtTest> +#include <malloc.h> + +// A painted layer's effects surface, starved of memory. +class EffectsFailureTests : public QObject { + Q_OBJECT +private slots: + // Large asks map memory, so the address limit counts them. + void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); } + // First, while malloc's heap holds nothing a limit would miss. + void aStarvedLaterPassKeepsTheLastSurface(); + void aSurfaceWithoutMemoryLeavesTheLastEffects(); +}; + +void EffectsFailureTests::aSurfaceWithoutMemoryLeavesTheLastEffects() +{ + EditorSession session; + CanvasView canvas(session); + session.createDocument(60, 40); + session.setShowsTransformControls(false); + canvas.resize(300, 200); + session.viewport.resize(QSizeF(300, 200), 1, QSizeF(60, 40)); + session.zoom(1); + const QImage white = solid(60, 40, qRgba(255, 255, 255, 255)); + session.insert(ImportedImage(white, white, QStringLiteral("White"))); + // Eighty pixels a document pixel: a 4800 by 3200 grid. + const QImage fine = solid(800, 800, qRgba(0, 0, 255, 255)); + session.insert(ImportedImage(fine, fine, QStringLiteral("Square"))); + const QUuid square = session.activeLayerID().value(); + LayerEffects effects; + effects.stroke = StrokeEffect{.size = 160, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, square).transform = placedAt(QPointF(25, 15), QSizeF(10, 10)); + record(snapshot, square).effects = effects; + }); + session.zoom(1); + const auto shown = [&](QPoint pixel) { + const QPointF view = session.viewport.viewPoint(QPointF(pixel) + QPointF(0.5, 0.5), QSizeF(60, 40)); + return canvas.grab().toImage().pixelColor(int(std::floor(view.x())), int(std::floor(view.y()))); + }; + shown(QPoint(0, 0)); + QVERIFY(QTest::qWaitFor([&] { return session.effectsPreviews.rendered(square).has_value(); }, 10000)); + session.selectTool(NavigationTool::brush); + BrushSettings settings = session.brushSettings(); + settings.diameter = 4; + settings.red = 1; + session.setBrushSettings(settings); + session.beginBrush(QPointF(45, 20)); + session.continueBrush(QPointF(55, 20)); + { + // The surface is made; its first pass finds no memory. + const AddressSpaceLimit limit(200ll * 1024 * 1024); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("a painted layer's effects could not be redone")); + QCOMPARE(shown(QPoint(23, 20)), QColor(255, 255, 0)); + QCOMPARE(shown(QPoint(50, 20)), QColor(Qt::red)); + QCOMPARE(shown(QPoint(50, 16)), QColor(Qt::white)); + } + session.cancelBrush(); +} + +void EffectsFailureTests::aStarvedLaterPassKeepsTheLastSurface() +{ + if (!placesStarvation()) + QSKIP("the failure point is placed for Qt 6.4's allocations"); + const QImage base = solid(3000, 3000, qRgba(0, 0, 255, 255)); + LayerEffects effects; + effects.stroke = StrokeEffect{.size = 5, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + const std::unique_ptr<LayerEffectsSurface> surface = + LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(3000, 3000), QRectF(0, 0, 3000, 3000)); + const ImportedImage asset(base, QImage(), QStringLiteral("Layer")); + surface->update(asset, {}, std::nullopt); + const qint64 first = surface->image().value().cacheKey(); + const BrushPatch patch{QRectF(0, 0, 256, 256), solid(256, 256, qRgba(255, 0, 0, 255))}; + { + // The pass copies the whole surface; no memory is left. + const AddressSpaceLimit limit(16ll * 1024 * 1024); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("a painted layer's effects could not be redone")); + surface->update(asset, {patch}, std::nullopt); + } + QCOMPARE(surface->image().value().cacheKey(), first); + // With memory back, the tile is redone: never counted taken. + surface->update(asset, {patch}, std::nullopt); + QVERIFY(surface->image().value() == LayerEffectsRenderer::render(composite(base, {patch}), std::nullopt, effects).image); +} + +QTEST_MAIN(EffectsFailureTests) +#include "EffectsFailureTests.moc" diff --git a/tests/EffectsPanelTests.cpp b/tests/EffectsPanelTests.cpp new file mode 100644 index 0000000..5e9d2b4 --- /dev/null +++ b/tests/EffectsPanelTests.cpp @@ -0,0 +1,433 @@ +#include "ContentView.h" +#include "MenuFixtures.h" +#include "Rendering/EditorCanvas.h" +#include "UI/EffectsSheet.h" +#include "UI/LayerIcons.h" +#include "UI/LayersPanel.h" +#include <QApplication> +#include <QDialog> +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> +#include <QWheelEvent> +#include <QtTest> +#include <cmath> + +// Layer effects in the window: footer, panel, words. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +// A white layer in a shown editor. +struct Editor { + EditorSession session; + ContentView view{session}; + QUuid layer; + Editor() + { + session.createDocument(40, 30); + QImage white(40, 30, QImage::Format_RGBA8888_Premultiplied); + white.fill(Qt::white); + session.insert(ImportedImage(white, white, QStringLiteral("White"))); + layer = session.activeLayerID().value(); + view.show(); + } + // The panel's shown sheet; a closed panel's goes later. + EffectsSheet *sheet() + { + for (EffectsSheet *each : view.findChildren<EffectsSheet *>()) { + if (each->isVisible()) + return each; + } + return nullptr; + } + QDialog &panel() { return *qobject_cast<QDialog *>(sheet()->window()); } + // The shown panel holds the keys, as users find it. + bool keyed() { return QTest::qWaitFor([&] { return sheet() && QApplication::activeWindow() == &panel(); }); } + LayerEffects effects() const { return session.activeLayer().value().effects.value(); } +}; + +// Types into a field and stays there. +void typing(QLineEdit &field, const QString &text) +{ + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); +} + +// Types into a field, then leaves it. +void type(QLineEdit &field, const QString &text) +{ + typing(field, text); + field.clearFocus(); +} + +// Counts the paints a widget receives. +struct Painted : QObject { + int &count; + explicit Painted(int &count) : count(count) {} + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +bool inked(const QImage &image, int x, int y) +{ + return qAlpha(image.pixel(x, y)) > 128; +} +} + +class EffectsPanelTests : public QObject { + Q_OBJECT +private slots: + void theFooterAddsEachEffectAndOpensItsPanel(); + void theSheetEditsItsEffect(); + void aWheelOnTheSliderReplacesTheTyping(); + void theSheetKeepsSwiftsMeasures(); + void eachKindHasSwiftsControls_data(); + void eachKindHasSwiftsControls(); + void aMissingEffectHidesItsControls(); + void cancelAndEscapePutTheEffectBack(); + void anEffectThatGoesTakesItsPanel(); + void deletingNamesAChosenEffect(); + void aCanvasPressLetsGoOfTheChosenEffect(); +}; + +void EffectsPanelTests::theFooterAddsEachEffectAndOpensItsPanel() +{ + Editor editor; + auto &button = find<QToolButton>(editor.view, "layerEffects"); + QCOMPARE(button.toolTip(), QString("Layer effects: stroke and drop shadow")); + QCOMPARE(button.accessibleName(), QString("Layer effects")); + QCOMPARE(button.popupMode(), QToolButton::InstantPopup); + // Swift's sparkles, in the footer's ink: three four-pointed stars. + const LayersPanel &panel = editor.view.layersPanel(); + const QImage icon = LayerIcons::pixmap(LayerIcon::sparkles, 16, panel.palette().color(QPalette::PlaceholderText), panel.devicePixelRatio()).toImage(); + QCOMPARE(button.icon().pixmap(QSize(16, 16), panel.devicePixelRatio()).toImage().convertToFormat(icon.format()), icon); + const QImage glyph = LayerIcons::pixmap(LayerIcon::sparkles, 18, Qt::black, 1).toImage(); + QVERIFY(inked(glyph, 7, 10) && inked(glyph, 13, 4) && inked(glyph, 14, 13) && !inked(glyph, 2, 2) && !inked(glyph, 16, 9)); + QStringList names; + for (const QAction *entry : button.menu()->actions()) + names << entry->text(); + QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…"})); + QVERIFY(button.isEnabled()); + button.menu()->actions()[0]->trigger(); + QVERIFY((editor.session.effectsEditing() == LayerEffectSelection{editor.layer, LayerEffectKind::stroke})); + QCOMPARE(editor.panel().objectName(), QString("effectsPanel")); + QCOMPARE(editor.panel().windowTitle(), QString("Stroke")); + // Another kind cancels this panel: the new stroke goes. + button.menu()->actions()[1]->trigger(); + QCOMPARE(editor.panel().windowTitle(), QString("Drop Shadow")); + QVERIFY(!editor.effects().stroke && editor.effects().shadow); + editor.session.finishEffectsEditing(true); + QTRY_VERIFY(!editor.sheet()); + // A layer without pixels takes none. + editor.session.addBlankLayer(); + QVERIFY(!button.isEnabled()); +} + +void EffectsPanelTests::theSheetEditsItsEffect() +{ + Editor editor; + editor.session.addEffect(LayerEffectKind::stroke); + QVERIFY(editor.keyed()); + EffectsSheet &sheet = *editor.sheet(); + auto &size = find<QLineEdit>(sheet, "sizeField"); + auto &slider = find<QSlider>(sheet, "sizeSlider"); + QCOMPARE(size.text(), QString("4")); + type(size, "12"); + QCOMPARE(editor.effects().stroke->size, 12.0); + QCOMPARE(slider.value(), 22); + // No finite number changes nothing; the field goes back. + type(size, "abc"); + QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); + type(size, "inf"); + QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12"); + // Up steps one, Shift ten; past the range, clamped. + QTest::keyClick(&size, Qt::Key_Up); + QCOMPARE(editor.effects().stroke->size, 13.0); + QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(editor.effects().stroke->size, 23.0); + // A step replaces pending typing, even where it clamps. + typing(size, "12"); + QTest::keyClick(&size, Qt::Key_Up); + QVERIFY(editor.effects().stroke->size == 24 && size.text() == "24" && !size.isModified()); + size.clearFocus(); + QCOMPARE(editor.effects().stroke->size, 24.0); + type(size, "900"); + QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500"); + typing(size, "12"); + QTest::keyClick(&size, Qt::Key_Up); + QVERIFY(editor.effects().stroke->size == 500 && size.text() == "500" && !size.isModified()); + size.clearFocus(); + QCOMPARE(editor.effects().stroke->size, 500.0); + // The slider sets any value; the field rounds, ties even. + slider.setValue(500); + QVERIFY(editor.effects().stroke->size == 250.5 && size.text() == "250"); + // Leaving an untouched field keeps the exact value. + size.setFocus(); + size.clearFocus(); + QCOMPARE(editor.effects().stroke->size, 250.5); + // Typing waits while other changes arrive. + auto &opacity = find<QLineEdit>(sheet, "opacityField"); + QCOMPARE(opacity.text(), QString("100")); + typing(size, "3"); + find<QSlider>(sheet, "opacitySlider").setValue(500); + QVERIFY(editor.effects().stroke->opacity == 0.5 && opacity.text() == "50" && size.text() == "3"); + size.clearFocus(); + QCOMPARE(editor.effects().stroke->size, 3.0); + // Inside or Outside, as Swift's segments, following an undo. + const QList<QToolButton *> position = find<QWidget>(sheet, "strokePosition").findChildren<QToolButton *>(); + QVERIFY(position.size() == 2 && position[0]->text() == "Outside" && position[1]->text() == "Inside" && position[0]->isChecked()); + position[1]->click(); + QVERIFY(editor.effects().stroke->inside && position[1]->isChecked()); + editor.session.undo(); + QVERIFY(!editor.effects().stroke->inside && position[0]->isChecked()); + position[1]->click(); + // The swatch opens the picker, whose colour shows at once. + auto &swatch = find<QAbstractButton>(sheet, "effectColor"); + QVERIFY(swatch.toolTip() == "Stroke color" && swatch.accessibleName() == "Stroke color" && swatch.size() == QSize(36, 18)); + swatch.click(); + QVERIFY(editor.session.colorPicker().value().target.kind == ColorPickerTarget::Kind::effect); + QTest::qWait(100); + int paints = 0; + Painted counter(paints); + swatch.installEventFilter(&counter); + editor.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + QCOMPARE(editor.effects().stroke->color(), (PaletteColor{1, 0, 0})); + QTRY_VERIFY(paints > 0); + editor.session.closeColorPicker(true); + // Return commits, then goes on to OK, keeping everything. + typing(size, "7"); + QTest::keyClick(&size, Qt::Key_Return); + QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == 7 && editor.effects().stroke->inside); + QTRY_VERIFY(!editor.sheet()); +} + +void EffectsPanelTests::aWheelOnTheSliderReplacesTheTyping() +{ + Editor editor; + editor.session.addEffect(LayerEffectKind::stroke); + QVERIFY(editor.keyed()); + EffectsSheet &sheet = *editor.sheet(); + auto &size = find<QLineEdit>(sheet, "sizeField"); + auto &slider = find<QSlider>(sheet, "sizeSlider"); + // A wheel moves the slider; the field keeps the keys. + typing(size, "12"); + QWheelEvent wheel(QPointF(10, 5), slider.mapToGlobal(QPointF(10, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier, + Qt::NoScrollPhase, false); + QApplication::sendEvent(&slider, &wheel); + const double wheeled = 1 + 499.0 * (6 - QApplication::wheelScrollLines()) / 1000; + QCOMPARE(editor.effects().stroke->size, wheeled); + QVERIFY(size.hasFocus() && size.text() == QString::number(std::lround(wheeled)) && !size.isModified()); + // Return then keeps the slider's value, going on to OK. + QTest::keyClick(&size, Qt::Key_Return); + QVERIFY(!editor.session.effectsEditing() && editor.effects().stroke->size == wheeled); +} + +void EffectsPanelTests::theSheetKeepsSwiftsMeasures() +{ + Editor editor; + editor.session.addEffect(LayerEffectKind::stroke); + EffectsSheet &sheet = *editor.sheet(); + QTRY_VERIFY(sheet.isVisible()); + QCOMPARE(sheet.width(), 340); + auto &slider = find<QSlider>(sheet, "sizeSlider"); + auto &field = find<QLineEdit>(sheet, "sizeField"); + QWidget &row = *slider.parentWidget(); + QLabel *title = nullptr, *unit = nullptr; + for (QLabel *label : row.findChildren<QLabel *>()) { + if (label->text() == "Size") + title = label; + if (label->text() == "px") + unit = label; + } + // Title 64, slider 130, field 48, then the unit. + QVERIFY(title && unit && title->buddy() == &slider); + QVERIFY(title->x() == 0 && title->width() == 64 && slider.x() == 74 && slider.width() == 130); + QVERIFY(field.x() == 214 && field.width() == 48 && unit->x() == 264); + QVERIFY(field.alignment() == Qt::AlignRight && field.accessibleName() == "Size" && field.placeholderText() == "Size"); + // Twenty points round the sheet, sixteen between its rows. + const QWidget &colour = *find<QAbstractButton>(sheet, "effectColor").parentWidget(); + QCOMPARE(colour.x(), 20); + QCOMPARE(row.y() - colour.geometry().bottom() - 1, 16); + QCOMPARE(colour.findChild<QLabel *>()->width(), 64); + // Cancel, then the default OK ten points on. + const QPushButton &cancel = find<QPushButton>(sheet, "effectsCancel"), &ok = find<QPushButton>(sheet, "effectsOK"); + QVERIFY(ok.x() - cancel.geometry().right() - 1 == 10 && ok.geometry().right() == 319); + QVERIFY(ok.isDefault() && !cancel.autoDefault()); + // Swift's segments touch. + const QList<QToolButton *> position = find<QWidget>(sheet, "strokePosition").findChildren<QToolButton *>(); + QCOMPARE(position[1]->x(), position[0]->geometry().right() + 1); +} + +void EffectsPanelTests::eachKindHasSwiftsControls_data() +{ + QTest::addColumn<LayerEffectKind>("kind"); + QTest::addColumn<QString>("headline"); + QTest::addColumn<QStringList>("fields"); + const QStringList shadow{"Opacity:0:100", "Angle:-180:180", "Distance:0:300", "Blur:0:300"}; + QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:1:500", "Opacity:0:100"}; + QTest::newRow("shadow") << LayerEffectKind::shadow << QString("Drop Shadow") << shadow; + QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100"}; + QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow") << shadow; +} + +void EffectsPanelTests::eachKindHasSwiftsControls() +{ + QFETCH(LayerEffectKind, kind); + QFETCH(QString, headline); + QFETCH(QStringList, fields); + Editor editor; + editor.session.addEffect(kind); + QVERIFY(editor.keyed()); + EffectsSheet &sheet = *editor.sheet(); + QCOMPARE(editor.panel().windowTitle(), headline); + const QLabel *head = nullptr; + for (const QLabel *label : sheet.findChildren<QLabel *>()) { + if (label->text() == headline) + head = label; + } + QVERIFY(head && head->font().pixelSize() == 13 && head->font().weight() == QFont::DemiBold); + // Swift's fields in order, each clamped to its range's ends. + QStringList names; + for (const QLineEdit *field : sheet.findChildren<QLineEdit *>()) + names << field->accessibleName(); + QStringList titles; + for (const QString &spec : fields) { + const QStringList parts = spec.split(':'); + titles << parts[0]; + auto &field = *sheet.findChild<QLineEdit *>(parts[0].toLower() + "Field"); + type(field, "99999"); + QCOMPARE(field.text(), parts[2]); + type(field, "-99999"); + QCOMPARE(field.text(), parts[1]); + } + QCOMPARE(names, titles); +} + +void EffectsPanelTests::aMissingEffectHidesItsControls() +{ + Editor editor; + editor.session.addEffect(LayerEffectKind::stroke); + // For a kind the layer lacks, the headline alone. + EffectsSheet other(editor.session, LayerEffectKind::shadow); + QVERIFY(!find<QSlider>(other, "blurSlider").isVisibleTo(&other) && !find<QAbstractButton>(other, "effectColor").isVisibleTo(&other)); + editor.session.changeEffects([](LayerEffects &effects) { effects.shadow = ShadowEffect(); }); + QVERIFY(find<QSlider>(other, "blurSlider").isVisibleTo(&other) && find<QAbstractButton>(other, "effectColor").isVisibleTo(&other)); +} + +void EffectsPanelTests::cancelAndEscapePutTheEffectBack() +{ + Editor editor; + // Return on a focused Cancel still means OK, Swift's default. + editor.session.addEffect(LayerEffectKind::shadow); + QVERIFY(editor.keyed()); + type(find<QLineEdit>(*editor.sheet(), "angleField"), "-45"); + auto &cancel = find<QPushButton>(*editor.sheet(), "effectsCancel"); + cancel.setFocus(); + QTest::keyClick(&cancel, Qt::Key_Return); + QVERIFY(editor.effects().shadow->angle == -45 && !editor.session.effectsEditing()); + // An existing shadow: Cancel puts it back as it was. + editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true); + QCOMPARE(find<QLineEdit>(*editor.sheet(), "distanceField").text(), QString("20")); + find<QSlider>(*editor.sheet(), "distanceSlider").setValue(400); + QCOMPARE(editor.effects().shadow->distance, 120.0); + find<QPushButton>(*editor.sheet(), "effectsCancel").click(); + QVERIFY(editor.effects().shadow->distance == 20 && !editor.session.effectsEditing()); + // A new overlay's panel: Escape takes the overlay away again. + editor.session.addEffect(LayerEffectKind::colorOverlay); + QVERIFY(editor.effects().colorOverlay); + QTest::keyClick(&editor.panel(), Qt::Key_Escape); + QVERIFY(!editor.effects().colorOverlay && editor.effects().shadow && !editor.session.effectsEditing()); +} + +void EffectsPanelTests::anEffectThatGoesTakesItsPanel() +{ + Editor editor; + editor.session.addEffect(LayerEffectKind::stroke); + editor.session.openEffectColorPicker(LayerEffectKind::stroke); + QVERIFY(editor.sheet() && editor.session.colorPicker()); + // Undoing the stroke takes its picker and its panel. + editor.session.undo(); + QVERIFY(!editor.session.activeLayer().value().effects); + QVERIFY(!editor.session.colorPicker() && !editor.session.effectsEditing() && !editor.session.effectsEditingOriginal()); + QTRY_VERIFY(!editor.sheet()); + // A palette's picker stays: only an effect's goes with it. + editor.session.addEffect(LayerEffectKind::stroke); + editor.session.openColorPicker(false); + QVERIFY(editor.session.colorPicker().value().target.kind == ColorPickerTarget::Kind::palette); + editor.session.undo(); + QVERIFY(editor.session.colorPicker() && !editor.session.effectsEditing()); + editor.session.closeColorPicker(false); + // Its kind gone while another stays closes the panel too. + LayerEffects shadow; + shadow.shadow = ShadowEffect(); + editor.session.setEffects(shadow); + editor.session.addEffect(LayerEffectKind::stroke); + editor.session.undo(); + QVERIFY(editor.effects().shadow && !editor.effects().stroke && !editor.session.effectsEditing()); + // So does its layer, deleted. + editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true); + editor.session.dropEffectSelection(); + editor.session.deleteLayerOrMask(); + QVERIFY(editor.session.document().value().layers.empty() && !editor.session.effectsEditing()); + QTRY_VERIFY(!editor.sheet()); +} + +void EffectsPanelTests::deletingNamesAChosenEffect() +{ + Bar bar; + EditorSession &session = bar.session(); + session.createDocument(4, 4); + session.insert(white()); + LayerEffects effects; + effects.stroke = StrokeEffect(); + session.setEffects(effects); + auto &trash = find<QToolButton>(bar.window, "deleteLayer"); + QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); + session.selectEffect(LayerEffectKind::stroke, session.activeLayerID().value()); + QCOMPARE(bar.action("deleteLayer").text(), QString("Delete Stroke")); + QVERIFY(trash.toolTip() == "Delete selected effect" && trash.accessibleName() == "Delete selected effect"); + // A choice an undo took away names the layer again. + session.undo(); + QVERIFY(session.effectSelection() && !session.selectedEffect()); + QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); + session.redo(); + session.selectEffect(LayerEffectKind::stroke, session.activeLayerID().value()); + // Delete takes the effect; the layer stays. + bar.action("deleteLayer").trigger(); + QVERIFY(!session.activeLayer().value().effects && session.document().value().layers.size() == 1); + QVERIFY(bar.action("deleteLayer").text() == "Delete Layer" && trash.toolTip() == "Delete selected layer"); +} + +void EffectsPanelTests::aCanvasPressLetsGoOfTheChosenEffect() +{ + Editor editor; + LayerEffects effects; + effects.stroke = StrokeEffect(); + editor.session.setEffects(effects); + editor.session.selectEffect(LayerEffectKind::stroke, editor.layer); + CanvasView &canvas = *editor.view.findChild<CanvasView *>(); + const QPoint middle = canvas.rect().center(); + // Another button keeps it; the left lets go before guards. + QTest::mouseClick(&canvas, Qt::RightButton, {}, middle); + QVERIFY(editor.session.effectSelection()); + editor.session.setIsProjectBusy(true); + QTest::mouseClick(&canvas, Qt::LeftButton, {}, middle); + QVERIFY(!editor.session.effectSelection()); + editor.session.setIsProjectBusy(false); +} + +QTEST_MAIN(EffectsPanelTests) +#include "EffectsPanelTests.moc" diff --git a/tests/EffectsPreviewCacheTests.cpp b/tests/EffectsPreviewCacheTests.cpp new file mode 100644 index 0000000..63a4ee8 --- /dev/null +++ b/tests/EffectsPreviewCacheTests.cpp @@ -0,0 +1,351 @@ +#include "Document/BrushStroke.h" +#include "Document/LayerEffects+Renderer.h" +#include "Rendering/EffectsPreviewCache.h" +#include "RenderFixtures.h" +#include <QThread> +#include <QElapsedTimer> +#include <QtTest> +#include <memory> + +// Swift's EffectsPreviewCache: one worker, a delay, the newest request. +namespace { +QImage filled(int width, int height, const QColor &colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +LayerEffects outline(double size) +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + return effects; +} + +ImageLayer layerOf(const QImage &image, const LayerEffects &effects) +{ + ImageLayer layer(ImportedImage(image, QImage(), QStringLiteral("Layer")), QPointF(0, 0)); + layer.effects = effects; + return layer; +} + +// Counts landings, and outlives the cache that calls it. +struct Landings { + std::shared_ptr<int> count = std::make_shared<int>(0); + std::function<void()> callback() const + { + return [count = count] { ++*count; }; + } +}; + +std::optional<EffectsPreviewCache::Result> ask(EffectsPreviewCache &cache, const ImageLayer &layer, const Landings &landings, + const std::optional<QImage> &mask = std::nullopt, + const std::optional<LayerTransform> &placement = std::nullopt) +{ + return cache.preview(layer, mask, layer.transform, placement, landings.callback()); +} + +// The result once the worker has had its turn. +EffectsPreviewCache::Result landed(EffectsPreviewCache &cache, const ImageLayer &layer, const Landings &landings, int count) +{ + if (!QTest::qWaitFor([&] { return *landings.count == count; }, 5000)) + throw std::runtime_error("no preview landed"); + return cache.rendered(layer.id).value(); +} +} + +class EffectsPreviewCacheTests : public QObject { + Q_OBJECT +private slots: + void aPreviewLandsAfterItsDelayAsTheRendererDrawsIt(); + void movingTheLayerKeepsThePreviewUnlessAMaskIsPlaced(); + void settingsKeepTheLastPreviewButNewPixelsDropIt(); + void onlyTheNewestRequestLands(); + void aResultLandsOnlyOnTheRequestThatAskedForIt(); + void seedsStandInUntilTheWorkerCatchesUp(); + void preparingDropsLayersWithoutEffectsAndSharesTheBudget(); + void aMaskHidesPixelsInTheReducedPreview(); + void aReducedPreviewScalesEveryLength(); + void aClosedCacheLandsNothing(); +}; + +void EffectsPreviewCacheTests::aPreviewLandsAfterItsDelayAsTheRendererDrawsIt() +{ + EffectsPreviewCache cache; + const QImage image = filled(20, 10, QColor(0, 0, 255)); + const ImageLayer layer = layerOf(image, outline(3)); + const Landings landings; + QElapsedTimer clock; + clock.start(); + QVERIFY(!ask(cache, layer, landings) && !cache.rendered(layer.id)); + const EffectsPreviewCache::Result result = landed(cache, layer, landings, 1); + QVERIFY(clock.elapsed() >= 60); + // Small enough to render whole: the export's own pixels. + const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(image, std::nullopt, outline(3)); + QVERIFY(result.image == expected.image && result.inset == 5 && expected.inset == 5 && !result.placement); + // Asked again, the same pixels come back and nothing renders. + QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), result.image.cacheKey()); + QTest::qWait(150); + QCOMPARE(*landings.count, 1); + // No effects, or none shown: nothing, and the entry goes. + ImageLayer hidden = layer; + hidden.effects->stroke->enabled = false; + QVERIFY(!ask(cache, hidden, landings) && !cache.rendered(layer.id)); + QTest::qWait(150); + QVERIFY(!cache.rendered(layer.id)); + // No pixels, or effects out of bounds: nothing either. + ImageLayer blank(QStringLiteral("Blank"), QSizeF(20, 10)); + blank.effects = outline(3); + QVERIFY(!ask(cache, blank, landings)); + QVERIFY(!ask(cache, layerOf(image, outline(600)), landings)); + QTest::qWait(150); + QCOMPARE(*landings.count, 1); +} + +void EffectsPreviewCacheTests::movingTheLayerKeepsThePreviewUnlessAMaskIsPlaced() +{ + EffectsPreviewCache cache; + ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255))); + const QImage mask = layer.mask->enabledImage().value(); + const Landings landings; + ask(cache, layer, landings, mask); + const qint64 first = landed(cache, layer, landings, 1).image.cacheKey(); + // Effects render in layer pixels: a move only moves them. + layer.transform.origin = QPointF(7, 3); + QCOMPARE(ask(cache, layer, landings, mask).value().image.cacheKey(), first); + QTest::qWait(150); + QCOMPARE(*landings.count, 1); + // A mask placed apart resamples, the last preview shown meanwhile. + const LayerTransform placement = placedAt(QPointF(2, 2), QSizeF(20, 10)); + QCOMPARE(ask(cache, layer, landings, mask, placement).value().image.cacheKey(), first); + const qint64 second = landed(cache, layer, landings, 2).image.cacheKey(); + layer.transform.origin = QPointF(9, 3); + QCOMPARE(ask(cache, layer, landings, mask, placement).value().image.cacheKey(), second); + QVERIFY(landed(cache, layer, landings, 3).image.cacheKey() != second); + // Without a mask the placement changes nothing. + ImageLayer bare = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + ask(cache, bare, landings, std::nullopt, placement); + const qint64 unmasked = landed(cache, bare, landings, 4).image.cacheKey(); + bare.transform.origin = QPointF(1, 1); + QCOMPARE(ask(cache, bare, landings, std::nullopt, placedAt(QPointF(5, 5), QSizeF(20, 10))).value().image.cacheKey(), unmasked); + QTest::qWait(150); + QCOMPARE(*landings.count, 4); +} + +void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt() +{ + EffectsPreviewCache cache; + ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + const Landings landings; + ask(cache, layer, landings); + const qint64 first = landed(cache, layer, landings, 1).image.cacheKey(); + // Same kinds: the old preview shows while the new renders. + layer.effects = outline(5); + QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), first); + QCOMPARE(landed(cache, layer, landings, 2).inset, 7.0); + // Another kind: nothing is shown until it lands. + layer.effects->colorOverlay = ColorOverlayEffect(); + QVERIFY(!ask(cache, layer, landings)); + landed(cache, layer, landings, 3); + // New pixels, or a mask switched on, drop it too. + layer.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red")); + QVERIFY(!ask(cache, layer, landings)); + landed(cache, layer, landings, 4); + layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255))); + QVERIFY(!ask(cache, layer, landings, layer.mask->enabledImage())); + landed(cache, layer, landings, 5); + // A mask switched off is no source. + layer.mask->isEnabled = false; + QVERIFY(!ask(cache, layer, landings)); + landed(cache, layer, landings, 6); +} + +void EffectsPreviewCacheTests::onlyTheNewestRequestLands() +{ + EffectsPreviewCache cache; + ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + const Landings older, newer; + ask(cache, layer, older); + layer.effects = outline(6); + ask(cache, layer, newer); + QCOMPARE(landed(cache, layer, newer, 1).inset, 8.0); + QTest::qWait(150); + QCOMPARE(*older.count, 0); +} + +void EffectsPreviewCacheTests::aResultLandsOnlyOnTheRequestThatAskedForIt() +{ + EffectsPreviewCache cache; + ImageLayer layer = layerOf(filled(1500, 1500, QColor(0, 0, 255)), outline(40)); + const Landings older, newer; + ask(cache, layer, older); + // Its render finishes while the UI thread is busy elsewhere. + QTest::qWait(70); + QThread::msleep(2000); + layer.effects = outline(6); + ask(cache, layer, newer); + QCOMPARE(landed(cache, layer, newer, 1).inset, 8.0); + QCOMPARE(*older.count, 0); + // A seed meanwhile leaves the late result nowhere to land. + layer.effects = outline(9); + const Landings late; + ask(cache, layer, late); + QTest::qWait(70); + QThread::msleep(2000); + cache.seed(layer.id, filled(4, 4, Qt::green), placedAt(QPointF(0, 0), QSizeF(4, 4))); + QTest::qWait(150); + QVERIFY(*late.count == 0 && cache.rendered(layer.id).value().placement); +} + +void EffectsPreviewCacheTests::seedsStandInUntilTheWorkerCatchesUp() +{ + EffectsPreviewCache cache; + const ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + const Landings dropped, landings; + ask(cache, layer, dropped); + // A seed replaces the render under way, which never lands. + const QImage warped = filled(30, 12, QColor(0, 255, 0)); + const LayerTransform placement = placedAt(QPointF(4, 5), QSizeF(30, 12)); + cache.seed(layer.id, warped, placement); + EffectsPreviewCache::Result seeded = cache.rendered(layer.id).value(); + QVERIFY(seeded.image.cacheKey() == warped.cacheKey() && seeded.inset == 0 && seeded.placement == placement); + // It stands in for the next request, then gives way. + seeded = ask(cache, layer, landings).value(); + QVERIFY(seeded.image.cacheKey() == warped.cacheKey() && seeded.placement == placement); + const EffectsPreviewCache::Result fresh = landed(cache, layer, landings, 1); + QVERIFY(fresh.image.cacheKey() != warped.cacheKey() && !fresh.placement); + QCOMPARE(*dropped.count, 0); + // Gone for good: new pixels find neither. + ImageLayer repainted = layer; + repainted.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red")); + QVERIFY(!ask(cache, repainted, landings)); +} + +void EffectsPreviewCacheTests::preparingDropsLayersWithoutEffectsAndSharesTheBudget() +{ + EffectsPreviewCache cache; + const ImageLayer wide = layerOf(filled(6000, 100, QColor(0, 0, 255)), outline(4)); + const ImageLayer small = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); + const Landings landings; + cache.prepare({wide, small}); + ask(cache, wide, landings); + ask(cache, small, landings); + QVERIFY(QTest::qWaitFor([&] { return *landings.count == 2; }, 5000)); + // Two layers with effects: 1536 a side, margins included. + EffectsPreviewCache::Result result = cache.rendered(wide.id).value(); + QVERIFY(result.image.size() == QSize(1533, 33) && result.inset == 4); + // 1024 layers share 128 a side; twenty thousand get 32. + std::vector<ImageLayer> many(1024, wide); + for (ImageLayer &layer : many) + layer.id = QUuid::createUuid(); + many.front() = wide; + cache.prepare(many); + ask(cache, wide, landings); + result = landed(cache, wide, landings, 3); + QVERIFY(result.image.size() == QSize(126, 8) && result.inset == 3); + many.resize(20000, many.back()); + for (ImageLayer &layer : many) + layer.id = QUuid::createUuid(); + many.front() = wide; + cache.prepare(many); + ask(cache, wide, landings); + result = landed(cache, wide, landings, 4); + QVERIFY(result.image.size() == QSize(30, 7) && result.inset == 3); + // Layers without effects shown go, seeds too; none leaves 1536. + ask(cache, small, landings); + landed(cache, small, landings, 5); + ImageLayer off = small; + off.effects->stroke->enabled = false; + cache.seed(wide.id, filled(4, 4, Qt::green), placedAt(QPointF(0, 0), QSizeF(4, 4))); + cache.prepare({off}); + QVERIFY(!cache.rendered(small.id) && !cache.rendered(wide.id)); + ask(cache, wide, landings); + QCOMPARE(landed(cache, wide, landings, 6).image.size(), QSize(1533, 33)); +} + +void EffectsPreviewCacheTests::aMaskHidesPixelsInTheReducedPreview() +{ + EffectsPreviewCache cache; + ImageLayer layer = layerOf(filled(3000, 100, QColor(0, 0, 255)), outline(4)); + // Left half hidden: the preview shows the right half. + QImage mask = gray(3000, 100, 255); + for (int y = 0; y < 100; ++y) + std::fill(mask.scanLine(y), mask.scanLine(y) + 1500, uchar(0)); + const Landings landings; + cache.prepare({layer}); + ask(cache, layer, landings, mask); + const QImage image = landed(cache, layer, landings, 1).image; + QVERIFY(image.size() == QSize(1532, 61)); + QCOMPARE(qAlpha(image.pixel(300, 30)), 0); + QCOMPARE(image.pixel(1200, 30), qRgba(0, 0, 255, 255)); + // The outline runs round what shows, at the halfway line. + QCOMPARE(image.pixel(764, 30), qRgba(255, 255, 0, 255)); + // A mask of another size is reduced by its own. + QImage coarse(2, 1, QImage::Format_Grayscale8); + coarse.scanLine(0)[0] = 0; + coarse.scanLine(0)[1] = 255; + layer.asset = ImportedImage(filled(6000, 100, QColor(0, 0, 255)), QImage(), QStringLiteral("Wide")); + ask(cache, layer, landings, coarse); + const QImage wide = landed(cache, layer, landings, 2).image; + QVERIFY(wide.size() == QSize(1533, 33)); + QCOMPARE(qAlpha(wide.pixel(104, 16)), 0); + QCOMPARE(wide.pixel(1404, 16), qRgba(0, 0, 255, 255)); + // Stripes a row high shrink across and grow down, unaliased. + QImage stripes(6000, 1, QImage::Format_Grayscale8); + for (int x = 0; x < 6000; ++x) + stripes.scanLine(0)[x] = uchar(x % 2 ? 255 : 0); + layer.mask = LayerMask(LayerMask::assetFrom(stripes)); + ask(cache, layer, landings, stripes); + const QImage row = landed(cache, layer, landings, 3).image; + int low = 255, high = 0; + for (int x = 10; x < 1523; ++x) { + low = std::min(low, qAlpha(row.pixel(x, 16))); + high = std::max(high, qAlpha(row.pixel(x, 16))); + } + QVERIFY(low == 125 && high == 129); + layer.mask = LayerMask(LayerMask::assetFrom(stripes.scaled(6000, 100))); + ask(cache, layer, landings, layer.mask->enabledImage()); + QVERIFY(landed(cache, layer, landings, 4).image == row); +} + +void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength() +{ + EffectsPreviewCache cache; + LayerEffects effects; + effects.stroke = StrokeEffect{.size = 4, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + effects.shadow = ShadowEffect{.angle = 45, .distance = 8, .blur = 3, .red = 1, .green = 0, .blue = 0, .opacity = 0.8}; + effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5}; + effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 30, .blur = 12, .red = 0, .green = 0, .blue = 0, .opacity = 0.7}; + const QImage image = noise(6000, 100, 7); + const ImageLayer layer = layerOf(image, effects); + const Landings landings; + cache.prepare({layer}); + ask(cache, layer, landings); + const EffectsPreviewCache::Result result = landed(cache, layer, landings, 1); + // The renderer on the reduced pixels, every length reduced alike. + const double factor = 1528.0 / (6000 + 2 * LayerEffectsRenderer::margin(effects)); + const QImage reduced = image.scaled(1518, 25, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + effects.stroke->size *= factor; + effects.shadow->distance *= factor; + effects.shadow->blur *= factor; + effects.innerShadow->distance *= factor; + effects.innerShadow->blur *= factor; + QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image); +} + +void EffectsPreviewCacheTests::aClosedCacheLandsNothing() +{ + const Landings landings; + { + EffectsPreviewCache cache; + ask(cache, layerOf(filled(1500, 1500, QColor(0, 0, 255)), outline(40)), landings); + QTest::qWait(70); + } + QTest::qWait(150); + QCOMPARE(*landings.count, 0); +} + +QTEST_GUILESS_MAIN(EffectsPreviewCacheTests) +#include "EffectsPreviewCacheTests.moc" diff --git a/tests/ExportTests.cpp b/tests/ExportTests.cpp new file mode 100644 index 0000000..58808e1 --- /dev/null +++ b/tests/ExportTests.cpp @@ -0,0 +1,323 @@ +#include "IO/ImageExporter.h" +#include "AddressSpaceLimit.h" +#include "Document/LayerMask.h" +#include "RenderFixtures.h" +#include <QColorSpace> +#include <QImageReader> +#include <QTemporaryDir> +#include <QtTest> + +namespace { +ImportedImage asset(const QImage &image, const QString &name) +{ + return ImportedImage(image, image, name); +} + +// A 2x2 image, left column red, 4x4 at (1,1). +ProjectSnapshot snapshot(double rotation = 0, bool flip = false) +{ + QImage image = BrushRaster::context(2, 2, false); + image.setPixel(0, 0, qRgba(255, 0, 0, 255)); + image.setPixel(0, 1, qRgba(255, 0, 0, 255)); + const QUuid id = QUuid::createUuid(); + const LayerTransform transform{.origin = {1, 1}, .size = {4, 4}, .rotation = rotation, .flipX = flip, .sampling = LayerSampling::nearest}; + const ProjectLayerRecord record{.id = id, .name = "Red", .isVisible = true, .transform = transform, .imageFile = id.toString(QUuid::WithoutBraces) + ".png"}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = 6, .height = 6, .activeLayerID = id, .layers = {record}}, + .images = {{id, asset(image, "Red")}}}; +} + +QImage decoded(const QByteArray &data) +{ + return QImage::fromData(data).convertToFormat(QImage::Format_RGBA8888); +} +} + +class ExportTests : public QObject { + Q_OBJECT +private slots: + void pngPreservesDimensionsAlphaOrientationAndTransforms_data(); + void pngPreservesDimensionsAlphaOrientationAndTransforms(); + void orderVisibilityClippingAndAtomicOverwrite(); + void blankCanvasAndOversizedCanvas(); + void resolutionTravelsWithThePixels(); + void missingPixelsAndCyclesAreRefused(); + void masksFoldersAndClippingReachTheExport(); + void aFileThatCannotBeWrittenThrowsAndKeepsTheOldOne(); + void blendModesAndPlacedMasksReachTheExport(); + void aSnapshotsMaskFollowsItsRecord(); + void aCanvasThatCannotBeAllocatedThrows(); + void exportsAreLogged(); +}; + +void ExportTests::pngPreservesDimensionsAlphaOrientationAndTransforms_data() +{ + QTest::addColumn<double>("rotation"); + QTest::addColumn<bool>("flip"); + QTest::addColumn<QPoint>("red"); + QTest::addColumn<QPoint>("clear"); + QTest::newRow("upright") << 0.0 << false << QPoint(1, 1) << QPoint(4, 1); + QTest::newRow("flipped") << 0.0 << true << QPoint(4, 1) << QPoint(1, 1); + QTest::newRow("a right angle") << 90.0 << false << QPoint(1, 1) << QPoint(1, 4); +} + +void ExportTests::pngPreservesDimensionsAlphaOrientationAndTransforms() +{ + QFETCH(double, rotation); + QFETCH(bool, flip); + QFETCH(QPoint, red); + QFETCH(QPoint, clear); + const QByteArray data = ImageExporter::pngData(snapshot(rotation, flip)); + QVERIFY(data.startsWith("\x89PNG")); + const QImage image = decoded(data); + QCOMPARE(image.size(), QSize(6, 6)); + QCOMPARE(QImage::fromData(data).colorSpace(), QColorSpace(QColorSpace::SRgb)); + QCOMPARE(image.pixel(red), qRgba(255, 0, 0, 255)); + QCOMPARE(qAlpha(image.pixel(clear)), 0); + QCOMPARE(qAlpha(image.pixel(0, 0)), 0); +} + +void ExportTests::orderVisibilityClippingAndAtomicOverwrite() +{ + const ProjectSnapshot original = snapshot(); + const QUuid id = QUuid::createUuid(); + QTemporaryDir directory; + const QString path = directory.filePath("Export.png"); + for (const bool visible : {true, false}) { + ProjectSnapshot stacked = original; + stacked.images.insert({id, asset(solid(1, 1, qRgba(0, 0, 255, 255)), "Blue")}); + // The blue layer reaches past the canvas on every side. + stacked.manifest.layers.push_back({.id = id, .name = "Blue", .isVisible = visible, + .transform = {.origin = {-2, -2}, .size = {10, 10}}, .imageFile = "blue.png"}); + ImageExporter::exportPNG(stacked, path); + const QImage written = QImage(path).convertToFormat(QImage::Format_RGBA8888); + QCOMPARE(written.size(), QSize(6, 6)); + QCOMPARE(written.pixel(1, 1), visible ? qRgba(0, 0, 255, 255) : qRgba(255, 0, 0, 255)); + QCOMPARE(written.pixel(5, 5), visible ? qRgba(0, 0, 255, 255) : qRgba(0, 0, 0, 0)); + } + QCOMPARE(QDir(directory.path()).entryList(QDir::Files), QStringList{"Export.png"}); +} + +void ExportTests::blankCanvasAndOversizedCanvas() +{ + const ProjectSnapshot blank{.manifest = {.documentID = QUuid::createUuid(), .width = 2, .height = 2, .activeLayerID = std::nullopt, .layers = {}}, .images = {}}; + QCOMPARE(decoded(ImageExporter::pngData(blank)).pixel(1, 1), qRgba(0, 0, 0, 0)); + const auto sized = [](int width, int height) { + return ProjectSnapshot{.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = std::nullopt, .layers = {}}, .images = {}}; + }; + for (const QSize &size : {QSize(30'000, 30'000), QSize(30'001, 1), QSize(1, 30'001), QSize(0, 5), QSize(5, 0), QSize(10'001, 10'000)}) { + try { + ImageExporter::render(sized(size.width(), size.height())); + QFAIL("an oversized or empty canvas must be refused"); + } catch (const ExportError &error) { + QCOMPARE(error.kind, ExportError::Kind::tooLarge); + } + } + QCOMPARE(ImageExporter::render(sized(30'000, 1)).image.size(), QSize(30'000, 1)); + QCOMPARE(ImageExporter::render(sized(1, 30'000)).image.size(), QSize(1, 30'000)); +} + +void ExportTests::resolutionTravelsWithThePixels() +{ + ProjectSnapshot print = snapshot(); + QCOMPARE(ImageExporter::render(print).resolution, 72.0); + QCOMPARE(QImage::fromData(ImageExporter::pngData(print)).dotsPerMeterX(), 2835); + print.manifest.resolution = 300; + QCOMPARE(ImageExporter::render(print).resolution, 300.0); + const QImage png = QImage::fromData(ImageExporter::pngData(print)); + QCOMPARE(png.dotsPerMeterX(), 11811); + QCOMPARE(png.dotsPerMeterY(), 11811); + const QImage jpeg = QImage::fromData(ImageExporter::jpeg(ImageExporter::render(print), {}).data); + QCOMPARE(qRound(jpeg.dotsPerMeterX() * 0.0254), 300); + QCOMPARE(qRound(jpeg.dotsPerMeterY() * 0.0254), 300); +} + +void ExportTests::missingPixelsAndCyclesAreRefused() +{ + const auto kind = [](const ProjectSnapshot &broken) -> std::optional<ProjectError::Kind> { + try { + ImageExporter::render(broken); + } catch (const ProjectError &error) { + return error.kind; + } + return std::nullopt; + }; + ProjectSnapshot noPixels = snapshot(); + noPixels.images.clear(); + QCOMPARE(kind(noPixels), std::optional(ProjectError::Kind::missingImage)); + ProjectSnapshot noMask = snapshot(); + noMask.manifest.layers[0].maskFile = "mask.png"; + QCOMPARE(kind(noMask), std::optional(ProjectError::Kind::missingImage)); + noMask.masks.insert({noMask.manifest.layers[0].id, asset(gray(2, 2, 255), "Mask")}); + QCOMPARE(kind(noMask), std::nullopt); + // A folder has no image file and needs no pixels. + ProjectSnapshot folder = snapshot(); + folder.manifest.layers.push_back({.id = QUuid::createUuid(), .name = "Folder", .isVisible = true, .transform = {}, .imageFile = std::nullopt, + .isGroup = true}); + QCOMPARE(kind(folder), std::nullopt); + ProjectSnapshot cyclic = snapshot(); + cyclic.manifest.layers[0].maskSourceID = cyclic.manifest.layers[0].id; + QCOMPARE(kind(cyclic), std::optional(ProjectError::Kind::invalid)); +} + +void ExportTests::masksFoldersAndClippingReachTheExport() +{ + // The Swift live-mask fixtures, exported: red under a hidden source. + const auto pixels = [](std::array<int, 4> alpha, int color) { + QImage image = BrushRaster::context(2, 2, false); + for (int index = 0; index < 4; ++index) + image.setPixel(index % 2, index / 2, qRgba(color * alpha[index] / 255, 0, 0, alpha[index])); + return image; + }; + const auto alphas = [](const QImage &image) { + return std::array<int, 4>{qAlpha(image.pixel(0, 0)), qAlpha(image.pixel(1, 0)), qAlpha(image.pixel(0, 1)), qAlpha(image.pixel(1, 1))}; + }; + const QUuid target = QUuid::createUuid(), source = QUuid::createUuid(), folder = QUuid::createUuid(); + const LayerTransform whole{.origin = {0, 0}, .size = {2, 2}, .sampling = LayerSampling::nearest}; + ProjectSnapshot linked{.manifest = {.documentID = QUuid::createUuid(), .width = 2, .height = 2, .activeLayerID = target, + .layers = {{.id = target, .name = "Target", .isVisible = true, .transform = whole, .imageFile = "t.png", .maskSourceID = source}, + {.id = source, .name = "Source", .isVisible = false, .transform = whole, .imageFile = "s.png"}}}, + .images = {{target, asset(pixels({255, 255, 255, 255}, 255), "Target")}, {source, asset(pixels({255, 0, 128, 255}, 0), "Source")}}}; + QCOMPARE(alphas(ImageExporter::render(linked).image), (std::array<int, 4>{255, 0, 128, 255})); + linked.manifest.layers[0].opacity = 0.5; + QCOMPARE(alphas(ImageExporter::render(linked).image), (std::array<int, 4>{127, 0, 64, 127})); + // The layer's own mask multiplies in. + linked.manifest.layers[0].maskFile = "mask.png"; + linked.masks.insert({target, asset(gray(2, 2, 128), "Mask")}); + QCOMPARE(alphas(ImageExporter::render(linked).image), (std::array<int, 4>{64, 0, 32, 64})); + linked.manifest.layers[0].maskEnabled = false; + QCOMPARE(alphas(ImageExporter::render(linked).image), (std::array<int, 4>{127, 0, 64, 127})); + + // A visible neighbour clipped to its base forms a stack. + ProjectSnapshot stack{.manifest = {.documentID = QUuid::createUuid(), .width = 2, .height = 2, .activeLayerID = target, + .layers = {{.id = source, .name = "Base", .isVisible = true, .transform = whole, .imageFile = "s.png"}, + {.id = target, .name = "Top", .isVisible = true, .transform = whole, .imageFile = "t.png", .maskSourceID = source}}}, + .images = {{target, asset(pixels({255, 255, 255, 255}, 255), "Top")}, {source, asset(pixels({255, 128, 32, 0}, 0), "Base")}}}; + const QImage clipped = ImageExporter::render(stack).image; + QCOMPARE(alphas(clipped), (std::array<int, 4>{255, 128, 32, 0})); + QCOMPARE(qRed(clipped.pixel(1, 0)), 128); + + // A folder's mask covers what lies inside it, nothing else. + stack.manifest.layers.insert(stack.manifest.layers.begin(), {.id = folder, .name = "Folder", .isVisible = true, .transform = whole, + .imageFile = std::nullopt, .isGroup = true, .maskFile = "folder.png"}); + stack.masks.insert({folder, asset(gray(2, 2, 128), "Folder mask")}); + stack.manifest.layers[1].parentID = folder; + stack.manifest.layers[2].parentID = folder; + QCOMPARE(alphas(ImageExporter::render(stack).image), (std::array<int, 4>{128, 64, 16, 0})); + // A disabled folder mask clips nothing. + stack.manifest.layers[0].maskEnabled = false; + QCOMPARE(alphas(ImageExporter::render(stack).image), (std::array<int, 4>{255, 128, 32, 0})); + stack.manifest.layers[0].maskEnabled = true; + stack.manifest.layers[1].parentID = std::nullopt; + stack.manifest.layers[2].parentID = std::nullopt; + QCOMPARE(alphas(ImageExporter::render(stack).image), (std::array<int, 4>{255, 128, 32, 0})); +} + +void ExportTests::blendModesAndPlacedMasksReachTheExport() +{ + const QUuid below = QUuid::createUuid(), above = QUuid::createUuid(), child = QUuid::createUuid(); + const LayerTransform whole{.origin = {0, 0}, .size = {4, 4}, .sampling = LayerSampling::nearest}; + ProjectSnapshot blended{.manifest = {.documentID = QUuid::createUuid(), .width = 4, .height = 4, .activeLayerID = above, + .layers = {{.id = below, .name = "Below", .isVisible = true, .transform = whole, .imageFile = "b.png"}, + {.id = above, .name = "Above", .isVisible = true, .transform = whole, .imageFile = "a.png", + .blendMode = LayerBlendMode::multiply}}}, + .images = {{below, asset(solid(4, 4, qRgba(200, 100, 50, 255)), "Below")}, {above, asset(solid(4, 4, qRgba(128, 128, 128, 255)), "Above")}}}; + QCOMPARE(ImageExporter::render(blended).image.pixel(1, 1), qRgba(100, 50, 25, 255)); + blended.manifest.layers[1].blendMode = std::nullopt; + QCOMPARE(ImageExporter::render(blended).image.pixel(1, 1), qRgba(128, 128, 128, 255)); + + // A stack blends with its base's mode; absent means Normal. + blended.images.insert({child, asset(solid(4, 4, qRgba(128, 128, 128, 255)), "Child")}); + blended.manifest.layers.push_back({.id = child, .name = "Child", .isVisible = true, .transform = whole, .imageFile = "c.png", .maskSourceID = above}); + QCOMPARE(ImageExporter::render(blended).image.pixel(1, 1), qRgba(128, 128, 128, 255)); + blended.manifest.layers[1].blendMode = LayerBlendMode::multiply; + QCOMPARE(ImageExporter::render(blended).image.pixel(1, 1), qRgba(100, 50, 25, 255)); + blended.manifest.layers.pop_back(); + + // A mask moved apart hides by where it sits. + blended.manifest.layers[1].blendMode = std::nullopt; + blended.manifest.layers[1].maskFile = "mask.png"; + QImage halves = gray(2, 1, 255); + halves.scanLine(0)[1] = 0; + blended.masks.insert({above, asset(halves, "Mask")}); + // Over the whole layer its right half hides. + const QImage whole4 = ImageExporter::render(blended).image; + QCOMPARE(whole4.pixel(1, 1), qRgba(128, 128, 128, 255)); + QCOMPARE(whole4.pixel(2, 1), qRgba(200, 100, 50, 255)); + // Placed right, only the last column hides; white borders show. + blended.manifest.layers[1].maskPlacement = LayerTransform{.origin = {2, 0}, .size = {2, 4}, .sampling = LayerSampling::nearest}; + const QImage placed = ImageExporter::render(blended).image; + QCOMPARE(placed.pixel(0, 1), qRgba(128, 128, 128, 255)); + QCOMPARE(placed.pixel(2, 1), qRgba(128, 128, 128, 255)); + QCOMPARE(placed.pixel(3, 1), qRgba(200, 100, 50, 255)); +} + +void ExportTests::aSnapshotsMaskFollowsItsRecord() +{ + ProjectSnapshot masked = snapshot(); + ProjectLayerRecord &layer = masked.manifest.layers[0]; + masked.masks.insert({layer.id, asset(gray(2, 2, 77), "Mask")}); + // No mask file, no mask, whatever the snapshot holds. + QVERIFY(!masked.mask(layer).has_value()); + layer.maskFile = "mask.png"; + const LayerMask plain = masked.mask(layer).value(); + QCOMPARE(plain.asset.image(), gray(2, 2, 77)); + QCOMPARE(plain.isEnabled, true); + QCOMPARE(plain.isLinked, true); + QVERIFY(!plain.placement.has_value()); + // The two flags travel apart. + layer.maskEnabled = false; + layer.maskLinked = true; + layer.maskPlacement = LayerTransform{.origin = {3, 4}, .size = {5, 6}}; + const LayerMask disabled = masked.mask(layer).value(); + QCOMPARE(disabled.isEnabled, false); + QCOMPARE(disabled.isLinked, true); + QCOMPARE(disabled.placement.value(), (LayerTransform{.origin = {3, 4}, .size = {5, 6}})); + layer.maskEnabled = true; + layer.maskLinked = false; + const LayerMask unlinked = masked.mask(layer).value(); + QCOMPARE(unlinked.isEnabled, true); + QCOMPARE(unlinked.isLinked, false); + masked.masks.clear(); + QVERIFY(!masked.mask(layer).has_value()); +} + +void ExportTests::aFileThatCannotBeWrittenThrowsAndKeepsTheOldOne() +{ + QTemporaryDir directory; + const QString path = directory.filePath("kept.png"); + ImageExporter::write("first", path); + ImageExporter::write("second", path); + QFile kept(path); + QVERIFY(kept.open(QIODevice::ReadOnly)); + QCOMPARE(kept.readAll(), QByteArray("second")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not write .*")); + QVERIFY_EXCEPTION_THROWN(ImageExporter::write("third", directory.filePath("missing/folder/file.png")), std::runtime_error); +} + +void ExportTests::aCanvasThatCannotBeAllocatedThrows() +{ + // 10,000 x 10,000 pixels need 400 MB. + const ProjectSnapshot large{.manifest = {.documentID = QUuid::createUuid(), .width = 10'000, .height = 10'000, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + std::optional<AddressSpaceLimit> limit(std::in_place, 64 * 1024 * 1024); + std::optional<ExportError::Kind> thrown; + try { + ImageExporter::render(large); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); +} + +void ExportTests::exportsAreLogged() +{ + QTemporaryDir directory; + const QString path = directory.filePath("logged.png"); + QTest::ignoreMessage(QtInfoMsg, "rendered 6 x 6 from 1 visible layers"); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("wrote \\d+ bytes to \".*logged.png\"")); + ImageExporter::exportPNG(snapshot(), path); +} + +QTEST_MAIN(ExportTests) +#include "ExportTests.moc" diff --git a/tests/EyedropperFixtures.h b/tests/EyedropperFixtures.h new file mode 100644 index 0000000..a40df2e --- /dev/null +++ b/tests/EyedropperFixtures.h @@ -0,0 +1,38 @@ +#pragma once +#include "InlineTextFixtures.h" +#include "UI/ColorPickerSheet.h" + +// Shared by the Eyedropper's tests and picking's. +namespace { +// Red left, blue right, filling the view. +struct Picking : Canvas { + Picking() + { + QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::blue); + for (int y = 0; y < 300; ++y) { + for (int x = 0; x < 200; ++x) + image.setPixelColor(x, y, Qt::red); + } + session.insert(ImportedImage(image, image, QStringLiteral("Split"))); + tool(NavigationTool::eyedropper); + } + void tool(NavigationTool tool) + { + session.selectTool(tool); + canvas->synchronizeDisplay(); + } + QString foreground() const { return session.foregroundColor().hex(); } + const SampleRingOverlay &ring() const { return canvas->sampleRing(); } + bool picks() const { return shows(CanvasView::eyedropperCursor(canvas->devicePixelRatio())); } +}; + +inline QWidget *pickerPanel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == ColorPickerPanelController::identifier() && widget->isVisible()) + return widget; + } + return nullptr; +} +} diff --git a/tests/EyedropperTests.cpp b/tests/EyedropperTests.cpp new file mode 100644 index 0000000..e9f7faa --- /dev/null +++ b/tests/EyedropperTests.cpp @@ -0,0 +1,208 @@ +#include "ContentView.h" +#include "EyedropperFixtures.h" +#include <QCheckBox> + +// The Eyedropper: sampling, its ring, its cursor, its bar. +class EyedropperTests : public QObject { + Q_OBJECT +private slots: + void theEyedropperSamplesTheCompositeIntoTheForeground(); + void theRingShowsTheSampleOverTheOriginal(); + void pickingShowsTheEyedropperCursor(); + void spaceStillPansWhilePicking(); + void theBrushCircleHidesWhilePicking(); + void aMountedCanvasLeavesThePickerTheKeys(); + void theBarTogglesTheRing(); +}; + +void EyedropperTests::theEyedropperSamplesTheCompositeIntoTheForeground() +{ + Picking shown; + shown.press(QPointF(100, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.move(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("0000FF")); + shown.release(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("0000FF")); + // Past the document nothing is sampled; the colour stays. + shown.press(QPointF(100, 150)); + shown.move(QPointF(-20, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + // Busy, the palette rests: the drag samples nothing. + shown.session.setIsProjectBusy(true); + shown.move(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.release(QPointF(300, 150)); + // A busy press samples nothing either. + shown.click(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.session.setIsProjectBusy(false); + // With a mask chosen the foreground takes it, as Swift's. + shown.session.addMask(); + QVERIFY(shown.session.isMaskSelected()); + shown.click(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("0000FF")); + QVERIFY(!shown.session.maskPaintWhite()); +} + +void EyedropperTests::theRingShowsTheSampleOverTheOriginal() +{ + Picking shown; + shown.session.setForegroundColor(PaletteColor{0, 1, 0}); + shown.press(QPointF(100, 150)); + QCOMPARE(shown.ring().frame().value(), QRectF(42, 92, 116, 116)); + QCOMPARE(shown.ring().original(), (PaletteColor{0, 1, 0})); + QCOMPARE(shown.ring().sampled().hex(), QString("FF0000")); + // The new colour above, the old below, gray around both. + const QImage shot = shown.canvas->grab().toImage(); + QCOMPARE(shot.pixelColor(100, 107), QColor(Qt::red)); + QCOMPARE(shot.pixelColor(100, 193), QColor(Qt::green)); + for (const int y : {97, 117}) { + const QColor rim = shot.pixelColor(100, y); + QVERIFY2(std::abs(rim.red() - 115) <= 1 && rim.red() == rim.green() && rim.green() == rim.blue(), qPrintable(rim.name())); + } + QCOMPARE(shot.pixelColor(100, 150), QColor(Qt::red)); + // Sixteen points of colour, eight each side of the line. + QCOMPARE(shot.pixelColor(100, 100), QColor(Qt::red)); + QCOMPARE(shot.pixelColor(100, 114), QColor(Qt::red)); + // Antialiased: the gray's outer edge mixes with the red. + const QColor edge = shot.pixelColor(139, 111); + QVERIFY2(edge.red() > 115 && edge.red() < 255 && edge.green() > 0 && edge.green() < 115, qPrintable(edge.name())); + // It follows the drag, both places repainted; the original stays. + QCoreApplication::processEvents(); + PaintSpy paints(*shown.canvas); + shown.move(QPointF(300, 150)); + QCOMPARE(shown.ring().frame().value(), QRectF(242, 92, 116, 116)); + QCOMPARE(shown.ring().original(), (PaletteColor{0, 1, 0})); + QCOMPARE(shown.ring().sampled().hex(), QString("0000FF")); + QTRY_VERIFY(paints.painted.contains(QRect(42, 92, 316, 116))); + QCoreApplication::processEvents(); + PaintSpy hidden(*shown.canvas); + shown.release(QPointF(300, 150)); + QVERIFY(!shown.ring().frame()); + QTRY_VERIFY(hidden.painted.contains(QRect(242, 92, 116, 116))); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(300, 107), QColor(Qt::blue)); + // Off in the bar, sampling shows no ring. + shown.session.setShowsSampleRing(false); + shown.press(QPointF(100, 150)); + QVERIFY(!shown.ring().frame()); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.release(QPointF(100, 150)); +} + +void EyedropperTests::pickingShowsTheEyedropperCursor() +{ + Picking shown; + QVERIFY(shown.picks()); + // The tip is the hot spot; white rings the glyph. + const QCursor eyedropper = CanvasView::eyedropperCursor(1); + QCOMPARE(eyedropper.hotSpot(), QPoint(5, 19)); + const QImage glyph = eyedropper.pixmap().toImage(); + QCOMPARE(glyph.size(), QSize(24, 24)); + QCOMPARE(glyph.pixelColor(5, 18), QColor(Qt::black)); + QCOMPARE(glyph.pixelColor(17, 5), QColor(Qt::black)); + const QColor halo = glyph.pixelColor(5, 20); + QVERIFY2(halo.red() == 255 && halo.green() == 255 && halo.blue() == 255 && halo.alpha() > 200, qPrintable(halo.name(QColor::HexArgb))); + QCOMPARE(glyph.pixelColor(2, 18).alpha(), 0); + // Sixteen offsets smooth the halo: eight leave 67 here. + QVERIFY2(std::abs(glyph.pixelColor(10, 4).alpha() - 54) <= 4, qPrintable(glyph.pixelColor(10, 4).name(QColor::HexArgb))); + // Space leaves it, as Swift's cursor rects rank it first. + QTest::keyPress(shown.canvas, Qt::Key_Space); + QVERIFY(shown.picks()); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + // The Brush's crosshair until Alt is held. + shown.tool(NavigationTool::brush); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(shown.picks()); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); + // A hover under Alt, its key unseen, shows it too. + shown.hover(QPointF(100, 150), Qt::AltModifier); + QVERIFY(shown.picks()); + shown.hover(QPointF(100, 150)); + // Clone Stamp's Alt picks a source, not a colour. + shown.tool(NavigationTool::cloneStamp); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QCOMPARE(shown.canvas->cursor().shape(), Qt::CrossCursor); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); +} + +void EyedropperTests::spaceStillPansWhilePicking() +{ + Picking shown; + QTest::keyPress(shown.canvas, Qt::Key_Space); + const QPointF before = shown.session.viewport.documentPoint(QPointF(200, 150), shown.documentSize()); + shown.press(QPointF(100, 150)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::ClosedHandCursor); + shown.move(QPointF(130, 150)); + QCOMPARE(shown.session.viewport.documentPoint(QPointF(200, 150), shown.documentSize()), before - QPointF(30, 0)); + QCOMPARE(shown.foreground(), QString("000000")); + QVERIFY(!shown.ring().frame()); + shown.release(QPointF(130, 150)); + QVERIFY(shown.picks()); + QTest::keyRelease(shown.canvas, Qt::Key_Space); +} + +void EyedropperTests::theBrushCircleHidesWhilePicking() +{ + Picking shown; + shown.tool(NavigationTool::brush); + shown.hover(QPointF(100, 150)); + QVERIFY(shown.canvas->brushCursor().circle()); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(!shown.canvas->brushCursor().circle()); + // Picking's hover moves no circle, as Swift's. + shown.hover(QPointF(300, 150), Qt::AltModifier); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(100, 150)); + shown.hover(QPointF(300, 150)); + QCOMPARE(shown.canvas->brushCursor().circle().value().center(), QPointF(300, 150)); +} + +void EyedropperTests::aMountedCanvasLeavesThePickerTheKeys() +{ + Picking shown; + shown.session.openColorPicker(false); + shown.canvas->clearFocus(); + shown.canvas->hide(); + shown.canvas->show(); + QTest::qWait(50); + QVERIFY(!shown.canvas->hasFocus()); + // Without a picker, a mounted canvas takes them. + shown.session.closeColorPicker(false); + shown.canvas->hide(); + shown.canvas->show(); + QTRY_VERIFY(shown.canvas->hasFocus()); +} + +void EyedropperTests::theBarTogglesTheRing() +{ + EditorSession session; + session.createDocument(40, 40); + session.selectTool(NavigationTool::eyedropper); + ContentView view(session); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + auto *ring = view.findChild<QCheckBox *>(QStringLiteral("sampleRing")); + QVERIFY(ring); + QCOMPARE(ring->text(), QString("Sample Ring")); + QVERIFY(ring->isChecked()); + QSignalSpy changed(&session, &EditorSession::changed); + // Title first, the checkbox 16 points on, as Swift's. + const QLabel *title = ring->parentWidget()->findChild<QLabel *>(); + QCOMPARE(title->text(), QString("Eyedropper")); + QCOMPARE(ring->geometry().left() - title->geometry().right() - 1, 16); + ring->click(); + QVERIFY(!session.showsSampleRing()); + QCOMPARE(changed.count(), 1); + ring->click(); + QVERIFY(session.showsSampleRing()); + // Set elsewhere, the checkbox follows without writing back. + session.setShowsSampleRing(false); + QVERIFY(!ring->isChecked()); + QCOMPARE(changed.count(), 3); +} + +QTEST_MAIN(EyedropperTests) +#include "EyedropperTests.moc" diff --git a/tests/FilterControlsTests.cpp b/tests/FilterControlsTests.cpp new file mode 100644 index 0000000..3b39a0f --- /dev/null +++ b/tests/FilterControlsTests.cpp @@ -0,0 +1,411 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "SelectionFixtures.h" +#include "UI/ColorPaletteControls.h" +#include "UI/CurvesControls.h" +#include "UI/FilterSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QLabel> +#include <QLayout> +#include <QLineEdit> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> +#include <QWheelEvent> +#include <QtTest> + +// Each kind's controls, as Swift's body lays them. +namespace { +struct Sheet { + EditorSession session; + std::unique_ptr<FilterSheet> sheet; + explicit Sheet(FilterKind kind) + { + session.createDocument(8, 8); + QImage image = BrushRaster::context(8, 8, false); + image.fill(QColor(120, 160, 200)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); + // The fill opens only over a selection. + if (kind == FilterKind::contentAwareFill) + session.applySelection(rectPath(QRectF(2, 2, 3, 3)), SelectionMode::replace, "Select"); + session.beginFilter(kind); + sheet = std::make_unique<FilterSheet>(session); + } + template <typename Widget> Widget &child(const char *name) const + { + auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; + } + const FilterSettings &settings() const { return session.filterEdit().value().settings; } + // Types into a field and presses Return. + void type(const char *name, const QString &text) + { + QLineEdit &field = child<QLineEdit>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + QTest::keyClick(&field, Qt::Key_Return); + } +}; + +// Counts the paint events a widget receives. +struct Paints : QObject { + int count = 0; + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +// The unit label beside a field, Swift's unitSuffix. +QString unit(QLineEdit &field) +{ + const QList<QLabel *> labels = field.parentWidget()->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly); + return labels.last()->text(); +} +} + +class FilterControlsTests : public QObject { + Q_OBJECT +private slots: + void eachKindShowsSwiftsControls(); + void logarithmicSlidersGiveSmallValuesTheirTravel(); + void theSlidersShowTheSessionsNumbersWithoutWritingBack(); + void arrowKeysStepTheSlidersOn(); + void fieldsApplyWhatTheyReadAndShowSwiftsDecimals(); + void aWheelOnASliderReplacesItsFieldsTyping(); + void noiseTakesItsDistributionAndChannels(); + void removeBackgroundShowsQualityAndItsAdvancedRows(); + void gradientMapSwatchesOpenThePickerAndFollowIt(); + void theMapRepaintsWhenItChanges(); + void curvesEditThroughTheSheet(); +}; + +void FilterControlsTests::eachKindShowsSwiftsControls() +{ + const std::pair<FilterKind, QStringList> rows[] = { + {FilterKind::gaussianBlur, {"radius"}}, {FilterKind::motionBlur, {"angle", "distance"}}, {FilterKind::addNoise, {"amount"}}, + {FilterKind::lensCorrection, {"removeDistortion"}}, {FilterKind::exposure, {"exposure", "offset", "gamma"}}, + {FilterKind::grain, {"amount", "size", "roughness"}}, {FilterKind::contentAwareFill, {}}, {FilterKind::curves, {}}, + {FilterKind::gradientMap, {}}, {FilterKind::removeBackground, {"refine", "contrast", "shiftEdge"}}}; + for (const auto &[kind, names] : rows) { + Sheet sheet(kind); + QVERIFY2(sheet.sheet->findChildren<QSlider *>().size() == names.size(), qPrintable(rawValue(kind))); + for (const QString &name : names) { + QLineEdit &field = sheet.child<QLineEdit>(qPrintable(name + QStringLiteral("Field"))); + QCOMPARE(field.width(), 56); + QVERIFY(field.alignment() & Qt::AlignRight); + QVERIFY(!field.accessibleName().isEmpty() && field.placeholderText() == field.accessibleName()); + QSlider &slider = sheet.child<QSlider>(qPrintable(name + QStringLiteral("Slider"))); + // Swift's title: sixty points at least, naming the slider. + QLabel *title = nullptr; + for (QLabel *each : slider.parentWidget()->findChildren<QLabel *>()) + title = each->buddy() == &slider ? each : title; + QVERIFY(title && title->text() == field.accessibleName() && title->minimumWidth() == 60); + } + QCOMPARE(sheet.sheet->findChild<CurvesControls *>() != nullptr, kind == FilterKind::curves); + QCOMPARE(sheet.sheet->findChild<GradientMapControls *>() != nullptr, kind == FilterKind::gradientMap); + QCOMPARE(sheet.sheet->findChild<QCheckBox *>(QStringLiteral("monochromatic")) != nullptr, kind == FilterKind::addNoise); + } + // Units as Swift's: px, degrees, percent, none. + const std::tuple<FilterKind, const char *, QString> units[] = { + {FilterKind::gaussianBlur, "radiusField", "px"}, {FilterKind::motionBlur, "angleField", "°"}, {FilterKind::motionBlur, "distanceField", "px"}, + {FilterKind::addNoise, "amountField", "%"}, {FilterKind::lensCorrection, "removeDistortionField", ""}, {FilterKind::grain, "sizeField", "px"}, + {FilterKind::exposure, "gammaField", ""}, {FilterKind::removeBackground, "refineField", "px"}, + {FilterKind::removeBackground, "contrastField", "%"}, {FilterKind::removeBackground, "shiftEdgeField", "px"}}; + for (const auto &[kind, name, expected] : units) { + Sheet sheet(kind); + QCOMPARE(unit(sheet.child<QLineEdit>(name)), expected); + } + Sheet lens(FilterKind::lensCorrection); + QCOMPARE(lens.child<QLabel>("filterWords").text(), + QString("Positive straightens lines that bow outward (barrel); negative, lines that bow inward (pincushion).")); + QCOMPARE(lens.child<QLabel>("filterWords").foregroundRole(), QPalette::PlaceholderText); + QCOMPARE(lens.child<QLabel>("filterWords").font().pixelSize(), 12); + // Wrapped within the sheet, and never read as markup. + for (const char *name : {"filterWords", "filterError", "filterLimited", "filterActivity"}) { + QVERIFY2(lens.child<QLabel>(name).wordWrap(), name); + QCOMPARE(lens.child<QLabel>(name).textFormat(), Qt::PlainText); + } + QCOMPARE(lens.sheet->layout()->contentsMargins(), QMargins(24, 24, 24, 24)); + Sheet fill(FilterKind::contentAwareFill); + QCOMPARE(fill.child<QLabel>("filterWords").text(), QString("Fill the selection using surrounding pixels from this layer.")); + QCOMPARE(fill.child<QLabel>("filterLimited").font().pixelSize(), 12); + QVERIFY(fill.child<QLabel>("filterWords").font().pixelSize() != 12); +} + +void FilterControlsTests::logarithmicSlidersGiveSmallValuesTheirTravel() +{ + // Halfway along a logarithmic slider: the ends' geometric mean, rounded. + const std::tuple<FilterKind, const char *, double FilterSettings::*, double> middles[] = { + {FilterKind::gaussianBlur, "radiusSlider", &FilterSettings::radius, 5}, + {FilterKind::motionBlur, "distanceSlider", &FilterSettings::distance, 45}, + {FilterKind::motionBlur, "angleSlider", &FilterSettings::angle, 0}, + {FilterKind::addNoise, "amountSlider", &FilterSettings::amount, 6.3}, + {FilterKind::lensCorrection, "removeDistortionSlider", &FilterSettings::distortion, 0}}; + for (const auto &[kind, name, field, expected] : middles) { + Sheet sheet(kind); + sheet.child<QSlider>(name).setValue(500); + QCOMPARE(sheet.settings().*field, expected); + } + Sheet exposure(FilterKind::exposure); + exposure.child<QSlider>("gammaSlider").setValue(500); + QCOMPARE(exposure.settings().exposure.gamma, 0.32); + exposure.child<QSlider>("offsetSlider").setValue(250); + QCOMPARE(exposure.settings().exposure.offset, -0.25); + exposure.child<QSlider>("offsetSlider").setValue(1000); + QCOMPARE(exposure.child<QLineEdit>("offsetField").text(), QString("0.5")); + exposure.child<QSlider>("exposureSlider").setValue(333); + QCOMPARE(exposure.settings().exposure.exposure, -6.68); + Sheet grain(FilterKind::grain); + grain.child<QSlider>("amountSlider").setValue(333); + QCOMPARE(grain.settings().grain.amount, 33.0); + grain.child<QSlider>("sizeSlider").setValue(500); + QCOMPARE(grain.settings().grain.size, 3.2); + grain.child<QSlider>("roughnessSlider").setValue(333); + QCOMPARE(grain.settings().grain.roughness, 33.0); +} + +void FilterControlsTests::theSlidersShowTheSessionsNumbersWithoutWritingBack() +{ + Sheet sheet(FilterKind::gaussianBlur); + QSlider &radius = sheet.child<QSlider>("radiusSlider"); + // Radius 5 lies halfway along its logarithmic travel. + sheet.session.updateFilter(FilterSettings{.radius = 5}, true); + QCOMPARE(radius.value(), 500); + // Between steps, a number shows without rounding back. + sheet.session.updateFilter(FilterSettings{.radius = 5.04}, true); + QCOMPARE(radius.value(), 501); + QCOMPARE(sheet.settings().radius, 5.04); + QCOMPARE(sheet.child<QLineEdit>("radiusField").text(), QString("5")); + // Return, untouched, keeps 5.04, not the shown 5. + QTest::keyClick(&sheet.child<QLineEdit>("radiusField"), Qt::Key_Return); + QCOMPARE(sheet.settings().radius, 5.04); + // With Preview off, a slider keeps it off. + sheet.session.updateFilter(sheet.settings(), false); + radius.setValue(700); + QVERIFY(!sheet.session.filterEdit().value().preview && sheet.settings().radius == 23.9); + QVERIFY(!sheet.child<QCheckBox>("filterPreview").isChecked()); +} + +void FilterControlsTests::arrowKeysStepTheSlidersOn() +{ + // A step rounding to the same value leaves the thumb. + Sheet blur(FilterKind::gaussianBlur); + QSlider &radius = blur.child<QSlider>("radiusSlider"); + for (int press = 0; press < 100; ++press) + QTest::keyClick(&radius, Qt::Key_Right); + QCOMPARE(blur.settings().radius, 3.8); + Sheet motion(FilterKind::motionBlur); + QSlider &angle = motion.child<QSlider>("angleSlider"); + for (int press = 0; press < 100; ++press) + QTest::keyClick(&angle, Qt::Key_Right); + QCOMPARE(motion.settings().angle, 31.0); +} + +void FilterControlsTests::fieldsApplyWhatTheyReadAndShowSwiftsDecimals() +{ + Sheet sheet(FilterKind::motionBlur); + // English would group thousands; the twin's fields never do. + sheet.child<QLineEdit>("distanceField").setLocale(QLocale(QLocale::English, QLocale::UnitedStates)); + QCOMPARE(sheet.child<QLineEdit>("distanceField").text(), QString("10")); + sheet.type("distanceField", QStringLiteral("2000")); + // Ungrouped, as every field of the twin. + QVERIFY(sheet.settings().distance == 2000 && sheet.child<QLineEdit>("distanceField").text() == QString("2000")); + // Normalizing clamps what passes the range; words go back. + sheet.type("angleField", QStringLiteral("135")); + QCOMPARE(sheet.settings().angle, 90.0); + QCOMPARE(sheet.child<QLineEdit>("angleField").text(), QString("90")); + for (const QString &words : {QStringLiteral("steep"), QStringLiteral("inf")}) { + sheet.type("angleField", words); + QCOMPARE(sheet.settings().angle, 90.0); + QCOMPARE(sheet.child<QLineEdit>("angleField").text(), QString("90")); + } + // Up to the kind's decimals, none trailing. + Sheet exposure(FilterKind::exposure); + exposure.type("offsetField", QStringLiteral("0.12345")); + QCOMPARE(exposure.settings().exposure.offset, 0.12345); + QCOMPARE(exposure.child<QLineEdit>("offsetField").text(), QString("0.1235")); + exposure.type("exposureField", QStringLiteral("1.5")); + QCOMPARE(exposure.child<QLineEdit>("exposureField").text(), QString("1.5")); + // A German field reads and writes its own decimal mark. + exposure.child<QLineEdit>("exposureField").setLocale(QLocale(QLocale::German, QLocale::Germany)); + exposure.type("exposureField", QStringLiteral("2,25")); + QCOMPARE(exposure.settings().exposure.exposure, 2.25); + QCOMPARE(exposure.child<QLineEdit>("exposureField").text(), QString("2,25")); + // A field being typed in keeps its typing. + QLineEdit &gamma = exposure.child<QLineEdit>("gammaField"); + gamma.setFocus(); + gamma.selectAll(); + QTest::keyClicks(&gamma, QStringLiteral("2.")); + exposure.child<QSlider>("exposureSlider").setValue(0); + QCOMPARE(gamma.text(), QString("2.")); +} + +void FilterControlsTests::aWheelOnASliderReplacesItsFieldsTyping() +{ + Sheet sheet(FilterKind::motionBlur); + const auto wheel = [&sheet](const char *name) { + QSlider &slider = sheet.child<QSlider>(name); + QWheelEvent notch(QPointF(10, 5), slider.mapToGlobal(QPointF(10, 5)), QPoint(), QPoint(0, 120), Qt::NoButton, Qt::NoModifier, + Qt::NoScrollPhase, false); + QApplication::sendEvent(&slider, ¬ch); + }; + const auto typing = [&sheet](const char *name, const QString &text) { + QLineEdit &field = sheet.child<QLineEdit>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + return &field; + }; + // A notch that leaves the rounded value leaves the typing. + QLineEdit *distance = typing("distanceField", QStringLiteral("123")); + wheel("distanceSlider"); + QVERIFY(sheet.settings().distance == 10 && distance->text() == "123" && distance->isModified()); + // One that moves it shows the value instead. + QLineEdit *angle = typing("angleField", QStringLiteral("45")); + wheel("angleSlider"); + QVERIFY(sheet.settings().angle == 1 && angle->text() == "1" && !angle->isModified()); + // Return then keeps the slider's value. + QTest::keyClick(angle, Qt::Key_Return); + QCOMPARE(sheet.settings().angle, 1.0); +} + +void FilterControlsTests::noiseTakesItsDistributionAndChannels() +{ + Sheet sheet(FilterKind::addNoise); + QToolButton &uniform = sheet.child<QToolButton>("uniformChoice"), &gaussian = sheet.child<QToolButton>("gaussianChoice"); + QVERIFY(uniform.isChecked() && !gaussian.isChecked()); + QCOMPARE(uniform.parentWidget()->findChild<QLabel *>()->text(), QString("Distribution")); + gaussian.click(); + QVERIFY(sheet.settings().gaussian); + QCheckBox &mono = sheet.child<QCheckBox>("monochromatic"); + mono.click(); + QVERIFY(sheet.settings().monochromatic); + // The session's own changes show without being written back. + sheet.session.updateFilter(FilterSettings{.amount = 20}, true); + QVERIFY(uniform.isChecked() && !mono.isChecked()); +} + +void FilterControlsTests::removeBackgroundShowsQualityAndItsAdvancedRows() +{ + Sheet sheet(FilterKind::removeBackground); + QCOMPARE(sheet.child<QLabel>("filterWords").text(), QString("Hide the background behind a layer mask, keeping the foreground subjects. " + "The pixels stay, so the background can be painted back at any time.")); + QVERIFY(sheet.child<QLabel>("filterWords").font().pixelSize() != 12); + // Swift's segments: Basic, then Advanced, the label hidden. + QWidget &quality = sheet.child<QWidget>("backgroundQuality"); + QCOMPARE(quality.accessibleName(), QString("Quality")); + QCOMPARE(quality.layout()->spacing(), 0); + QCOMPARE(quality.toolTip(), QString("Basic is quick; Advanced refines the mask against the layer's own detail, for hair and fur")); + const QList<QToolButton *> choices = quality.findChildren<QToolButton *>(); + QVERIFY(choices.size() == 2 && choices[0]->text() == "Basic" && choices[1]->text() == "Advanced" && choices[0]->isChecked()); + const std::pair<const char *, QString> rows[] = {{"refineSlider", "Pull the mask onto the image's own edges, which recovers hair and fur"}, + {"contrastSlider", "Clear the haze that leaves background showing through thin areas"}, + {"shiftEdgeSlider", "Shrink the mask to drop the rim of background color around the subject, or grow it"}}; + // Basic hides Advanced's rows; Advanced shows them. + for (const auto &[name, help] : rows) + QVERIFY2(!sheet.child<QSlider>(name).parentWidget()->isVisibleTo(sheet.sheet.get()), name); + choices[1]->click(); + QVERIFY(sheet.settings().backgroundQuality == BackgroundQuality::advanced && choices[1]->isChecked()); + for (const auto &[name, help] : rows) { + QVERIFY2(sheet.child<QSlider>(name).parentWidget()->isVisibleTo(sheet.sheet.get()), name); + QCOMPARE(sheet.child<QSlider>(name).parentWidget()->toolTip(), help); + } + // Each slider sets its own setting over Swift's range. + sheet.child<QSlider>("refineSlider").setValue(500); + sheet.child<QSlider>("contrastSlider").setValue(1000); + sheet.child<QSlider>("shiftEdgeSlider").setValue(0); + QVERIFY(sheet.settings().refineEdges == 20 && sheet.settings().matteContrast == 100 && sheet.settings().shiftEdge == -10); + // A quality set elsewhere shows at once. + FilterSettings basic = sheet.settings(); + basic.backgroundQuality = BackgroundQuality::basic; + sheet.session.updateFilter(basic, true); + QVERIFY(choices[0]->isChecked() && !sheet.child<QSlider>("refineSlider").parentWidget()->isVisibleTo(sheet.sheet.get())); +} + +void FilterControlsTests::gradientMapSwatchesOpenThePickerAndFollowIt() +{ + Sheet sheet(FilterKind::gradientMap); + SwatchButton &shadows = sheet.child<SwatchButton>("shadowsSwatch"); + QCOMPARE(shadows.toolTip(), QString("Choose the shadows color")); + shadows.click(); + QVERIFY(!sheet.session.colorPicker().value().target.highlights); + sheet.session.closeColorPicker(false); + SwatchButton &highlights = sheet.child<SwatchButton>("highlightsSwatch"); + QCOMPARE(highlights.size(), QSize(24, 24)); + QCOMPARE(highlights.toolTip(), QString("Choose the highlights color")); + QCOMPARE(highlights.accessibleName(), QString("Highlights color")); + highlights.click(); + QVERIFY(sheet.session.colorPicker().value().target.highlights); + // The sheet hands the picker's working colour to the map. + PickerHSB hsb = sheet.session.colorPicker().value().hsb; + hsb.setRGB(PaletteColor{1, 0, 0}); + sheet.session.setColorPickerHSB(hsb); + QCOMPARE(sheet.settings().gradientMap.highlights, AdjustmentColor(1, 0, 0)); + sheet.session.closeColorPicker(true); + QCheckBox &reverse = sheet.child<QCheckBox>("gradientMapReverse"); + reverse.click(); + QVERIFY(sheet.settings().gradientMap.reversed); + FilterSettings forward = sheet.settings(); + forward.gradientMap.reversed = false; + sheet.session.updateFilter(forward, true); + QVERIFY(!reverse.isChecked()); + reverse.click(); + // Each swatch shows its own end. + QCOMPARE(highlights.grab().toImage().pixelColor(12, 12).rgb(), QColor(Qt::red).rgb()); + QCOMPARE(shadows.grab().toImage().pixelColor(12, 12).rgb(), QColor(Qt::black).rgb()); + // Reversed, the bar runs from the highlights to the shadows. + const QImage drawn = sheet.child<QWidget>("gradientMapBar").grab().toImage(); + QCOMPARE(drawn.height(), 20); + // A 35% black rim lies over the gradient's edge. + const int middle = drawn.width() / 2; + QVERIFY(std::abs(drawn.pixelColor(middle, 0).red() - int(std::lround(drawn.pixelColor(middle, 10).red() * 0.65))) <= 2); + for (const int x : {2, drawn.width() / 2, drawn.width() - 3}) { + const QColor seen = drawn.pixelColor(x, 10); + const int red = int(std::lround(255 * (1 - (x + 0.5) / drawn.width()))); + QVERIFY2(std::abs(seen.red() - red) <= 3 && seen.green() == 0 && seen.blue() == 0, qPrintable(seen.name())); + } +} + +void FilterControlsTests::theMapRepaintsWhenItChanges() +{ + Sheet sheet(FilterKind::gradientMap); + sheet.sheet->show(); + QVERIFY(QTest::qWaitForWindowExposed(sheet.sheet.get())); + Paints bar, swatch; + sheet.child<QWidget>("gradientMapBar").installEventFilter(&bar); + sheet.child<SwatchButton>("shadowsSwatch").installEventFilter(&swatch); + QTest::qWait(50); + bar.count = swatch.count = 0; + FilterSettings changed = sheet.settings(); + changed.gradientMap.shadows = AdjustmentColor(0, 1, 0); + sheet.session.updateFilter(changed, true); + QTRY_VERIFY(bar.count > 0 && swatch.count > 0); +} + +void FilterControlsTests::curvesEditThroughTheSheet() +{ + Sheet sheet(FilterKind::curves); + CurvesSettings lifted; + lifted.channels[0] = {{0, 40}, {255, 255}}; + sheet.session.updateFilter(FilterSettings{.curves = lifted}, true); + CurvesSettings red = lifted; + red.channel = LevelsChannel::red; + sheet.session.updateFilter(FilterSettings{.curves = red}, true); + QCOMPARE(sheet.sheet->findChild<CurvesControls *>()->findChild<QComboBox *>()->currentIndex(), 1); + sheet.session.updateFilter(FilterSettings{.curves = lifted}, true); + // The hosted controls read the session's curve and write it. + for (QPushButton *button : sheet.sheet->findChildren<QPushButton *>()) { + if (button->text() == QStringLiteral("Reset curve")) + button->click(); + } + QVERIFY(sheet.settings().curves == CurvesSettings()); +} + +QTEST_MAIN(FilterControlsTests) +#include "FilterControlsTests.moc" diff --git a/tests/FilterFailureTests.cpp b/tests/FilterFailureTests.cpp new file mode 100644 index 0000000..302ac09 --- /dev/null +++ b/tests/FilterFailureTests.cpp @@ -0,0 +1,48 @@ +#include "AddressSpaceLimit.h" +#include "Document/Filters.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <QtTest> +#include <malloc.h> + +// The filters' pixels when memory runs out. +class FilterFailureTests : public QObject { + Q_OBJECT +private slots: + void theBlursFailAsAContextWhenTheirRowsFindNoMemory(); + void aTrimThatFindsNoMemoryIsARenderError(); +}; + +void FilterFailureTests::theBlursFailAsAContextWhenTheirRowsFindNoMemory() +{ + // One pixel wide: its row list asks as its pixels. + QImage tall(1, 16'000'000, QImage::Format_RGBA8888_Premultiplied); + tall.fill(Qt::red); + malloc_trim(0); + { + // The 64 MB result fits; the rows do not. + const AddressSpaceLimit limit(80ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, PixelAdjust::motionBlur(tall, 1, 0)); + } + malloc_trim(0); + { + // Result and 256 MB of sums fit; rows do not. + const AddressSpaceLimit limit(350ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, PixelAdjust::gaussianBlur(tall, 1, false)); + } +} + +void FilterFailureTests::aTrimThatFindsNoMemoryIsARenderError() +{ + // Opaque but for its border: the crop copies 96 MB. + QImage framed(6000, 4000, QImage::Format_RGBA8888_Premultiplied); + framed.fill(Qt::transparent); + QPainter(&framed).fillRect(QRect(1, 1, 5998, 3998), Qt::red); + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, PixelFilter::trimmed(framed, LayerTransform{.origin = {0, 0}, .size = {6000, 4000}})); +} + +QTEST_GUILESS_MAIN(FilterFailureTests) +#include "FilterFailureTests.moc" diff --git a/tests/FilterModelTests.cpp b/tests/FilterModelTests.cpp new file mode 100644 index 0000000..3385e39 --- /dev/null +++ b/tests/FilterModelTests.cpp @@ -0,0 +1,356 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession+Model.h" +#include "Document/Filters.h" +#include "Document/PixelAdjust.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainter> +#include <QtTest> +#include <cmath> +#include <limits> +#include <numbers> + +extern "C" { +#include "LensPixels.h" +#include "NoisePixels.h" +} + +// The filters' models: kinds, settings, the edit's grids, the blur. +namespace { +QImage filled(int width, int height, QColor colour = Qt::red) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +// A painted asset without full pixels: a two-pixel base. +ImportedImage painted(int width, int height) +{ + return ImportedImage(std::make_shared<const RasterSnapshot>(width, height, filled(2, 2), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}), QImage(), + QStringLiteral("Painted")); +} + +ImageLayer layer(int width, int height, QPointF origin = QPointF()) +{ + const QImage image = filled(width, height); + return ImageLayer(ImportedImage(image, image, QStringLiteral("Red")), origin); +} + +int alpha(const QImage &image, int x, int y) +{ + return image.constScanLine(y)[x * 4 + 3]; +} +} + +class FilterModelTests : public QObject { + Q_OBJECT +private slots: + void kindsKeepSwiftsNames(); + void settingsNormalizeIntoTheirRanges(); + void blursTakeRoomRoundTheLayer(); + void previewsScaleTheLongSideTo2048UnlessGrainy(); + void aBlurGrowsTheGridAndNeverShrinksIt(); + void contentAwareFillGrowsOverTheArea(); + void theMotionBlurTapersAsAGaussianAlongItsAngle(); + void theMotionBlurReadsNothingPastItsImage(); + void theGaussianBlurFadesTheImagesOwnEdge(); + void theImageAdjustmentsRunTheirOwnSettings(); + void aSelectionLimitsTheFilter(); + void previewsFilterByTheirScale(); + void theKernelsKeepSwiftsConstants(); + void paintedLayersKeepTheirTiles(); +}; + +void FilterModelTests::kindsKeepSwiftsNames() +{ + const std::pair<FilterKind, const char *> kinds[] = { + {FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"}, + {FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::removeBackground, "Remove Background"}, + {FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"}, + {FilterKind::gradientMap, "Gradient Map"}, {FilterKind::grain, "Grain"}}; + // Swift's allCases is this order, the menus'. + QCOMPARE(allFilterKinds.size(), std::size(kinds)); + for (size_t index = 0; index < std::size(kinds); ++index) { + const auto &[kind, name] = kinds[index]; + QVERIFY(allFilterKinds[index] == kind); + QCOMPARE(rawValue(kind), QString(name)); + QCOMPARE(isAutomatic(kind), kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground); + QCOMPARE(isImageAdjustment(kind), index >= 6); + } + // Remove Background's qualities, Swift's words and order. + QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic); + QVERIFY(rawValue(BackgroundQuality::basic) == "Basic" && rawValue(BackgroundQuality::advanced) == "Advanced"); + QVERIFY(FilterSettings().backgroundQuality == BackgroundQuality::basic); +} + +void FilterModelTests::settingsNormalizeIntoTheirRanges() +{ + constexpr double nan = std::numeric_limits<double>::quiet_NaN(); + FilterSettings wild{.radius = 1000, .angle = -200, .distance = 0, .amount = 1000, .distortion = -500}; + wild.grain.amount = 500; + wild.exposure.gamma = 50; + wild.gradientMap.shadows = AdjustmentColor(2, -1, 0.5); + wild.curves.channels[1] = {{0, 0}, {128, 255}, {255, 255}}; + wild.refineEdges = 90; + wild.matteContrast = -5; + wild.shiftEdge = 20; + const FilterSettings tamed = wild.normalized(); + QVERIFY(tamed.refineEdges == 40 && tamed.matteContrast == 0 && tamed.shiftEdge == 10); + QVERIFY(tamed.radius == 250 && tamed.angle == -90 && tamed.distance == 1 && tamed.amount == 400 && tamed.distortion == -100); + QVERIFY(tamed.grain.amount == 100 && tamed.exposure.gamma == 9.99); + QCOMPARE(tamed.gradientMap.shadows, AdjustmentColor(1, 0, 0.5)); + // Curves pass as they are, as Swift's. + QVERIFY(tamed.curves == wild.curves); + FilterSettings wide{.radius = 0, .angle = 200, .distance = 5000, .amount = 0, .distortion = 500}; + wide.refineEdges = -1; + wide.matteContrast = 300; + wide.shiftEdge = -20; + const FilterSettings low = wide.normalized(); + QVERIFY(low.radius == 0.1 && low.angle == 90 && low.distance == 2000 && low.amount == 0.1 && low.distortion == 100); + QVERIFY(low.refineEdges == 0 && low.matteContrast == 100 && low.shiftEdge == -10); + // What is no number falls back to the default. + FilterSettings unknown{.radius = nan, .angle = nan, .distance = nan, .amount = nan, .distortion = nan}; + unknown.refineEdges = nan; + unknown.matteContrast = nan; + unknown.shiftEdge = nan; + QVERIFY(unknown.normalized() == FilterSettings()); + QVERIFY(FilterSettings().refineEdges == 12 && FilterSettings().matteContrast == 25 && FilterSettings().shiftEdge == 0); +} + +void FilterModelTests::blursTakeRoomRoundTheLayer() +{ + QCOMPARE(FilterEdit::blurMargin(FilterKind::gaussianBlur, FilterSettings{.radius = 3}), 11.0); + QCOMPARE(FilterEdit::blurMargin(FilterKind::motionBlur, FilterSettings{.distance = 10}), 7.0); + for (const FilterKind kind : {FilterKind::addNoise, FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill, + FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain}) + QCOMPARE(FilterEdit::blurMargin(kind, FilterSettings{.radius = 3, .distance = 10}), 0.0); +} + +void FilterModelTests::previewsScaleTheLongSideTo2048UnlessGrainy() +{ + // Swift's Int() truncates: 2215 tall previews 2047. + for (const auto &[size, preview] : {std::pair(QSize(4096, 3), QSize(2048, 1)), std::pair(QSize(3, 2215), QSize(2, 2047))}) { + const FilterEdit edit(FilterKind::exposure, layer(size.width(), size.height()), std::nullopt, FilterSettings(), std::nullopt); + QCOMPARE(edit.previewSource.size(), preview); + QCOMPARE(edit.previewScale, double(preview.width()) / size.width()); + // The preview's far corner is the layer's. + QCOMPARE(edit.previewMapping.map(QPointF(preview.width(), preview.height())), QPointF(size.width(), size.height())); + QCOMPARE(edit.mapping.map(QPointF(size.width(), size.height())), QPointF(size.width(), size.height())); + const FilterJob job = edit.previewJob(); + QVERIFY(job.kind == FilterKind::exposure && job.scale == edit.previewScale && job.seed == edit.seed && job.image.size() == preview); + QVERIFY(job.settings == edit.settings && job.mapping == edit.previewMapping); + } + // Noise and grain preview whole: enlarged grain looks coarse. + for (const FilterKind kind : {FilterKind::addNoise, FilterKind::grain, FilterKind::contentAwareFill, FilterKind::removeBackground}) { + const FilterEdit edit(kind, layer(4096, 3), std::nullopt, FilterSettings(), std::nullopt); + QVERIFY(edit.previewSource.size() == QSize(4096, 3) && edit.previewScale == 1 && edit.previewMapping == edit.mapping); + } + // A small layer's own pixels are the preview. + const ImageLayer small = layer(2048, 3); + const FilterEdit whole(FilterKind::exposure, small, std::nullopt, FilterSettings(), std::nullopt); + QCOMPARE(whole.previewSource.cacheKey(), small.asset.value().image().cacheKey()); + // Two edits roll their own grain. + const FilterEdit first(FilterKind::addNoise, layer(2, 2), std::nullopt, FilterSettings(), std::nullopt); + const FilterEdit second(FilterKind::addNoise, layer(2, 2), std::nullopt, FilterSettings(), std::nullopt); + QVERIFY(first.seed != second.seed); + // Settings arrive normalized. + QCOMPARE(FilterEdit(FilterKind::gaussianBlur, layer(2, 2), std::nullopt, FilterSettings{.radius = 1000}, std::nullopt).settings.radius, 250.0); +} + +void FilterModelTests::aBlurGrowsTheGridAndNeverShrinksIt() +{ + FilterEdit edit(FilterKind::gaussianBlur, layer(40, 20, QPointF(10, 10)), std::nullopt, FilterSettings{.radius = 3}, std::nullopt); + QCOMPARE(edit.grownMargin, 11.0); + QCOMPARE(edit.grownImage.value().size(), QSize(62, 42)); + QCOMPARE(edit.grownTransform.value().origin, QPointF(-1, -1)); + QCOMPARE(edit.grownTransform.value().size, QSizeF(62, 42)); + // The layer's pixels sit inside the padding. + QVERIFY(alpha(edit.grownImage.value(), 10, 10) == 0 && alpha(edit.grownImage.value(), 11, 11) == 255 && alpha(edit.grownImage.value(), 51, 31) == 0); + QVERIFY(edit.previewSource.size() == QSize(62, 42) && edit.mapping.map(QPointF(0, 0)) == QPointF(-1, -1)); + // A smaller blur keeps the grid; a bigger one grows. + edit.settings = FilterSettings{.radius = 1}; + edit.growForBlur(); + QCOMPARE(edit.grownImage.value().size(), QSize(62, 42)); + edit.settings = FilterSettings{.radius = 10}; + edit.growForBlur(); + QCOMPARE(edit.grownMargin, 32.0); + QCOMPARE(edit.grownImage.value().size(), QSize(104, 84)); + QCOMPARE(edit.grownTransform.value().origin, QPointF(-22, -22)); + // A motion blur's reach is half its streak. + const FilterEdit motion(FilterKind::motionBlur, layer(4, 4), std::nullopt, FilterSettings{.distance = 9}, std::nullopt); + QCOMPARE(motion.grownImage.value().size(), QSize(18, 18)); + // Past 30,000 a side or 100 million pixels: refused. + QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(29990, 1), std::nullopt, FilterSettings{.radius = 3}, std::nullopt)); + QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, layer(1, 29990), std::nullopt, FilterSettings{.radius = 3}, std::nullopt)); + QVERIFY_THROWS_EXCEPTION(ProjectError, FilterEdit(FilterKind::gaussianBlur, ImageLayer(painted(9990, 9990), QPointF()), std::nullopt, + FilterSettings{.radius = 3}, std::nullopt)); +} + +void FilterModelTests::contentAwareFillGrowsOverTheArea() +{ + // The area passes the layer's right and bottom only. + const FilterEdit edit(FilterKind::contentAwareFill, layer(10, 10, QPointF(5, 5)), std::nullopt, FilterSettings(), QRectF(8, 8, 12.5, 9)); + QCOMPARE(edit.grownImage.value().size(), QSize(16, 12)); + QCOMPARE(edit.grownTransform.value().origin, QPointF(5, 5)); + QCOMPARE(edit.grownMargin, 0.0); + QVERIFY(edit.previewSource.size() == QSize(16, 12) && edit.previewScale == 1); + // Inside the layer nothing grows. + const FilterEdit inside(FilterKind::contentAwareFill, layer(10, 10), std::nullopt, FilterSettings(), QRectF(2, 2, 3, 3)); + QVERIFY(!inside.grownImage && !inside.grownTransform && inside.grownMargin == 0); +} + +void FilterModelTests::theMotionBlurTapersAsAGaussianAlongItsAngle() +{ + QImage dot = BrushRaster::context(21, 21, false); + QPainter(&dot).fillRect(10, 10, 1, 1, Qt::white); + // Level, the streak's taps are the Gaussian's weights. + const QImage level = PixelAdjust::motionBlur(dot, 2, 0); + double total = 0; + for (int i = 0; i <= 6; ++i) + total += (i == 0 ? 1 : 2) * std::exp(-i * i / 8.0); + for (int i = 0; i <= 6; ++i) { + const int expected = int(std::lround(float(std::exp(-i * i / 8.0) / total) * 255)); + QVERIFY2(alpha(level, 10 + i, 10) == expected && alpha(level, 10 - i, 10) == expected, qPrintable(QString::number(i))); + QCOMPARE(alpha(level, 10, 10 + std::max(i, 1)), 0); + } + // A quarter turn streaks down the column. + const QImage upright = PixelAdjust::motionBlur(dot, 2, std::numbers::pi / 2); + QVERIFY(alpha(upright, 10, 13) == alpha(level, 13, 10) && alpha(upright, 13, 10) == 0); + // Unclamped: what streaks past the edge is lost. + QImage edge = BrushRaster::context(3, 1, false); + edge.fill(Qt::white); + const float kept = float(1 / total) + float(std::exp(-1 / 8.0) / total) + float(std::exp(-4 / 8.0) / total); + QCOMPARE(alpha(PixelAdjust::motionBlur(edge, 2, 0), 0, 0), int(std::lround(kept * 255))); + // Slanted taps share bilinearly: 0.48 here, then 0.32. + const QImage slanted = PixelAdjust::motionBlur(dot, 1, std::atan2(0.6, 0.8)); + double sum = 0; + for (int i = 0; i <= 3; ++i) + sum += (i == 0 ? 1 : 2) * std::exp(-i * i / 2.0); + QCOMPARE(alpha(slanted, 9, 11), int(std::lround((std::exp(-0.5) * 0.48 + std::exp(-2.0) * 0.32) / sum * 255))); + const auto refusal = [&dot](double radius, double angle) { + try { + PixelAdjust::motionBlur(dot, radius, angle); + } catch (const std::logic_error &error) { + return QString::fromUtf8(error.what()); + } + return QString(); + }; + const QString words = QStringLiteral("a motion blur needs a finite positive radius and angle"); + QCOMPARE(refusal(0, 0), words); + QCOMPARE(refusal(std::numeric_limits<double>::infinity(), 0), words); + QCOMPARE(refusal(2, std::numeric_limits<double>::quiet_NaN()), words); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::motionBlur(dot.convertToFormat(QImage::Format_ARGB32), 2, 0)); +} + +void FilterModelTests::theMotionBlurReadsNothingPastItsImage() +{ + // A clear 5 by 5 in an opaque one-pixel frame. + std::vector<uchar> framed(7 * 7 * 4, 255); + for (int y = 1; y < 6; ++y) + std::fill_n(framed.begin() + (y * 7 + 1) * 4, 5 * 4, uchar(0)); + const QImage inside(framed.data() + (7 + 1) * 4, 5, 5, 7 * 4, QImage::Format_RGBA8888_Premultiplied); + for (const double angle : {0.0, std::numbers::pi / 2, std::numbers::pi / 4, -std::numbers::pi / 4}) + QCOMPARE(PixelAdjust::motionBlur(inside, 2, angle), BrushRaster::context(5, 5, false)); +} + +void FilterModelTests::theGaussianBlurFadesTheImagesOwnEdge() +{ + // Unclamped, as Core Image blurs a finite image: corners fade. + const QImage blurred = PixelFilter::run(FilterJob{FilterKind::gaussianBlur, filled(8, 8, Qt::white), FilterSettings{.radius = 1}, 1, std::nullopt, QTransform()}); + double side = 0, sum = 0; + for (int i = 0; i <= 3; ++i) { + side += std::exp(-i * i / 2.0); + sum += (i == 0 ? 1 : 2) * std::exp(-i * i / 2.0); + } + QCOMPARE(alpha(blurred, 0, 0), int(std::lround(side / sum * side / sum * 255))); + QCOMPARE(alpha(blurred, 4, 4), 255); +} + +void FilterModelTests::theImageAdjustmentsRunTheirOwnSettings() +{ + const QImage colour = filled(4, 4, QColor(100, 150, 200)); + FilterSettings settings; + settings.curves.channels[0] = {{0, 40}, {255, 255}}; + settings.exposure.exposure = 1; + settings.gradientMap = GradientMapSettings{{1, 0, 0}, {0, 0, 1}, false}; + settings.grain.amount = 50; + const auto run = [&](FilterKind kind) { return PixelFilter::run(FilterJob{kind, colour, settings, 1, std::nullopt, QTransform(), 9}); }; + const QImage made[] = {settings.curves.apply(colour), settings.exposure.apply(colour), settings.gradientMap.apply(colour), + settings.grain.apply(colour, QPointF(), 1, 9)}; + const FilterKind kinds[] = {FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain}; + for (int index = 0; index < 4; ++index) { + QVERIFY(made[index] != colour); + QCOMPARE(run(kinds[index]), made[index]); + } +} + +void FilterModelTests::aSelectionLimitsTheFilter() +{ + const QImage gray = filled(4, 1, QColor(100, 100, 100)); + QPainterPath left; + left.addRect(0, 0, 2, 1); + const SelectionClip clip = DocumentSelection{left, false}.clip(QSizeF(4, 1)); + FilterSettings settings; + settings.exposure.exposure = 1; + const QImage made = PixelFilter::run(FilterJob{FilterKind::exposure, gray, settings, 1, clip, QTransform()}); + QVERIFY(made.pixelColor(1, 0).red() > 100); + QCOMPARE(made.pixelColor(2, 0), QColor(100, 100, 100)); +} + +void FilterModelTests::previewsFilterByTheirScale() +{ + // Half white across: a half-size preview blurs half as far. + QImage stripe = BrushRaster::context(40, 3, false); + QPainter(&stripe).fillRect(20, 0, 20, 3, Qt::white); + const auto run = [&stripe](FilterKind kind, const FilterSettings &settings, double scale) { + return PixelFilter::run(FilterJob{kind, stripe, settings, scale, std::nullopt, QTransform(), 3}); + }; + QCOMPARE(run(FilterKind::gaussianBlur, FilterSettings{.radius = 4}, 0.5), run(FilterKind::gaussianBlur, FilterSettings{.radius = 2}, 1)); + QCOMPARE(run(FilterKind::motionBlur, FilterSettings{.distance = 16}, 0.5), run(FilterKind::motionBlur, FilterSettings{.distance = 8}, 1)); + // Grain sits in layer pixels: half scale, twice the units. + FilterSettings grain; + grain.grain.amount = 50; + QCOMPARE(run(FilterKind::grain, grain, 0.5), grain.grain.apply(stripe, QPointF(), 2, 3)); +} + +void FilterModelTests::theKernelsKeepSwiftsConstants() +{ + // A 16-pixel streak spreads as an even one: sigma 4.62. + QImage dot = BrushRaster::context(41, 41, false); + QPainter(&dot).fillRect(20, 20, 1, 1, Qt::white); + const QImage streak = PixelFilter::run(FilterJob{FilterKind::motionBlur, dot, FilterSettings{.distance = 16}, 1, std::nullopt, QTransform()}); + QCOMPARE(alpha(streak, 20, 20), 22); + // Remove Distortion at 100 is the kernel's k of 0.35. + QImage ramp = BrushRaster::context(40, 30, false); + for (int x = 0; x < 40; ++x) + QPainter(&ramp).fillRect(x, 0, 1, 30, QColor(x * 6, 0, 0)); + const QImage bent = PixelFilter::run(FilterJob{FilterKind::lensCorrection, ramp, FilterSettings{.distortion = 100}, 1, std::nullopt, QTransform()}); + QImage lens = BrushRaster::context(40, 30, false); + lens_distort(ramp.constBits(), lens.bits(), 40, 30, size_t(ramp.bytesPerLine()), 0.35); + QCOMPARE(bent, lens); + // The job's settings are normalized first: 500 is 100. + QCOMPARE(PixelFilter::run(FilterJob{FilterKind::lensCorrection, ramp, FilterSettings{.distortion = 500}, 1, std::nullopt, QTransform()}), bent); + // Add Noise hands the kernel amount, kind, channels, seed. + const QImage gray = filled(8, 8, QColor(128, 128, 128)); + for (const FilterSettings &settings : {FilterSettings{.amount = 30}, FilterSettings{.amount = 30, .gaussian = true}, + FilterSettings{.amount = 30, .monochromatic = true}}) { + QImage noise = gray.copy(); + noise_add(noise.bits(), 8, 8, size_t(noise.bytesPerLine()), 30, settings.gaussian ? 1 : 0, settings.monochromatic ? 1 : 0, 5); + QVERIFY(noise != gray); + QCOMPARE(PixelFilter::run(FilterJob{FilterKind::addNoise, gray, settings, 1, std::nullopt, QTransform(), 5}), noise); + } +} + +void FilterModelTests::paintedLayersKeepTheirTiles() +{ + // A painted layer's preview and grid come from its tiles. + const ImportedImage asset = painted(3000, 10); + const FilterEdit adjusted(FilterKind::exposure, ImageLayer(asset, QPointF()), std::nullopt, FilterSettings(), std::nullopt); + QVERIFY(adjusted.previewSource.size() == QSize(2048, 6) && !asset.raster->hasMaterializedPixels()); + const FilterEdit blurred(FilterKind::gaussianBlur, ImageLayer(asset, QPointF()), std::nullopt, FilterSettings(), std::nullopt); + QVERIFY(blurred.grownImage.value().size() == QSize(3010, 20) && !asset.raster->hasMaterializedPixels()); +} + +QTEST_GUILESS_MAIN(FilterModelTests) +#include "FilterModelTests.moc" diff --git a/tests/FilterSessionTests.cpp b/tests/FilterSessionTests.cpp new file mode 100644 index 0000000..a5c70f9 --- /dev/null +++ b/tests/FilterSessionTests.cpp @@ -0,0 +1,376 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include "SelectionFixtures.h" +#include <QSignalSpy> +#include <QThreadPool> +#include <QtTest> + +// Filters in the session: every kind's begin, update and commit. +namespace { +std::unique_ptr<EditorSession> filled(int width, int height, QColor colour = Qt::red) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height); + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + session->insert(ImportedImage(image, image, QStringLiteral("Filled"))); + return session; +} + +bool committed(EditorSession &session) +{ + bool done = false; + session.commitFilter([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} + +// The newest preview has landed and nothing waits. +bool settled(const EditorSession &session) +{ + return QTest::qWaitFor([&] { return session.filterEdit() && !session.filterEdit().value().preparing; }, 20000); +} + +const FilterSettings &settings(const EditorSession &session) +{ + return session.filterEdit().value().settings; +} +} + +class FilterSessionTests : public QObject { + Q_OBJECT +private slots: + void beginOpensEveryKindWhereColorsCanBeAdjusted(); + void gradientMapStartsFromThePalette(); + void updatesNormalizeGrowAndDropWhatPreviewOffHides(); + void theLastCommitIsWhereTheNextStarts(); + void nothingToChangeClosesWithoutAStep(); + void blurCommitsAreTrimmedAndOthersKeepTheirPlace(); + void aRunningPreviewDropsAtTheCommit(); + void anAutomaticKindRendersOncePerSettings(); + void aCoveringMaskStaysPutUnderABlur(); + void theGradientMapPickerEditsTheOpenMap(); + void onlyTheFillGrowsOverTheSelection(); + void aGrowthPastTheLimitsIsAnError(); +}; + +void FilterSessionTests::beginOpensEveryKindWhereColorsCanBeAdjusted() +{ + const std::unique_ptr<EditorSession> session = filled(8, 8); + for (const FilterKind kind : {FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise, FilterKind::lensCorrection, + FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain}) { + session->beginFilter(kind); + QVERIFY2(session->filterEdit() && session->filterEdit().value().kind == kind, qPrintable(rawValue(kind))); + QVERIFY(settled(*session) && session->filterEdit().value().previewImage(session->activeLayerID().value())); + session->cancelFilter(); + } + // The fill needs a selection: without one nothing opens. + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(!session->filterEdit()); + // Under Hue/Saturation, and on a blank layer, nothing opens. + session->beginHueSaturation(); + session->beginFilter(FilterKind::gaussianBlur); + QVERIFY(!session->filterEdit()); + session->cancelHueSaturation(); + session->addBlankLayer(); + session->beginFilter(FilterKind::exposure); + QVERIFY(!session->filterEdit()); + session->undo(); + // A transform edit lands first; a lasso draft goes. + session->selectTool(NavigationTool::move); + session->beginTransform(); + LayerTransform moved = session->activeLayer().value().transform; + moved.origin = QPointF(1, 0); + session->previewTransform(moved); + session->beginFilter(FilterKind::exposure); + QVERIFY(!session->transformEdit()); + QCOMPARE(session->filterEdit().value().mapping.map(QPointF(0, 0)), QPointF(1, 0)); + session->cancelFilter(); + session->selectTool(NavigationTool::lasso); + session->beginLasso(QPointF(0, 0), SelectionMode::replace); + session->beginFilter(FilterKind::exposure); + QVERIFY(!session->lassoDraft() && session->filterEdit()); + session->cancelFilter(); +} + +void FilterSessionTests::gradientMapStartsFromThePalette() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4); + session->setForegroundColor(PaletteColor{0.2, 0.4, 0.6}); + session->setBackgroundColor(PaletteColor{0.9, 0.8, 0.7}); + session->beginFilter(FilterKind::gradientMap); + QCOMPARE(settings(*session).gradientMap.shadows, AdjustmentColor(0.2, 0.4, 0.6)); + QCOMPARE(settings(*session).gradientMap.highlights, AdjustmentColor(0.9, 0.8, 0.7)); + session->cancelFilter(); + // Other kinds start from the remembered settings. + session->beginFilter(FilterKind::exposure); + QVERIFY(settings(*session) == FilterSettings()); + session->cancelFilter(); +} + +void FilterSessionTests::updatesNormalizeGrowAndDropWhatPreviewOffHides() +{ + const std::unique_ptr<EditorSession> session = filled(20, 20); + session->beginFilter(FilterKind::gaussianBlur); + QVERIFY(settled(*session)); + QCOMPARE(session->filterEdit().value().grownMargin, 5.0); + // A radius past range stops at 250; the grid grows. + session->updateFilter(FilterSettings{.radius = 1000}, true); + QCOMPARE(settings(*session).radius, 250.0); + QCOMPARE(session->filterEdit().value().grownMargin, 752.0); + QVERIFY(!session->filterEdit().value().preparedPreview); + QVERIFY(settled(*session) && session->filterEdit().value().preparedPreview); + // Off: the preview and the queue go at once. + const int revision = session->brushRevision(); + session->updateFilter(FilterSettings{.radius = 2}, true); + session->updateFilter(FilterSettings{.radius = 3}, true); + QVERIFY(session->filterEdit().value().pending); + session->updateFilter(FilterSettings{.radius = 3}, false); + QVERIFY(!session->filterEdit().value().pending && !session->filterEdit().value().preparedPreview); + QVERIFY(!session->filterEdit().value().previewImage(session->activeLayerID().value())); + QVERIFY(session->brushRevision() > revision); + // The run under way lands unseen. + QVERIFY(settled(*session)); + QVERIFY(!session->filterEdit().value().preparedPreview); + session->cancelFilter(); +} + +void FilterSessionTests::theLastCommitIsWhereTheNextStarts() +{ + const std::unique_ptr<EditorSession> session = filled(8, 8); + session->beginFilter(FilterKind::gaussianBlur); + session->updateFilter(FilterSettings{.radius = 3, .angle = 30}, false); + QVERIFY(committed(*session)); + QVERIFY(session->filterSettings().radius == 3 && session->filterSettings().angle == 30); + session->beginFilter(FilterKind::motionBlur); + QVERIFY(settings(*session).radius == 3 && settings(*session).angle == 30); + // A cancel remembers nothing. + session->updateFilter(FilterSettings{.angle = 60}, false); + session->cancelFilter(); + QCOMPARE(session->filterSettings().angle, 30.0); +} + +void FilterSessionTests::nothingToChangeClosesWithoutAStep() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4); + const int steps = session->history.undoCount(); + for (const FilterKind kind : {FilterKind::lensCorrection, FilterKind::exposure, FilterKind::grain}) { + session->beginFilter(kind); + FilterSettings still = settings(*session); + still.grain.amount = 0; + session->updateFilter(still, false); + QVERIFY(committed(*session)); + QVERIFY2(!session->filterEdit() && session->history.undoCount() == steps, qPrintable(rawValue(kind))); + QVERIFY(!session->isProjectBusy()); + } + QVERIFY(session->filterSettings() == FilterSettings()); + // A change is a step, named for the filter. + session->beginFilter(FilterKind::lensCorrection); + session->updateFilter(FilterSettings{.distortion = 50}, false); + QVERIFY(committed(*session)); + QCOMPARE(session->history.undoCount(), steps + 1); + QCOMPARE(session->history.undoName(), QString("Lens Correction")); +} + +void FilterSessionTests::blurCommitsAreTrimmedAndOthersKeepTheirPlace() +{ + const std::unique_ptr<EditorSession> session = filled(10, 10); + session->beginFilter(FilterKind::motionBlur); + session->updateFilter(FilterSettings{.distance = 8}, false); + QVERIFY(committed(*session)); + // A level streak spreads sideways alone. + const ImageLayer blurred = session->activeLayer().value(); + QVERIFY(blurred.transform.origin.x() < 0 && blurred.transform.origin.y() == 0); + QCOMPARE(blurred.transform.size.height(), 10.0); + QCOMPARE(blurred.asset.value().size(), blurred.transform.size.toSize()); + QCOMPARE(blurred.asset.value().name, QString("Motion Blur")); + session->beginFilter(FilterKind::exposure); + FilterSettings brighter; + brighter.exposure.exposure = 1; + session->updateFilter(brighter, false); + QVERIFY(committed(*session)); + QCOMPARE(session->activeLayer().value().transform, blurred.transform); + QCOMPARE(session->activeLayer().value().asset.value().size(), blurred.asset.value().size()); +} + +void FilterSessionTests::aRunningPreviewDropsAtTheCommit() +{ + const std::unique_ptr<EditorSession> session = filled(3000, 2000); + session->beginFilter(FilterKind::exposure); + QVERIFY(settled(*session)); + FilterSettings one, two; + one.exposure.exposure = 1; + two.exposure.exposure = 2; + session->updateFilter(one, true); + session->updateFilter(two, true); + QVERIFY(session->filterEdit().value().pending); + const int revision = session->brushRevision(); + bool done = false; + session->commitFilter([&done] { done = true; }); + QVERIFY(!session->filterEdit().value().pending); + QTRY_VERIFY_WITH_TIMEOUT(done, 20000); + // One redraw, the commit's: the preview under way landed nowhere. + QCOMPARE(session->brushRevision(), revision + 1); + QVERIFY(session->filterSettings() == two); +} + +void FilterSessionTests::anAutomaticKindRendersOncePerSettings() +{ + const std::unique_ptr<EditorSession> session = filled(16, 16); + session->applySelection(rectPath(QRectF(6, 6, 4, 4)), SelectionMode::replace, "Select"); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(settled(*session)); + const auto made = [&session] { return session->filterEdit().value().preparedPreview.value().cacheKey(); }; + const qint64 first = made(); + // The same settings keep the fill, Preview off too. + const int revision = session->brushRevision(); + session->updateFilter(settings(*session), false); + QVERIFY(!session->filterEdit().value().preparing && made() == first && session->brushRevision() > revision); + // Other settings make it once more; the commit takes it. + const FilterSettings other{.radius = 5}; + session->updateFilter(other, true); + QVERIFY(session->filterEdit().value().preparing); + QVERIFY(settled(*session) && made() != first); + const qint64 second = made(); + session->updateFilter(other, true); + QVERIFY(!session->filterEdit().value().preparing && made() == second); + QVERIFY(committed(*session)); + QCOMPARE(session->activeLayer().value().asset.value().image().cacheKey(), second); + // A fill landing for older settings is made again. + session->undo(); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(settled(*session)); + session->updateFilter(FilterSettings{.radius = 6}, true); + session->updateFilter(FilterSettings{.radius = 7}, false); + QVERIFY(settled(*session)); + const qint64 stale = made(); + QVERIFY(committed(*session)); + QVERIFY(session->activeLayer().value().asset.value().image().cacheKey() != stale); +} + +void FilterSessionTests::aCoveringMaskStaysPutUnderABlur() +{ + // Paint the left quarter; the mask reveals the half. + auto session = std::make_unique<EditorSession>(); + session->createDocument(40, 20); + QImage image = BrushRaster::context(40, 20, false); + QPainter(&image).fillRect(0, 0, 10, 20, Qt::red); + session->insert(ImportedImage(image, image, QStringLiteral("Quarter"))); + const QUuid id = session->activeLayerID().value(); + session->applySelection(rectPath(QRectF(0, 0, 20, 20)), SelectionMode::replace, "Select"); + session->addMask(false); + session->selectLayerTarget(id, false); + session->beginFilter(FilterKind::gaussianBlur); + QVERIFY(committed(*session)); + // Trimmed round the quarter, the mask still reveals the half. + const ImageLayer layer = session->activeLayer().value(); + const QImage mask = layer.mask.value().asset.image(); + QVERIFY(layer.transform.size.width() < 20 && mask.size() == layer.asset.value().size()); + const QTransform toMask = BrushRaster::pixelToDocument(layer.transform, mask.width(), mask.height()).inverted(); + // Swift's grown box would hide x from 5 to 10. + for (const QPointF point : {QPointF(2.5, 10.5), QPointF(7.5, 10.5), QPointF(12.5, 10.5)}) { + const QPointF pixel = toMask.map(point); + QCOMPARE(int(mask.constScanLine(int(pixel.y()))[int(pixel.x())]), 255); + } +} + +void FilterSessionTests::theGradientMapPickerEditsTheOpenMap() +{ + // Swift's own test of it sits in ImageAdjustmentTests. + const std::unique_ptr<EditorSession> session = filled(4, 4); + // Only an open Gradient Map takes it. + session->openGradientMapColorPicker(true); + QVERIFY(!session->colorPicker()); + session->beginFilter(FilterKind::exposure); + session->openGradientMapColorPicker(true); + QVERIFY(!session->colorPicker()); + session->cancelFilter(); + session->beginFilter(FilterKind::gradientMap); + // A busy project, a save for one, keeps it shut. + session->setIsProjectBusy(true); + session->openGradientMapColorPicker(true); + QVERIFY(!session->colorPicker()); + session->setIsProjectBusy(false); + // Each end opens on its own colour, from the palette. + session->openGradientMapColorPicker(false); + QCOMPARE(session->colorPicker().value().target.title(), QString("Color Picker (Gradient Map Shadows)")); + QCOMPARE(session->colorPicker().value().original.hex(), QString("000000")); + session->closeColorPicker(false); + session->openGradientMapColorPicker(true); + const ColorPickerState picker = session->colorPicker().value(); + QCOMPARE(picker.target.title(), QString("Color Picker (Gradient Map Highlights)")); + QCOMPARE(picker.original.hex(), QString("FFFFFF")); + // One picker at a time. + session->openGradientMapColorPicker(false); + QVERIFY(session->colorPicker().value().target.highlights); + // The map follows the working colour; the same is silent. + PickerHSB hsb = picker.hsb; + hsb.setRGB(PaletteColor{1, 0, 0}); + session->setColorPickerHSB(hsb); + session->previewGradientMapColor(); + QCOMPARE(settings(*session).gradientMap.highlights, AdjustmentColor(1, 0, 0)); + QSignalSpy changed(session.get(), &EditorSession::changed); + session->previewGradientMapColor(); + QCOMPARE(changed.count(), 0); + session->closeColorPicker(false); + // A palette picker's colour stays out of the map. + session->openColorPicker(false); + hsb.setRGB(PaletteColor{0, 1, 0}); + session->setColorPickerHSB(hsb); + const GradientMapSettings before = settings(*session).gradientMap; + session->previewGradientMapColor(); + QVERIFY(settings(*session).gradientMap == before); + session->closeColorPicker(false); + // Committing, the map takes no picker. + session->commitFilter(); + session->openGradientMapColorPicker(true); + QVERIFY(!session->colorPicker()); + QTRY_VERIFY(!session->filterEdit()); + // Committing commits the picker's colour into the step. + session->beginFilter(FilterKind::gradientMap); + session->openGradientMapColorPicker(false); + session->setColorPickerHSB(hsb); + QVERIFY(committed(*session)); + QVERIFY(!session->colorPicker()); + QCOMPARE(session->filterSettings().gradientMap.shadows, AdjustmentColor(0, 1, 0)); +} + +void FilterSessionTests::onlyTheFillGrowsOverTheSelection() +{ + // A 10 by 10 layer centred in 20 by 20. + auto session = std::make_unique<EditorSession>(); + session->createDocument(20, 20); + QImage image = BrushRaster::context(10, 10, false); + image.fill(Qt::red); + session->insert(ImportedImage(image, image, QStringLiteral("Small"))); + session->selectAll(); + session->beginFilter(FilterKind::exposure); + QVERIFY(!session->filterEdit().value().grownImage); + session->cancelFilter(); + session->beginFilter(FilterKind::contentAwareFill); + QCOMPARE(session->filterEdit().value().grownImage.value().size(), QSize(20, 20)); + session->cancelFilter(); +} + +void FilterSessionTests::aGrowthPastTheLimitsIsAnError() +{ + // A painted strip: its grid grows from tiles, cheaply. + auto session = std::make_unique<EditorSession>(); + session->createDocument(29000, 1); + QImage base = BrushRaster::context(2, 1, false); + base.fill(Qt::red); + session->insert(ImportedImage(std::make_shared<const RasterSnapshot>(29000, 1, base, QRectF(0, 0, 2, 1), std::vector<BrushPatch>{}), QImage(), + QStringLiteral("Painted"))); + session->beginFilter(FilterKind::gaussianBlur); + QCOMPARE(session->filterEdit().value().grownMargin, 5.0); + // Radius 250 needs 30,504 pixels across: refused, the grid kept. + session->updateFilter(FilterSettings{.radius = 250}, false); + QCOMPARE(session->brushError().value(), QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what())); + QVERIFY(session->filterEdit() && session->filterEdit().value().grownMargin == 5); + QCOMPARE(settings(*session).radius, 250.0); + session->cancelFilter(); +} + +QTEST_GUILESS_MAIN(FilterSessionTests) +#include "FilterSessionTests.moc" diff --git a/tests/FilterSheetTests.cpp b/tests/FilterSheetTests.cpp new file mode 100644 index 0000000..d8642db --- /dev/null +++ b/tests/FilterSheetTests.cpp @@ -0,0 +1,173 @@ +#include "Document/BrushStroke.h" +#include "ContentView.h" +#include "SelectionFixtures.h" +#include "UI/FilterSheet.h" +#include <QCheckBox> +#include <QDialog> +#include <QProgressBar> +#include <QPushButton> +#include <QtTest> + +// The filter panel, driven as a user drives it. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +// A blue field, a red object, a selection, an editor. +struct Editor { + EditorSession session; + ContentView view{session}; + Editor() + { + session.createDocument(64, 48, true); + QImage image = BrushRaster::context(64, 48, false); + image.fill(QColor(51, 153, 204)); + QPainter painter(&image); + painter.fillRect(QRect(20, 16, 12, 10), Qt::red); + painter.end(); + session.insert(ImportedImage(image, image, "Object")); + session.applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); + view.show(); + } + // The panel's shown sheet; a closed panel's goes later. + FilterSheet *sheet() + { + for (FilterSheet *each : view.findChildren<FilterSheet *>()) { + if (each->isVisible()) + return each; + } + return nullptr; + } + QDialog &panel() { return *qobject_cast<QDialog *>(sheet()->window()); } + bool prepared() { return QTest::qWaitFor([&] { return session.filterEdit().has_value() && !session.filterEdit().value().preparing; }, 20000); } +}; +} + +class FilterSheetTests : public QObject { + Q_OBJECT +private slots: + void thePanelShowsWithTheEditAndSaysWhatItWaitsFor(); + void previewCancelAndOKReachTheSession(); + void escapeAndClosingCancelReturnCommits(); + void noSourceShowsTheErrorAndHoldsOK(); +}; + +void FilterSheetTests::thePanelShowsWithTheEditAndSaysWhatItWaitsFor() +{ + Editor editor; + QVERIFY(!editor.sheet()); + editor.session.beginFilter(FilterKind::contentAwareFill); + FilterSheet &sheet = *editor.sheet(); + QCOMPARE(editor.panel().objectName(), QString("filterPanel")); + QCOMPARE(editor.panel().windowTitle(), QString("Content-Aware Fill")); + QCOMPARE(sheet.width(), 380); + QCOMPARE(find<QLabel>(sheet, "filterWords").text(), QString("Fill the selection using surrounding pixels from this layer.")); + QVERIFY(find<QCheckBox>(sheet, "filterPreview").isChecked()); + QVERIFY(find<QLabel>(sheet, "filterLimited").isVisible()); + QVERIFY(!find<QLabel>(sheet, "filterError").isVisible()); + // Working: OK waits with the spinner. + QVERIFY(!find<QPushButton>(sheet, "filterOK").isEnabled()); + QVERIFY(find<QProgressBar>(sheet, "filterSpinner").isVisible()); + QCOMPARE(find<QProgressBar>(sheet, "filterSpinner").maximum(), 0); + QCOMPARE(find<QLabel>(sheet, "filterActivity").text(), QString("Working…")); + QVERIFY(editor.prepared()); + QVERIFY(find<QPushButton>(sheet, "filterOK").isEnabled()); + QVERIFY(!find<QProgressBar>(sheet, "filterSpinner").isVisible()); + QVERIFY(!find<QLabel>(sheet, "filterActivity").isVisible()); + // Applying: everything rests until the result lands. + bool done = false; + editor.session.commitFilter([&] { done = true; }); + QVERIFY(!sheet.isEnabled()); + QCOMPARE(find<QLabel>(sheet, "filterActivity").text(), QString("Applying…")); + QVERIFY(find<QProgressBar>(sheet, "filterSpinner").isVisible()); + QTRY_VERIFY(done); + QVERIFY(!editor.sheet() && !editor.session.filterEdit().has_value()); + QCOMPARE(editor.session.history.undoName(), QString("Content-Aware Fill")); +} + +void FilterSheetTests::previewCancelAndOKReachTheSession() +{ + Editor editor; + editor.session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(editor.prepared()); + const QUuid id = editor.session.activeLayerID().value(); + QVERIFY(editor.session.filterEdit().value().previewImage(id).has_value()); + QCheckBox &preview = find<QCheckBox>(*editor.sheet(), "filterPreview"); + preview.click(); + QVERIFY(!editor.session.filterEdit().value().preview); + QVERIFY(!editor.session.filterEdit().value().previewImage(id).has_value()); + preview.click(); + QVERIFY(editor.session.filterEdit().value().previewImage(id).has_value()); + find<QPushButton>(*editor.sheet(), "filterCancel").click(); + QVERIFY(!editor.session.filterEdit().has_value() && !editor.sheet()); + editor.session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(editor.prepared()); + const int count = editor.session.history.undoCount(); + find<QPushButton>(*editor.sheet(), "filterOK").click(); + QTRY_VERIFY(!editor.session.filterEdit().has_value()); + QCOMPARE(editor.session.history.undoCount(), count + 1); +} + +void FilterSheetTests::escapeAndClosingCancelReturnCommits() +{ + Editor editor; + editor.session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(editor.prepared()); + QTest::keyClick(editor.sheet(), Qt::Key_Escape); + QVERIFY(!editor.session.filterEdit().has_value() && !editor.sheet()); + editor.session.beginFilter(FilterKind::contentAwareFill); + editor.panel().close(); + QVERIFY(!editor.session.filterEdit().has_value() && !editor.sheet()); + // Return is OK while it is enabled, nothing while waiting. + editor.session.beginFilter(FilterKind::contentAwareFill); + QTest::keyClick(editor.sheet(), Qt::Key_Return); + QVERIFY(editor.session.filterEdit().has_value() && !editor.session.filterEdit().value().committing); + QVERIFY(editor.prepared()); + QTest::keyClick(editor.sheet(), Qt::Key_Return); + QVERIFY(editor.session.filterEdit().value().committing); + QTRY_VERIFY(!editor.session.filterEdit().has_value()); + QCOMPARE(editor.session.history.undoName(), QString("Content-Aware Fill")); + // Return on Cancel is still OK's: nothing while OK waits. + const int count = editor.session.history.undoCount(); + editor.session.beginFilter(FilterKind::contentAwareFill); + QPushButton &cancel = find<QPushButton>(*editor.sheet(), "filterCancel"); + cancel.setFocus(); + QTest::keyClick(&cancel, Qt::Key_Return); + QVERIFY(editor.session.filterEdit().has_value() && !editor.session.filterEdit().value().committing); + QVERIFY(editor.prepared()); + QTest::keyClick(&cancel, Qt::Key_Return); + QVERIFY(editor.session.filterEdit().value().committing); + QTRY_VERIFY(!editor.session.filterEdit().has_value()); + QCOMPARE(editor.session.history.undoCount(), count + 1); +} + +void FilterSheetTests::noSourceShowsTheErrorAndHoldsOK() +{ + Editor editor; + editor.session.selectAll(); + editor.session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(editor.prepared()); + QLabel &error = find<QLabel>(*editor.sheet(), "filterError"); + QVERIFY(error.isVisible()); + QCOMPARE(error.text(), QString("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image.")); + QCOMPARE(error.foregroundRole(), QPalette::BrightText); + QVERIFY(!find<QPushButton>(*editor.sheet(), "filterOK").isEnabled()); + QTest::keyClick(editor.sheet(), Qt::Key_Return); + QVERIFY(editor.session.filterEdit().has_value() && !editor.session.filterEdit().value().committing); + // Without a selection, others open without the note. + find<QPushButton>(*editor.sheet(), "filterCancel").click(); + editor.session.deselect(); + QVERIFY(!editor.session.canContentAwareFill()); + editor.session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(!find<QLabel>(*editor.sheet(), "filterLimited").isVisible()); + // Only an automatic kind holds OK while it prepares. + QVERIFY(editor.session.filterEdit().value().preparing && find<QPushButton>(*editor.sheet(), "filterOK").isEnabled()); +} + +QTEST_MAIN(FilterSheetTests) +#include "FilterSheetTests.moc" diff --git a/tests/FilterTests.cpp b/tests/FilterTests.cpp new file mode 100644 index 0000000..9523e8d --- /dev/null +++ b/tests/FilterTests.cpp @@ -0,0 +1,142 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include <QPainter> +#include <QtTest> +#include <set> + +// Swift's FilterTests: the blurs, noise and lens correction. +namespace { +// Premultiplied RGBA bytes, row after row. +std::vector<uchar> bytes(const QImage &image) +{ + const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector<uchar> result; + for (int y = 0; y < drawn.height(); ++y) + result.insert(result.end(), drawn.constScanLine(y), drawn.constScanLine(y) + drawn.width() * 4); + return result; +} + +QImage run(FilterKind kind, const QImage &image, const FilterSettings &settings, quint32 seed = 0) +{ + return PixelFilter::run(FilterJob{kind, image, settings, 1, std::nullopt, QTransform(), seed}); +} + +// An opaque white dot amid a clear 41 by 41. +QImage dot() +{ + QImage image = BrushRaster::context(41, 41, false); + QPainter(&image).fillRect(20, 20, 1, 1, Qt::white); + return image; +} +} + +class FilterTests : public QObject { + Q_OBJECT +private slots: + void gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep(); + void motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal(); + void addNoiseChangesColorButNeverAlphaAndMonochromaticKeepsGrays(); + void removeDistortionBendsAboutTheCenterAndOnlyPincushionCorrectionOpensTheCorners(); +}; + +void FilterTests::gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep() +{ + EditorSession session; + session.createDocument(40, 20); + // Left half opaque white, right half clear. + QImage image = BrushRaster::context(40, 20, false); + QPainter(&image).fillRect(0, 0, 20, 20, Qt::white); + session.insert(ImportedImage(image, image, QStringLiteral("Half"))); + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(session.filterEdit() && !session.canEditLayers()); + session.updateFilter(FilterSettings{.radius = 3}, true); + const int count = session.history.undoCount(); + bool done = false; + session.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session.filterEdit() && session.history.undoCount() == count + 1); + QCOMPARE(session.filterSettings().radius, 3.0); + // Unclamped: the layer grew by the blur's reach, then trimmed. + const ImageLayer layer = session.activeLayer().value(); + const QImage result = layer.asset.value().image(); + QCOMPARE(layer.transform.origin, QPointF(-8, -8)); + QCOMPARE(result.size(), QSize(36, 36)); + const auto alpha = [&result](int x) { return int(bytes(result)[size_t((18 * result.width() + x + 8) * 4 + 3)]); }; + // Its own border softens, as the hard edge does. + QCOMPARE(alpha(-8), 1); + QCOMPARE(alpha(0), 144); + QCOMPARE(alpha(19), 144); + QCOMPARE(alpha(20), 111); + QCOMPARE(alpha(27), 1); +} + +void FilterTests::motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal() +{ + const auto streak = [](double angle) { + const std::vector<uchar> made = bytes(run(FilterKind::motionBlur, dot(), FilterSettings{.angle = angle, .distance = 16})); + return [made](int x, int y) { return int(made[size_t((y * 41 + x) * 4 + 3)]); }; + }; + const auto horizontal = streak(0); + QVERIFY(horizontal(24, 20) > 0 && horizontal(16, 20) > 0 && horizontal(20, 24) == 0); + const auto vertical = streak(90); + QVERIFY(vertical(20, 24) > 0 && vertical(20, 16) > 0 && vertical(24, 20) == 0); + // 45° runs up-right and down-left on screen, never up-left. + const auto diagonal = streak(45); + QVERIFY(diagonal(23, 17) > 0 && diagonal(17, 23) > 0 && diagonal(17, 17) == 0); +} + +void FilterTests::addNoiseChangesColorButNeverAlphaAndMonochromaticKeepsGrays() +{ + // Left half opaque mid gray, right half clear. + QImage gray = BrushRaster::context(32, 8, false); + QPainter(&gray).fillRect(0, 0, 16, 8, QColor::fromRgbF(0.5, 0.5, 0.5)); + const auto pixels = [&gray](const FilterSettings &settings) { return bytes(run(FilterKind::addNoise, gray, settings, 7)); }; + std::vector<size_t> opaque, clear; + for (size_t offset = 0; offset < 32 * 8 * 4; offset += 4) + (offset / 4 % 32 < 16 ? opaque : clear).push_back(offset); + const std::vector<uchar> color = pixels(FilterSettings{.amount = 10}); + // The same seed gives the same grain. + QVERIFY(pixels(FilterSettings{.amount = 10}) == color); + std::set<int> values; + bool channelsDiffer = false; + for (const size_t offset : opaque) { + QCOMPARE(int(color[offset + 3]), 255); + QVERIFY(color[offset] >= 112 && color[offset] <= 144); + values.insert(color[offset]); + channelsDiffer |= color[offset] != color[offset + 1]; + } + QVERIFY(values.size() > 5 && channelsDiffer); + for (const size_t offset : clear) + QVERIFY(color[offset] == 0 && color[offset + 3] == 0); + const std::vector<uchar> mono = pixels(FilterSettings{.amount = 10, .gaussian = true, .monochromatic = true}); + for (const size_t offset : opaque) + QVERIFY(mono[offset] == mono[offset + 1] && mono[offset + 1] == mono[offset + 2] && mono[offset + 3] == 255); +} + +void FilterTests::removeDistortionBendsAboutTheCenterAndOnlyPincushionCorrectionOpensTheCorners() +{ + // An opaque image with a distinct colour in each quadrant. + QImage source = BrushRaster::context(40, 30, false); + { + QPainter painter(&source); + const QRect quarters[] = {QRect(0, 0, 20, 15), QRect(20, 0, 20, 15), QRect(0, 15, 20, 15), QRect(20, 15, 20, 15)}; + for (int index = 0; index < 4; ++index) + painter.fillRect(quarters[index], QColor::fromRgbF(index / 3.0, 0.5, 1 - index / 3.0)); + } + const auto pixels = [&source](double distortion) { return bytes(run(FilterKind::lensCorrection, source, FilterSettings{.distortion = distortion})); }; + const auto alpha = [](const std::vector<uchar> &made, int x, int y) { return int(made[size_t((y * 40 + x) * 4 + 3)]); }; + const std::vector<uchar> original = pixels(0); + for (size_t index = 0; index < 40 * 30; ++index) + QCOMPARE(int(original[index * 4 + 3]), 255); + // Straightening barrel stretches the edges outward: nothing opens. + const std::vector<uchar> barrel = pixels(100); + QVERIFY(alpha(barrel, 0, 0) == 255 && alpha(barrel, 39, 29) == 255); + // Straightening pincushion pulls the edges in: the corners clear. + const std::vector<uchar> pincushion = pixels(-100); + QVERIFY(alpha(pincushion, 0, 0) == 0 && alpha(pincushion, 39, 29) == 0); + const size_t middle = (15 * 40 + 20) * 4; + QVERIFY(std::equal(pincushion.begin() + qsizetype(middle), pincushion.begin() + qsizetype(middle + 4), original.begin() + qsizetype(middle))); +} + +QTEST_GUILESS_MAIN(FilterTests) +#include "FilterTests.moc" diff --git a/tests/FiltersTests.cpp b/tests/FiltersTests.cpp new file mode 100644 index 0000000..f8cd5a3 --- /dev/null +++ b/tests/FiltersTests.cpp @@ -0,0 +1,91 @@ +#include "Document/Filters.h" +#include <QtTest> + +// PixelFilter::trimmed: the crop and the placement that keeps it. +namespace { +QImage clearImage(int width, int height, QImage::Format format = QImage::Format_RGBA8888_Premultiplied) +{ + QImage image(width, height, format); + image.fill(Qt::transparent); + return image; +} + +void paint(QImage &image, const QRect &area) +{ + for (int y = area.top(); y <= area.bottom(); ++y) { + for (int x = area.left(); x <= area.right(); ++x) + image.setPixelColor(x, y, Qt::red); + } +} +} + +class FiltersTests : public QObject { + Q_OBJECT +private slots: + void trimmedCropsToThePixelsThatAreThere(); + void trimmedKeepsTheCropInPlaceUnderTurnAndFlip(); + void trimmedLeavesAFullOrEmptyImageAlone(); + void trimmedReadsAnyFormat(); +}; + +void FiltersTests::trimmedCropsToThePixelsThatAreThere() +{ + QImage image = clearImage(8, 4); + paint(image, QRect(2, 1, 4, 2)); + // Twice its size, nearest: every pixel is two units. + const LayerTransform placed{.origin = {10, 20}, .size = {16, 8}, .sampling = LayerSampling::nearest}; + const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, placed); + QCOMPARE(trimmed.image.size(), QSize(4, 2)); + QCOMPARE(trimmed.image.pixelColor(0, 0), QColor(Qt::red)); + QCOMPARE(trimmed.image.pixelColor(3, 1), QColor(Qt::red)); + QCOMPARE(trimmed.transform.size, QSizeF(8, 4)); + QCOMPARE(trimmed.transform.origin, QPointF(14, 22)); + QCOMPARE(trimmed.transform.sampling, placed.sampling); +} + +void FiltersTests::trimmedKeepsTheCropInPlaceUnderTurnAndFlip() +{ + QImage image = clearImage(8, 4); + paint(image, QRect(0, 0, 2, 1)); + // A quarter turn: the crop's middle swings about the centre. + LayerTransform turned{.origin = {0, 0}, .size = {16, 8}, .rotation = 90}; + PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, turned); + QCOMPARE(trimmed.transform.size, QSizeF(4, 2)); + QCOMPARE(trimmed.transform.origin, QPointF(9, -3)); + QCOMPARE(trimmed.transform.rotation, 90.0); + LayerTransform flipped{.origin = {0, 0}, .size = {16, 8}, .flipX = true}; + trimmed = PixelFilter::trimmed(image, flipped); + QCOMPARE(trimmed.transform.origin, QPointF(12, 0)); + QVERIFY(trimmed.transform.flipX); +} + +void FiltersTests::trimmedLeavesAFullOrEmptyImageAlone() +{ + const LayerTransform placed{.origin = {3, 4}, .size = {8, 4}}; + QImage full = clearImage(8, 4); + paint(full, full.rect()); + PixelFilter::Trimmed trimmed = PixelFilter::trimmed(full, placed); + QCOMPARE(trimmed.image.cacheKey(), full.cacheKey()); + QCOMPARE(trimmed.transform, placed); + // Nothing there: nothing to cut, the image stays. + const QImage empty = clearImage(8, 4); + trimmed = PixelFilter::trimmed(empty, placed); + QCOMPARE(trimmed.image.cacheKey(), empty.cacheKey()); + QCOMPARE(trimmed.transform, placed); +} + +void FiltersTests::trimmedReadsAnyFormat() +{ + // Sixteen bits a channel: the alpha byte lies elsewhere. + QImage image = clearImage(8, 4, QImage::Format_RGBA64); + image.setPixelColor(5, 3, Qt::green); + const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, {.origin = {0, 0}, .size = {8, 4}}); + QCOMPARE(trimmed.image.size(), QSize(1, 1)); + QCOMPARE(trimmed.image.format(), QImage::Format_RGBA64); + QCOMPARE(trimmed.image.pixelColor(0, 0), QColor(Qt::green)); + QCOMPARE(trimmed.transform.origin, QPointF(5, 3)); + QCOMPARE(trimmed.transform.size, QSizeF(1, 1)); +} + +QTEST_GUILESS_MAIN(FiltersTests) +#include "FiltersTests.moc" diff --git a/tests/FloatingPanelTests.cpp b/tests/FloatingPanelTests.cpp new file mode 100644 index 0000000..9fc0fca --- /dev/null +++ b/tests/FloatingPanelTests.cpp @@ -0,0 +1,285 @@ +#include "CanvasFixtures.h" +#include "UI/ColorPickerSheet.h" +#include "UI/FilterSheet.h" +#include "UI/FloatingPanel.h" +#include "UI/HueSaturationSheet.h" +#include "UI/LevelsSheet.h" +#include <QApplication> +#include <QLabel> +#include <QWindow> +#include <QtTest> + +// Swift's FloatingPanelTests. +namespace { +QWidget *visiblePanel(const QString &name) +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == name && widget->isVisible()) + return widget; + } + return nullptr; +} +} + +class FloatingPanelTests : public QObject { + Q_OBJECT +private slots: + void colorPickerPanelSurvivesALayoutPass(); + void levelsPanelSurvivesALayoutPass(); + void adjustmentPanelSurvivesALayoutPass(); + void adjustmentEditorsUseMovableNonmodalPanels_data(); + void adjustmentEditorsUseMovableNonmodalPanels(); + void aPanelOpensOverTheCanvasThenWhereItWasLeft(); + void itsCloseButtonAndEscapeCancel(); + void aPanelRemembersWhereItIsMovedAndCentresWithoutACanvas(); +}; + +void FloatingPanelTests::colorPickerPanelSurvivesALayoutPass() +{ + Shown shown(QSize(40, 20)); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + shown.session.openColorPicker(false); + ColorPickerPanelController controller(shown.window); + controller.show(shown.session.colorPicker().value(), shown.session); + QTest::qWait(400); + QWidget *panel = visiblePanel(ColorPickerPanelController::identifier()); + QVERIFY(panel); + QVERIFY(panel->findChild<ColorPickerSheet *>()); + QVERIFY(!panel->isModal()); + QVERIFY(panel->windowFlags().testFlag(Qt::Tool)); + controller.close(); + shown.session.closeColorPicker(false); + QVERIFY(!panel->isVisible()); +} + +void FloatingPanelTests::levelsPanelSurvivesALayoutPass() +{ + Shown shown(QSize(40, 20)); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + shown.session.beginLevels(); + FloatingPanel panel(QStringLiteral("testLevelsPanel"), shown.window); + panel.show(QStringLiteral("Levels"), new LevelsSheet(shown.session)); + QTest::qWait(400); + QVERIFY(panel.isVisible()); + // A preview landing while hosted survives a pass too. + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 2, 255, 0, 255}); + shown.session.updateLevels(settings, true); + QTRY_VERIFY(shown.session.levels().value().preparedPreview); + QTest::qWait(400); + panel.close(); + shown.session.cancelLevels(); + QVERIFY(!panel.isVisible() && !shown.session.levels()); +} + +void FloatingPanelTests::adjustmentPanelSurvivesALayoutPass() +{ + Shown shown(QSize(40, 20)); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + shown.session.beginHueSaturation(); + FloatingPanel panel(QStringLiteral("testAdjustmentPanel"), shown.window); + panel.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(shown.session)); + QTest::qWait(400); + QVERIFY(panel.isVisible()); + const int height = visiblePanel(QStringLiteral("testAdjustmentPanel"))->height(); + // A range's spectrum grows it; a landed preview passes too. + shown.session.updateHueSaturation(HueSaturationSettings(120, 0, 0, false, ColorRange::reds), true); + QTRY_VERIFY(shown.session.hueSaturation().value().previewImage(shown.session.activeLayerID().value())); + QTest::qWait(400); + QVERIFY(visiblePanel(QStringLiteral("testAdjustmentPanel"))->height() > height); + panel.close(); + shown.session.cancelHueSaturation(); + QVERIFY(!panel.isVisible() && !shown.session.hueSaturation()); +} + +void FloatingPanelTests::adjustmentEditorsUseMovableNonmodalPanels_data() +{ + QTest::addColumn<AdjustmentKind>("kind"); + for (const AdjustmentKind kind : allAdjustmentKinds) + QTest::newRow(qPrintable(rawValue(kind))) << kind; +} + +void FloatingPanelTests::adjustmentEditorsUseMovableNonmodalPanels() +{ + QFETCH(AdjustmentKind, kind); + Shown shown(QSize(40, 20)); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + shown.session.addAdjustment(kind); + bool opened = false; + shown.session.beginAdjustmentEditing(shown.session.adjustmentEditingID().value(), [&opened] { opened = true; }); + QTRY_VERIFY(opened); + FloatingPanel controller(QStringLiteral("testDynamicAdjustmentPanel"), shown.window); + controller.onClose = [&shown] { shown.session.finishAdjustmentEditing(false); }; + if (kind == AdjustmentKind::levels) + controller.show(QStringLiteral("Levels"), new LevelsSheet(shown.session)); + else if (kind == AdjustmentKind::hsv) + controller.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(shown.session)); + else + controller.show(rawValue(kind), new FilterSheet(shown.session)); + QTest::qWait(400); + QWidget *panel = visiblePanel(QStringLiteral("testDynamicAdjustmentPanel")); + QVERIFY(panel); + // A tool window beside the canvas: movable, never modal. + QVERIFY(panel->windowFlags().testFlag(Qt::Tool) && !panel->isModal() && !shown.session.showsBusy()); + const QPoint origin = panel->pos(); + panel->move(origin + QPoint(20, 20)); + QCOMPARE(panel->pos(), origin + QPoint(20, 20)); + panel->close(); + QVERIFY(!shown.session.adjustmentEditingID()); + QVERIFY(!shown.session.levels() && !shown.session.hueSaturation() && !shown.session.filterEdit()); +} + +void FloatingPanelTests::aPanelOpensOverTheCanvasThenWhereItWasLeft() +{ + Shown shown; + // Wider than the canvas: the canvas's middle, not the window's. + shown.window.resize(600, 400); + FloatingPanel panel(QStringLiteral("testPlacedPanel"), shown.window); + QVERIFY(!panel.isVisible()); + panel.close(); + // First over the canvas's middle. + panel.show(QStringLiteral("First"), new QLabel(QStringLiteral("One"))); + QWidget *window = visiblePanel(QStringLiteral("testPlacedPanel")); + QVERIFY(window); + const QPoint middle = shown.canvas->mapToGlobal(shown.canvas->rect().center()); + QVERIFY(std::abs(window->pos().x() + window->width() / 2 - middle.x()) <= 1); + QVERIFY(std::abs(window->pos().y() + window->height() / 2 - middle.y()) <= 1); + // It takes the keys, fixed to its content's size. + QTRY_COMPARE(QApplication::activeWindow(), window); + QCOMPARE(window->size(), window->findChild<QLabel *>()->sizeHint()); + QCOMPARE(window->minimumSize(), window->maximumSize()); + // Moved, it keeps its place through content and a close. + const QPoint spot(30, 20); + window->move(spot); + // Shown again, it takes the keys back. + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + auto *old = new QLabel(QStringLiteral("Old")); + panel.show(QStringLiteral("Second"), old); + QTRY_COMPARE(QApplication::activeWindow(), window); + QCOMPARE(window->pos(), spot); + QCOMPARE(window->windowTitle(), QString("Second")); + QTRY_VERIFY(old->isVisible()); + QPointer<QLabel> gone(old); + auto *wider = new QLabel(QStringLiteral("Wider content than before")); + panel.show(QStringLiteral("Third"), wider); + QVERIFY(gone && !gone->isVisible()); + QCOMPARE(window->size(), wider->sizeHint()); + QTRY_VERIFY(!gone); + panel.close(); + QVERIFY(!panel.isVisible()); + panel.show(QStringLiteral("Fourth"), new QLabel(QStringLiteral("Four"))); + QCOMPARE(window->pos(), spot); + // Another panel of that name, later, opens there too. + panel.close(); + FloatingPanel again(QStringLiteral("testPlacedPanel"), shown.window); + again.show(QStringLiteral("Again"), new QLabel(QStringLiteral("Five"))); + QCOMPARE(visiblePanel(QStringLiteral("testPlacedPanel"))->pos(), spot); +} + +void FloatingPanelTests::itsCloseButtonAndEscapeCancel() +{ + Shown shown; + // Without a listener, its close button just closes. + FloatingPanel quiet(QStringLiteral("testQuietPanel"), shown.window); + quiet.show(QStringLiteral("Quiet"), new QLabel(QStringLiteral("Content"))); + visiblePanel(QStringLiteral("testQuietPanel"))->close(); + QVERIFY(!quiet.isVisible()); + // A hidden panel is never handed the keys. + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + FloatingPanel::refocus(QStringLiteral("testQuietPanel")); + QTest::qWait(50); + QCOMPARE(QApplication::activeWindow(), &shown.window); + FloatingPanel panel(QStringLiteral("testClosedPanel"), shown.window); + int closes = 0; + panel.onClose = [&closes] { ++closes; }; + panel.show(QStringLiteral("Panel"), new QLabel(QStringLiteral("Content"))); + QWidget *window = visiblePanel(QStringLiteral("testClosedPanel")); + QVERIFY(window); + window->close(); + QCOMPARE(closes, 1); + QVERIFY(!panel.isVisible()); + panel.show(QStringLiteral("Panel"), new QLabel(QStringLiteral("Content"))); + QTest::keyClick(window, Qt::Key_Escape); + QCOMPARE(closes, 2); + QVERIFY(!panel.isVisible()); + // Hidden by its owner, it reports nothing. + panel.show(QStringLiteral("Panel"), new QLabel(QStringLiteral("Content"))); + panel.close(); + QCOMPARE(closes, 2); + // A click on the canvas hands it the keys back. + panel.show(QStringLiteral("Panel"), new QLabel(QStringLiteral("Content"))); + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + FloatingPanel::refocus(QStringLiteral("testClosedPanel")); + QTRY_COMPARE(QApplication::activeWindow(), window); + // Only the panel of that name. + quiet.show(QStringLiteral("Quiet"), new QLabel(QStringLiteral("Content"))); + QWidget *other = visiblePanel(QStringLiteral("testQuietPanel")); + QTRY_COMPARE(QApplication::activeWindow(), other); + FloatingPanel::refocus(QStringLiteral("testClosedPanel")); + QTRY_COMPARE(QApplication::activeWindow(), window); + // A miss activates no widget; a bare window keeps focus. + QWindow bare; + bare.show(); + bare.requestActivate(); + QTRY_COMPARE(QGuiApplication::focusWindow(), &bare); + FloatingPanel::refocus(QStringLiteral("testMissingPanel")); + QTest::qWait(50); + QCOMPARE(QGuiApplication::focusWindow(), &bare); +} + +void FloatingPanelTests::aPanelRemembersWhereItIsMovedAndCentresWithoutACanvas() +{ + Shown shown; + QPointer<QWidget> dropped; + { + FloatingPanel moved(QStringLiteral("testMovedPanel"), shown.window); + moved.show(QStringLiteral("Moved"), new QLabel(QStringLiteral("Content"))); + dropped = visiblePanel(QStringLiteral("testMovedPanel")); + dropped->move(QPoint(25, 35)); + QTRY_COMPARE(dropped->pos(), QPoint(25, 35)); + } + // Its window goes with it. + QVERIFY(!dropped); + // Gone without a close, it reopens where it was moved. + FloatingPanel again(QStringLiteral("testMovedPanel"), shown.window); + again.show(QStringLiteral("Again"), new QLabel(QStringLiteral("Content"))); + QCOMPARE(visiblePanel(QStringLiteral("testMovedPanel"))->pos(), QPoint(25, 35)); + // Shown, never moved, it keeps that place for the next. + QPoint first; + { + FloatingPanel still(QStringLiteral("testStillPanel"), shown.window); + still.show(QStringLiteral("Still"), new QLabel(QStringLiteral("Content"))); + first = visiblePanel(QStringLiteral("testStillPanel"))->pos(); + } + const QPoint away = shown.window.pos() + QPoint(40, 30); + shown.window.move(away); + QTRY_COMPARE(shown.window.pos(), away); + FloatingPanel next(QStringLiteral("testStillPanel"), shown.window); + next.show(QStringLiteral("Next"), new QLabel(QStringLiteral("Content"))); + QCOMPARE(visiblePanel(QStringLiteral("testStillPanel"))->pos(), first); + // A window without a canvas centres it on itself. + QWidget plain; + plain.setGeometry(100, 80, 500, 300); + plain.show(); + QVERIFY(QTest::qWaitForWindowExposed(&plain)); + FloatingPanel centred(QStringLiteral("testCentredPanel"), plain); + centred.show(QStringLiteral("Centred"), new QLabel(QStringLiteral("Content"))); + QWidget *window = visiblePanel(QStringLiteral("testCentredPanel")); + const QPoint middle = plain.geometry().center(); + QVERIFY(std::abs(window->pos().x() + window->width() / 2 - middle.x()) <= 1); + QVERIFY(std::abs(window->pos().y() + window->height() / 2 - middle.y()) <= 1); +} + +QTEST_MAIN(FloatingPanelTests) +#include "FloatingPanelTests.moc" diff --git a/tests/FloatingSelectionTests.cpp b/tests/FloatingSelectionTests.cpp new file mode 100644 index 0000000..6319487 --- /dev/null +++ b/tests/FloatingSelectionTests.cpp @@ -0,0 +1,428 @@ +#include "Document/BrushStroke.h" +#include "Document/Distort.h" +#include "IO/ImageExporter.h" +#include "SelectionFixtures.h" +#include <QRegularExpression> + +// Ctrl+T on selected pixels: they float, transform and merge back. +namespace { +// A 100×40 layer: red on the left, blue right. +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(100, 40, true); + QImage image = BrushRaster::context(100, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 50, 40), Qt::red); + painter.fillRect(QRect(50, 0, 50, 40), Qt::blue); + painter.end(); + session->insert(ImportedImage(image, image, "Halves")); + session->deleteLayer(session->document().value().layers[0].id); + return session; +} + +void select(EditorSession &session, const QRectF &rect) +{ + session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); +} + +std::vector<int> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +QImage render(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} + +// Waits for the selected pixels to float. +bool floated(EditorSession &session) +{ + bool done = false; + session.beginSelectionTransform([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 10000) && !session.isProjectBusy(); +} +} + +class FloatingSelectionTests : public QObject { + Q_OBJECT +private slots: + void transformSelectionMovesPixelsAndOutlineAsOneUndo(); + void escapeRestoresExactlyWithoutAnUndoStep(); + void applyingAnUnchangedTransformLeavesSoftEdgesUntouched(); + void scalingAndMovingPastTheLayerEdgeGrowsTheLayer(); + void aDistortedSelectionWarpsItsPixelsAndItsOutline(); + void aMergeThatCannotGrowRestoresTheDocument(); + void transformSelectionIsRefusedWhereSwiftRefusesIt(); + void theFloatingLayerTakesItsSourcesPlaceAndLook(); + void aSourceGoneMidLiftPutsTheDocumentBack(); + void aMaskGrowsWithTheMergedLayer(); + void aTransformedSourceTakesTheMergeInItsOwnGrid(); + void aFoldedDistortionDropsTheOutline(); +}; + +void FloatingSelectionTests::transformSelectionMovesPixelsAndOutlineAsOneUndo() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + select(*session, QRectF(10, 10, 10, 10)); + const CanvasDocument before = session->document().value(); + const int count = session->history.undoCount(); + QVERIFY(session->canTransformSelection()); + QVERIFY(floated(*session)); + const TransformEdit edit = session->transformEdit().value(); + QVERIFY(edit.floating && session->tool() == NavigationTool::move); + QCOMPARE(session->activeLayer().value().name, QString("Floating Selection")); + QCOMPARE(session->transformPixelSize(), std::optional(QSizeF(10, 10))); + LayerTransform draft = edit.draft; + draft.origin = QPointF(70, 20); + session->previewTransform(draft); + QCOMPARE(session->displayedSelection().value().path.boundingRect(), QRectF(70, 20, 10, 10)); + session->commitTransform(); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Transform Selection")); + QVERIFY(session->document().value().layers.size() == 1 && session->activeLayerID() == source); + QCOMPARE(session->selection().value().path.boundingRect(), QRectF(70, 20, 10, 10)); + const QImage result = render(*session); + QCOMPARE(pixel(result, 15, 15)[3], 0); + QCOMPARE(pixel(result, 75, 25), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 85, 25), (std::vector<int>{0, 0, 255, 255})); + session->undo(); + QVERIFY(session->document().value() == before); +} + +void FloatingSelectionTests::escapeRestoresExactlyWithoutAnUndoStep() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + const CanvasDocument before = session->document().value(); + const std::optional<QUuid> active = session->activeLayerID(); + const int count = session->history.undoCount(); + QVERIFY(floated(*session)); + LayerTransform draft = session->transformEdit().value().draft; + draft.origin.rx() += 30; + session->previewTransform(draft); + session->cancelTransform(); + QVERIFY(session->document().value() == before && session->activeLayerID() == active); + QVERIFY(!session->transformEdit().has_value() && session->history.undoCount() == count); +} + +void FloatingSelectionTests::applyingAnUnchangedTransformLeavesSoftEdgesUntouched() +{ + const auto session = makeSession(); + session->setSelectionAntialiased(true); + QPainterPath ellipse; + ellipse.addEllipse(QRectF(10.3, 5.7, 30, 25)); + session->applySelection(ellipse, SelectionMode::replace, "Select"); + const CanvasDocument before = session->document().value(); + const int count = session->history.undoCount(); + QVERIFY(floated(*session)); + session->commitTransform(); + QVERIFY(session->document().value() == before && session->history.undoCount() == count); +} + +void FloatingSelectionTests::scalingAndMovingPastTheLayerEdgeGrowsTheLayer() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + select(*session, QRectF(0, 0, 10, 10)); + QVERIFY(floated(*session)); + LayerTransform draft = session->transformEdit().value().draft; + // Twice as large, and hanging off the canvas corner. + draft.size = QSizeF(20, 20); + draft.origin = QPointF(90, 30); + session->previewTransform(draft); + // Switching tools applies it. + session->selectTool(NavigationTool::lasso); + QVERIFY(!session->transformEdit().has_value() && session->history.undoName() == "Transform Selection"); + QCOMPARE(layerWith(*session, source).size(), QSizeF(110, 50)); + const QImage result = render(*session); + QCOMPARE(pixel(result, 95, 35), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 5, 5)[3], 0); + QCOMPARE(session->selection().value().path.boundingRect(), QRectF(90, 30, 20, 20)); +} + +void FloatingSelectionTests::aDistortedSelectionWarpsItsPixelsAndItsOutline() +{ + const auto session = makeSession(); + session->setSelectionAntialiased(false); + select(*session, QRectF(10, 10, 20, 20)); + QVERIFY(floated(*session)); + session->beginDistort(); + // One corner pulled out, one pushed in: still convex. + Corners corners = session->transformEdit().value().corners.value(); + corners[1] = QPointF(40, 5); + corners[2] = QPointF(22, 22); + session->previewCorners(corners); + session->commitTransform(); + QCOMPARE(session->history.undoName(), QString("Transform Selection")); + const QRectF bounds = session->selection().value().path.boundingRect(); + QVERIFY2(std::abs(bounds.left() - 10) < 0.01 && std::abs(bounds.top() - 5) < 0.01 && std::abs(bounds.right() - 40) < 0.01 + && std::abs(bounds.bottom() - 30) < 0.01, + qPrintable(QStringLiteral("%1 %2 %3 %4").arg(bounds.left()).arg(bounds.top()).arg(bounds.right()).arg(bounds.bottom()))); + // In the old square, outside the new shape: a hole. + const QImage result = render(*session); + QCOMPARE(pixel(result, 28, 28)[3], 0); + QCOMPARE(pixel(result, 14, 14), (std::vector<int>{255, 0, 0, 255})); + // Hard outlines stay hard, warped or moved. + QVERIFY(!session->selection().value().antialiased); + QVERIFY(floated(*session)); + LayerTransform draft = session->transformEdit().value().draft; + draft.origin.rx() += 5; + session->previewTransform(draft); + QVERIFY(!session->displayedSelection().value().antialiased); + session->commitTransform(); + QVERIFY(!session->selection().value().antialiased); +} + +void FloatingSelectionTests::aMergeThatCannotGrowRestoresTheDocument() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + const CanvasDocument before = session->document().value(); + const int count = session->history.undoCount(); + QVERIFY(floated(*session)); + // Wider than a layer may grow: refused, the document restored. + LayerTransform draft = session->transformEdit().value().draft; + draft.size = QSizeF(35000, 10); + session->previewTransform(draft); + QCOMPARE(session->transformEdit().value().draft.size, QSizeF(35000, 10)); + session->commitTransform(); + QVERIFY(session->document().value() == before); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(session->brushError().has_value()); + // A source without pixels cannot take them. + const QImage dot = BrushRaster::context(4, 4, false); + QVERIFY_THROWS_EXCEPTION(ProjectError, FloatingMerge::merge(dot, LayerTransform{.origin = {0, 0}, .size = {4, 4}}, ImageLayer("Blank", QSizeF(10, 10)))); +} + +void FloatingSelectionTests::transformSelectionIsRefusedWhereSwiftRefusesIt() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + // Without a selection Ctrl+T transforms the layer. + QVERIFY(!session->canTransformSelection()); + session->transformCommand(); + QVERIFY(session->transformEdit().has_value() && !session->transformEdit().value().floating); + session->cancelTransform(); + // An empty selection, a mask target, a blank layer: no. + select(*session, QRectF(0, 0, 10, 10)); + session->applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, "Subtract"); + QVERIFY(!session->canTransformSelection()); + select(*session, QRectF(0, 0, 10, 10)); + session->addLayerMask(true); + session->selectLayerTarget(id, true); + QVERIFY(!session->canTransformSelection()); + session->selectLayerTarget(id, false); + QVERIFY(session->canTransformSelection()); + // Hidden, the layer takes no edit. + session->toggleLayerVisibility(id); + QVERIFY(!session->canTransformSelection()); + session->toggleLayerVisibility(id); + session->addBlankLayer(); + select(*session, QRectF(0, 0, 10, 10)); + QVERIFY(!session->canTransformSelection()); + // Refused, the callback still comes and nothing changes. + const CanvasDocument before = session->document().value(); + QVERIFY(floated(*session)); + QVERIFY(session->document().value() == before && !session->transformEdit().has_value()); + // Pixels with a selection: Ctrl+T floats them. + session->selectLayer(id); + select(*session, QRectF(0, 0, 10, 10)); + session->transformCommand(); + QTRY_VERIFY(session->transformEdit().has_value() && session->transformEdit().value().floating); + QVERIFY(!session->canTransformSelection()); + session->cancelTransform(); + // A sliver under the copy's rounding: nothing to lift. + select(*session, QRectF(10, 10, 0.0005, 10)); + QVERIFY(session->canTransformSelection()); + const CanvasDocument sliver = session->document().value(); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no selected pixels to lift"))); + QVERIFY(floated(*session)); + QVERIFY(session->document().value() == sliver && !session->transformEdit().has_value()); +} + +void FloatingSelectionTests::theFloatingLayerTakesItsSourcesPlaceAndLook() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + session->groupSelectedLayers(); + const std::optional<QUuid> folder = layerWith(*session, source).parentID; + QVERIFY(folder.has_value()); + // A layer above, so the floating one's place shows. + session->selectLayer(source); + session->addBlankLayer(); + session->selectLayer(source); + session->setLayerOpacity(0.5); + session->setLayerBlendMode(LayerBlendMode::multiply); + session->selectTool(NavigationTool::marquee); + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(floated(*session)); + QCOMPARE(session->tool(), NavigationTool::move); + const ImageLayer floating = session->activeLayer().value(); + const std::vector<ImageLayer> &layers = session->document().value().layers; + QCOMPARE(indexOf(layers, floating.id), indexOf(layers, source) + 1); + QCOMPARE(floating.parentID, folder); + QCOMPARE(floating.opacity, 0.5); + QCOMPARE(floating.blendMode, LayerBlendMode::multiply); + QCOMPARE(floating.transform.origin, QPointF(10, 10)); + session->cancelTransform(); +} + +void FloatingSelectionTests::aSourceGoneMidLiftPutsTheDocumentBack() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + const CanvasDocument before = session->document().value(); + bool done = false; + session->beginSelectionTransform([&] { done = true; }); + QVERIFY(session->isProjectBusy()); + // The layer goes mid-clear, as a load would take it. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + snapshot.manifest.layers.clear(); + snapshot.images.clear(); + snapshot.manifest.activeLayerID = std::nullopt; + }); + QTRY_VERIFY(done); + // Swift puts the document from before back. + QVERIFY(session->document().value() == before); + QVERIFY(!session->transformEdit().has_value() && !session->isProjectBusy()); +} + +void FloatingSelectionTests::aMaskGrowsWithTheMergedLayer() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + // A black block in the white mask marks its place. + QImage drawn = BrushRaster::context(100, 40, true); + drawn.fill(Qt::white); + for (int y = 20; y < 30; ++y) + std::fill_n(drawn.scanLine(y) + 60, 10, uchar(0)); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, source, LayerMask::assetFrom(drawn)); }); + session->selectLayer(source); + const ImageIdentity mask = layerWith(*session, source).mask.value().asset.identity(); + // Moved within the layer, the mask stays as it was. + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(floated(*session)); + LayerTransform draft = session->transformEdit().value().draft; + draft.origin = QPointF(30, 10); + session->previewTransform(draft); + session->commitTransform(); + QCOMPARE(layerWith(*session, source).mask.value().asset.identity(), mask); + // Past the right edge, the mask grows white beside it. + select(*session, QRectF(30, 10, 10, 10)); + QVERIFY(floated(*session)); + draft = session->transformEdit().value().draft; + draft.origin = QPointF(95, 10); + session->previewTransform(draft); + session->commitTransform(); + const ImageLayer grown = layerWith(*session, source); + QCOMPARE(grown.size(), QSizeF(105, 40)); + const QImage grownMask = grown.mask.value().asset.image(); + QCOMPARE(grownMask.size(), QSize(105, 40)); + QCOMPARE(qGray(grownMask.pixel(102, 5)), 255); + QCOMPARE(qGray(grownMask.pixel(65, 25)), 0); + QCOMPARE(qGray(grownMask.pixel(50, 5)), 255); + // Past the left edge, the old mask shifts along. + select(*session, QRectF(0, 20, 10, 10)); + QVERIFY(floated(*session)); + draft = session->transformEdit().value().draft; + draft.origin = QPointF(-5, 20); + session->previewTransform(draft); + session->commitTransform(); + const ImageLayer left = layerWith(*session, source); + QCOMPARE(left.transform.origin, QPointF(-5, 0)); + const QImage leftMask = left.mask.value().asset.image(); + QCOMPARE(leftMask.size(), QSize(110, 40)); + QCOMPARE(qGray(leftMask.pixel(66, 25)), 0); + QCOMPARE(qGray(leftMask.pixel(62, 25)), 255); + QCOMPARE(qGray(leftMask.pixel(2, 25)), 255); + // A mask placed apart keeps its grid. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).maskPlacement = LayerTransform{.origin = {-5, 0}, .size = {110, 40}}; }); + session->selectLayer(source); + const ImageIdentity placed = layerWith(*session, source).mask.value().asset.identity(); + select(*session, QRectF(95, 10, 10, 10)); + QVERIFY(floated(*session)); + draft = session->transformEdit().value().draft; + draft.origin = QPointF(110, 10); + session->previewTransform(draft); + session->commitTransform(); + // The canvas clipped the selection: five columns float. + QCOMPARE(layerWith(*session, source).size(), QSizeF(120, 40)); + QCOMPARE(layerWith(*session, source).mask.value().asset.identity(), placed); +} + +void FloatingSelectionTests::aTransformedSourceTakesTheMergeInItsOwnGrid() +{ + const std::vector<int> red{255, 0, 0, 255}, blue{0, 0, 255, 255}; + QImage image = BrushRaster::context(50, 20, false); + image.fill(Qt::blue); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 25, 20), Qt::red); + painter.end(); + // Doubled at 20,10: red to 70, blue past it. + EditorSession scaled; + scaled.createDocument(200, 100, true); + scaled.insert(ImportedImage(image, image, "Halves")); + const QUuid id = scaled.activeLayerID().value(); + const LayerTransform doubled{.origin = {20, 10}, .size = {100, 40}}; + rewrite(scaled, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = doubled; }); + scaled.selectLayer(id); + select(scaled, QRectF(30, 20, 20, 20)); + QVERIFY(floated(scaled)); + LayerTransform draft = scaled.transformEdit().value().draft; + draft.origin = QPointF(80, 20); + scaled.previewTransform(draft); + scaled.commitTransform(); + QCOMPARE(layerWith(scaled, id).transform, doubled); + QCOMPARE(layerWith(scaled, id).asset.value().size(), QSize(50, 20)); + QImage result = render(scaled); + QCOMPARE(pixel(result, 90, 30), red); + QCOMPARE(pixel(result, 110, 30), blue); + QCOMPARE(pixel(result, 40, 30)[3], 0); + // Turned a quarter: the block lands where it was put. + EditorSession turned; + turned.createDocument(200, 100, true); + turned.insert(ImportedImage(image, image, "Halves")); + const QUuid other = turned.activeLayerID().value(); + const LayerTransform quarter{.origin = {20, 10}, .size = {100, 40}, .rotation = 90}; + rewrite(turned, [&](ProjectSnapshot &snapshot) { record(snapshot, other).transform = quarter; }); + turned.selectLayer(other); + const QImage before = render(turned); + const std::vector<int> above = pixel(before, 70, 20); + QVERIFY(above[3] == 255 && above != pixel(before, 70, 50)); + select(turned, QRectF(66, 16, 8, 8)); + QVERIFY(floated(turned)); + draft = turned.transformEdit().value().draft; + draft.origin.ry() += 30; + turned.previewTransform(draft); + turned.commitTransform(); + QCOMPARE(layerWith(turned, other).transform, quarter); + result = render(turned); + QCOMPARE(pixel(result, 70, 50), above); + QCOMPARE(pixel(result, 70, 20)[3], 0); +} + +void FloatingSelectionTests::aFoldedDistortionDropsTheOutline() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 20, 20)); + const DocumentSelection outline = session->selection().value(); + QVERIFY(floated(*session)); + session->beginDistort(); + // A bow tie is usable but folded: no outline carries. + const Corners folded = {QPointF(10, 10), QPointF(30, 30), QPointF(30, 10), QPointF(10, 30)}; + session->previewCorners(folded); + QVERIFY(session->transformEdit().value().corners.value() == folded); + session->commitTransform(); + QCOMPARE(session->history.undoName(), QString("Transform Selection")); + QVERIFY(!session->selection().has_value()); + session->undo(); + QVERIFY(session->selection().value() == outline); +} + +QTEST_GUILESS_MAIN(FloatingSelectionTests) +#include "FloatingSelectionTests.moc" diff --git a/tests/FolderMaskClipTests.cpp b/tests/FolderMaskClipTests.cpp new file mode 100644 index 0000000..a6d1430 --- /dev/null +++ b/tests/FolderMaskClipTests.cpp @@ -0,0 +1,307 @@ +#include "Document/LayerMask.h" +#include "IO/ImageExporter.h" +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include "AddressSpaceLimit.h" +#include <QtTest> + +namespace { +int value(const QImage &image, int x, int y) +{ + return image.constScanLine(y)[x]; +} +} + +class FolderMaskClipTests : public QObject { + Q_OBJECT +private slots: + void applyMultipliesThePlacedMaskIntoCoverage(); + void applyRotatesSamplesAndAntialiasesLikeALayerMask(); + void coverageThatCannotBeAllocatedThrows(); + void sharedCoverageThatCannotBeCopiedThrows(); + void theSixtyFourthFolderStillClipsAndTheNextDoesNot(); + void drawHandsEachLayerItsFoldersCoverage(); + void nestedFoldersMultiplyAndAreAskedOnce(); + void aFolderClipMultipliesALayersAlpha(); + void aFolderClipMustBeDeviceSizedAlpha(); +}; + +void FolderMaskClipTests::applyMultipliesThePlacedMaskIntoCoverage() +{ + QImage device = BrushRaster::context(40, 20, false); + QPainter painter(&device); + painter.translate(10, 0); + QImage coverage(40, 20, QImage::Format_Alpha8); + coverage.fill(255); + QImage halves = gray(2, 1, 255); + halves.scanLine(0)[1] = 128; + const FolderMaskClip clip{halves, LayerTransform{.origin = {0, 0}, .size = {20, 10}, .sampling = LayerSampling::nearest}}; + clip.apply(clip.transform.center(), painter, coverage); + QCOMPARE(value(coverage, 12, 5), 255); + QCOMPARE(value(coverage, 25, 5), 128); + QCOMPARE(value(coverage, 5, 5), 0); + QCOMPARE(value(coverage, 35, 5), 0); + QCOMPARE(value(coverage, 12, 15), 0); + clip.apply(clip.transform.center(), painter, coverage); + QCOMPARE(value(coverage, 12, 5), 255); + QCOMPARE(value(coverage, 25, 5), 64); + + QImage scaled(40, 20, QImage::Format_Alpha8); + scaled.fill(255); + LayerTransform turned = clip.transform; + turned.flipX = true; + FolderMaskClip{halves, turned}.apply(turned.center() * 0.5, painter, scaled, 0.5); + QCOMPARE(value(scaled, 12, 2), 128); + QCOMPARE(value(scaled, 17, 2), 255); + QCOMPARE(value(scaled, 22, 2), 0); + QCOMPARE(value(scaled, 12, 7), 0); +} + +void FolderMaskClipTests::applyRotatesSamplesAndAntialiasesLikeALayerMask() +{ + QImage device = BrushRaster::context(40, 40, false); + QPainter painter(&device); + QImage halves = gray(2, 1, 255); + halves.scanLine(0)[1] = 128; + QImage turned(40, 40, QImage::Format_Alpha8); + turned.fill(255); + const LayerTransform quarter{.origin = {10, 15}, .size = {20, 10}, .rotation = 90, .sampling = LayerSampling::nearest}; + FolderMaskClip{halves, quarter}.apply(quarter.center(), painter, turned); + QCOMPARE(value(turned, 20, 12), 255); + QCOMPARE(value(turned, 20, 27), 128); + QCOMPARE(value(turned, 12, 20), 0); + + const auto row = [&](LayerSampling sampling) { + QImage coverage(40, 40, QImage::Format_Alpha8); + coverage.fill(255); + QImage step = gray(2, 1, 0); + step.scanLine(0)[1] = 255; + const LayerTransform wide{.origin = {0, 0}, .size = {40, 40}, .sampling = sampling}; + FolderMaskClip{step, wide}.apply(wide.center(), painter, coverage); + QList<int> values; + for (int x = 0; x < 40; x += 5) + values << value(coverage, x, 20); + return values; + }; + QCOMPARE(row(LayerSampling::nearest), QList<int>({0, 0, 0, 0, 255, 255, 255, 255})); + const QList<int> smooth = row(LayerSampling::smooth); + QVERIFY(std::is_sorted(smooth.begin(), smooth.end())); + QVERIFY(std::count_if(smooth.begin(), smooth.end(), [](int level) { return level > 0 && level < 255; }) >= 2); + + QImage corner = gray(2, 2, 0); + corner.scanLine(0)[0] = 255; + const LayerTransform upright{.origin = {0, 0}, .size = {40, 40}, .sampling = LayerSampling::nearest}; + const auto bright = [&](bool flipX, bool flipY) { + QImage coverage(40, 40, QImage::Format_Alpha8); + coverage.fill(255); + LayerTransform flipped = upright; + flipped.flipX = flipX; + flipped.flipY = flipY; + FolderMaskClip{corner, flipped}.apply(flipped.center(), painter, coverage); + return QList<int>({value(coverage, 10, 10), value(coverage, 30, 10), value(coverage, 10, 30), value(coverage, 30, 30)}); + }; + QCOMPARE(bright(false, false), QList<int>({255, 0, 0, 0})); + QCOMPARE(bright(true, false), QList<int>({0, 255, 0, 0})); + QCOMPARE(bright(false, true), QList<int>({0, 0, 255, 0})); + QCOMPARE(bright(true, true), QList<int>({0, 0, 0, 255})); + + QImage tilted(40, 40, QImage::Format_Alpha8); + tilted.fill(255); + const LayerTransform tilt{.origin = {10, 10}, .size = {20, 20}, .rotation = 30, .sampling = LayerSampling::nearest}; + FolderMaskClip{gray(1, 1, 255), tilt}.apply(tilt.center(), painter, tilted); + int partial = 0; + for (int y = 0; y < 40; ++y) { + for (int x = 0; x < 40; ++x) + partial += value(tilted, x, y) > 0 && value(tilted, x, y) < 255; + } + QVERIFY(partial > 10); + QCOMPARE(value(tilted, 20, 20), 255); +} + +void FolderMaskClipTests::coverageThatCannotBeAllocatedThrows() +{ + QImage device = BrushRaster::context(16384, 16384, true); + QPainter painter(&device); + QImage coverage(16384, 16384, QImage::Format_Alpha8); + const FolderMaskClip clip{gray(1, 1, 255), LayerTransform{.origin = {0, 0}, .size = {16384, 16384}}}; + const QUuid folder = QUuid::createUuid(), inside = QUuid::createUuid(); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + std::optional<ExportError::Kind> applyThrew, drawThrew; + try { + clip.apply(clip.transform.center(), painter, coverage); + } catch (const ExportError &error) { + applyThrew = error.kind; + } + try { + FolderMaskClip::draw({inside}, [&](QUuid id) { return id == inside ? std::optional(folder) : std::nullopt; }, + [&](QUuid) -> std::optional<FolderMaskClip::Applier> { return [](const QPainter &, QImage &) {}; }, + painter, [](QUuid, const QImage &) {}); + } catch (const ExportError &error) { + drawThrew = error.kind; + } + limit.reset(); + QCOMPARE(applyThrew, std::optional(ExportError::Kind::render)); + QCOMPARE(drawThrew, std::optional(ExportError::Kind::render)); +} + +void FolderMaskClipTests::sharedCoverageThatCannotBeCopiedThrows() +{ + QImage device = BrushRaster::context(4, 4, false); + QPainter painter(&device); + QImage coverage(16384, 16384, QImage::Format_Alpha8); + const QImage keeper = coverage; + const qint64 coverageBytes = coverage.sizeInBytes(); + const FolderMaskClip clip{gray(1, 1, 255), LayerTransform{.origin = {0, 0}, .size = {4, 4}}}; + // Room for the veil, not for the copy as well. + std::optional<AddressSpaceLimit> limit(std::in_place, coverageBytes + coverageBytes / 2); + std::optional<ExportError::Kind> thrown; + try { + clip.apply(clip.transform.center(), painter, coverage); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); + QCOMPARE(keeper.size(), QSize(16384, 16384)); +} + +void FolderMaskClipTests::theSixtyFourthFolderStillClipsAndTheNextDoesNot() +{ + QImage device = BrushRaster::context(4, 4, false); + QPainter painter(&device); + const QUuid layerID = QUuid::createUuid(); + QList<QUuid> folders; + for (int index = 0; index < 65; ++index) + folders << QUuid::createUuid(); + const auto parent = [&](QUuid id) -> std::optional<QUuid> { + if (id == layerID) + return folders[0]; + const int index = folders.indexOf(id); + return index + 1 < folders.size() ? std::optional(folders[index + 1]) : std::nullopt; + }; + const LayerTransform whole{.origin = {0, 0}, .size = {4, 4}}; + const FolderMaskClip half{gray(1, 1, 128), whole}, hidden{gray(1, 1, 0), whole}; + const auto clip = [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + const FolderMaskClip *mask = id == folders[63] ? &half : id == folders[64] ? &hidden : nullptr; + if (!mask) + return std::nullopt; + return [&, mask](const QPainter &context, QImage &coverage) { mask->apply(whole.center(), context, coverage); }; + }; + int seen = -1; + FolderMaskClip::draw({layerID}, parent, clip, painter, [&](QUuid, const QImage &coverage) { seen = coverage.isNull() ? -2 : value(coverage, 1, 1); }); + QCOMPARE(seen, 128); +} + +void FolderMaskClipTests::drawHandsEachLayerItsFoldersCoverage() +{ + QImage device = BrushRaster::context(8, 4, false); + QPainter painter(&device); + const QUuid folder = QUuid::createUuid(), plain = QUuid::createUuid(), outside = QUuid::createUuid(); + const QUuid inside = QUuid::createUuid(), insidePlain = QUuid::createUuid(); + const auto parent = [&](QUuid id) -> std::optional<QUuid> { + if (id == inside) + return folder; + if (id == insidePlain) + return plain; + return std::nullopt; + }; + const FolderMaskClip mask{gray(1, 1, 100), LayerTransform{.origin = {0, 0}, .size = {8, 4}}}; + const auto clip = [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + if (id != folder) + return std::nullopt; + return [&](const QPainter &context, QImage &coverage) { mask.apply(mask.transform.center(), context, coverage); }; + }; + QList<QUuid> order; + QHash<QUuid, QImage> clips; + FolderMaskClip::draw({outside, inside, insidePlain}, parent, clip, painter, [&](QUuid id, const QImage &coverage) { + order << id; + clips.insert(id, coverage); + }); + QCOMPARE(order, QList<QUuid>({outside, inside, insidePlain})); + QVERIFY(clips[outside].isNull()); + QVERIFY(clips[insidePlain].isNull()); + QCOMPARE(clips[inside].format(), QImage::Format_Alpha8); + QCOMPARE(clips[inside].size(), QSize(8, 4)); + QCOMPARE(value(clips[inside], 3, 2), 100); +} + +void FolderMaskClipTests::nestedFoldersMultiplyAndAreAskedOnce() +{ + QImage device = BrushRaster::context(8, 4, false); + QPainter painter(&device); + const QUuid outer = QUuid::createUuid(), middle = QUuid::createUuid(), inner = QUuid::createUuid(); + const QUuid deep = QUuid::createUuid(), deepToo = QUuid::createUuid(), shallow = QUuid::createUuid(); + const auto parent = [&](QUuid id) -> std::optional<QUuid> { + if (id == deep || id == deepToo) + return inner; + if (id == inner) + return middle; + if (id == middle || id == shallow) + return outer; + return std::nullopt; + }; + const FolderMaskClip half{gray(1, 1, 128), LayerTransform{.origin = {0, 0}, .size = {8, 4}}}; + QHash<QUuid, int> asked; + const auto clip = [&](QUuid id) -> std::optional<FolderMaskClip::Applier> { + asked[id] += 1; + if (id == middle) + return std::nullopt; + return [&](const QPainter &context, QImage &coverage) { half.apply(half.transform.center(), context, coverage); }; + }; + QHash<QUuid, int> seen; + FolderMaskClip::draw({deep, shallow, deepToo}, parent, clip, painter, + [&](QUuid id, const QImage &coverage) { seen.insert(id, value(coverage, 3, 2)); }); + QCOMPARE(seen[deep], 64); + QCOMPARE(seen[deepToo], 64); + QCOMPARE(seen[shallow], 128); + QCOMPARE(asked[outer], 1); + QCOMPARE(asked[middle], 1); + QCOMPARE(asked[inner], 1); +} + +void FolderMaskClipTests::aFolderClipMultipliesALayersAlpha() +{ + QImage clip(8, 4, QImage::Format_Alpha8); + clip.fill(255); + for (int y = 0; y < 4; ++y) { + clip.scanLine(y)[4] = clip.scanLine(y)[5] = 128; + clip.scanLine(y)[6] = clip.scanLine(y)[7] = 0; + } + QImage surface = BrushRaster::context(8, 4, false); + QPainter painter(&surface); + const LayerTransform transform = placedAt({2, 0}, {6, 4}); + QImage mask = gray(6, 4, 255); + for (int y = 0; y < 4; ++y) + mask.scanLine(y)[0] = mask.scanLine(y)[2] = 128; + LayerRenderer::draw(solid(6, 4, qRgba(255, 255, 255, 255)), transform, transform.center(), painter, + {.mask = mask, .clip = clip}); + painter.end(); + QCOMPARE(qAlpha(surface.pixel(1, 1)), 0); + QVERIFY(std::abs(qAlpha(surface.pixel(2, 1)) - 128) <= 1); + QCOMPARE(qAlpha(surface.pixel(3, 1)), 255); + QVERIFY(std::abs(qAlpha(surface.pixel(4, 1)) - 64) <= 1); + QVERIFY(std::abs(qAlpha(surface.pixel(5, 1)) - 128) <= 1); + QCOMPARE(qAlpha(surface.pixel(6, 1)), 0); + + QImage unmasked = BrushRaster::context(8, 4, false); + QPainter second(&unmasked); + LayerRenderer::draw(solid(6, 4, qRgba(255, 255, 255, 255)), transform, transform.center(), second, {.clip = clip}); + second.end(); + QCOMPARE(qAlpha(unmasked.pixel(2, 1)), 255); + QVERIFY(std::abs(qAlpha(unmasked.pixel(4, 1)) - 128) <= 1); + QCOMPARE(qAlpha(unmasked.pixel(7, 1)), 0); +} + +void FolderMaskClipTests::aFolderClipMustBeDeviceSizedAlpha() +{ + QImage surface = BrushRaster::context(8, 4, false); + QPainter painter(&surface); + const LayerTransform transform = placedAt({0, 0}, {8, 4}); + const QImage image = solid(8, 4, qRgba(9, 9, 9, 255)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(image, transform, transform.center(), painter, {.clip = gray(8, 4, 255)})); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(image, transform, transform.center(), painter, + {.clip = QImage(4, 4, QImage::Format_Alpha8)})); +} + +QTEST_MAIN(FolderMaskClipTests) +#include "FolderMaskClipTests.moc" diff --git a/tests/GradientCanvasTests.cpp b/tests/GradientCanvasTests.cpp new file mode 100644 index 0000000..6c32bfb --- /dev/null +++ b/tests/GradientCanvasTests.cpp @@ -0,0 +1,364 @@ +#include "BrushCanvasFixtures.h" +#include "UI/GradientControls.h" +#include "UI/LassoControls.h" +#include "Rendering/TransformOverlay.h" +#include <numbers> +#include <QCheckBox> +#include <QComboBox> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> + +// Swift's gradient on the canvas: drag, ends, keys, bar. +namespace { +void gradient(Canvas &shown) +{ + shown.session.addBlankLayer(); + shown.session.selectTool(NavigationTool::gradient); + shown.canvas->synchronizeDisplay(); +} + +QColor shownAt(Canvas &shown, int x, int y) +{ + return shown.canvas->grab().toImage().pixelColor(x, y); +} + +template <typename Widget> Widget &find(QWidget &parent, const char *name) +{ + auto *found = parent.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} +} + +class GradientCanvasTests : public QObject { + Q_OBJECT +private slots: + void aDragDrawsAPendingGradientAndItsLine(); + void endsAreGrabbedWithinTenPoints(); + void shiftKeepsTheLineToFortyFiveDegrees(); + void escapeCancelsAndReturnApplies(); + void theOverlayDrawsTheLineAsSwift(); + void aFoldersPendingMaskClipsAtOnce(); + void theBarSetsTheGradient(); + void theSwatchShowsTheColoursOverChecks(); +}; + +void GradientCanvasTests::aDragDrawsAPendingGradientAndItsLine() +{ + Canvas shown; + gradient(shown); + const QColor ground = shownAt(shown, 200, 100); + shown.drag(QPointF(50, 150), QPointF(350, 150)); + const std::optional<GradientEdit> &edit = shown.session.gradientEdit(); + QVERIFY(edit.has_value()); + QCOMPARE(edit.value().start, QPointF(50, 150)); + QCOMPARE(edit.value().end, QPointF(350, 150)); + QCOMPARE(shown.canvas->synchronizeDisplay(), false); + // Black at the start, fading; the line and ends above. + QCOMPARE(shownAt(shown, 40, 100), QColor(0, 0, 0)); + const QColor mid = shownAt(shown, 200, 100); + QVERIFY2(std::abs(mid.red() - ground.red() / 2) <= 3, qPrintable(mid.name() + " " + ground.name())); + // Each end: its colour, a white ring, a black rim. + QCOMPARE(shownAt(shown, 50, 150), QColor(0, 0, 0)); + QCOMPARE(shownAt(shown, 50, 145), QColor(255, 255, 255)); + const QImage view = shown.canvas->grab().toImage(); + QVERIFY(std::min({view.pixelColor(50, 143).red(), view.pixelColor(50, 144).red()}) < 110); + QCOMPARE(shownAt(shown, 350, 150), QColor(191, 191, 191)); + // The white line over the black one lightens its row. + QVERIFY(shownAt(shown, 200, 150).red() > mid.red() + 40); + // A click without a line leaves nothing pending. + shown.session.cancelGradient(); + shown.click(QPointF(100, 100)); + QVERIFY(!shown.session.gradientEdit()); +} + +void GradientCanvasTests::endsAreGrabbedWithinTenPoints() +{ + Canvas shown; + gradient(shown); + shown.drag(QPointF(50, 150), QPointF(350, 150)); + // Nine points from the end grabs it; the start stays. + shown.drag(QPointF(359, 150), QPointF(300, 100)); + QCOMPARE(shown.session.gradientEdit().value().start, QPointF(50, 150)); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(300, 100)); + shown.drag(QPointF(52, 157), QPointF(80, 200)); + QCOMPARE(shown.session.gradientEdit().value().start, QPointF(80, 200)); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(300, 100)); + // Eleven points from either end starts a new line there. + shown.drag(QPointF(80, 211), QPointF(150, 250)); + QCOMPARE(shown.session.gradientEdit().value().start, QPointF(80, 211)); + shown.drag(QPointF(161, 250), QPointF(250, 250)); + shown.drag(QPointF(311, 100), QPointF(390, 20)); + QCOMPARE(shown.session.gradientEdit().value().start, QPointF(311, 100)); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20)); + // A release lets go: hovering moves nothing. + shown.hover(QPointF(200, 200)); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20)); + // Losing focus drops a drag under way. + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.press(QPointF(390, 20)); + QWidget other(&shown.window); + other.setFocusPolicy(Qt::StrongFocus); + other.show(); + other.setFocus(); + QTRY_VERIFY(!shown.canvas->hasFocus()); + shown.move(QPointF(200, 200)); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(390, 20)); + shown.release(QPointF(200, 200)); +} + +void GradientCanvasTests::shiftKeepsTheLineToFortyFiveDegrees() +{ + Canvas shown; + gradient(shown); + shown.press(QPointF(100, 100)); + shown.move(QPointF(200, 130), Qt::ShiftModifier); + const QPointF end = shown.session.gradientEdit().value().end; + QCOMPARE(end.y(), 100.0); + QCOMPARE(end.x(), 100 + std::hypot(100.0, 30.0)); + shown.move(QPointF(180, 170), Qt::ShiftModifier); + const QPointF diagonal = shown.session.gradientEdit().value().end; + QVERIFY(std::abs((diagonal.x() - 100) - (diagonal.y() - 100)) < 1e-9); + shown.release(QPointF(180, 170), Qt::ShiftModifier); + // The start snaps around the end the same way. + shown.press(QPointF(100, 100)); + shown.move(QPointF(100 + 3, 250), Qt::ShiftModifier); + const QPointF start = shown.session.gradientEdit().value().start, fixed = shown.session.gradientEdit().value().end; + QVERIFY(start.x() < fixed.x() && std::abs(std::abs(start.x() - fixed.x()) - std::abs(start.y() - fixed.y())) < 1e-9); + shown.release(QPointF(103, 250)); +} + +void GradientCanvasTests::escapeCancelsAndReturnApplies() +{ + Canvas shown; + gradient(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.drag(QPointF(50, 150), QPointF(350, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.gradientEdit()); + const int count = shown.session.history.undoCount(); + shown.drag(QPointF(50, 150), QPointF(350, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QTRY_COMPARE(shown.session.history.undoCount(), count + 1); + QVERIFY(!shown.session.gradientEdit()); + shown.drag(QPointF(50, 150), QPointF(350, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Enter); + QTRY_COMPARE(shown.session.history.undoCount(), count + 2); +} + +void GradientCanvasTests::theOverlayDrawsTheLineAsSwift() +{ + Canvas shown; + QImage image = BrushRaster::context(60, 40, false); + image.fill(QColor(90, 90, 90)); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Block"))); + shown.session.selectTool(NavigationTool::gradient); + const TransformOverlay overlay(shown.session); + const auto drawn = [&] { + QImage layer(400, 300, QImage::Format_ARGB32_Premultiplied); + layer.fill(Qt::transparent); + QPainter painter(&layer); + overlay.draw(painter, shown.canvas->palette()); + return layer; + }; + // A press alone has no line to draw. + shown.session.beginGradient(QPointF(100, 150)); + QVERIFY(!overlay.gradientLine()); + shown.session.moveGradient(std::nullopt, QPointF(300, 150)); + // A three-point shade under the one-point white line. + QVERIFY(qAlpha(drawn().pixel(200, 148)) > 40); + QCOMPARE(qAlpha(drawn().pixel(200, 146)), 0); + // The radial's dashed rim, at the line's length. + GradientSettings radial = shown.session.gradientSettings(); + radial.shape = GradientShape::radial; + shown.session.setGradientSettings(radial); + shown.session.moveGradient(std::nullopt, QPointF(160, 150)); + const QImage rim = drawn(); + int dashes = 0; + for (int step = 0; step < 160; ++step) { + const double angle = (100 + step) * std::numbers::pi / 180; + dashes += qAlpha(rim.pixel(int(100 + 60 * std::cos(angle)), int(150 + 60 * std::sin(angle)))) > 0; + } + QVERIFY2(dashes > 40 && dashes < 150, qPrintable(QString::number(dashes))); + // Switched to Move, not yet applied: the line, no handles. + shown.session.selectTool(NavigationTool::move); + QVERIFY(shown.session.gradientEdit() && overlay.geometry()); + QCOMPARE(qAlpha(drawn().pixel(int(overlay.geometry().value().handles[0].x()), int(overlay.geometry().value().handles[0].y()))), 0); + QTRY_VERIFY(!shown.session.gradientEdit()); +} + +void GradientCanvasTests::aFoldersPendingMaskClipsAtOnce() +{ + Canvas shown; + QImage image = BrushRaster::context(400, 300, false); + image.fill(QColor(200, 30, 30)); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + shown.session.groupSelectedLayers(); + const QUuid folder = shown.session.activeLayerID().value(); + QVERIFY(shown.session.document().value().layers[size_t(indexOf(shown.session.document().value().layers, folder))].isGroup); + shown.session.addLayerMask(true); + shown.session.selectLayerTarget(folder, true); + shown.session.selectTool(NavigationTool::gradient); + GradientSettings settings = shown.session.gradientSettings(); + settings.style = GradientStyle::foregroundToBackground; + shown.session.setGradientSettings(settings); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shownAt(shown, 20, 100), QColor(200, 30, 30)); + // Black to white across the folder's mask, still pending. + shown.drag(QPointF(0, 150), QPointF(400, 150)); + QVERIFY(shown.session.gradientEdit().value().raster->isMask); + // Over gray checks, red minus green measures the red shown. + const auto red = [&](int x) { + const QColor seen = shownAt(shown, x, 100); + return seen.red() - seen.green(); + }; + const int mid = red(200); + QVERIFY2(red(20) < 20 && red(380) > 150 && std::abs(mid - 85) <= 4, qPrintable(QString::number(mid))); + // The step lands as it looked. + shown.session.commitGradient(); + QTRY_VERIFY(!shown.session.gradientEdit()); + shown.canvas->synchronizeDisplay(); + QVERIFY(std::abs(red(200) - mid) <= 2); +} + +void GradientCanvasTests::theBarSetsTheGradient() +{ + EditorSession session; + session.createDocument(40, 20, true); + session.selectTool(NavigationTool::gradient); + GradientControls bar(session); + bar.show(); + QCOMPARE(bar.title->text(), QString("Gradient")); + QStringList labels; + for (const QLabel *label : bar.findChildren<QLabel *>()) + labels << label->text(); + labels.sort(); + QCOMPARE(labels, (QStringList{"%", "Gradient", "Mask", "Opacity"})); + auto &linear = find<QToolButton>(bar, "gradientLinear"), &radial = find<QToolButton>(bar, "gradientRadial"); + QCOMPARE(radial.toolTip(), QString("Linear runs along the line; Radial spreads out from the start point")); + QVERIFY(linear.isChecked()); + radial.click(); + QCOMPARE(session.gradientSettings().shape, GradientShape::radial); + QVERIFY(radial.isChecked() && !linear.isChecked()); + linear.click(); + QCOMPARE(session.gradientSettings().shape, GradientShape::linear); + QVERIFY(linear.isChecked() && !radial.isChecked()); + auto &style = find<QComboBox>(bar, "gradientStyle"); + QCOMPARE(style.count(), 2); + QCOMPARE(style.itemText(0), QString("Foreground to Background")); + QCOMPARE(style.itemText(1), QString("Foreground to Transparent")); + QCOMPARE(style.currentIndex(), 1); + style.setCurrentIndex(0); + emit style.activated(0); + QCOMPARE(session.gradientSettings().style, GradientStyle::foregroundToBackground); + style.setCurrentIndex(1); + emit style.activated(1); + QCOMPARE(session.gradientSettings().style, GradientStyle::foregroundToTransparent); + auto &reverse = find<QCheckBox>(bar, "gradientReverse"); + reverse.click(); + QVERIFY(session.gradientSettings().reversed && reverse.isChecked()); + reverse.click(); + QVERIFY(!session.gradientSettings().reversed); + // The slider runs from 1% to 100% in thousandths. + auto &slider = find<QSlider>(bar, "gradientOpacitySlider"); + QCOMPARE(slider.width(), 100); + slider.setValue(0); + QCOMPARE(session.gradientSettings().opacity, 0.01); + slider.setValue(5000); + QCOMPARE(session.gradientSettings().opacity, 1.0); + slider.setValue(333); + QCOMPARE(session.gradientSettings().opacity, 0.333); + auto &field = find<SelectionAmountField>(bar, "gradientOpacity"); + QCOMPARE(field.text(), QString("33")); + QCOMPARE(field.width(), 42); + QCOMPARE(field.toolTip(), QString("Press 1–9 for 10–90%, 0 for 100%")); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, "70"); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.gradientSettings().opacity, 0.7); + // The session's changes show without writing back. + session.setGradientSettings(GradientSettings{GradientShape::radial, GradientStyle::foregroundToBackground, true, 0.12345}); + QVERIFY(radial.isChecked() && style.currentIndex() == 0 && reverse.isChecked()); + QCOMPARE(slider.value(), 123); + QCOMPARE(field.text(), QString("12")); + QCOMPARE(session.gradientSettings().opacity, 0.12345); + session.setGradientSettings(GradientSettings{GradientShape::linear, GradientStyle::foregroundToTransparent, false, 0.5}); + QVERIFY(linear.isChecked() && style.currentIndex() == 1 && !reverse.isChecked()); + QCOMPARE(slider.value(), 500); + // Apply and Cancel while pending, at the far end. + auto &apply = find<QPushButton>(bar, "gradientApply"), &cancel = find<QPushButton>(bar, "gradientCancel"); + QVERIFY(!apply.isVisible() && !cancel.isVisible()); + 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()); + const int count = session.history.undoCount(); + cancel.click(); + QVERIFY(!session.gradientEdit()); + QCOMPARE(session.history.undoCount(), count); + session.beginGradient(QPointF(1, 1)); + session.moveGradient(std::nullopt, QPointF(30, 1)); + apply.click(); + QTRY_COMPARE(session.history.undoCount(), count + 1); + // The edit ends from the event loop, after its step. + QTRY_VERIFY(!session.gradientEdit()); + // The note, in secondary text, on a mask. + auto ¬e = find<QLabel>(bar, "gradientMaskNote"); + QVERIFY(!note.isVisible()); + QCOMPARE(note.foregroundRole(), QPalette::PlaceholderText); + session.addLayerMask(true); + QVERIFY(note.isVisible()); + session.setIsProjectBusy(true); + QTRY_VERIFY(!bar.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(bar.isEnabled()); + EditorSession empty; + const GradientControls idle(empty); + QVERIFY(!idle.isEnabled()); +} + +void GradientCanvasTests::theSwatchShowsTheColoursOverChecks() +{ + EditorSession session; + session.createDocument(40, 20, true); + session.selectTool(NavigationTool::gradient); + session.setForegroundColor(PaletteColor{1, 0, 0}); + GradientControls bar(session); + QWidget &swatch = find<QWidget>(bar, "gradientSwatch"); + PaintSpy spy(swatch); + bar.show(); + QTRY_VERIFY(spy.count > 0); + QCOMPARE(swatch.size(), QSize(56, 18)); + const auto drawn = [&] { + QImage image(56, 18, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + swatch.render(&image, QPoint(), QRegion(), QWidget::DrawChildren); + return image; + }; + const QImage shown = drawn(); + // Red at the start, fading to clear over the checks. + QVERIFY(shown.pixelColor(1, 9).red() > 240 && shown.pixelColor(1, 9).green() < 12); + QVERIFY(std::abs(shown.pixelColor(28, 10).green() - 130) <= 4); + QVERIFY(std::abs(shown.pixelColor(50, 6).green() - 230) <= 4); + for (const int y : {2, 6, 10, 14, 16}) + QCOMPARE(shown.pixelColor(50, y).green() < 210, y != 6 && y != 14); + // A half-black rim, its corners rounded and smooth. + QVERIFY(shown.pixelColor(28, 0).green() < shown.pixelColor(28, 10).green() - 40); + QVERIFY(qAlpha(shown.pixel(0, 0)) < 40); + QVERIFY(qAlpha(shown.pixel(1, 0)) > 40 && qAlpha(shown.pixel(1, 0)) < 240); + // Every change repaints it. + QApplication::processEvents(); + spy.count = 0; + session.setForegroundColor(PaletteColor{0, 0, 1}); + QTRY_VERIFY(spy.count > 0); + QVERIFY(drawn().pixelColor(1, 9).blue() > 240); +} + +QTEST_MAIN(GradientCanvasTests) +#include "GradientCanvasTests.moc" diff --git a/tests/GradientTests.cpp b/tests/GradientTests.cpp new file mode 100644 index 0000000..10cf1d9 --- /dev/null +++ b/tests/GradientTests.cpp @@ -0,0 +1,464 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ImageExporter.h" +#include <QSignalSpy> + +// Swift's GradientTests, and the palette the gradient reads. +namespace { +std::unique_ptr<EditorSession> makeSession(int width = 101, int height = 4) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height); + session->addBlankLayer(); + session->selectTool(NavigationTool::gradient); + GradientSettings settings = session->gradientSettings(); + settings.style = GradientStyle::foregroundToBackground; + session->setGradientSettings(settings); + return session; +} + +std::vector<int> exported(const EditorSession &session, int x, int y) +{ + return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y); +} + +void drag(EditorSession &session, QPointF start, QPointF end) +{ + session.beginGradient(start); + session.moveGradient(std::nullopt, end); + session.endGradientDrag(); +} + +// Swift's `await commitGradient()`: done once the step lands. +bool commit(EditorSession &session) +{ + bool finished = false; + session.commitGradient([&] { finished = true; }); + return QTest::qWaitFor([&] { return finished; }, 5000); +} + +// Core Graphics quantises ramps: ends may fall levels short. +bool near(int value, int target, int tolerance = 3) +{ + return std::abs(value - target) <= tolerance; +} + +bool near(const std::vector<int> &value, const std::vector<int> &target) +{ + for (size_t i = 0; i < value.size(); ++i) { + if (!near(value[i], target[i])) + return false; + } + return true; +} + +void setting(EditorSession &session, const std::function<void(GradientSettings &)> &change) +{ + GradientSettings settings = session.gradientSettings(); + change(settings); + session.setGradientSettings(settings); +} +} + +class GradientTests : public QObject { + Q_OBJECT +private slots: + void foregroundToBackgroundFillsCanvasAndCommitsOneUndo(); + void radialSpreadsFromStartToRimInEveryDirection(); + void reverseOpacityAndDirectionFollowSettings(); + void foregroundToTransparentPreservesUnderlyingPixelsAndAlpha(); + void cancelUndoAndClicksLeaveDocumentUntouched(); + void redraggingReplacesPendingLineWithoutAccumulating(); + void maskGradientWritesCoverageInsideLayerBounds(); + void paletteChangesUpdatePendingPreview(); + void switchingToolLayerOrTargetAppliesIt(); + void aPendingGradientHoldsLayerEdits(); + void invertAndFiltersApplyItFirst(); + void digitsAndTabSetOpacityAndShape(); + void thePaletteIsTheBrushsColourAndABackground(); + void aMasksPaletteIsItsPaint(); + void aPendingGradientKeepsItsTarget(); + void revisionsBusyAndLinelessCommits(); +}; + +void GradientTests::foregroundToBackgroundFillsCanvasAndCommitsOneUndo() +{ + const auto session = makeSession(); + const int count = session->history.undoCount(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(session->gradientEdit()); + QVERIFY(!session->activeLayer().value().asset && session->history.undoCount() == count); + QVERIFY(commit(*session)); + QVERIFY(!session->gradientEdit() && !session->brushError()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Gradient")); + QVERIFY(near(exported(*session, 0, 0), {0, 0, 0, 255})); + QVERIFY(near(exported(*session, 100, 3), {255, 255, 255, 255})); + const std::vector<int> middle = exported(*session, 50, 1); + QVERIFY(near(middle[0], 128) && middle[0] == middle[1] && middle[3] == 255); + session->undo(); + QVERIFY(!session->activeLayer().value().asset); +} + +void GradientTests::radialSpreadsFromStartToRimInEveryDirection() +{ + const auto session = makeSession(101, 101); + setting(*session, [](GradientSettings &settings) { settings.shape = GradientShape::radial; }); + drag(*session, QPointF(50.5, 50.5), QPointF(90.5, 50.5)); + QVERIFY(commit(*session)); + QVERIFY(near(exported(*session, 50, 50), {0, 0, 0, 255})); + // Equal distances get equal values; past the rim, background. + std::vector<int> halfway; + for (const QPoint point : {QPoint(70, 50), QPoint(30, 50), QPoint(50, 70), QPoint(50, 30)}) + halfway.push_back(exported(*session, point.x(), point.y())[0]); + for (const int value : halfway) + QVERIFY2(near(value, 128, 5) && near(value, halfway[0], 1), qPrintable(QString::number(value))); + QVERIFY(near(exported(*session, 100, 50), {255, 255, 255, 255})); + QVERIFY(near(exported(*session, 0, 0), {255, 255, 255, 255})); +} + +void GradientTests::reverseOpacityAndDirectionFollowSettings() +{ + const auto session = makeSession(); + setting(*session, [](GradientSettings &settings) { + settings.reversed = true; + settings.opacity = 0.5; + }); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(commit(*session)); + // White at the start, half clear on a blank layer. + const std::vector<int> start = exported(*session, 0, 2); + QVERIFY(near(start[3], 128) && near(start[0], start[3])); + const std::vector<int> end = exported(*session, 100, 2); + QVERIFY(near(end[0], 0) && near(end[3], 128)); +} + +void GradientTests::foregroundToTransparentPreservesUnderlyingPixelsAndAlpha() +{ + const auto session = makeSession(); + session->setPaletteColor(PaletteColor{1, 0, 0}, false); + // Everything before the start is foreground: solid red. + drag(*session, QPointF(100.5, 2), QPointF(101, 2)); + QVERIFY(commit(*session)); + QVERIFY(near(exported(*session, 50, 2), {255, 0, 0, 255})); + session->setPaletteColor(PaletteColor::black(), false); + setting(*session, [](GradientSettings &settings) { settings.style = GradientStyle::foregroundToTransparent; }); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(commit(*session)); + QVERIFY(near(exported(*session, 0, 2), {0, 0, 0, 255})); + QVERIFY(near(exported(*session, 100, 2), {255, 0, 0, 255})); + const std::vector<int> middle = exported(*session, 50, 2); + QVERIFY(near(middle[0], 128) && middle[1] == 0 && middle[3] == 255); +} + +void GradientTests::cancelUndoAndClicksLeaveDocumentUntouched() +{ + // Nothing else to undo: a pending gradient alone enables Undo. + EditorSession fresh; + fresh.createDocument(20, 4, true); + fresh.history.reset(); + fresh.selectTool(NavigationTool::gradient); + QVERIFY(!fresh.canUndo()); + drag(fresh, QPointF(0.5, 2), QPointF(10.5, 2)); + QVERIFY(fresh.canUndo()); + fresh.undo(); + QVERIFY(!fresh.gradientEdit() && !fresh.canUndo()); + const auto session = makeSession(); + const std::vector<ImageLayer> before = session->document().value().layers; + session->beginGradient(QPointF(10, 2)); + session->endGradientDrag(); + QVERIFY(!session->gradientEdit()); + drag(*session, QPointF(0, 2), QPointF(100, 2)); + session->cancelGradient(); + QVERIFY(!session->gradientEdit() && session->document().value().layers == before); + // The first Undo discards a pending gradient, as Photoshop. + drag(*session, QPointF(0, 2), QPointF(100, 2)); + QVERIFY(session->canUndo()); + const int count = session->history.undoCount(); + session->undo(); + QVERIFY(!session->gradientEdit() && session->document().value().layers == before); + QCOMPARE(session->history.undoCount(), count); +} + +void GradientTests::redraggingReplacesPendingLineWithoutAccumulating() +{ + const auto session = makeSession(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + drag(*session, QPointF(100.5, 2), QPointF(0.5, 2)); + QVERIFY(commit(*session)); + QVERIFY(near(exported(*session, 0, 2), {255, 255, 255, 255})); + QVERIFY(near(exported(*session, 100, 2), {0, 0, 0, 255})); +} + +void GradientTests::maskGradientWritesCoverageInsideLayerBounds() +{ + const auto session = makeSession(); + drag(*session, QPointF(0, 2), QPointF(0.6, 2)); + QVERIFY(commit(*session)); + session->addLayerMask(true); + const QUuid id = session->activeLayerID().value(); + session->selectLayerTarget(id, true); + QVERIFY(session->isMaskSelected()); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(commit(*session)); + QVERIFY(!session->brushError()); + QCOMPARE(session->history.undoName(), QString("Gradient Mask")); + // The mask's palette paints black, to hide, then white. + QVERIFY(near(exported(*session, 0, 2)[3], 0)); + QVERIFY(near(exported(*session, 100, 2)[3], 255)); + QVERIFY(near(exported(*session, 50, 2)[3], 128)); +} + +void GradientTests::paletteChangesUpdatePendingPreview() +{ + const auto session = makeSession(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + const int revision = session->brushRevision(); + session->swapPaletteColors(); + QVERIFY(session->brushRevision() > revision); + QCOMPARE(session->gradientColors(false)[0].redF(), 1.0); + session->cancelGradient(); +} + +void GradientTests::switchingToolLayerOrTargetAppliesIt() +{ + const auto session = makeSession(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + // Swift's Task: the step lands once the caller has returned. + session->selectTool(NavigationTool::brush); + QVERIFY(session->gradientEdit() && !session->isProjectBusy()); + QTRY_VERIFY(!session->gradientEdit() && !session->isProjectBusy()); + QCOMPARE(session->history.undoName(), QString("Gradient")); + const QUuid first = session->activeLayerID().value(); + session->addBlankLayer(); + session->selectTool(NavigationTool::gradient); + drag(*session, QPointF(0.5, 2), QPointF(50.5, 2)); + const int count = session->history.undoCount(); + session->selectLayer(first); + QTRY_COMPARE(session->history.undoCount(), count + 1); + QVERIFY(!session->gradientEdit()); + drag(*session, QPointF(0.5, 2), QPointF(50.5, 2)); + session->selectLayers({first}, first); + QVERIFY(session->gradientEdit()); + // A new multi-selection applies it too. + const QUuid top = session->document().value().layers.back().id; + session->selectLayers({first, top}, first); + QTRY_VERIFY(!session->gradientEdit()); + session->selectLayers({first}, first); + drag(*session, QPointF(0.5, 2), QPointF(50.5, 2)); + session->addLayerMask(true); + QVERIFY(session->gradientEdit()); + session->selectLayerTarget(first, true); + QTRY_VERIFY(!session->gradientEdit()); + QCOMPARE(session->history.undoName(), QString("Gradient")); +} + +void GradientTests::aPendingGradientHoldsLayerEdits() +{ + const auto session = makeSession(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(!session->canEditLayers() && !session->canPaint() && session->canUseHistory()); + const int count = int(session->document().value().layers.size()); + session->addBlankLayer(); + QCOMPARE(int(session->document().value().layers.size()), count); + // A step restored drops the pending gradient. + QVERIFY(commit(*session)); + session->undo(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(session->gradientEdit()); + session->redo(); + QVERIFY(!session->gradientEdit()); + QCOMPARE(session->history.undoName(), QString("Gradient")); + // The window keeps its tab while one is pending. + ProjectWorkspace workspace; + QVERIFY(workspace.canSwitch()); + workspace.current().session.createDocument(8, 8, true); + workspace.current().session.selectTool(NavigationTool::gradient); + workspace.current().session.beginGradient(QPointF(1, 1)); + workspace.current().session.moveGradient(std::nullopt, QPointF(6, 1)); + QVERIFY(!workspace.canSwitch()); + workspace.current().session.cancelGradient(); + QVERIFY(workspace.canSwitch()); +} + +void GradientTests::invertAndFiltersApplyItFirst() +{ + const auto session = makeSession(); + // Invert needs pixels: a first gradient lands, a second waits. + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(commit(*session)); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + const int count = session->history.undoCount(); + bool inverted = false; + session->invertPixels([&] { inverted = true; }); + QTRY_VERIFY(inverted); + QCOMPARE(session->history.undoCount(), count + 2); + QVERIFY(!session->gradientEdit()); + // Inverted black to white: the start is white now. + QVERIFY(near(exported(*session, 0, 2), {255, 255, 255, 255})); + DocumentSelection selection; + selection.path.addRect(QRectF(10, 0, 20, 4)); + session->setSelection(selection, QStringLiteral("Marquee")); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + QVERIFY(session->gradientEdit() && session->canContentAwareFill()); + session->beginFilter(FilterKind::contentAwareFill); + QTRY_VERIFY(session->filterEdit().has_value()); + QVERIFY(!session->gradientEdit()); + session->cancelFilter(); +} + +void GradientTests::digitsAndTabSetOpacityAndShape() +{ + const auto session = makeSession(); + QVERIFY(session->usesOpacityKeys()); + session->typeOpacityDigit(4, 0.0); + session->typeOpacityDigit(5, 0.1); + QCOMPARE(session->gradientSettings().opacity, 0.45); + QCOMPARE(session->brushSettings().opacity, 1.0); + setting(*session, [](GradientSettings &settings) { + settings.style = GradientStyle::foregroundToTransparent; + settings.reversed = true; + }); + session->cycleToolMode(); + QCOMPARE(session->gradientSettings(), (GradientSettings{GradientShape::radial, GradientStyle::foregroundToTransparent, true, 0.45})); + session->cycleToolMode(); + QCOMPARE(session->gradientSettings().shape, GradientShape::linear); + QCOMPARE(rawValue(GradientShape::linear), QString("Linear")); + QCOMPARE(rawValue(GradientShape::radial), QString("Radial")); + QCOMPARE(rawValue(GradientStyle::foregroundToBackground), QString("Foreground to Background")); + QCOMPARE(rawValue(GradientStyle::foregroundToTransparent), QString("Foreground to Transparent")); + // Settings redraw the pending preview. + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + for (const auto &change : std::initializer_list<std::function<void()>>{ + [&] { session->setBackgroundColor(PaletteColor{0, 0, 1}); }, [&] { session->setMaskPaintWhite(true); }, + [&] { session->setBrushSettings(brush(12, 1, 0, 1, 0)); }, [&] { setting(*session, [](GradientSettings &s) { s.reversed = true; }); }}) { + const int revision = session->brushRevision(); + change(); + QVERIFY(session->brushRevision() > revision); + } +} + +void GradientTests::thePaletteIsTheBrushsColourAndABackground() +{ + EditorSession session; + session.createDocument(20, 10, true); + QCOMPARE(session.foregroundColor(), PaletteColor::black()); + QCOMPARE(session.backgroundColor(), PaletteColor::white()); + session.setPaletteColor(PaletteColor{0.2, 0.4, 0.6}, false); + QCOMPARE(session.brushSettings().red, 0.2); + QCOMPARE(session.brushSettings().blue, 0.6); + session.setPaletteColor(PaletteColor{1, 0, 0}, true); + QCOMPARE(session.paletteColor(true), (PaletteColor{1, 0, 0})); + session.swapPaletteColors(); + QCOMPARE(session.foregroundColor(), (PaletteColor{1, 0, 0})); + QCOMPARE(session.backgroundColor(), (PaletteColor{0.2, 0.4, 0.6})); + // Fill takes the palette's colour. + DocumentSelection selection; + selection.path.addRect(QRectF(0, 0, 10, 10)); + session.setSelection(selection, QStringLiteral("Marquee")); + bool filled = false; + session.fillSelection(EditorSession::FillSource::foreground, [&] { filled = true; }); + QTRY_VERIFY(filled); + QCOMPARE(exported(session, 5, 5), (std::vector<int>{255, 0, 0, 255})); + session.resetPaletteColors(); + QCOMPARE(session.foregroundColor(), PaletteColor::black()); + QCOMPARE(session.backgroundColor(), PaletteColor::white()); + // Busy, the palette stays. + session.setIsProjectBusy(true); + QVERIFY(!session.canEditPalette()); + session.setPaletteColor(PaletteColor::white(), false); + session.swapPaletteColors(); + QCOMPARE(session.foregroundColor(), PaletteColor::black()); + session.setIsProjectBusy(false); +} + +void GradientTests::aMasksPaletteIsItsPaint() +{ + EditorSession session; + session.createDocument(20, 10, true); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + QCOMPARE(session.paletteColor(false), PaletteColor::black()); + QCOMPARE(session.paletteColor(true), PaletteColor::white()); + session.setPaletteColor(PaletteColor::white(), false); + QVERIFY(session.maskPaintWhite()); + session.setPaletteColor(PaletteColor::white(), true); + QVERIFY(!session.maskPaintWhite()); + session.swapPaletteColors(); + QVERIFY(session.maskPaintWhite()); + session.resetPaletteColors(); + QVERIFY(!session.maskPaintWhite()); + // A mask's gradient colours are gray. + const std::array<QColor, 2> colors = session.gradientColors(true); + QCOMPARE(colors[0], QColor::fromRgbF(0, 0, 0, 1)); + QCOMPARE(colors[1].alphaF(), 0.0f); +} + +void GradientTests::aPendingGradientKeepsItsTarget() +{ + const auto session = makeSession(); + // Another tool draws no gradient. + session->selectTool(NavigationTool::brush); + session->beginGradient(QPointF(1, 1)); + QVERIFY(!session->gradientEdit()); + session->selectTool(NavigationTool::gradient); + const QUuid first = session->activeLayerID().value(); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + const BrushStroke *raster = session->gradientEdit().value().raster.get(); + // Same target: a click restarts, the old pixels still showing. + const std::vector<BrushPatch> shown = raster->patches(); + session->beginGradient(QPointF(40, 2)); + QCOMPARE(session->gradientEdit().value().start, QPointF(40, 2)); + QCOMPARE(session->gradientEdit().value().end, QPointF(40, 2)); + QCOMPARE(raster->patches().front().image, shown.front().image); + session->endGradientDrag(); + QVERIFY(!session->gradientEdit()); + // Until a switch lands, other targets start no line. + session->addBlankLayer(); + session->selectTool(NavigationTool::gradient); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + const BrushStroke *second = session->gradientEdit().value().raster.get(); + session->selectLayer(first); + session->beginGradient(QPointF(10, 1)); + QCOMPARE(session->gradientEdit().value().raster.get(), second); + QCOMPARE(session->gradientEdit().value().start, QPointF(0.5, 2)); + QTRY_VERIFY(!session->gradientEdit()); + session->addLayerMask(true); + session->selectLayerTarget(first, false); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + const BrushStroke *pixels = session->gradientEdit().value().raster.get(); + session->selectLayerTarget(first, true); + session->beginGradient(QPointF(10, 1)); + QCOMPARE(session->gradientEdit().value().raster.get(), pixels); + QCOMPARE(session->gradientEdit().value().start, QPointF(0.5, 2)); + QVERIFY(!pixels->isMask); + QTRY_VERIFY(!session->gradientEdit()); +} + +void GradientTests::revisionsBusyAndLinelessCommits() +{ + const auto session = makeSession(); + int revision = session->brushRevision(); + session->beginGradient(QPointF(1, 1)); + QVERIFY(session->brushRevision() > revision); + // A line not yet drawn commits nothing and ends. + const int count = session->history.undoCount(); + QVERIFY(commit(*session)); + QVERIFY(!session->gradientEdit()); + QCOMPARE(session->history.undoCount(), count); + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + revision = session->brushRevision(); + session->cancelGradient(); + QVERIFY(session->brushRevision() > revision); + // Busy, the commit waits: the gradient stays pending. + drag(*session, QPointF(0.5, 2), QPointF(100.5, 2)); + session->setIsProjectBusy(true); + QVERIFY(commit(*session)); + QVERIFY(session->gradientEdit()); + session->setIsProjectBusy(false); + QCOMPARE(session->history.undoCount(), count); +} + +QTEST_GUILESS_MAIN(GradientTests) +#include "GradientTests.moc" diff --git a/tests/GroupSessionTests.cpp b/tests/GroupSessionTests.cpp new file mode 100644 index 0000000..1f012cf --- /dev/null +++ b/tests/GroupSessionTests.cpp @@ -0,0 +1,253 @@ +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include <QTemporaryDir> +#include <QtTest> + +// The session cases of Swift's GroupTests. +namespace { +std::vector<QUuid> rowIDs(const EditorSession &session) +{ + std::vector<QUuid> result; + for (const LayerHierarchy::Entry &row : session.layerRows()) + result.push_back(row.layer.id); + return result; +} + +std::vector<QUuid> renderIDs(const EditorSession &session) +{ + std::vector<QUuid> result; + for (const ImageLayer &layer : session.document().value().renderLayers()) + result.push_back(layer.id); + return result; +} + +std::optional<QUuid> parentOf(const EditorSession &session, QUuid id) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == id) + return layer.parentID; + } + throw std::runtime_error("no such layer"); +} +} + +class GroupSessionTests : public QObject { + Q_OBJECT +private slots: + void nestedGroupsMoveOutCollapseAndDeleteUndo(); + void layersArePlacedAboveBelowAndInsideFolders(); + void placingRespectsTheNestingLimit(); + void hiddenParentOverridesChildrenAndExportOrderFollowsGroups(); + void groupsRoundTripAndSurviveImageAndCanvasResize(); + void rowsOfASessionWithoutADocument(); +}; + +void GroupSessionTests::nestedGroupsMoveOutCollapseAndDeleteUndo() +{ + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid outer = session.activeLayerID().value(); + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(inner)); + QVERIFY(!session.placeLayer(outer, inner)); + QVERIFY(!session.placeLayer(inner, inner)); + // The panel asks before a drop; validation never sees these. + QVERIFY(!session.canPlaceLayer(outer, inner)); + QVERIFY(!session.canPlaceLayer(inner, inner)); + QVERIFY(!session.canPlaceLayer(outer, child)); + QVERIFY(!session.canPlaceLayer(outer, QUuid::createUuid())); + QVERIFY(session.canPlaceLayer(child, outer)); + session.toggleGroupExpansion(outer); + QCOMPARE(rowIDs(session), std::vector<QUuid>{outer}); + QCOMPARE(session.activeLayerID(), std::optional(outer)); + session.toggleGroupExpansion(outer); + std::vector<int> depths; + for (const LayerHierarchy::Entry &row : session.layerRows()) + depths.push_back(row.depth); + QCOMPARE(depths, (std::vector<int>{0, 1, 2})); + session.selectLayer(child); + session.moveActiveLayerOutOfGroup(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(outer)); + QCOMPARE(session.history.undoName(), QString("Move Layer")); + session.undo(); + QCOMPARE(parentOf(session, child), std::optional(inner)); + session.selectLayer(outer); + session.deleteActiveLayer(); + QVERIFY(session.document().value().layers.empty()); + session.undo(); + QCOMPARE(int(session.document().value().layers.size()), 3); + QCOMPARE(parentOf(session, child), std::optional(inner)); +} + +void GroupSessionTests::layersArePlacedAboveBelowAndInsideFolders() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid bottom = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid middle = session.activeLayerID().value(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid inside = session.activeLayerID().value(); + // To the top of the root, then to its bottom. + QVERIFY(session.placeLayer(bottom, std::nullopt)); + QCOMPARE(session.document().value().layers.back().id, bottom); + QCOMPARE(session.activeLayerID(), std::optional(bottom)); + QVERIFY(session.placeLayer(bottom, std::nullopt, std::nullopt, true)); + QCOMPARE(session.document().value().layers.front().id, bottom); + // Just above a sibling; the target shares the new parent. + QVERIFY(session.placeLayer(bottom, std::nullopt, middle)); + QCOMPARE(rowIDs(session), (std::vector<QUuid>{folder, inside, bottom, middle})); + QVERIFY(!session.placeLayer(bottom, std::nullopt, inside)); + // A target beats the bottom. + QVERIFY(session.placeLayer(bottom, std::nullopt, middle, true)); + QCOMPARE(rowIDs(session), (std::vector<QUuid>{folder, inside, bottom, middle})); + QVERIFY(!session.placeLayer(bottom, std::nullopt, bottom)); + QVERIFY(!session.placeLayer(bottom, std::nullopt, QUuid::createUuid())); + // Into a folded folder, which opens, above a layer inside. + session.selectLayer(middle); + session.toggleGroupExpansion(folder); + QVERIFY(session.placeLayer(bottom, folder, inside)); + QVERIFY(!session.collapsedGroupIDs().contains(folder)); + QCOMPARE(parentOf(session, bottom), std::optional(folder)); + QCOMPARE(rowIDs(session), (std::vector<QUuid>{folder, bottom, inside, middle})); + // Only folders take layers; strangers and busy sessions move none. + QVERIFY(!session.placeLayer(bottom, middle)); + QVERIFY(!session.canPlaceLayer(bottom, middle)); + QVERIFY(!session.placeLayer(QUuid::createUuid(), std::nullopt)); + QVERIFY(!session.placeLayer(bottom, QUuid::createUuid())); + QVERIFY(session.canPlaceLayer(bottom, std::nullopt) && session.canPlaceLayer(bottom, folder)); + session.setIsImporting(true); + QVERIFY(!session.canPlaceLayer(bottom, std::nullopt)); + QVERIFY(!session.placeLayer(bottom, std::nullopt)); + // Outside a folder there is no group to leave. + session.setIsImporting(false); + session.selectLayer(middle); + const int count = session.history.undoCount(); + session.moveActiveLayerOutOfGroup(); + QCOMPARE(session.history.undoCount(), count); + // A layer leaves its folder to sit just above it. + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid top = session.activeLayerID().value(); + session.selectLayer(inside); + session.moveActiveLayerOutOfGroup(); + QCOMPARE(rowIDs(session), (std::vector<QUuid>{top, inside, folder, bottom, middle})); +} + +void GroupSessionTests::placingRespectsTheNestingLimit() +{ + EditorSession session; + session.createDocument(8, 8); + // Each new folder goes inside the active one: 64 deep. + for (int depth = 0; depth < 64; ++depth) + session.addGroup(); + const QUuid deepest = session.activeLayerID().value(); + QCOMPARE(int(session.document().value().layers.size()), 64); + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid leaf = session.activeLayerID().value(); + const std::optional<CanvasDocument> before = session.document(); + // A folder may go no deeper; a layer still may. + QVERIFY(session.canPlaceLayer(folder, deepest)); + QVERIFY(!session.placeLayer(folder, deepest)); + QCOMPARE(session.document(), before); + QVERIFY(session.placeLayer(leaf, deepest)); + QCOMPARE(parentOf(session, leaf), std::optional(deepest)); +} + +void GroupSessionTests::hiddenParentOverridesChildrenAndExportOrderFollowsGroups() +{ + EditorSession session; + session.createDocument(64, 32); + session.addGroup(); + const QUuid group = session.activeLayerID().value(); + QTemporaryDir folder; + QImage fixture(64, 32, QImage::Format_RGBA8888); + fixture.fill(Qt::red); + QVERIFY(fixture.save(folder.filePath("fixture.png"), "PNG")); + bool done = false; + session.importImages({QUrl::fromLocalFile(folder.filePath("fixture.png"))}, std::nullopt, [&] { done = true; }); + QVERIFY(QTest::qWaitFor([&] { return done; }, 10'000)); + const QUuid child = session.activeLayerID().value(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(group)); + const ImageIdentity source = session.activeLayer().value().asset.value().identity(); + session.toggleLayerVisibility(group); + QVERIFY(session.activeLayer().value().isVisible); + QVERIFY(session.document().value().renderLayers().empty()); + QVERIFY(!session.canTransform()); + // A hidden folder exports nothing of what it holds. + const QImage hiddenExport = QImage::fromData(ImageExporter::pngData(session.projectSnapshot().value()), "PNG"); + QCOMPARE(hiddenExport.size(), QSize(64, 32)); + QCOMPARE(hiddenExport.pixelColor(0, 0).alpha(), 0); + session.toggleLayerVisibility(group); + QCOMPARE(renderIDs(session), std::vector<QUuid>{child}); + QCOMPARE(QImage::fromData(ImageExporter::pngData(session.projectSnapshot().value()), "PNG").pixelColor(0, 0), QColor(255, 0, 0)); + QVERIFY(session.activeLayer().value().asset.value().identity() == source); + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid other = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid otherChild = session.activeLayerID().value(); + QCOMPARE(renderIDs(session), (std::vector<QUuid>{child, otherChild})); + session.selectLayer(other); + session.moveActiveLayer(-1); + QCOMPARE(renderIDs(session), (std::vector<QUuid>{otherChild, child})); + QCOMPARE(parentOf(session, otherChild), std::optional(other)); +} + +void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize() +{ + EditorSession session; + session.createDocument(20, 10); + session.addGroup(); + const QUuid group = session.activeLayerID().value(); + session.renameLayer(group, "Artwork"); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + QCOMPARE(snapshot.manifest.version, qint64(7)); + const auto record = [](const ProjectSnapshot &from, QUuid id) { + for (const ProjectLayerRecord &layer : from.manifest.layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such record"); + }; + QTemporaryDir folder; + const QString path = folder.filePath("Groups.comp"); + ProjectStore::save(snapshot, path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QCOMPARE(record(loaded, group).isGroup, std::optional(true)); + QCOMPARE(record(loaded, group).name, QString("Artwork")); + QCOMPARE(record(loaded, child).parentID, std::optional(group)); + const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 40, .height = 20, .resolution = 72}); + const ProjectSnapshot cropped = CanvasResizer::resize(resized, {.width = 30, .height = 15}); + QCOMPARE(record(cropped, child).parentID, std::optional(group)); + QCOMPARE(record(cropped, group).isGroup, std::optional(true)); + // A version 1 project keeps its version through the store. + ProjectSnapshot legacy{.manifest = {.documentID = QUuid::createUuid(), .width = 20, .height = 10, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + legacy.manifest.version = 1; + ProjectStore::save(legacy, path); + QCOMPARE(ProjectStore::load(path).manifest.version, qint64(1)); +} + +void GroupSessionTests::rowsOfASessionWithoutADocument() +{ + QVERIFY(EditorSession().layerRows().empty()); +} + +QTEST_GUILESS_MAIN(GroupSessionTests) +#include "GroupSessionTests.moc" diff --git a/tests/GroupTests.cpp b/tests/GroupTests.cpp new file mode 100644 index 0000000..7eb6764 --- /dev/null +++ b/tests/GroupTests.cpp @@ -0,0 +1,283 @@ +#include "Document/EditorSession.h" +#include <QtTest> + +namespace { +const LayerTransform transform{.origin = {0, 0}, .size = {10, 10}}; + +ProjectLayerRecord record(const QString &name, std::optional<QUuid> parent = std::nullopt, bool isGroup = false, + bool isVisible = true) +{ + return {.id = QUuid::createUuid(), .name = name, .isVisible = isVisible, .transform = transform, + .imageFile = isGroup ? std::nullopt : std::optional<QString>(name + ".png"), + .parentID = parent, .isGroup = isGroup, .opacity = std::nullopt, .blendMode = std::nullopt}; +} + +QStringList names(const std::vector<LayerHierarchy::Entry> &entries) +{ + QStringList result; + for (const LayerHierarchy::Entry &entry : entries) + result << QString("%1:%2:%3").arg(entry.layer.name).arg(entry.depth).arg(entry.visible ? "shown" : "hidden"); + return result; +} + +bool isInvalid(const std::vector<ProjectLayerRecord> &layers) +{ + try { + LayerHierarchy::validate(layers); + } catch (const ProjectError &error) { + return error.kind == ProjectError::Kind::invalid; + } + return false; +} + +// A chain of nested folders, outermost first. +std::vector<ProjectLayerRecord> chain(int count) +{ + std::vector<ProjectLayerRecord> layers; + for (int index = 0; index < count; ++index) + layers.push_back(record(QString::number(index), index == 0 ? std::nullopt : std::optional(layers.back().id), true)); + return layers; +} +} + +class GroupTests : public QObject { + Q_OBJECT +private slots: + void malformedParentLinksAndCyclesAreRejected(); + void duplicateIdsAndFoldersWithPixelsAreRejected(); + void nestingStopsAtSixtyFourLevels(); + void entriesWalkFoldersBottomToTop(); + void topFirstReversesEveryLevel(); + void collapsedFoldersHideTheirContents(); + void hiddenParentOverridesChildren(); + void anAbsentFolderFlagMeansALeaf(); + void entriesStopBelowSixtyFourLevels(); + void documentHierarchyFollowsItsLayers(); + void hierarchyRecordNamesFilesLikeTheMacApp(); + void projectErrorsCarryTheirMessages(); +}; + +void GroupTests::malformedParentLinksAndCyclesAreRejected() +{ + ProjectLayerRecord group = record("Group", std::nullopt, true); + ProjectLayerRecord nested = record("Nested", group.id, true); + LayerHierarchy::validate({group, nested}); + group.parentID = nested.id; + QVERIFY(isInvalid({group, nested})); + QVERIFY(isInvalid({group})); + ProjectLayerRecord rasterParent = group; + rasterParent.parentID = std::nullopt; + rasterParent.isGroup = false; + QVERIFY(isInvalid({rasterParent, nested})); + rasterParent.isGroup = std::nullopt; + QVERIFY(isInvalid({rasterParent, nested})); + ProjectLayerRecord selfParent = record("Loop", std::nullopt, true); + selfParent.parentID = selfParent.id; + QVERIFY(isInvalid({selfParent})); +} + +void GroupTests::duplicateIdsAndFoldersWithPixelsAreRejected() +{ + const ProjectLayerRecord layer = record("Layer"); + LayerHierarchy::validate({layer, record("Other")}); + QVERIFY(isInvalid({layer, layer})); + ProjectLayerRecord folder = record("Folder", std::nullopt, true); + LayerHierarchy::validate({folder}); + folder.imageFile = "Folder.png"; + QVERIFY(isInvalid({folder})); +} + +void GroupTests::nestingStopsAtSixtyFourLevels() +{ + const std::vector<ProjectLayerRecord> folders = chain(64); + LayerHierarchy::validate(folders); + std::vector<ProjectLayerRecord> withLeaf = folders; + withLeaf.push_back(record("Leaf", folders[63].id)); + LayerHierarchy::validate(withLeaf); + std::vector<ProjectLayerRecord> withFolder = folders; + withFolder.push_back(record("Folder", folders[63].id, true)); + QVERIFY(isInvalid(withFolder)); +} + +void GroupTests::entriesWalkFoldersBottomToTop() +{ + const ProjectLayerRecord bottom = record("Bottom"); + const ProjectLayerRecord folder = record("Folder", std::nullopt, true); + const ProjectLayerRecord inner = record("Inner", folder.id, true); + const ProjectLayerRecord deep = record("Deep", inner.id); + const ProjectLayerRecord child = record("Child", folder.id); + const ProjectLayerRecord top = record("Top"); + const auto entries = LayerHierarchy::entries({bottom, deep, folder, child, inner, top}); + QCOMPARE(names(entries), QStringList({"Bottom:0:shown", "Folder:0:shown", "Child:1:shown", "Inner:1:shown", + "Deep:2:shown", "Top:0:shown"})); + QCOMPARE(entries[4].layer.id, deep.id); + const ProjectLayerRecord raster = record("Raster"); + QCOMPARE(names(LayerHierarchy::entries({raster, record("Stray", raster.id)})), QStringList({"Raster:0:shown"})); +} + +void GroupTests::topFirstReversesEveryLevel() +{ + const ProjectLayerRecord bottom = record("Bottom"); + const ProjectLayerRecord folder = record("Folder", std::nullopt, true); + const ProjectLayerRecord low = record("Low", folder.id); + const ProjectLayerRecord high = record("High", folder.id); + const ProjectLayerRecord top = record("Top"); + QCOMPARE(names(LayerHierarchy::entries({bottom, folder, low, high, top}, true)), + QStringList({"Top:0:shown", "Folder:0:shown", "High:1:shown", "Low:1:shown", "Bottom:0:shown"})); +} + +void GroupTests::collapsedFoldersHideTheirContents() +{ + const ProjectLayerRecord folder = record("Folder", std::nullopt, true); + const ProjectLayerRecord child = record("Child", folder.id); + const ProjectLayerRecord other = record("Other", std::nullopt, true); + const ProjectLayerRecord kept = record("Kept", other.id); + QCOMPARE(names(LayerHierarchy::entries({folder, child, other, kept}, false, {folder.id})), + QStringList({"Folder:0:shown", "Other:0:shown", "Kept:1:shown"})); + + const ProjectLayerRecord outer = record("Outer", std::nullopt, true); + const ProjectLayerRecord inner = record("Inner", outer.id, true); + const ProjectLayerRecord deep = record("Deep", inner.id); + const ProjectLayerRecord sibling = record("Sibling", outer.id); + const ProjectLayerRecord empty = record("Empty", std::nullopt, true); + QCOMPARE(names(LayerHierarchy::entries({outer, inner, deep, sibling, empty}, false, {inner.id})), + QStringList({"Outer:0:shown", "Inner:1:shown", "Sibling:1:shown", "Empty:0:shown"})); + QCOMPARE(names(LayerHierarchy::entries({outer, inner, deep, sibling, empty})), + QStringList({"Outer:0:shown", "Inner:1:shown", "Deep:2:shown", "Sibling:1:shown", "Empty:0:shown"})); +} + +void GroupTests::hiddenParentOverridesChildren() +{ + const ProjectLayerRecord folder = record("Folder", std::nullopt, true, false); + const ProjectLayerRecord inner = record("Inner", folder.id, true); + const ProjectLayerRecord deep = record("Deep", inner.id); + const ProjectLayerRecord hidden = record("Hidden", std::nullopt, false, false); + const ProjectLayerRecord shown = record("Shown"); + const std::vector<ProjectLayerRecord> layers{folder, inner, deep, hidden, shown}; + const auto entries = LayerHierarchy::entries(layers); + QCOMPARE(names(entries), QStringList({"Folder:0:hidden", "Inner:1:hidden", "Deep:2:hidden", "Hidden:0:hidden", + "Shown:0:shown"})); + QVERIFY(entries[2].layer.isVisible); + const auto visible = LayerHierarchy::visibleLayers(layers); + QCOMPARE(int(visible.size()), 1); + QCOMPARE(visible[0].id, shown.id); + const ProjectLayerRecord low = record("Low"); + const ProjectLayerRecord openFolder = record("Open", std::nullopt, true); + const ProjectLayerRecord inside = record("Inside", openFolder.id); + const ProjectLayerRecord high = record("High"); + const auto ordered = LayerHierarchy::visibleLayers({low, openFolder, inside, high}); + QCOMPARE(int(ordered.size()), 3); + QCOMPARE(ordered[0].id, low.id); + QCOMPARE(ordered[1].id, inside.id); + QCOMPARE(ordered[2].id, high.id); +} + +void GroupTests::anAbsentFolderFlagMeansALeaf() +{ + ProjectLayerRecord legacy = record("Legacy"); + legacy.isGroup = std::nullopt; + const ProjectLayerRecord stray = record("Stray", legacy.id); + QCOMPARE(names(LayerHierarchy::entries({legacy, stray})), QStringList({"Legacy:0:shown"})); + const auto visible = LayerHierarchy::visibleLayers({legacy}); + QCOMPARE(int(visible.size()), 1); + QCOMPARE(visible[0].id, legacy.id); + + QVERIFY(legacy.imageFile.has_value()); + LayerHierarchy::validate({legacy}); + std::vector<ProjectLayerRecord> deepest = chain(64); + ProjectLayerRecord leaf = record("Leaf", deepest[63].id); + leaf.isGroup = std::nullopt; + deepest.push_back(leaf); + LayerHierarchy::validate(deepest); +} + +void GroupTests::entriesStopBelowSixtyFourLevels() +{ + std::vector<ProjectLayerRecord> layers = chain(70); + const auto entries = LayerHierarchy::entries(layers); + QCOMPARE(int(entries.size()), 65); + QCOMPARE(entries.back().depth, 64); +} + +void GroupTests::documentHierarchyFollowsItsLayers() +{ + CanvasDocument document(100, 100); + ImageLayer folder("Folder", document.size()); + folder.isGroup = true; + folder.isVisible = false; + ImageLayer child(ImportedImage(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Child"), QPointF(0, 0)); + child.parentID = folder.id; + ImageLayer open("Open", document.size()); + open.isGroup = true; + ImageLayer shown("Shown", document.size()); + shown.parentID = open.id; + ImageLayer top("Top", document.size()); + document.layers = {folder, child, open, shown, top}; + QCOMPARE(names(document.hierarchyEntries()), + QStringList({"Folder:0:hidden", "Child:1:hidden", "Open:0:shown", "Shown:1:shown", "Top:0:shown"})); + QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>({open.id, shown.id, top.id})); + const std::vector<ImageLayer> rendered = document.renderLayers(); + QCOMPARE(int(rendered.size()), 2); + QCOMPARE(rendered[0], shown); + QCOMPARE(rendered[1], top); + + ImageLayer twin("Twin", document.size()); + twin.id = top.id; + document.layers.push_back(twin); + QVERIFY_THROWS_EXCEPTION(std::logic_error, document.renderLayers()); +} + +void GroupTests::hierarchyRecordNamesFilesLikeTheMacApp() +{ + ImageLayer layer(ImportedImage(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Photo"), QPointF(3, 4)); + layer.id = QUuid("e621e1f8-c36c-495a-93fc-0c247a3e6e5f"); + layer.parentID = QUuid::createUuid(); + layer.isVisible = false; + layer.opacity = 0.25; + layer.blendMode = LayerBlendMode::screen; + const ProjectLayerRecord record = layer.hierarchyRecord(); + QCOMPARE(record.id, layer.id); + QCOMPARE(record.name, QString("Photo")); + QCOMPARE(record.isVisible, false); + QCOMPARE(record.transform, layer.transform); + QCOMPARE(record.imageFile, std::optional<QString>("E621E1F8-C36C-495A-93FC-0C247A3E6E5F.png")); + QCOMPARE(record.parentID, layer.parentID); + QCOMPARE(record.isGroup, std::optional<bool>(false)); + QCOMPARE(record.opacity, std::optional<double>(0.25)); + QCOMPARE(record.blendMode, std::optional<LayerBlendMode>(LayerBlendMode::screen)); + ImageLayer folder("Folder", QSizeF(10, 10)); + folder.isGroup = true; + QCOMPARE(folder.hierarchyRecord().imageFile, std::nullopt); + QCOMPARE(folder.hierarchyRecord().isGroup, std::optional<bool>(true)); +} + +void GroupTests::projectErrorsCarryTheirMessages() +{ + const ProjectError invalid(ProjectError::Kind::invalid); + QCOMPARE(invalid.kind, ProjectError::Kind::invalid); + QCOMPARE(invalid.version, std::nullopt); + QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged.")); + + const ProjectError version = ProjectError::unsupportedVersion(9); + QCOMPARE(version.kind, ProjectError::Kind::version); + QCOMPARE(version.version, std::optional<int>(9)); + QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–7.")); + QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version)); + + const ProjectError missingImage(ProjectError::Kind::missingImage); + QCOMPARE(missingImage.kind, ProjectError::Kind::missingImage); + QCOMPARE(QString(missingImage.what()), + QString("An image inside the project is missing or damaged. The current document has not been replaced.")); + + const ProjectError tooLarge(ProjectError::Kind::tooLarge); + QCOMPARE(tooLarge.kind, ProjectError::Kind::tooLarge); + QCOMPARE(QString(tooLarge.what()), + QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit.")); + + const ProjectError encode(ProjectError::Kind::encode); + QCOMPARE(encode.kind, ProjectError::Kind::encode); + QCOMPARE(QString(encode.what()), QString("An image could not be saved. The previous project has not been replaced.")); +} + +QTEST_APPLESS_MAIN(GroupTests) +#include "GroupTests.moc" diff --git a/tests/GroupingSelectionTests.cpp b/tests/GroupingSelectionTests.cpp new file mode 100644 index 0000000..9fde6fe --- /dev/null +++ b/tests/GroupingSelectionTests.cpp @@ -0,0 +1,394 @@ +#include "Document/EditorSession.h" +#include <QtTest> + +namespace { +ImageLayer layerWith(const EditorSession &session, QUuid id) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such layer"); +} + +ImportedImage asset(int width, int height, const QString &name) +{ + return ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), name); +} + +QStringList names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result; +} +} + +class GroupingSelectionTests : public QObject { + Q_OBJECT +private slots: + void singleLayerAndFolderAreWrappedRatherThanCreatingAChildFolder(); + void multipleSelectionPreservesOrderAndSelectedFolderDescendants(); + void itemsFromDifferentFoldersUseCommonParentAndEmptySelectionCreatesEmptyGroup(); + void nestedSelectionsShareAnAncestorAboveTheirParents(); + void wrappedLayersKeepTheirStackingOrder(); + void theWrapperSitsAtTheTopmostSelectedBranch(); + void selectionKeepsOnlyLayersTheDocumentHolds(); + void extendingTheSelectionAddsAndTakesAway(); + void severalSelectedLayersDeleteAsOneStep(); + void aSelectionTheDocumentLacksMakesAnEmptyFolder(); + void deletingAListOfOneOrNone(); + void groupingIsRefusedAtItsLimits(); +}; + +void GroupingSelectionTests::singleLayerAndFolderAreWrappedRatherThanCreatingAChildFolder() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid layer = session.activeLayerID().value(); + session.groupSelectedLayers(); + const QUuid inner = session.activeLayerID().value(); + QCOMPARE(layerWith(session, layer).parentID, std::optional(inner)); + QCOMPARE(session.history.undoName(), QString("Group Layers")); + session.groupSelectedLayers(); + const QUuid outer = session.activeLayerID().value(); + QCOMPARE(layerWith(session, inner).parentID, std::optional(outer)); + QCOMPARE(layerWith(session, layer).parentID, std::optional(inner)); + QCOMPARE(session.activeLayer().value().parentID, std::nullopt); + QVERIFY(session.activeLayer().value().isGroup); + // What stays keeps its place; what is wrapped goes last. + QCOMPARE(names(session), (QStringList{"Folder 2", "Layer 1", "Folder 1"})); + session.undo(); + QCOMPARE(layerWith(session, inner).parentID, std::nullopt); + QCOMPARE(int(session.document().value().layers.size()), 2); +} + +void GroupingSelectionTests::multipleSelectionPreservesOrderAndSelectedFolderDescendants() +{ + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid sibling = session.activeLayerID().value(); + const std::optional<CanvasDocument> before = session.document(); + session.selectLayers({folder, child, sibling}, sibling); + QCOMPARE(int(session.selectedLayerIDs().size()), 3); + QCOMPARE(session.activeLayerID(), std::optional(sibling)); + QVERIFY(!session.canTransform()); + session.groupSelectedLayers(); + const QUuid wrapper = session.activeLayerID().value(); + QCOMPARE(layerWith(session, folder).parentID, std::optional(wrapper)); + QCOMPARE(layerWith(session, sibling).parentID, std::optional(wrapper)); + QCOMPARE(layerWith(session, child).parentID, std::optional(folder)); + std::vector<QUuid> rendered; + for (const ImageLayer &layer : session.document().value().renderLayers()) + rendered.push_back(layer.id); + QCOMPARE(rendered, (std::vector<QUuid>{child, sibling})); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{wrapper}); + session.undo(); + QCOMPARE(session.document(), before); + session.redo(); + QCOMPARE(int(session.document().value().layers.size()), 4); +} + +void GroupingSelectionTests::itemsFromDifferentFoldersUseCommonParentAndEmptySelectionCreatesEmptyGroup() +{ + EditorSession session; + session.createDocument(100, 100); + session.groupSelectedLayers(); + const QUuid first = session.activeLayerID().value(); + QVERIFY(session.activeLayer().value().isGroup); + session.addBlankLayer(); + const QUuid a = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.groupSelectedLayers(); + const QUuid second = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid b = session.activeLayerID().value(); + session.selectLayers({a, b}, b); + session.groupSelectedLayers(); + const QUuid group = session.activeLayerID().value(); + QCOMPARE(session.activeLayer().value().parentID, std::nullopt); + QCOMPARE(layerWith(session, a).parentID, std::optional(group)); + QCOMPARE(layerWith(session, b).parentID, std::optional(group)); + QCOMPARE(layerWith(session, first).parentID, std::nullopt); + QCOMPARE(layerWith(session, second).parentID, std::nullopt); + // Two layers of one folder are wrapped inside that folder. + session.selectLayer(first); + session.addBlankLayer(); + const QUuid c = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid d = session.activeLayerID().value(); + session.toggleGroupExpansion(first); + session.selectLayers({c, d}, d); + session.groupSelectedLayers(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(first)); + QVERIFY(!session.collapsedGroupIDs().contains(first)); + QCOMPARE(layerWith(session, c).parentID, session.activeLayerID()); + QCOMPARE(layerWith(session, d).parentID, session.activeLayerID()); +} + +void GroupingSelectionTests::nestedSelectionsShareAnAncestorAboveTheirParents() +{ + EditorSession session; + session.createDocument(100, 100); + // G holds A(C(a)), then B(b) below it. + session.addGroup(); + const QUuid g = session.activeLayerID().value(); + session.addGroup(); + const QUuid folderA = session.activeLayerID().value(); + session.addGroup(); + const QUuid folderC = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid a = session.activeLayerID().value(); + session.selectLayer(g); + session.addGroup(); + const QUuid folderB = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid b = session.activeLayerID().value(); + // b comes first: its own folder is no shared one. + QCOMPARE(names(session), (QStringList{"Folder 1", "Folder 4", "Layer 2", "Folder 2", "Folder 3", "Layer 1"})); + QCOMPARE(layerWith(session, folderB).parentID, std::optional(g)); + QCOMPARE(layerWith(session, folderA).parentID, std::optional(g)); + QCOMPARE(layerWith(session, folderC).parentID, std::optional(folderA)); + QCOMPARE(layerWith(session, a).parentID, std::optional(folderC)); + // A carries a; b and A meet in G. + session.selectLayers({folderA, a, b}, b); + session.groupSelectedLayers(); + const ImageLayer wrapper = session.activeLayer().value(); + QVERIFY(wrapper.isGroup); + QCOMPARE(wrapper.parentID, std::optional(g)); + QCOMPARE(layerWith(session, folderA).parentID, std::optional(wrapper.id)); + QCOMPARE(layerWith(session, b).parentID, std::optional(wrapper.id)); + QCOMPARE(layerWith(session, a).parentID, std::optional(folderC)); + QCOMPARE(layerWith(session, folderC).parentID, std::optional(folderA)); + QCOMPARE(layerWith(session, folderB).parentID, std::optional(g)); + // The wrapper stands where A stood: G's higher branch. + QCOMPARE(names(session), (QStringList{"Folder 1", "Folder 4", "Folder 5", "Folder 3", "Layer 1", "Layer 2", "Folder 2"})); +} + +void GroupingSelectionTests::wrappedLayersKeepTheirStackingOrder() +{ + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid above = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid inside = session.activeLayerID().value(); + // Last in the list, yet inside the folder below. + QVERIFY(session.placeLayer(inside, folder)); + QCOMPARE(names(session), (QStringList{"Folder 1", "Layer 1", "Layer 2"})); + session.selectLayers({above, inside}, above); + session.groupSelectedLayers(); + QCOMPARE(names(session), (QStringList{"Folder 1", "Folder 2", "Layer 2", "Layer 1"})); + std::vector<QUuid> rendered; + for (const ImageLayer &layer : session.document().value().renderLayers()) + rendered.push_back(layer.id); + QCOMPARE(rendered, (std::vector<QUuid>{inside, above})); +} + +void GroupingSelectionTests::theWrapperSitsAtTheTopmostSelectedBranch() +{ + EditorSession session; + session.createDocument(100, 100); + for (int index = 0; index < 4; ++index) + session.addBlankLayer(); + const std::vector<ImageLayer> layers = session.document().value().layers; + // Layers 1 and 3 are wrapped where Layer 3 stood. + session.selectLayers({layers[0].id, layers[2].id}, layers[0].id); + session.groupSelectedLayers(); + QCOMPARE(names(session), (QStringList{"Layer 2", "Folder 1", "Layer 4", "Layer 1", "Layer 3"})); + std::vector<QString> rows; + for (const LayerHierarchy::Entry &row : session.layerRows()) + rows.push_back(QString(row.depth, '>') + row.layer.name); + QCOMPARE(rows, (std::vector<QString>{"Layer 4", "Folder 1", ">Layer 3", ">Layer 1", "Layer 2"})); + // A layer deep in a folder stands for that folder. + EditorSession deep; + deep.createDocument(100, 100); + deep.addBlankLayer(); + const QUuid low = deep.activeLayerID().value(); + deep.addGroup(); + const QUuid folder = deep.activeLayerID().value(); + deep.selectLayer(std::nullopt); + deep.addBlankLayer(); + deep.addBlankLayer(); + const QUuid child = deep.activeLayerID().value(); + // Placed last in the list, yet inside the folder. + QVERIFY(deep.placeLayer(child, folder)); + QCOMPARE(names(deep), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3"})); + deep.selectLayers({low, child}, low); + deep.groupSelectedLayers(); + QCOMPARE(names(deep), (QStringList{"Folder 1", "Folder 2", "Layer 2", "Layer 1", "Layer 3"})); + QCOMPARE(deep.activeLayer().value().parentID, std::nullopt); +} + +void GroupingSelectionTests::selectionKeepsOnlyLayersTheDocumentHolds() +{ + EditorSession session; + session.selectLayers({QUuid::createUuid()}, std::nullopt); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>()); + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid first = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid second = session.activeLayerID().value(); + const QUuid stranger = QUuid::createUuid(); + session.selectLayers({first, second, stranger}, stranger); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second})); + // A primary the document lacks leaves the set to choose. + QVERIFY(session.selectedLayerIDs().contains(session.activeLayerID().value())); + session.selectLayers({first, second}, first); + QCOMPARE(session.activeLayerID(), std::optional(first)); + session.selectLayers({}, std::nullopt); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>()); + QCOMPARE(session.activeLayerID(), std::nullopt); +} + +void GroupingSelectionTests::extendingTheSelectionAddsAndTakesAway() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid first = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid second = session.activeLayerID().value(); + session.extendSelection(first); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second})); + QCOMPARE(session.activeLayerID(), std::optional(first)); + // Taking the active layer out hands its role on. + session.extendSelection(first); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{second}); + QCOMPARE(session.activeLayerID(), std::optional(second)); + // The last selected layer stays selected. + session.extendSelection(second); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{second}); + session.extendSelection(first); + session.extendSelection(second); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{first}); + QCOMPARE(session.activeLayerID(), std::optional(first)); + session.extendSelection(QUuid::createUuid()); + session.setIsImporting(true); + session.extendSelection(second); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{first}); + // Taking a third layer out leaves either active layer alone. + session.setIsImporting(false); + session.addBlankLayer(); + const QUuid third = session.activeLayerID().value(); + for (const QUuid primary : {first, second}) { + session.selectLayers({first, second, third}, primary); + session.extendSelection(third); + QCOMPARE(session.activeLayerID(), std::optional(primary)); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second})); + } +} + +void GroupingSelectionTests::severalSelectedLayersDeleteAsOneStep() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid keep = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid top = session.activeLayerID().value(); + QCOMPARE(layerWith(session, child).parentID, std::optional(folder)); + const std::optional<CanvasDocument> before = session.document(); + const int count = session.history.undoCount(); + session.selectLayers({folder, top}, top); + session.deleteSelectedLayers(); + QCOMPARE(names(session), QStringList{"Layer 1"}); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Delete Layers")); + QCOMPARE(session.activeLayerID(), std::optional(keep)); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{keep}); + session.undo(); + QCOMPARE(session.document(), before); + // One selected layer is deleted as itself. + session.selectLayers({keep}, keep); + session.deleteSelectedLayers(); + QVERIFY(names(session).indexOf("Layer 1") < 0); + QCOMPARE(session.history.undoName(), QString("Delete Layer")); + session.setIsImporting(true); + session.deleteSelectedLayers(); + QCOMPARE(int(session.document().value().layers.size()), 3); +} + +void GroupingSelectionTests::aSelectionTheDocumentLacksMakesAnEmptyFolder() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + session.addBlankLayer(); + session.selectLayer(QUuid::createUuid()); + session.groupSelectedLayers(); + // Nothing to wrap: the empty folder goes on top. + QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 2", "Folder 1"})); + QCOMPARE(session.activeLayer().value().parentID, std::nullopt); + for (const ImageLayer &layer : session.document().value().layers) + QCOMPARE(layer.parentID, std::nullopt); +} + +void GroupingSelectionTests::deletingAListOfOneOrNone() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid first = session.activeLayerID().value(); + session.addBlankLayer(); + const int count = session.history.undoCount(); + session.finishDeletingLayers({}, {}); + QCOMPARE(session.history.undoCount(), count); + QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 2"})); + session.finishDeletingLayers({first}, {}); + QCOMPARE(names(session), QStringList{"Layer 2"}); + QCOMPARE(session.history.undoName(), QString("Delete Layer")); + QCOMPARE(session.history.undoCount(), count + 1); +} + +void GroupingSelectionTests::groupingIsRefusedAtItsLimits() +{ + EditorSession session; + session.groupSelectedLayers(); + QVERIFY(!session.document().has_value()); + session.createDocument(8, 8); + session.addBlankLayer(); + // Wrapped 64 times over, a layer can go no deeper. + for (int depth = 0; depth < 70; ++depth) { + session.selectLayer(session.document().value().layers.back().id); + session.groupSelectedLayers(); + } + QCOMPARE(int(session.document().value().layers.size()), 1 + 64); + session.setIsImporting(true); + session.groupSelectedLayers(); + QCOMPARE(int(session.document().value().layers.size()), 1 + 64); + // A document holds 10,000 layers and no more. + EditorSession crowded; + crowded.createDocument(8, 8); + crowded.beginEdit("Fill"); + const ImportedImage dot = asset(1, 1, "Dot"); + for (int index = 0; index < 9'999; ++index) + crowded.insert(dot); + crowded.endEdit(); + crowded.groupSelectedLayers(); + QCOMPARE(int(crowded.document().value().layers.size()), 10'000); + QVERIFY(crowded.activeLayer().value().isGroup); + crowded.groupSelectedLayers(); + QCOMPARE(int(crowded.document().value().layers.size()), 10'000); +} + +QTEST_GUILESS_MAIN(GroupingSelectionTests) +#include "GroupingSelectionTests.moc" diff --git a/tests/GuidedMatteTests.cpp b/tests/GuidedMatteTests.cpp new file mode 100644 index 0000000..7b1de95 --- /dev/null +++ b/tests/GuidedMatteTests.cpp @@ -0,0 +1,165 @@ +#include "Document/GuidedMatte.h" +#include <QtTest> +#include <algorithm> + +// The guided filter under Remove Background's Refine. +namespace { +// Values that vary in both directions, 0 to 1. +std::vector<float> pattern(int width, int height, int seed) +{ + std::vector<float> values(size_t(width) * size_t(height)); + for (size_t index = 0; index < values.size(); ++index) + values[index] = float((index * 37 + size_t(seed) * 11) % 17) / 16; + return values; +} + +// The mean of a clamped square, summed plainly in doubles. +double mean(const std::vector<double> &source, int width, int height, int radius, int x, int y) +{ + double sum = 0; + for (int dy = -radius; dy <= radius; ++dy) { + for (int dx = -radius; dx <= radius; ++dx) + sum += source[size_t(std::clamp(y + dy, 0, height - 1)) * size_t(width) + size_t(std::clamp(x + dx, 0, width - 1))]; + } + return sum / ((2 * radius + 1) * (2 * radius + 1)); +} + +// He, Sun and Tang's steps, plainly, in doubles, clamped. +std::vector<double> reference(const std::vector<float> &mask, const std::vector<float> &guide, int width, int height, int radius, double epsilon) +{ + const size_t count = size_t(width) * size_t(height); + std::vector<double> p(mask.begin(), mask.end()), g(guide.begin(), guide.end()), squares(count), products(count), a(count), b(count), result(count); + for (size_t i = 0; i < count; ++i) { + squares[i] = g[i] * g[i]; + products[i] = g[i] * p[i]; + } + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const size_t i = size_t(y * width + x); + const double meanGuide = mean(g, width, height, radius, x, y), meanMask = mean(p, width, height, radius, x, y); + const double variance = mean(squares, width, height, radius, x, y) - meanGuide * meanGuide; + a[i] = (mean(products, width, height, radius, x, y) - meanGuide * meanMask) / (variance + epsilon); + b[i] = meanMask - a[i] * meanGuide; + } + } + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + result[size_t(y * width + x)] = std::clamp(mean(a, width, height, radius, x, y) * g[size_t(y * width + x)] + mean(b, width, height, radius, x, y), 0.0, 1.0); + } + return result; +} + +QImage gray(int width, int height, const std::function<int(int, int)> &value) +{ + QImage image(width, height, QImage::Format_Grayscale8); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.scanLine(y)[x] = uchar(value(x, y)); + } + return image; +} +} + +class GuidedMatteTests : public QObject { + Q_OBJECT +private slots: + void theBoxAveragesItsSquareWithTheEdgeRepeated(); + void theFilterIsHesArithmetic(); + void theFilterPullsASoftMaskOntoTheGuidesEdge(); + void levelsReadGrayOverBlackAndImagesRound(); + void refineWorksOnASmallerCopyAndDrawsBackUp(); +}; + +void GuidedMatteTests::theBoxAveragesItsSquareWithTheEdgeRepeated() +{ + const int width = 7, height = 5; + const std::vector<float> source = pattern(width, height, 1); + const std::vector<double> wide(source.begin(), source.end()); + for (const int radius : {1, 2, 6, 9}) { + const std::vector<float> box = GuidedMatte::box(source, width, height, radius); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + QVERIFY(std::abs(box[size_t(y * width + x)] - mean(wide, width, height, radius, x, y)) < 1e-5); + } + } +} + +void GuidedMatteTests::theFilterIsHesArithmetic() +{ + const int width = 9, height = 6; + const std::vector<float> mask = pattern(width, height, 2), guide = pattern(width, height, 5); + const std::vector<float> filtered = GuidedMatte::filter(mask, guide, width, height, 2, 1e-4f); + const std::vector<double> expected = reference(mask, guide, width, height, 2, 1e-4); + for (size_t index = 0; index < filtered.size(); ++index) + QVERIFY2(std::abs(filtered[index] - expected[index]) < 2e-3, qPrintable(QString::number(expected[index]))); + // An overshooting average is held between 0 and 1. + const std::vector<float> row{0, 0, 0, 0.5f, 1, 0, 0.5f}; + for (const std::vector<float> &cut : {std::vector<float>{0, 0, 1, 0, 0, 1, 0}, std::vector<float>{0, 0, 0, 1, 1, 0, 1}}) { + const std::vector<float> held = GuidedMatte::filter(cut, row, 7, 1, 1, 1e-4f); + const std::vector<double> clamped = reference(cut, row, 7, 1, 1, 1e-4); + QVERIFY(std::count(clamped.begin(), clamped.end(), 0.0) + std::count(clamped.begin(), clamped.end(), 1.0) > 0); + for (size_t index = 0; index < held.size(); ++index) + QVERIFY2(std::abs(held[index] - clamped[index]) < 2e-3, qPrintable(QString::number(held[index]))); + } +} + +void GuidedMatteTests::theFilterPullsASoftMaskOntoTheGuidesEdge() +{ + // The guide steps at 20; the mask ramps across twenty. + const int width = 40, height = 4; + std::vector<float> guide(size_t(width * height)), mask(size_t(width * height)); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + guide[size_t(y * width + x)] = x < 20 ? 0.2f : 0.8f; + mask[size_t(y * width + x)] = std::clamp(float(x - 10) / 20, 0.0f, 1.0f); + } + } + const std::vector<float> filtered = GuidedMatte::filter(mask, guide, width, height, 4, 1e-4f); + // At the guide's edge the mask steps five times faster. + QVERIFY(filtered[20] - filtered[19] > 0.24f && mask[20] - mask[19] < 0.051f); + // Far from it, the flat ends stay black and white. + QVERIFY(filtered[0] == 0 && filtered[39] == 1); +} + +void GuidedMatteTests::levelsReadGrayOverBlackAndImagesRound() +{ + // Gray reads exactly; colour through Qt's weights; clear as black. + const QImage steps = gray(3, 1, [](int x, int) { return x * 100; }); + QCOMPARE(GuidedMatte::levels(steps, 3, 1), (std::vector<float>{0, 100.0f / 255, 200.0f / 255})); + QImage colour(2, 1, QImage::Format_RGBA8888_Premultiplied); + colour.setPixelColor(0, 0, QColor(255, 0, 0)); + colour.setPixelColor(1, 0, QColor(0, 0, 0, 0)); + QCOMPARE(GuidedMatte::levels(colour, 2, 1), (std::vector<float>{float(qGray(255, 0, 0)) / 255, 0})); + // Shrunk, a checkerboard averages rather than aliasing. + const QImage checks = gray(4, 4, [](int x, int y) { return (x + y) % 2 ? 255 : 0; }); + for (const float level : GuidedMatte::levels(checks, 2, 2)) + QVERIFY(std::abs(level - 0.5f) <= 1.0f / 255); + // Back to bytes: rounded, and held inside 0 to 255. + const QImage bytes = GuidedMatte::image({-0.5f, 0.5f, 1.5f, 0.2f}, 4, 1); + QVERIFY(bytes.format() == QImage::Format_Grayscale8 && bytes.size() == QSize(4, 1)); + QCOMPARE(QByteArray(reinterpret_cast<const char *>(bytes.constScanLine(0)), 4), QByteArray("\x00\x80\xff\x33", 4)); +} + +void GuidedMatteTests::refineWorksOnASmallerCopyAndDrawsBackUp() +{ + const QImage mask = gray(300, 20, [](int x, int y) { return std::clamp((x - 100) * 2 + y * 3, 0, 255); }); + const QImage guide = gray(300, 20, [](int x, int y) { return x < 150 ? 40 + y : 220 - y; }); + // Within the limit it is the filter at full size. + const QImage whole = GuidedMatte::refine(mask, guide, 3, 300); + QCOMPARE(whole, GuidedMatte::image(GuidedMatte::filter(GuidedMatte::levels(mask, 300, 20), GuidedMatte::levels(guide, 300, 20), 300, 20, 3, 1e-4f), 300, 20)); + // Past it: a third the size and radius, drawn back. + const QImage small = GuidedMatte::image(GuidedMatte::filter(GuidedMatte::levels(mask, 100, 7), GuidedMatte::levels(guide, 100, 7), 100, 7, 4, 1e-4f), 100, 7); + const QImage reduced = GuidedMatte::refine(mask, guide, 12, 100); + QVERIFY(reduced.format() == QImage::Format_Grayscale8 && reduced.size() == QSize(300, 20)); + QCOMPARE(reduced, small.scaled(300, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_Grayscale8)); + // A faint guide, where epsilon decides how much counts. + const QImage faint = gray(300, 20, [](int x, int y) { return 100 + (x + y) % 3; }); + QCOMPARE(GuidedMatte::refine(mask, faint, 3, 300), + GuidedMatte::image(GuidedMatte::filter(GuidedMatte::levels(mask, 300, 20), GuidedMatte::levels(faint, 300, 20), 300, 20, 3, 1e-4f), 300, 20)); + // A radius that rounds to nothing still reaches a pixel. + QCOMPARE(GuidedMatte::refine(mask, guide, 0.2, 300), + GuidedMatte::image(GuidedMatte::filter(GuidedMatte::levels(mask, 300, 20), GuidedMatte::levels(guide, 300, 20), 300, 20, 1, 1e-4f), 300, 20)); +} + +QTEST_GUILESS_MAIN(GuidedMatteTests) +#include "GuidedMatteTests.moc" diff --git a/tests/HighDpiCanvasTests.cpp b/tests/HighDpiCanvasTests.cpp new file mode 100644 index 0000000..579eb6e --- /dev/null +++ b/tests/HighDpiCanvasTests.cpp @@ -0,0 +1,179 @@ +#include "Document/BrushStroke.h" +#include "Rendering/AdjustmentSurface.h" +#include "Rendering/EditorCanvas.h" +#include "RenderFixtures.h" +#include "UI/LevelsSheet.h" +#include <QtTest> + +// Widgets at device pixel ratio 2: two coordinate systems. +namespace { +struct Probe : QWidget { + std::function<void(QPainter &)> body; + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + if (body) + body(painter); + } +}; + +// A probe at an offset inside a window, both shown. +struct Framed { + QWidget window; + Probe child; + Framed() + { + window.resize(300, 200); + child.setParent(&window); + child.setGeometry(57, 42, 200, 100); + window.show(); + if (!QTest::qWaitForWindowExposed(&window)) + throw std::runtime_error("the window never showed"); + } +}; +} + +class HighDpiCanvasTests : public QObject { + Q_OBJECT +private slots: + void theScreenScalesByTwo(); + void theVisibleRectIsTheWidgetInItsOwnPoints(); + void theSurfaceCoversAnOffsetWidgetInDevicePixels(); + void theCanvasDrawsDevicePixels(); + void levelsEyedroppersDrawDevicePixels(); +}; + +void HighDpiCanvasTests::theScreenScalesByTwo() +{ + QWidget widget; + QCOMPARE(widget.devicePixelRatio(), 2.0); +} + +void HighDpiCanvasTests::theVisibleRectIsTheWidgetInItsOwnPoints() +{ + Framed framed; + std::vector<QRectF> seen; + framed.child.body = [&](QPainter &painter) { + seen.push_back(BrushRaster::visibleRect(painter)); + painter.translate(10, 5); + seen.push_back(BrushRaster::visibleRect(painter)); + painter.setClipRect(QRectF(10, 10, 50, 20)); + seen.push_back(BrushRaster::visibleRect(painter)); + }; + // Through a grab and through the window's own store alike. + framed.child.grab(); + framed.child.repaint(); + QCOMPARE(int(seen.size()), 6); + for (size_t pass = 0; pass < 6; pass += 3) { + QCOMPARE(seen[pass], QRectF(0, 0, 200, 100)); + QCOMPARE(seen[pass + 1], QRectF(-10, -5, 200, 100)); + QCOMPARE(seen[pass + 2], QRectF(10, 10, 50, 20)); + } +} + +void HighDpiCanvasTests::theSurfaceCoversAnOffsetWidgetInDevicePixels() +{ + Framed framed; + QSize allocated; + framed.child.body = [&](QPainter &painter) { + AdjustmentSurface::draw(painter, [&](QPainter &surface) { + allocated = QSize(surface.device()->width(), surface.device()->height()); + surface.fillRect(QRectF(0, 0, 200, 100), Qt::red); + }); + }; + QImage shot = framed.child.grab().toImage().convertToFormat(QImage::Format_ARGB32); + QCOMPARE(shot.size(), QSize(400, 200)); + QCOMPARE(allocated, QSize(400, 200)); + for (const QPoint &point : {QPoint(0, 0), QPoint(399, 0), QPoint(0, 199), QPoint(399, 199), QPoint(200, 100)}) + QCOMPARE(shot.pixel(point), qRgb(255, 0, 0)); + // Through the window the child keeps its store offset. + const QImage whole = framed.window.grab().toImage().convertToFormat(QImage::Format_ARGB32); + QCOMPARE(whole.size(), QSize(600, 400)); + for (const QPoint &point : {QPoint(114, 84), QPoint(513, 84), QPoint(114, 283), QPoint(513, 283), QPoint(314, 184)}) + QCOMPARE(whole.pixel(point), qRgb(255, 0, 0)); + QVERIFY(whole.pixel(113, 84) != qRgb(255, 0, 0)); + QVERIFY(whole.pixel(514, 283) != qRgb(255, 0, 0)); + // A clip bounds the surface, in device pixels too. + framed.child.body = [&](QPainter &painter) { + painter.fillRect(QRectF(0, 0, 200, 100), Qt::black); + painter.setClipRect(QRectF(10, 10, 50, 20)); + AdjustmentSurface::draw(painter, [&](QPainter &surface) { + allocated = QSize(surface.device()->width(), surface.device()->height()); + surface.fillRect(QRectF(0, 0, 200, 100), Qt::red); + }); + }; + shot = framed.child.grab().toImage().convertToFormat(QImage::Format_ARGB32); + QCOMPARE(allocated, QSize(100, 40)); + QCOMPARE(shot.pixel(20, 20), qRgb(255, 0, 0)); + QCOMPARE(shot.pixel(119, 59), qRgb(255, 0, 0)); + QCOMPARE(shot.pixel(10, 10), qRgb(0, 0, 0)); + QCOMPARE(shot.pixel(120, 60), qRgb(0, 0, 0)); +} + +void HighDpiCanvasTests::theCanvasDrawsDevicePixels() +{ + EditorSession session; + session.createDocument(2, 1); + QImage edge = BrushRaster::context(2, 1, false); + edge.setPixel(0, 0, qRgba(0, 0, 0, 255)); + edge.setPixel(1, 0, qRgba(255, 255, 255, 255)); + session.insert(ImportedImage(edge, edge, "Edge"), QPointF(1, 0.5)); + session.setShowsTransformControls(false); + QWidget window; + auto *canvas = new CanvasView(session, &window); + QPalette dark = canvas->palette(); + dark.setColor(QPalette::Base, QColor(0x1b, 0x1b, 0x1b)); + canvas->setPalette(dark); + window.resize(300, 200); + canvas->setGeometry(0, 0, 40, 20); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTRY_COMPARE(session.viewport.backingScale, 2.0); + QCOMPARE(session.viewport.viewSize, QSizeF(40, 20)); + session.zoom(4); + QCOMPARE(session.viewport.pointsPerPixel(), 2.0); + const QImage shot = canvas->grab().toImage().convertToFormat(QImage::Format_ARGB32); + QCOMPARE(shot.size(), QSize(80, 40)); + // The two document pixels span eight device pixels, hard-edged. + QCOMPARE(qRed(shot.pixel(37, 19)), 0); + QCOMPARE(qRed(shot.pixel(39, 19)), 0); + QCOMPARE(qRed(shot.pixel(40, 19)), 255); + QCOMPARE(qRed(shot.pixel(42, 19)), 255); + QCOMPARE(qRed(shot.pixel(2, 2)), 27); +} + +void HighDpiCanvasTests::levelsEyedroppersDrawDevicePixels() +{ + EditorSession session; + session.createDocument(2, 1); + QImage red(2, 1, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session.insert(ImportedImage(red, red, QStringLiteral("Red"))); + session.beginLevels(); + LevelsSheet sheet(session); + sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&sheet)); + // Fourteen points: the glyph spans all 28 pixels. + for (QPushButton *button : sheet.findChildren<QPushButton *>()) { + if (button->text() == QStringLiteral("Black")) { + const QImage drawn = button->icon().pixmap(QSize(14, 14)).toImage(); + QCOMPARE(drawn.size(), QSize(28, 28)); + int right = 0; + for (int y = 0; y < 28; ++y) { + for (int x = 16; x < 28; ++x) + right += qAlpha(drawn.pixel(x, y)) > 0; + } + QVERIFY(right > 20); + } + } +} + +int main(int argc, char **argv) +{ + // Every widget on this screen scales by two. + qputenv("QT_SCALE_FACTOR", "2"); + QApplication app(argc, argv); + HighDpiCanvasTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "HighDpiCanvasTests.moc" diff --git a/tests/HistoryTests.cpp b/tests/HistoryTests.cpp new file mode 100644 index 0000000..0daac68 --- /dev/null +++ b/tests/HistoryTests.cpp @@ -0,0 +1,461 @@ +#include "Document/DocumentHistory.h" +#include "Document/EditorSession.h" +#include <QtTest> +#include <functional> + +namespace { +ImportedImage asset(int width, int height, const QString &name) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + QImage thumbnail(8, 8, QImage::Format_RGBA8888_Premultiplied); + thumbnail.fill(Qt::red); + return ImportedImage(image, thumbnail, name); +} + +std::optional<CanvasDocument> documentNamed(const QString &layerName) +{ + CanvasDocument document(64, 32); + document.layers.push_back(ImageLayer(layerName, document.size())); + return document; +} +} + +class HistoryTests : public QObject { + Q_OBJECT +private slots: + void undoAndRedoReturnTheRecordedSnapshots(); + void nestedTransactionsRecordOneEntry(); + void noOpEditsPreserveRedo(); + void aNewEditClearsRedo(); + void savedRevisionFollowsUndoAndRedo(); + void endWithoutBeginIsIgnored(); + void resetClearsEverything(); + void historyBoundsEntriesAndUniqueRetainedPixels(); + void retainedBytesCountEachHistoryOnlyImageOnce(); + void byteLimitDropsTheOldestPastEntryFirst(); + void undoTrimsRedoWhenItsPixelsExceedTheLimit(); + void undoTrimsThePastBeforeTheRedoStack(); + void negativeLimitsBecomeZero(); + void everyLayerEditRoundTripsWithSelection(); + void navigationNoOpsAndSaveRevisionPreserveHistory(); + void replacementCanvasAndNestedTransactionsUndoAsOne(); + void historyBlockedDuringImportsAndDialogs(); +}; + +void HistoryTests::undoAndRedoReturnTheRecordedSnapshots() +{ + DocumentHistory history; + const std::optional<CanvasDocument> before = documentNamed("Before"); + std::optional<CanvasDocument> after = before; + after->layers[0].name = "After"; + const QUuid selection = before->layers[0].id; + history.begin("Rename Layer", before, std::nullopt); + history.end(after, selection); + QCOMPARE(history.undoCount(), 1); + QCOMPARE(history.undoName(), QString("Rename Layer")); + QCOMPARE(history.redoName(), QString()); + QVERIFY(history.canUndo() && !history.canRedo()); + + const DocumentHistory::Snapshot undone = history.undo().value(); + QVERIFY(undone.document == before); + QCOMPARE(undone.activeLayerID, std::nullopt); + QCOMPARE(history.undoName(), QString()); + QCOMPARE(history.redoName(), QString("Rename Layer")); + QVERIFY(!history.canUndo() && history.canRedo()); + QVERIFY(!history.undo().has_value()); + + const DocumentHistory::Snapshot redone = history.redo().value(); + QVERIFY(redone.document == after); + QCOMPARE(redone.activeLayerID, std::optional<QUuid>(selection)); + QVERIFY(history.canUndo() && !history.canRedo()); + QVERIFY(!history.redo().has_value()); + + QVERIFY(!undone.revision.isNull() && !redone.revision.isNull()); + QVERIFY(undone.revision != redone.revision); + history.begin("Hide Layer", after, selection); + history.end(std::nullopt, std::nullopt); + const DocumentHistory::Snapshot second = history.undo().value(); + QCOMPARE(second.revision, redone.revision); +} + +void HistoryTests::nestedTransactionsRecordOneEntry() +{ + DocumentHistory history; + const std::optional<CanvasDocument> start = documentNamed("A"); + std::optional<CanvasDocument> edited = start; + history.begin("First", start, std::nullopt); + history.end(documentNamed("Earlier"), std::nullopt); + QVERIFY(history.canUndo()); + history.undo(); + QVERIFY(history.canRedo()); + history.begin("Layer Setup", start, std::nullopt); + QVERIFY(!history.canRedo()); + QVERIFY(!history.redo().has_value()); + history.end(start, std::nullopt); + history.redo(); + history.begin("Layer Setup", start, std::nullopt); + QVERIFY(!history.canUndo()); + edited->layers[0].name = "B"; + history.begin("Inner", edited, std::nullopt); + edited->layers[0].name = "C"; + history.end(edited, std::nullopt); + QCOMPARE(history.undoCount(), 1); + QVERIFY(!history.canUndo()); + QVERIFY(!history.undo().has_value()); + history.end(edited, std::nullopt); + QCOMPARE(history.undoCount(), 2); + QCOMPARE(history.undoName(), QString("Layer Setup")); + QVERIFY(history.undo().value().document == start); +} + +void HistoryTests::noOpEditsPreserveRedo() +{ + DocumentHistory history; + const std::optional<CanvasDocument> start = documentNamed("A"); + std::optional<CanvasDocument> renamed = start; + renamed->layers[0].name = "B"; + history.begin("Rename Layer", start, std::nullopt); + history.end(renamed, std::nullopt); + history.undo(); + history.begin("Rename Layer", start, std::nullopt); + history.end(start, start->layers[0].id); + QCOMPARE(history.undoCount(), 0); + QVERIFY(history.canRedo()); + QVERIFY(!history.isModified()); +} + +void HistoryTests::aNewEditClearsRedo() +{ + DocumentHistory history; + const std::optional<CanvasDocument> start = documentNamed("A"); + history.begin("Rename Layer", start, std::nullopt); + history.end(documentNamed("B"), std::nullopt); + history.undo(); + QVERIFY(history.canRedo()); + history.begin("Delete Layer", start, std::nullopt); + history.end(std::nullopt, std::nullopt); + QVERIFY(!history.canRedo()); + QCOMPARE(history.undoName(), QString("Delete Layer")); +} + +void HistoryTests::savedRevisionFollowsUndoAndRedo() +{ + DocumentHistory history; + QVERIFY(!history.isModified()); + const std::optional<CanvasDocument> start = documentNamed("A"); + history.begin("Rename Layer", start, std::nullopt); + history.end(documentNamed("B"), std::nullopt); + QVERIFY(history.isModified()); + history.markSaved(); + QVERIFY(!history.isModified()); + history.undo(); + QVERIFY(history.isModified()); + history.redo(); + QVERIFY(!history.isModified()); + history.undo(); + history.markSaved(); + history.redo(); + QVERIFY(history.isModified()); +} + +void HistoryTests::endWithoutBeginIsIgnored() +{ + DocumentHistory history; + history.end(documentNamed("A"), std::nullopt); + QCOMPARE(history.undoCount(), 0); + QVERIFY(!history.isModified()); + history.begin("Rename Layer", documentNamed("A"), std::nullopt); + history.end(documentNamed("B"), std::nullopt); + QCOMPARE(history.undoCount(), 1); +} + +void HistoryTests::resetClearsEverything() +{ + DocumentHistory history; + const std::optional<CanvasDocument> start = documentNamed("A"); + history.begin("One", start, std::nullopt); + history.end(documentNamed("B"), std::nullopt); + history.begin("Two", start, std::nullopt); + history.end(documentNamed("C"), std::nullopt); + const QUuid revisionBeforeReset = history.undo().value().revision; + history.begin("Open", start, std::nullopt); + history.reset(); + QVERIFY(!history.canUndo() && !history.canRedo()); + QCOMPARE(history.undoCount(), 0); + QVERIFY(!history.isModified()); + history.begin("Three", start, std::nullopt); + history.end(documentNamed("E"), std::nullopt); + QVERIFY(history.canUndo()); + const QUuid revisionAfterReset = history.undo().value().revision; + QVERIFY(!revisionAfterReset.isNull()); + QVERIFY(revisionAfterReset != revisionBeforeReset); + + DocumentHistory interrupted; + interrupted.begin("Open", start, std::nullopt); + interrupted.reset(); + interrupted.end(documentNamed("D"), std::nullopt); + QCOMPARE(interrupted.undoCount(), 0); + QVERIFY(!interrupted.isModified()); +} + +void HistoryTests::historyBoundsEntriesAndUniqueRetainedPixels() +{ + DocumentHistory history(2, 0); + std::optional<CanvasDocument> doc = CanvasDocument(64, 32); + doc->layers.push_back(ImageLayer(asset(64, 32, "fixture"), QPointF(0, 0))); + for (const QString name : {"A", "B", "C"}) { + history.begin("Rename", doc, doc->layers[0].id); + doc->layers[0].name = name; + history.end(doc, doc->layers[0].id); + } + QCOMPARE(history.undoCount(), 2); + QCOMPARE(history.retainedBytes(doc), 0); + history.begin("Delete", doc, doc->layers[0].id); + doc->layers.clear(); + history.end(doc, std::nullopt); + QCOMPARE(history.undoCount(), 0); + QCOMPARE(history.retainedBytes(doc), 0); +} + +void HistoryTests::retainedBytesCountEachHistoryOnlyImageOnce() +{ + DocumentHistory history; + std::optional<CanvasDocument> doc = CanvasDocument(64, 32); + doc->layers.push_back(ImageLayer(asset(10, 10, "old"), QPointF(0, 0))); + for (const QString name : {"A", "B"}) { + history.begin("Rename", doc, std::nullopt); + doc->layers[0].name = name; + history.end(doc, std::nullopt); + } + QCOMPARE(history.retainedBytes(doc), 0); + history.begin("Replace", doc, std::nullopt); + doc->layers[0].asset = asset(20, 10, "new"); + history.end(doc, std::nullopt); + const qint64 oldBytes = 10 * 10 * 4 + 8 * 8 * 4; + QCOMPARE(history.retainedBytes(doc), oldBytes); + QCOMPARE(history.retainedBytes(std::nullopt), oldBytes + 20 * 10 * 4 + 8 * 8 * 4); +} + +void HistoryTests::byteLimitDropsTheOldestPastEntryFirst() +{ + const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; + DocumentHistory history(100, small); + std::optional<CanvasDocument> doc = CanvasDocument(64, 32); + doc->layers.push_back(ImageLayer(asset(10, 10, "x"), QPointF(0, 0))); + history.begin("First", doc, std::nullopt); + doc->layers[0].asset = asset(10, 10, "y"); + history.end(doc, std::nullopt); + QCOMPARE(history.undoCount(), 1); + history.begin("Second", doc, std::nullopt); + doc->layers[0].asset = asset(10, 10, "z"); + history.end(doc, std::nullopt); + QCOMPARE(history.undoCount(), 1); + QCOMPARE(history.undoName(), QString("Second")); + QCOMPARE(history.retainedBytes(doc), small); +} + +void HistoryTests::undoTrimsRedoWhenItsPixelsExceedTheLimit() +{ + const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; + DocumentHistory history(100, small); + std::optional<CanvasDocument> doc = CanvasDocument(64, 32); + doc->layers.push_back(ImageLayer(asset(10, 10, "small"), QPointF(0, 0))); + const std::optional<CanvasDocument> before = doc; + history.begin("Enlarge", doc, std::nullopt); + doc->layers[0].asset = asset(40, 40, "large"); + history.end(doc, std::nullopt); + QCOMPARE(history.undoCount(), 1); + const DocumentHistory::Snapshot undone = history.undo().value(); + QVERIFY(undone.document == before); + QVERIFY(!history.canRedo()); + QCOMPARE(history.retainedBytes(before), 0); +} + +void HistoryTests::undoTrimsThePastBeforeTheRedoStack() +{ + const qint64 small = 10 * 10 * 4 + 8 * 8 * 4; + DocumentHistory history(100, 2 * small); + std::optional<CanvasDocument> doc = CanvasDocument(64, 32); + doc->layers.push_back(ImageLayer(asset(10, 10, "a"), QPointF(0, 0))); + history.begin("First", doc, std::nullopt); + doc->layers[0].asset = asset(10, 10, "b"); + history.end(doc, std::nullopt); + history.begin("Second", doc, std::nullopt); + doc->layers[0].asset = asset(15, 15, "c"); + history.end(doc, std::nullopt); + QCOMPARE(history.undoCount(), 2); + history.undo(); + QCOMPARE(history.undoCount(), 0); + QVERIFY(history.canRedo()); + QCOMPARE(history.redoName(), QString("Second")); +} + +void HistoryTests::negativeLimitsBecomeZero() +{ + DocumentHistory history(-5, -1); + QCOMPARE(history.entryLimit, 0); + QCOMPARE(history.retainedByteLimit, 0); + history.begin("Rename", documentNamed("A"), std::nullopt); + history.end(documentNamed("B"), std::nullopt); + QCOMPARE(history.undoCount(), 0); + QVERIFY(history.isModified()); +} + +void HistoryTests::everyLayerEditRoundTripsWithSelection() +{ + EditorSession session; + std::vector<std::pair<std::optional<CanvasDocument>, std::optional<QUuid>>> states{{std::nullopt, std::nullopt}}; + const auto capture = [&] { states.push_back({session.document(), session.activeLayerID()}); }; + session.createDocument(800, 600); + capture(); + session.addBlankLayer(); + capture(); + const QUuid base = session.activeLayerID().value(); + session.addBlankLayer(); + capture(); + const QUuid id = session.activeLayerID().value(); + session.renameLayer(id, "Foreground"); + capture(); + session.toggleLayerVisibility(id); + capture(); + session.reorderLayers({0}, 2); + capture(); + session.moveActiveLayer(1); + capture(); + session.deleteActiveLayer(); + capture(); + // Beyond Swift's list: an image, a folder, several layers. + session.insert(asset(8, 8, "Image")); + capture(); + const QUuid image = session.activeLayerID().value(); + session.setLayerOpacity(0.5); + capture(); + session.setLayerBlendMode(LayerBlendMode::overlay); + capture(); + session.beginOpacityEdit(); + session.setLayerOpacity(0.25); + session.finishOpacityEdit(); + capture(); + session.beginTransform(); + session.previewTransform({.origin = {3, 4}, .size = {16, 24}, .rotation = 10}); + session.commitTransform(); + capture(); + session.addGroup(); + capture(); + const QUuid folder = session.activeLayerID().value(); + session.selectLayers({image, folder}, folder); + session.groupSelectedLayers(); + capture(); + // Placing makes another layer active: redo must bring it back. + QVERIFY(session.placeLayer(image, std::nullopt, std::nullopt, true)); + capture(); + session.selectLayers({image, base}, image); + session.deleteSelectedLayers(); + capture(); + QCOMPARE(int(session.document().value().layers.size()), 2); + for (auto expected = states.rbegin() + 1; expected != states.rend(); ++expected) { + QVERIFY(session.canUndo()); + session.undo(); + QCOMPARE(session.document(), expected->first); + QCOMPARE(session.activeLayerID(), expected->second); + } + QVERIFY(!session.canUndo()); + for (auto expected = states.begin() + 1; expected != states.end(); ++expected) { + session.redo(); + QCOMPARE(session.document(), expected->first); + QCOMPARE(session.activeLayerID(), expected->second); + } + QVERIFY(!session.canRedo()); +} + +void HistoryTests::navigationNoOpsAndSaveRevisionPreserveHistory() +{ + EditorSession session; + // With a view to fit, a refit undoes the zoom. + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + session.createDocument(800, 600); + session.addBlankLayer(); + const QUuid id = session.activeLayerID().value(); + session.history.markSaved(); + QVERIFY(!session.isModified()); + session.renameLayer(id, "Changed"); + QVERIFY(session.isModified()); + session.undo(); + QVERIFY(!session.isModified()); + const int count = session.history.undoCount(); + session.zoom(2); + session.renameLayer(id, "Layer 1"); + session.renameLayer(id, " "); + session.reorderLayers({0}, 1); + QCOMPARE(session.history.undoCount(), count); + QVERIFY(session.canRedo()); + session.redo(); + QCOMPARE(session.viewport.zoom(), 2.0); + QVERIFY(session.isModified()); + session.undo(); + session.addBlankLayer(); + QVERIFY(!session.canRedo()); + QVERIFY(session.isModified()); +} + +void HistoryTests::replacementCanvasAndNestedTransactionsUndoAsOne() +{ + EditorSession session; + session.createDocument(100, 200); + session.beginEdit("Layer Setup"); + session.addBlankLayer(); + session.addBlankLayer(); + QVERIFY(!session.canUndo()); + session.endEdit(); + QCOMPARE(session.history.undoName(), QString("Layer Setup")); + session.undo(); + QVERIFY(session.document().value().layers.empty()); + session.redo(); + const std::optional<CanvasDocument> previous = session.document(); + session.viewport.resize(QSizeF(800, 600), 1, previous.value().size()); + session.zoom(3); + session.createDocument(300, 400); + QCOMPARE(session.history.undoName(), QString("New Canvas")); + session.zoom(3); + session.undo(); + QCOMPARE(session.document(), previous); + // Another canvas came back: the view fits it again. + QVERIFY(session.viewport.followsFit()); + session.redo(); + QCOMPARE(session.document().value().size(), QSizeF(300, 400)); +} + +void HistoryTests::historyBlockedDuringImportsAndDialogs() +{ + EditorSession session; + session.createDocument(40, 40); + session.setIsImporting(true); + session.undo(); + QVERIFY(session.document().has_value()); + session.setIsImporting(false); + session.setShowsNewDocument(true); + session.undo(); + QVERIFY(session.document().has_value()); + session.setShowsNewDocument(false); + session.undo(); + session.setShowsImporter(true); + session.redo(); + QVERIFY(!session.document().has_value()); + session.setShowsImporter(false); + session.redo(); + QVERIFY(session.document().has_value()); + // A busy project, a rename, an import error block too. + const auto blocked = [&](const std::function<void(bool)> &set) { + set(true); + const bool refused = !session.canUndo() && !session.canUseHistory(); + set(false); + return refused && session.canUndo(); + }; + QVERIFY(blocked([&](bool on) { session.setIsProjectBusy(on); })); + QVERIFY(blocked([&](bool on) { session.setRenamingLayerID(on ? std::optional(QUuid::createUuid()) : std::nullopt); })); + QVERIFY(blocked([&](bool on) { session.setImportError(on ? std::optional(QString("failed")) : std::nullopt); })); +} + +QTEST_GUILESS_MAIN(HistoryTests) +#include "HistoryTests.moc" diff --git a/tests/HueSaturationCanvasTests.cpp b/tests/HueSaturationCanvasTests.cpp new file mode 100644 index 0000000..607052e --- /dev/null +++ b/tests/HueSaturationCanvasTests.cpp @@ -0,0 +1,333 @@ +#include "MenuFixtures.h" +#include "SelectionCanvasFixtures.h" +#include "UI/FloatingPanel.h" +#include "UI/HueSaturationSheet.h" +#include <QCheckBox> +#include <QLineEdit> +#include <QPushButton> + +// Hue/Saturation in its panel and on the canvas. +namespace { +// Red left, green right, filling the view. +struct Halves : Canvas { + Halves() + { + QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + for (int y = 0; y < 300; ++y) { + for (int x = 200; x < 400; ++x) + image.setPixelColor(x, y, Qt::green); + } + session.insert(ImportedImage(image, image, QStringLiteral("Halves"))); + } + QColor shown(QPoint at) + { + canvas->synchronizeDisplay(); + return canvas->grab().toImage().pixelColor(at); + } + const HueSaturationSettings &settings() const { return session.hueSaturation().value().settings; } + RangeAdjustment adjustment(ColorRange range) const + { + return settings().adjustments.contains(range) ? settings().adjustments.at(range) : RangeAdjustment(); + } +}; + +QWidget *adjustmentPanel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == QStringLiteral("adjustmentPanel") && widget->isVisible()) + return widget; + } + return nullptr; +} + +// A window with one red layer, shown and active. +struct Editor : Bar { + Editor() + { + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + session().createDocument(4, 4, true); + QImage red(4, 4, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session().insert(ImportedImage(red, red, QStringLiteral("Red"))); + } +}; +} + +class HueSaturationCanvasTests : public QObject { + Q_OBJECT +private slots: + void thePreviewStandsInForTheLayer(); + void anEyedropperClickSetsTheBandAndHandsThePanelTheKeys(); + void colourPickingOutranksTheRangeEyedropperOnlyWhenAsked(); + void targetingDragsSaturationOrWithCtrlTheHue(); + void targetingTakesPressesAndDoubleClicksFirst(); + void thePanelFollowsTheEdit(); + void returnInAFieldClampsAndCommits(); + void returnFromAnyButtonGoesToOK(); +}; + +void HueSaturationCanvasTests::thePreviewStandsInForTheLayer() +{ + Halves halves; + halves.session.beginHueSaturation(); + halves.session.updateHueSaturation(HueSaturationSettings(120), true); + QTRY_VERIFY(halves.session.hueSaturation().value().previewImage(halves.session.activeLayerID().value())); + QCOMPARE(halves.shown(QPoint(100, 150)), QColor(Qt::green)); + QCOMPARE(halves.shown(QPoint(300, 150)), QColor(Qt::blue)); + // Off, the layer's own pixels show again. + halves.session.updateHueSaturation(HueSaturationSettings(120), false); + QCOMPARE(halves.shown(QPoint(100, 150)), QColor(Qt::red)); + halves.session.updateHueSaturation(HueSaturationSettings(120), true); + QTRY_VERIFY(halves.session.hueSaturation().value().previewImage(halves.session.activeLayerID().value())); + halves.session.cancelHueSaturation(); + QCOMPARE(halves.shown(QPoint(300, 150)), QColor(Qt::green)); +} + +void HueSaturationCanvasTests::anEyedropperClickSetsTheBandAndHandsThePanelTheKeys() +{ + Halves halves; + halves.session.beginHueSaturation(); + halves.session.updateHueSaturation(HueSaturationSettings(0, 0, 0, false, ColorRange::yellows), false); + FloatingPanel panel(QStringLiteral("adjustmentPanel"), halves.window); + panel.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(halves.session)); + QTRY_COMPARE(QApplication::activeWindow(), adjustmentPanel()); + const QCursor eyedropper = CanvasView::eyedropperCursor(halves.canvas->devicePixelRatio()); + QVERIFY(!halves.shows(eyedropper)); + halves.session.setHueSampleMode(HueSampleMode::replace); + halves.canvas->synchronizeDisplay(); + QVERIFY(halves.shows(eyedropper)); + halves.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &halves.window); + // Green: Yellows centred on 120. + halves.click(QPointF(300, 150)); + QCOMPARE(halves.settings().band(), (HueBand{75, 105, 135, 165})); + QTRY_COMPARE(QApplication::activeWindow(), adjustmentPanel()); + // With Space held the press pans instead. + const QSizeF pan = halves.session.viewport.pan; + QTest::keyPress(halves.canvas, Qt::Key_Space); + halves.drag(QPointF(100, 150), QPointF(110, 160)); + QTest::keyRelease(halves.canvas, Qt::Key_Space); + QCOMPARE(halves.session.viewport.pan, pan + QSizeF(10, 10)); + QCOMPARE(halves.settings().band(), (HueBand{75, 105, 135, 165})); + // Put away, the tool's own cursor returns. + halves.session.setHueSampleMode(std::nullopt); + halves.canvas->synchronizeDisplay(); + QVERIFY(!halves.shows(eyedropper)); + halves.session.cancelHueSaturation(); +} + +void HueSaturationCanvasTests::colourPickingOutranksTheRangeEyedropperOnlyWhenAsked() +{ + Halves halves; + halves.session.selectTool(NavigationTool::eyedropper); + halves.session.beginHueSaturation(); + halves.session.updateHueSaturation(HueSaturationSettings(0, 0, 0, false, ColorRange::yellows), false); + // Unarmed, the Eyedropper takes the colour. + halves.click(QPointF(300, 150)); + QCOMPARE(halves.session.foregroundColor().hex(), QString("00FF00")); + QCOMPARE(halves.settings().band(), defaultBand(ColorRange::yellows)); + // Armed, the range's eyedropper takes the click. + halves.session.setHueSampleMode(HueSampleMode::replace); + halves.click(QPointF(100, 150)); + QCOMPARE(halves.session.foregroundColor().hex(), QString("00FF00")); + QCOMPARE(halves.settings().band(), (HueBand{315, 345, 15, 45})); + // An open colour picker outranks both. + halves.session.openColorPicker(false); + halves.click(QPointF(300, 150)); + QCOMPARE(halves.session.colorPicker().value().color().hex(), QString("00FF00")); + QCOMPARE(halves.settings().band(), (HueBand{315, 345, 15, 45})); + halves.session.closeColorPicker(false); + // Armed targeting leaves a colour drag its sampling. + halves.session.setHueSampleMode(std::nullopt); + halves.session.setHueTargeting(true); + halves.canvas->synchronizeDisplay(); + halves.press(QPointF(100, 150)); + halves.move(QPointF(300, 150)); + QCOMPARE(halves.session.foregroundColor().hex(), QString("00FF00")); + halves.release(QPointF(300, 150)); + halves.session.cancelHueSaturation(); +} + +void HueSaturationCanvasTests::targetingDragsSaturationOrWithCtrlTheHue() +{ + Halves halves; + halves.session.beginHueSaturation(); + halves.session.setHueTargeting(true); + halves.canvas->synchronizeDisplay(); + QCOMPARE(halves.canvas->cursor().shape(), Qt::SizeHorCursor); + // Red picks Reds; right raises a unit per two points. + halves.press(QPointF(100, 150)); + QCOMPARE(halves.settings().range, ColorRange::reds); + halves.move(QPointF(140, 150)); + QCOMPARE(halves.adjustment(ColorRange::reds), (RangeAdjustment{0, 20, 0})); + // Ctrl drags the hue, from the drag's start. + halves.move(QPointF(160, 150), Qt::ControlModifier); + QCOMPARE(halves.adjustment(ColorRange::reds), (RangeAdjustment{30, 20, 0})); + halves.release(QPointF(160, 150)); + QCOMPARE(halves.canvas->cursor().shape(), Qt::SizeHorCursor); + // Alt mid-drag shows the Brush's eyedropper till the next drag. + halves.session.selectTool(NavigationTool::brush); + halves.canvas->synchronizeDisplay(); + halves.press(QPointF(100, 150)); + QTest::keyPress(halves.canvas, Qt::Key_Alt); + QVERIFY(halves.shows(CanvasView::eyedropperCursor(halves.canvas->devicePixelRatio()))); + halves.move(QPointF(120, 150), Qt::AltModifier); + QCOMPARE(halves.canvas->cursor().shape(), Qt::SizeHorCursor); + QCOMPARE(halves.adjustment(ColorRange::reds), (RangeAdjustment{30, 30, 0})); + halves.release(QPointF(120, 150), Qt::AltModifier); + QTest::keyRelease(halves.canvas, Qt::Key_Alt); + halves.session.selectTool(NavigationTool::marquee); + // Ended, moves change nothing. + halves.move(QPointF(300, 150)); + QCOMPARE(halves.adjustment(ColorRange::reds), (RangeAdjustment{30, 30, 0})); + // A buttonless move ends a drag whose release was lost. + halves.press(QPointF(300, 150)); + QCOMPARE(halves.settings().range, ColorRange::greens); + halves.hover(QPointF(320, 150)); + halves.move(QPointF(360, 150)); + QCOMPARE(halves.adjustment(ColorRange::greens), RangeAdjustment()); + halves.release(QPointF(360, 150)); + // With Space held the press pans; Colorize refuses. + const QSizeF pan = halves.session.viewport.pan; + QTest::keyPress(halves.canvas, Qt::Key_Space); + halves.drag(QPointF(100, 150), QPointF(90, 150)); + QTest::keyRelease(halves.canvas, Qt::Key_Space); + QCOMPARE(halves.session.viewport.pan, pan + QSizeF(-10, 0)); + QCOMPARE(halves.settings().range, ColorRange::greens); + halves.session.updateHueSaturation(HueSaturationSettings::colorizeStart(), false); + halves.drag(QPointF(100, 150), QPointF(140, 150)); + QVERIFY(halves.settings() == HueSaturationSettings::colorizeStart()); + // Put away, the tool's cursor returns. + halves.session.setHueTargeting(false); + halves.canvas->synchronizeDisplay(); + QVERIFY(halves.canvas->cursor().shape() != Qt::SizeHorCursor); + halves.session.cancelHueSaturation(); +} + +void HueSaturationCanvasTests::targetingTakesPressesAndDoubleClicksFirst() +{ + Halves halves; + // Refused under Colorize, a press still reaches no tool. + halves.session.selectTool(NavigationTool::zoom); + halves.session.beginHueSaturation(); + halves.session.updateHueSaturation(HueSaturationSettings::colorizeStart(), false); + halves.session.setHueTargeting(true); + halves.canvas->synchronizeDisplay(); + halves.click(QPointF(100, 150)); + QCOMPARE(halves.session.viewport.zoom(), 1.0); + halves.session.cancelHueSaturation(); + // A polygonal outline begun before stays under a double click. + halves.session.selectTool(NavigationTool::lasso); + halves.session.setLassoKind(LassoKind::polygonal); + halves.canvas->synchronizeDisplay(); + halves.click(QPointF(20, 20)); + halves.click(QPointF(120, 20)); + QVERIFY(halves.session.lassoDraft()); + halves.session.beginHueSaturation(); + halves.session.setHueTargeting(true); + QTest::mouseDClick(halves.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(100, 150)); + QCOMPARE(halves.settings().range, ColorRange::reds); + QVERIFY(halves.session.lassoDraft()); + halves.release(QPointF(100, 150)); + halves.session.cancelHueSaturation(); + halves.session.cancelLasso(); + // Open text: the double click targets, not its words. + halves.session.selectTool(NavigationTool::type); + halves.session.beginText(QPointF(300, 50), true); + QVERIFY(halves.session.textDraft()); + halves.session.beginHueSaturation(); + halves.session.setHueTargeting(true); + halves.canvas->synchronizeDisplay(); + QTest::mouseDClick(halves.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(300, 150)); + QCOMPARE(halves.settings().range, ColorRange::greens); + halves.release(QPointF(300, 150)); + halves.session.cancelHueSaturation(); +} + +void HueSaturationCanvasTests::thePanelFollowsTheEdit() +{ + Editor editor; + QVERIFY(!adjustmentPanel()); + editor.session().beginHueSaturation(); + QWidget *panel = adjustmentPanel(); + QVERIFY(panel); + QCOMPARE(panel->windowTitle(), QString("Hue/Saturation")); + HueSaturationSheet *sheet = panel->findChild<HueSaturationSheet *>(); + QVERIFY(sheet->isVisible()); + // A change keeps the sheet; a new edit makes one. + editor.session().updateHueSaturation(HueSaturationSettings(90), false); + QVERIFY(sheet->isVisible()); + editor.session().cancelHueSaturation(); + QVERIFY(!panel->isVisible()); + editor.session().beginHueSaturation(); + QVERIFY(panel->isVisible()); + QTRY_COMPARE(panel->findChildren<HueSaturationSheet *>().size(), 1); + // Its close button and Escape cancel. + panel->close(); + QVERIFY(!editor.session().hueSaturation()); + editor.session().beginHueSaturation(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QTest::keyClick(panel, Qt::Key_Escape); + QVERIFY(!editor.session().hueSaturation() && !panel->isVisible()); + // Escape over typing: the field lets go after the edit. + editor.session().beginHueSaturation(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QLineEdit &hue = *panel->findChild<QLineEdit *>(QStringLiteral("hueField")); + hue.setFocus(); + QTRY_VERIFY(hue.hasFocus()); + QTest::keyClicks(&hue, QStringLiteral("5")); + QTest::keyClick(&hue, Qt::Key_Escape); + QTRY_VERIFY(!panel->isVisible()); + QVERIFY(!editor.session().hueSaturation()); +} + +void HueSaturationCanvasTests::returnInAFieldClampsAndCommits() +{ + Editor editor; + editor.session().beginHueSaturation(); + QWidget *panel = adjustmentPanel(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QLineEdit &lightness = *panel->findChild<QLineEdit *>(QStringLiteral("lightnessField")); + lightness.setFocus(); + QTRY_VERIFY(lightness.hasFocus()); + lightness.selectAll(); + QTest::keyClicks(&lightness, QStringLiteral("-500")); + QTest::keyClick(&lightness, Qt::Key_Return); + QTRY_VERIFY(!editor.session().hueSaturation()); + QCOMPARE(editor.session().history.undoName(), QString("Hue/Saturation")); + QCOMPARE(editor.session().activeLayer().value().asset.value().image().pixelColor(0, 0), QColor(Qt::black)); + QVERIFY(!panel->isVisible()); +} + +void HueSaturationCanvasTests::returnFromAnyButtonGoesToOK() +{ + Editor editor; + // Swift's default action: no other control takes Return. + for (const char *text : {"Reset", "Cancel", "Colorize", "Preview"}) { + // Window managers hand the keys back once a panel closes. + editor.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &editor.window); + editor.session().beginHueSaturation(); + editor.session().updateHueSaturation(HueSaturationSettings(120), false); + QWidget *panel = adjustmentPanel(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QAbstractButton *button = nullptr; + for (QAbstractButton *found : panel->findChildren<QAbstractButton *>()) { + if (found->text() == QString::fromUtf8(text) && found->isVisible()) + button = found; + } + QTRY_VERIFY(button && button->isEnabled()); + button->setFocus(); + QTRY_VERIFY(button->hasFocus()); + const int steps = editor.session().history.undoCount(); + QTest::keyClick(button, Qt::Key_Return); + QTRY_VERIFY2(!editor.session().hueSaturation(), text); + QCOMPARE(editor.session().history.undoCount(), steps + 1); + } +} + +QTEST_MAIN(HueSaturationCanvasTests) +#include "HueSaturationCanvasTests.moc" diff --git a/tests/HueSaturationFailureTests.cpp b/tests/HueSaturationFailureTests.cpp new file mode 100644 index 0000000..81de930 --- /dev/null +++ b/tests/HueSaturationFailureTests.cpp @@ -0,0 +1,115 @@ +#include "AddressSpaceLimit.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Rendering/RasterSnapshot.h" +#include <QtTest> +#include <QThreadPool> +#include <malloc.h> + +// Hue/Saturation when memory runs out, each on a fresh heap. +namespace { +// A painted asset without full pixels: a two-pixel base. +std::unique_ptr<EditorSession> rasterSession(int width, int height) +{ + QImage base(2, 2, QImage::Format_RGBA8888_Premultiplied); + base.fill(Qt::red); + auto session = std::make_unique<EditorSession>(); + session->createDocument(20, 10); + session->insert(ImportedImage(std::make_shared<const RasterSnapshot>(width, height, base, QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}), + QImage(), QStringLiteral("Painted"))); + return session; +} +} + +// Earlier tests leave free heap that big asks would reuse. +class HueSaturationFailureTests : public QObject { + Q_OBJECT +private slots: + // First: it leaves no free heap behind for the rest. + void aFailedPreviewShowsTheErrorAndNothingElse(); + void failuresReachTheSessionsError(); + void theFilterFailsAsAContextWhenMemoryRunsOut(); + void aFailureReportsThoughTheNextPreviewWaits(); +}; + +void HueSaturationFailureTests::aFailedPreviewShowsTheErrorAndNothingElse() +{ + // Flattened first; the preview's copy needs 67 MB. + const std::unique_ptr<EditorSession> session = rasterSession(8000, 2100); + QVERIFY(!session->activeLayer().value().asset.value().image().isNull()); + session->beginHueSaturation(); + const int revision = session->brushRevision(); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + session->updateHueSaturation(HueSaturationSettings(120), true); + QTRY_VERIFY(session->brushError()); + } + const HueSaturationEdit &edit = session->hueSaturation().value(); + QVERIFY(!edit.previewImage(edit.layerID) && session->brushRevision() == revision); + session->cancelHueSaturation(); +} + +void HueSaturationFailureTests::failuresReachTheSessionsError() +{ + // Workers allocate in their own arenas: only large asks fail. + { + // 10000 by 9999 in tiles: the scaled copy, 256 MB. + const std::unique_ptr<EditorSession> session = rasterSession(10000, 9999); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + session->beginHueSaturation(); + } + QVERIFY(!session->hueSaturation() && session->brushError()); + } + // 30000 by 3000: a small copy; the flatten, 360 MB. + const std::unique_ptr<EditorSession> session = rasterSession(30000, 3000); + session->beginHueSaturation(); + session->updateHueSaturation(HueSaturationSettings(120), false); + const ImageIdentity before = session->activeLayer().value().asset.value().identity(); + const int count = session->history.undoCount(); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + bool done = false; + session->commitHueSaturation([&done] { done = true; }); + QTRY_VERIFY(done); + } + QVERIFY(session->brushError() && !session->hueSaturation() && !session->isProjectBusy()); + QCOMPARE(session->activeLayer().value().asset.value().identity(), before); + QCOMPARE(session->history.undoCount(), count); +} + +void HueSaturationFailureTests::theFilterFailsAsAContextWhenMemoryRunsOut() +{ + // One pixel wide: its row list asks as its pixels. + QImage tall(1, 16'000'000, QImage::Format_RGBA8888_Premultiplied); + tall.fill(Qt::red); + const HueSaturationJob job{tall, HueSaturationSettings(120), std::nullopt, QTransform(), false}; + malloc_trim(0); + const AddressSpaceLimit limit(80ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, HueSaturationFilter::run(job)); +} + +void HueSaturationFailureTests::aFailureReportsThoughTheNextPreviewWaits() +{ + const std::unique_ptr<EditorSession> session = rasterSession(8000, 2100); + QVERIFY(!session->activeLayer().value().asset.value().image().isNull()); + session->beginHueSaturation(); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + session->updateHueSaturation(HueSaturationSettings(120), true); + // Failed but undelivered when the next one asks. + QThreadPool::globalInstance()->waitForDone(); + } + session->updateHueSaturation(HueSaturationSettings(240), true); + QVERIFY(session->hueSaturationPending() && !session->brushError()); + QTRY_VERIFY(session->brushError()); + QTRY_VERIFY(!session->hueSaturationPending() && session->hueSaturation().value().previewImage(session->activeLayerID().value())); + session->cancelHueSaturation(); +} + +QTEST_GUILESS_MAIN(HueSaturationFailureTests) +#include "HueSaturationFailureTests.moc" diff --git a/tests/HueSaturationModelTests.cpp b/tests/HueSaturationModelTests.cpp new file mode 100644 index 0000000..6f8f26f --- /dev/null +++ b/tests/HueSaturationModelTests.cpp @@ -0,0 +1,332 @@ +#include "Document/HueSaturation.h" +#include "Document/LayerTransform.h" +#include "Document/Selection.h" +#include <QtTest> +#include <cmath> + +// Hue/Saturation's models and cube at their edges. +namespace { +std::array<int, 4> bytes(const QImage &image, int x = 0, int y = 0) +{ + const uchar *pixel = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied).constScanLine(y) + x * 4; + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} + +QImage filled(QColor colour, int width = 1, int height = 1) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(colour); + return image; +} + +bool close(const HueSaturationFilter::Color &color, double red, double green, double blue) +{ + return std::abs(color.red - red) < 1e-9 && std::abs(color.green - green) < 1e-9 && std::abs(color.blue - blue) < 1e-9; +} +} + +class HueSaturationModelTests : public QObject { + Q_OBJECT +private slots: + void rangesKeepSwiftsNamesAndBands(); + void bandsMeasureForwardAndRamp(); + void bandsCentreWidenAndNarrow(); + void handlesStayInOrder(); + void settingsEditTheirRange(); + void adjustWorksInHsl(); + void theCubeRunsRedFastest(); + void theFilterLooksUpUnpremultiplied(); + void previewsScaleTheLongSideToEightThousand(); +}; + +void HueSaturationModelTests::rangesKeepSwiftsNamesAndBands() +{ + QStringList names; + for (const ColorRange range : allColorRanges) + names << rawValue(range); + QCOMPARE(names, (QStringList{"Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"})); + QCOMPARE(colorRanges.front(), ColorRange::reds); + QCOMPARE(colorRanges.back(), ColorRange::magentas); + const std::pair<ColorRange, std::array<double, 4>> bands[] = { + {ColorRange::master, {0, 0, 360, 360}}, {ColorRange::reds, {315, 345, 15, 45}}, {ColorRange::yellows, {15, 45, 75, 105}}, + {ColorRange::greens, {75, 105, 135, 165}}, {ColorRange::cyans, {135, 165, 195, 225}}, {ColorRange::blues, {195, 225, 255, 285}}, + {ColorRange::magentas, {255, 285, 315, 345}}}; + for (const auto &[range, handles] : bands) + QCOMPARE(defaultBand(range).handles(), handles); + QStringList modes; + for (const HueSampleMode mode : allHueSampleModes) + modes << rawValue(mode) + QLatin1Char('|') + help(mode); + QCOMPARE(modes, (QStringList{"Sample|Click the image to center this range on that color", "Add|Click the image to widen this range to include that color", + "Remove|Click the image to narrow this range to exclude that color"})); +} + +void HueSaturationModelTests::bandsMeasureForwardAndRamp() +{ + QCOMPARE(HueBand::forward(350, 10), 20.0); + QCOMPARE(HueBand::forward(10, 350), 340.0); + QCOMPARE(HueBand::forward(0, 720), 0.0); + QCOMPARE(HueBand::forward(0, -30), 330.0); + const HueBand yellows = defaultBand(ColorRange::yellows); + QCOMPARE(yellows.weight(30), 0.5); + QCOMPARE(yellows.weight(60), 1.0); + QCOMPARE(yellows.weight(90), 0.5); + QCOMPARE(yellows.weight(105), 0.0); + QCOMPARE(yellows.weight(106), 0.0); + // Shoulders of no width are full at once. + const HueBand sharp{100, 100, 200, 200}; + QVERIFY(sharp.weight(100) == 1 && sharp.weight(200) == 1 && sharp.weight(99) == 0 && sharp.weight(201) == 0); + QCOMPARE((HueBand{10, 20, 30, 10}.weight(12)), 1.0); + // A span under a degree still measures its hues. + QCOMPARE((HueBand{100, 100.2, 100.5, 100.8}.weight(200)), 0.0); +} + +void HueSaturationModelTests::bandsCentreWidenAndNarrow() +{ + // Centred: core 30, shoulders 30, around the hue. + const HueBand centred = defaultBand(ColorRange::greens).centered(0); + QCOMPARE(centred, (HueBand{315, 345, 15, 45})); + QCOMPARE(defaultBand(ColorRange::greens).centered(350).handles(), (std::array<double, 4>{305, 335, 5, 35})); + // Widened by the nearer edge, shoulders kept. + HueBand band = defaultBand(ColorRange::greens); + band.include(100); + QCOMPARE(band, (HueBand{70, 100, 135, 165})); + band = defaultBand(ColorRange::greens); + band.include(140); + QCOMPARE(band, (HueBand{75, 105, 140, 170})); + band = defaultBand(ColorRange::greens); + band.include(120); + QCOMPARE(band, defaultBand(ColorRange::greens)); + // Narrowed a degree past the hue, from the nearer end. + band = defaultBand(ColorRange::greens); + band.exclude(90); + QCOMPARE(band, (HueBand{91, 121, 135, 165})); + band = defaultBand(ColorRange::greens); + band.exclude(150); + QCOMPARE(band, (HueBand{75, 105, 119, 149})); + band = defaultBand(ColorRange::greens); + band.exclude(200); + QCOMPARE(band, defaultBand(ColorRange::greens)); + // A tie widens the start, as Swift's <=. + band.include(300); + QCOMPARE(band, (HueBand{270, 300, 135, 165})); + // Uneven shoulders keep their own widths. + const HueBand uneven{300, 320, 340, 350}; + QCOMPARE(uneven.centered(100), (HueBand{70, 90, 110, 120})); + band = uneven; + band.include(250); + QCOMPARE(band, (HueBand{230, 250, 340, 350})); + band = uneven; + band.include(355); + QCOMPARE(band, (HueBand{300, 320, 355, 5})); + band = uneven; + band.exclude(310); + QCOMPARE(band, (HueBand{311, 331, 340, 350})); + band = uneven; + band.exclude(345); + QCOMPARE(band, (HueBand{300, 320, 334, 344})); + // Across zero every handle wraps; the core may cross. + band = defaultBand(ColorRange::reds); + band.exclude(350); + QCOMPARE(band, (HueBand{351, 21, 15, 45})); + QCOMPARE(band.weight(21), 0.8); + band = defaultBand(ColorRange::reds); + band.exclude(10); + QCOMPARE(band, (HueBand{315, 345, 339, 9})); + // Widening stops short of a full circle. + HueBand wide{10, 40, 300, 330}; + wide.include(320); + QCOMPARE(wide, (HueBand{10, 40, 320, 350})); + wide = HueBand{10, 40, 300, 330}; + wide.include(335); + QCOMPARE(wide, (HueBand{10, 40, 335, 0})); + wide = HueBand{10, 40, 300, 330}; + wide.include(330.5); + QCOMPARE(wide, (HueBand{10, 40, 330.5, 0})); + wide = HueBand{10, 40, 300, 330}; + wide.include(5); + QCOMPARE(wide, (HueBand{335, 5, 300, 325})); +} + +void HueSaturationModelTests::handlesStayInOrder() +{ + HueBand band = defaultBand(ColorRange::greens); + band.setHandle(0, 60); + QCOMPARE(band.falloffStart, 60.0); + band.setHandle(2, 150); + QCOMPARE(band.rangeEnd, 150.0); + band.setHandle(3, 500); + QCOMPARE(band.falloffEnd, 165.0); + band.setHandle(3, 530); + QCOMPARE(band.falloffEnd, 170.0); + band = defaultBand(ColorRange::greens); + band.setHandle(3, -150); + QCOMPARE(band.falloffEnd, 210.0); + // The end passes neither the core nor a circle. + band.setHandle(3, 130); + QCOMPARE(band.falloffEnd, 210.0); + band.setHandle(3, 70); + QCOMPARE(band.falloffEnd, 210.0); + band.setHandle(0, 105); + QCOMPARE(band.falloffStart, 105.0); + band.setHandle(0, 106); + QCOMPARE(band.falloffStart, 105.0); + band = defaultBand(ColorRange::greens); + band.setHandle(3, 67); + QCOMPARE(band.falloffEnd, 165.0); + // A span of a degree or less is refused. + HueBand thin{100, 100, 100, 101}; + thin.setHandle(3, 101.5); + QCOMPARE(thin.falloffEnd, 101.5); + thin.setHandle(3, 100.5); + QCOMPARE(thin.falloffEnd, 101.5); +} + +void HueSaturationModelTests::settingsEditTheirRange() +{ + const HueSaturationSettings plain; + QCOMPARE(plain.adjustments.size(), size_t(1)); + QCOMPARE(plain.adjustments.at(ColorRange::master), RangeAdjustment()); + QCOMPARE(plain.bands.size(), size_t(7)); + QVERIFY(plain.isIdentity() && plain.range == ColorRange::master && !plain.colorize && !plain.invertRange); + HueSaturationSettings settings(10, 20, 30, false, ColorRange::cyans); + QVERIFY(settings.hue() == 10 && settings.saturation() == 20 && settings.lightness() == 30); + settings.range = ColorRange::blues; + QVERIFY(settings.hue() == 0 && settings.saturation() == 0 && settings.lightness() == 0); + settings.setSaturation(-5); + settings.setLightness(7); + QCOMPARE(settings.adjustments.at(ColorRange::blues), (RangeAdjustment{0, -5, 7})); + QCOMPARE(settings.band(), defaultBand(ColorRange::blues)); + settings.setBand(HueBand{1, 2, 3, 4}); + QCOMPARE(settings.bands.at(ColorRange::blues), (HueBand{1, 2, 3, 4})); + settings.bands.erase(ColorRange::blues); + QCOMPARE(settings.band(), defaultBand(ColorRange::blues)); + QCOMPARE(settings.weight(ColorRange::blues, 240), 1.0); + QCOMPARE(HueSaturationSettings::colorizeStart(), HueSaturationSettings(0, 25, 0, true)); + QVERIFY(!HueSaturationSettings(0, 0, 0, true).isIdentity()); + // Invert flips only the selected range's weight. + settings.invertRange = true; + QCOMPARE(settings.weight(ColorRange::blues, 240), 0.0); + QCOMPARE(settings.weight(ColorRange::reds, 0), 1.0); + QCOMPARE(settings.weight(ColorRange::master, 120), 1.0); + settings.range = ColorRange::master; + QCOMPARE(settings.weight(ColorRange::master, 120), 1.0); +} + +void HueSaturationModelTests::adjustWorksInHsl() +{ + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(120)), 0, 1, 0)); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(-120)), 0, 0, 1)); + QVERIFY(close(HueSaturationFilter::adjust(0, 0, 1, HueSaturationSettings(180)), 1, 1, 0)); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 1, HueSaturationSettings(60)), 1, 0, 0)); + QVERIFY(close(HueSaturationFilter::adjust(0, 1, 1, HueSaturationSettings(60)), 0, 0, 1)); + // The last sectors: 180 to 240, 300 to 360. + const HueSaturationFilter::Color azure = HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(210)); + QVERIFY(close(azure, 0, 0.5, 1)); + const HueSaturationFilter::Color rose = HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(330)); + QVERIFY(close(rose, 1, 0, 0.5)); + // Saturation multiplies: gray stays, colour halves. + QVERIFY(close(HueSaturationFilter::adjust(0.5, 0.5, 0.5, HueSaturationSettings(0, 100)), 0.5, 0.5, 0.5)); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(0, -50)), 0.75, 0.25, 0.25)); + QVERIFY(close(HueSaturationFilter::adjust(0.75, 0.25, 0.25, HueSaturationSettings(0, 300)), 1, 0, 0)); + // Lightness pulls toward white or black. + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(0, 0, 50)), 1, 0.5, 0.5)); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(0, 0, -50)), 0.5, 0, 0)); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(0, 0, 250)), 1, 1, 1)); + // Colorize sets hue and saturation outright. + QVERIFY(close(HueSaturationFilter::adjust(0.2, 0.2, 0.2, HueSaturationSettings(240, 100, 0, true)), 0, 0, 0.4)); + QVERIFY(close(HueSaturationFilter::adjust(0.2, 0.2, 0.2, HueSaturationSettings(600, 250, 0, true)), 0, 0, 0.4)); + QVERIFY(close(HueSaturationFilter::adjust(0.2, 0.4, 0.2, HueSaturationSettings(0, -20, 50, true)), 0.65, 0.65, 0.65)); + // A range's share follows its weight at the hue. + const HueSaturationSettings reds(0, 0, -100, false, ColorRange::reds); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, reds), 0, 0, 0)); + QVERIFY(close(HueSaturationFilter::adjust(0, 0, 1, reds), 0, 0, 1)); + const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(HueSaturationSettings(20, 10, -30, false, ColorRange::yellows)); + QCOMPARE(response.size(), size_t(361)); + QVERIFY(response[30].shift == 10 && response[30].saturation == 5 && response[30].lightness == -15); + QVERIFY(response[60].shift == 20 && response[0].shift == 0 && response[360].shift == 0); + // Near gray still has a hue to turn. + QVERIFY(close(HueSaturationFilter::adjust(0.5, 0.504, 0.5, HueSaturationSettings(120)), 0.5, 0.5, 0.504)); + // Between 60 and 120 degrees green leads. + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0, HueSaturationSettings(90)), 0.5, 1, 0)); + // A hue below zero reads 330, where reds weigh half. + const HueSaturationSettings redsTurn(60, 0, 0, false, ColorRange::reds); + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0.5, redsTurn), 1, 0, 0)); + // The response rounds: hue 30.6 reads degree 31. + QVERIFY(close(HueSaturationFilter::adjust(1, 0.51, 0, redsTurn), 1, 58.6 / 60, 0)); + // Degree 360 has its own response. + QVERIFY(close(HueSaturationFilter::adjust(1, 0, 0.004, HueSaturationSettings(120)), 0.004, 1, 0)); + // Saturation caps at one before colour returns. + QVERIFY(close(HueSaturationFilter::adjust(0.7, 0.1, 0.1, HueSaturationSettings(0, 100)), 0.8, 0, 0)); + QCOMPARE(HueSaturationFilter::shiftedHue(10, HueSaturationSettings(-30)), 340.0); + QCOMPARE(HueSaturationFilter::shiftedHue(350, HueSaturationSettings(30)), 20.0); +} + +void HueSaturationModelTests::theCubeRunsRedFastest() +{ + const std::vector<float> identity = HueSaturationFilter::cube(HueSaturationSettings()); + QCOMPARE(identity.size(), size_t(33 * 33 * 33 * 4)); + const auto at = [&](int red, int green, int blue) { return identity.data() + ((blue * 33 + green) * 33 + red) * 4; }; + QVERIFY(at(1, 0, 0)[0] == 1.0f / 32 && at(1, 0, 0)[1] == 0 && at(1, 0, 0)[3] == 1); + QVERIFY(at(0, 2, 0)[1] == 2.0f / 32 && at(0, 0, 3)[2] == 3.0f / 32); + QVERIFY(std::abs(at(32, 16, 8)[0] - 1) < 1e-6f && std::abs(at(32, 16, 8)[1] - 0.5f) < 1e-6f && std::abs(at(32, 16, 8)[2] - 0.25f) < 1e-6f); + // Each corner is adjusted: red turns green. + const std::vector<float> turned = HueSaturationFilter::cube(HueSaturationSettings(120)); + const float *red = turned.data() + 32 * 4; + QVERIFY(std::abs(red[0]) < 1e-6f && std::abs(red[1] - 1) < 1e-6f && std::abs(red[2]) < 1e-6f); +} + +void HueSaturationModelTests::theFilterLooksUpUnpremultiplied() +{ + // Half-clear red turns half-clear green, alpha kept. + const AdjustedPixels half = HueSaturationFilter::run({filled(QColor(255, 0, 0, 128)), HueSaturationSettings(120), std::nullopt, QTransform(), false}); + QVERIFY((bytes(half.image) == std::array<int, 4>{0, 128, 0, 128})); + QVERIFY(!half.thumbnail); + QCOMPARE(half.image.format(), QImage::Format_RGBA8888_Premultiplied); + const AdjustedPixels clear = HueSaturationFilter::run({filled(Qt::transparent), HueSaturationSettings(0, 0, 100), std::nullopt, QTransform(), true}); + QVERIFY((bytes(clear.image) == std::array<int, 4>{0, 0, 0, 0})); + QCOMPARE(clear.thumbnail.value().size(), QSize(1, 1)); + // Between corners the cube interpolates, trilinear. + const HueSaturationSettings turn(40, -30, 20); + const std::vector<float> cube = HueSaturationFilter::cube(turn); + const AdjustedPixels between = HueSaturationFilter::run({filled(QColor(100, 150, 200)), turn, std::nullopt, QTransform(), false}); + const std::array<int, 4> made = bytes(between.image); + const double place[3] = {100.0 / 255 * 32, 150.0 / 255 * 32, 200.0 / 255 * 32}; + for (int channel = 0; channel < 3; ++channel) { + double value = 0; + for (int corner = 0; corner < 8; ++corner) { + double share = 1; + int index[3]; + for (int axis = 0; axis < 3; ++axis) { + const int low = int(place[axis]), high = corner >> axis & 1; + index[axis] = low + high; + share *= high ? place[axis] - low : 1 - (place[axis] - low); + } + value += share * cube[size_t(((index[2] * 33 + index[1]) * 33 + index[0]) * 4 + channel)]; + } + QVERIFY(std::abs(made[size_t(channel)] - value * 255) <= 0.51); + } + const AdjustedPixels white = HueSaturationFilter::run({filled(QColor(255, 255, 255)), HueSaturationSettings(0, 0, -50), std::nullopt, QTransform(), false}); + QVERIFY((bytes(white.image) == std::array<int, 4>{128, 128, 128, 255})); + // A selection blends: the left pixel turns, the right stays. + QPainterPath left; + left.addRect(0, 0, 1, 1); + const SelectionClip clip = DocumentSelection{left, false}.clip(QSizeF(2, 1)); + const AdjustedPixels selected = HueSaturationFilter::run({filled(Qt::red, 2, 1), HueSaturationSettings(120), clip, QTransform(), false}); + QVERIFY((bytes(selected.image, 0) == std::array<int, 4>{0, 255, 0, 255})); + QVERIFY((bytes(selected.image, 1) == std::array<int, 4>{255, 0, 0, 255})); +} + +void HueSaturationModelTests::previewsScaleTheLongSideToEightThousand() +{ + // Swift's Int() truncates: 8311 wide previews 7999. + for (const auto &[size, preview] : {std::pair(QSize(2, 9000), QSize(1, 8000)), std::pair(QSize(8311, 2), QSize(7999, 1))}) { + const HueSaturationEdit edit(QUuid::createUuid(), ImportedImage(filled(Qt::red, size.width(), size.height()), QImage(), QStringLiteral("Red")), + std::nullopt, LayerTransform{QPointF(0, 0), QSizeF(size)}); + QCOMPARE(edit.previewSource.size(), preview); + // The preview's far corner is the layer's. + QCOMPARE(edit.previewPixelToDocument.map(QPointF(preview.width(), preview.height())), QPointF(size.width(), size.height())); + } +} + +QTEST_GUILESS_MAIN(HueSaturationModelTests) +#include "HueSaturationModelTests.moc" diff --git a/tests/HueSaturationSessionTests.cpp b/tests/HueSaturationSessionTests.cpp new file mode 100644 index 0000000..a42aa3d --- /dev/null +++ b/tests/HueSaturationSessionTests.cpp @@ -0,0 +1,392 @@ +#include "Document/EditorSession.h" +#include "Document/ProjectWorkspace.h" +#include <QSignalSpy> +#include <QThreadPool> +#include <QtTest> + +// Hue/Saturation in the session: gates, previews, commits, signals. +namespace { +std::unique_ptr<EditorSession> filled(int width, int height, QColor colour) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height); + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(colour); + session->insert(ImportedImage(image, image, QStringLiteral("Filled"))); + return session; +} + +// 3000 by 2000 red: long enough runs to queue behind. +std::unique_ptr<EditorSession> big() +{ + return filled(3000, 2000, Qt::red); +} + +bool settled(const EditorSession &session, int revision) +{ + return QTest::qWaitFor([&] { return session.brushRevision() > revision && !session.hueSaturationPending(); }); +} + +std::optional<QImage> shown(const EditorSession &session) +{ + const HueSaturationEdit &edit = session.hueSaturation().value(); + return edit.previewImage(edit.layerID); +} + +QColor first(const QImage &image) +{ + return image.pixelColor(0, 0); +} +} + +class HueSaturationSessionTests : public QObject { + Q_OBJECT +private slots: + void beginRefusesAndLandsAPendingGradientFirst(); + void otherEditsWaitWhileItIsOpen(); + void previewsQueueAndDropWhenCancelled(); + void commitsRunOnceAndSkipAChangedLayer(); + void signalsFollowEveryStep(); + void samplesAndTargetsNeedAColour(); + void beginCommitsAnOpenTransform(); + void thePreviewFlagStaysAndIdentityClearsAtOnce(); + void aSelectionLimitsThePreview(); + void aCommitDropsTheQueueAndLandsOnce(); + void theRebuildDropsAShape(); + void samplesSkipGrayAndReadPaleColours(); + void aDoneRenderLandsBeforeTheNextStarts(); +}; + +void HueSaturationSessionTests::beginRefusesAndLandsAPendingGradientFirst() +{ + const std::unique_ptr<EditorSession> session = filled(6, 1, Qt::red); + session->beginHueSaturation(); + const QUuid opened = session->hueSaturation().value().id; + session->beginHueSaturation(); + QCOMPARE(session->hueSaturation().value().id, opened); + session->cancelHueSaturation(); + // A blank layer holds nothing to adjust. + session->addBlankLayer(); + session->beginHueSaturation(); + QVERIFY(!session->hueSaturation()); + session->undo(); + // The gradient lands, then the edit opens on its pixels. + session->selectTool(NavigationTool::gradient); + session->beginGradient(QPointF(0, 0.5)); + session->moveGradient(std::nullopt, QPointF(6, 0.5)); + const int count = session->history.undoCount(); + session->beginHueSaturation(); + QTRY_VERIFY(session->hueSaturation()); + QVERIFY(!session->gradientEdit()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->hueSaturation().value().original.identity(), session->activeLayer().value().asset.value().identity()); +} + +void HueSaturationSessionTests::otherEditsWaitWhileItIsOpen() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(8, 8); + QImage image(8, 8, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + QPainterPath corner; + corner.addRect(0, 0, 4, 4); + session.applySelection(corner, SelectionMode::replace, QStringLiteral("Select")); + QVERIFY(session.canEditLayers() && session.canContentAwareFill() && workspace.canSwitch()); + session.beginHueSaturation(); + QVERIFY(!session.canEditLayers() && !session.canContentAwareFill() && !workspace.canSwitch()); + session.beginLevels(); + QVERIFY(!session.levels()); + session.cancelHueSaturation(); + QVERIFY(session.canEditLayers() && session.canContentAwareFill() && workspace.canSwitch()); +} + +void HueSaturationSessionTests::previewsQueueAndDropWhenCancelled() +{ + const std::unique_ptr<EditorSession> session = big(); + session->beginHueSaturation(); + int revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(120), true); + session->updateHueSaturation(HueSaturationSettings(240), true); + // The newest waits for the running one, then shows. + QVERIFY(session->hueSaturationPending()); + QVERIFY(settled(*session, revision + 1)); + QCOMPARE(first(shown(*session).value()), QColor(Qt::blue)); + // Turned off mid-render, the queued and running drop. + session->updateHueSaturation(HueSaturationSettings(120), true); + session->updateHueSaturation(HueSaturationSettings(240), true); + QVERIFY(session->hueSaturationPending()); + session->updateHueSaturation(HueSaturationSettings(120), false); + QVERIFY(!session->hueSaturationPending() && !shown(*session)); + revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(60), true); + QVERIFY(settled(*session, revision)); + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(first(shown(*session).value()), QColor(Qt::yellow)); + // Cancelled mid-render, it lands on no later edit. + session->updateHueSaturation(HueSaturationSettings(120), true); + session->updateHueSaturation(HueSaturationSettings(240), true); + session->cancelHueSaturation(); + QVERIFY(!session->hueSaturationPending()); + session->beginHueSaturation(); + revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(180), true); + QVERIFY(settled(*session, revision)); + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(first(shown(*session).value()), QColor(Qt::cyan)); + QVERIFY(!session->hueSaturation().value().previewImage(QUuid::createUuid())); + session->cancelHueSaturation(); +} + +void HueSaturationSessionTests::commitsRunOnceAndSkipAChangedLayer() +{ + const std::unique_ptr<EditorSession> session = big(); + session->beginHueSaturation(); + session->setHueSampleMode(HueSampleMode::add); + session->setHueTargeting(true); + session->updateHueSaturation(HueSaturationSettings(120), false); + const int count = session->history.undoCount(); + bool once = false, twice = false; + session->commitHueSaturation([&once] { once = true; }); + QVERIFY(session->isProjectBusy() && !session->hueSampleMode() && !session->hueTargeting()); + session->commitHueSaturation([&twice] { twice = true; }); + QTRY_VERIFY(once && twice); + QVERIFY(!session->hueSaturation() && !session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), count + 1); + const ImportedImage asset = session->activeLayer().value().asset.value(); + QCOMPARE(asset.name, QString("Filled")); + QCOMPARE(asset.thumbnail.size(), QSize(96, 64)); + QCOMPARE(first(asset.image()), QColor(Qt::green)); + // The layer changed under the edit: the commit lands nothing. + session->beginHueSaturation(); + session->updateHueSaturation(HueSaturationSettings(60), false); + session->undo(); + bool done = false; + session->commitHueSaturation([&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session->hueSaturation() && !session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), count); + QCOMPARE(first(session->activeLayer().value().asset.value().image()), QColor(Qt::red)); + // Nothing to change closes at once, redrawn. + session->beginHueSaturation(); + const int revision = session->brushRevision(); + done = false; + session->commitHueSaturation([&done] { done = true; }); + QVERIFY(!session->hueSaturation() && !session->isProjectBusy()); + QCOMPARE(session->brushRevision(), revision + 1); + QTRY_VERIFY(done); + done = false; + session->commitHueSaturation([&done] { done = true; }); + QTRY_VERIFY(done); +} + +void HueSaturationSessionTests::signalsFollowEveryStep() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4, Qt::red); + QSignalSpy changed(session.get(), &EditorSession::changed); + session->beginHueSaturation(); + QCOMPARE(changed.count(), 1); + changed.clear(); + const int revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(120), true); + QCOMPARE(changed.count(), 1); + QVERIFY(settled(*session, revision)); + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(changed.count(), 2); + changed.clear(); + session->setHueSampleMode(HueSampleMode::remove); + session->setHueSampleMode(HueSampleMode::remove); + session->setHueTargeting(true); + session->setHueTargeting(true); + QCOMPARE(changed.count(), 2); + changed.clear(); + session->updateHueSaturation(HueSaturationSettings(120), false); + QCOMPARE(session->brushRevision(), revision + 2); + session->cancelHueSaturation(); + QCOMPARE(changed.count(), 2); + QCOMPARE(session->brushRevision(), revision + 3); + QVERIFY(!session->hueSampleMode() && !session->hueTargeting()); + // Without an edit, cancelling drops the armed tools alone. + changed.clear(); + session->cancelHueSaturation(); + QCOMPARE(changed.count(), 0); + session->setHueTargeting(true); + changed.clear(); + session->cancelHueSaturation(); + QCOMPARE(changed.count(), 1); + QVERIFY(!session->hueTargeting() && session->brushRevision() == revision + 3); + session->setHueSampleMode(HueSampleMode::add); + changed.clear(); + session->cancelHueSaturation(); + QCOMPARE(changed.count(), 1); + QVERIFY(!session->hueSampleMode()); +} + +void HueSaturationSessionTests::samplesAndTargetsNeedAColour() +{ + // Hue 90 exactly: halfway between Yellows and Greens. + const std::unique_ptr<EditorSession> session = filled(4, 4, QColor(127, 254, 0)); + session->beginHueSaturation(); + session->updateHueSaturation(HueSaturationSettings(0, 0, 0, false, ColorRange::reds), false); + const HueBand reds = session->hueSaturation().value().settings.band(); + // No eyedropper armed: nothing. + session->sampleHueRange(QPointF(1, 1)); + QVERIFY(session->hueSaturation().value().settings.band() == reds); + session->setHueSampleMode(HueSampleMode::replace); + HueSaturationSettings colorized(0, 0, 0, true, ColorRange::reds); + session->updateHueSaturation(colorized, false); + session->sampleHueRange(QPointF(1, 1)); + QVERIFY(session->hueSaturation().value().settings.band() == reds); + // Targeting needs its mode armed, no Colorize and colour. + session->setHueTargeting(true); + QVERIFY(!session->beginHueTargeting(QPointF(1, 1))); + HueSaturationSettings start; + start.adjustments[ColorRange::yellows] = RangeAdjustment{30, 10, 0}; + session->updateHueSaturation(start, false); + session->setHueTargeting(false); + QVERIFY(!session->beginHueTargeting(QPointF(1, 1))); + session->setHueTargeting(true); + QVERIFY(!session->beginHueTargeting(QPointF(9, 9))); + // A tie goes to the first range, as Swift's max(by:). + QVERIFY(session->beginHueTargeting(QPointF(1, 1))); + QCOMPARE(session->hueSaturation().value().settings.range, ColorRange::yellows); + // Drags start from the range's own numbers. + session->dragHueTargeting(20, true); + session->dragHueTargeting(-10, false); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows), (RangeAdjustment{40, 5, 0})); + session->dragHueTargeting(1000, false); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows).saturation, 100.0); + session->dragHueTargeting(-1000, false); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows).saturation, -100.0); + session->dragHueTargeting(1000, true); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows).hue, 180.0); + session->dragHueTargeting(-1000, true); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows).hue, -180.0); + // Ended, drags change nothing. + session->endHueTargeting(); + session->dragHueTargeting(20, true); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::yellows).hue, -180.0); + session->cancelHueSaturation(); +} + +void HueSaturationSessionTests::beginCommitsAnOpenTransform() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4, Qt::red); + session->selectTool(NavigationTool::move); + session->beginTransform(); + LayerTransform moved = session->activeLayer().value().transform; + moved.origin = QPointF(1, 0); + session->previewTransform(moved); + session->beginHueSaturation(); + QVERIFY(!session->transformEdit()); + // Its pixels map through the new place. + QCOMPARE(session->hueSaturation().value().pixelToDocument.map(QPointF(0, 0)), QPointF(1, 0)); + session->cancelHueSaturation(); +} + +void HueSaturationSessionTests::thePreviewFlagStaysAndIdentityClearsAtOnce() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4, Qt::red); + session->beginHueSaturation(); + QVERIFY(session->hueSaturation().value().preview); + session->updateHueSaturation(HueSaturationSettings(120), false); + QVERIFY(!session->hueSaturation().value().preview); + int revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(120), true); + QVERIFY(settled(*session, revision)); + // Nothing to show: the preview goes without a render. + revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(), true); + QVERIFY(!shown(*session) && !session->hueSaturationPending() && session->hueSaturation().value().preview); + QCOMPARE(session->brushRevision(), revision + 1); + session->cancelHueSaturation(); +} + +void HueSaturationSessionTests::aSelectionLimitsThePreview() +{ + const std::unique_ptr<EditorSession> session = filled(4, 1, Qt::red); + QPainterPath left; + left.addRect(0, 0, 2, 1); + session->applySelection(left, SelectionMode::replace, QStringLiteral("Select")); + session->beginHueSaturation(); + const int revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(120), true); + QVERIFY(settled(*session, revision)); + const QImage preview = shown(*session).value(); + QVERIFY(preview.pixelColor(1, 0) == QColor(Qt::green) && preview.pixelColor(2, 0) == QColor(Qt::red)); + session->cancelHueSaturation(); +} + +void HueSaturationSessionTests::aCommitDropsTheQueueAndLandsOnce() +{ + const std::unique_ptr<EditorSession> session = big(); + session->beginHueSaturation(); + session->updateHueSaturation(HueSaturationSettings(120), true); + session->updateHueSaturation(HueSaturationSettings(240), true); + QVERIFY(session->hueSaturationPending()); + const int revision = session->brushRevision(); + bool done = false; + session->commitHueSaturation([&done] { done = true; }); + QVERIFY(!session->hueSaturationPending()); + QTRY_VERIFY(done); + // The running preview dropped: one redraw, the commit's. + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(first(session->activeLayer().value().asset.value().image()), QColor(Qt::blue)); +} + +void HueSaturationSessionTests::theRebuildDropsAShape() +{ + EditorSession session; + session.createDocument(20, 20); + session.selectTool(NavigationTool::shape); + session.beginShape(QPointF(2, 2)); + session.dragShape(QPointF(12, 12), false, false); + session.finishShape(); + QVERIFY(session.activeLayer().value().shape); + session.beginHueSaturation(); + session.updateHueSaturation(HueSaturationSettings(120), false); + bool done = false; + session.commitHueSaturation([&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session.activeLayer().value().shape); + QCOMPARE(session.history.undoName(), QString("Hue/Saturation")); +} + +void HueSaturationSessionTests::samplesSkipGrayAndReadPaleColours() +{ + // Yellows centred on red moves: HSB saturation 0.1 samples. + const HueSaturationSettings yellows(0, 0, 0, false, ColorRange::yellows); + const std::unique_ptr<EditorSession> pale = filled(4, 4, QColor(255, 230, 230)); + pale->beginHueSaturation(); + pale->updateHueSaturation(yellows, false); + pale->setHueSampleMode(HueSampleMode::replace); + pale->sampleHueRange(QPointF(1, 1)); + QCOMPARE(pale->hueSaturation().value().settings.band(), (HueBand{315, 345, 15, 45})); + // Gray has no hue: the band stays. + const std::unique_ptr<EditorSession> gray = filled(4, 4, QColor(128, 128, 128)); + gray->beginHueSaturation(); + gray->updateHueSaturation(yellows, false); + gray->setHueSampleMode(HueSampleMode::replace); + gray->sampleHueRange(QPointF(1, 1)); + QCOMPARE(gray->hueSaturation().value().settings.band(), defaultBand(ColorRange::yellows)); +} + +void HueSaturationSessionTests::aDoneRenderLandsBeforeTheNextStarts() +{ + const std::unique_ptr<EditorSession> session = filled(4, 4, Qt::red); + session->beginHueSaturation(); + const int revision = session->brushRevision(); + session->updateHueSaturation(HueSaturationSettings(120), true); + // Done but undelivered: Qt would drop it for the next. + QThreadPool::globalInstance()->waitForDone(); + session->updateHueSaturation(HueSaturationSettings(240), true); + QVERIFY(session->hueSaturationPending()); + QVERIFY(settled(*session, revision + 1)); + QCOMPARE(session->brushRevision(), revision + 2); + QCOMPARE(first(shown(*session).value()), QColor(Qt::blue)); + session->cancelHueSaturation(); +} + +QTEST_GUILESS_MAIN(HueSaturationSessionTests) +#include "HueSaturationSessionTests.moc" diff --git a/tests/HueSaturationSheetFixtures.h b/tests/HueSaturationSheetFixtures.h new file mode 100644 index 0000000..9205fd7 --- /dev/null +++ b/tests/HueSaturationSheetFixtures.h @@ -0,0 +1,116 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/HueSaturationSheet.h" +#include <QAccessible> +#include <QCheckBox> +#include <QComboBox> +#include <QFrame> +#include <QLabel> +#include <QLayout> +#include <QLineEdit> +#include <QPushButton> +#include <QSlider> +#include <QToolButton> +#include <QtTest> + +// Shared by the sheet's tests: a sheet and its parts. +namespace { +// Red, yellow, green and blue: a hue for each range. +struct Sheet { + EditorSession session; + std::unique_ptr<HueSaturationSheet> sheet; + Sheet() + { + session.createDocument(4, 1); + QImage source(4, 1, QImage::Format_RGBA8888_Premultiplied); + const QColor hues[] = {Qt::red, Qt::yellow, Qt::green, Qt::blue}; + for (int x = 0; x < 4; ++x) + source.setPixelColor(x, 0, hues[x]); + session.insert(ImportedImage(source, source, QStringLiteral("Hues"))); + session.beginHueSaturation(); + sheet = std::make_unique<HueSaturationSheet>(session); + } + template <typename Widget> Widget &child(const char *name) const + { + auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; + } + template <typename Widget> Widget &titled(const QString &text) const + { + for (Widget *widget : sheet->findChildren<Widget *>()) { + if (widget->text() == text) + return *widget; + } + throw std::runtime_error(text.toStdString()); + } + const HueSaturationSettings &settings() const { return session.hueSaturation().value().settings; } + void set(const HueSaturationSettings &settings) { session.updateHueSaturation(settings, true); } + bool show() + { + sheet->show(); + return QTest::qWaitForWindowActive(sheet.get()); + } +}; + +inline QString name(QWidget &widget) +{ + return QAccessible::queryAccessibleInterface(&widget)->text(QAccessible::Name); +} + +// The name a label's relation gives, as Linux reads it. +inline QString labelled(QWidget &widget) +{ + const auto labels = QAccessible::queryAccessibleInterface(&widget)->relations(QAccessible::Label); + return labels.size() == 1 ? labels.first().first->text(QAccessible::Name) : QString(); +} + +inline void type(QLineEdit &field, const QString &text) +{ + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); +} + +// A widget's drawing over white, without its parent's. +inline QImage drawn(QWidget &widget) +{ + QImage canvas(widget.size(), QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::white); + widget.render(&canvas, QPoint(), QRegion(), QWidget::DrawChildren); + return canvas; +} + +// White under a quarter of the highlight, as Qt blends. +inline QColor tinted(const QColor &highlight) +{ + QImage blended(1, 1, QImage::Format_ARGB32_Premultiplied); + blended.fill(Qt::white); + QColor quarter = highlight; + quarter.setAlphaF(0.25f); + QPainter(&blended).fillRect(0, 0, 1, 1, quarter); + return blended.pixelColor(0, 0); +} + +inline void send(QWidget &widget, double x, QEvent::Type type, Qt::MouseButtons held, Qt::MouseButton button = Qt::LeftButton) +{ + const QPointF at(x, 6); + QMouseEvent event(type, at, widget.mapToGlobal(at), type == QEvent::MouseMove ? Qt::NoButton : button, held, Qt::NoModifier); + QApplication::sendEvent(&widget, &event); +} + +// Counts a widget's paint events. +class Paints : public QObject { +public: + explicit Paints(QWidget &widget) { widget.installEventFilter(this); } + int count = 0; + +protected: + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; +} diff --git a/tests/HueSaturationSheetTests.cpp b/tests/HueSaturationSheetTests.cpp new file mode 100644 index 0000000..22b6ada --- /dev/null +++ b/tests/HueSaturationSheetTests.cpp @@ -0,0 +1,306 @@ +#include "HueSaturationSheetFixtures.h" + +// Swift's HueSaturationSheet: its controls, bindings and states. +class HueSaturationSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetIsLaidOutAsSwifts(); + void aRangeShowsItsSpectrumAndSamplers(); + void slidersAndFieldsWriteTheirValue(); + void fieldsClampOnReturnAndPrintWhole(); + void samplersAndTargetingToggle(); + void theControlsFollowTheSession(); + void togglesResetAndTheButtons(); +}; + +void HueSaturationSheetTests::theSheetIsLaidOutAsSwifts() +{ + Sheet shown; + QVERIFY(shown.show()); + QCOMPARE(shown.sheet->width(), 460); + QCOMPARE(shown.sheet->layout()->contentsMargins(), QMargins(24, 24, 24, 24)); + QCOMPARE(shown.sheet->layout()->spacing(), 16); + QComboBox &range = shown.child<QComboBox>("hueRange"); + QCOMPARE(range.count(), 7); + QCOMPARE(range.itemText(0), QString("Master")); + QCOMPARE(range.itemText(6), QString("Magentas")); + QCOMPARE(range.width(), 160); + // Swift hides the picker's label; it still names it. + QCOMPARE(labelled(range), QString("Range")); + QVERIFY(shown.titled<QLabel>(QStringLiteral("Range")).isHidden()); + QVERIFY(range.isEnabled()); + // Master: no spectrum, no eyedroppers; targeting stays. + for (const char *sampler : {"hueSample", "hueAdd", "hueRemove"}) + QVERIFY2(shown.child<QToolButton>(sampler).isHidden(), sampler); + QVERIFY(!shown.child<QToolButton>("hueTargeting").isHidden()); + QVERIFY(shown.child<QWidget>("hueSpectrum").isHidden()); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Apply outside this range instead")).isHidden()); + for (QFrame *frame : shown.sheet->findChildren<QFrame *>()) { + if (frame->frameShape() == QFrame::VLine || frame->frameShape() == QFrame::HLine) + QCOMPARE(frame->foregroundRole(), QPalette::Mid); + if (frame->frameShape() == QFrame::VLine) + QVERIFY(frame->isHidden()); + } + const std::pair<const char *, int> rows[] = {{"Hue", 18000}, {"Saturation", 10000}, {"Lightness", 10000}}; + for (const auto &[title, reach] : rows) { + const QString lower = QString::fromLatin1(title).toLower(); + QSlider &slider = *shown.sheet->findChild<QSlider *>(lower + QStringLiteral("Slider")); + QVERIFY(slider.minimum() == -reach && slider.maximum() == reach && slider.value() == 0); + QCOMPARE(labelled(slider), QString(title)); + QLineEdit &field = *shown.sheet->findChild<QLineEdit *>(lower + QStringLiteral("Field")); + QCOMPARE(field.text(), QString("0")); + QCOMPARE(field.width(), 48); + QCOMPARE(field.alignment(), Qt::AlignRight); + QCOMPARE(name(field), QString(title)); + QCOMPARE(field.placeholderText(), QString(title)); + // Title 76 wide; slider 10 on; unit 2 past. + QLabel *heading = field.parentWidget()->findChildren<QLabel *>().first(); + QLabel *unit = field.parentWidget()->findChildren<QLabel *>().last(); + QCOMPARE(heading->width(), 76); + QCOMPARE(slider.x() - heading->geometry().right() - 1, 10); + QCOMPARE(unit->x() - field.geometry().right() - 1, 2); + QCOMPARE(unit->text(), QString(lower == QStringLiteral("hue") ? "°" : "")); + } + QVERIFY(!shown.titled<QCheckBox>(QStringLiteral("Colorize")).isChecked()); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Preview")).isChecked()); + // Toggles 18 apart, the buttons at their own widths. + QCheckBox &colorize = shown.titled<QCheckBox>(QStringLiteral("Colorize")), &preview = shown.titled<QCheckBox>(QStringLiteral("Preview")); + QCOMPARE(preview.x() - colorize.geometry().right() - 1, 18); + QPushButton &ok = shown.titled<QPushButton>(QStringLiteral("OK")), &reset = shown.titled<QPushButton>(QStringLiteral("Reset")); + QVERIFY(ok.isDefault()); + for (QPushButton *button : {&ok, &reset, &shown.titled<QPushButton>(QStringLiteral("Cancel"))}) { + QCOMPARE(button->width(), button->sizeHint().width()); + QVERIFY(button == &ok || !button->autoDefault()); + } + QLabel &limited = shown.child<QLabel>("hueLimited"); + QCOMPARE(limited.text(), QString("Limited to the selection")); + QCOMPARE(limited.font().pixelSize(), 12); + QCOMPARE(limited.foregroundRole(), QPalette::PlaceholderText); + QVERIFY(limited.isHidden()); + // Swift's help and names for each tool. + QToolButton &targeting = shown.child<QToolButton>("hueTargeting"); + QCOMPARE(targeting.toolTip(), QString("Targeted adjustment: drag on the image to change that color's saturation, or its hue with Ctrl held")); + QCOMPARE(name(targeting), QString("Targeted adjustment")); + QCOMPARE(targeting.size(), QSize(24, 20)); + const std::pair<const char *, HueSampleMode> samplers[] = {{"hueSample", HueSampleMode::replace}, {"hueAdd", HueSampleMode::add}, {"hueRemove", HueSampleMode::remove}}; + for (const auto &[object, mode] : samplers) { + QToolButton &sampler = shown.child<QToolButton>(object); + QCOMPARE(sampler.toolTip(), help(mode)); + QCOMPARE(name(sampler), rawValue(mode) + QStringLiteral(" color")); + QCOMPARE(sampler.size(), QSize(24, 20)); + } + // Over a selection it is limited; the sheet says so. + shown.session.cancelHueSaturation(); + QPainterPath left; + left.addRect(0, 0, 2, 1); + shown.session.applySelection(left, SelectionMode::replace, QStringLiteral("Select")); + shown.session.beginHueSaturation(); + QVERIFY(!limited.isHidden()); +} + +void HueSaturationSheetTests::aRangeShowsItsSpectrumAndSamplers() +{ + Sheet shown; + QVERIFY(shown.show()); + QComboBox &range = shown.child<QComboBox>("hueRange"); + range.setFocus(); + QTest::keyClick(&range, Qt::Key_Down); + QCOMPARE(shown.settings().range, ColorRange::reds); + for (const char *sampler : {"hueSample", "hueAdd", "hueRemove"}) + QVERIFY2(shown.child<QToolButton>(sampler).isVisible(), sampler); + QToolButton &sample = shown.child<QToolButton>("hueSample"), &add = shown.child<QToolButton>("hueAdd"); + QCOMPARE(add.x() - sample.geometry().right() - 1, 6); + QFrame *divider = nullptr; + for (QFrame *frame : shown.sheet->findChildren<QFrame *>()) { + if (frame->frameShape() == QFrame::VLine) + divider = frame; + } + QVERIFY(divider && divider->isVisible() && divider->height() == 16); + QVERIFY(shown.child<QWidget>("hueSpectrum").isVisible()); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Apply outside this range instead")).isVisible()); + QLabel &readout = shown.child<QLabel>("spectrumReadout"); + QCOMPARE(readout.text(), QString("315° 345° 15° 45°")); + QCOMPARE(readout.font().pixelSize(), 10); + QCOMPARE(readout.foregroundRole(), QPalette::PlaceholderText); + // Colorize: hue and saturation set outright, no range. + shown.titled<QCheckBox>(QStringLiteral("Colorize")).click(); + QVERIFY(shown.settings() == HueSaturationSettings::colorizeStart()); + QVERIFY(!range.isEnabled()); + QVERIFY(!shown.child<QToolButton>("hueTargeting").isVisible() && !shown.child<QWidget>("hueSpectrum").isVisible()); + QSlider &hue = *shown.sheet->findChild<QSlider *>(QStringLiteral("hueSlider")); + QSlider &saturation = *shown.sheet->findChild<QSlider *>(QStringLiteral("saturationSlider")); + QVERIFY(hue.minimum() == 0 && hue.maximum() == 36000); + QVERIFY(saturation.minimum() == 0 && saturation.maximum() == 10000 && saturation.value() == 2500); + QCOMPARE(shown.child<QLineEdit>("saturationField").text(), QString("25")); + // Off again: everything back to the start. + shown.titled<QCheckBox>(QStringLiteral("Colorize")).click(); + QVERIFY(shown.settings() == HueSaturationSettings()); + QVERIFY(range.isEnabled() && shown.child<QToolButton>("hueTargeting").isVisible()); + // A range under Colorize still shows no spectrum. + shown.set(HueSaturationSettings(0, 25, 0, true, ColorRange::reds)); + QVERIFY(!shown.child<QWidget>("hueSpectrum").isVisible() && !sample.isVisible()); +} + +void HueSaturationSheetTests::slidersAndFieldsWriteTheirValue() +{ + Sheet shown; + QVERIFY(shown.show()); + QSlider &hue = *shown.sheet->findChild<QSlider *>(QStringLiteral("hueSlider")); + QSlider &saturationSlider = *shown.sheet->findChild<QSlider *>(QStringLiteral("saturationSlider")); + hue.setValue(3060); + QCOMPARE(shown.settings().hue(), 30.6); + QLineEdit &hueField = shown.child<QLineEdit>("hueField"); + QCOMPARE(hueField.text(), QString("31")); + // Visited without typing, the shown whole number stays unread. + hueField.setFocus(); + QTRY_VERIFY(hueField.hasFocus()); + QLineEdit &saturation = shown.child<QLineEdit>("saturationField"); + saturation.setFocus(); + QTRY_VERIFY(saturation.hasFocus()); + QCOMPARE(shown.settings().hue(), 30.6); + // Typed and left, a number applies unclamped. + type(saturation, QStringLiteral("250")); + hueField.setFocus(); + QCOMPARE(shown.settings().saturation(), 250.0); + QCOMPARE(saturationSlider.value(), 10000); + QCOMPARE(saturation.text(), QString("250")); + // Applied, the field follows the session again. + saturationSlider.setValue(-2000); + QCOMPARE(saturation.text(), QString("-20")); + // What is no finite number reverts. + for (const char *text : {"abc", "inf", "nan"}) { + type(hueField, QString::fromLatin1(text)); + saturation.setFocus(); + QCOMPARE(shown.settings().hue(), 30.6); + QCOMPARE(hueField.text(), QString("31")); + } + // Past any int, the slider rests at its end. + type(saturation, QStringLiteral("1e300")); + hueField.setFocus(); + QCOMPARE(saturationSlider.value(), 10000); + // Sliders show Colorize's range without writing. + HueSaturationSettings colour = HueSaturationSettings::colorizeStart(); + colour.setHue(-40); + shown.set(colour); + QCOMPARE(hue.value(), 0); + QCOMPARE(shown.settings().hue(), -40.0); + QCOMPARE(hueField.text(), QString("-40")); + // Typing waits through other values; its own slider replaces it. + type(hueField, QStringLiteral("4")); + saturationSlider.setValue(1000); + QVERIFY(hueField.text() == "4" && hueField.isModified()); + hue.setValue(1000); + QVERIFY(hueField.text() == "10" && !hueField.isModified() && hueField.hasFocus()); + saturation.setFocus(); + QCOMPARE(shown.settings().hue(), 10.0); +} + +void HueSaturationSheetTests::fieldsClampOnReturnAndPrintWhole() +{ + Sheet shown; + QVERIFY(shown.show()); + // Return clamps into the slider's range. + QLineEdit &lightness = shown.child<QLineEdit>("lightnessField"); + type(lightness, QStringLiteral("-150")); + QTest::keyClick(&lightness, Qt::Key_Return); + QCOMPARE(shown.settings().lightness(), -100.0); + QCOMPARE(lightness.text(), QString("-100")); + // Under Colorize, Colorize's range. + shown.set(HueSaturationSettings::colorizeStart()); + QLineEdit &hue = shown.child<QLineEdit>("hueField"); + type(hue, QStringLiteral("400")); + QTest::keyClick(&hue, Qt::Key_Return); + QCOMPARE(shown.settings().hue(), 360.0); + // The user's decimal mark reads; digits print ungrouped. + lightness.setLocale(QLocale(QLocale::German)); + type(lightness, QStringLiteral("12,4")); + hue.setFocus(); + QCOMPARE(shown.settings().lightness(), 12.4); + QCOMPARE(lightness.text(), QString("12")); + type(lightness, QStringLiteral("12345")); + hue.setFocus(); + QCOMPARE(shown.settings().lightness(), 12345.0); + QCOMPARE(lightness.text(), QString("12345")); +} + +void HueSaturationSheetTests::samplersAndTargetingToggle() +{ + Sheet shown; + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::reds)); + QVERIFY(shown.show()); + QToolButton &sample = shown.child<QToolButton>("hueSample"), &add = shown.child<QToolButton>("hueAdd"), + &remove = shown.child<QToolButton>("hueRemove"), &targeting = shown.child<QToolButton>("hueTargeting"); + shown.session.setHueTargeting(true); + sample.click(); + QVERIFY(shown.session.hueSampleMode() == HueSampleMode::replace && !shown.session.hueTargeting()); + QVERIFY(sample.isChecked() && !add.isChecked() && !targeting.isChecked()); + // A second click puts the eyedropper away. + sample.click(); + QVERIFY(!shown.session.hueSampleMode() && !sample.isChecked()); + add.click(); + QVERIFY(shown.session.hueSampleMode() == HueSampleMode::add && add.isChecked()); + targeting.click(); + QVERIFY(!shown.session.hueSampleMode() && shown.session.hueTargeting()); + QVERIFY(targeting.isChecked() && !add.isChecked()); + remove.click(); + QVERIFY(shown.session.hueSampleMode() == HueSampleMode::remove && !shown.session.hueTargeting()); + targeting.click(); + targeting.click(); + QVERIFY(!shown.session.hueSampleMode() && !shown.session.hueTargeting() && !targeting.isChecked()); +} + +void HueSaturationSheetTests::theControlsFollowTheSession() +{ + Sheet shown; + QComboBox &range = shown.child<QComboBox>("hueRange"); + HueSaturationSettings yellows(0, 0, 0, false, ColorRange::yellows); + yellows.invertRange = true; + shown.session.updateHueSaturation(yellows, false); + QCOMPARE(range.currentText(), QString("Yellows")); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Apply outside this range instead")).isChecked()); + QVERIFY(!shown.titled<QCheckBox>(QStringLiteral("Preview")).isChecked()); + shown.session.updateHueSaturation(HueSaturationSettings::colorizeStart(), true); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Colorize")).isChecked()); + QVERIFY(shown.titled<QCheckBox>(QStringLiteral("Preview")).isChecked()); + QCOMPARE(range.currentText(), QString("Master")); +} + +void HueSaturationSheetTests::togglesResetAndTheButtons() +{ + Sheet shown; + shown.set(HueSaturationSettings(30, 0, 0, false, ColorRange::reds)); + QCheckBox &preview = shown.titled<QCheckBox>(QStringLiteral("Preview")); + preview.click(); + QVERIFY(!shown.session.hueSaturation().value().preview); + // Off, the controls keep Preview off. + shown.sheet->findChild<QSlider *>(QStringLiteral("lightnessSlider"))->setValue(500); + QVERIFY(!shown.session.hueSaturation().value().preview && shown.settings().lightness() == 5); + preview.click(); + QVERIFY(shown.session.hueSaturation().value().preview); + QCheckBox &invert = shown.titled<QCheckBox>(QStringLiteral("Apply outside this range instead")); + invert.click(); + QVERIFY(shown.settings().invertRange); + invert.click(); + QVERIFY(!shown.settings().invertRange); + // Reset: back to the start, or Colorize's start. + shown.titled<QPushButton>(QStringLiteral("Reset")).click(); + QVERIFY(shown.settings() == HueSaturationSettings()); + HueSaturationSettings colour = HueSaturationSettings::colorizeStart(); + colour.setHue(200); + shown.set(colour); + shown.titled<QPushButton>(QStringLiteral("Reset")).click(); + QVERIFY(shown.settings() == HueSaturationSettings::colorizeStart()); + // OK commits one step; Cancel leaves the pixels. + shown.titled<QPushButton>(QStringLiteral("OK")).click(); + QTRY_VERIFY(!shown.session.hueSaturation()); + QCOMPARE(shown.session.history.undoName(), QString("Hue/Saturation")); + const int steps = shown.session.history.undoCount(); + shown.session.beginHueSaturation(); + shown.set(HueSaturationSettings(90)); + shown.titled<QPushButton>(QStringLiteral("Cancel")).click(); + QVERIFY(!shown.session.hueSaturation()); + QCOMPARE(shown.session.history.undoCount(), steps); +} + +QTEST_MAIN(HueSaturationSheetTests) +#include "HueSaturationSheetTests.moc" diff --git a/tests/HueSaturationSpectrumTests.cpp b/tests/HueSaturationSpectrumTests.cpp new file mode 100644 index 0000000..54461be --- /dev/null +++ b/tests/HueSaturationSpectrumTests.cpp @@ -0,0 +1,181 @@ +#include "HueSaturationSheetFixtures.h" + +// Swift's SpectrumEditor and the sheet's drawn glyphs. +namespace { +// How much ink a glyph lays down, over white. +int ink(const QImage &image) +{ + int total = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + total += 255 - qGray(image.pixel(x, y)); + } + return total; +} + +// The box round every inked pixel. +QRect inked(const QImage &image) +{ + QRect box; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qGray(image.pixel(x, y)) < 255) + box |= QRect(x, y, 1, 1); + } + } + return box; +} +} + +class HueSaturationSpectrumTests : public QObject { + Q_OBJECT +private slots: + void theSamplersDrawSwiftsGlyphs(); + void theSpectrumDragsItsNearestHandle(); + void theSpectrumShowsTheHuesAndTheirShift(); + void theSpectrumRepaintsWithTheBand(); +}; + +void HueSaturationSpectrumTests::theSamplersDrawSwiftsGlyphs() +{ + Sheet shown; + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::reds)); + QVERIFY(shown.show()); + QToolButton &sample = shown.child<QToolButton>("hueSample"), &add = shown.child<QToolButton>("hueAdd"), + &remove = shown.child<QToolButton>("hueRemove"), &targeting = shown.child<QToolButton>("hueTargeting"); + const QColor black = sample.palette().color(QPalette::WindowText), white(Qt::white); + // Chosen, a quarter of the accent fills a rounded frame. + QCOMPARE(drawn(sample).pixelColor(1, 10), white); + shown.session.setHueSampleMode(HueSampleMode::replace); + QCOMPARE(drawn(sample).pixelColor(1, 10), tinted(sample.palette().color(QPalette::Highlight))); + QCOMPARE(drawn(sample).pixelColor(0, 0), white); + shown.session.setHueSampleMode(std::nullopt); + // Add and Remove carry a disc; a bar cuts across. + QCOMPARE(drawn(sample).pixelColor(16, 12), white); + QCOMPARE(drawn(add).pixelColor(16, 12), black); + QCOMPARE(drawn(remove).pixelColor(16, 12), black); + const QColor bar = drawn(remove).pixelColor(16, 13); + QVERIFY(bar != black && bar != white); + // Add's upright bar cuts where Remove's disc is whole. + QCOMPARE(drawn(remove).pixelColor(17, 15), black); + const QColor cut = drawn(add).pixelColor(17, 15); + QVERIFY(cut != black && cut != white); + // The eyedropper's bulb, top right, in each. + for (QToolButton *each : {&sample, &add, &remove}) + QCOMPARE(drawn(*each).pixelColor(15, 6), black); + // The hand: an outline, hollow in its palm. + const QImage hand = drawn(targeting); + QCOMPARE(hand.pixelColor(12, 11), white); + // Every stroke measured once and pinned. + QCOMPARE(ink(drawn(sample)), 10478); + QCOMPARE(ink(drawn(add)), 20338); + QCOMPARE(ink(drawn(remove)), 21338); + QCOMPARE(ink(hand), 13232); + QCOMPARE(inked(drawn(sample)), QRect(6, 4, 12, 12)); + QCOMPARE(inked(drawn(add)), QRect(6, 4, 16, 14)); + QCOMPARE(inked(drawn(remove)), QRect(6, 4, 16, 14)); + QCOMPARE(inked(hand), QRect(6, 3, 14, 16)); +} + +void HueSaturationSpectrumTests::theSpectrumDragsItsNearestHandle() +{ + Sheet shown; + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::reds)); + QVERIFY(shown.show()); + QWidget &handles = shown.child<QWidget>("spectrumHandles"); + QCOMPARE(handles.height(), 12); + const double width = handles.width(); + QCOMPARE(width, 412.0); + // Degrees in sixty-fourths of a turn map back exactly. + const auto x = [width](double degrees) { return degrees / 360 * width; }; + // 22.5 degrees is nearest the core's end, at 15. + send(handles, x(22.5), QEvent::MouseButtonPress, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 345, 22.5, 45})); + // Held, it keeps its handle though 45 is nearer. + send(handles, x(39.375), QEvent::MouseMove, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 345, 39.375, 45})); + send(handles, x(39.375), QEvent::MouseButtonRelease, Qt::NoButton); + // A new press takes the nearest again. + send(handles, x(43.59375), QEvent::MouseButtonPress, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 345, 39.375, 43.59375})); + // Past the end, the degrees stop at 360. + send(handles, width + 50, QEvent::MouseButtonPress, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 0, 39.375, 43.59375})); + QCOMPARE(shown.child<QLabel>("spectrumReadout").text(), QString("315° 0° 39° 44°")); + // Nearness wraps: 348.75 is nearer 0 than 315. + send(handles, x(348.75), QEvent::MouseButtonPress, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 348.75, 39.375, 43.59375})); + // Before the start, the degrees stop at 0. + send(handles, -30, QEvent::MouseButtonPress, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 0, 39.375, 43.59375})); + // The right button neither presses nor drags a handle. + send(handles, x(90), QEvent::MouseButtonPress, Qt::RightButton, Qt::RightButton); + send(handles, x(5.625), QEvent::MouseMove, Qt::RightButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 0, 39.375, 43.59375})); + // Past its shoulder the held core end is refused. + send(handles, x(39.375), QEvent::MouseButtonPress, Qt::LeftButton); + send(handles, x(50.625), QEvent::MouseMove, Qt::LeftButton); + QCOMPARE(shown.settings().band(), (HueBand{315, 0, 39.375, 43.59375})); +} + +void HueSaturationSpectrumTests::theSpectrumShowsTheHuesAndTheirShift() +{ + Sheet shown; + shown.set(HueSaturationSettings(60, 0, 0, false, ColorRange::reds)); + QVERIFY(shown.show()); + QWidget &editor = shown.child<QWidget>("hueSpectrum"); + QWidget &before = shown.child<QWidget>("spectrumBefore"), &after = shown.child<QWidget>("spectrumAfter"), &handles = shown.child<QWidget>("spectrumHandles"); + QLabel &readout = shown.child<QLabel>("spectrumReadout"); + QCOMPARE(before.height(), 16); + QCOMPARE(after.height(), 16); + // Five apart, the readout centred below. + QCOMPARE(handles.y() - before.geometry().bottom() - 1, 5); + QCOMPARE(readout.y() - after.geometry().bottom() - 1, 5); + QVERIFY(std::abs(2 * readout.x() + readout.width() - editor.width()) <= 1); + QCOMPARE(readout.width(), readout.sizeHint().width()); + // Slices of five degrees: red first, cyan halfway. + const int middle = before.width() / 2 + 2; + QCOMPARE(drawn(before).pixelColor(3, 8), QColor(Qt::red)); + QCOMPARE(drawn(before).pixelColor(8, 8).rgb(), QColor::fromHsvF(5.0f / 360, 1, 1).rgb()); + QCOMPARE(drawn(before).pixelColor(middle, 8), QColor(Qt::cyan)); + // Slices overlap: no white between them. + QCOMPARE(drawn(before).pixelColor(5, 8).blue(), 0); + // Corners round off over white. + QVERIFY(drawn(before).pixelColor(0, 0).green() > 200); + // Reds turn yellow; cyan, outside their band, stays. + QCOMPARE(drawn(after).pixelColor(3, 8), QColor(Qt::yellow)); + QCOMPARE(drawn(after).pixelColor(middle, 8), QColor(Qt::cyan)); + // Bars for the core, blocks for the shoulders. + const QColor black = handles.palette().color(QPalette::WindowText), white(Qt::white); + const QImage marks = drawn(handles); + const int start = int(345.0 / 360 * handles.width()), end = int(15.0 / 360 * handles.width()), shoulder = int(45.0 / 360 * handles.width()); + QVERIFY(marks.pixelColor(start, 0) == black && marks.pixelColor(start, 11) == black); + QVERIFY(marks.pixelColor(end, 0) == black && marks.pixelColor(end, 11) == black); + QVERIFY(marks.pixelColor(shoulder, 6) == black && marks.pixelColor(shoulder, 1) == white && marks.pixelColor(shoulder, 10) == white); + QVERIFY(marks.pixelColor(shoulder - 3, 6) == black && marks.pixelColor(shoulder + 3, 6) == black && marks.pixelColor(shoulder + 5, 6) == white); + QCOMPARE(marks.pixelColor(100, 6), white); + // The marks take the window's text colour. + QPalette red = shown.sheet->palette(); + red.setColor(QPalette::WindowText, Qt::red); + shown.sheet->setPalette(red); + QCOMPARE(drawn(handles).pixelColor(start, 0), QColor(Qt::red)); +} + +void HueSaturationSpectrumTests::theSpectrumRepaintsWithTheBand() +{ + Sheet shown; + shown.set(HueSaturationSettings(0, 0, 0, false, ColorRange::reds)); + QVERIFY(shown.show()); + Paints handles(shown.child<QWidget>("spectrumHandles")), after(shown.child<QWidget>("spectrumAfter")); + QTest::qWait(50); + handles.count = after.count = 0; + HueSaturationSettings settings = shown.settings(); + HueBand band = settings.band(); + band.setHandle(2, 30); + settings.setBand(band); + shown.set(settings); + QTRY_VERIFY(handles.count > 0 && after.count > 0); +} + +QTEST_MAIN(HueSaturationSpectrumTests) +#include "HueSaturationSpectrumTests.moc" diff --git a/tests/HueSaturationTests.cpp b/tests/HueSaturationTests.cpp new file mode 100644 index 0000000..4455d7c --- /dev/null +++ b/tests/HueSaturationTests.cpp @@ -0,0 +1,323 @@ +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QtTest> + +// Swift's HueSaturationTests. +namespace { +// 40 by 20: red, gray, a half-clear blue strip. +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(40, 20); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + for (int y = 0; y < 20; ++y) { + for (int x = 0; x < 40; ++x) + image.setPixelColor(x, y, y >= 16 ? QColor(0, 0, 255, 128) : x < 20 ? QColor(255, 0, 0) : QColor(128, 128, 128)); + } + session->insert(ImportedImage(image, image, QStringLiteral("Colors"))); + return session; +} + +// 40 by 20: red left, blue right, both opaque. +std::unique_ptr<EditorSession> redAndBlue() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(40, 20); + QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 20; ++y) { + for (int x = 0; x < 40; ++x) + image.setPixelColor(x, y, x < 20 ? QColor(255, 0, 0) : QColor(0, 0, 255)); + } + session->insert(ImportedImage(image, image, QStringLiteral("RedBlue"))); + return session; +} + +// Premultiplied RGBA bytes of an image's pixel. +std::array<int, 4> bytesAt(const QImage &image, int x, int y) +{ + const uchar *pixel = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied).constScanLine(y) + x * 4; + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} + +std::array<int, 4> pixel(const EditorSession &session, int x, int y) +{ + return bytesAt(ImageExporter::render(session.projectSnapshot().value()).image, x, y); +} + +// The cube is interpolated: a few levels either way. +bool near(const std::array<int, 4> &value, const std::array<int, 4> &target, int tolerance = 8) +{ + for (size_t index = 0; index < 4; ++index) { + if (std::abs(value[index] - target[index]) > tolerance) + return false; + } + return true; +} + +// Swift's `await hueSaturationTask?.value`: a preview has landed since. +bool previewSettles(const EditorSession &session, int revision) +{ + return QTest::qWaitFor([&] { return session.brushRevision() > revision && !session.hueSaturationPending(); }); +} + +// Previews what the settings make and waits for it. +bool preview(EditorSession &session, const HueSaturationSettings &settings) +{ + const int revision = session.brushRevision(); + session.updateHueSaturation(settings, true); + return previewSettles(session, revision); +} + +void commit(EditorSession &session) +{ + bool done = false; + session.commitHueSaturation([&done] { done = true; }); + if (!QTest::qWaitFor([&] { return done; })) + throw std::runtime_error("the commit never finished"); +} + +void apply(EditorSession &session, const HueSaturationSettings &settings) +{ + session.beginHueSaturation(); + session.updateHueSaturation(settings, true); + commit(session); +} + +std::array<int, 4> previewPixel(const EditorSession &session, int x, int y) +{ + const HueSaturationEdit &edit = session.hueSaturation().value(); + return bytesAt(edit.previewImage(edit.layerID).value(), x, y); +} + +HueSaturationSettings withRange(ColorRange range, RangeAdjustment adjustment) +{ + return HueSaturationSettings(adjustment.hue, adjustment.saturation, adjustment.lightness, false, range); +} +} + +class HueSaturationTests : public QObject { + Q_OBJECT +private slots: + void defaultsAreAnExactNoOp(); + void hueRotatesSaturationAndLightnessFollowPhotoshopRanges(); + void colorizeGivesEverythingOneHueAndKeepsAlpha(); + void adjustmentStaysInsideTheSelectionAndIsOneUndoStep(); + void previewIsLiveDoesNotTouchTheDocumentAndNeverAccumulates(); + void bandWeightsRampThroughFalloffAndWrapAround(); + void colorRangesAdjustIndependently(); + void rangesLeaveOtherHuesAloneAndInvertFlipsTheBand(); + void slidersEditTheSelectedRangeAndTheAfterBarFollowsHueShifts(); + void eyedroppersRecenterWidenAndNarrowTheBand(); + void targetedAdjustmentPicksTheRangeUnderTheCursor(); + void samplingNeedsAColorRangeAndAColorfulPixel(); +}; + +void HueSaturationTests::defaultsAreAnExactNoOp() +{ + const std::unique_ptr<EditorSession> session = makeSession(); + const std::optional<CanvasDocument> before = session->document(); + const int count = session->history.undoCount(); + session->beginHueSaturation(); + // Other edits wait. + QVERIFY(session->hueSaturation() && !session->canEditLayers()); + session->updateHueSaturation(HueSaturationSettings(), true); + commit(*session); + QVERIFY(session->document() == before && session->history.undoCount() == count); + QVERIFY(!session->hueSaturation() && session->canEditLayers()); +} + +void HueSaturationTests::hueRotatesSaturationAndLightnessFollowPhotoshopRanges() +{ + const std::unique_ptr<EditorSession> session = makeSession(); + apply(*session, HueSaturationSettings(120)); + QVERIFY(near(pixel(*session, 5, 5), {0, 255, 0, 255})); + QVERIFY(near(pixel(*session, 30, 5), {128, 128, 128, 255})); + session->undo(); + apply(*session, HueSaturationSettings(0, -100)); + const std::array<int, 4> gray = pixel(*session, 5, 5); + QVERIFY(gray[0] == gray[1] && gray[1] == gray[2] && gray[3] == 255); + session->undo(); + apply(*session, HueSaturationSettings(0, 0, 100)); + QVERIFY(near(pixel(*session, 5, 5), {255, 255, 255, 255})); + session->undo(); + apply(*session, HueSaturationSettings(0, 0, -100)); + QVERIFY(near(pixel(*session, 5, 5), {0, 0, 0, 255})); +} + +void HueSaturationTests::colorizeGivesEverythingOneHueAndKeepsAlpha() +{ + const std::unique_ptr<EditorSession> session = makeSession(); + apply(*session, HueSaturationSettings(240, 100, 0, true)); + const std::array<int, 4> left = pixel(*session, 5, 5), right = pixel(*session, 30, 5); + QVERIFY(left[2] > left[0] && right[2] > right[0]); + QCOMPARE(left[3], 255); + // The half-clear strip keeps its alpha. + QVERIFY(std::abs(pixel(*session, 5, 18)[3] - 128) <= 2); +} + +void HueSaturationTests::adjustmentStaysInsideTheSelectionAndIsOneUndoStep() +{ + const std::unique_ptr<EditorSession> session = makeSession(); + QPainterPath left; + left.addRect(0, 0, 10, 20); + session->applySelection(left, SelectionMode::replace, QStringLiteral("Select")); + const int count = session->history.undoCount(); + apply(*session, HueSaturationSettings(120)); + QVERIFY(session->history.undoCount() == count + 1 && session->history.undoName() == QString("Hue/Saturation")); + QVERIFY(near(pixel(*session, 5, 5), {0, 255, 0, 255})); + QVERIFY(near(pixel(*session, 15, 5), {255, 0, 0, 255})); + session->undo(); + QVERIFY(near(pixel(*session, 5, 5), {255, 0, 0, 255})); +} + +void HueSaturationTests::previewIsLiveDoesNotTouchTheDocumentAndNeverAccumulates() +{ + const std::unique_ptr<EditorSession> session = makeSession(); + const std::optional<CanvasDocument> before = session->document(); + const int count = session->history.undoCount(); + session->beginHueSaturation(); + QVERIFY(preview(*session, HueSaturationSettings(120))); + QVERIFY(near(previewPixel(*session, 5, 5), {0, 255, 0, 255})); + QVERIFY(session->document() == before && session->history.undoCount() == count); + // Each preview starts from the original, never stacking. + QVERIFY(preview(*session, HueSaturationSettings(240))); + QVERIFY(near(previewPixel(*session, 5, 5), {0, 0, 255, 255})); + // Preview off drops back to the layer's own pixels. + session->updateHueSaturation(HueSaturationSettings(240), false); + const HueSaturationEdit &edit = session->hueSaturation().value(); + QVERIFY(!edit.previewImage(edit.layerID)); + session->cancelHueSaturation(); + QVERIFY(session->document() == before && !session->hueSaturation()); + // OK renders full size, only then changing the layer. + session->beginHueSaturation(); + QVERIFY(preview(*session, HueSaturationSettings(120))); + commit(*session); + QCOMPARE(session->document().value().layers.front().asset.value().size().width(), before.value().layers.front().asset.value().size().width()); + QVERIFY(near(pixel(*session, 5, 5), {0, 255, 0, 255})); +} + +void HueSaturationTests::bandWeightsRampThroughFalloffAndWrapAround() +{ + // 315, 345, 15, 45: wrapping past zero. + const HueBand reds = defaultBand(ColorRange::reds); + QVERIFY(reds.weight(0) == 1 && reds.weight(345) == 1 && reds.weight(15) == 1); + QVERIFY(std::abs(reds.weight(330) - 0.5) < 0.001); + QVERIFY(std::abs(reds.weight(30) - 0.5) < 0.001); + QVERIFY(reds.weight(315) == 0 && reds.weight(45) == 0 && reds.weight(180) == 0); + QCOMPARE(defaultBand(ColorRange::master).weight(123), 1.0); + // Handles keep their order: crossings are refused. + HueBand band = defaultBand(ColorRange::greens); + band.setHandle(1, 200); + QVERIFY(band == defaultBand(ColorRange::greens)); + band.setHandle(1, 110); + QCOMPARE(band.rangeStart, 110.0); +} + +void HueSaturationTests::colorRangesAdjustIndependently() +{ + const std::unique_ptr<EditorSession> session = redAndBlue(); + HueSaturationSettings settings(60, 0, 0, false, ColorRange::reds); + settings.adjustments[ColorRange::blues] = RangeAdjustment{0, -100, 0}; + session->beginHueSaturation(); + QVERIFY(preview(*session, settings)); + commit(*session); + const std::array<int, 4> red = pixel(*session, 5, 5), blue = pixel(*session, 30, 5); + QVERIFY(near(red, {255, 255, 0, 255})); + QVERIFY(blue[0] == blue[1] && blue[1] == blue[2]); +} + +void HueSaturationTests::rangesLeaveOtherHuesAloneAndInvertFlipsTheBand() +{ + const std::unique_ptr<EditorSession> session = redAndBlue(); + const std::array<int, 4> before = pixel(*session, 30, 5); + apply(*session, withRange(ColorRange::reds, {0, 0, -100})); + QVERIFY(near(pixel(*session, 5, 5), {0, 0, 0, 255})); + QVERIFY(near(pixel(*session, 30, 5), before)); + session->undo(); + HueSaturationSettings inverted = withRange(ColorRange::reds, {0, 0, -100}); + inverted.invertRange = true; + apply(*session, inverted); + QVERIFY(near(pixel(*session, 5, 5), {255, 0, 0, 255})); + QVERIFY(near(pixel(*session, 30, 5), {0, 0, 0, 255})); +} + +void HueSaturationTests::slidersEditTheSelectedRangeAndTheAfterBarFollowsHueShifts() +{ + HueSaturationSettings settings; + settings.setHue(30); + settings.range = ColorRange::greens; + QCOMPARE(settings.hue(), 0.0); + settings.setHue(-40); + QVERIFY(settings.adjustments.at(ColorRange::master).hue == 30 && settings.adjustments.at(ColorRange::greens).hue == -40); + settings.range = ColorRange::master; + QVERIFY(settings.hue() == 30 && !settings.isIdentity()); + // The after spectrum moves hues in the band alone. + HueSaturationSettings greensOnly(60, 0, 0, false, ColorRange::greens); + greensOnly.adjustments[ColorRange::master] = RangeAdjustment(); + QVERIFY(std::abs(HueSaturationFilter::shiftedHue(120, greensOnly) - 180) < 0.001); + QVERIFY(std::abs(HueSaturationFilter::shiftedHue(0, greensOnly)) < 0.001); +} + +void HueSaturationTests::eyedroppersRecenterWidenAndNarrowTheBand() +{ + // Red at hue 0 left, blue at 240 right. + const std::unique_ptr<EditorSession> session = redAndBlue(); + session->beginHueSaturation(); + session->updateHueSaturation(HueSaturationSettings(10, 0, 0, false, ColorRange::greens), false); + session->setHueSampleMode(HueSampleMode::replace); + session->sampleHueRange(QPointF(5, 5)); + HueBand band = session->hueSaturation().value().settings.band(); + QVERIFY(band.weight(0) == 1 && band.weight(120) == 0); + session->setHueSampleMode(HueSampleMode::add); + session->sampleHueRange(QPointF(30, 5)); + band = session->hueSaturation().value().settings.band(); + QVERIFY(band.weight(240) == 1 && band.weight(0) == 1); + session->setHueSampleMode(HueSampleMode::remove); + session->sampleHueRange(QPointF(30, 5)); + band = session->hueSaturation().value().settings.band(); + QCOMPARE(band.weight(240), 0.0); + session->cancelHueSaturation(); + QVERIFY(!session->hueSampleMode()); +} + +void HueSaturationTests::targetedAdjustmentPicksTheRangeUnderTheCursor() +{ + const std::unique_ptr<EditorSession> session = redAndBlue(); + session->beginHueSaturation(); + session->setHueTargeting(true); + QVERIFY(session->beginHueTargeting(QPointF(30, 5))); + QCOMPARE(session->hueSaturation().value().settings.range, ColorRange::blues); + session->dragHueTargeting(60, false); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::blues).saturation, 30.0); + // Each drag counts from its start, never stacking. + session->dragHueTargeting(20, false); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::blues).saturation, 10.0); + session->dragHueTargeting(-40, true); + QCOMPARE(session->hueSaturation().value().settings.adjustments.at(ColorRange::blues).hue, -20.0); + session->endHueTargeting(); + session->cancelHueSaturation(); + QVERIFY(!session->hueTargeting()); +} + +void HueSaturationTests::samplingNeedsAColorRangeAndAColorfulPixel() +{ + const std::unique_ptr<EditorSession> session = redAndBlue(); + session->beginHueSaturation(); + const HueBand untouched = session->hueSaturation().value().settings.band(); + session->setHueSampleMode(HueSampleMode::replace); + // Master is selected: nothing to retarget. + session->sampleHueRange(QPointF(5, 5)); + QVERIFY(session->hueSaturation().value().settings.band() == untouched); + // A gray pixel has no hue to sample. + const std::unique_ptr<EditorSession> gray = makeSession(); + gray->beginHueSaturation(); + gray->updateHueSaturation(HueSaturationSettings(0, 0, 0, false, ColorRange::reds), false); + const HueBand before = gray->hueSaturation().value().settings.band(); + gray->setHueSampleMode(HueSampleMode::replace); + gray->sampleHueRange(QPointF(30, 5)); + QVERIFY(gray->hueSaturation().value().settings.band() == before); +} + +QTEST_GUILESS_MAIN(HueSaturationTests) +#include "HueSaturationTests.moc" diff --git a/tests/ImageAdjustmentModelTests.cpp b/tests/ImageAdjustmentModelTests.cpp new file mode 100644 index 0000000..f8f95d9 --- /dev/null +++ b/tests/ImageAdjustmentModelTests.cpp @@ -0,0 +1,221 @@ +#include "Document/Curves.h" +#include "Document/ImageAdjustments.h" +#include "IO/ProjectStore.h" +#include <QtTest> +#include <cmath> + +// The adjustments' models at their edges: ranges, curves, tables. +namespace { +QImage filled(int width, int height, QColor colour) +{ + QImage result(width, height, QImage::Format_RGBA8888_Premultiplied); + result.fill(colour); + return result; +} + +std::array<int, 4> first(const QImage &image) +{ + const uchar *pixel = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied).constBits(); + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} + +// Whether a call throws ProjectError(invalid). +template <typename Call> bool refused(Call call) +{ + try { + call(); + } catch (const ProjectError &error) { + return error.kind == ProjectError::Kind::invalid; + } + return false; +} + +CurvesSettings withCurve(size_t channel, std::vector<CurvePoint> points) +{ + CurvesSettings settings; + settings.channels[channel] = std::move(points); + return settings; +} +} + +class ImageAdjustmentModelTests : public QObject { + Q_OBJECT +private slots: + void curvesAreValidAsSwifts(); + void curvesInterpolateWithoutOvershoot(); + void curvesRunEachChannelThenTheComposite(); + void exposureKeepsItsRangesAndTable(); + void gradientMapsKeepTheirEndsAndColours(); + void grainKeepsItsRangesSeedAndPlace(); + void theKernelSeesPremultipliedRgba(); +}; + +void ImageAdjustmentModelTests::curvesAreValidAsSwifts() +{ + QVERIFY(CurvesSettings().isValid()); + CurvesSettings three; + three.channels.pop_back(); + QVERIFY(!three.isValid()); + QVERIFY(!withCurve(0, {{0, 0}}).isValid() && !withCurve(0, {}).isValid()); + std::vector<CurvePoint> many{{0, 0}}; + for (int index = 1; index < 31; ++index) + many.push_back({double(index), 10}); + many.push_back({255, 255}); + QCOMPARE(many.size(), size_t(32)); + QVERIFY(withCurve(1, many).isValid()); + many.insert(many.end() - 1, CurvePoint{100, 10}); + std::sort(many.begin(), many.end(), [](const CurvePoint &a, const CurvePoint &b) { return a.x < b.x; }); + QVERIFY(!withCurve(1, many).isValid()); + // Ends at 0 and 255, x rising, values in range. + QVERIFY(!withCurve(2, {{1, 0}, {255, 255}}).isValid()); + QVERIFY(!withCurve(2, {{0, 0}, {254, 255}}).isValid()); + QVERIFY(!withCurve(3, {{0, 0}, {100, 10}, {100, 20}, {255, 255}}).isValid()); + QVERIFY(!withCurve(3, {{0, 0}, {100, 10}, {50, 20}, {255, 255}}).isValid()); + QVERIFY(!withCurve(3, {{0, 0}, {100, 256}, {255, 255}}).isValid()); + QVERIFY(!withCurve(3, {{0, -1}, {255, 255}}).isValid()); + QVERIFY(!withCurve(0, {{0, 0}, {100, std::nan("")}, {255, 255}}).isValid()); + QVERIFY(withCurve(0, {{0, 255}, {255, 0}}).isValid()); +} + +void ImageAdjustmentModelTests::curvesInterpolateWithoutOvershoot() +{ + const CurvesSettings identity; + for (const double x : {0.0, 1.0, 64.5, 200.0, 255.0}) + QCOMPARE(identity.value(x, 0), x); + // Swift's Hermite: secant ends, harmonic means inside. + const CurvesSettings lifted = withCurve(0, {{0, 0}, {128, 200}, {255, 255}}); + const double inner = 2 / (1 / (200.0 / 128) + 1 / (55.0 / 127)); + QVERIFY(std::abs(lifted.value(64, 0) - (0.125 * 128 * (200.0 / 128) + 100 - 0.125 * 128 * inner)) < 1e-9); + const double t = 63.5 / 127; + QVERIFY(std::abs(lifted.value(191.5, 0) + - ((2 * t * t * t - 3 * t * t + 1) * 200 + (t * t * t - 2 * t * t + t) * 127 * inner + (-2 * t * t * t + 3 * t * t) * 255 + + (t * t * t - t * t) * 127 * (55.0 / 127))) + < 1e-9); + QCOMPARE(lifted.value(128, 0), 200.0); + double previous = -1; + for (int x = 0; x <= 255; ++x) { + QVERIFY(lifted.value(x, 0) >= previous); + previous = lifted.value(x, 0); + } + // A peak flattens: nothing passes its neighbours. + const CurvesSettings peak = withCurve(0, {{0, 0}, {128, 250}, {255, 0}}); + for (int x = 0; x <= 255; ++x) + QVERIFY(peak.value(x, 0) <= 250); + QCOMPARE(peak.value(128, 0), 250.0); + // Off the curve: the first point below, the last above. + QCOMPARE(lifted.value(-40, 0), 0.0); + QCOMPARE(lifted.value(400, 0), 255.0); + QCOMPARE(lifted.value(std::nan(""), 0), 0.0); + QCOMPARE(withCurve(2, {{0, 30}, {255, 90}}).value(std::nan(""), 2), 30.0); + // A straight curve stops at its ends, never runs on. + const CurvesSettings line = withCurve(1, {{0, 100}, {255, 150}}); + QCOMPARE(line.value(400, 1), 150.0); + QCOMPARE(line.value(-40, 1), 100.0); +} + +void ImageAdjustmentModelTests::curvesRunEachChannelThenTheComposite() +{ + CurvesSettings settings = withCurve(1, {{0, 255}, {255, 0}}); + QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{55, 100, 50, 255})); + settings.channels[0] = {{0, 255}, {255, 0}}; + QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{200, 155, 205, 255})); + settings.channels[3] = {{0, 0}, {255, 0}}; + QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{200, 155, 255, 255})); + QVERIFY(refused([] { withCurve(0, {{0, 0}, {256, 255}}).apply(filled(1, 1, Qt::red)); })); +} + +void ImageAdjustmentModelTests::exposureKeepsItsRangesAndTable() +{ + QVERIFY((ExposureSettings{20, 0.5, 9.99}.isValid() && ExposureSettings{-20, -0.5, 0.01}.isValid())); + for (const ExposureSettings &wild : {ExposureSettings{20.01, 0, 1}, ExposureSettings{-20.01, 0, 1}, ExposureSettings{0, -0.51, 1}, + ExposureSettings{0, 0.51, 1}, ExposureSettings{0, 0, 0.009}, ExposureSettings{0, 0, 10}, + ExposureSettings{std::nan(""), 0, 1}}) { + QVERIFY(!wild.isValid()); + QVERIFY(refused([&] { wild.apply(filled(1, 1, Qt::red)); })); + } + QCOMPARE((ExposureSettings{30, -2, 0}.normalized()), (ExposureSettings{20, -0.5, 0.01})); + QCOMPARE((ExposureSettings{-30, 2, 20}.normalized()), (ExposureSettings{-20, 0.5, 9.99})); + QCOMPARE((ExposureSettings{std::nan(""), INFINITY, std::nan("")}.normalized()), ExposureSettings()); + const std::array<float, 256> identity = ExposureSettings().table(); + for (size_t index = 0; index < 256; ++index) + QVERIFY(std::abs(identity[index] - float(index) / 255) < 1e-5f); + // A stop doubles linear light; the offset lifts black. + QCOMPARE((ExposureSettings{1, 0, 1}.table()[128]), 0.68845345f); + QCOMPARE((ExposureSettings{0, 0.1, 1}.table()[0]), 0.34919021f); + QCOMPARE((ExposureSettings{0, -0.5, 1}.table()[255]), 0.7353569f); + QCOMPARE((ExposureSettings{-20, 0, 1}.table()[255]), 1.2321472e-5f); + QCOMPARE((ExposureSettings{20, 0, 1}.table()[1]), 1.0f); +} + +void ImageAdjustmentModelTests::gradientMapsKeepTheirEndsAndColours() +{ + const GradientMapSettings settings{{0, 0, 0}, {1, 0.7, 0}, false}; + QCOMPARE(settings.ends().dark, AdjustmentColor(0, 0, 0)); + GradientMapSettings reversed = settings; + reversed.reversed = true; + QCOMPARE(reversed.ends().dark, AdjustmentColor(1, 0.7, 0)); + QCOMPARE(reversed.ends().light, AdjustmentColor(0, 0, 0)); + // Gray 128 lies halfway, each byte rounded. + QVERIFY((first(settings.apply(filled(1, 1, QColor(128, 128, 128)))) == std::array<int, 4>{128, 90, 0, 255})); + QVERIFY((first(settings.apply(filled(1, 1, Qt::transparent))) == std::array<int, 4>{0, 0, 0, 0})); + const GradientMapSettings wild{{1.5, 0, 0}, {0, std::nan(""), -0.5}, true}; + QVERIFY(!wild.isValid() && !(GradientMapSettings{{0, 0, 0}, {0, 0, 1.01}, false}.isValid())); + QVERIFY(!(GradientMapSettings{{1.5, 0, 0}, {1, 1, 1}, false}.isValid())); + QVERIFY(refused([&] { wild.apply(filled(1, 1, Qt::red)); })); + QCOMPARE(wild.normalized(), (GradientMapSettings{{1, 0, 0}, {0, 0, 0}, true})); + QCOMPARE(AdjustmentColor(PaletteColor{0.2, 0.4, 0.6}), AdjustmentColor(0.2, 0.4, 0.6)); + QVERIFY(AdjustmentColor(0, 1, 0.5).isValid() && !AdjustmentColor(0, 1, INFINITY).isValid() && !AdjustmentColor(-0.1, 0, 0).isValid()); + for (const AdjustmentColor &wild : {AdjustmentColor(1.1, 0, 0), AdjustmentColor(0, -0.1, 0), AdjustmentColor(0, 1.1, 0), AdjustmentColor(0, 0, -0.1)}) + QVERIFY(!wild.isValid()); +} + +void ImageAdjustmentModelTests::grainKeepsItsRangesSeedAndPlace() +{ + QVERIFY((GrainSettings{100, 20, 100, 0}.isValid() && GrainSettings{0, 0.5, 0, 0}.isValid())); + for (const GrainSettings &wild : {GrainSettings{101, 1.5, 50, 0}, GrainSettings{-1, 1.5, 50, 0}, GrainSettings{25, 0.4, 50, 0}, + GrainSettings{25, 21, 50, 0}, GrainSettings{25, 1.5, -1, 0}, GrainSettings{25, 1.5, 101, 0}, + GrainSettings{std::nan(""), 1.5, 50, 0}}) + QVERIFY(!wild.isValid()); + QCOMPARE((GrainSettings{std::nan(""), std::nan(""), std::nan(""), 4}.normalized()), (GrainSettings{25, 1.5, 50, 4})); + QCOMPARE((GrainSettings{-5, 50, 200, 4}.normalized()), (GrainSettings{0, 20, 100, 4})); + QCOMPARE((GrainSettings{150, 0.1, -5, 4}.normalized()), (GrainSettings{100, 0.5, 0, 4})); + const GrainSettings settings{60, 2, 40, 7}; + const QImage gray = filled(8, 8, QColor(128, 128, 128)); + for (const double scale : {0.0, -1.0, double(std::nan("")), double(INFINITY)}) + QVERIFY(refused([&] { settings.apply(gray, QPointF(), scale); })); + QVERIFY(refused([] { GrainSettings{101, 1.5, 50, 0}.apply(filled(1, 1, Qt::red)); })); + // No amount hands the same image back. + QCOMPARE((GrainSettings{0, 1.5, 50, 0}.apply(gray).cacheKey()), gray.cacheKey()); + // A given seed stands in for the stored one. + GrainSettings reseeded = settings; + reseeded.seed = 8; + QCOMPARE(settings.apply(gray, QPointF(), 1, 8u), reseeded.apply(gray)); + QVERIFY(settings.apply(gray) != reseeded.apply(gray)); + // Pixels twice as big sit at other grain. + QVERIFY(settings.apply(gray, QPointF(), 2) != settings.apply(gray)); + const QImage whole = settings.apply(filled(4, 4, QColor(128, 128, 128))); + QCOMPARE(settings.apply(filled(2, 2, QColor(128, 128, 128)), QPointF(1, 2)), whole.copy(1, 2, 2, 2)); +} + +void ImageAdjustmentModelTests::theKernelSeesPremultipliedRgba() +{ + QImage source(3, 2, QImage::Format_ARGB32); + source.fill(QColor(200, 100, 50, 128)); + const QImage result = ImageAdjustmentPixels::run(source, [](uchar *pixels, int width, int height, qsizetype stride) { + QCOMPARE(width, 3); + QCOMPARE(height, 2); + QCOMPARE(stride, qsizetype(12)); + // Premultiplied, red first, alpha last. + QVERIFY(std::abs(pixels[0] - 100) <= 1 && std::abs(pixels[1] - 50) <= 1 && std::abs(pixels[2] - 25) <= 1 && pixels[3] == 128); + pixels[12 + 3] = 255; + }); + QCOMPARE(result.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(qAlpha(result.pixel(0, 1)), 255); + QCOMPARE(ImageAdjustmentPixels::clamp(7, 0, 5, 1), 5.0); + QCOMPARE(ImageAdjustmentPixels::clamp(-7, 0, 5, 1), 0.0); + QCOMPARE(ImageAdjustmentPixels::clamp(std::nan(""), 0, 5, 1), 1.0); + QCOMPARE(ImageAdjustmentPixels::clamp(-INFINITY, 0, 5, 1), 1.0); +} + +QTEST_GUILESS_MAIN(ImageAdjustmentModelTests) +#include "ImageAdjustmentModelTests.moc" diff --git a/tests/ImageAdjustmentTests.cpp b/tests/ImageAdjustmentTests.cpp new file mode 100644 index 0000000..d8198b3 --- /dev/null +++ b/tests/ImageAdjustmentTests.cpp @@ -0,0 +1,237 @@ +#include "Document/Curves.h" +#include "Document/EditorSession.h" +#include "Document/ImageAdjustments.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore+Json.h" +#include <QJsonDocument> +#include <QtTest> +#include <set> + +// Swift's ImageAdjustmentTests. +namespace { +// A width by height image of one straight sRGB colour. +QImage image(int width, int height, double red, double green, double blue, double alpha = 1) +{ + QImage result(width, height, QImage::Format_RGBA8888_Premultiplied); + result.fill(QColor::fromRgbF(float(red), float(green), float(blue), float(alpha))); + return result; +} + +QImage gray(int width = 4, int height = 4, double alpha = 1) +{ + return image(width, height, 128.0 / 255, 128.0 / 255, 128.0 / 255, alpha); +} + +// Straight RGBA bytes of every pixel, top row first. +std::vector<std::array<int, 4>> pixels(const QImage &image) +{ + const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector<std::array<int, 4>> result; + for (int y = 0; y < drawn.height(); ++y) { + const uchar *row = drawn.constScanLine(y); + for (int x = 0; x < drawn.width(); ++x) { + const int a = row[x * 4 + 3]; + std::array<int, 4> pixel{0, 0, 0, a}; + for (int c = 0; c < 3; ++c) + pixel[size_t(c)] = a == 0 ? 0 : std::min(255, (row[x * 4 + c] * 255 + a / 2) / a); + result.push_back(pixel); + } + } + return result; +} + +bool committed(EditorSession &session) +{ + bool done = false; + session.commitFilter([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} +} + +class ImageAdjustmentTests : public QObject { + Q_OBJECT +private slots: + void exposureWorksInLinearLightWithOffsetAndGamma(); + void gradientMapColorsByBrightnessAndReverses(); + void grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone(); + void imageMenuExposureChangesTheLayerInOneStep(); + void gradientMapColorsUseTheAppColorPicker(); + void settingsSaveAndOlderAdjustmentsStillOpen(); + void newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel(); +}; + +void ImageAdjustmentTests::exposureWorksInLinearLightWithOffsetAndGamma() +{ + const QImage input = gray(); + QVERIFY(pixels(ExposureSettings().apply(input)) == pixels(input)); + const std::array<int, 4> brighter = pixels(ExposureSettings{1, 0, 1}.apply(input))[0]; + QVERIFY2(std::abs(brighter[0] - 176) <= 2, "+1 stop doubles linear light"); + QCOMPARE(brighter[0], brighter[2]); + const std::array<int, 4> lifted = pixels(ExposureSettings{0, 0, 2}.apply(input))[0]; + QVERIFY2(std::abs(lifted[0] - 181) <= 2, "gamma 2 takes the square root of linear light"); + const std::array<int, 4> offset = pixels(ExposureSettings{0, 0.1, 1}.apply(image(4, 4, 0, 0, 0)))[0]; + QVERIFY2(std::abs(offset[0] - 89) <= 2, "offset adds linear light"); + const QImage translucent = gray(4, 4, 0.5); + QCOMPARE(pixels(ExposureSettings{1, 0, 1}.apply(translucent))[0][3], pixels(translucent)[0][3]); +} + +void ImageAdjustmentTests::gradientMapColorsByBrightnessAndReverses() +{ + GradientMapSettings settings{{1, 0, 0}, {0, 0, 1}, false}; + QVERIFY((pixels(settings.apply(image(4, 4, 0, 0, 0)))[0] == std::array<int, 4>{255, 0, 0, 255})); + QVERIFY((pixels(settings.apply(image(4, 4, 1, 1, 1)))[0] == std::array<int, 4>{0, 0, 255, 255})); + const std::array<int, 4> middle = pixels(settings.apply(gray()))[0]; + QVERIFY(std::abs(middle[0] - 127) <= 2); + QVERIFY(std::abs(middle[2] - 128) <= 2); + QCOMPARE(middle[1], 0); + const std::array<int, 4> translucent = pixels(settings.apply(image(4, 4, 1, 1, 1, 0.5)))[0]; + QVERIFY(translucent[2] >= 250); + QVERIFY(translucent[0] <= 5); + QVERIFY2(std::abs(translucent[3] - 128) <= 1, "alpha kept"); + settings.reversed = true; + QVERIFY((pixels(settings.apply(image(4, 4, 0, 0, 0)))[0] == std::array<int, 4>{0, 0, 255, 255})); +} + +void ImageAdjustmentTests::grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone() +{ + const GrainSettings settings{60, 2, 40, 7}; + const std::vector<std::array<int, 4>> whole = pixels(settings.apply(gray(40, 40))); + std::set<int> reds; + for (const std::array<int, 4> &pixel : whole) + reds.insert(pixel[0]); + QVERIFY2(reds.size() > 5, "grain varies the brightness"); + QVERIFY2(std::all_of(whole.begin(), whole.end(), [](const std::array<int, 4> &pixel) { return pixel[0] == pixel[1] && pixel[1] == pixel[2]; }), + "the same change on every channel"); + // A piece at its place gets the whole's grain there. + const std::vector<std::array<int, 4>> part = pixels(settings.apply(gray(20, 20), QPointF(10, 10))); + std::vector<std::array<int, 4>> crop; + for (int y = 0; y < 20; ++y) { + for (int x = 0; x < 20; ++x) + crop.push_back(whole[size_t((y + 10) * 40 + x + 10)]); + } + QVERIFY2(part == crop, "grain must not shift when only part of the canvas redraws"); + GrainSettings reseeded = settings; + reseeded.seed = 8; + QVERIFY2(pixels(reseeded.apply(gray(40, 40))) != whole, "another seed, another pattern"); + QVERIFY2(pixels(GrainSettings{0, 1.5, 50, 0}.apply(gray())) == pixels(gray()), "no amount, no change"); + const std::vector<std::array<int, 4>> cleared = pixels(settings.apply(gray(4, 4, 0))); + QVERIFY2(std::all_of(cleared.begin(), cleared.end(), [](const std::array<int, 4> &pixel) { return pixel[3] == 0; }), "clear pixels stay clear"); +} + +void ImageAdjustmentTests::imageMenuExposureChangesTheLayerInOneStep() +{ + EditorSession session; + session.createDocument(8, 8); + const QImage base = gray(8, 8); + session.insert(ImportedImage(base, base, QStringLiteral("Gray"))); + session.beginFilter(FilterKind::exposure); + FilterSettings settings = session.filterEdit().value().settings; + settings.exposure.exposure = 1; + session.updateFilter(settings, true); + const int count = session.history.undoCount(); + QVERIFY(committed(session)); + QVERIFY(session.history.undoCount() == count + 1 && session.history.undoName() == QStringLiteral("Exposure")); + const std::array<int, 4> result = pixels(session.activeLayer().value().asset.value().image())[0]; + QVERIFY2(std::abs(result[0] - 176) <= 2, QByteArray::number(result[0])); + QVERIFY(isImageAdjustment(FilterKind::exposure) && !isImageAdjustment(FilterKind::gaussianBlur)); +} + +void ImageAdjustmentTests::gradientMapColorsUseTheAppColorPicker() +{ + EditorSession session; + session.createDocument(8, 8); + const QImage base = gray(8, 8); + session.insert(ImportedImage(base, base, QStringLiteral("Gray"))); + const PaletteColor foreground = session.foregroundColor(), background = session.backgroundColor(); + session.beginFilter(FilterKind::gradientMap); + const auto map = [&session] { return session.filterEdit().value().settings.gradientMap; }; + const AdjustmentColor start = map().highlights; + + session.openGradientMapColorPicker(true); + const ColorPickerState picker = session.colorPicker().value(); + QVERIFY((picker.target == ColorPickerTarget{ColorPickerTarget::Kind::gradientMap, false, std::nullopt, true})); + QVERIFY(AdjustmentColor(picker.original) == start); + PickerHSB hsb = picker.hsb; + hsb.setRGB(PaletteColor{1, 0, 0}); + session.setColorPickerHSB(hsb); + session.previewGradientMapColor(); + QVERIFY2(map().highlights == AdjustmentColor(1, 0, 0), "the gradient follows the working color"); + session.closeColorPicker(false); + QVERIFY2(map().highlights == start, "Cancel restores it"); + + session.openGradientMapColorPicker(false); + hsb = session.colorPicker().value().hsb; + hsb.setRGB(PaletteColor{0, 0, 1}); + session.setColorPickerHSB(hsb); + session.closeColorPicker(true); + QVERIFY(map().shadows == AdjustmentColor(0, 0, 1)); + QVERIFY(session.foregroundColor() == foreground); + QVERIFY2(session.backgroundColor() == background, "the palette is untouched"); + + session.openGradientMapColorPicker(true); + session.cancelFilter(); + QVERIFY2(!session.colorPicker() && !session.filterEdit(), "closing the panel closes its picker"); +} + +void ImageAdjustmentTests::settingsSaveAndOlderAdjustmentsStillOpen() +{ + const LayerAdjustment levels{AdjustmentKind::levels}; + const QJsonObject data = ManifestJson::encoded(levels); + const QString json = QString::fromUtf8(QJsonDocument(data).toJson()); + QVERIFY2(!json.contains("exposureSettings"), "an existing kind saves exactly as before"); + QVERIFY(!json.contains("gradientMapSettings")); + QVERIFY(!json.contains("grainSettings")); + QVERIFY(ManifestJson::adjustment(data) == levels); + LayerAdjustment grain{AdjustmentKind::grain}; + grain.setGrain(GrainSettings{.amount = 40, .size = 3, .roughness = 10, .seed = 9}); + const LayerAdjustment decoded = ManifestJson::adjustment(ManifestJson::encoded(grain)); + QVERIFY(decoded == grain); + QVERIFY(decoded.isValid()); + LayerAdjustment broken{AdjustmentKind::exposure}; + ExposureSettings exposure = broken.exposure(); + exposure.gamma = 0; + broken.setExposure(exposure); + QVERIFY(!broken.isValid()); +} + +void ImageAdjustmentTests::newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel() +{ + EditorSession session; + session.createDocument(20, 20); + const QImage base = gray(20, 20); + session.insert(ImportedImage(base, base, QStringLiteral("Gray"))); + session.setPaletteColor(PaletteColor{1, 0, 0}, false); + session.setPaletteColor(PaletteColor{0, 0, 1}, true); + session.addAdjustment(AdjustmentKind::gradientMap); + const QUuid id = session.activeLayerID().value(); + const LayerAdjustment adjustment = session.activeLayer().value().adjustment.value(); + QVERIFY(adjustment.kind == AdjustmentKind::gradientMap); + QVERIFY(adjustment.gradientMap().shadows == AdjustmentColor(1, 0, 0)); + QVERIFY(adjustment.gradientMap().highlights == AdjustmentColor(0, 0, 1)); + QCOMPARE(session.adjustmentEditingID(), std::optional(id)); + bool done = false; + session.beginAdjustmentEditing(id, [&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY2(session.filterEdit().value().kind == FilterKind::gradientMap, "edited in the floating filter panel, like Curves"); + session.finishAdjustmentEditing(false); + + const std::array<int, 4> middle = pixels(ImageExporter::render(session.projectSnapshot().value()).image)[210]; + const QString shown = QStringLiteral("%1 %2 %3").arg(middle[0]).arg(middle[1]).arg(middle[2]); + QVERIFY2(middle[0] > 100, qPrintable("gray maps between red and blue: " + shown)); + QVERIFY2(middle[2] > 100, qPrintable(shown)); + QVERIFY2(middle[1] < 20, qPrintable(shown)); + + session.addAdjustment(AdjustmentKind::grain); + const quint32 first = session.activeLayer().value().adjustment.value().grain().seed; + // This test never opened its panel: no edit to finish. + session.setAdjustmentEditingID(std::nullopt); + session.addAdjustment(AdjustmentKind::grain); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(std::count_if(layers.begin(), layers.end(), [](const ImageLayer &layer) { return layer.adjustment && layer.adjustment->kind == AdjustmentKind::grain; }), 2); + QVERIFY2(session.activeLayer().value().adjustment.value().grain().seed != first, "each Grain layer gets its own pattern"); + QVERIFY(std::find(allAdjustmentKinds.begin(), allAdjustmentKinds.end(), AdjustmentKind::exposure) != allAdjustmentKinds.end() + && filterKind(AdjustmentKind::exposure) == FilterKind::exposure); +} + +QTEST_GUILESS_MAIN(ImageAdjustmentTests) +#include "ImageAdjustmentTests.moc" diff --git a/tests/ImageFileDropTests.cpp b/tests/ImageFileDropTests.cpp new file mode 100644 index 0000000..4c21a58 --- /dev/null +++ b/tests/ImageFileDropTests.cpp @@ -0,0 +1,358 @@ +#include "ContentView.h" +#include "IO/ImageFileDrop.h" +#include "UI/ProjectTabs.h" +#include "UI/ProjectWorkspaceView.h" +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QMessageBox> +#include <QTemporaryDir> +#include <QtTest> +#include <csignal> +#include <sys/resource.h> + +// Swift's ImageFileDrop: files and pictures reach the importer in order. +class ImageFileDropTests : public QObject { + Q_OBJECT +private slots: + void fileDropProvidersReachImporterInOrder(); + void aPictureIsCopiedToAFileFirst(); + void whatCannotBeReadIsSaid(); + void onlyFilesAndPicturesAreHeld(); + void theCanvasTheTabsAndTheNewButtonTakeFiles(); + void aCanvasAloneImportsUnderSwiftsGuards(); + void anOpenAlertShowsTheGrownError(); + void eachFileWaitsBehindTheErrorBeforeIt(); + void aPictureThatCannotBeKeptIsUnreadable(); + void aPictureReachesATabWhole(); + void aLazyDragGivesOnlyThePictureChosen(); +}; + +namespace { +QString fixture(const QTemporaryDir &folder, const QString &name, const char *format) +{ + QImage image(6, 4, QImage::Format_RGBA8888); + image.fill(Qt::red); + const QString path = folder.filePath(name); + if (!image.save(path, format)) + throw std::runtime_error("could not write a fixture"); + return path; +} + +QMimeData *files(const QStringList &paths, const QList<QUrl> &others = {}) +{ + auto *data = new QMimeData; + QList<QUrl> urls; + for (const QString &path : paths) + urls << QUrl::fromLocalFile(path); + data->setUrls(urls + others); + return data; +} + +QStringList names(const EditorSession &session) +{ + QStringList listed; + if (session.document()) + for (const ImageLayer &layer : session.document().value().layers) + listed << layer.name; + return listed; +} + +bool dropOn(QWidget &target, QMimeData *data, QPoint at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) +{ + QDragEnterEvent entered(at, Qt::CopyAction, data, Qt::LeftButton, modifiers); + QApplication::sendEvent(&target, &entered); + QDropEvent dropped(at, Qt::CopyAction, data, Qt::LeftButton, modifiers); + QApplication::sendEvent(&target, &dropped); + delete data; + return entered.isAccepted() && dropped.isAccepted(); +} +} + +void ImageFileDropTests::fileDropProvidersReachImporterInOrder() +{ + QTemporaryDir folder; + const QString first = fixture(folder, "first.png", "PNG"), second = fixture(folder, "second.jpg", "JPEG"); + EditorSession session; + bool done = false; + std::unique_ptr<QMimeData> data(files({first, second})); + ImageFileDrop::importProviders(*data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(names(session), (QStringList{"first", "second"})); + QVERIFY(!session.importError()); +} + +void ImageFileDropTests::aPictureIsCopiedToAFileFirst() +{ + QImage image(3, 5, QImage::Format_RGBA8888); + image.fill(Qt::blue); + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + // A browser's picture: a web address and the bytes. + QMimeData data; + data.setUrls({QUrl("https://example.com/picture.png")}); + data.setData("image/jpeg", "not read: PNG comes first"); + data.setData("image/png", png); + EditorSession session; + bool done = false; + ImageFileDrop::importProviders(data, session, QPointF(2, 2), nullptr, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session.importError()); + const QStringList listed = names(session); + QCOMPARE(listed.size(), 1); + QVERIFY(listed.value(0).startsWith("Dropped-")); + QCOMPARE(session.document().value().size(), QSize(3, 5)); + QVERIFY(QFileInfo::exists(QDir::temp().filePath(listed.value(0) + ".png"))); + QFile::remove(QDir::temp().filePath(listed.value(0) + ".png")); +} + +void ImageFileDropTests::whatCannotBeReadIsSaid() +{ + const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager."; + EditorSession session; + bool done = false; + std::unique_ptr<QMimeData> link(files({}, {QUrl("https://example.com/page")})); + ImageFileDrop::importProviders(*link, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session.importError(), std::optional(said)); + QVERIFY(!session.document()); + // A file among them arrives; the words join the importer's. + QTemporaryDir folder; + const QString broken = folder.filePath("broken.png"); + QFile file(broken); + QVERIFY(file.open(QIODevice::WriteOnly) && file.write("no picture") > 0); + file.close(); + session.setImportError(std::nullopt); + done = false; + std::unique_ptr<QMimeData> mixed(files({fixture(folder, "kept.png", "PNG"), broken}, {QUrl("https://example.com/page")})); + ImageFileDrop::importProviders(*mixed, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(names(session), QStringList{"kept"}); + const QString error = session.importError().value(); + QVERIFY(error.startsWith("broken.png") && error.endsWith("\n\n" + said)); +} + +void ImageFileDropTests::onlyFilesAndPicturesAreHeld() +{ + QMimeData data; + QVERIFY(!ImageFileDrop::holdsImages(data)); + data.setText("words"); + data.setUrls({QUrl("https://example.com/picture.png")}); + QVERIFY(!ImageFileDrop::holdsImages(data)); + data.setData("image/webp", "bytes"); + QVERIFY(ImageFileDrop::holdsImages(data)); + QMimeData file; + file.setUrls({QUrl::fromLocalFile("/tmp/any.png")}); + QVERIFY(ImageFileDrop::holdsImages(file)); +} + +void ImageFileDropTests::theCanvasTheTabsAndTheNewButtonTakeFiles() +{ + QTemporaryDir folder; + const QString path = fixture(folder, "dropped.png", "PNG"); + ProjectWorkspace workspace; + ProjectWorkspaceView window{workspace}; + ProjectTab &first = workspace.current(); + first.session.createDocument(40, 20); + ProjectTab &second = workspace.addTab(false); + second.session.createDocument(8, 8); + workspace.select(first.id); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + auto *content = window.findChild<ContentView *>(); + QVERIFY(content && dropOn(*content, files({path}), content->rect().center())); + QTRY_COMPARE(names(first.session), QStringList{"dropped"}); + // Words are no files. + auto *words = new QMimeData; + words->setText("dropped.png"); + QVERIFY(!dropOn(*content, words, content->rect().center())); + // Alt refuses only layer rows; files go where dropped. + ProjectTabButton *secondButton = nullptr; + for (ProjectTabButton *button : window.tabs()->buttons()) + if (button->tab->id == second.id) + secondButton = button; + QTest::keyPress(window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QVERIFY(secondButton && dropOn(*secondButton, files({path}), QPoint(5, 5), Qt::AltModifier)); + QTest::keyRelease(window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QTRY_COMPARE(names(second.session), QStringList{"dropped"}); + // The last import ends before the next drop is taken. + QTRY_VERIFY(workspace.canSwitch()); + auto *newButton = window.findChild<NewCanvasButton *>(); + QVERIFY(newButton && dropOn(*newButton, files({path}), QPoint(5, 5))); + QTRY_COMPARE(int(workspace.tabs().size()), 3); + QTRY_COMPARE(names(workspace.current().session), QStringList{"dropped"}); +} + +// Without a workspace, drops go to the canvas's session. +void ImageFileDropTests::aCanvasAloneImportsUnderSwiftsGuards() +{ + QTemporaryDir folder; + const QString path = fixture(folder, "alone.png", "PNG"); + EditorSession session; + session.createDocument(40, 20); + QImage red(10, 10, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session.insert(ImportedImage(red, QImage(), "Red"), QPointF(20, 10)); + ContentView content(session); + content.resize(1000, 700); + content.show(); + const auto refused = [&] { return !dropOn(content, files({path}), content.rect().center()); }; + session.setIsProjectBusy(true); + QVERIFY(refused()); + session.setIsProjectBusy(false); + session.setShowsNewDocument(true); + QVERIFY(refused()); + session.setShowsNewDocument(false); + session.setShowsImporter(true); + QVERIFY(refused()); + session.setShowsImporter(false); + session.setRenamingLayerID(session.activeLayerID()); + QVERIFY(refused()); + session.setRenamingLayerID(std::nullopt); + session.beginLevels(); + QVERIFY(session.levels() && refused()); + session.cancelLevels(); + QVERIFY(dropOn(content, files({path}), content.rect().center())); + QTRY_COMPARE(names(session), (QStringList{"Red", "alone"})); +} + +// Words added to a shown error reach the shown alert. +void ImageFileDropTests::anOpenAlertShowsTheGrownError() +{ + EditorSession session; + ContentView content(session); + content.show(); + session.setImportError(QStringLiteral("broken.png could not be read.")); + auto *alert = content.findChild<QMessageBox *>(); + QVERIFY(alert && alert->isVisible()); + session.setImportError(QStringLiteral("broken.png could not be read.\n\nSome dropped items couldn’t be read.")); + QCOMPARE(content.findChildren<QMessageBox *>().size(), 1); + QCOMPARE(alert->informativeText(), QString("broken.png could not be read.\n\nSome dropped items couldn’t be read.")); +} + +// Swift's gate between providers: an error holds the next. +void ImageFileDropTests::eachFileWaitsBehindTheErrorBeforeIt() +{ + QTemporaryDir folder; + for (const char *name : {"first.png", "second.png"}) { + QFile broken(folder.filePath(name)); + QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("no picture") > 0); + } + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(8, 8); + bool done = false; + std::unique_ptr<QMimeData> data(files({folder.filePath("first.png"), folder.filePath("second.png")})); + workspace.receiveProviders(*data, workspace.current().id, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(session.importError().has_value()); + QVERIFY(session.importError().value().startsWith("first.png")); + // The second waits while the first error stands. + QTest::qWait(150); + QVERIFY(!done && !session.importError().value().contains("second.png")); + session.setImportError(std::nullopt); + QTRY_VERIFY(done); + QVERIFY(session.importError().value().startsWith("second.png")); +} + +// A copy cut short by a size limit imports nothing. +void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable() +{ + QImage noisy(64, 64, QImage::Format_RGBA8888); + quint32 seed = 7; + for (int y = 0; y < 64; ++y) + for (int x = 0; x < 64; ++x) + noisy.setPixel(x, y, (seed = seed * 1664525u + 1013904223u) | 0xff000000u); + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + noisy.save(&buffer, "PNG"); + QVERIFY(png.size() > 4096); + QMimeData data; + data.setData("image/png", png); + const auto dropped = [] { return QDir::temp().entryList({"Dropped-*"}, QDir::Files).size(); }; + const qsizetype before = dropped(); + EditorSession session; + bool done = false; + signal(SIGXFSZ, SIG_IGN); + rlimit room{}; + QCOMPARE(getrlimit(RLIMIT_FSIZE, &room), 0); + rlimit tight = room; + tight.rlim_cur = 1024; + QCOMPARE(setrlimit(RLIMIT_FSIZE, &tight), 0); + ImageFileDrop::importProviders(data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; }); + QCOMPARE(setrlimit(RLIMIT_FSIZE, &room), 0); + QTRY_VERIFY(done); + QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, or TIFF files from the file manager.")); + QVERIFY(!session.document()); + QCOMPARE(dropped(), before); +} + +// A picture with its address is one provider, whole. +void ImageFileDropTests::aPictureReachesATabWhole() +{ + QImage image(5, 3, QImage::Format_RGBA8888); + image.fill(Qt::green); + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + QMimeData data; + data.setUrls({QUrl("https://example.com/picture.png")}); + data.setData("image/png", png); + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(8, 8); + bool done = false; + workspace.receiveProviders(data, workspace.current().id, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session.importError()); + const QStringList listed = names(session); + QCOMPARE(listed.size(), 1); + QVERIFY(listed.value(0).startsWith("Dropped-")); + QFile::remove(QDir::temp().filePath(listed.value(0) + ".png")); +} + +namespace { +// A drag that hands each format over only when asked. +class LazyDrag : public QMimeData { +public: + QByteArray png; + mutable QStringList asked; + QStringList formats() const override { return {"text/uri-list", "image/bmp", "image/png", "image/jpeg"}; } + +protected: + QVariant retrieveData(const QString &type, QMetaType) const override + { + asked << type; + if (type == QLatin1String("text/uri-list")) + return QVariantList{QUrl("https://example.com/picture.png")}; + return type == QLatin1String("image/png") ? QVariant(png) : QVariant(); + } +}; +} + +void ImageFileDropTests::aLazyDragGivesOnlyThePictureChosen() +{ + QImage image(4, 6, QImage::Format_RGBA8888); + image.fill(Qt::blue); + LazyDrag drag; + QBuffer buffer(&drag.png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(8, 8); + bool done = false; + workspace.receiveProviders(drag, workspace.current().id, std::nullopt, [&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!session.importError()); + QCOMPARE(names(session).size(), 1); + QFile::remove(QDir::temp().filePath(names(session).value(0) + ".png")); + drag.asked.removeDuplicates(); + drag.asked.sort(); + QCOMPARE(drag.asked, (QStringList{"image/png", "text/uri-list"})); +} + +QTEST_MAIN(ImageFileDropTests) +#include "ImageFileDropTests.moc" diff --git a/tests/ImageImportSessionTests.cpp b/tests/ImageImportSessionTests.cpp new file mode 100644 index 0000000..945481a --- /dev/null +++ b/tests/ImageImportSessionTests.cpp @@ -0,0 +1,333 @@ +#include "Document/EditorSession.h" +#include "Rendering/RasterSnapshot.h" +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> + +// The session cases of Swift's ImageImportTests, and the queue. +namespace { +const QString unreadable = QStringLiteral("The image could not be read. It may be damaged or unavailable."); +const QString unsupported = QStringLiteral("Choose a JPEG, PNG, HEIC, or TIFF image."); +const QString tooLarge = QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit."); + +// A 64 by 32 PNG, as Swift's fixture is. +QUrl fixture(const QTemporaryDir &folder, const QString &name = QStringLiteral("fixture.png")) +{ + QImage image(64, 32, QImage::Format_RGBA8888); + image.fill(Qt::red); + if (!image.save(folder.filePath(name), "PNG")) + throw std::runtime_error("cannot write the fixture"); + return QUrl::fromLocalFile(folder.filePath(name)); +} + +// Runs the event loop until the request is in. +bool imported(EditorSession &session, const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt) +{ + bool done = false; + session.importImages(urls, point, [&] { done = true; }); + return QTest::qWaitFor([&] { return done && !session.isImporting(); }, 10'000); +} + +// The thread that logs each finished decode. +QThread *decodingThread = nullptr; +QtMessageHandler forward = nullptr; + +void noteDecodingThread(QtMsgType type, const QMessageLogContext &context, const QString &message) +{ + if (message.startsWith("imported ") && !message.startsWith("imported a request")) + decodingThread = QThread::currentThread(); + forward(type, context, message); +} + +// Ends a session between two events of the loop. +class Reaper : public QObject { +public: + std::unique_ptr<EditorSession> *victim = nullptr; + + bool event(QEvent *event) override + { + if (event->type() != QEvent::User) + return QObject::event(event); + victim->reset(); + return true; + } +}; + +// Pixels the canvas holds without the memory for them. +ImportedImage hollow(int width, int height) +{ + return ImportedImage(std::make_shared<const RasterSnapshot>(width, height, QImage(), QRectF(), std::vector<BrushPatch>()), QImage(), "Hollow"); +} +} + +class ImageImportSessionTests : public QObject { + Q_OBJECT +private slots: + void importPlacementAndPartialFailure(); + void dropPositionUsesDocumentCoordinates(); + void queuedImportsAreNotLost(); + void aRequestIsOneUndoStep(); + void anEmptyRequestIsDoneAtOnce(); + void importsWaitWhileTheProjectIsBusy(); + void importingCommitsAnOpenTransform(); + void decodingRunsOffTheCallersThread(); + void aCallbackSeesItsRequestSettled(); + void aSessionMayEndWhileItsImportRuns(); + void aUrlThatIsNoFileIsUnsupported(); + void thePixelBudgetCountsWhatTheCanvasHolds(); +}; + +void ImageImportSessionTests::importPlacementAndPartialFailure() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + QVERIFY(imported(session, {url, url})); + QCOMPARE(session.document().value().size(), QSizeF(64, 32)); + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers.back().id)); + QCOMPARE(session.document().value().layers.back().name, QString("fixture")); + session.createDocument(128, 128); + const QUrl missing = QUrl::fromLocalFile(url.toLocalFile() + ".missing"); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*fixture\\.png\\.missing: .*")); + QVERIFY(imported(session, {url, missing})); + QCOMPARE(session.document().value().size(), QSizeF(128, 128)); + QCOMPARE(int(session.document().value().layers.size()), 1); + QCOMPARE(session.document().value().layers.front().transform.origin, QPointF(32, 48)); + QCOMPARE(session.importError(), std::optional("fixture.png.missing: " + unreadable)); + QVERIFY(!session.isImporting()); +} + +void ImageImportSessionTests::dropPositionUsesDocumentCoordinates() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + session.createDocument(1000, 800); + const QSizeF size = session.document().value().size(); + session.viewport.resize(QSizeF(700, 500), 2, size); + session.zoom(2.5); + session.viewport.translate(QSizeF(70, -35)); + const QPointF location = session.viewport.viewPoint(QPointF(300, 250), size); + const QPointF dropPoint = session.viewport.documentPoint(location, size); + QVERIFY(imported(session, {url}, dropPoint)); + QCOMPARE(session.document().value().layers.front().transform.origin, QPointF(268, 234)); + QCOMPARE(session.document().value().size(), QSizeF(1000, 800)); +} + +void ImageImportSessionTests::queuedImportsAreNotLost() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + int finished = 0; + QList<int> order; + // Each request reports its layers and its undo steps. + const auto report = [&](int request) { + order << request << int(session.document().value().layers.size()) << session.history.undoCount(); + ++finished; + }; + session.importImages({url, url}, QPointF(999, 999), [&] { report(1); }); + session.importImages({url}, std::nullopt, [&] { report(2); }); + QVERIFY(session.isImporting()); + QVERIFY(QTest::qWaitFor([&] { return finished == 2 && !session.isImporting(); }, 10'000)); + // First in, first done, each step recorded before its report. + QCOMPARE(order, (QList<int>{1, 2, 1, 2, 3, 2})); + QCOMPARE(int(session.document().value().layers.size()), 3); + QCOMPARE(session.document().value().size(), QSizeF(64, 32)); + // No canvas yet: both ignore the drop point. + QCOMPARE(session.document().value().layers[0].transform.origin, QPointF(0, 0)); + QCOMPARE(session.document().value().layers[1].transform.origin, QPointF(0, 0)); + QVERIFY(!session.isImporting()); + QCOMPARE(session.importError(), std::nullopt); + // One step for each request. + QCOMPARE(session.history.undoCount(), 2); +} + +void ImageImportSessionTests::aRequestIsOneUndoStep() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder), other = fixture(folder, "other.png"); + EditorSession session; + session.createDocument(200, 100); + const std::optional<CanvasDocument> before = session.document(); + const int count = session.history.undoCount(); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^imported .*fixture\\.png.* 64 x 32$")); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^imported .*other\\.png.* 64 x 32$")); + QTest::ignoreMessage(QtInfoMsg, "imported a request of 2 files"); + QVERIFY(imported(session, {url, other}, QPointF(100, 50))); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Import Images")); + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.document().value().layers[0].name, QString("fixture")); + QCOMPARE(session.document().value().layers[1].name, QString("other")); + // Both land centred on the drop point. + QCOMPARE(session.document().value().layers[0].transform.origin, QPointF(68, 34)); + QCOMPARE(session.document().value().layers[1].transform.origin, QPointF(68, 34)); + session.undo(); + QCOMPARE(session.document(), before); +} + +void ImageImportSessionTests::anEmptyRequestIsDoneAtOnce() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + bool done = false; + session.importImages({}, std::nullopt, [&] { done = true; }); + QVERIFY(done && !session.isImporting()); + QCOMPARE(int(changes.count()), 0); + session.importImages({}); + QVERIFY(!session.isImporting()); + // Files may come without anyone waiting for them. + QTemporaryDir folder; + session.importImages({fixture(folder)}); + QVERIFY(QTest::qWaitFor([&] { return !session.isImporting(); }, 10'000)); + QTest::qWait(50); + QCOMPARE(int(session.document().value().layers.size()), 1); +} + +void ImageImportSessionTests::importsWaitWhileTheProjectIsBusy() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder), other = fixture(folder, "other.png"); + EditorSession session; + session.setIsProjectBusy(true); + int done = 0; + session.importImages({url}, std::nullopt, [&] { ++done; }); + session.importImages({other}, std::nullopt, [&] { ++done; }); + QTest::qWait(100); + QVERIFY(done == 0 && !session.isImporting() && !session.document().has_value()); + session.setIsProjectBusy(false); + QVERIFY(QTest::qWaitFor([&] { return done == 2; }, 10'000)); + // Those that waited go in as they came. + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.document().value().layers[0].name, QString("fixture")); + QCOMPARE(session.document().value().layers[1].name, QString("other")); +} + +void ImageImportSessionTests::importingCommitsAnOpenTransform() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + QVERIFY(imported(session, {url})); + const QUuid first = session.activeLayerID().value(); + session.beginTransform(); + session.nudgeLayer(7, 0); + QVERIFY(session.transformEdit().has_value()); + QVERIFY(imported(session, {url})); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.document().value().layers.front().id, first); + QCOMPARE(session.document().value().layers.front().transform.origin, QPointF(7, 0)); +} + +void ImageImportSessionTests::decodingRunsOffTheCallersThread() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + decodingThread = nullptr; + forward = qInstallMessageHandler(noteDecodingThread); + const bool finished = imported(session, {url}); + qInstallMessageHandler(forward); + QVERIFY(finished); + // The importer logs from the thread that decoded. + QVERIFY(decodingThread != nullptr); + QVERIFY(decodingThread != QThread::currentThread()); + QCOMPARE(session.thread(), QThread::currentThread()); +} + +void ImageImportSessionTests::aCallbackSeesItsRequestSettled() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + const QUrl missing = QUrl::fromLocalFile(folder.filePath("missing.png")); + EditorSession session; + QStringList seen; + const auto note = [&](const QString &request) { + seen << QStringLiteral("%1: importing %2, steps %3, error %4").arg(request).arg(int(session.isImporting())) + .arg(session.history.undoCount()).arg(session.importError().value_or("none")); + }; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*missing\\.png: .*")); + session.importImages({url}, std::nullopt, [&] { note("first"); }); + session.importImages({missing}, std::nullopt, [&] { note("last"); }); + QVERIFY(QTest::qWaitFor([&] { return seen.size() == 2; }, 10'000)); + // The first goes on while the next request decodes. + QCOMPARE(seen[0], QString("first: importing 1, steps 1, error none")); + // The last finds the import over and its error shown. + QCOMPARE(seen[1], "last: importing 0, steps 1, error missing.png: " + unreadable); +} + +void ImageImportSessionTests::aSessionMayEndWhileItsImportRuns() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + bool done = false; + { + EditorSession session; + session.importImages({url, url}, std::nullopt, [&] { done = true; }); + QVERIFY(session.isImporting()); + } + // The worker ends alone; nothing calls back into the dead. + QTest::qWait(300); + QVERIFY(!done); + // Nor does a callback already posted when the session ends. + auto session = std::make_unique<EditorSession>(); + Reaper reaper; + reaper.victim = &session; + bool began = false, reaped = false; + connect(session.get(), &EditorSession::changed, &reaper, [&] { + began = began || session->isImporting(); + // The drain is over; its callback waits in the queue. + if (began && !session->isImporting() && !reaped) { + reaped = true; + QCoreApplication::postEvent(&reaper, new QEvent(QEvent::User), Qt::HighEventPriority); + } + }); + session->importImages({url}, std::nullopt, [&] { done = true; }); + QVERIFY(QTest::qWaitFor([&] { return session == nullptr; }, 10'000)); + QTest::qWait(100); + QVERIFY(reaped && !done); +} + +void ImageImportSessionTests::aUrlThatIsNoFileIsUnsupported() +{ + QTemporaryDir folder; + EditorSession session; + const QUrl gone = QUrl::fromLocalFile(folder.filePath("gone.png")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*gone\\.png: .*")); + QVERIFY(imported(session, {QUrl("https://example.invalid/picture.png"), gone})); + QVERIFY(!session.document().has_value()); + // Each failure on its own, a blank line between. + QCOMPARE(session.importError(), std::optional("picture.png: " + unsupported + "\n\n" + "gone.png: " + unreadable)); + QCOMPARE(session.history.undoCount(), 0); + // The next import's error names its own failure alone. + session.setImportError(std::nullopt); + const QUrl lost = QUrl::fromLocalFile(folder.filePath("lost.png")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*lost\\.png: .*")); + QVERIFY(imported(session, {lost})); + QCOMPARE(session.importError(), std::optional("lost.png: " + unreadable)); +} + +void ImageImportSessionTests::thePixelBudgetCountsWhatTheCanvasHolds() +{ + QTemporaryDir folder; + const QUrl url = fixture(folder); + EditorSession session; + // 100,000,000 less these leaves 2,048: the fixture exactly. + session.insert(hollow(10'000, 9'999)); + session.insert(hollow(7'952, 1)); + session.addBlankLayer(); + QVERIFY(imported(session, {url})); + QCOMPARE(int(session.document().value().layers.size()), 4); + QCOMPARE(session.importError(), std::nullopt); + // Full now: the next pixel is one too many. + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*fixture\\.png: .*")); + QVERIFY(imported(session, {url})); + QCOMPARE(int(session.document().value().layers.size()), 4); + QCOMPARE(session.importError(), std::optional("fixture.png: " + tooLarge)); + QVERIFY(!session.document().value().layers[0].asset.value().raster->hasMaterializedPixels()); +} + +QTEST_GUILESS_MAIN(ImageImportSessionTests) +#include "ImageImportSessionTests.moc" diff --git a/tests/ImageImportTests.cpp b/tests/ImageImportTests.cpp new file mode 100644 index 0000000..d7547d6 --- /dev/null +++ b/tests/ImageImportTests.cpp @@ -0,0 +1,488 @@ +#include "AddressSpaceLimit.h" +#include "IO/ImageImporter.h" +#include <QBuffer> +#include <QColorSpace> +#include <QImageWriter> +#include <QTemporaryDir> +#include <QLibraryInfo> +#include <QVersionNumber> +#include <QtTest> + +namespace { +const QColor red(255, 0, 0), clear(0, 0, 0, 0), warm(200, 100, 50); + +// 64x32: `colour` over the left `columns` and top `rows`. +QImage block(QImage::Format format, const QColor &colour, const QColor &rest, int columns = 32, int rows = 32) +{ + QImage image(64, 32, format); + image.fill(rest); + for (int y = 0; y < rows; ++y) { + for (int x = 0; x < columns; ++x) + image.setPixelColor(x, y, colour); + } + return image; +} + +QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone) +{ + QByteArray data; + QBuffer buffer(&data); + QImageWriter writer(&buffer, format); + writer.setQuality(100); + writer.setTransformation(turned); + if (!writer.write(image)) + throw std::runtime_error("cannot encode the fixture"); + return data; +} + +QString written(const QTemporaryDir &folder, const QString &name, const QByteArray &data) +{ + QFile file(folder.filePath(name)); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) + throw std::runtime_error("cannot write the fixture"); + return file.fileName(); +} + +// An EXIF orientation tag after the JPEG's first marker. +QByteArray withOrientation(QByteArray jpeg, int orientation) +{ + QByteArray exif("\xFF\xE1\x00\x22" "Exif\0\0" "II\x2A\x00\x08\x00\x00\x00" "\x01\x00" "\x12\x01\x03\x00\x01\x00\x00\x00", 28); + exif += char(orientation); + exif += QByteArray("\x00\x00\x00" "\x00\x00\x00\x00", 7); + return jpeg.insert(2, exif); +} + +// The HEIC with its nclx box naming other colours. +QByteArray withNclx(QByteArray heic, int primaries, int transfer) +{ + const qsizetype box = heic.indexOf("colrnclx"); + if (box < 0) + throw std::runtime_error("the fixture holds no nclx box"); + heic[box + 9] = char(primaries); + heic[box + 11] = char(transfer); + return heic; +} + +std::optional<ImageImportError::Kind> importError(const QString &path, qint64 remainingPixels = 100'000'000) +{ + try { + ImageImporter::decode(path, remainingPixels); + } catch (const ImageImportError &error) { + return error.kind; + } + return std::nullopt; +} + +// Whether the pixel is nearer the warm block than white. +bool isBlock(const QImage &image, int x, int y) +{ + const QColor pixel = image.pixelColor(x, y); + return pixel.alpha() == 255 && pixel.red() > 150 && pixel.green() < 140 && pixel.blue() < 100; +} + +const QRegularExpression refusal("^cannot import .*"); +} + +class ImageImportTests : public QObject { + Q_OBJECT +private slots: + void init(); + void supportedFormats_data(); + void supportedFormats(); + void everyOrientationTurnsUpright_data(); + void everyOrientationTurnsUpright(); + void orientationAndColorConversion(); + void heicAlphaIsMultipliedOnce_data(); + void heicAlphaIsMultipliedOnce(); + void heicTurnsOnceAndConvertsItsProfile(); + void heicColoursFollowTheNclxBox_data(); + void heicColoursFollowTheNclxBox(); + void pngPreservesTransparency(); + void translucentColoursConvertUnpremultiplied(); + void deepAndGrayImagesBecomeEightBitColour(); + void limitsAndInvalidFiles(); + void theTypeComesFromTheBytes(); + void namesDropOneExtension(); + void thumbnailsRoundOutward_data(); + void thumbnailsRoundOutward(); + void thumbnailsAreSmoothed(); + void errorsCarrySwiftsDescriptions(); + void aDecodeWithoutMemoryIsUnreadable(); +}; + +void ImageImportTests::init() +{ + // A warning no test expects is a failure. + QTest::failOnWarning(QRegularExpression(".*")); +} + +void ImageImportTests::supportedFormats_data() +{ + QTest::addColumn<QString>("name"); + QTest::newRow("png") << "fixture.png"; + QTest::newRow("jpeg") << "fixture.jpeg"; + QTest::newRow("tiff") << "fixture.tiff"; + QTest::newRow("heic") << "red.heic"; +} + +void ImageImportTests::supportedFormats() +{ + QFETCH(QString, name); + QTemporaryDir folder; + const QByteArray format = QFileInfo(name).suffix().toLatin1(); + const QString path = format == "heic" ? QFINDTESTDATA("fixtures/red.heic") + : written(folder, name, encoded(block(QImage::Format_RGBA8888, red, clear), format.constData())); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^imported .* 64 x 32$")); + const ImportedImage result = ImageImporter::decode(path); + QCOMPARE(result.size(), QSize(64, 32)); + QCOMPARE(result.image().format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(result.image().colorSpace(), QColorSpace(QColorSpace::SRgb)); + QCOMPARE(result.thumbnail.size(), QSize(64, 32)); + QCOMPARE(result.name, QFileInfo(name).completeBaseName()); + // Red left; JPEG has no alpha: black right. + const QColor left = result.image().pixelColor(8, 16), right = result.image().pixelColor(56, 8); + QVERIFY2(left.red() >= 250 && left.green() <= 5 && left.blue() <= 5 && left.alpha() == 255, qPrintable(left.name(QColor::HexArgb))); + QCOMPARE(right.alpha(), format == "jpeg" ? 255 : 0); + QVERIFY2(right.red() <= 5, qPrintable(right.name(QColor::HexArgb))); +} + +void ImageImportTests::heicAlphaIsMultipliedOnce_data() +{ + QTest::addColumn<QString>("fixture"); + QTest::newRow("straight alpha in the file") << "fixtures/red.heic"; + QTest::newRow("multiplied alpha in the file") << "fixtures/premultiplied.heic"; +} + +void ImageImportTests::heicAlphaIsMultipliedOnce() +{ + QFETCH(QString, fixture); + const QImage image = ImageImporter::decode(QFINDTESTDATA(fixture)).image(); + // Blue (0, 0, 255) under alpha 128, bottom right. + const uchar *bytes = image.constScanLine(24) + 56 * 4; + QVERIFY2(bytes[0] <= 3 && bytes[1] <= 3 && qAbs(bytes[2] - 128) <= 3 && qAbs(bytes[3] - 128) <= 3, + qPrintable(QStringLiteral("%1 %2 %3 %4").arg(bytes[0]).arg(bytes[1]).arg(bytes[2]).arg(bytes[3]))); + QCOMPARE(image.pixelColor(56, 8).alpha(), 0); + QVERIFY(image.pixelColor(8, 16).red() >= 250 && image.pixelColor(8, 16).alpha() == 255); +} + +void ImageImportTests::everyOrientationTurnsUpright_data() +{ + QTest::addColumn<int>("orientation"); + QTest::addColumn<QSize>("size"); + // The quarter of the result that holds the block. + QTest::addColumn<QPoint>("quarter"); + QTest::newRow("1 upright") << 1 << QSize(64, 32) << QPoint(0, 0); + QTest::newRow("2 mirrored") << 2 << QSize(64, 32) << QPoint(1, 0); + QTest::newRow("3 half turn") << 3 << QSize(64, 32) << QPoint(1, 1); + QTest::newRow("4 flipped") << 4 << QSize(64, 32) << QPoint(0, 1); + QTest::newRow("5 transposed") << 5 << QSize(32, 64) << QPoint(0, 0); + QTest::newRow("6 quarter turn") << 6 << QSize(32, 64) << QPoint(1, 0); + QTest::newRow("7 transversed") << 7 << QSize(32, 64) << QPoint(1, 1); + QTest::newRow("8 three quarters") << 8 << QSize(32, 64) << QPoint(0, 1); +} + +void ImageImportTests::everyOrientationTurnsUpright() +{ + QFETCH(int, orientation); + QFETCH(QSize, size); + QFETCH(QPoint, quarter); + QTemporaryDir folder; + // The block fills the top left quarter as stored. + const QImage stored = block(QImage::Format_RGB888, warm, Qt::white, 32, 16); + // EXIF 1 to 8 as Qt's mirror, flip, turn bits. + const int flags[] = {0, 1, 3, 2, 6, 4, 5, 7}; + const QStringList paths{written(folder, "turned.jpg", withOrientation(encoded(stored, "jpeg"), orientation)), + written(folder, "turned.tiff", encoded(stored, "tiff", QImageIOHandler::Transformations(flags[orientation - 1])))}; + for (const QString &path : paths) { + const QImage image = ImageImporter::decode(path).image(); + QCOMPARE(image.size(), size); + for (int row = 0; row < 2; ++row) { + for (int column = 0; column < 2; ++column) { + const QPoint centre(size.width() / 4 + column * size.width() / 2, size.height() / 4 + row * size.height() / 2); + QVERIFY2(isBlock(image, centre.x(), centre.y()) == (QPoint(column, row) == quarter), + qPrintable(QStringLiteral("%1 at quarter %2, %3").arg(path).arg(column).arg(row))); + } + } + } +} + +void ImageImportTests::orientationAndColorConversion() +{ + QTemporaryDir folder; + QImage wide = block(QImage::Format_RGBA8888, warm, clear); + wide.setColorSpace(QColorSpace::DisplayP3); + const ImportedImage result = ImageImporter::decode(written(folder, "p3.tiff", encoded(wide, "tiff", QImageIOHandler::TransformationRotate90))); + QCOMPARE(result.size(), QSize(32, 64)); + QCOMPARE(result.image().colorSpace(), QColorSpace(QColorSpace::SRgb)); + // Display P3 (200, 100, 50) is sRGB (215, 93, 31). + QCOMPARE(result.image().pixelColor(16, 8), QColor(215, 93, 31)); + QCOMPARE(result.image().pixelColor(16, 56), clear); + // Untagged pixels are sRGB already. + const ImportedImage plain = ImageImporter::decode(written(folder, "plain.tiff", encoded(block(QImage::Format_RGBA8888, warm, clear), "tiff"))); + QCOMPARE(plain.image().pixelColor(8, 16), warm); +} + +void ImageImportTests::heicTurnsOnceAndConvertsItsProfile() +{ + // The file holds a turn box and EXIF orientation 6. + const ImportedImage result = ImageImporter::decode(QFINDTESTDATA("fixtures/turned-p3.heic")); + QCOMPARE(result.size(), QSize(32, 64)); + QCOMPARE(result.name, QString("turned-p3")); + // Stored left turns to the top; white and gray follow. + const QColor top = result.image().pixelColor(16, 8), white = result.image().pixelColor(24, 56), dark = result.image().pixelColor(8, 56); + QVERIFY2(qAbs(top.red() - 215) <= 4 && qAbs(top.green() - 93) <= 4 && qAbs(top.blue() - 31) <= 4, qPrintable(top.name())); + QVERIFY2(white.red() >= 250 && white.green() >= 250 && white.blue() >= 250, qPrintable(white.name())); + QVERIFY2(qAbs(dark.red() - 16) <= 4 && qAbs(dark.green() - 16) <= 4 && qAbs(dark.blue() - 16) <= 4, qPrintable(dark.name())); + // Its budget and its damage are judged like any other's. + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*turned-p3.heic: 32 x 64 pixels$")); + QCOMPARE(importError(QFINDTESTDATA("fixtures/turned-p3.heic"), 2047), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(importError(QFINDTESTDATA("fixtures/turned-p3.heic"), 2048), std::nullopt); + QTemporaryDir folder; + QFile whole(QFINDTESTDATA("fixtures/red.heic")); + QVERIFY(whole.open(QIODevice::ReadOnly)); + QByteArray bytes = whole.readAll(); + // Cut short, the file cannot be read; spoiled, not decoded. + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*cut.heic: Invalid input: No 'meta' box(: .*)?$")); + QCOMPARE(importError(written(folder, "cut.heic", bytes.left(700))), std::optional(ImageImportError::Kind::unreadable)); + for (qsizetype index = bytes.size() - 300; index < bytes.size(); ++index) + bytes[index] = char(index * 37); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*spoiled.heic: Decoder plugin generated an error: .*")); + QCOMPARE(importError(written(folder, "spoiled.heic", bytes)), std::optional(ImageImportError::Kind::unreadable)); +} + +void ImageImportTests::heicColoursFollowTheNclxBox_data() +{ + QTest::addColumn<int>("primaries"); + QTest::addColumn<int>("transfer"); + // What (200, 100, 50) and gray 16 become. + QTest::addColumn<QColor>("expected"); + QTest::addColumn<int>("gray"); + QTest::addColumn<QString>("reason"); + QTest::newRow("Display P3, sRGB curve") << 12 << 13 << QColor(215, 93, 31) << 16 << ""; + QTest::newRow("Display P3, no curve named") << 12 << 2 << QColor(215, 93, 31) << 16 << ""; + QTest::newRow("sRGB named") << 1 << 13 << QColor(200, 100, 50) << 16 << ""; + QTest::newRow("nothing named") << 2 << 2 << QColor(200, 100, 50) << 16 << ""; + // The video curve's straight toe lifts gray 16 to 31. + QTest::newRow("BT.709 video") << 1 << 1 << QColor(206, 114, 66) << 31 << ""; + QTest::newRow("video curve, no primaries named") << 2 << 6 << QColor(206, 114, 66) << 31 << ""; + QTest::newRow("BT.2020, 10 bits") << 9 << 14 << QColor(246, 94, 50) << 31 << ""; + QTest::newRow("BT.2020, 12 bits") << 9 << 15 << QColor(246, 94, 50) << 31 << ""; + QTest::newRow("PQ") << 9 << 16 << QColor() << 0 << "transfer characteristics 16 have no conversion to sRGB here"; + QTest::newRow("HLG") << 9 << 18 << QColor() << 0 << "transfer characteristics 18 have no conversion to sRGB here"; + QTest::newRow("xvYCC") << 1 << 11 << QColor(206, 114, 66) << 31 << ""; + QTest::newRow("BT.1361") << 1 << 12 << QColor(206, 114, 66) << 31 << ""; + QTest::newRow("linear light") << 1 << 8 << QColor(229, 168, 122) << 71 << ""; + QTest::newRow("gamma 2.2") << 1 << 4 << QColor(201, 100, 46) << 7 << ""; + QTest::newRow("gamma 2.8") << 1 << 5 << QColor(189, 76, 26) << 1 << ""; + QTest::newRow("SMPTE 240M") << 1 << 7 << QColor() << 0 << "transfer characteristics 7 have no conversion to sRGB here"; + QTest::newRow("a logarithmic curve") << 1 << 9 << QColor() << 0 << "transfer characteristics 9 have no conversion to sRGB here"; + QTest::newRow("XYZ primaries, which Qt cannot hold") << 10 << 13 << QColor() << 0 << "colour primaries 10 have no conversion to sRGB here"; + QTest::newRow("primaries libheif refuses") << 3 << 13 << QColor() << 0 << "its nclx colour box: "; + QTest::newRow("a curve libheif refuses") << 1 << 3 << QColor() << 0 << "its nclx colour box: "; +} + +void ImageImportTests::heicColoursFollowTheNclxBox() +{ + QFETCH(int, primaries); + QFETCH(int, transfer); + QFETCH(QColor, expected); + QFETCH(int, gray); + QFETCH(QString, reason); + QFile fixture(QFINDTESTDATA("fixtures/p3-nclx.heic")); + QVERIFY(fixture.open(QIODevice::ReadOnly)); + QTemporaryDir folder; + const QString path = written(folder, "named.heic", withNclx(fixture.readAll(), primaries, transfer)); + if (!expected.isValid()) { + // Qt says so itself before the importer refuses. + if (primaries == 10) + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^QColorSpace attempted constructed from invalid primaries: .*")); + // libheif words its own refusals; ours are whole. + const QString tail = reason.endsWith(": ") ? ".+$" : "$"; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*named.heic: " + QRegularExpression::escape(reason) + tail)); + QCOMPARE(importError(path), std::optional(ImageImportError::Kind::unreadable)); + return; + } + const QImage image = ImageImporter::decode(path).image(); + QCOMPARE(image.colorSpace(), QColorSpace(QColorSpace::SRgb)); + // The fixture is lossless: one level is Qt's rounding. + const QColor left = image.pixelColor(8, 16), dark = image.pixelColor(56, 24); + QVERIFY2(qAbs(left.red() - expected.red()) <= 1 && qAbs(left.green() - expected.green()) <= 1 && qAbs(left.blue() - expected.blue()) <= 1, + qPrintable(left.name())); + QVERIFY2(qAbs(dark.red() - gray) <= 1 && qAbs(dark.green() - gray) <= 1 && qAbs(dark.blue() - gray) <= 1, qPrintable(dark.name())); + QCOMPARE(image.pixelColor(56, 8), QColor(255, 255, 255)); +} + +void ImageImportTests::pngPreservesTransparency() +{ + QTemporaryDir folder; + const QImage image = ImageImporter::decode(written(folder, "clear.png", encoded(block(QImage::Format_RGBA8888, red, clear), "png"))).image(); + const uchar *bytes = image.constBits(); + QCOMPARE(bytes[3], uchar(255)); + QVERIFY(bytes[0] >= 250); + QCOMPARE(bytes[63 * 4 + 3], uchar(0)); +} + +void ImageImportTests::translucentColoursConvertUnpremultiplied() +{ + QTemporaryDir folder; + QImage wide = block(QImage::Format_RGBA8888, QColor(200, 100, 50, 128), clear); + wide.setColorSpace(QColorSpace::DisplayP3); + const QImage image = ImageImporter::decode(written(folder, "veil.png", encoded(wide, "png"))).image(); + // sRGB (215, 93, 31) under alpha 128, premultiplied. + const uchar *bytes = image.constBits(); + QVERIFY2(qAbs(bytes[0] - 108) <= 1 && qAbs(bytes[1] - 47) <= 1 && qAbs(bytes[2] - 16) <= 1, qPrintable(QString::number(bytes[0]))); + QCOMPARE(bytes[3], uchar(128)); +} + +void ImageImportTests::deepAndGrayImagesBecomeEightBitColour() +{ + QTemporaryDir folder; + const QImage deep = ImageImporter::decode(written(folder, "deep.png", encoded(block(QImage::Format_RGBA64, QColor(200, 100, 50, 128), clear), "png"))).image(); + QCOMPARE(deep.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(QColor::fromRgba(qRgba(deep.constBits()[0], deep.constBits()[1], deep.constBits()[2], deep.constBits()[3])), QColor(100, 50, 25, 128)); + const QImage gray = ImageImporter::decode(written(folder, "gray.jpg", encoded(block(QImage::Format_Grayscale8, QColor(90, 90, 90), Qt::white), "jpeg"))).image(); + QCOMPARE(gray.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(gray.pixelColor(8, 16), QColor(90, 90, 90)); +} + +void ImageImportTests::limitsAndInvalidFiles() +{ + QTemporaryDir folder; + const QString path = written(folder, "fixture.png", encoded(block(QImage::Format_RGBA8888, red, clear), "png")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*fixture.png: 64 x 32 pixels$")); + QCOMPARE(importError(path, 10), std::optional(ImageImportError::Kind::tooLarge)); + // The budget holds 64 x 32 exactly. + QTest::ignoreMessage(QtWarningMsg, refusal); + QCOMPARE(importError(path, 2047), std::optional(ImageImportError::Kind::tooLarge)); + QCOMPARE(importError(path, 2048), std::nullopt); + // 30,000 a side, either side. + for (const QSize &size : {QSize(30'001, 1), QSize(1, 30'001)}) { + QTest::ignoreMessage(QtWarningMsg, refusal); + QCOMPARE(importError(written(folder, "long.png", encoded(QImage(size, QImage::Format_Grayscale8), "png"))), + std::optional(ImageImportError::Kind::tooLarge)); + } + QCOMPARE(ImageImporter::decode(written(folder, "edge.png", encoded(QImage(30'000, 1, QImage::Format_Grayscale8), "png"))).size(), QSize(30'000, 1)); + QCOMPARE(ImageImporter::decode(written(folder, "edge.png", encoded(QImage(1, 30'000, QImage::Format_Grayscale8), "png"))).size(), QSize(1, 30'000)); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*text.png: no image format is recognised$")); + QCOMPARE(importError(written(folder, "text.png", "not an image")), std::optional(ImageImportError::Kind::unreadable)); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*missing.png: No such file or directory$")); + QCOMPARE(importError(folder.filePath("missing.png")), std::optional(ImageImportError::Kind::unreadable)); + // Half a TIFF lost its directory; half a PNG, pixels. + const QImage big = block(QImage::Format_RGBA8888, red, clear).scaled(640, 320); + // From Qt 6.9 the TIFF plugin logs libtiff errors critically. + if (QLibraryInfo::version() >= QVersionNumber(6, 9)) { + QTest::ignoreMessage(QtCriticalMsg, QRegularExpression("^\"Can not read TIFF directory count\"$")); + QTest::ignoreMessage(QtCriticalMsg, QRegularExpression("^\"Failed to read directory at offset \\d+\"$")); + } + const QString cutTiff = written(folder, "cut.tiff", encoded(big, "tiff").left(400'000)); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*cut.tiff: it states no size$")); + QCOMPARE(importError(cutTiff), std::optional(ImageImportError::Kind::unreadable)); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*cut.png: Unable to read image data$")); + const QByteArray png = encoded(big, "png"); + QCOMPARE(importError(written(folder, "cut.png", png.left(png.size() / 2))), std::optional(ImageImportError::Kind::unreadable)); + // Formats Qt reads and the app does not take. + const QByteArray gif = QByteArray::fromHex("47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b"); + const QByteArray avif = QByteArray::fromHex("0000001c667479706176696600000000617669666d6966316d696166"); + const QList<std::pair<QString, QByteArray>> unwanted{{"tiny.gif", gif}, {"tiny.avif", avif}, + {"fixture.bmp", encoded(block(QImage::Format_RGB888, red, Qt::white), "bmp")}, + {"fixture.webp", encoded(block(QImage::Format_RGB888, red, Qt::white), "webp")}}; + for (const auto &[name, data] : unwanted) { + QTest::ignoreMessage(QtWarningMsg, refusal); + QVERIFY2(importError(written(folder, name, data)) == std::optional(ImageImportError::Kind::unsupported), qPrintable(name)); + } +} + +void ImageImportTests::theTypeComesFromTheBytes() +{ + QTemporaryDir folder; + const QByteArray png = encoded(block(QImage::Format_RGBA8888, red, clear), "png"); + QCOMPARE(ImageImporter::decode(written(folder, "really-png.jpg", png)).image().pixelColor(56, 16), clear); + QCOMPARE(ImageImporter::decode(written(folder, "no-suffix", png)).size(), QSize(64, 32)); + QFile heic(QFINDTESTDATA("fixtures/red.heic")); + QVERIFY(heic.open(QIODevice::ReadOnly)); + QCOMPARE(ImageImporter::decode(written(folder, "really-heic.png", heic.readAll())).image().pixelColor(56, 8), clear); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*really-gif.png: gif$")); + const QByteArray gif = QByteArray::fromHex("47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b"); + QCOMPARE(importError(written(folder, "really-gif.png", gif)), std::optional(ImageImportError::Kind::unsupported)); +} + +void ImageImportTests::namesDropOneExtension() +{ + QTemporaryDir folder; + const QByteArray png = encoded(block(QImage::Format_RGBA8888, red, clear), "png"); + QCOMPARE(ImageImporter::decode(written(folder, "holiday.final.png", png)).name, QString("holiday.final")); + QCOMPARE(ImageImporter::decode(written(folder, "Paint & sky.png", png)).name, QString("Paint & sky")); + QCOMPARE(ImageImporter::decode(written(folder, "bare", png)).name, QString("bare")); + // A dot file has no extension to drop. + QCOMPARE(ImageImporter::decode(written(folder, ".png", png)).name, QString(".png")); +} + +void ImageImportTests::thumbnailsRoundOutward_data() +{ + QTest::addColumn<QSize>("size"); + QTest::addColumn<QSize>("thumbnail"); + QTest::newRow("small images keep their size") << QSize(96, 40) << QSize(96, 40); + QTest::newRow("97 wide") << QSize(97, 40) << QSize(96, 40); + QTest::newRow("wide") << QSize(200, 100) << QSize(96, 48); + QTest::newRow("28.8 rounds up") << QSize(100, 30) << QSize(96, 29); + QTest::newRow("tall") << QSize(30, 100) << QSize(29, 96); + QTest::newRow("a strip stays one pixel high") << QSize(2400, 1) << QSize(96, 1); +} + +void ImageImportTests::thumbnailsRoundOutward() +{ + QFETCH(QSize, size); + QFETCH(QSize, thumbnail); + QTemporaryDir folder; + QImage image(size, QImage::Format_RGB888); + image.fill(warm); + const ImportedImage result = ImageImporter::decode(written(folder, "sized.png", encoded(image, "png"))); + QCOMPARE(result.thumbnail.size(), thumbnail); + QCOMPARE(result.thumbnail.pixelColor(thumbnail.width() - 1, thumbnail.height() - 1), warm); + QCOMPARE(result.thumbnail.colorSpace(), QColorSpace(QColorSpace::SRgb)); +} + +void ImageImportTests::thumbnailsAreSmoothed() +{ + QTemporaryDir folder; + // Columns of black and white: nearest picks one, smooth blends. + QImage stripes(192, 192, QImage::Format_RGB888); + for (int y = 0; y < 192; ++y) { + for (int x = 0; x < 192; ++x) + stripes.setPixel(x, y, x % 2 == 0 ? qRgb(0, 0, 0) : qRgb(255, 255, 255)); + } + const QImage thumbnail = ImageImporter::decode(written(folder, "stripes.png", encoded(stripes, "png"))).thumbnail; + QCOMPARE(thumbnail.size(), QSize(96, 96)); + QVERIFY2(qAbs(thumbnail.pixelColor(48, 48).red() - 128) <= 2, qPrintable(thumbnail.pixelColor(48, 48).name())); +} + +void ImageImportTests::errorsCarrySwiftsDescriptions() +{ + QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unreadable).what()), QString("The image could not be read. It may be damaged or unavailable.")); + QCOMPARE(QString(ImageImportError(ImageImportError::Kind::unsupported).what()), QString("Choose a JPEG, PNG, HEIC, or TIFF image.")); + QCOMPARE(QString(ImageImportError(ImageImportError::Kind::tooLarge).what()), + QString("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.")); + QCOMPARE(ImageImportError(ImageImportError::Kind::tooLarge).kind, ImageImportError::Kind::tooLarge); +} + +void ImageImportTests::aDecodeWithoutMemoryIsUnreadable() +{ + QTemporaryDir folder; + // 9000 x 9000 needs 324 MB; the file is small. + QImage large(9000, 9000, QImage::Format_Grayscale8); + large.fill(90); + const QString path = written(folder, "large.png", encoded(large, "png")); + large = QImage(); + QTest::ignoreMessage(QtWarningMsg, "QImage: out of memory, returning null image"); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot import .*large.png: out of memory$")); + std::optional<ImageImportError::Kind> kind; + { + const AddressSpaceLimit limit(200ll * 1024 * 1024); + kind = importError(path); + } + QCOMPARE(kind, std::optional(ImageImportError::Kind::unreadable)); + QCOMPARE(ImageImporter::decode(path).size(), QSize(9000, 9000)); +} + +QTEST_MAIN(ImageImportTests) +#include "ImageImportTests.moc" diff --git a/tests/ImageSizeSheetTests.cpp b/tests/ImageSizeSheetTests.cpp new file mode 100644 index 0000000..dc7d940 --- /dev/null +++ b/tests/ImageSizeSheetTests.cpp @@ -0,0 +1,285 @@ +#include "UI/ColorPickerSheet.h" +#include "UI/ImageSizeSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QLabel> +#include <QPushButton> +#include <QApplication> +#include <QWheelEvent> +#include <QtTest> + +// Swift's ImageSizeSheet: pixels resampled or print size alone. +class ImageSizeSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetShowsTheCanvas(); + void pixelsFollowUnitsAndTheLock(); + void aNewResolutionKeepsThePrintSize(); + void withoutResamplingOnlyTheResolutionMoves(); + void anInvalidSizeRestsResize(); + void returnWithoutTypingKeepsTheNumbers(); + void aWheelOverUnitsCommitsTheTypingFirst(); + void halfwayNumbersRoundToEven(); +}; + +namespace { +struct Sheet { + std::optional<std::optional<ImageSizeOptions>> answer; + ImageSizeSheet sheet; + explicit Sheet(int width = 1200, int height = 800) + : sheet(CanvasDocument{width, height}, [this](std::optional<ImageSizeOptions> options) { answer = options; }) + { + sheet.show(); + } + template <typename Widget> Widget &find(const char *name) + { + Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; + } + void type(const char *name, const QString &text) + { + auto &field = find<PickerField>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + QTest::keyClick(&field, Qt::Key_Return); + } + void unit(const QString &name) + { + auto &units = find<QComboBox>("imageUnits"); + const int index = units.findText(name); + units.setCurrentIndex(index); + emit units.activated(index); + } + QString shown(const char *name) { return find<PickerField>(name).text(); } + QString result() { return find<QLabel>("imageResult").text(); } + QStringList units() + { + QStringList names; + auto &units = find<QComboBox>("imageUnits"); + for (int index = 0; index < units.count(); ++index) + names << units.itemText(index); + return names; + } +}; +} + +void ImageSizeSheetTests::theSheetShowsTheCanvas() +{ + Sheet shown; + QStringList texts; + for (const QLabel *label : shown.sheet.findChildren<QLabel *>()) + texts << label->text(); + QVERIFY(texts.contains("Image Size")); + QVERIFY(texts.contains("Current: 1,200 × 800 pixels")); + QVERIFY(texts.contains("pixels/inch")); + QCOMPARE(shown.sheet.width(), 430); + QCOMPARE(shown.units(), (QStringList{"Pixels", "Percent", "Inches", "Centimeters"})); + QCOMPARE(shown.shown("imageWidth"), QString("1200")); + QCOMPARE(shown.shown("imageResolution"), QString("72")); + QVERIFY(shown.find<QCheckBox>("imageLocked").isChecked()); + QVERIFY(shown.find<QCheckBox>("imageResample").isChecked()); + auto &sampling = shown.find<QComboBox>("imageSampling"); + QCOMPARE(sampling.currentText(), QString("High quality")); + QCOMPARE(sampling.count(), 3); + QCOMPARE(shown.find<QLabel>("imageExplanation").text(), QString("Resizes layer pixels and applies existing transforms. Undo restores the originals.")); + QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels")); + // Cancel answers with nothing; Resize with the size. + shown.find<QPushButton>("imageCancel").click(); + QVERIFY(!shown.answer.value().has_value()); + emit sampling.activated(0); + shown.find<QPushButton>("imageResize").click(); + const ImageSizeOptions options = shown.answer.value().value(); + QVERIFY(options.width == 1200 && options.height == 800 && options.resolution == 72 && options.sampling == LayerSampling::nearest); +} + +void ImageSizeSheetTests::pixelsFollowUnitsAndTheLock() +{ + Sheet shown; + shown.type("imageWidth", "600"); + QCOMPARE(shown.shown("imageHeight"), QString("400")); + shown.find<QCheckBox>("imageLocked").click(); + shown.type("imageHeight", "500"); + QCOMPARE(shown.shown("imageWidth"), QString("600")); + QCOMPARE(shown.result(), QString("Result: 600 × 500 pixels")); + shown.unit("Percent"); + QCOMPARE(shown.shown("imageWidth"), QString("50")); + QCOMPARE(shown.shown("imageHeight"), QString("62.5")); + shown.type("imageWidth", "25"); + QCOMPARE(shown.result(), QString("Result: 300 × 500 pixels")); + shown.unit("Inches"); + QCOMPARE(shown.shown("imageWidth"), QString("4.167")); + shown.type("imageWidth", "10"); + QCOMPARE(shown.result(), QString("Result: 720 × 500 pixels")); + shown.unit("Centimeters"); + shown.type("imageWidth", "2.54"); + QCOMPARE(shown.result(), QString("Result: 72 × 500 pixels")); + QCOMPARE(shown.shown("imageHeight"), QString("17.639")); + // Percent counts each side against its own original. + shown.unit("Percent"); + shown.type("imageHeight", "50"); + QCOMPARE(shown.result(), QString("Result: 72 × 400 pixels")); + // Locked, a height takes the width along. + shown.find<QCheckBox>("imageLocked").click(); + shown.unit("Pixels"); + shown.type("imageHeight", "800"); + QCOMPARE(shown.result(), QString("Result: 144 × 800 pixels")); + shown.find<QCheckBox>("imageLocked").click(); + shown.type("imageWidth", "600.6"); + shown.find<QPushButton>("imageResize").click(); + QCOMPARE(shown.answer.value().value().width, 601); + // Nothing, no number and zero are refused, as Swift's guard. + for (const QString &refused : {QString("0"), QString("-3"), QString("wide")}) { + shown.type("imageWidth", refused); + QCOMPARE(shown.result(), QString("Result: 601 × 800 pixels")); + } +} + +void ImageSizeSheetTests::aNewResolutionKeepsThePrintSize() +{ + Sheet shown; + // In physical units the pixels follow the resolution. + shown.unit("Inches"); + shown.type("imageResolution", "144"); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); + QCOMPARE(shown.shown("imageWidth"), QString("16.667")); + // In pixels they stay. + shown.unit("Pixels"); + shown.type("imageResolution", "300"); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); + shown.find<QPushButton>("imageResize").click(); + QCOMPARE(shown.answer.value().value().resolution, 300.0); + // Zero changes nothing but the resolution, which is refused. + shown.unit("Inches"); + shown.type("imageResolution", "0"); + QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch.")); + shown.type("imageResolution", "150"); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); + // Centimetres keep the print size alike. + shown.unit("Centimeters"); + shown.type("imageResolution", "300"); + QCOMPARE(shown.result(), QString("Result: 4,800 × 3,200 pixels")); +} + +void ImageSizeSheetTests::withoutResamplingOnlyTheResolutionMoves() +{ + Sheet shown; + shown.type("imageWidth", "600"); + shown.find<QCheckBox>("imageLocked").click(); + shown.find<QCheckBox>("imageResample").click(); + // The pixels go back; physical units alone; locked. + QCOMPARE(shown.units(), (QStringList{"Inches", "Centimeters"})); + QCOMPARE(shown.find<QComboBox>("imageUnits").currentText(), QString("Inches")); + QVERIFY(shown.find<QCheckBox>("imageLocked").isChecked()); + QVERIFY(!shown.find<QCheckBox>("imageLocked").isEnabled()); + QVERIFY(!shown.find<QWidget>("imageSamplingRow").isVisible()); + QCOMPARE(shown.find<QLabel>("imageExplanation").text(), QString("Only print dimensions and resolution change. Pixels stay unchanged.")); + QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels")); + shown.type("imageWidth", "10"); + QCOMPARE(shown.shown("imageResolution"), QString("120")); + QCOMPARE(shown.shown("imageHeight"), QString("6.667")); + shown.unit("Centimeters"); + shown.type("imageHeight", "25.4"); + QCOMPARE(shown.shown("imageResolution"), QString("80")); + shown.find<QPushButton>("imageResize").click(); + const ImageSizeOptions options = shown.answer.value().value(); + QVERIFY(options.width == 1200 && options.height == 800 && options.resolution == 80); + // Without resampling a resolution moves no pixels. + shown.type("imageResolution", "160"); + QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels")); + // Back on, the units return; the pixels are kept. + shown.find<QCheckBox>("imageResample").click(); + QCOMPARE(shown.units(), (QStringList{"Pixels", "Percent", "Inches", "Centimeters"})); + QCOMPARE(shown.find<QComboBox>("imageUnits").currentText(), QString("Centimeters")); + QVERIFY(shown.find<QCheckBox>("imageLocked").isEnabled()); +} + +void ImageSizeSheetTests::anInvalidSizeRestsResize() +{ + Sheet shown; + auto &resize = shown.find<QPushButton>("imageResize"); + QVERIFY(resize.isDefault() && resize.isEnabled()); + shown.find<QCheckBox>("imageLocked").click(); + shown.type("imageWidth", "30001"); + QCOMPARE(shown.result(), QString("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch.")); + QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::BrightText); + QVERIFY(!resize.isEnabled()); + // Past 100 megapixels while resampling. + shown.type("imageWidth", "20000"); + shown.type("imageHeight", "5001"); + QVERIFY(!resize.isEnabled()); + shown.type("imageHeight", "5000"); + QVERIFY(resize.isEnabled()); + QCOMPARE(shown.find<QLabel>("imageResult").foregroundRole(), QPalette::PlaceholderText); + shown.type("imageResolution", "9601"); + QVERIFY(!resize.isEnabled()); + shown.type("imageResolution", "9600"); + QVERIFY(resize.isEnabled()); + shown.type("imageResolution", "0.5"); + QVERIFY(!resize.isEnabled()); + shown.type("imageResolution", "72"); + shown.type("imageWidth", "30000"); + shown.type("imageHeight", "1"); + QVERIFY(resize.isEnabled()); + // Without resampling the pixels are the canvas's, however many. + Sheet huge(30'000, 30'000); + QVERIFY(!huge.find<QPushButton>("imageResize").isEnabled()); + huge.find<QCheckBox>("imageResample").click(); + QVERIFY(huge.find<QPushButton>("imageResize").isEnabled()); +} + +void ImageSizeSheetTests::returnWithoutTypingKeepsTheNumbers() +{ + Sheet shown; + shown.find<QCheckBox>("imageResample").click(); + // A size shown to three places would move the resolution. + auto &width = shown.find<PickerField>("imageWidth"); + width.setFocus(); + QTest::keyClick(&width, Qt::Key_Return); + QCOMPARE(shown.shown("imageResolution"), QString("72")); + shown.type("imageWidth", "7"); + auto &resolution = shown.find<PickerField>("imageResolution"); + QCOMPARE(resolution.text(), QString("171.429")); + resolution.setFocus(); + QTest::keyClick(&resolution, Qt::Key_Return); + shown.find<QPushButton>("imageResize").click(); + QCOMPARE(shown.answer.value().value().resolution, 1200.0 / 7); +} + +// Scrolled, Units take no focus; the typing meant pixels. +void ImageSizeSheetTests::aWheelOverUnitsCommitsTheTypingFirst() +{ + Sheet shown; + // Focus moves only in the active window. + shown.sheet.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&shown.sheet)); + auto &width = shown.find<PickerField>("imageWidth"); + width.setFocus(); + width.selectAll(); + QTest::keyClicks(&width, "600"); + auto &units = shown.find<QComboBox>("imageUnits"); + QWheelEvent wheel(QPointF(5, 5), units.mapToGlobal(QPointF(5, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(&units, &wheel); + QCOMPARE(units.currentText(), QString("Percent")); + QCOMPARE(width.text(), QString("50")); + QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels")); +} + +// Foundation rounds halves to even: 17 pixels, 16 per inch. +void ImageSizeSheetTests::halfwayNumbersRoundToEven() +{ + CanvasDocument canvas{17, 19}; + canvas.resolution = 16; + ImageSizeSheet sheet(canvas, [](std::optional<ImageSizeOptions>) {}); + auto &units = *sheet.findChild<QComboBox *>(QStringLiteral("imageUnits")); + const int inches = units.findText(QStringLiteral("Inches")); + units.setCurrentIndex(inches); + emit units.activated(inches); + QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("imageWidth"))->text(), QString("1.062")); + QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("imageHeight"))->text(), QString("1.188")); +} + +QTEST_MAIN(ImageSizeSheetTests) +#include "ImageSizeSheetTests.moc" diff --git a/tests/ImageSizeTests.cpp b/tests/ImageSizeTests.cpp new file mode 100644 index 0000000..cf0a677 --- /dev/null +++ b/tests/ImageSizeTests.cpp @@ -0,0 +1,400 @@ +#include "AddressSpaceLimit.h" +#include "Document/EditorSession.h" +#include "Document/LayerMask.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include "ProjectFixtures.h" +#include <QBuffer> +#include <QImageReader> +#include <QTemporaryDir> +#include <QtTest> + +namespace { +// A 64x32 layer, red left, clear right, filling its document. +ProjectSnapshot imported() +{ + const QUuid id = QUuid::createUuid(); + const ProjectLayerRecord record{.id = id, .name = "fixture", .isVisible = true, .transform = {.origin = {0, 0}, .size = {64, 32}}, + .imageFile = uuidString(id) + ".png"}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = 64, .height = 32, .activeLayerID = id, .layers = {record}}, + .images = {{id, asset(halfRed(), "fixture")}}}; +} + +std::optional<ProjectError::Kind> resizeError(const ProjectSnapshot &snapshot, const ImageSizeOptions &options) +{ + return projectError([&] { ImageResizer::resize(snapshot, options); }); +} +} + +class ImageSizeTests : public QObject { + Q_OBJECT +private slots: + void resizePreservesLayerIdentityAndUndoRestoresSource(); + void resolutionOnlyRetainsPixelsAndSurvivesSaveAndExport(); + void rotatedHiddenLayerScalesInDocumentAxesAndInvalidSizeIsRejected(); + void limitsAreJudgedAtTheirEdges(); + void budgetsCountEveryLayer(); + void missingPixelsAreRefused(); + void masksScaleWithTheirLayers(); + void solidAndPlacedMasksKeepTheirPixels(); + void recordsKeepEveryField_data(); + void recordsKeepEveryField(); + void thumbnailsTruncate_data(); + void thumbnailsTruncate(); + void aSurfaceThatCannotBeAllocatedThrows(); +}; + +void ImageSizeTests::resizePreservesLayerIdentityAndUndoRestoresSource() +{ + const ProjectSnapshot input = imported(); + const QUuid id = input.manifest.layers[0].id; + QTest::ignoreMessage(QtInfoMsg, "resized 64 x 32 to 128 x 96"); + const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 300, .sampling = LayerSampling::nearest}); + QCOMPARE(result.manifest.width, qint64(128)); + QCOMPARE(result.manifest.height, qint64(96)); + QCOMPARE(result.manifest.resolution, std::optional(300.0)); + QCOMPARE(result.manifest.documentID, input.manifest.documentID); + QCOMPARE(result.manifest.activeLayerID, std::optional(id)); + const ProjectLayerRecord &layer = result.manifest.layers[0]; + QCOMPARE(layer.id, id); + QCOMPARE(layer.transform, (LayerTransform{.origin = {0, 0}, .size = {128, 96}, .sampling = LayerSampling::nearest})); + const ImportedImage &scaled = result.images.at(id); + QCOMPARE(scaled.name, QString("fixture")); + QCOMPARE(scaled.size(), QSize(128, 96)); + QCOMPARE(scaled.image().format(), QImage::Format_RGBA8888_Premultiplied); + // Nearest keeps the edge hard: red to 63, clear after. + QCOMPARE(scaled.image().pixelColor(0, 0), QColor(255, 0, 0)); + QCOMPARE(scaled.image().pixelColor(63, 95), QColor(255, 0, 0)); + QCOMPARE(scaled.image().pixelColor(64, 0).alpha(), 0); + QCOMPARE(scaled.image().pixelColor(127, 0).alpha(), 0); + // The source is untouched. + QCOMPARE(input.images.at(id).size(), QSize(64, 32)); + // In the session: one step; undo gives the source back. + EditorSession session; + session.installProject(input, "fixture.comp"); + const std::optional<CanvasDocument> original = session.document(); + session.applyImageSize(result); + QCOMPARE(session.document().value().size(), QSizeF(128, 96)); + QCOMPARE(session.document().value().resolution, 300.0); + QCOMPARE(session.activeLayerID(), std::optional(id)); + QCOMPARE(session.document().value().layers.front().asset.value().size(), QSize(128, 96)); + QCOMPARE(session.history.undoName(), QString("Image Size")); + session.undo(); + QCOMPARE(session.document(), original); + QVERIFY(session.document().value().layers.front().asset.value().identity() == input.images.at(id).identity()); + session.redo(); + QCOMPARE(session.document().value().resolution, 300.0); + // One side alone is a resize too. + QCOMPARE(ImageResizer::resize(input, {.width = 128, .height = 32, .resolution = 72}).images.at(id).size(), QSize(128, 32)); + QCOMPARE(ImageResizer::resize(input, {.width = 64, .height = 48, .resolution = 72}).images.at(id).size(), QSize(64, 48)); +} + +void ImageSizeTests::resolutionOnlyRetainsPixelsAndSurvivesSaveAndExport() +{ + ProjectSnapshot before = imported(); + before.manifest.layers[0].transform = {.origin = {3.5, -2}, .size = {40, 20}, .rotation = 30, .flipX = true, .sampling = LayerSampling::smooth}; + const QUuid id = before.manifest.layers[0].id; + before.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + before.masks.insert({id, LayerMask::assetFrom(gray(64, 32, 200))}); + const ProjectSnapshot resized = ImageResizer::resize(before, {.width = 64, .height = 32, .resolution = 300}); + QCOMPARE(resized.manifest.layers[0].transform, before.manifest.layers[0].transform); + QCOMPARE(resized.images.at(id).identity(), before.images.at(id).identity()); + // The mask and its record come through untouched too. + QCOMPARE(resized.masks.at(id).identity(), before.masks.at(id).identity()); + QCOMPARE(resized.manifest.layers[0].maskFile, before.manifest.layers[0].maskFile); + QCOMPARE(resized.manifest.resolution, std::optional(300.0)); + // In the session a new resolution alone is one step. + EditorSession session; + session.installProject(before, "fixture.comp"); + QCOMPARE(session.document().value().resolution, 72.0); + session.applyImageSize(resized); + QCOMPARE(session.document().value().resolution, 300.0); + QCOMPARE(session.document().value().size(), QSizeF(64, 32)); + QCOMPARE(session.history.undoCount(), 1); + session.undo(); + QCOMPARE(session.document().value().resolution, 72.0); + QTemporaryDir folder; + ProjectStore::save(resized, folder.filePath("Size.comp")); + const ProjectSnapshot loaded = ProjectStore::load(folder.filePath("Size.comp")); + QCOMPARE(loaded.manifest.resolution, std::optional(300.0)); + QByteArray png = ImageExporter::pngData(loaded); + QBuffer buffer(&png); + QImageReader reader(&buffer); + // 300 pixels an inch is 11,811 dots a metre. + QCOMPARE(reader.read().dotsPerMeterX(), 11'811); +} + +void ImageSizeTests::rotatedHiddenLayerScalesInDocumentAxesAndInvalidSizeIsRejected() +{ + ProjectSnapshot input = imported(); + ProjectLayerRecord &record = input.manifest.layers[0]; + record.isVisible = false; + record.transform = {.origin = {-16, 4}, .size = {64, 32}, .rotation = 90}; + const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72, .sampling = LayerSampling::nearest}); + const ProjectLayerRecord &output = result.manifest.layers[0]; + QVERIFY(!output.isVisible); + // Turned: 32x64 at (0, -12); Swift allows a pixel. + QCOMPARE(output.transform.rotation, 0.0); + QVERIFY(qAbs(output.transform.size.width() - 64) <= 1 && qAbs(output.transform.size.height() - 192) <= 1); + QVERIFY(qAbs(output.transform.origin.x()) <= 1 && qAbs(output.transform.origin.y() + 36) <= 1); + const QImage image = result.images.at(record.id).image(); + QCOMPARE(QSizeF(image.size()), output.transform.size); + // A quarter turn clockwise: the red half is on top. + QCOMPARE(image.pixelColor(32, 10), QColor(255, 0, 0)); + QCOMPARE(image.pixelColor(32, 90), QColor(255, 0, 0)); + QCOMPARE(image.pixelColor(32, 102).alpha(), 0); + // Off the right angles the box is exact: floor, ceil. + record.transform = {.origin = {10, 10}, .size = {40, 20}, .rotation = 30}; + const ProjectSnapshot leaning = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72}); + QCOMPARE(leaning.manifest.layers[0].transform, (LayerTransform{.origin = {15, 4}, .size = {90, 112}})); + QCOMPARE(resizeError(input, {.width = 30'000, .height = 30'000, .resolution = 72}), std::optional(ProjectError::Kind::tooLarge)); +} + +void ImageSizeTests::limitsAreJudgedAtTheirEdges() +{ + ProjectSnapshot input = imported(); + const auto tooLarge = std::optional(ProjectError::Kind::tooLarge); + QCOMPARE(resizeError(input, {.width = 0, .height = 10, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge); + for (const double wild : {0.99, 9600.01, std::nan(""), double(INFINITY)}) + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = wild}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = 1}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = 9600}), std::nullopt); + // An empty document scales to the limits for nothing. + input.manifest.layers.clear(); + input.images.clear(); + // No layer's own rule stands in for the document's here. + QCOMPARE(resizeError(input, {.width = 0, .height = 10, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'001, .resolution = 72}), tooLarge); + QCOMPARE(resizeError(input, {.width = 30'000, .height = 1, .resolution = 72}), std::nullopt); + QCOMPARE(resizeError(input, {.width = 1, .height = 30'000, .resolution = 72}), std::nullopt); + // The same size skips every other rule: only resolution changes. + ProjectSnapshot wide = imported(); + wide.manifest.width = 20'000; + wide.manifest.height = 20'000; + QCOMPARE(ImageResizer::resize(wide, {.width = 20'000, .height = 20'000, .resolution = 144}).manifest.resolution, std::optional(144.0)); +} + +void ImageSizeTests::budgetsCountEveryLayer() +{ + // Scaled a hundredfold, the small layer takes a megapixel. + ProjectSnapshot input = imported(); + input.manifest.width = 100; + input.manifest.height = 100; + ProjectLayerRecord small = input.manifest.layers[0]; + small.transform = {.origin = {0, 0}, .size = {10, 10}}; + ProjectLayerRecord whole = small; + whole.id = QUuid::createUuid(); + whole.imageFile = uuidString(whole.id) + ".png"; + whole.transform = {.origin = {0, 0}, .size = {100, 100}}; + input.images.insert({whole.id, asset(halfRed(), "whole")}); + input.manifest.layers = {small, whole}; + const auto tooLarge = std::optional(ProjectError::Kind::tooLarge); + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge); + // A megapixel and 99 more fill the budget exactly. + input.manifest.layers[1].transform.size = {99, 100}; + const ProjectSnapshot full = ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest}); + QCOMPARE(full.images.at(whole.id).size(), QSize(9'900, 10'000)); + input.manifest.layers[1].transform.size = {99.01, 100}; + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge); + // Masks have their own budget, counted the same way. + input.manifest.layers = {small, whole}; + for (ProjectLayerRecord &layer : input.manifest.layers) { + layer.imageFile = std::nullopt; + layer.maskFile = uuidString(layer.id) + ".mask.png"; + input.masks.insert({layer.id, LayerMask::assetFrom(gray(4, 4, 200))}); + } + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge); + input.manifest.layers[1].transform.size = {99, 100}; + QCOMPARE(ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest}) + .masks.at(whole.id).size(), QSize(9'900, 10'000)); + input.manifest.layers[1].transform.size = {99.01, 100}; + QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge); + // A side past 30,000 is refused though the pixels fit. + ProjectSnapshot strip = imported(); + strip.manifest.layers[0].transform = {.origin = {0, 0}, .size = {64, 1}}; + strip.manifest.layers.push_back(strip.manifest.layers[0]); + QCOMPARE(resizeError(strip, {.width = 30'000, .height = 32, .resolution = 72}), std::nullopt); + strip.manifest.layers[0].transform.size = {64.01, 1}; + QCOMPARE(resizeError(strip, {.width = 30'000, .height = 32, .resolution = 72}), tooLarge); + // 300 times 100.003 is 30,000.9: one pixel past, either way. + ProjectSnapshot edge = imported(); + edge.manifest.width = 100; + edge.manifest.height = 100; + edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {100.003, 1}}; + QCOMPARE(resizeError(edge, {.width = 30'000, .height = 100, .resolution = 72}), tooLarge); + edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {1, 100.003}}; + QCOMPARE(resizeError(edge, {.width = 100, .height = 30'000, .resolution = 72}), tooLarge); + edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {1, 100}}; + QCOMPARE(resizeError(edge, {.width = 100, .height = 30'000, .resolution = 72}), std::nullopt); +} + +void ImageSizeTests::missingPixelsAreRefused() +{ + ProjectSnapshot input = imported(); + input.images.clear(); + QCOMPARE(resizeError(input, {.width = 128, .height = 64, .resolution = 72}), std::optional(ProjectError::Kind::missingImage)); + input = imported(); + input.manifest.layers[0].maskFile = "mask.png"; + QCOMPARE(resizeError(input, {.width = 128, .height = 64, .resolution = 72}), std::optional(ProjectError::Kind::missingImage)); + // A layer scaled out of reach fails its own rule. + input = imported(); + input.manifest.layers[0].transform.origin = {900'000, 0}; + QCOMPARE(resizeError(input, {.width = 128, .height = 32, .resolution = 72}), std::optional(ProjectError::Kind::tooLarge)); +} + +void ImageSizeTests::masksScaleWithTheirLayers() +{ + ProjectSnapshot input = imported(); + const QUuid id = input.manifest.layers[0].id; + input.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + // White on the left half, 100 on the right. + QImage mask = gray(64, 32, 100); + for (int y = 0; y < 32; ++y) + std::fill_n(mask.scanLine(y), 32, uchar(255)); + input.masks.insert({id, LayerMask::assetFrom(mask)}); + const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72, .sampling = LayerSampling::nearest}); + const QImage scaled = result.masks.at(id).image(); + QCOMPARE(scaled.format(), QImage::Format_Grayscale8); + QCOMPARE(scaled.size(), QSize(128, 96)); + QCOMPARE(int(scaled.constScanLine(0)[0]), 255); + QCOMPARE(int(scaled.constScanLine(95)[63]), 255); + QCOMPARE(int(scaled.constScanLine(0)[64]), 100); + QCOMPARE(int(scaled.constScanLine(95)[127]), 100); + QCOMPARE(result.manifest.layers[0].maskFile, input.manifest.layers[0].maskFile); + // Turned, the box's corners hold no mask: coverage zero. + input.manifest.layers[0].transform.rotation = 45; + const QImage turned = ImageResizer::resize(input, {.width = 128, .height = 64, .resolution = 72}).masks.at(id).image(); + QCOMPARE(int(turned.constScanLine(0)[0]), 0); + QCOMPARE(int(turned.constScanLine(turned.height() / 2)[turned.width() / 2 - 8]), 255); +} + +void ImageSizeTests::solidAndPlacedMasksKeepTheirPixels() +{ + ProjectSnapshot input = imported(); + const QUuid id = input.manifest.layers[0].id; + input.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + input.masks.insert({id, LayerMask::solid(false).asset}); + const ProjectSnapshot solid = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72}); + QCOMPARE(solid.masks.at(id).identity(), input.masks.at(id).identity()); + // A mask placed apart keeps its pixels; its placement scales. + input.masks.clear(); + input.masks.insert({id, LayerMask::assetFrom(gray(30, 15, 200))}); + input.manifest.layers[0].maskPlacement = LayerTransform{.origin = {10, 4}, .size = {30, 15}, .flipX = true}; + const ProjectSnapshot placed = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72}); + QCOMPARE(placed.masks.at(id).identity(), input.masks.at(id).identity()); + const LayerTransform placement = placed.manifest.layers[0].maskPlacement.value(); + QCOMPARE(placement.origin, QPointF(20, 12)); + QCOMPARE(placement.size, QSizeF(60, 45)); + QVERIFY(placement.flipX); +} + +void ImageSizeTests::recordsKeepEveryField_data() +{ + QTest::addColumn<bool>("enabled"); + QTest::addColumn<bool>("linked"); + QTest::newRow("a disabled, linked mask") << false << true; + QTest::newRow("an enabled, unlinked mask") << true << false; +} + +void ImageSizeTests::recordsKeepEveryField() +{ + QFETCH(bool, enabled); + QFETCH(bool, linked); + ProjectSnapshot input = imported(); + const QUuid folder = QUuid::createUuid(), source = QUuid::createUuid(); + ProjectLayerRecord &layer = input.manifest.layers[0]; + layer.parentID = folder; + layer.opacity = 0.25; + layer.blendMode = LayerBlendMode::overlay; + layer.maskFile = "mask.png"; + layer.maskEnabled = enabled; + layer.maskSourceID = source; + layer.maskLinked = linked; + input.masks.insert({layer.id, LayerMask::solid(true).asset}); + const std::optional<QString> imageFile = layer.imageFile; + // A folder has no pixels; its box still scales. + input.manifest.layers.push_back({.id = folder, .name = "Folder", .isVisible = true, .transform = {.origin = {8, 4}, .size = {16, 8}}, + .imageFile = std::nullopt, .isGroup = true}); + const ProjectSnapshot output = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72}); + const ProjectLayerRecord &kept = output.manifest.layers[0]; + QCOMPARE(kept.name, QString("fixture")); + QCOMPARE(kept.imageFile, imageFile); + QCOMPARE(kept.parentID, std::optional(folder)); + QCOMPARE(kept.isGroup, std::nullopt); + QCOMPARE(kept.opacity, std::optional(0.25)); + QCOMPARE(kept.blendMode, std::optional(LayerBlendMode::overlay)); + QCOMPARE(kept.maskFile, std::optional(QString("mask.png"))); + QCOMPARE(kept.maskEnabled, std::optional(enabled)); + QCOMPARE(kept.maskSourceID, std::optional(source)); + QCOMPARE(kept.maskLinked, std::optional(linked)); + QCOMPARE(kept.maskPlacement, std::nullopt); + const ProjectLayerRecord &group = output.manifest.layers[1]; + QCOMPARE(group.isGroup, std::optional(true)); + QCOMPARE(group.transform, (LayerTransform{.origin = {16, 12}, .size = {32, 24}})); + QCOMPARE(int(output.images.size()), 1); +} + +void ImageSizeTests::thumbnailsTruncate_data() +{ + QTest::addColumn<QSize>("size"); + QTest::addColumn<QSize>("thumbnail"); + QTest::newRow("small layers keep their size") << QSize(96, 40) << QSize(96, 40); + QTest::newRow("smaller still, never enlarged") << QSize(48, 20) << QSize(48, 20); + QTest::newRow("28.8 loses its fraction") << QSize(100, 30) << QSize(96, 28); + QTest::newRow("tall") << QSize(30, 100) << QSize(28, 96); + QTest::newRow("a strip stays one pixel high") << QSize(2400, 1) << QSize(96, 1); +} + +void ImageSizeTests::thumbnailsTruncate() +{ + QFETCH(QSize, size); + QFETCH(QSize, thumbnail); + const ProjectSnapshot input = imported(); + const ProjectSnapshot output = ImageResizer::resize(input, {.width = size.width(), .height = size.height(), .resolution = 72}); + QCOMPARE(output.images.begin()->second.thumbnail.size(), thumbnail); + // Inside the red half; halved edges fade, as on macOS. + QCOMPARE(output.images.begin()->second.thumbnail.pixelColor(thumbnail.width() / 4, thumbnail.height() / 2), QColor(255, 0, 0)); +} + +void ImageSizeTests::aSurfaceThatCannotBeAllocatedThrows() +{ + const ProjectSnapshot input = imported(); + std::optional<ExportError::Kind> kind; + { + // 9000 x 9000 asks for 324 MB. + const AddressSpaceLimit limit(200ll * 1024 * 1024); + try { + ImageResizer::resize(input, {.width = 9000, .height = 9000, .resolution = 72}); + } catch (const ExportError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(ExportError::Kind::render)); + // A mask's surface fails the same way. + ProjectSnapshot masked = imported(); + masked.manifest.layers[0].imageFile = std::nullopt; + masked.manifest.layers[0].maskFile = "mask.png"; + masked.masks.insert({masked.manifest.layers[0].id, LayerMask::assetFrom(gray(64, 32, 200))}); + masked.images.clear(); + kind.reset(); + { + // 9000 x 9000 gray asks for 81 MB. + const AddressSpaceLimit limit(40ll * 1024 * 1024); + try { + ImageResizer::resize(masked, {.width = 9000, .height = 9000, .resolution = 72}); + } catch (const ExportError &error) { + kind = error.kind; + } + } + QCOMPARE(kind, std::optional(ExportError::Kind::render)); +} + +QTEST_MAIN(ImageSizeTests) +#include "ImageSizeTests.moc" diff --git a/tests/InlineTextDrawFixtures.h b/tests/InlineTextDrawFixtures.h new file mode 100644 index 0000000..58f42ae --- /dev/null +++ b/tests/InlineTextDrawFixtures.h @@ -0,0 +1,60 @@ +#pragma once +#include "SelectionCanvasFixtures.h" +#include <QInputMethodEvent> + +// Shared by the inline editor's drawing tests: red text, pixels. +namespace { +struct DrawnText : Canvas { + DrawnText() + { + QObject::connect(&session, &EditorSession::changed, canvas, [this] { canvas->synchronizeDisplay(); }); + session.addBlankLayer(); + session.selectTool(NavigationTool::type); + session.setPaletteColor(PaletteColor{1, 0, 0}, false); + } + InlineTextEditor &editor() const + { + if (!canvas->inlineTextEditor()) + throw std::runtime_error("no editor"); + return *canvas->inlineTextEditor(); + } + // New red text, applied: its layer is active. + QUuid text(QPointF at, const QString &content) + { + session.beginText(at, true); + QTest::keyClicks(canvas, content); + if (!session.finishText()) + throw std::runtime_error("the text was refused"); + return session.activeLayerID().value(); + } + QImage grab() const { return canvas->grab().toImage().convertToFormat(QImage::Format_RGB32); } + // A layer point on screen, through the editor's own box. + QPoint onScreen(QPointF logical) const + { + const QPointF point = editor().boxTransform().map(logical); + return QPoint(int(std::floor(point.x())), int(std::floor(point.y()))); + } +}; + +inline bool reddish(QColor colour) +{ + return colour.red() > 128 && colour.green() < 90 && colour.blue() < 90; +} + +inline bool greenish(QColor colour) +{ + return colour.green() > 128 && colour.red() < 90 && colour.blue() < 90; +} + +inline QRect where(const QImage &image, bool (*test)(QColor)) +{ + QRect found; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (test(image.pixelColor(x, y))) + found |= QRect(x, y, 1, 1); + } + } + return found; +} +} diff --git a/tests/InlineTextDrawTests.cpp b/tests/InlineTextDrawTests.cpp new file mode 100644 index 0000000..833cb2d --- /dev/null +++ b/tests/InlineTextDrawTests.cpp @@ -0,0 +1,400 @@ +#include "MenuFixtures.h" +#include "InlineTextDrawFixtures.h" +#include <QClipboard> +#include <QElapsedTimer> +#include <QAction> +#include <QLineEdit> +#include <QPushButton> +#include <QStyleHints> + +// The editor on screen: its text, frame, caret, selection, focus. +class InlineTextDrawTests : public QObject { + Q_OBJECT +private slots: + void theEditorShowsTheDraftInsteadOfItsLayer(); + void theFrameAndHandlesSitOnTheBox(); + void aTurnedFlippedLayerIsEditedInPlace(); + void theCaretShowsWhileTheCanvasHasFocus(); + void aSelectionShowsBehindItsText(); + void textPastTheBoxShowsAPlus(); + void aFreshDraftTakesTheKeysButSparesAField(); + void theDraftTakesItsKeysAheadOfShortcuts(); + void theEditMenuReachesOpenText(); + void editsRepaintTheEditor(); + void openingTextRepaintsItsWholeLayer(); +}; + +void InlineTextDrawTests::theEditorShowsTheDraftInsteadOfItsLayer() +{ + DrawnText shown; + shown.text(QPointF(20, 30), QStringLiteral("Hide")); + QVERIFY(where(shown.grab(), reddish).isValid()); + shown.session.editActiveText(); + shown.session.changeTextStyle([](LayerTextStyle &style) { + style.content = QStringLiteral("Hi"); + style.red = 0; + style.green = 1; + }); + // The layer's red is gone; the draft's green shows. + const QImage editing = shown.grab(); + QVERIFY(!where(editing, reddish).isValid()); + QVERIFY(where(editing, greenish).isValid()); + shown.session.cancelText(); + QVERIFY(where(shown.grab(), reddish).isValid()); +} + +void InlineTextDrawTests::theFrameAndHandlesSitOnTheBox() +{ + DrawnText shown; + shown.session.beginText(QRectF(100, 80, 200, 120)); + const QImage image = shown.grab(); + const QColor accent = shown.canvas->palette().color(QPalette::Highlight); + const QSizeF size = shown.editor().logicalSize(); + for (const QPointF &unit : LayerTransform::handles) + QCOMPARE(image.pixelColor(shown.onScreen(QPointF(unit.x() * size.width(), unit.y() * size.height()))), QColor(Qt::white)); + // The frame: an accent line, a sixth of a handle. + const QColor frame = image.pixelColor(shown.onScreen(QPointF(size.width() / 4, 0.25))); + QVERIFY2(std::abs(frame.red() - accent.red()) < 40 && std::abs(frame.blue() - accent.blue()) < 40, qPrintable(frame.name())); + QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width() / 4, 4))) != frame); + // No plus without overflow. + QVERIFY(image.pixelColor(shown.onScreen(QPointF(size.width(), size.height()))) == QColor(Qt::white)); + // Text draws over the frame, a subview over its view. + shown.session.changeTextStyle([](LayerTextStyle &style) { + style.content = QStringLiteral("H"); + style.leading = 30; + }); + QVERIFY(reddish(shown.grab().pixelColor(shown.onScreen(QPointF(24, 0.25))))); +} + +void InlineTextDrawTests::aTurnedFlippedLayerIsEditedInPlace() +{ + DrawnText shown; + const QUuid id = shown.text(QPointF(150, 100), QStringLiteral("Turn")); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { + LayerTransform &transform = record(snapshot, id).transform; + transform.rotation = 90; + transform.flipX = true; + }); + const QRect drawn = where(shown.grab(), reddish); + // A field keeps the keys, so no caret draws. + auto *field = new QLineEdit(&shown.window); + field->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + shown.session.editActiveText(); + QTest::qWait(20); + QVERIFY(!shown.editor().caretShown()); + const QRect edited = where(shown.grab(), reddish); + QVERIFY2(std::abs(edited.left() - drawn.left()) <= 2 && std::abs(edited.top() - drawn.top()) <= 2 && std::abs(edited.right() - drawn.right()) <= 2 + && std::abs(edited.bottom() - drawn.bottom()) <= 2, + qPrintable(QString("%1,%2 %3x%4 against %5,%6 %7x%8") + .arg(edited.x()).arg(edited.y()).arg(edited.width()).arg(edited.height()) + .arg(drawn.x()).arg(drawn.y()).arg(drawn.width()).arg(drawn.height()))); + // Turned, the text runs down the screen. + QVERIFY(edited.height() > edited.width()); +} + +void InlineTextDrawTests::theCaretShowsWhileTheCanvasHasFocus() +{ + DrawnText shown; + auto *field = new QLineEdit(&shown.window); + field->setGeometry(0, 0, 10, 10); + field->show(); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("ab")); + const QRectF caret = shown.editor().textTransform().mapRect( + QRectF(QPointF(LayerTextStyle::padding, LayerTextStyle::padding), QSizeF(1, shown.session.textDraft().value().style.lineHeight())) + .translated(QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("ab")), 0)); + const QPoint middle = caret.center().toPoint(); + QVERIFY(shown.editor().caretShown()); + QVERIFY2(reddish(shown.grab().pixelColor(middle)), qPrintable(shown.grab().pixelColor(middle).name())); + // Input methods see the caret where it draws. + const QRectF asked = static_cast<const QWidget *>(shown.canvas)->inputMethodQuery(Qt::ImCursorRectangle).toRectF(); + QVERIFY(asked.adjusted(-1, -1, 1, 1).contains(middle)); + // It blinks at half the system's flash time, repainting. + QTest::keyClick(shown.canvas, Qt::Key_End); + QElapsedTimer clock; + clock.start(); + // The key's own repaint lands first; then only the blink's. + QTest::qWait(50); + PaintSpy spy(*shown.canvas); + QTRY_VERIFY(!shown.editor().caretShown()); + QVERIFY(clock.elapsed() >= QGuiApplication::styleHints()->cursorFlashTime() / 2 - 50); + QTRY_VERIFY(spy.painted.contains(middle)); + QTRY_VERIFY(shown.editor().caretShown()); + // Without the keys it stops; with them, shows at once. + const QTimer *blink = shown.canvas->findChild<QTimer *>(QStringLiteral("caretBlink")); + QVERIFY(blink && blink->isActive()); + field->setFocus(); + QVERIFY(!shown.editor().caretShown() && !blink->isActive()); + QVERIFY(!reddish(shown.grab().pixelColor(middle))); + shown.canvas->setFocus(); + QVERIFY(shown.editor().caretShown() && blink->isActive()); + // A move or an edit shows the caret at once. + QTRY_VERIFY(!shown.editor().caretShown()); + QTest::keyClick(shown.canvas, Qt::Key_Left); + QVERIFY(shown.editor().caretShown()); + QTRY_VERIFY(!shown.editor().caretShown()); + QTest::keyClicks(shown.canvas, QStringLiteral("c")); + QVERIFY(shown.editor().caretShown()); + // The blink ends with its editor. + shown.session.cancelText(); + QVERIFY(!shown.canvas->findChild<QTimer *>(QStringLiteral("caretBlink"))); +} + +void InlineTextDrawTests::aSelectionShowsBehindItsText() +{ + DrawnText shown; + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("iiii")); + QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); + // Above the letters, the line's height is highlighted. + const QPoint above = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + 3)).toPoint(); + QCOMPARE(shown.grab().pixelColor(above), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); + const double line = shown.session.textDraft().value().style.lineHeight(); + const QPoint below = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 3, LayerTextStyle::padding + line - 3)).toPoint(); + QCOMPARE(shown.grab().pixelColor(below), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); + // To the last selected letter's edge. + const double selected = QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii")); + const QPoint last = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + selected - 3, LayerTextStyle::padding + 3)).toPoint(); + QCOMPARE(shown.grab().pixelColor(last), shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); + // No caret while something is selected. + const QPointF mapped = shown.editor().textTransform().map( + QPointF(LayerTextStyle::padding + QFontMetricsF(TextLayout::font(shown.session.textDraft().value().style)).horizontalAdvance(QStringLiteral("iiii")) + 0.25, + LayerTextStyle::padding + 40)); + const QPoint end(int(std::floor(mapped.x())), int(std::floor(mapped.y()))); + QVERIFY(!reddish(shown.grab().pixelColor(end))); + QTest::keyClick(shown.canvas, Qt::Key_End); + QVERIFY(reddish(shown.grab().pixelColor(end))); + QVERIFY(shown.grab().pixelColor(above) != shown.canvas->palette().color(QPalette::Active, QPalette::Highlight)); + // Unfocused, a selection shows in the inactive colour. + QPalette palette = shown.canvas->palette(); + palette.setColor(QPalette::Inactive, QPalette::Highlight, QColor(40, 200, 40)); + shown.canvas->setPalette(palette); + QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); + auto *field = new QLineEdit(&shown.window); + field->show(); + field->setFocus(); + QTRY_VERIFY(!shown.canvas->hasFocus()); + QCOMPARE(shown.grab().pixelColor(above), QColor(40, 200, 40)); +} + +void InlineTextDrawTests::textPastTheBoxShowsAPlus() +{ + DrawnText shown; + shown.session.beginText(QRectF(100, 80, 120, 60)); + const QPointF corner(120, 60); + QCOMPARE(shown.grab().pixelColor(shown.onScreen(corner)), QColor(Qt::white)); + QTest::keyClicks(shown.canvas, QStringLiteral("more words than the box holds")); + QVERIFY(shown.grab().pixelColor(shown.onScreen(corner)).lightness() < 100); +} + +void InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField() +{ + DrawnText shown; + auto *field = new QLineEdit(&shown.window); + auto *button = new QPushButton(&shown.window); + field->setGeometry(0, 0, 10, 10); + button->setGeometry(20, 0, 10, 10); + field->show(); + button->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + QTest::qWait(50); + QVERIFY(field->hasFocus()); + shown.session.cancelText(); + button->setFocus(); + QTRY_VERIFY(button->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + QTRY_VERIFY(shown.canvas->hasFocus()); + // A focus request leaves open text's keys alone. + button->setFocus(); + QTRY_VERIFY(button->hasFocus()); + shown.canvas->consumeFocusRequest(7); + QTest::qWait(50); + QVERIFY(button->hasFocus()); + shown.session.cancelText(); + shown.canvas->consumeFocusRequest(8); + QTRY_VERIFY(shown.canvas->hasFocus()); +} + +void InlineTextDrawTests::theDraftTakesItsKeysAheadOfShortcuts() +{ + DrawnText shown; + auto *selectAll = new QAction(&shown.window); + selectAll->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_A)); + shown.window.addAction(selectAll); + int triggered = 0; + QObject::connect(selectAll, &QAction::triggered, [&] { ++triggered; }); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); + QCOMPARE(triggered, 1); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("all")); + QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier); + QCOMPARE(triggered, 1); + QCOMPARE(shown.editor().anchor(), 0); + QCOMPARE(shown.editor().caretPosition(), 3); + // Shift-Tab keeps the keys where the text is. + auto *next = new QPushButton(&shown.window); + next->show(); + QTest::keyClick(shown.canvas, Qt::Key_Backtab, Qt::ShiftModifier); + QVERIFY(shown.canvas->hasFocus()); + QCOMPARE(shown.session.textDraft().value().style.content, QString("all")); + // So do its other keys, and Backspace over the fills. + int stolen = 0; + const auto steal = [&](const QList<QKeySequence> &keys) { + auto *action = new QAction(&shown.window); + action->setShortcuts(keys); + shown.window.addAction(action); + QObject::connect(action, &QAction::triggered, [&] { ++stolen; }); + }; + for (const QKeySequence::StandardKey key : {QKeySequence::Undo, QKeySequence::Redo, QKeySequence::Copy, QKeySequence::Cut, QKeySequence::Paste}) + steal(QKeySequence::keyBindings(key)); + steal({QKeySequence(Qt::ALT | Qt::Key_Backspace), QKeySequence(Qt::CTRL | Qt::Key_Backspace), QKeySequence(Qt::SHIFT | Qt::Key_Backspace)}); + QTest::keyClick(shown.canvas, Qt::Key_End); + QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier); + QTest::keyClick(shown.canvas, Qt::Key_C, Qt::ControlModifier); + QTest::keyClick(shown.canvas, Qt::Key_X, Qt::ControlModifier); + QTest::keyClick(shown.canvas, Qt::Key_V, Qt::ControlModifier); + QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier); + QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.session.textDraft().value().style.content, QString("all")); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::ShiftModifier); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::AltModifier); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::ControlModifier); + QCOMPARE(shown.session.textDraft().value().style.content, QString()); + QCOMPARE(stolen, 0); +} + +void InlineTextDrawTests::theEditMenuReachesOpenText() +{ + Bar bar; + bar.window.show(); + QVERIFY(QTest::qWaitForWindowActive(&bar.window)); + EditorSession &session = bar.session(); + session.createDocument(400, 300, true); + session.selectTool(NavigationTool::type); + session.beginText(QPointF(20, 30), true); + auto *canvas = bar.window.findChild<CanvasView *>(); + QTRY_VERIFY(canvas->hasFocus()); + QTest::keyClicks(canvas, QStringLiteral("menu")); + const auto content = [&] { return session.textDraft().value().style.content; }; + // Swift's text branch: bare entries, enabled, the text view's. + QCOMPARE(bar.action("undo").text(), QString("Undo")); + QVERIFY(bar.action("undo").isEnabled() && bar.action("redo").isEnabled()); + bar.action("undo").trigger(); + QCOMPARE(content(), QString()); + bar.action("redo").trigger(); + QCOMPARE(content(), QString("menu")); + bar.action("selectAll").trigger(); + QCOMPARE(canvas->inlineTextEditor()->anchor(), 0); + QVERIFY(!session.selection()); + bar.action("copy").trigger(); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("menu")); + bar.action("cut").trigger(); + QCOMPARE(content(), QString()); + bar.action("paste").trigger(); + bar.action("paste").trigger(); + QCOMPARE(content(), QString("menumenu")); + // Closed, the entries carry the step's name again. + QVERIFY(session.finishText()); + QCOMPARE(bar.action("undo").text(), QString("Undo New Text Layer")); +} + +void InlineTextDrawTests::editsRepaintTheEditor() +{ + DrawnText shown; + // A field keeps the keys: no blink repaints the edits. + auto *field = new QLineEdit(&shown.window); + field->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + QTest::qWait(50); + PaintSpy spy(*shown.canvas); + QTest::keyClicks(shown.canvas, QStringLiteral("Wide words")); + QTRY_VERIFY(spy.painted.contains(shown.editor().drawnRect().toAlignedRect() & shown.canvas->rect())); + // Closing new text repaints where it stood. + const QRect stood = shown.editor().drawnRect().toAlignedRect() & shown.canvas->rect(); + spy.painted = QRect(); + shown.session.cancelText(); + QTRY_VERIFY(spy.painted.contains(stood)); + // A paste grows the box past its old margins. + shown.session.beginText(QPointF(20, 30), true); + QTest::qWait(50); + QVERIFY(!shown.canvas->findChild<QTimer *>(QStringLiteral("caretBlink"))->isActive()); + spy.painted = QRect(); + QGuiApplication::clipboard()->setText(QStringLiteral("iiiiiiiiiiiiiiii")); + QTest::keyClick(shown.canvas, Qt::Key_V, Qt::ControlModifier); + QTRY_VERIFY(spy.painted.contains(shown.editor().drawnRect().toAlignedRect() & shown.canvas->rect())); + shown.session.cancelText(); + // A layer whose text shrank shows whole again when left. + const QUuid id = shown.text(QPointF(20, 30), QStringLiteral("Hi you all")); + const QPoint right = layerWith(shown.session, id).transform.point(QPointF(0.95, 0.5)).toPoint(); + const auto shrink = [&] { + shown.session.editActiveText(); + QTest::keyClick(shown.canvas, Qt::Key_End); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::AltModifier); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::AltModifier); + QVERIFY(!shown.editor().drawnRect().contains(right)); + QTest::qWait(50); + spy.painted = QRect(); + }; + shrink(); + shown.session.cancelText(); + QTRY_VERIFY(spy.painted.contains(right)); + // So does one whose draft another replaces. + shrink(); + TextDraft other = shown.session.textDraft().value(); + other.id = QUuid::createUuid(); + other.layerID = std::nullopt; + other.transform = std::nullopt; + other.origin = QPointF(20, 200); + shown.session.setTextDraft(other); + QTRY_VERIFY(spy.painted.contains(right)); + // The caret's line after a final newline falls far below. + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 20), true); + shown.session.changeTextStyle([](LayerTextStyle &style) { + style.fontSize = 24; + style.leading = 120; + }); + QTest::keyClicks(shown.canvas, QStringLiteral("A")); + QTest::qWait(50); + spy.painted = QRect(); + QTest::keyClick(shown.canvas, Qt::Key_Return); + const QPoint below = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding, LayerTextStyle::padding + 180)).toPoint(); + QVERIFY(below.y() > shown.editor().textTransform().map(QPointF(0, shown.editor().logicalSize().height())).y() + 40); + QTRY_VERIFY(spy.painted.contains(below)); +} + +void InlineTextDrawTests::openingTextRepaintsItsWholeLayer() +{ + DrawnText shown; + shown.session.beginText(QPointF(20, 20), true); + shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 24; }); + QTest::keyClicks(shown.canvas, QStringLiteral("Tall")); + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + const QSize pixels = layerWith(shown.session, id).asset.value().size(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {20, 20}, .size = QSizeF(pixels.width(), pixels.height() * 5)}; + }); + // Point text shows at the width's scale; the rest goes. + const QPoint low = layerWith(shown.session, id).transform.point(QPointF(0.5, 0.9)).toPoint(); + QTest::qWait(50); + PaintSpy spy(*shown.canvas); + shown.session.editActiveText(); + QVERIFY(!shown.editor().drawnRect().contains(low)); + QTRY_VERIFY(spy.painted.contains(low)); +} + +QTEST_MAIN(InlineTextDrawTests) +#include "InlineTextDrawTests.moc" diff --git a/tests/InlineTextEditorTests.cpp b/tests/InlineTextEditorTests.cpp new file mode 100644 index 0000000..59ccdcb --- /dev/null +++ b/tests/InlineTextEditorTests.cpp @@ -0,0 +1,461 @@ +#include "InlineTextFixtures.h" + +// Typing on the canvas: keys, moves, undo, deletions, spacing. +class InlineTextEditorTests : public QObject { + Q_OBJECT +private slots: + void aDraftOpensTheEditorAndItsTextGrows(); + void typingIsOneUndoStep(); + void anotherDraftStartsAfresh(); + void arrowsHomeAndEndMoveAndShiftSelects(); + void upAndDownKeepTheirColumn(); + void deletionsReachCharactersWordsAndLines(); + void aWrappedParagraphMovesByItsLines(); + void aCaretPastTheBoxHasNoLine(); + void aFinalNewlineHasALineForTheCaret(); + void arrowsFollowARightToLeftParagraph(); + void returnTabEscapeAndFinishing(); + void altWithTheArrowsSetsSpacing(); +}; + +void InlineTextEditorTests::aDraftOpensTheEditorAndItsTextGrows() +{ + TextCanvas shown; + InlineTextEditor &editor = shown.editor(); + QCOMPARE(editor.draftID(), shown.session.textDraft().value().id); + QCOMPARE(editor.logicalSize(), EditorSession::textBoxSize(shown.session.textDraft().value().style)); + QCOMPARE(editor.shownTransform(), (LayerTransform{.origin = {20, 30}, .size = editor.logicalSize()})); + const double empty = editor.logicalSize().width(); + shown.type(QStringLiteral("Grow")); + QCOMPARE(shown.content(), QString("Grow")); + QCOMPARE(editor.logicalSize(), EditorSession::textBoxSize(shown.session.textDraft().value().style)); + QVERIFY(editor.logicalSize().width() > empty); + // Six view points a handle at any zoom. + QCOMPARE(editor.handleSize(), 6.0); + shown.session.zoom(2); + QCOMPARE(editor.handleSize(), 3.0); + shown.session.zoom(4); + QCOMPARE(editor.handleSize(), 2.0); + shown.session.zoom(0.005); + QCOMPARE(editor.handleSize(), 600.0); + shown.session.zoom(1); + // Content set elsewhere pulls the caret back inside. + TextDraft shorter = shown.session.textDraft().value(); + shorter.style.content = QStringLiteral("Gr"); + shown.session.setTextDraft(shorter); + QCOMPARE(editor.caretPosition(), 2); + QCOMPARE(editor.anchor(), 2); + // Point text on a scaled layer grows at that scale. + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + const QSize pixels = layerWith(shown.session, id).asset.value().size(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {50, 40}, .size = QSizeF(pixels) * 2, .rotation = 30}; + }); + shown.session.editActiveText(); + InlineTextEditor &scaled = shown.editor(); + const QPointF corner = scaled.shownTransform().point(QPointF(0, 0)); + shown.key(Qt::Key_End); + shown.type(QStringLiteral(" more")); + QCOMPARE(scaled.shownTransform().size, scaled.logicalSize() * 2); + QVERIFY((scaled.shownTransform().point(QPointF(0, 0)) - corner).manhattanLength() < 1e-9); + QCOMPARE(scaled.shownTransform().rotation, 30.0); + // Handles stay six points across a layer twice the size. + shown.session.zoom(1); + QCOMPARE(scaled.handleSize(), 3.0); + // A box keeps its size however much is typed. + shown.session.cancelText(); + shown.session.beginText(QRectF(10, 10, 200, 100)); + shown.type(QStringLiteral("Boxed words that go on")); + QCOMPARE(shown.editor().logicalSize(), QSizeF(200, 100)); + // A box shows the transform its draft holds, never grown. + QVERIFY(shown.session.finishText()); + shown.session.editActiveText(); + TextDraft boxed = shown.session.textDraft().value(); + boxed.style.boxSize = QSizeF(300, 100); + boxed.transform = LayerTransform{.origin = {10, 10}, .size = {300, 100}}; + shown.session.setTextDraft(boxed); + QCOMPARE(shown.editor().shownTransform(), boxed.transform.value()); +} + +void InlineTextEditorTests::typingIsOneUndoStep() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi you")); + QCOMPARE(shown.editor().caretPosition(), 6); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("Hi you")); + QCOMPARE(shown.editor().caretPosition(), 6); + // A move ends the step; deleting starts another. + shown.key(Qt::Key_Left); + shown.type(QStringLiteral("!")); + shown.key(Qt::Key_Backspace); + shown.key(Qt::Key_Backspace); + QCOMPARE(shown.content(), QString("Hi yu")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("Hi yo!u")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("Hi you")); + // Typing drops the redo; a move ends the step. + shown.key(Qt::Key_End); + shown.type(QStringLiteral("r")); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("Hi your")); + shown.key(Qt::Key_Left); + shown.type(QStringLiteral("s")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("Hi your")); + // A fresh draft starts with nothing to undo or redo. + shown.key(Qt::Key_Z, Qt::ControlModifier); + shown.session.cancelText(); + shown.session.beginText(QPointF(100, 100), true); + shown.key(Qt::Key_Z, Qt::ControlModifier); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString()); + QVERIFY(shown.session.textDraft()); + shown.type(QStringLiteral("x")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); +} + +void InlineTextEditorTests::anotherDraftStartsAfresh() +{ + TextCanvas shown; + // A draft set over this drops its steps, as Swift's. + shown.type(QStringLiteral("ab")); + TextDraft other = shown.session.textDraft().value(); + other.id = QUuid::createUuid(); + other.style.content = QStringLiteral("new"); + shown.session.setTextDraft(other); + QCOMPARE(shown.editor().draftID(), other.id); + shown.type(QStringLiteral("s")); + QCOMPARE(shown.content(), QString("nesw")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("new")); + other.id = QUuid::createUuid(); + shown.session.setTextDraft(other); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("new")); + // A composition in progress ends with its draft. + shown.compose(QStringLiteral("ni")); + other.id = QUuid::createUuid(); + other.style.content = QStringLiteral("NEW"); + shown.session.setTextDraft(other); + QVERIFY(!shown.editor().marked()); + shown.commit(QStringLiteral("X")); + QCOMPARE(shown.content(), QString("NEWX")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("NEW")); +} + +void InlineTextEditorTests::arrowsHomeAndEndMoveAndShiftSelects() +{ + TextCanvas shown; + shown.type(QStringLiteral("one two")); + shown.key(Qt::Key_Return); + shown.type(QStringLiteral("three")); + shown.key(Qt::Key_Home); + QCOMPARE(shown.editor().caretPosition(), 8); + shown.key(Qt::Key_Up); + QCOMPARE(shown.editor().caretPosition(), 0); + shown.key(Qt::Key_Right, Qt::ControlModifier); + QCOMPARE(shown.editor().caretPosition(), 3); + shown.key(Qt::Key_Right, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{3, 7})); + // A plain arrow collapses a selection to its side. + shown.key(Qt::Key_Left); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + shown.key(Qt::Key_End, Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{3, 7})); + shown.key(Qt::Key_Right); + QCOMPARE(shown.editor().caretPosition(), 7); + shown.key(Qt::Key_Left, Qt::ControlModifier); + QCOMPARE(shown.editor().caretPosition(), 4); + // Down keeps its first x; below the last, the end. + shown.key(Qt::Key_Down); + QVERIFY(shown.editor().caretPosition() > 8 && shown.editor().caretPosition() < 13); + shown.key(Qt::Key_Down); + QCOMPARE(shown.editor().caretPosition(), 13); + shown.key(Qt::Key_Home, Qt::ControlModifier); + QCOMPARE(shown.editor().caretPosition(), 0); + shown.key(Qt::Key_End, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 13})); + // A character a step; above the first line, its start. + shown.key(Qt::Key_Home, Qt::ControlModifier); + shown.key(Qt::Key_Right); + shown.key(Qt::Key_Right); + shown.key(Qt::Key_Left); + QCOMPARE(shown.caret(), 1); + shown.key(Qt::Key_Up); + QCOMPARE(shown.caret(), 0); + // Nothing lies past either end. + shown.key(Qt::Key_Left); + QCOMPARE(shown.caret(), 0); + shown.key(Qt::Key_End, Qt::ControlModifier); + shown.key(Qt::Key_Right); + QCOMPARE(shown.caret(), 13); +} + +void InlineTextEditorTests::upAndDownKeepTheirColumn() +{ + TextCanvas shown; + shown.type(QStringLiteral("abcdef")); + shown.key(Qt::Key_Return); + shown.type(QStringLiteral("ab")); + shown.key(Qt::Key_Return); + shown.type(QStringLiteral("abcdef")); + shown.key(Qt::Key_Home, Qt::ControlModifier); + shown.key(Qt::Key_End); + // Through a short line and back, as NSTextView. + shown.key(Qt::Key_Down); + QCOMPARE(shown.caret(), 9); + shown.key(Qt::Key_Down); + QCOMPARE(shown.caret(), 16); + shown.key(Qt::Key_Up); + shown.key(Qt::Key_Up); + QCOMPARE(shown.caret(), 6); + // An edit starts the column afresh. + shown.key(Qt::Key_Down); + shown.key(Qt::Key_Backspace); + shown.key(Qt::Key_Down); + QCOMPARE(shown.caret(), 10); +} + +void InlineTextEditorTests::aWrappedParagraphMovesByItsLines() +{ + TextCanvas shown; + shown.session.cancelText(); + shown.session.beginText(QRectF(10, 10, 300, 400)); + shown.type(QStringLiteral("alpha beta gamma")); + // One word a line: Home keeps to its line. + shown.key(Qt::Key_Home); + QCOMPARE(shown.editor().caretPosition(), 11); + shown.key(Qt::Key_Up); + QCOMPARE(shown.editor().caretPosition(), 6); + // A wrapped line's end stays before its breaking space. + shown.key(Qt::Key_End); + QCOMPARE(shown.editor().caretPosition(), 10); + shown.key(Qt::Key_Up); + QCOMPARE(shown.editor().caretPosition(), 4); + shown.key(Qt::Key_Down); + shown.key(Qt::Key_Down); + QVERIFY(shown.editor().caretPosition() > 11 && shown.editor().caretPosition() <= 16); + // The last line keeps its space: End goes past it. + shown.key(Qt::Key_End, Qt::ControlModifier); + shown.type(QStringLiteral(" ")); + shown.key(Qt::Key_Home); + shown.key(Qt::Key_End); + QCOMPARE(shown.editor().caretPosition(), 17); +} + +void InlineTextEditorTests::aCaretPastTheBoxHasNoLine() +{ + TextCanvas shown; + shown.session.cancelText(); + // Room for one line: "beta" falls past the bottom. + shown.session.beginText(QRectF(10, 10, 300, 120)); + shown.type(QStringLiteral("alpha beta")); + shown.key(Qt::Key_Up); + QCOMPARE(shown.caret(), 10); + shown.key(Qt::Key_Home); + QCOMPARE(shown.caret(), 0); + // The laid line still wraps before its space. + shown.key(Qt::Key_End); + QCOMPARE(shown.caret(), 5); + shown.key(Qt::Key_Up); + QCOMPARE(shown.caret(), 0); + shown.key(Qt::Key_End); + shown.key(Qt::Key_Right); + shown.key(Qt::Key_End); + QCOMPARE(shown.caret(), 10); +} + +void InlineTextEditorTests::deletionsReachCharactersWordsAndLines() +{ + TextCanvas shown; + shown.type(QStringLiteral("alpha beta gamma")); + shown.key(Qt::Key_Backspace, Qt::AltModifier); + QCOMPARE(shown.content(), QString("alpha beta ")); + shown.key(Qt::Key_Backspace, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + shown.type(QStringLiteral("alpha beta")); + shown.key(Qt::Key_Home); + shown.key(Qt::Key_Delete, Qt::AltModifier); + QCOMPARE(shown.content(), QString(" beta")); + shown.key(Qt::Key_Delete); + QCOMPARE(shown.content(), QString("beta")); + shown.key(Qt::Key_Delete, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + // A character as a reader counts it: the emoji whole. + shown.type(QStringLiteral("a")); + shown.typeText(QStringLiteral("😀")); + QCOMPARE(shown.content(), QString("a😀")); + shown.key(Qt::Key_Backspace); + QCOMPARE(shown.content(), QString("a")); + // A letter and its accent move and go as one. + shown.typeText(QStringLiteral("e\u0301")); + shown.key(Qt::Key_Left); + QCOMPARE(shown.caret(), 1); + shown.key(Qt::Key_Right); + QCOMPARE(shown.caret(), 3); + shown.key(Qt::Key_Backspace); + QCOMPARE(shown.content(), QString("a")); + // A selection goes first, whatever the key. + shown.type(QStringLiteral("bc")); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_Delete, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("a")); + // Shift adds nothing; the keypad's Delete deletes. + shown.type(QStringLiteral("b cd")); + shown.key(Qt::Key_Backspace, Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("ab c")); + shown.key(Qt::Key_Backspace, Qt::ShiftModifier | Qt::AltModifier); + QCOMPARE(shown.content(), QString("ab ")); + shown.key(Qt::Key_Home); + shown.key(Qt::Key_Delete, Qt::KeypadModifier); + QCOMPARE(shown.content(), QString("b ")); + // At either end nothing goes, and the redo lasts. + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("ab ")); + QCOMPARE(shown.caret(), 0); + shown.key(Qt::Key_Backspace); + shown.key(Qt::Key_End); + shown.key(Qt::Key_Delete); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("b ")); +} + +void InlineTextEditorTests::returnTabEscapeAndFinishing() +{ + TextCanvas shown; + shown.type(QStringLiteral("A")); + shown.key(Qt::Key_Return); + shown.key(Qt::Key_Tab); + shown.key(Qt::Key_Space); + shown.type(QStringLiteral("BM")); + QCOMPARE(shown.content(), QString("A\n\t BM")); + // Tool letters and Space type here: the canvas keeps none. + QCOMPARE(shown.session.tool(), NavigationTool::type); + QCOMPARE(shown.editor().logicalSize().height(), EditorSession::textBoxSize(shown.session.textDraft().value().style).height()); + const size_t count = shown.session.document().value().layers.size(); + shown.key(Qt::Key_Escape); + QVERIFY(!shown.session.textDraft() && !shown.canvas->inlineTextEditor()); + QCOMPARE(shown.session.document().value().layers.size(), count); + shown.session.beginText(QPointF(40, 40), true); + shown.type(QStringLiteral("Kept")); + shown.key(Qt::Key_Return, Qt::ControlModifier); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Kept")); + shown.session.editActiveText(); + shown.key(Qt::Key_End); + shown.type(QStringLiteral("!")); + shown.key(Qt::Key_Enter, Qt::ControlModifier); + QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Kept!")); +} + +void InlineTextEditorTests::altWithTheArrowsSetsSpacing() +{ + TextCanvas shown; + shown.type(QStringLiteral("Spaced")); + const auto style = [&] { return shown.session.textDraft().value().style; }; + shown.key(Qt::Key_Right, Qt::AltModifier); + QCOMPARE(style().tracking, 1.0); + shown.key(Qt::Key_Left, Qt::AltModifier | Qt::ShiftModifier); + QCOMPARE(style().tracking, -9.0); + // Up closes the lines from what Auto works out to. + shown.key(Qt::Key_Up, Qt::AltModifier); + QCOMPARE(style().leading, 72 * 1.2 - 1); + shown.key(Qt::Key_Down, Qt::AltModifier | Qt::ShiftModifier); + QCOMPARE(style().leading, 72 * 1.2 + 9); + shown.session.changeTextStyle([](LayerTextStyle &changed) { changed.leading = 5; }); + shown.key(Qt::Key_Up, Qt::AltModifier | Qt::ShiftModifier); + QCOMPARE(style().leading, 1.0); + // Past the style's bounds nothing changes. + shown.session.changeTextStyle([](LayerTextStyle &changed) { changed.tracking = 995; }); + shown.key(Qt::Key_Right, Qt::AltModifier | Qt::ShiftModifier); + QCOMPARE(style().tracking, 995.0); + QCOMPARE(shown.content(), QString("Spaced")); + QCOMPARE(shown.editor().caretPosition(), 6); +} + +void InlineTextEditorTests::aFinalNewlineHasALineForTheCaret() +{ + TextCanvas shown; + shown.session.cancelText(); + // In a box, Return makes TextKit's extra line, the caret's. + shown.session.beginText(QRectF(10, 10, 300, 300)); + shown.type(QStringLiteral("A")); + shown.key(Qt::Key_Return); + shown.key(Qt::Key_Home); + shown.type(QStringLiteral("B")); + QCOMPARE(shown.content(), QString("A\nB")); + // Point text keeps its measure; that line lies past it. + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 30), true); + shown.type(QStringLiteral("A")); + const QSizeF one = shown.editor().logicalSize(); + shown.key(Qt::Key_Return); + QCOMPARE(shown.editor().logicalSize(), one); + shown.key(Qt::Key_Up); + QCOMPARE(shown.caret(), 0); + shown.key(Qt::Key_Down); + QCOMPARE(shown.caret(), 2); + shown.key(Qt::Key_Home); + shown.type(QStringLiteral("B")); + QCOMPARE(shown.content(), QString("A\nB")); + // A final U+2028 breaks the line the same way. + const QString separator(QChar::LineSeparator); + shown.session.cancelText(); + shown.session.beginText(QRectF(10, 10, 300, 300)); + shown.typeText(QStringLiteral("A") + separator); + shown.key(Qt::Key_Home); + shown.type(QStringLiteral("B")); + QCOMPARE(shown.content(), QStringLiteral("A") + separator + QStringLiteral("B")); + // A line ends before the separator that breaks it. + shown.key(Qt::Key_Home, Qt::ControlModifier); + shown.key(Qt::Key_End); + QCOMPARE(shown.caret(), 1); +} + +void InlineTextEditorTests::arrowsFollowARightToLeftParagraph() +{ + TextCanvas shown; + shown.typeText(QStringLiteral("אבג דה")); + // Right goes back through Hebrew, as Qt's fields move. + shown.key(Qt::Key_Home); + shown.key(Qt::Key_Left); + QCOMPARE(shown.caret(), 1); + shown.key(Qt::Key_Right); + QCOMPARE(shown.caret(), 0); + // A plain arrow collapses to its own side. + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 2})); + shown.key(Qt::Key_Right); + QCOMPARE(shown.selection(), (TextRange{0, 0})); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_Left); + QCOMPARE(shown.selection(), (TextRange{1, 1})); + // Words go the same way. + shown.key(Qt::Key_Left, Qt::ControlModifier); + QCOMPARE(shown.caret(), 3); + shown.key(Qt::Key_Right, Qt::ControlModifier); + QCOMPARE(shown.caret(), 0); + // Each paragraph reads its own way, whatever parts them. + for (const QString &separator : {QStringLiteral("\n"), QStringLiteral("\r"), QString(QChar(0x2029))}) { + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("אב") + separator + QStringLiteral("ab"); + shown.session.setTextDraft(draft); + shown.key(Qt::Key_End, Qt::ControlModifier); + shown.key(Qt::Key_Home); + shown.key(Qt::Key_Right); + QCOMPARE(shown.caret(), 4); + } +} + +QTEST_MAIN(InlineTextEditorTests) +#include "InlineTextEditorTests.moc" diff --git a/tests/InlineTextFixtures.h b/tests/InlineTextFixtures.h new file mode 100644 index 0000000..39a0674 --- /dev/null +++ b/tests/InlineTextFixtures.h @@ -0,0 +1,46 @@ +#pragma once +#include "SelectionCanvasFixtures.h" +#include <QInputMethodEvent> + +// Shared by the inline editor's key and input tests. +namespace { +// A canvas that follows its session, as ContentView makes it. +struct TextCanvas : Canvas { + TextCanvas() + { + QObject::connect(&session, &EditorSession::changed, canvas, [this] { canvas->synchronizeDisplay(); }); + session.addBlankLayer(); + session.selectTool(NavigationTool::type); + session.beginText(QPointF(20, 30), true); + } + InlineTextEditor &editor() const + { + if (!canvas->inlineTextEditor()) + throw std::runtime_error("no editor"); + return *canvas->inlineTextEditor(); + } + QString content() const { return session.textDraft().value().style.content; } + int caret() const { return editor().caretPosition(); } + void key(Qt::Key key, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::keyClick(canvas, key, modifiers); } + void type(const QString &text) { QTest::keyClicks(canvas, text); } + // A key whose text is more than one UTF-16 unit. + void typeText(const QString &text, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QKeyEvent press(QEvent::KeyPress, 0, modifiers, text); + QApplication::sendEvent(canvas, &press); + } + // Input methods: a preedit, or a commit replacing text. + void compose(const QString &preedit, const QList<QInputMethodEvent::Attribute> &attributes = {}) + { + QInputMethodEvent event(preedit, attributes); + QApplication::sendEvent(canvas, &event); + } + void commit(const QString &text, int from = 0, int length = 0) + { + QInputMethodEvent event; + event.setCommitString(text, from, length); + QApplication::sendEvent(canvas, &event); + } + TextRange selection() const { return {std::min(editor().anchor(), editor().caretPosition()), std::max(editor().anchor(), editor().caretPosition())}; } +}; +} diff --git a/tests/InlineTextHandleTests.cpp b/tests/InlineTextHandleTests.cpp new file mode 100644 index 0000000..c2174d9 --- /dev/null +++ b/tests/InlineTextHandleTests.cpp @@ -0,0 +1,266 @@ +#include "InlineTextFixtures.h" +#include <QLineEdit> +#include <array> + +// Open text's handles: cursors, resizing, limits, the padding. +namespace { +// A point of the box, in its own pixels. +QPointF at(TextCanvas &shown, double x, double y) +{ + return shown.editor().boxTransform().map(QPointF(x, y)); +} + +Qt::CursorShape hovered(TextCanvas &shown, QPointF point) +{ + shown.hover(point); + return shown.canvas->cursor().shape(); +} +} + +class InlineTextHandleTests : public QObject { + Q_OBJECT +private slots: + void handlesFollowTheBoxEdges(); + void handlesResizeTheBox(); + void aTurnedBoxResizesAlongItsEdges(); + void aScaledLayerKeepsItsScale(); + void thePaddingTakesNoClicks(); + void aLostReleaseEndsAResize(); + void anEditEndsAResize(); + void aSquashedLayerTakesFarDrags(); +}; + +void InlineTextHandleTests::handlesFollowTheBoxEdges() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + const QSizeF size = shown.editor().logicalSize(); + // Swift's frame positions, corner by corner round the box. + const std::array<Qt::CursorShape, 8> shapes{Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor, + Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor}; + for (size_t handle = 0; handle < shapes.size(); ++handle) { + const QPointF unit = LayerTransform::handles[handle]; + QCOMPARE(hovered(shown, at(shown, unit.x() * size.width(), unit.y() * size.height())), shapes[handle]); + } + // Bands ten points either side of each edge; inside, text. + const double width = size.width(), middle = size.height() / 2; + QCOMPARE(hovered(shown, at(shown, width / 2, middle)), Qt::IBeamCursor); + QCOMPARE(hovered(shown, at(shown, width - 9, middle)), Qt::SizeHorCursor); + QCOMPARE(hovered(shown, at(shown, width - 11, middle)), Qt::IBeamCursor); + QCOMPARE(hovered(shown, at(shown, width + 9, middle)), Qt::SizeHorCursor); + QCOMPARE(hovered(shown, at(shown, width + 11, middle)), Qt::IBeamCursor); + QCOMPARE(hovered(shown, at(shown, width / 2, 9)), Qt::SizeVerCursor); + QCOMPARE(hovered(shown, at(shown, width / 2, -9)), Qt::SizeVerCursor); + QCOMPARE(hovered(shown, at(shown, width / 2, -11)), Qt::IBeamCursor); + // A press shows its handle's cursor before any hover. + QCOMPARE(hovered(shown, at(shown, width / 2, middle)), Qt::IBeamCursor); + const QPointF right = at(shown, width, middle); + shown.press(right); + QCOMPARE(shown.canvas->cursor().shape(), Qt::SizeHorCursor); + // The release shows what lies under the pointer. + shown.move(right - QPointF(500, 0)); + shown.release(right - QPointF(500, 0)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::IBeamCursor); + // A narrow box keeps a third of itself for text. + QCOMPARE(shown.editor().logicalSize().width(), 16.0); + QCOMPARE(hovered(shown, at(shown, 8, 0)), Qt::SizeVerCursor); + QCOMPARE(hovered(shown, at(shown, 8, middle)), Qt::IBeamCursor); +} + +void InlineTextHandleTests::handlesResizeTheBox() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + const QSizeF size = shown.editor().logicalSize(); + const QPointF right = at(shown, size.width(), size.height() / 2); + // A press alone makes point text a box its size. + shown.click(right); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(size)); + QCOMPARE(shown.session.textDraft().value().transform, std::optional(LayerTransform{.origin = QPointF(20, 30), .size = size})); + // Dragged, the edge follows and the far edge stays. + shown.drag(right, right + QPointF(40, 0)); + const QSizeF wider(size.width() + 40, size.height()); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(wider)); + QCOMPARE(shown.session.textDraft().value().transform, std::optional(LayerTransform{.origin = QPointF(20, 30), .size = wider})); + QCOMPARE(shown.session.textDraft().value().style.content, QString("Hi")); + // Past the far edge a side stops at 16 pixels. + const QPointF left = at(shown, 0, size.height() / 2); + shown.drag(left, left + QPointF(500, 0)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(16, size.height()))); + QCOMPARE(shown.session.textDraft().value().transform.value().origin, QPointF(20 + wider.width() - 16, 30)); + const QPointF top = at(shown, 8, 0); + shown.drag(top, top + QPointF(0, 500)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(16, 16))); + QCOMPARE(shown.session.textDraft().value().transform.value().origin, QPointF(20 + wider.width() - 16, 30 + size.height() - 16)); + const QPointF bottom = at(shown, 8, 16); + shown.drag(bottom, bottom + QPointF(0, 40)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(16, 56))); + shown.drag(bottom + QPointF(0, 40), bottom - QPointF(0, 500)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(16, 16))); + // Past 30,000 pixels the box stays as it was. + const QPointF edge = at(shown, 16, 8); + shown.drag(edge, edge + QPointF(40'000, 0)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(16, 16))); +} + +void InlineTextHandleTests::aTurnedBoxResizesAlongItsEdges() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 90; }); + // The install fits the view; one point a pixel again. + shown.session.zoom(1); + shown.session.editActiveText(); + const QSizeF size = shown.editor().logicalSize(); + // Turned a quarter, the right edge faces down. + const QPointF right = at(shown, size.width(), size.height() / 2); + QCOMPARE(hovered(shown, right), Qt::SizeVerCursor); + shown.drag(right, right + QPointF(0, 40)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(size.width() + 40, size.height()))); + // And the bottom edge faces left. + const QPointF bottom = at(shown, size.width() / 2, size.height()); + QCOMPARE(hovered(shown, bottom), Qt::SizeHorCursor); + shown.drag(bottom, bottom - QPointF(40, 0)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(size.width() + 40, size.height() + 40))); + // A turn past int's range counts its rest: 280 degrees. + QVERIFY(shown.session.finishText()); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 1e12; }); + shown.session.zoom(1); + shown.session.editActiveText(); + QCOMPARE(hovered(shown, at(shown, 0, 0)), Qt::SizeBDiagCursor); +} + +void InlineTextHandleTests::aScaledLayerKeepsItsScale() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.size *= 20; }); + shown.session.zoom(1); + shown.session.editActiveText(); + shown.key(Qt::Key_End); + shown.type(QStringLiteral("!")); + // Point text grows at its layer's scale; presses keep it. + const QSizeF size = shown.editor().logicalSize(); + const LayerTransform grown = shown.editor().shownTransform(); + QCOMPARE(grown.size, size * 20); + const QPointF right = at(shown, size.width(), size.height() / 2); + shown.press(right); + QCOMPARE(shown.session.textDraft().value().transform, std::optional(grown)); + // A valid box whose layer would pass 300,000 pixels stays. + shown.move(right + QPointF(300'000, 0)); + shown.release(right + QPointF(300'000, 0)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(size)); + QCOMPARE(shown.session.textDraft().value().transform, std::optional(grown)); +} + +void InlineTextHandleTests::thePaddingTakesNoClicks() +{ + TextCanvas shown; + shown.session.cancelText(); + shown.session.zoom(2); + shown.session.beginText(QPointF(190, 140), true); + shown.type(QStringLiteral("Pad")); + const double middle = shown.editor().logicalSize().height() / 2; + const QPointF padding = at(shown, 6, middle), letters = at(shown, LayerTextStyle::padding + 1, middle); + QVERIFY(shown.canvas->rect().contains(padding.toPoint())); + // Zoomed in, a click in the padding does nothing. + shown.click(padding); + QCOMPARE(shown.caret(), 3); + QCOMPARE(shown.session.textDraft().value().style.content, QString("Pad")); + // Nor a drag from it, after a click on letters. + shown.click(letters); + QCOMPARE(shown.caret(), 0); + shown.key(Qt::Key_End); + shown.drag(padding, letters); + QCOMPARE(shown.selection(), (TextRange{3, 3})); +} + +void InlineTextHandleTests::aLostReleaseEndsAResize() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + const QSizeF size = shown.editor().logicalSize(); + const QPointF right = at(shown, size.width(), size.height() / 2); + // A hover without the button ends it, and its cursor. + shown.press(right); + shown.move(right + QPointF(40, 0)); + QCOMPARE(hovered(shown, at(shown, size.width() / 2, size.height() / 2)), Qt::IBeamCursor); + // So does a press: a padding drag then does nothing. + const QSizeF held = shown.session.textDraft().value().style.boxSize.value(); + const QPointF edge = at(shown, held.width(), size.height() / 2); + shown.press(edge); + shown.move(edge + QPointF(20, 0)); + const QSizeF wider = shown.session.textDraft().value().style.boxSize.value(); + QCOMPARE(wider, QSizeF(held.width() + 20, held.height())); + const QPointF padding = at(shown, wider.width() / 2, 11); + shown.press(padding); + shown.move(padding + QPointF(30, 0)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(wider)); + shown.release(padding + QPointF(30, 0)); + // And a lost focus, the cursor at once. + const QPointF end = at(shown, wider.width(), size.height() / 2); + shown.press(end); + shown.move(end - QPointF(500, 0)); + QLineEdit field(&shown.window); + field.show(); + field.setFocus(); + QTRY_VERIFY(field.hasFocus()); + QCOMPARE(shown.canvas->cursor().shape(), Qt::IBeamCursor); +} + +void InlineTextHandleTests::anEditEndsAResize() +{ + TextCanvas shown; + shown.type(QStringLiteral("abc")); + const QSizeF size = shown.editor().logicalSize(); + const QPointF right = at(shown, size.width(), size.height() / 2); + // Its drag would write back the draft it began with. + shown.press(right); + shown.move(right + QPointF(20, 0)); + shown.type(QStringLiteral("X")); + shown.move(right + QPointF(40, 0)); + QCOMPARE(shown.session.textDraft().value().style.content, QString("abcX")); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(size.width() + 20, size.height()))); + shown.release(right + QPointF(40, 0)); + // A spacing key ends it too. + const QPointF edge = at(shown, size.width() + 20, size.height() / 2); + shown.press(edge); + shown.move(edge + QPointF(20, 0)); + shown.key(Qt::Key_Right, Qt::AltModifier); + const double tracking = shown.session.textDraft().value().style.tracking; + QVERIFY(tracking > 0); + shown.move(edge + QPointF(40, 0)); + QCOMPARE(shown.session.textDraft().value().style.tracking, tracking); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(size.width() + 40, size.height()))); + shown.release(edge + QPointF(40, 0)); +} + +void InlineTextHandleTests::aSquashedLayerTakesFarDrags() +{ + TextCanvas shown; + shown.session.cancelText(); + shown.drag(QPointF(20, 20), QPointF(120, 120)); + TextDraft draft = shown.session.textDraft().value(); + draft.style.fontSize = 1; + draft.style.boxSize = QSizeF(100, 30'000); + draft.style.content = QStringLiteral("abc"); + shown.session.setTextDraft(draft); + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.size = QSizeF(100, 10); }); + shown.session.zoom(1); + shown.session.editActiveText(); + // Rows past int's range still lie below the lines. + const QPointF inside = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 1, 15'000)); + shown.press(inside); + shown.move(inside + QPointF(0, 1'000'000)); + QCOMPARE(shown.caret(), 3); + shown.release(inside + QPointF(0, 1'000'000)); +} + +QTEST_MAIN(InlineTextHandleTests) +#include "InlineTextHandleTests.moc" diff --git a/tests/InlineTextInputTests.cpp b/tests/InlineTextInputTests.cpp new file mode 100644 index 0000000..23a8478 --- /dev/null +++ b/tests/InlineTextInputTests.cpp @@ -0,0 +1,379 @@ +#include "InlineTextFixtures.h" +#include <QClipboard> +#include <QLineEdit> + +// What types, the cap, the clipboard and input methods. +class InlineTextInputTests : public QObject { + Q_OBJECT +private slots: + void keysTypeAsQtsTextFieldsJudge(); + void theTextHoldsAHundredThousandUnits(); + void theClipboardTakesPlainText(); + void inputMethodsComposeInTheDraft(); + void inputMethodsReplaceOnlyWhatTheyName(); + void inputMethodsFollowTheCaret(); + void aPreeditLeavesTheHistoryAlone(); + void theEditMenuWorksAroundAPreedit(); +}; + +void InlineTextInputTests::keysTypeAsQtsTextFieldsJudge() +{ + TextCanvas shown; + // A joiner types, even under Ctrl and Shift. + const QString joiner(QChar(0x200D)); + shown.typeText(joiner, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), joiner); + // Ctrl alone or with Shift commands; AltGr's Ctrl types. + shown.key(Qt::Key_K, Qt::ControlModifier); + shown.key(Qt::Key_K, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), joiner); + shown.key(Qt::Key_K, Qt::ControlModifier | Qt::AltModifier); + QCOMPARE(shown.content(), joiner + QStringLiteral("k")); + // Shift-+ and Shift-− type here, as in any text view. + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.key(Qt::Key_Plus, Qt::ShiftModifier); + shown.key(Qt::Key_Underscore, Qt::ShiftModifier); + QCOMPARE(shown.content(), joiner + QStringLiteral("k+_")); + QCOMPARE(shown.session.activeLayer().value().blendMode, LayerBlendMode::normal); +} + +void InlineTextInputTests::theTextHoldsAHundredThousandUnits() +{ + TextCanvas shown; + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QString(99'999, QLatin1Char('a')); + shown.session.setTextDraft(draft); + shown.key(Qt::Key_End, Qt::ControlModifier); + shown.type(QStringLiteral("bc")); + QCOMPARE(shown.content().size(), 100'000); + QVERIFY(shown.content().endsWith(QLatin1Char('b'))); + // Replacing keeps within the cap too. + QGuiApplication::clipboard()->setText(QStringLiteral("xyz")); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_V, Qt::ControlModifier); + QCOMPARE(shown.content().size(), 100'000); + QVERIFY(shown.content().endsWith(QLatin1Char('b'))); + // So does an input method's text. + shown.commit(QStringLiteral("zz")); + QCOMPARE(shown.content().size(), 100'000); + QVERIFY(shown.content().endsWith(QLatin1Char('b'))); +} + +void InlineTextInputTests::theClipboardTakesPlainText() +{ + TextCanvas shown; + shown.type(QStringLiteral("copy me")); + shown.key(Qt::Key_A, Qt::ControlModifier); + QCOMPARE(shown.selection(), (TextRange{0, 7})); + shown.key(Qt::Key_C, Qt::ControlModifier); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("copy me")); + shown.key(Qt::Key_End); + shown.key(Qt::Key_Left, Qt::ControlModifier | Qt::ShiftModifier); + shown.key(Qt::Key_X, Qt::ControlModifier); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("me")); + QCOMPARE(shown.content(), QString("copy ")); + QMimeData *rich = new QMimeData; + rich->setHtml(QStringLiteral("<b>bold</b>")); + rich->setText(QStringLiteral("plain")); + QGuiApplication::clipboard()->setMimeData(rich); + shown.key(Qt::Key_V, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("copy plain")); + // Each paste is its own step. + shown.key(Qt::Key_V, Qt::ControlModifier); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("copy plain")); + // Nothing selected, nothing cut, and the redo lasts. + shown.key(Qt::Key_X, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("copy plain")); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("plain")); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("copy plainplain")); + // Shift-Delete is the platform's Cut, never a deletion. + QVERIFY(QKeySequence::keyBindings(QKeySequence::Cut).contains(QKeySequence(Qt::SHIFT | Qt::Key_Delete))); + shown.key(Qt::Key_Home); + shown.key(Qt::Key_Delete, Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("copy plainplain")); + shown.key(Qt::Key_End); + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.key(Qt::Key_Delete, Qt::ShiftModifier); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("n")); + QCOMPARE(shown.content(), QString("copy plainplai")); +} + +void InlineTextInputTests::inputMethodsComposeInTheDraft() +{ + TextCanvas shown; + QVERIFY(shown.canvas->testAttribute(Qt::WA_InputMethodEnabled)); + shown.type(QStringLiteral("x")); + shown.compose(QStringLiteral("ni"), {QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 1, 1)}); + // Marked text is in the draft, as in NSTextView's storage. + QCOMPARE(shown.content(), QString("xni")); + QCOMPARE(shown.editor().marked(), std::optional(TextRange{1, 3})); + QCOMPARE(shown.caret(), 2); + const QWidget &widget = *shown.canvas; + // Input methods see the text around, without their own. + QCOMPARE(widget.inputMethodQuery(Qt::ImSurroundingText).toString(), QString("x")); + QCOMPARE(widget.inputMethodQuery(Qt::ImCursorPosition).toInt(), 1); + shown.commit(QStringLiteral("你")); + QCOMPARE(shown.content(), QString("x你")); + QVERIFY(!shown.editor().marked()); + QCOMPARE(shown.caret(), 2); + QCOMPARE(widget.inputMethodQuery(Qt::ImSurroundingText).toString(), QString("x你")); + QCOMPARE(widget.inputMethodQuery(Qt::ImCursorPosition).toInt(), 2); + QVERIFY(widget.inputMethodQuery(Qt::ImHints).toInt() & Qt::ImhMultiLine); + const QRectF caret = widget.inputMethodQuery(Qt::ImCursorRectangle).toRectF(); + QVERIFY(caret.left() > 20 + 12 && caret.height() > 80); + // The typing and the composed word undo together. + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + // After a move, composing starts a step of its own. + shown.type(QStringLiteral("a")); + shown.key(Qt::Key_Left); + shown.compose(QStringLiteral("b")); + shown.commit(QStringLiteral("b")); + QCOMPARE(shown.content(), QString("ba")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("a")); + // A paste lands before the preedit, which follows, as Qt's. + shown.compose(QStringLiteral("ni")); + QGuiApplication::clipboard()->setText(QStringLiteral("P")); + shown.key(Qt::Key_V, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("Pnia")); + QCOMPARE(shown.editor().marked(), std::optional(TextRange{1, 3})); + // An empty preedit ends the composing. + shown.compose(QStringLiteral("ni")); + shown.compose(QString()); + QCOMPARE(shown.content(), QString("Pa")); + QVERIFY(!shown.editor().marked()); + // A preedit records nothing: undo takes the last step back. + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("n")); + shown.key(Qt::Key_Left); + shown.compose(QStringLiteral("ni")); + QCOMPARE(shown.content(), QString("Pani")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("a")); + QVERIFY(!shown.editor().marked()); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("Pa")); + shown.session.cancelText(); + QVERIFY(!shown.canvas->testAttribute(Qt::WA_InputMethodEnabled)); +} + +void InlineTextInputTests::inputMethodsReplaceOnlyWhatTheyName() +{ + TextCanvas shown; + // A commit alone types; one may replace text before. + shown.commit(QStringLiteral("helo")); + QCOMPARE(shown.content(), QString("helo")); + shown.commit(QStringLiteral("hello"), -4, 4); + QCOMPARE(shown.content(), QString("hello")); + QCOMPARE(shown.caret(), 5); + shown.commit(QString(), -1, 1); + QCOMPARE(shown.content(), QString("hell")); + shown.key(Qt::Key_Left); + shown.commit(QString(), -1, 1); + QCOMPARE(shown.content(), QString("hel")); + QCOMPARE(shown.caret(), 2); + // Qt removes a range whole or not at all. + shown.commit(QString(), -1, 5); + QCOMPARE(shown.content(), QString("hel")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("hell")); + shown.key(Qt::Key_End); + // One typing step, as keys make; input drops the redo. + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + shown.commit(QStringLiteral("h")); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("h")); + shown.commit(QStringLiteral("ell")); + // Nothing inserted or removed changes nothing. + shown.key(Qt::Key_A, Qt::ControlModifier); + shown.compose(QString()); + QCOMPARE(shown.content(), QString("hell")); + QCOMPARE(shown.selection(), (TextRange{0, 4})); + shown.key(Qt::Key_Home); + shown.commit(QString(), 9, 1); + QCOMPARE(shown.caret(), 0); + QCOMPARE(shown.content(), QString("hell")); + // While composing, a replacement counts from the preedit. + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("ni")); + shown.commit(QStringLiteral("X"), -2, 2); + QCOMPARE(shown.content(), QString("heX")); + QVERIFY(!shown.editor().marked()); + QCOMPARE(shown.caret(), 3); + // A replacement of nothing still places what it inserts. + shown.commit(QStringLiteral("Y"), -1, 0); + QCOMPARE(shown.content(), QString("heYX")); + QCOMPARE(shown.caret(), 3); + // An input method may set the selection alone. + shown.key(Qt::Key_Left, Qt::ShiftModifier); + shown.compose(QString(), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 1, 2, QVariant())}); + QCOMPARE(shown.selection(), (TextRange{1, 3})); + QCOMPARE(shown.content(), QString("heYX")); + // Input takes a selection first, then counts from there. + shown.commit(QStringLiteral("Z"), 1, 0); + QCOMPARE(shown.content(), QString("hXZ")); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + // A selection beside a preedit goes with the next input. + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("abcdef"); + shown.session.setTextDraft(draft); + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("ni"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 1, 2, QVariant())}); + QCOMPARE(shown.content(), QString("abcnidef")); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImCurrentSelection).toString(), QString("bc")); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImAnchorPosition).toInt(), 1); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImCursorPosition).toInt(), 3); + // Its updates move the caret and keep that selection. + shown.compose(QStringLiteral("ni"), {QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 0, 1, QVariant())}); + QCOMPARE(shown.caret(), 3); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImCurrentSelection).toString(), QString("bc")); + shown.commit(QStringLiteral("X")); + QCOMPARE(shown.content(), QString("aXdef")); + // Its own move ends a typing run, as keys do. + shown.commit(QStringLiteral("abc")); + shown.compose(QString(), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 0, QVariant())}); + shown.commit(QStringLiteral("Q")); + QCOMPARE(shown.content(), QString("QaXabcdef")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("aXabcdef")); + // Queries read it in the text without the preedit. + shown.key(Qt::Key_Home); + shown.compose(QStringLiteral("ni"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 3, -2, QVariant())}); + QCOMPARE(shown.content(), QString("aniXabcdef")); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImCurrentSelection).toString(), QString("Xa")); +} + +void InlineTextInputTests::inputMethodsFollowTheCaret() +{ + TextCanvas shown; + QTRY_VERIFY(shown.canvas->hasFocus()); + // Edits, moves and zooms move the candidates, as microfocus. + QSignalSpy moved(QGuiApplication::inputMethod(), &QInputMethod::cursorRectangleChanged); + shown.type(QStringLiteral("a")); + QVERIFY(!moved.isEmpty()); + moved.clear(); + shown.key(Qt::Key_Left); + QVERIFY(!moved.isEmpty()); + moved.clear(); + shown.session.zoom(2); + QVERIFY(!moved.isEmpty()); + // Without the keys the canvas leaves input methods be. + auto *field = new QLineEdit(&shown.window); + field->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + moved.clear(); + shown.session.zoom(1); + shown.editor().selectAll(); + QVERIFY(moved.isEmpty()); +} + +void InlineTextInputTests::aPreeditLeavesTheHistoryAlone() +{ + TextCanvas shown; + // Qt's contract: a cancelled composition keeps the redo. + shown.type(QStringLiteral("abc")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + shown.compose(QStringLiteral("ni")); + shown.compose(QString()); + QCOMPARE(shown.content(), QString()); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("abc")); + // A commit is one step, from the text before composing. + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("x")); + shown.compose(QStringLiteral("xy")); + shown.commit(QStringLiteral("XY")); + QCOMPARE(shown.content(), QString("abcXY")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("abc")); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("abcXY")); + // A preedit takes a selection in a typing step. + shown.key(Qt::Key_A, Qt::ControlModifier); + shown.compose(QStringLiteral("q")); + shown.compose(QString()); + QCOMPARE(shown.content(), QString()); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("abcXY")); + // A partial commit lands; what follows counts from there. + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("mn")); + QInputMethodEvent partial(QStringLiteral("n"), {}); + partial.setCommitString(QStringLiteral("M")); + QApplication::sendEvent(shown.canvas, &partial); + shown.key(Qt::Key_Left); + shown.commit(QStringLiteral("N")); + QCOMPARE(shown.content(), QString("abcXYMN")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("abcXYM")); +} + +void InlineTextInputTests::theEditMenuWorksAroundAPreedit() +{ + TextCanvas shown; + const auto composing = [&] { + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("abcdef"); + shown.session.setTextDraft(draft); + shown.key(Qt::Key_End); + shown.compose(QStringLiteral("ni"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 1, 2, QVariant())}); + }; + // Copy and Cut take the selection beside it, as Qt's. + composing(); + QGuiApplication::clipboard()->setText(QStringLiteral("sentinel")); + shown.editor().copy(); + QCOMPARE(QGuiApplication::clipboard()->text(), QString("bc")); + shown.editor().cut(); + QCOMPARE(shown.content(), QString("anidef")); + QCOMPARE(shown.editor().marked(), std::optional(TextRange{1, 3})); + QCOMPARE(shown.caret(), 3); + shown.commit(QStringLiteral("Y")); + QCOMPARE(shown.content(), QString("aYdef")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("adef")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("abcdef")); + // Paste replaces it; the preedit follows the pasted text. + composing(); + QGuiApplication::clipboard()->setText(QStringLiteral("P")); + shown.editor().paste(); + QCOMPARE(shown.content(), QString("aPnidef")); + QCOMPARE(shown.editor().marked(), std::optional(TextRange{2, 4})); + shown.commit(QStringLiteral("X")); + QCOMPARE(shown.content(), QString("aPXdef")); + // Select All selects around it; the next input takes it. + composing(); + shown.editor().selectAll(); + QCOMPARE(shown.content(), QString("abcdefni")); + QCOMPARE(shown.editor().inputMethodQuery(Qt::ImCurrentSelection).toString(), QString("abcdef")); + shown.commit(QStringLiteral("X")); + QCOMPARE(shown.content(), QString("X")); + // It ends a typing run, a preedit showing or not. + shown.type(QStringLiteral("y")); + shown.compose(QStringLiteral("ni")); + shown.editor().selectAll(); + shown.commit(QStringLiteral("Z")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("Xy")); + // A paste is its own step and drops the redo. + shown.key(Qt::Key_End); + shown.type(QStringLiteral("c")); + QGuiApplication::clipboard()->setText(QStringLiteral("P")); + shown.key(Qt::Key_V, Qt::ControlModifier); + shown.type(QStringLiteral("b")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString("XycP")); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QGuiApplication::clipboard()->setText(QStringLiteral("Q")); + shown.key(Qt::Key_V, Qt::ControlModifier); + shown.key(Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.content(), QString("XycQ")); +} + +QTEST_MAIN(InlineTextInputTests) +#include "InlineTextInputTests.moc" diff --git a/tests/InlineTextPointerTests.cpp b/tests/InlineTextPointerTests.cpp new file mode 100644 index 0000000..805a996 --- /dev/null +++ b/tests/InlineTextPointerTests.cpp @@ -0,0 +1,444 @@ +#include "InlineTextFixtures.h" +#include <QLineEdit> +#include <array> + +// The Type tool's pointer: new text, clicks, drags, selections. +namespace { +// Where a prefix of a line ends, plus a nudge. +QPointF after(TextCanvas &shown, const QString &prefix, double nudge = 1, int line = 0) +{ + const LayerTextStyle style = shown.session.textDraft().value().style; + const double padding = LayerTextStyle::padding; + const double x = QFontMetricsF(TextLayout::font(style)).horizontalAdvance(prefix); + return shown.editor().textTransform().map(QPointF(padding + x + nudge, padding + style.lineHeight() * (line + 0.5))); +} + +// The bounds of the pixels two grabs disagree on. +QRect changed(const QImage &from, const QImage &to) +{ + QRect found; + for (int y = 0; y < to.height(); ++y) { + for (int x = 0; x < to.width(); ++x) { + if (to.pixel(x, y) != from.pixel(x, y)) + found |= QRect(x, y, 1, 1); + } + } + return found; +} +} + +class InlineTextPointerTests : public QObject { + Q_OBJECT +private slots: + void aClickOrADragBeginsText(); + void aClickOpensTheTextUnderIt(); + void clicksPlaceTheCaretAndDragsSelect(); + void doubleAndTripleClicksTakeWordsAndParagraphs(); + void aClickLandsAPreedit(); + void anUndoneCanvasLeavesTheBoxInert(); + void aLostReleaseDropsTheBox(); + void shiftExtendsByTheLastClicksUnit(); + void blankSpaceAndEmptyLinesTakeClicks(); +}; + +void InlineTextPointerTests::aClickOrADragBeginsText() +{ + TextCanvas shown; + shown.session.cancelText(); + // A click is Swift's small box: point text there. + shown.click(QPointF(50, 60)); + QCOMPARE(shown.session.textDraft().value().origin, QPointF(50, 60)); + QVERIFY(!shown.session.textDraft().value().style.boxSize); + shown.session.cancelText(); + // A drag draws a box, shown in the accent meanwhile. + const QImage before = shown.canvas->grab().toImage(); + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + const QImage drawing = shown.canvas->grab().toImage(); + const QRect drawn = changed(before, drawing); + QVERIFY2(std::abs(drawn.left() - 40) <= 1 && std::abs(drawn.right() - 140) <= 1 && std::abs(drawn.top() - 50) <= 1 && std::abs(drawn.bottom() - 110) <= 1, + qPrintable(QString("%1 %2 %3 %4").arg(drawn.left()).arg(drawn.top()).arg(drawn.right()).arg(drawn.bottom()))); + QCOMPARE(drawing.pixel(90, 80), before.pixel(90, 80)); + // One point wide on whole points: two rows, half each. + const QColor accent = shown.canvas->palette().color(QPalette::Highlight); + const auto half = [](int line, int ground, int shown) { return std::abs(shown - (line + ground) / 2) <= 2; }; + for (const int row : {49, 50}) { + const QColor ground = before.pixelColor(90, row), edge = drawing.pixelColor(90, row); + QVERIFY2(half(accent.red(), ground.red(), edge.red()) && half(accent.green(), ground.green(), edge.green()) + && half(accent.blue(), ground.blue(), edge.blue()), + qPrintable(edge.name())); + } + shown.release(QPointF(140, 110)); + QCOMPARE(shown.session.textDraft().value().style.boxSize, std::optional(QSizeF(100, 60))); + QCOMPARE(shown.session.textDraft().value().origin, QPointF(40, 50)); + // Under four pixels both ways: a click; else a box. + const std::array<std::pair<QPointF, std::optional<QSizeF>>, 3> drags{ + {{QPointF(43, 53), std::nullopt}, {QPointF(44, 54), QSizeF(16, 16)}, {QPointF(43, 100), QSizeF(16, 50)}}}; + for (const auto &[to, box] : drags) { + shown.session.cancelText(); + shown.drag(QPointF(40, 50), to); + QCOMPARE(shown.session.textDraft().value().style.boxSize, box); + } + shown.session.cancelText(); + // Escape, or another tool, drops the box being drawn. + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + shown.key(Qt::Key_Escape); + shown.release(QPointF(140, 110)); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.canvas->grab().toImage().pixel(90, 50), before.pixel(90, 50)); + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + shown.session.selectTool(NavigationTool::marquee); + shown.session.selectTool(NavigationTool::type); + shown.release(QPointF(140, 110)); + QVERIFY(!shown.session.textDraft()); + // A box past the limits is refused; its outline goes. + shown.press(QPointF(40, 50)); + shown.move(QPointF(40'040, 110)); + QCoreApplication::processEvents(); + PaintSpy spy(*shown.canvas); + shown.release(QPointF(40'040, 110)); + QVERIFY(!shown.session.textDraft()); + QTRY_VERIFY(spy.painted.contains(QPoint(40, 80))); + QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); +} + +void InlineTextPointerTests::aClickOpensTheTextUnderIt() +{ + TextCanvas shown; + // A click beside open text keeps it, then begins anew. + shown.type(QStringLiteral("Kept")); + const size_t count = shown.session.document().value().layers.size(); + shown.click(QPointF(300, 250)); + QCOMPARE(shown.session.document().value().layers.size(), count + 1); + QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250)); + shown.session.cancelText(); + // A click opens text, active or not, the caret there. + const QUuid kept = shown.session.document().value().layers.back().id; + shown.session.selectLayer(shown.session.document().value().layers.front().id); + const QPointF on(20 + LayerTextStyle::padding + 1, 80); + shown.click(on); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(kept)); + QCOMPARE(shown.caret(), 0); + shown.session.cancelText(); + // Hidden text stays shut: the click begins new text. + shown.session.toggleLayerVisibility(kept); + shown.click(on); + QVERIFY(!shown.session.textDraft().value().layerID); + shown.session.cancelText(); + shown.session.toggleLayerVisibility(kept); + // A new box widened leftwards lands where it shows. + shown.drag(QPointF(40, 150), QPointF(240, 250)); + shown.type(QStringLiteral("Box")); + const QPointF left = shown.editor().boxTransform().map(QPointF(0, 50)); + shown.drag(left, left - QPointF(20, 0)); + QVERIFY(shown.session.finishText()); + QCOMPARE(shown.session.activeLayer().value().transform.origin, QPointF(20, 150)); +} + +void InlineTextPointerTests::clicksPlaceTheCaretAndDragsSelect() +{ + TextCanvas shown; + shown.type(QStringLiteral("alpha beta")); + shown.click(after(shown, QStringLiteral("alp"))); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + // A drag selects; Shift-click takes the selection on. + shown.drag(after(shown, QStringLiteral("al")), after(shown, QStringLiteral("alpha b"))); + QCOMPARE(shown.selection(), (TextRange{2, 7})); + shown.click(after(shown, QStringLiteral("alpha be")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{2, 8})); + // Shift's drag keeps that anchor, past it both ways. + shown.press(after(shown, QStringLiteral("alpha ")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{2, 6})); + shown.move(after(shown, QStringLiteral("a")), Qt::ShiftModifier); + QCOMPARE(shown.editor().anchor(), 2); + QCOMPARE(shown.caret(), 1); + shown.release(after(shown, QStringLiteral("a")), Qt::ShiftModifier); + // Without the button held a move drags nothing. + shown.press(after(shown, QStringLiteral("alp"))); + shown.hover(after(shown, QStringLiteral("alpha b"))); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + shown.release(after(shown, QStringLiteral("alp"))); + // A click on a later line finds that line. + shown.key(Qt::Key_End); + shown.key(Qt::Key_Return); + shown.type(QStringLiteral("gamma")); + shown.click(after(shown, QStringLiteral("ga"), 1, 1)); + QCOMPARE(shown.caret(), 13); + // Clicks go through a flipped layer's mirror, as it shows. + QVERIFY(shown.session.finishText()); + const QUuid id = shown.session.activeLayerID().value(); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.flipX = true; }); + shown.session.editActiveText(); + shown.click(after(shown, QStringLiteral("al"))); + QCOMPARE(shown.caret(), 2); +} + +void InlineTextPointerTests::doubleAndTripleClicksTakeWordsAndParagraphs() +{ + TextCanvas shown; + shown.type(QStringLiteral("alpha beta")); + // Qt sends a double click's second press by itself. + const auto doubleClick = [&](QPointF at) { + shown.click(at); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); + }; + // The word under the pointer, from any of its letters. + const QPointF word = after(shown, QStringLiteral("alpha be")); + doubleClick(word); + QCOMPARE(shown.selection(), (TextRange{6, 10})); + shown.release(word); + doubleClick(after(shown, QStringLiteral("alpha "), 2)); + QCOMPARE(shown.selection(), (TextRange{6, 10})); + shown.release(word); + doubleClick(after(shown, QStringLiteral("alpha"), -2)); + QCOMPARE(shown.selection(), (TextRange{0, 5})); + shown.release(word); + // Its drag goes by words, forwards and back past it. + doubleClick(word); + shown.move(after(shown, QStringLiteral("alpha bet"))); + QCOMPARE(shown.editor().anchor(), 6); + QCOMPARE(shown.caret(), 10); + shown.move(after(shown, QStringLiteral("a"))); + QCOMPARE(shown.editor().anchor(), 10); + QCOMPARE(shown.caret(), 0); + shown.move(after(shown, QStringLiteral("alpha bet"))); + QCOMPARE(shown.editor().anchor(), 6); + QCOMPARE(shown.caret(), 10); + shown.release(word); + // A third click soon after takes the paragraph and break. + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("alpha beta\r\ngamma"); + shown.session.setTextDraft(draft); + doubleClick(word); + shown.release(word); + shown.click(word); + QCOMPARE(shown.selection(), (TextRange{0, 12})); + // Fourth clicks and far third clicks are single again. + shown.click(word); + QCOMPARE(shown.selection(), (TextRange{8, 8})); + doubleClick(word); + shown.release(word); + shown.click(after(shown, QStringLiteral("al"))); + QCOMPARE(shown.selection(), (TextRange{2, 2})); +} + +void InlineTextPointerTests::aClickLandsAPreedit() +{ + TextCanvas shown; + shown.type(QStringLiteral("ab")); + shown.compose(QStringLiteral("ni")); + // Qt asks the input method to commit; else it lands. + shown.click(after(shown, QStringLiteral("a"))); + QVERIFY(!shown.editor().marked()); + QCOMPARE(shown.content(), QString("abni")); + QCOMPARE(shown.caret(), 1); + shown.key(Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(shown.content(), QString()); + // A handle's press lands it too. + shown.type(QStringLiteral("ab")); + shown.compose(QStringLiteral("ni")); + const QSizeF size = shown.editor().logicalSize(); + const QPointF right = shown.editor().boxTransform().map(QPointF(size.width(), size.height() / 2)); + shown.press(right); + QVERIFY(!shown.editor().marked()); + QCOMPARE(shown.content(), QString("abni")); + shown.release(right); +} + +void InlineTextPointerTests::anUndoneCanvasLeavesTheBoxInert() +{ + TextCanvas shown; + shown.session.cancelText(); + // Undo takes the canvas mid-drag: the box makes nothing. + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + shown.session.undo(); + shown.session.undo(); + QVERIFY(!shown.session.document()); + shown.move(QPointF(160, 130)); + const QImage blank = shown.canvas->grab().toImage(); + QCOMPARE(blank.pixel(40, 80), blank.pixel(300, 250)); + shown.release(QPointF(160, 130)); + QVERIFY(!shown.session.textDraft()); +} + +void InlineTextPointerTests::aLostReleaseDropsTheBox() +{ + TextCanvas shown; + shown.session.cancelText(); + const QImage before = shown.canvas->grab().toImage(); + // A move without the button: its release was lost. + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + QCoreApplication::processEvents(); + PaintSpy hover(*shown.canvas); + shown.hover(QPointF(200, 150)); + QTRY_VERIFY(hover.painted.contains(QPoint(40, 80))); + QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); + shown.release(QPointF(200, 150)); + QVERIFY(!shown.session.textDraft()); + // A press after a lost release starts afresh. + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + QCoreApplication::processEvents(); + PaintSpy press(*shown.canvas); + shown.press(QPointF(300, 250)); + QTRY_VERIFY(press.painted.contains(QPoint(40, 80))); + shown.release(QPointF(300, 250)); + QCOMPARE(shown.session.textDraft().value().origin, QPointF(300, 250)); + QVERIFY(!shown.session.textDraft().value().style.boxSize); + shown.session.cancelText(); + // So does a lost focus. + shown.press(QPointF(40, 50)); + shown.move(QPointF(140, 110)); + QLineEdit field(&shown.window); + field.show(); + field.setFocus(); + QTRY_VERIFY(field.hasFocus()); + shown.release(QPointF(140, 110)); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.canvas->grab().toImage().pixel(40, 80), before.pixel(40, 80)); +} + +void InlineTextPointerTests::shiftExtendsByTheLastClicksUnit() +{ + TextCanvas shown; + shown.type(QStringLiteral("alpha beta gamma")); + const auto doubleClick = [&](QPointF at) { + shown.click(at); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); + shown.release(at); + }; + // Before any click, Shift extends from the caret. + shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); + QCOMPARE(shown.editor().anchor(), 16); + QCOMPARE(shown.caret(), 13); + // After a double click, by whole words either way. + doubleClick(after(shown, QStringLiteral("al"))); + QCOMPARE(shown.selection(), (TextRange{0, 5})); + shown.click(after(shown, QStringLiteral("alpha be")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 10})); + shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 16})); + doubleClick(after(shown, QStringLiteral("alpha be"))); + shown.click(after(shown, QStringLiteral("al")), Qt::ShiftModifier); + QCOMPARE(shown.editor().anchor(), 10); + QCOMPARE(shown.caret(), 0); + shown.press(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{6, 16})); + shown.move(after(shown, QStringLiteral("al")), Qt::ShiftModifier); + QCOMPARE(shown.editor().anchor(), 10); + QCOMPARE(shown.caret(), 0); + shown.release(after(shown, QStringLiteral("al")), Qt::ShiftModifier); + // A key's selection brings characters back. + shown.key(Qt::Key_Right, Qt::ShiftModifier); + shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{10, 13})); + doubleClick(after(shown, QStringLiteral("alpha be"))); + shown.click(after(shown, QStringLiteral("al")), Qt::ShiftModifier); + shown.key(Qt::Key_Left); + shown.click(after(shown, QStringLiteral("alpha beta ga")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 13})); + // So does other text, or a style from anywhere. + doubleClick(after(shown, QStringLiteral("al"))); + TextDraft draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("omega beta gamma"); + shown.session.setTextDraft(draft); + shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 8})); + doubleClick(after(shown, QStringLiteral("om"))); + shown.key(Qt::Key_Right, Qt::AltModifier); + shown.key(Qt::Key_Left, Qt::AltModifier); + shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 8})); + doubleClick(after(shown, QStringLiteral("om"))); + shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 60; }); + shown.click(after(shown, QStringLiteral("omega be")), Qt::ShiftModifier); + QCOMPARE(shown.selection(), (TextRange{0, 8})); + // A style set mid-drag ends the drag; so does typing. + shown.press(after(shown, QStringLiteral("omega b"))); + shown.session.changeTextStyle([](LayerTextStyle &style) { style.fontSize = 72; }); + shown.move(after(shown, QStringLiteral("om"))); + QCOMPARE(shown.selection(), (TextRange{7, 7})); + shown.release(after(shown, QStringLiteral("om"))); + shown.press(after(shown, QStringLiteral("om"))); + shown.type(QStringLiteral("X")); + shown.move(after(shown, QStringLiteral("omega b"))); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + shown.release(after(shown, QStringLiteral("omega b"))); + // Even the same letter typed over the letter selected. + shown.press(after(shown, QStringLiteral("om"))); + shown.move(after(shown, QStringLiteral("omX"))); + QCOMPARE(shown.selection(), (TextRange{2, 3})); + shown.type(QStringLiteral("X")); + shown.move(after(shown, QStringLiteral("omXeg"))); + QCOMPARE(shown.selection(), (TextRange{3, 3})); + shown.release(after(shown, QStringLiteral("omXeg"))); +} + +void InlineTextPointerTests::blankSpaceAndEmptyLinesTakeClicks() +{ + TextCanvas shown; + shown.session.cancelText(); + shown.drag(QPointF(20, 20), QPointF(380, 280)); + TextDraft draft = shown.session.textDraft().value(); + draft.style.fontSize = 24; + draft.style.content = QStringLiteral("abc\ndef"); + shown.session.setTextDraft(draft); + // Below the lines is the end; above them, the start. + shown.click(after(shown, QString(), 1, 2)); + QCOMPARE(shown.caret(), 7); + shown.press(after(shown, QStringLiteral("d"), 1, 1)); + shown.move(after(shown, QStringLiteral("d"), 1, -1)); + QCOMPARE(shown.editor().anchor(), 5); + QCOMPARE(shown.caret(), 0); + shown.release(after(shown, QStringLiteral("d"), 1, -1)); + // An empty last line has no word or paragraph. + draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("abc\n\ndef\n"); + shown.session.setTextDraft(draft); + const auto clicks = [&](QPointF at, int count) { + shown.click(at); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, at.toPoint()); + shown.release(at); + if (count == 3) + shown.click(at); + }; + clicks(after(shown, QString(), 1, 3), 3); + QCOMPARE(shown.selection(), (TextRange{9, 9})); + clicks(after(shown, QString(), 1, 3), 2); + QCOMPARE(shown.selection(), (TextRange{9, 9})); + clicks(after(shown, QStringLiteral("d"), 1, 2), 2); + QCOMPARE(shown.selection(), (TextRange{5, 8})); + // An empty line between others is its break. + clicks(after(shown, QString(), 1, 1), 3); + QCOMPARE(shown.selection(), (TextRange{4, 5})); + // Past the text, a double click takes its last word. + draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("abc def"); + shown.session.setTextDraft(draft); + clicks(after(shown, QStringLiteral("abc def"), 20), 2); + QCOMPARE(shown.selection(), (TextRange{4, 7})); + // A final line separator leaves no word either. + draft = shown.session.textDraft().value(); + draft.style.content = QStringLiteral("abc") + QChar(QChar::LineSeparator); + shown.session.setTextDraft(draft); + clicks(after(shown, QString(), 1, 1), 2); + QCOMPARE(shown.selection(), (TextRange{4, 4})); + // Below an overfull box's lines: the end of what shows. + draft = shown.session.textDraft().value(); + draft.style.boxSize = QSizeF(150, 100); + draft.style.content = QStringLiteral("aaa bbb ccc ddd eee fff"); + shown.session.setTextDraft(draft); + shown.click(after(shown, QStringLiteral("ccc"), 1, 1)); + shown.key(Qt::Key_End); + const int shows = shown.caret(); + QVERIFY(shows < 23); + shown.click(after(shown, QString(), 1, 2)); + QCOMPARE(shown.caret(), shows); +} + +QTEST_MAIN(InlineTextPointerTests) +#include "InlineTextPointerTests.moc" diff --git a/tests/InlineTextPreeditTests.cpp b/tests/InlineTextPreeditTests.cpp new file mode 100644 index 0000000..f67320b --- /dev/null +++ b/tests/InlineTextPreeditTests.cpp @@ -0,0 +1,110 @@ +#include "InlineTextDrawFixtures.h" + +// A preedit on screen: underlined, or as its method says. +class InlineTextPreeditTests : public QObject { + Q_OBJECT +private slots: + void markedTextIsUnderlined(); + void theInputMethodStylesItsPreedit(); +}; + +void InlineTextPreeditTests::markedTextIsUnderlined() +{ + DrawnText shown; + shown.session.beginText(QPointF(20, 30), true); + QInputMethodEvent composing(QStringLiteral("nn"), {}); + QApplication::sendEvent(shown.canvas, &composing); + // Below the baseline, where no letter of these reaches. + const LayerTextStyle style = shown.session.textDraft().value().style; + const QFontMetricsF metrics(TextLayout::font(style)); + const double baseline = LayerTextStyle::padding + style.lineHeight() - metrics.descent(); + // Short of the caret, which spans the whole line. + const QRect band = shown.editor().textTransform().mapRect( + QRectF(LayerTextStyle::padding, baseline + 1, metrics.horizontalAdvance(QStringLiteral("nn")) - 4, metrics.descent() - 2)).toAlignedRect(); + QVERIFY(where(shown.grab().copy(band), reddish).isValid()); + // A format that is none keeps the underline, as Qt's. + QInputMethodEvent formless(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, QVariant())}); + QApplication::sendEvent(shown.canvas, &formless); + QVERIFY(where(shown.grab().copy(band), reddish).isValid()); + QInputMethodEvent committed; + committed.setCommitString(QStringLiteral("nn")); + QApplication::sendEvent(shown.canvas, &committed); + shown.canvas->clearFocus(); + QVERIFY(!where(shown.grab().copy(band), reddish).isValid()); + // Only the preedit: the letters after it stay bare. + QTest::keyClicks(shown.canvas, QStringLiteral("xx")); + QTest::keyClick(shown.canvas, Qt::Key_Home); + QInputMethodEvent ahead(QStringLiteral("nn"), {}); + QApplication::sendEvent(shown.canvas, &ahead); + const double nnnn = metrics.horizontalAdvance(QStringLiteral("nnnn")); + const QRect after = shown.editor().textTransform().mapRect( + QRectF(LayerTextStyle::padding + nnnn + 2, baseline + 1, metrics.horizontalAdvance(QStringLiteral("xx")) - 4, metrics.descent() - 2)).toAlignedRect(); + QVERIFY(where(shown.grab().copy(band), reddish).isValid()); + QVERIFY(!where(shown.grab().copy(after), reddish).isValid()); +} + +void InlineTextPreeditTests::theInputMethodStylesItsPreedit() +{ + DrawnText shown; + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.session.beginText(QPointF(20, 30), true); + // Its clause's format replaces the underline; its caret hides. + QTextCharFormat clause; + clause.setBackground(Qt::yellow); + QInputMethodEvent composing(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, 0, 2, clause), + QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 2, 0)}); + QApplication::sendEvent(shown.canvas, &composing); + const LayerTextStyle style = shown.session.textDraft().value().style; + const QFontMetricsF metrics(TextLayout::font(style)); + const double padding = LayerTextStyle::padding, baseline = padding + style.lineHeight() - metrics.descent(); + const double width = metrics.horizontalAdvance(QStringLiteral("nn")); + const QPoint above = shown.editor().textTransform().map(QPointF(padding + width / 2, baseline - metrics.xHeight() - 4)).toPoint(); + QCOMPARE(shown.grab().pixelColor(above), QColor(Qt::yellow)); + // Formats count from the preedit, wherever it sits. + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("nn")); + QApplication::sendEvent(shown.canvas, &composing); + QVERIFY(shown.grab().pixelColor(above) != QColor(Qt::yellow)); + const QPoint later = shown.editor().textTransform().map(QPointF(padding + width * 1.5, baseline - metrics.xHeight() - 4)).toPoint(); + QCOMPARE(shown.grab().pixelColor(later), QColor(Qt::yellow)); + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 30), true); + QApplication::sendEvent(shown.canvas, &composing); + const QRect band = shown.editor().textTransform().mapRect(QRectF(padding, baseline + 1, width - 4, metrics.descent() - 2)).toAlignedRect(); + QVERIFY(!where(shown.grab().copy(band), reddish).isValid()); + const QPoint caret = shown.editor().textTransform().map(QPointF(padding + width + 0.5, padding + style.lineHeight() / 2)).toPoint(); + QVERIFY(shown.editor().caretShown()); + QVERIFY(!reddish(shown.grab().pixelColor(caret))); + // A caret colour is its own; no format, the underline. + QInputMethodEvent coloured(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 2, 1, QColor(Qt::blue))}); + QApplication::sendEvent(shown.canvas, &coloured); + const QColor blue = shown.grab().pixelColor(caret); + QVERIFY2(blue.blue() > 110 && blue.red() < 90 && blue.green() < 90, qPrintable(blue.name())); + QVERIFY(where(shown.grab().copy(band), reddish).isValid()); + // The input method's selection shows beside its preedit. + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("xx")); + QInputMethodEvent selecting(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 0, 2, QVariant())}); + QApplication::sendEvent(shown.canvas, &selecting); + QCOMPARE(shown.session.textDraft().value().style.content, QString("xxnn")); + const QPalette::ColorGroup group = shown.canvas->hasFocus() ? QPalette::Active : QPalette::Inactive; + const QPoint first = shown.editor().textTransform().map(QPointF(padding + 3, padding + 3)).toPoint(); + QCOMPARE(shown.grab().pixelColor(first), shown.canvas->palette().color(group, QPalette::Highlight)); + // Past the preedit, it sits after the preedit's letters. + shown.session.cancelText(); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("xx")); + QInputMethodEvent ahead(QStringLiteral("nn"), {QInputMethodEvent::Attribute(QInputMethodEvent::Selection, 2, -2, QVariant())}); + QApplication::sendEvent(shown.canvas, &ahead); + QCOMPARE(shown.session.textDraft().value().style.content, QString("nnxx")); + const double nn = metrics.horizontalAdvance(QStringLiteral("nn")); + const QPoint x = shown.editor().textTransform().map(QPointF(padding + nn + 3, padding + 3)).toPoint(); + QCOMPARE(shown.grab().pixelColor(x), shown.canvas->palette().color(group, QPalette::Highlight)); + QVERIFY(shown.grab().pixelColor(first) != shown.canvas->palette().color(group, QPalette::Highlight)); +} + +QTEST_MAIN(InlineTextPreeditTests) +#include "InlineTextPreeditTests.moc" diff --git a/tests/JPEGExportSheetTests.cpp b/tests/JPEGExportSheetTests.cpp new file mode 100644 index 0000000..445ef71 --- /dev/null +++ b/tests/JPEGExportSheetTests.cpp @@ -0,0 +1,259 @@ +#include "UI/ByteCounts.h" +#include "UI/ColorPaletteControls.h" +#include "UI/JPEGExportSheet.h" +#include <QColorDialog> +#include <QLabel> +#include <QProgressBar> +#include <QPushButton> +#include <QSettings> +#include <QSlider> +#include <QStandardPaths> +#include <QTimer> +#include <QtTest> + +// Swift's JPEGExportSheet: the preview waits, encodes aside, then exports. +class JPEGExportSheetTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void theSheetShowsTheRasterAndWaitsForItsPreview(); + void aChangeSupersedesThePreview(); + void aSupersededEncodingNeverLands(); + void theMatteComesFromTheColorDialog(); + void exportKeepsTheQualityAndCancelDoesNot(); + void theSavedQualityIsReadAsSwiftReadsIt(); + void anEncodingThatFailsIsShown(); +}; + +namespace { +// Clear on the left, opaque red on the right. +ExportRaster halfRed(int width = 64, int height = 32) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + for (int y = 0; y < height; ++y) + for (int x = width / 2; x < width; ++x) + image.setPixel(x, y, qRgba(255, 0, 0, 255)); + return {image, 144}; +} + +struct Sheet { + std::optional<std::optional<QByteArray>> answer; + JPEGExportSheet sheet; + explicit Sheet(ExportRaster raster = halfRed()) + : sheet(std::move(raster), [this](std::optional<QByteArray> data) { answer = std::move(data); }) + { + sheet.show(); + } + template <typename Widget> Widget &find(const char *name) + { + Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; + } + bool ready() { return find<QPushButton>("jpegExport").isEnabled(); } + QString note() { return find<QLabel>("jpegNote").text(); } + QColor previewAt(QPoint point) { return find<QWidget>("jpegPreview").grab().toImage().pixelColor(point); } +}; + +struct PaintCount : QObject { + int count = 0; + explicit PaintCount(QWidget &widget) { widget.installEventFilter(this); } + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +QStringList labels(const QWidget &widget) +{ + QStringList texts; + for (const QLabel *label : widget.findChildren<QLabel *>()) + texts << label->text(); + return texts; +} +} + +void JPEGExportSheetTests::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + QSettings().clear(); +} + +void JPEGExportSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() +{ + Sheet shown(halfRed(1200, 800)); + const QStringList texts = labels(shown.sheet); + QVERIFY(texts.contains("Export JPEG")); + QVERIFY(texts.contains("1,200 × 800 px · sRGB")); + QVERIFY(texts.contains("Background for transparency")); + QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("85%")); + QCOMPARE(shown.find<QSlider>("jpegQuality").value(), 85); + QCOMPARE(shown.find<QWidget>("jpegPreview").size(), QSize(560, 330)); + // Swift's task sleeps 200 ms before it encodes. + auto &wait = shown.find<QTimer>("jpegWait"); + QVERIFY(wait.isActive() && wait.isSingleShot()); + QCOMPARE(wait.interval(), 200); + QCOMPARE(wait.timerType(), Qt::PreciseTimer); + QVERIFY(!shown.ready()); + QCOMPARE(shown.note(), QString("Updating preview…")); + QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); + QVERIFY(!shown.find<QLabel>("jpegBytes").isVisible() && !shown.find<QLabel>("jpegError").isVisible()); + // The material plate behind the spinner, over the dark gray. + QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); + const QColor window = shown.sheet.palette().color(QPalette::Window), plate = shown.previewAt({252, 165}); + QVERIFY(qAbs(plate.red() - qRound(window.red() * 0.85 + 31 * 0.15)) <= 1); + QVERIFY(qAbs(plate.blue() - qRound(window.blue() * 0.85 + 31 * 0.15)) <= 1); + QTRY_VERIFY(shown.ready()); + const JPEGResult expected = ImageExporter::jpeg(halfRed(1200, 800), {}); + QCOMPARE(shown.find<QLabel>("jpegBytes").text(), ByteCounts::file(expected.data.size())); + QVERIFY(shown.find<QLabel>("jpegBytes").isVisible()); + QCOMPARE(shown.note(), QString("· encoded preview, fitted to window")); + QVERIFY(!shown.find<QProgressBar>("jpegSpinner").isVisible()); + // Fitted to 495 × 330 and centred: white, then red. + QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); + QCOMPARE(shown.previewAt({10, 165}), QColor(31, 31, 31)); + QVERIFY(qAbs(shown.previewAt({100, 165}).red() - 255) < 4 && qAbs(shown.previewAt({100, 165}).blue() - 255) < 4); + const QColor red = shown.previewAt({460, 165}); + QVERIFY(red.red() > 240 && red.green() < 16 && red.blue() < 16); +} + +void JPEGExportSheetTests::aChangeSupersedesThePreview() +{ + Sheet shown; + QTRY_VERIFY(shown.ready()); + auto &quality = shown.find<QSlider>("jpegQuality"); + // Each change waits anew; the stale preview stays beneath. + quality.setValue(10); + QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("10%")); + QVERIFY(!shown.ready()); + QCOMPARE(shown.note(), QString("Updating preview…")); + QVERIFY(shown.find<QTimer>("jpegWait").isActive()); + QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); + quality.setValue(100); + QTRY_VERIFY(shown.ready()); + QCOMPARE(shown.find<QLabel>("jpegBytes").text(), ByteCounts::file(ImageExporter::jpeg(halfRed(), {.quality = 1}).data.size())); + shown.find<QPushButton>("jpegExport").click(); + QCOMPARE(shown.answer.value().value(), ImageExporter::jpeg(halfRed(), {.quality = 1}).data); +} + +// Slow to encode: the change comes while it runs. +void JPEGExportSheetTests::aSupersededEncodingNeverLands() +{ + QImage noisy(6000, 4000, QImage::Format_RGBA8888_Premultiplied); + quint32 seed = 1; + for (int y = 0; y < noisy.height(); ++y) { + auto *row = reinterpret_cast<quint32 *>(noisy.scanLine(y)); + for (int x = 0; x < noisy.width(); ++x) + row[x] = (seed = seed * 1664525u + 1013904223u) | 0xff000000u; + } + Sheet shown({noisy, 72}); + QTRY_VERIFY(!shown.find<QTimer>("jpegWait").isActive()); + shown.find<QSlider>("jpegQuality").setValue(50); + // Only the newest request's preview may ever show. + bool stale = false; + while (!shown.ready()) { + stale = stale || shown.previewAt({100, 165}) != QColor(31, 31, 31); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + QVERIFY(!stale); + QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("50%")); +} + +void JPEGExportSheetTests::theMatteComesFromTheColorDialog() +{ + QSettings().remove(JPEGExportSheet::qualityKey); + Sheet shown; + QTRY_VERIFY(shown.ready()); + shown.find<SwatchButton>("jpegMatte").click(); + auto *dialog = shown.sheet.findChild<QColorDialog *>(); + QVERIFY(dialog && dialog->isVisible()); + QVERIFY(!dialog->testOption(QColorDialog::ShowAlphaChannel)); + QCOMPARE(dialog->currentColor(), QColor(Qt::white)); + // The same colour asks for nothing. + dialog->setCurrentColor(Qt::white); + dialog->accept(); + QVERIFY(shown.ready() && !shown.find<QTimer>("jpegWait").isActive()); + shown.find<SwatchButton>("jpegMatte").click(); + dialog = shown.sheet.findChild<QColorDialog *>(); + dialog->setCurrentColor(QColor(0, 0, 255)); + // The well repaints with its colour. + PaintCount painted(shown.find<SwatchButton>("jpegMatte")); + dialog->accept(); + QTRY_VERIFY(painted.count > 0); + QVERIFY(!shown.ready()); + QCOMPARE(shown.find<SwatchButton>("jpegMatte").grab().toImage().pixelColor(18, 9), QColor(0, 0, 255)); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("jpegExport").click(); + const QImage written = QImage::fromData(shown.answer.value().value(), "jpeg"); + const QColor left = written.pixelColor(8, 16), right = written.pixelColor(56, 16); + QVERIFY(left.blue() > 240 && left.red() < 16 && left.green() < 16); + QVERIFY(right.red() > 240 && right.blue() < 16); + QCOMPARE(shown.answer.value().value(), ImageExporter::jpeg(halfRed(), {.red = 0, .green = 0, .blue = 1}).data); +} + +void JPEGExportSheetTests::exportKeepsTheQualityAndCancelDoesNot() +{ + QSettings().remove(JPEGExportSheet::qualityKey); + { + Sheet shown; + shown.find<QSlider>("jpegQuality").setValue(40); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("jpegCancel").click(); + QVERIFY(!shown.answer.value().has_value()); + QVERIFY(!QSettings().contains(JPEGExportSheet::qualityKey)); + } + { + Sheet shown; + QCOMPARE(shown.find<QSlider>("jpegQuality").value(), 85); + shown.find<QSlider>("jpegQuality").setValue(40); + QTRY_VERIFY(shown.ready()); + shown.find<QPushButton>("jpegExport").click(); + QCOMPARE(QSettings().value(JPEGExportSheet::qualityKey).toDouble(), 0.4); + } + Sheet next; + QCOMPARE(next.find<QSlider>("jpegQuality").value(), 40); + QCOMPARE(next.find<QLabel>("jpegPercent").text(), QString("40%")); + // Return reaches Export once it is ready. + QTRY_VERIFY(next.ready()); + QVERIFY(next.find<QPushButton>("jpegExport").isDefault()); + QCOMPARE(next.find<QPushButton>("jpegExport").text(), QString("Export…")); +} + +void JPEGExportSheetTests::theSavedQualityIsReadAsSwiftReadsIt() +{ + const auto started = [](const QVariant &saved) { + QSettings().setValue(JPEGExportSheet::qualityKey, saved); + Sheet shown; + return shown.find<QLabel>("jpegPercent").text(); + }; + QCOMPARE(started(0.333), QString("33%")); + QCOMPARE(started(0.875), QString("88%")); + QCOMPARE(started(7), QString("100%")); + QCOMPARE(started(-2), QString("0%")); + QCOMPARE(started(QStringLiteral("high")), QString("85%")); + QCOMPARE(started(std::nan("")), QString("85%")); + QCOMPARE(started(qInf()), QString("85%")); + QSettings().remove(JPEGExportSheet::qualityKey); +} + +void JPEGExportSheetTests::anEncodingThatFailsIsShown() +{ + // JPEG holds at most 65,500 pixels a side. + Sheet shown(halfRed(70'000, 1)); + auto &error = shown.find<QLabel>("jpegError"); + QTRY_VERIFY(error.isVisible()); + QCOMPARE(error.text(), QString::fromUtf8(ExportError(ExportError::Kind::encode).what())); + QCOMPARE(error.foregroundRole(), QPalette::BrightText); + QVERIFY(!shown.ready() && !shown.find<QLabel>("jpegNote").isVisible() && !shown.find<QProgressBar>("jpegSpinner").isVisible()); + // A change clears the error while it waits. + shown.find<QSlider>("jpegQuality").setValue(50); + QVERIFY(!error.isVisible()); + QCOMPARE(shown.note(), QString("Updating preview…")); + QTRY_VERIFY(error.isVisible()); +} + +QTEST_MAIN(JPEGExportSheetTests) +#include "JPEGExportSheetTests.moc" diff --git a/tests/JPEGExportTests.cpp b/tests/JPEGExportTests.cpp new file mode 100644 index 0000000..66e164b --- /dev/null +++ b/tests/JPEGExportTests.cpp @@ -0,0 +1,177 @@ +#include "IO/ImageExporter.h" +#include "AddressSpaceLimit.h" +#include "RenderFixtures.h" +#include <QBuffer> +#include <QImageReader> +#include <QImageWriter> +#include <QColorSpace> +#include <QTemporaryDir> +#include <QtTest> + +class JPEGExportTests : public QObject { + Q_OBJECT +private slots: + void transparencyUsesChosenMatteAndProducesOpaqueSRGB_data(); + void transparencyUsesChosenMatteAndProducesOpaqueSRGB(); + void qualityChangesBytesAndDecodedPixels(); + void thePreviewIsDecodedFromTheFileAndKeptSmall(); + void pixelsLieOverTheMatteOnce(); + void whatCannotBeEncodedDecodedOrAllocatedThrows(); + void cancellationStopsAtSwiftsThreePoints(); + void anEncodingWithoutRoomIsARenderError(); +}; + +void JPEGExportTests::transparencyUsesChosenMatteAndProducesOpaqueSRGB_data() +{ + QTest::addColumn<JPEGOptions>("options"); + QTest::newRow("the default white") << JPEGOptions{}; + QTest::newRow("blue at full quality") << JPEGOptions{.quality = 1, .red = 0, .green = 0, .blue = 1}; + QTest::newRow("a mixed matte") << JPEGOptions{.quality = 1, .red = 0.8, .green = 0.4, .blue = 0.2}; +} + +void JPEGExportTests::transparencyUsesChosenMatteAndProducesOpaqueSRGB() +{ + QFETCH(JPEGOptions, options); + const ProjectSnapshot blank{.manifest = {.documentID = QUuid::createUuid(), .width = 20, .height = 12, .activeLayerID = std::nullopt, .layers = {}}, .images = {}}; + const JPEGResult result = ImageExporter::jpeg(ImageExporter::render(blank), options); + QVERIFY(result.data.startsWith("\xff\xd8\xff")); + const QImage image = QImage::fromData(result.data, "jpeg"); + QCOMPARE(image.size(), QSize(20, 12)); + QCOMPARE(image.colorSpace(), QColorSpace(QColorSpace::SRgb)); + QVERIFY(!image.hasAlphaChannel()); + const QColor pixel = image.pixelColor(0, 0); + QVERIFY(std::abs(pixel.redF() - options.red) < 0.03); + QVERIFY(std::abs(pixel.greenF() - options.green) < 0.03); + QVERIFY(std::abs(pixel.blueF() - options.blue) < 0.03); +} + +void JPEGExportTests::qualityChangesBytesAndDecodedPixels() +{ + QImage busy = BrushRaster::context(128, 128, false); + for (int y = 0; y < 128; ++y) { + for (int x = 0; x < 128; ++x) + busy.setPixel(x, y, qRgba((x * 37 + y * 17) % 256, (x * 11 + y * 53) % 256, (x * 79 + y * 7) % 256, 255)); + } + const ExportRaster raster{busy}; + const JPEGResult low = ImageExporter::jpeg(raster, {.quality = 0.1}), high = ImageExporter::jpeg(raster, {.quality = 1}); + QVERIFY(low.data.size() < high.data.size()); + // Quality is clamped to what the format knows. + QCOMPARE(ImageExporter::jpeg(raster, {.quality = 7}).data, high.data); + QCOMPARE(ImageExporter::jpeg(raster, {.quality = -3}).data, ImageExporter::jpeg(raster, {.quality = 0}).data); + const QImage lowPixels = QImage::fromData(low.data), highPixels = QImage::fromData(high.data); + double difference = 0; + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) + difference += std::abs(lowPixels.pixelColor(x, y).redF() - highPixels.pixelColor(x, y).redF()); + } + QVERIFY(difference > 1); + // Quality 1 is the writer's 100, 0.1 its 10. + const auto byTheWriter = [&](int quality) { + QImage opaque = busy.convertToFormat(QImage::Format_RGB888); + opaque.setDotsPerMeterX(2835); + opaque.setDotsPerMeterY(2835); + opaque.setColorSpace(QColorSpace::SRgb); + QByteArray data; + QBuffer buffer(&data); + QImageWriter writer(&buffer, "jpeg"); + writer.setQuality(quality); + writer.write(opaque); + return data; + }; + QCOMPARE(high.data, byTheWriter(100)); + QCOMPARE(low.data, byTheWriter(10)); + QTemporaryDir directory; + const QString path = directory.filePath("export.jpg"); + ImageExporter::write(high.data, path); + QFile written(path); + QVERIFY(written.open(QIODevice::ReadOnly)); + QCOMPARE(written.readAll(), high.data); +} + +void JPEGExportTests::thePreviewIsDecodedFromTheFileAndKeptSmall() +{ + const JPEGResult small = ImageExporter::jpeg({noise(64, 48, 1)}, {.quality = 0.1}); + QCOMPARE(small.preview.size(), QSize(64, 48)); + // Compression shows in the preview: it is not the source. + QCOMPARE(small.preview.convertToFormat(QImage::Format_RGB888), QImage::fromData(small.data).convertToFormat(QImage::Format_RGB888)); + QVERIFY(small.preview.convertToFormat(QImage::Format_RGB888) != noise(64, 48, 1).convertToFormat(QImage::Format_RGB888)); + const JPEGResult wide = ImageExporter::jpeg({solid(2400, 600, qRgba(10, 200, 30, 255))}, {}); + QCOMPARE(wide.preview.size(), QSize(1000, 250)); + QCOMPARE(QImage::fromData(wide.data).size(), QSize(2400, 600)); + const JPEGResult tall = ImageExporter::jpeg({solid(500, 2000, qRgba(10, 200, 30, 255))}, {}); + QCOMPARE(tall.preview.size(), QSize(250, 1000)); + QCOMPARE(ImageExporter::jpeg({solid(1000, 1000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1000)); + // Thin images keep a pixel across. + QCOMPARE(ImageExporter::jpeg({solid(2400, 1, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1)); + QCOMPARE(ImageExporter::jpeg({solid(1, 2400, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1, 1000)); +} + +void JPEGExportTests::pixelsLieOverTheMatteOnce() +{ + // Half-transparent red over a blue matte. + const JPEGResult result = ImageExporter::jpeg({solid(16, 16, qRgba(128, 0, 0, 128))}, {.quality = 1, .red = 0, .green = 0, .blue = 1}); + const QColor pixel = QImage::fromData(result.data).pixelColor(8, 8); + QVERIFY2(std::abs(pixel.red() - 128) <= 3 && pixel.green() <= 3 && std::abs(pixel.blue() - 127) <= 3, qPrintable(pixel.name())); +} + +void JPEGExportTests::whatCannotBeEncodedDecodedOrAllocatedThrows() +{ + const auto kind = [](const ExportRaster &raster) -> std::optional<ExportError::Kind> { + try { + ImageExporter::jpeg(raster, {}); + } catch (const ExportError &error) { + return error.kind; + } + return std::nullopt; + }; + // JPEG ends at 65,535 pixels a side. + QImage endless(70'000, 1, QImage::Format_RGBA8888_Premultiplied); + endless.fill(0); + QCOMPARE(kind({endless}), std::optional(ExportError::Kind::encode)); + // A preview the reader may not allocate. + const int allowed = QImageReader::allocationLimit(); + QImageReader::setAllocationLimit(1); + const std::optional<ExportError::Kind> unread = kind({solid(1000, 1000, qRgba(1, 2, 3, 255))}); + QImageReader::setAllocationLimit(allowed); + QCOMPARE(unread, std::optional(ExportError::Kind::encode)); + QCOMPARE(kind({solid(1000, 1000, qRgba(1, 2, 3, 255))}), std::nullopt); + // No room for the flattened copy: 192 MB. + const QImage large = solid(8192, 8192, qRgba(1, 2, 3, 255)); + std::optional<AddressSpaceLimit> limit(std::in_place, 100 * 1024 * 1024); + const std::optional<ExportError::Kind> unflattened = kind({large}); + limit.reset(); + QCOMPARE(unflattened, std::optional(ExportError::Kind::render)); +} + +// The flatten fits; the growing file does not. +void JPEGExportTests::anEncodingWithoutRoomIsARenderError() +{ + const ExportRaster raster{noise(6000, 4000, 7)}; + std::optional<ExportError::Kind> failed; + { + const AddressSpaceLimit limit(170 * 1024 * 1024); + try { + ImageExporter::jpeg(raster, {.quality = 1}); + } catch (const ExportError &error) { + failed = error.kind; + } + } + QCOMPARE(failed, std::optional(ExportError::Kind::render)); +} + +// Before the flatten, before the encoding, before the preview. +void JPEGExportTests::cancellationStopsAtSwiftsThreePoints() +{ + const ExportRaster raster{noise(64, 48, 1)}; + int asked = 0; + QCOMPARE(ImageExporter::jpeg(raster, {}, [&asked] { return ++asked < 0; }).data, ImageExporter::jpeg(raster, {}).data); + QCOMPARE(asked, 3); + for (int point = 1; point <= 3; ++point) { + asked = 0; + QVERIFY_THROWS_EXCEPTION(CancellationError, ImageExporter::jpeg(raster, {}, [&asked, point] { return ++asked == point; })); + QCOMPARE(asked, point); + } +} + +QTEST_MAIN(JPEGExportTests) +#include "JPEGExportTests.moc" diff --git a/tests/LassoControlsTests.cpp b/tests/LassoControlsTests.cpp new file mode 100644 index 0000000..9f216c7 --- /dev/null +++ b/tests/LassoControlsTests.cpp @@ -0,0 +1,275 @@ +#include "SelectionFixtures.h" +#include "UI/LassoControls.h" +#include <QtTest> + +// The selection tools' bar: kinds, mode, edges, amounts, deselect. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +struct Bar { + std::unique_ptr<EditorSession> session = selectionSession(100, 100); + LassoControls controls{*session}; + Bar() + { + controls.show(); + if (!QTest::qWaitForWindowExposed(&controls)) + throw std::runtime_error("the bar never showed"); + } + QToolButton &button(const char *name) { return find<QToolButton>(controls, name); } +}; +} + +class LassoControlsTests : public QObject { + Q_OBJECT +private slots: + void theBarShowsTheToolsKindsAndSwitchesThem(); + void theModeFollowsHeldKeysAndAnOutlineAndClicksSetTheChoice(); + void expandContractAndTheirAmountsNeedASelection(); + void deselectTheEmptyWordsAndAntialiasFollowTheSession(); + void theWandsControlsWriteItsSettings(); +}; + +void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem() +{ + Bar bar; + EditorSession &session = *bar.session; + QCOMPARE(bar.controls.title->text(), QString("Lasso")); + QVERIFY(bar.button("lassoFreehand").isVisible() && bar.button("lassoPolygonal").isVisible()); + QVERIFY(!bar.button("marqueeRectangle").isVisible() && !bar.button("marqueeEllipse").isVisible()); + QVERIFY(bar.button("lassoFreehand").isChecked() && !bar.button("lassoPolygonal").isChecked()); + QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); + // A choice ends an outline in progress. + session.beginLasso(QPointF(1, 1), SelectionMode::replace); + QTest::mouseClick(&bar.button("lassoPolygonal"), Qt::LeftButton); + QVERIFY(session.lassoKind() == LassoKind::polygonal && !session.lassoDraft().has_value()); + QVERIFY(bar.button("lassoPolygonal").isChecked()); + session.selectTool(NavigationTool::marquee); + QCOMPARE(bar.controls.title->text(), QString("Marquee")); + QVERIFY(bar.button("marqueeRectangle").isVisible() && bar.button("marqueeRectangle").isChecked()); + QVERIFY(!bar.button("lassoFreehand").isVisible()); + // Rectangles snap to whole pixels: no smoothing to offer. + QVERIFY(!find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); + QTest::mouseClick(&bar.button("marqueeEllipse"), Qt::LeftButton); + QVERIFY(session.marqueeKind() == LassoKind::ellipse); + session.setMarqueeKind(LassoKind::rectangle); + QVERIFY(bar.button("marqueeRectangle").isChecked() && !bar.button("marqueeEllipse").isChecked()); + session.setMarqueeKind(LassoKind::ellipse); + QVERIFY(bar.button("marqueeEllipse").isChecked() && !bar.button("marqueeRectangle").isChecked()); + QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); + session.selectTool(NavigationTool::wand); + QCOMPARE(bar.controls.title->text(), QString("Magic Wand")); + QVERIFY(!bar.button("marqueeRectangle").isVisible() && !bar.button("lassoFreehand").isVisible()); + QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible()); + QCOMPARE(bar.controls.height(), 42); +} + +void LassoControlsTests::theModeFollowsHeldKeysAndAnOutlineAndClicksSetTheChoice() +{ + Bar bar; + EditorSession &session = *bar.session; + QVERIFY(bar.button("selectionModeNew").isChecked()); + session.updateHeldSelectionKeys(true, false); + QVERIFY(bar.button("selectionModeAdd").isChecked() && !bar.button("selectionModeNew").isChecked()); + session.updateHeldSelectionKeys(false, true); + QVERIFY(bar.button("selectionModeSubtract").isChecked()); + session.updateHeldSelectionKeys(false, false); + QVERIFY(bar.button("selectionModeNew").isChecked()); + // An outline keeps its starting mode while it lasts. + session.beginLasso(QPointF(1, 1), SelectionMode::subtract); + session.updateHeldSelectionKeys(true, false); + QVERIFY(bar.button("selectionModeSubtract").isChecked()); + session.cancelLasso(); + session.updateHeldSelectionKeys(false, false); + QTest::mouseClick(&bar.button("selectionModeAdd"), Qt::LeftButton); + QVERIFY(session.selectionModeChoice() == SelectionMode::add && bar.button("selectionModeAdd").isChecked()); + QTest::mouseClick(&bar.button("selectionModeNew"), Qt::LeftButton); + QVERIFY(session.selectionModeChoice() == SelectionMode::replace); +} + +void LassoControlsTests::expandContractAndTheirAmountsNeedASelection() +{ + Bar bar; + EditorSession &session = *bar.session; + auto &expand = find<QPushButton>(bar.controls, "expandSelection"); + auto &contract = find<QPushButton>(bar.controls, "contractSelection"); + auto &amount = find<QLineEdit>(bar.controls, "selectionExpandAmount"); + auto &shrink = find<QLineEdit>(bar.controls, "selectionContractAmount"); + QVERIFY(!expand.isEnabled() && !contract.isEnabled() && !amount.isEnabled() && !shrink.isEnabled()); + QCOMPARE(amount.text(), QString("1")); + session.applySelection(rectPath(QRectF(40, 40, 20, 20)), SelectionMode::replace, "Select"); + QVERIFY(expand.isEnabled() && contract.isEnabled() && amount.isEnabled() && shrink.isEnabled()); + // Typed amounts apply at once, 1 to 500; arrows step. + amount.setFocus(); + QTRY_VERIFY(amount.hasFocus()); + amount.selectAll(); + QTest::keyClicks(&amount, "5"); + QCOMPARE(session.selectionExpandAmount(), 5); + QTest::keyClick(&amount, Qt::Key_Up); + QCOMPARE(session.selectionExpandAmount(), 6); + QCOMPARE(amount.text(), QString("6")); + QTest::keyClick(&amount, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session.selectionExpandAmount(), 16); + amount.selectAll(); + QTest::keyClicks(&amount, "600"); + QCOMPARE(session.selectionExpandAmount(), 500); + QTest::keyClicks(&amount, "a"); + QCOMPARE(amount.text(), QString("600")); + // Every digit is taken; the bounds clamp what they name. + amount.selectAll(); + QTest::keyClicks(&amount, "1000"); + QCOMPARE(amount.text(), QString("1000")); + QCOMPARE(session.selectionExpandAmount(), 500); + QTest::keyClick(&amount, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.selectionExpandAmount(), 490); + QCOMPARE(amount.text(), QString("490")); + // The menu bar borrows the focus: the typing stays. + amount.selectAll(); + QTest::keyClicks(&amount, "12"); + QVERIFY(amount.isUndoAvailable()); + contract.setFocus(Qt::MenuBarFocusReason); + QVERIFY(!amount.hasFocus()); + QCOMPARE(amount.text(), QString("12")); + QVERIFY(amount.isUndoAvailable()); + session.setSelectionExpandAmount(7); + QCOMPARE(amount.text(), QString("12")); + amount.setFocus(); + QTRY_VERIFY(amount.hasFocus()); + QTest::keyClicks(&amount, "3"); + QCOMPARE(session.selectionExpandAmount(), 123); + contract.setFocus(Qt::OtherFocusReason); + QCOMPARE(amount.text(), QString("123")); + // A popup borrows it the same way. + amount.setFocus(); + QTRY_VERIFY(amount.hasFocus()); + amount.selectAll(); + QTest::keyClicks(&amount, "45"); + contract.setFocus(Qt::PopupFocusReason); + session.setSelectionExpandAmount(9); + QVERIFY(amount.isUndoAvailable()); + QCOMPARE(amount.text(), QString("45")); + amount.setFocus(); + QTRY_VERIFY(amount.hasFocus()); + // Return shows the held amount and hands over the keys. + amount.selectAll(); + QTest::keyClicks(&amount, "0"); + QCOMPARE(session.selectionExpandAmount(), 1); + const int focus = session.canvasFocusRequest(); + QTest::keyClick(&amount, Qt::Key_Return); + QVERIFY(!amount.hasFocus()); + QCOMPARE(session.canvasFocusRequest(), focus + 1); + QCOMPARE(amount.text(), QString("1")); + session.setSelectionExpandAmount(3); + QCOMPARE(amount.text(), QString("3")); + QTest::mouseClick(&expand, Qt::LeftButton); + QCOMPARE(session.history.undoName(), QString("Expand Selection")); + QCOMPARE(bounds(session), QRectF(37, 37, 26, 26)); + shrink.setFocus(); + QTRY_VERIFY(shrink.hasFocus()); + QTest::keyClick(&shrink, Qt::Key_Down); + QCOMPARE(session.selectionContractAmount(), 1); + shrink.selectAll(); + QTest::keyClicks(&shrink, "2"); + QTest::keyClick(&shrink, Qt::Key_Escape); + QTest::mouseClick(&contract, Qt::LeftButton); + QCOMPARE(session.history.undoName(), QString("Contract Selection")); + QCOMPARE(bounds(session), QRectF(39, 39, 22, 22)); + // A draft in progress rests the edges. + session.beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!expand.isEnabled() && !contract.isEnabled()); +} + +void LassoControlsTests::deselectTheEmptyWordsAndAntialiasFollowTheSession() +{ + Bar bar; + EditorSession &session = *bar.session; + auto &deselect = find<QPushButton>(bar.controls, "deselect"); + auto &empty = find<QLabel>(bar.controls, "emptySelection"); + auto &antialias = find<QCheckBox>(bar.controls, "selectionAntialiased"); + QVERIFY(!deselect.isVisible() && !empty.isVisible()); + QVERIFY(antialias.isChecked()); + QTest::mouseClick(&antialias, Qt::LeftButton); + QVERIFY(!session.selectionAntialiased() && !antialias.isChecked()); + session.setSelectionAntialiased(true); + QVERIFY(antialias.isChecked()); + session.selectAll(); + QVERIFY(deselect.isVisible() && deselect.isEnabled() && !empty.isVisible()); + session.applySelection(rectPath(QRectF(0, 0, 100, 100)), SelectionMode::subtract, "Subtract"); + QVERIFY(empty.isVisible() && deselect.isVisible()); + QTest::mouseClick(&deselect, Qt::LeftButton); + QVERIFY(!session.selection().has_value() && !deselect.isVisible()); + // Busy, or without a canvas, the whole bar waits. + session.selectAll(); + session.setIsProjectBusy(true); + QVERIFY(deselect.isVisible() && !deselect.isEnabled()); + QTRY_VERIFY(!bar.controls.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(bar.controls.isEnabled()); + session.clearProject(); + QVERIFY(!bar.controls.isEnabled()); +} + +void LassoControlsTests::theWandsControlsWriteItsSettings() +{ + Bar bar; + EditorSession &session = *bar.session; + auto &tolerance = find<QLineEdit>(bar.controls, "wandTolerance"); + auto &size = find<QComboBox>(bar.controls, "wandSampleSize"); + auto &contiguous = find<QCheckBox>(bar.controls, "wandContiguous"); + // The wand's own controls show with the wand alone. + QVERIFY(!tolerance.isVisible() && !size.isVisible() && !contiguous.isVisible()); + session.selectTool(NavigationTool::wand); + QVERIFY(tolerance.isVisible() && size.isVisible() && contiguous.isVisible()); + QVERIFY(bar.button("wandThisLayer").isChecked() && !bar.button("wandAllLayers").isChecked()); + QCOMPARE(tolerance.text(), QString("32")); + QCOMPARE(size.currentText(), QString("Point Sample")); + QCOMPARE(size.count(), 3); + QVERIFY(contiguous.isChecked()); + // Tolerance runs 0 to 255, typed or stepped. + tolerance.setFocus(); + QTRY_VERIFY(tolerance.hasFocus()); + tolerance.selectAll(); + QTest::keyClicks(&tolerance, "300"); + QCOMPARE(session.wandSettings().tolerance, 255); + // A pasted million or too many digits: the top. + tolerance.selectAll(); + tolerance.insert(QStringLiteral("1000000")); + QCOMPARE(tolerance.text(), QString("1000000")); + QCOMPARE(session.wandSettings().tolerance, 255); + WandSettings low = session.wandSettings(); + low.tolerance = 7; + session.setWandSettings(low); + QCOMPARE(session.wandSettings().tolerance, 7); + tolerance.selectAll(); + tolerance.insert(QStringLiteral("99999999999999999999")); + QCOMPARE(session.wandSettings().tolerance, 255); + QTest::keyClick(&tolerance, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.wandSettings().tolerance, 245); + tolerance.selectAll(); + QTest::keyClicks(&tolerance, "0"); + QTest::keyClick(&tolerance, Qt::Key_Down); + QCOMPARE(session.wandSettings().tolerance, 0); + QTest::keyClick(&tolerance, Qt::Key_Return); + // The other three write their fields and read them back. + size.setCurrentIndex(2); + emit size.activated(2); + QVERIFY(session.wandSettings().sampleSize == WandSampleSize::fiveByFive); + QTest::mouseClick(&bar.button("wandAllLayers"), Qt::LeftButton); + QVERIFY(session.wandSettings().sampleAllLayers); + QTest::mouseClick(&contiguous, Qt::LeftButton); + QVERIFY(!session.wandSettings().contiguous); + QCOMPARE(session.wandSettings().tolerance, 0); + WandSettings back; + session.setWandSettings(back); + QCOMPARE(tolerance.text(), QString("32")); + QCOMPARE(size.currentText(), QString("Point Sample")); + QVERIFY(bar.button("wandThisLayer").isChecked() && contiguous.isChecked()); +} + +QTEST_MAIN(LassoControlsTests) +#include "LassoControlsTests.moc" diff --git a/tests/LayerAdjustmentTests.cpp b/tests/LayerAdjustmentTests.cpp new file mode 100644 index 0000000..722c569 --- /dev/null +++ b/tests/LayerAdjustmentTests.cpp @@ -0,0 +1,436 @@ +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageResizer.h" +#include "IO/ProjectStore+Json.h" +#include "ProjectFixtures.h" +#include <QJsonArray> +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> +#include <limits> + +// Adjustment layers in the document, the manifest and the store. +namespace { +constexpr double notANumber = std::numeric_limits<double>::quiet_NaN(); + +// Hue/Saturation with a range and every optional set. +LayerAdjustment full() +{ + LayerAdjustment adjustment{.kind = AdjustmentKind::hsv, .hue = 10, .saturation = -20, .lightness = 30, .colorize = true}; + HueSaturationSettings settings(40, 15, -25, true, ColorRange::reds); + settings.invertRange = true; + settings.bands[ColorRange::greens] = HueBand{70, 100, 140, 170}; + adjustment.hsvSettings = settings; + adjustment.levels.channel = LevelsChannel::red; + adjustment.levels.ranges[1].outputWhite = 200; + adjustment.curves.channel = LevelsChannel::blue; + adjustment.curves.channels[2] = {{0, 30}, {255, 255}}; + adjustment.setExposure(ExposureSettings{.exposure = 1, .offset = 0.1, .gamma = 2}); + adjustment.setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, true}); + adjustment.setGrain(GrainSettings{.amount = 40, .size = 3, .roughness = 10, .seed = 9}); + return adjustment; +} + +bool refused(const QJsonObject &object) +{ + try { + ManifestJson::adjustment(object); + } catch (const ProjectError &) { + return true; + } + return false; +} + +// The same rules guard saving and loading. +std::optional<ProjectError::Kind> stored(const ProjectSnapshot &snapshot) +{ + QTemporaryDir root; + const std::optional<ProjectError::Kind> saving = projectError([&] { ProjectStore::save(snapshot, root.filePath("Saved.comp")); }); + ProjectStore::save(twoLayers(), root.filePath("Read.comp")); + for (const auto &[id, pixels] : snapshot.images) { + if (!pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png")) + throw std::runtime_error("cannot write a fixture image"); + } + overwrite(root.filePath("Read.comp/manifest.json"), snapshot.manifest.encoded()); + const std::optional<ProjectError::Kind> loading = projectError([&] { ProjectStore::load(root.filePath("Read.comp")); }); + if (saving != loading) + throw std::runtime_error("saving and loading judged the project apart"); + return saving; +} + +std::unique_ptr<EditorSession> oneLayer() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(4, 4); + session->insert(asset(halfRed().copy(0, 0, 4, 4), "Red")); + return session; +} + +// Swift's tests clear the request: no editor opens here. +QUuid added(EditorSession &session, AdjustmentKind kind) +{ + session.addAdjustment(kind); + session.setAdjustmentEditingID(std::nullopt); + return session.activeLayerID().value(); +} +} + +class LayerAdjustmentTests : public QObject { + Q_OBJECT +private slots: + void kindsKeepSwiftsNamesAndOrder(); + void validityFollowsSwiftsBounds(); + void theOptionalSettingsFallBackToTheirDefaults(); + void theManifestWritesSwiftsKeys(); + void decodingRefusesWhatSwiftRefuses(); + void aSeedReadsWhatQtsParserReads(); + void theStoreKeepsAdjustmentsToVersion7WithoutPixels(); + void nothingClipsToAnAdjustment(); + void addingPlacesTheLayerAboveTheActiveOne(); + void updatesTakeValidSettingsAlone(); + void adjustmentsTravelWithTheirLayers(); +}; + +void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder() +{ + const std::pair<AdjustmentKind, const char *> kinds[] = {{AdjustmentKind::hsv, "Hue/Saturation"}, {AdjustmentKind::levels, "Levels"}, + {AdjustmentKind::curves, "Curves"}, {AdjustmentKind::exposure, "Exposure"}, + {AdjustmentKind::gradientMap, "Gradient Map"}, {AdjustmentKind::grain, "Grain"}}; + QCOMPARE(allAdjustmentKinds.size(), std::size(kinds)); + for (size_t index = 0; index < std::size(kinds); ++index) { + const auto &[kind, name] = kinds[index]; + QVERIFY(allAdjustmentKinds[index] == kind); + QCOMPARE(rawValue(kind), QString(name)); + QVERIFY(adjustmentKind(QString(name)) == kind); + } + QVERIFY(!adjustmentKind(QStringLiteral("Vibrance")) && !adjustmentKind(QStringLiteral("levels")) && !adjustmentKind(QStringLiteral(" Levels"))); + // The filter panel edits four; Levels and Hue/Saturation have theirs. + QVERIFY(filterKind(AdjustmentKind::curves) == FilterKind::curves && filterKind(AdjustmentKind::exposure) == FilterKind::exposure); + QVERIFY(filterKind(AdjustmentKind::gradientMap) == FilterKind::gradientMap && filterKind(AdjustmentKind::grain) == FilterKind::grain); + QVERIFY(!filterKind(AdjustmentKind::levels) && !filterKind(AdjustmentKind::hsv)); +} + +void LayerAdjustmentTests::validityFollowsSwiftsBounds() +{ + QVERIFY(full().isValid() && LayerAdjustment{AdjustmentKind::levels}.isValid()); + // Hue within 360 either way, the others within 100. + const std::tuple<double, double, double, bool> legacy[] = { + {360, -100, 100, true}, {-360.5, 0, 0, false}, {0, 100.5, 0, false}, {0, 0, -100.5, false}, {notANumber, 0, 0, false}, {0, 0, notANumber, false}}; + for (const auto &[hue, saturation, lightness, valid] : legacy) + QCOMPARE((LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = hue, .saturation = saturation, .lightness = lightness}.isValid()), valid); + // Each range's values too, and every band's handles finite. + LayerAdjustment ranged = full(); + ranged.hsvSettings->adjustments[ColorRange::blues] = {0, 0, 101}; + QVERIFY(!ranged.isValid()); + ranged = full(); + ranged.hsvSettings->bands[ColorRange::cyans].falloffEnd = std::numeric_limits<double>::infinity(); + QVERIFY(!ranged.isValid()); + // The legacy fields count only when no range settings exist. + ranged = full(); + ranged.hsvSettings->adjustments[ColorRange::reds] = {-360, -100, -100}; + QVERIFY(ranged.isValid()); + // Levels as normalized, then every kind's own rules. + LayerAdjustment levels{AdjustmentKind::hsv}; + levels.levels.ranges[3].white = levels.levels.ranges[3].black; + QVERIFY(!levels.isValid()); + LayerAdjustment curves{AdjustmentKind::hsv}; + curves.curves.channels[1] = {{0, 0}}; + QVERIFY(!curves.isValid()); + LayerAdjustment exposure{AdjustmentKind::hsv}; + exposure.setExposure(ExposureSettings{.gamma = 0}); + QVERIFY(!exposure.isValid()); + LayerAdjustment map{AdjustmentKind::hsv}; + map.setGradientMap(GradientMapSettings{{1.5, 0, 0}, {1, 1, 1}, false}); + QVERIFY(!map.isValid()); + LayerAdjustment grain{AdjustmentKind::hsv}; + grain.setGrain(GrainSettings{.amount = 101}); + QVERIFY(!grain.isValid()); +} + +void LayerAdjustmentTests::theOptionalSettingsFallBackToTheirDefaults() +{ + LayerAdjustment adjustment{.kind = AdjustmentKind::hsv, .hue = 120, .saturation = 10, .lightness = -5, .colorize = true}; + QVERIFY(!adjustment.exposureSettings && !adjustment.gradientMapSettings && !adjustment.grainSettings); + QVERIFY(adjustment.exposure() == ExposureSettings() && adjustment.gradientMap() == GradientMapSettings() && adjustment.grain() == GrainSettings()); + // Without range settings Hue/Saturation reads the legacy four. + QVERIFY(adjustment.resolvedHSV() == HueSaturationSettings(120, 10, -5, true)); + adjustment.hsvSettings = HueSaturationSettings(30); + QVERIFY(adjustment.resolvedHSV() == HueSaturationSettings(30)); + adjustment.setExposure(ExposureSettings{.exposure = 2}); + adjustment.setGradientMap(GradientMapSettings{{0, 1, 0}, {1, 1, 1}, false}); + adjustment.setGrain(GrainSettings{.seed = 5}); + QCOMPARE(adjustment.exposureSettings.value().exposure, 2.0); + QVERIFY(adjustment.gradientMap().shadows == AdjustmentColor(0, 1, 0) && adjustment.grainSettings.value().seed == 5); +} + +void LayerAdjustmentTests::theManifestWritesSwiftsKeys() +{ + const QJsonObject object = ManifestJson::encoded(full()); + QCOMPARE(object.keys(), (QStringList{"colorize", "curves", "exposureSettings", "gradientMapSettings", "grainSettings", "hsvSettings", "hue", "kind", + "levels", "lightness", "saturation"})); + QCOMPARE(object.value("kind").toString(), QString("Hue/Saturation")); + const QJsonObject hsv = object.value("hsvSettings").toObject(); + QCOMPARE(hsv.keys(), (QStringList{"adjustments", "bands", "colorize", "invertRange", "range"})); + QCOMPARE(hsv.value("range").toString(), QString("Reds")); + // A dictionary keyed by ColorRange: key and value in turn. + const QJsonArray adjustments = hsv.value("adjustments").toArray(); + QVERIFY(adjustments.size() == 2 && adjustments.at(0).toString() == QString("Reds")); + QCOMPARE(adjustments.at(1).toObject(), (QJsonObject{{"hue", 40}, {"saturation", 15}, {"lightness", -25}})); + const QJsonArray bands = hsv.value("bands").toArray(); + QVERIFY(bands.size() == 14 && bands.at(0).toString() == QString("Master")); + QCOMPARE(bands.at(3).toObject(), (QJsonObject{{"falloffStart", 315}, {"rangeStart", 345}, {"rangeEnd", 15}, {"falloffEnd", 45}})); + const QJsonObject levels = object.value("levels").toObject(); + QVERIFY(levels.value("channel").toString() == QString("Red") && levels.value("ranges").toArray().size() == 4); + QCOMPARE(levels.value("ranges").toArray().at(1).toObject(), + (QJsonObject{{"black", 0}, {"gamma", 1}, {"white", 255}, {"outputBlack", 0}, {"outputWhite", 200}})); + const QJsonObject curves = object.value("curves").toObject(); + QVERIFY(curves.value("channel").toString() == QString("Blue") && curves.value("channels").toArray().size() == 4); + QCOMPARE(curves.value("channels").toArray().at(2).toArray().at(0).toObject(), (QJsonObject{{"x", 0}, {"y", 30}})); + QCOMPARE(object.value("exposureSettings").toObject(), (QJsonObject{{"exposure", 1}, {"offset", 0.1}, {"gamma", 2}})); + QCOMPARE(object.value("gradientMapSettings").toObject(), + (QJsonObject{{"shadows", QJsonObject{{"red", 1}, {"green", 0}, {"blue", 0}}}, {"highlights", QJsonObject{{"red", 0}, {"green", 0}, {"blue", 1}}}, + {"reversed", true}})); + QCOMPARE(object.value("grainSettings").toObject(), (QJsonObject{{"amount", 40}, {"size", 3}, {"roughness", 10}, {"seed", 9}})); + QVERIFY(ManifestJson::adjustment(object) == full()); + // Through the whole manifest, beside the other keys. + ProjectManifest manifest = twoLayers().manifest; + manifest.layers[1].adjustment = full(); + const ProjectManifest decoded = ProjectManifest::decoded(manifest.encoded()); + QVERIFY(decoded.layers[1].adjustment == full() && !decoded.layers[0].adjustment); +} + +void LayerAdjustmentTests::decodingRefusesWhatSwiftRefuses() +{ + const QJsonObject good = ManifestJson::encoded(full()); + QVERIFY(!refused(good)); + const auto without = [&good](const char *key) { + QJsonObject object = good; + object.remove(QLatin1String(key)); + return object; + }; + const auto with = [&good](const char *key, const QJsonValue &value) { + QJsonObject object = good; + object.insert(QLatin1String(key), value); + return object; + }; + // Synthesized decoding needs every key but the optionals. + for (const char *key : {"kind", "hue", "saturation", "lightness", "colorize", "levels", "curves"}) + QVERIFY2(refused(without(key)), key); + for (const char *key : {"hsvSettings", "exposureSettings", "gradientMapSettings", "grainSettings"}) { + QVERIFY2(!refused(without(key)) && !refused(with(key, QJsonValue::Null)), key); + QVERIFY2(refused(with(key, QJsonArray())), key); + } + QVERIFY(refused(with("kind", "Vibrance")) && refused(with("hue", "10")) && refused(with("colorize", 1))); + // Pairs come whole, with known ranges, in an array. + const auto hsv = [&](const char *key, const QJsonValue &value) { + QJsonObject settings = good.value("hsvSettings").toObject(); + settings.insert(QLatin1String(key), value); + return with("hsvSettings", settings); + }; + QVERIFY(refused(hsv("adjustments", QJsonArray{"Reds"})) && refused(hsv("adjustments", QJsonArray{"Pinks", QJsonObject{{"hue", 1}, {"saturation", 0}, {"lightness", 0}}}))); + QVERIFY(refused(hsv("adjustments", QJsonObject())) && refused(hsv("range", "Violets"))); + QVERIFY(!refused(hsv("adjustments", QJsonArray()))); + // Later pairs win, as a Swift dictionary assigns. + const QJsonObject twice = hsv("adjustments", QJsonArray{"Reds", QJsonObject{{"hue", 1}, {"saturation", 0}, {"lightness", 0}}, "Reds", + QJsonObject{{"hue", 2}, {"saturation", 0}, {"lightness", 0}}}); + QCOMPARE(ManifestJson::adjustment(twice).hsvSettings.value().adjustments.at(ColorRange::reds).hue, 2.0); + // Levels holds four ranges; channels are Swift's names. + QJsonObject levels = good.value("levels").toObject(); + QJsonArray ranges = levels.value("ranges").toArray(); + ranges.append(ranges.first()); + levels.insert("ranges", ranges); + QVERIFY(refused(with("levels", levels))); + ranges.removeLast(); + ranges.removeLast(); + levels.insert("ranges", ranges); + QVERIFY(refused(with("levels", levels))); + levels = good.value("levels").toObject(); + levels.insert("channel", "Alpha"); + QVERIFY(refused(with("levels", levels))); + QJsonObject curves = good.value("curves").toObject(); + curves.insert("channels", QJsonObject()); + QVERIFY(refused(with("curves", curves))); + // A seed is Swift's UInt32. + const auto seeded = [&](const QJsonValue &seed) { + QJsonObject grain = good.value("grainSettings").toObject(); + grain.insert("seed", seed); + return with("grainSettings", grain); + }; + QVERIFY(refused(seeded(-1)) && refused(seeded(qint64(4'294'967'296))) && refused(seeded(1.5))); + QCOMPARE(ManifestJson::adjustment(seeded(qint64(4'294'967'295))).grainSettings.value().seed, 4'294'967'295u); +} + +void LayerAdjustmentTests::aSeedReadsWhatQtsParserReads() +{ + ProjectManifest manifest = twoLayers().manifest; + manifest.layers[1].adjustment = full(); + const QByteArray json = manifest.encoded(); + QVERIFY(json.contains("\"seed\": 9")); + const auto seed = [&json](const char *token) { + QByteArray changed = json; + changed.replace("\"seed\": 9", QByteArray("\"seed\": ") + token); + return ProjectManifest::decoded(changed).layers[1].adjustment.value().grainSettings.value().seed; + }; + // Whole numbers written 5.0 or 5e0 read as Swift's. + QCOMPARE(seed("5.0"), 5u); + QCOMPARE(seed("5e0"), 5u); + // Qt's parser fails a token that underflows; Swift reads zero. + QCOMPARE(projectError([&] { seed("1e-400"); }), ProjectError::Kind::invalid); +} + +void LayerAdjustmentTests::theStoreKeepsAdjustmentsToVersion7WithoutPixels() +{ + ProjectSnapshot snapshot = twoLayers(); + ProjectLayerRecord &blank = snapshot.manifest.layers[1]; + blank.adjustment = full(); + QCOMPARE(stored(snapshot), std::nullopt); + snapshot.manifest.version = 6; + QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); + snapshot.manifest.version = 7; + // Pixels, a folder or invalid settings refuse it. + blank.imageFile = uuidString(blank.id) + ".png"; + snapshot.images.insert({blank.id, asset(halfRed(), "pixels")}); + QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); + blank.imageFile = std::nullopt; + snapshot.images.erase(blank.id); + blank.isGroup = true; + QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); + blank.isGroup = std::nullopt; + blank.adjustment->hue = 400; + QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); +} + +void LayerAdjustmentTests::nothingClipsToAnAdjustment() +{ + ProjectSnapshot snapshot = twoLayers(); + ProjectLayerRecord &image = snapshot.manifest.layers[0], &blank = snapshot.manifest.layers[1]; + // An adjustment clips to a layer; nothing clips to it. + blank.adjustment = full(); + blank.maskSourceID = image.id; + QCOMPARE(stored(snapshot), std::nullopt); + blank.maskSourceID = std::nullopt; + std::swap(snapshot.manifest.layers[0], snapshot.manifest.layers[1]); + snapshot.manifest.layers[1].maskSourceID = snapshot.manifest.layers[0].id; + QCOMPARE(stored(snapshot), ProjectError::Kind::invalid); + // The session will not link a layer to one either. + const std::unique_ptr<EditorSession> session = oneLayer(); + const QUuid red = session->activeLayerID().value(); + const QUuid levels = added(*session, AdjustmentKind::levels); + QVERIFY(session->canLinkMask(red, levels) && !session->canLinkMask(levels, red)); +} + +void LayerAdjustmentTests::addingPlacesTheLayerAboveTheActiveOne() +{ + const std::unique_ptr<EditorSession> session = oneLayer(); + session->setForegroundColor(PaletteColor{1, 0, 0}); + session->setBackgroundColor(PaletteColor{0, 0, 1}); + session->addBlankLayer(); + const QUuid top = session->activeLayerID().value(); + session->selectLayer(session->document().value().layers[0].id); + const int steps = session->history.undoCount(); + session->addAdjustment(AdjustmentKind::gradientMap); + // Above the active layer, blank, the canvas's size. + const std::vector<ImageLayer> &layers = session->document().value().layers; + QCOMPARE(layers.size(), size_t(3)); + const ImageLayer &made = layers[1]; + QVERIFY(made.id == session->activeLayerID() && layers[2].id == top && !made.asset && !made.parentID); + QVERIFY(made.name == QString("Gradient Map") && made.transform.size == QSizeF(4, 4)); + QVERIFY(session->history.undoCount() == steps + 1 && session->history.undoName() == QString("New Gradient Map Adjustment")); + // Its editor is asked for; Swift's panel then opens it. + QCOMPARE(session->adjustmentEditingID(), std::optional(made.id)); + session->setAdjustmentEditingID(std::nullopt); + // A Gradient Map runs from the foreground to the background. + const LayerAdjustment &map = made.adjustment.value(); + QVERIFY(map.kind == AdjustmentKind::gradientMap && map.gradientMap().shadows == AdjustmentColor(1, 0, 0)); + QVERIFY(map.gradientMap().highlights == AdjustmentColor(0, 0, 1) && !map.grainSettings); + // Each Grain layer rolls its own pattern; others keep defaults. + added(*session, AdjustmentKind::grain); + const quint32 first = session->activeLayer().value().adjustment.value().grain().seed; + added(*session, AdjustmentKind::grain); + QVERIFY(session->activeLayer().value().adjustment.value().grain().seed != first); + added(*session, AdjustmentKind::curves); + QVERIFY(session->activeLayer().value().adjustment.value() == LayerAdjustment{AdjustmentKind::curves}); + // A folder takes it inside, uncollapsed. + session->selectLayer(top); + session->groupSelectedLayers(); + const QUuid folder = session->activeLayerID().value(); + session->toggleGroupExpansion(folder); + QVERIFY(session->collapsedGroupIDs().contains(folder)); + added(*session, AdjustmentKind::levels); + QVERIFY(session->activeLayer().value().parentID == folder && !session->collapsedGroupIDs().contains(folder)); + // Beside a layer in a folder, it joins that folder. + session->selectLayer(top); + added(*session, AdjustmentKind::exposure); + QVERIFY(session->activeLayer().value().parentID == folder); + // Ten thousand layers are the most a project holds. + ProjectSnapshot crowded = twoLayers(); + while (crowded.manifest.layers.size() < 9'999) + crowded.manifest.layers.push_back({.id = QUuid::createUuid(), .name = "n", .isVisible = true, + .transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt}); + EditorSession packed; + packed.installProject(crowded, QString()); + added(packed, AdjustmentKind::levels); + packed.addAdjustment(AdjustmentKind::levels); + QCOMPARE(packed.document().value().layers.size(), size_t(10'000)); + // Without a document or with the layers busy, nothing. + EditorSession empty; + empty.addAdjustment(AdjustmentKind::levels); + QVERIFY(!empty.document()); + session->setIsProjectBusy(true); + const size_t count = session->document().value().layers.size(); + session->addAdjustment(AdjustmentKind::levels); + QCOMPARE(session->document().value().layers.size(), count); +} + +void LayerAdjustmentTests::updatesTakeValidSettingsAlone() +{ + const std::unique_ptr<EditorSession> session = oneLayer(); + const QUuid id = added(*session, AdjustmentKind::levels); + const int steps = session->history.undoCount(), revision = session->brushRevision(); + QSignalSpy changed(session.get(), &EditorSession::changed); + LayerAdjustment value{AdjustmentKind::levels}; + value.levels.ranges[0].outputWhite = 0; + session->updateAdjustment(id, value); + // No step of its own; the canvas hears of it. + QVERIFY(session->activeLayer().value().adjustment == value && session->history.undoCount() == steps); + QVERIFY(session->brushRevision() == revision + 1 && changed.count() == 1); + // Invalid settings, or a missing layer, change nothing. + LayerAdjustment wild = value; + wild.hue = 1000; + session->updateAdjustment(id, wild); + session->updateAdjustment(QUuid::createUuid(), LayerAdjustment{AdjustmentKind::levels}); + QVERIFY(session->activeLayer().value().adjustment == value && changed.count() == 1); + // An adjustment layer paints nothing; its mask still paints. + QVERIFY(!session->canPaint()); + session->addMask(); + QVERIFY(session->isMaskSelected() && session->canPaint()); +} + +void LayerAdjustmentTests::adjustmentsTravelWithTheirLayers() +{ + const std::unique_ptr<EditorSession> session = oneLayer(); + const QUuid id = added(*session, AdjustmentKind::hsv); + session->updateAdjustment(id, full()); + // The record, the store and the install carry it whole. + QVERIFY(session->activeLayer().value().hierarchyRecord().adjustment == full()); + QTemporaryDir root; + ProjectStore::save(session->projectSnapshot().value(), root.filePath("Adjusted.comp")); + const ProjectSnapshot loaded = ProjectStore::load(root.filePath("Adjusted.comp")); + EditorSession restored; + restored.installProject(loaded, root.filePath("Adjusted.comp")); + QVERIFY(restored.document().value().layers[1].adjustment == full()); + // Both resizers pass it on, as Swift's rebuilds list it. + const ProjectSnapshot resized = ImageResizer::resize(loaded, ImageSizeOptions{.width = 8, .height = 8, .resolution = 72}); + QVERIFY(resized.manifest.layers[1].adjustment == full()); + const ProjectSnapshot extended = CanvasResizer::resize(loaded, CanvasSizeOptions{.width = 6, .height = 5}); + QVERIFY(extended.manifest.layers[1].adjustment == full()); + // A duplicate keeps it; the layer's equality reads it. + session->duplicateActiveLayer(); + QVERIFY(session->activeLayer().value().adjustment == full()); + ImageLayer changed = session->activeLayer().value(); + changed.adjustment->hue = 11; + QVERIFY(!(changed == session->activeLayer().value())); +} + +QTEST_MAIN(LayerAdjustmentTests) +#include "LayerAdjustmentTests.moc" diff --git a/tests/LayerAppearanceControlsTests.cpp b/tests/LayerAppearanceControlsTests.cpp new file mode 100644 index 0000000..0f52ba4 --- /dev/null +++ b/tests/LayerAppearanceControlsTests.cpp @@ -0,0 +1,175 @@ +#include "UI/LayerAppearanceControls.h" +#include "UI/BlendModePicker.h" +#include <QtTest> + +// The opacity slider and field, one undo step a drag. +namespace { +ImportedImage white() +{ + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + return ImportedImage(image, image, "White"); +} + +struct Controls { + EditorSession session; + LayerAppearanceControls widget{session}; + QSlider &slider = *widget.findChild<QSlider *>("opacitySlider"); + QLineEdit &field = *widget.findChild<QLineEdit *>("opacityPercent"); + double opacity() { return session.activeLayer().value().opacity; } +}; +} + +class LayerAppearanceControlsTests : public QObject { + Q_OBJECT +private slots: + void theControlsFollowTheActiveLayerAndItsGate(); + void aSliderDragIsOneUndoStep(); + void theFieldAppliesOnReturnEscapeAndLeavingAndStepsWithArrows(); + void aFieldLeftBehindByAnotherLayerAppliesNothing(); +}; + +void LayerAppearanceControlsTests::theControlsFollowTheActiveLayerAndItsGate() +{ + Controls controls; + QVERIFY(!controls.widget.isEnabled()); + QCOMPARE(controls.field.text(), QString("100")); + controls.session.createDocument(4, 4); + controls.session.insert(white()); + QVERIFY(controls.widget.isEnabled()); + controls.session.setLayerOpacity(0.25); + QCOMPARE(controls.slider.value(), 250); + QCOMPARE(controls.field.text(), QString("25")); + controls.session.insert(white()); + QCOMPARE(controls.slider.value(), 1000); + QCOMPARE(controls.field.text(), QString("100")); + controls.session.selectLayer(controls.session.document().value().layers.front().id); + QCOMPARE(controls.field.text(), QString("25")); + // Showing a finer opacity must not write the slider's back. + controls.session.setLayerOpacity(0.12345); + QCOMPARE(controls.slider.value(), 123); + QCOMPARE(controls.session.activeLayer().value().opacity, 0.12345); + QVERIFY(controls.widget.findChild<BlendModePicker *>() != nullptr); + controls.session.selectLayers({controls.session.document().value().layers.front().id, controls.session.document().value().layers.back().id}, std::nullopt); + QVERIFY(!controls.widget.isEnabled()); +} + +void LayerAppearanceControlsTests::aSliderDragIsOneUndoStep() +{ + Controls controls; + controls.session.createDocument(4, 4); + controls.session.insert(white()); + const int steps = controls.session.history.undoCount(); + controls.slider.setSliderDown(true); + emit controls.slider.sliderPressed(); + controls.slider.setValue(600); + controls.slider.setValue(400); + QCOMPARE(controls.opacity(), 0.4); + QVERIFY(!controls.session.canUndo()); + controls.slider.setSliderDown(false); + emit controls.slider.sliderReleased(); + QCOMPARE(controls.session.history.undoCount(), steps + 1); + QCOMPARE(controls.session.history.undoName(), QString("Layer Opacity")); + controls.session.undo(); + QCOMPARE(controls.opacity(), 1.0); + QCOMPARE(controls.slider.value(), 1000); + // The keyboard moves the slider too, a step each. + controls.slider.setValue(900); + QCOMPARE(controls.opacity(), 0.9); + QCOMPARE(controls.session.history.undoCount(), steps + 1); +} + +void LayerAppearanceControlsTests::theFieldAppliesOnReturnEscapeAndLeavingAndStepsWithArrows() +{ + Controls controls; + controls.session.createDocument(4, 4); + controls.session.insert(white()); + controls.widget.show(); + QVERIFY(QTest::qWaitForWindowActive(&controls.widget)); + controls.field.setFocus(); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "40"); + QTest::keyClick(&controls.field, Qt::Key_Return); + QCOMPARE(controls.opacity(), 0.4); + QVERIFY(!controls.field.hasFocus()); + QCOMPARE(controls.session.canvasFocusRequest(), 1); + controls.field.setFocus(); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "abc"); + QTest::keyClick(&controls.field, Qt::Key_Escape); + QCOMPARE(controls.opacity(), 0.4); + QCOMPARE(controls.field.text(), QString("40")); + QCOMPARE(controls.session.canvasFocusRequest(), 2); + // Leaving applies without asking for the canvas; past 100 clamps. + controls.field.setFocus(); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "250"); + controls.field.clearFocus(); + QCOMPARE(controls.opacity(), 1.0); + QCOMPARE(controls.field.text(), QString("100")); + QCOMPARE(controls.session.canvasFocusRequest(), 2); + // Arrows step one percent, ten with Shift, within the range. + controls.field.setFocus(); + QTest::keyClick(&controls.field, Qt::Key_Down); + QCOMPARE(controls.opacity(), 0.99); + QTest::keyClick(&controls.field, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(controls.opacity(), 0.89); + QCOMPARE(controls.field.text(), QString("89")); + QTest::keyClick(&controls.field, Qt::Key_Up, Qt::ShiftModifier); + QTest::keyClick(&controls.field, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(controls.opacity(), 1.0); + // A finer value is kept once, shown rounded. + const int steps = controls.session.history.undoCount(); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "12.345"); + QTest::keyClick(&controls.field, Qt::Key_Return); + QCOMPARE(controls.opacity(), 0.12345); + QCOMPARE(controls.field.text(), QString("12")); + QCOMPARE(controls.session.history.undoCount(), steps + 1); + controls.field.setFocus(); + QTest::keyClick(&controls.field, Qt::Key_Up); + QCOMPARE(controls.opacity(), 0.13); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "100"); + QTest::keyClick(&controls.field, Qt::Key_Return); + controls.field.setFocus(); + // Typing goes on while the session moves; the slider follows. + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "5"); + controls.session.setLayerOpacity(0.3); + QCOMPARE(controls.field.text(), QString("5")); + QCOMPARE(controls.slider.value(), 300); + QTest::keyClick(&controls.field, Qt::Key_Enter); + QCOMPARE(controls.opacity(), 0.05); +} + +void LayerAppearanceControlsTests::aFieldLeftBehindByAnotherLayerAppliesNothing() +{ + Controls controls; + controls.session.createDocument(4, 4); + controls.session.insert(white()); + const QUuid first = controls.session.activeLayerID().value(); + controls.session.insert(white()); + controls.widget.show(); + QVERIFY(QTest::qWaitForWindowActive(&controls.widget)); + controls.field.setFocus(); + controls.field.selectAll(); + QTest::keyClicks(&controls.field, "20"); + // Another layer active: the typed value belongs to no one. + controls.session.selectLayer(first); + QVERIFY(!controls.field.hasFocus()); + QCOMPARE(controls.field.text(), QString("100")); + for (const ImageLayer &layer : controls.session.document().value().layers) + QCOMPARE(layer.opacity, 1.0); + // The controls end an open drag when they go. + controls.slider.setSliderDown(true); + emit controls.slider.sliderPressed(); + controls.slider.setValue(500); + QVERIFY(!controls.session.canUndo()); + auto *later = new LayerAppearanceControls(controls.session); + delete later; + QVERIFY(controls.session.canUndo()); +} + +QTEST_MAIN(LayerAppearanceControlsTests) +#include "LayerAppearanceControlsTests.moc" diff --git a/tests/LayerAppearanceTests.cpp b/tests/LayerAppearanceTests.cpp new file mode 100644 index 0000000..0c9a470 --- /dev/null +++ b/tests/LayerAppearanceTests.cpp @@ -0,0 +1,453 @@ +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> + +namespace { +ImportedImage asset(double gray) +{ + QImage image(4, 4, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor::fromRgbF(gray, gray, gray)); + return ImportedImage(image, image, "Gray"); +} + +// The flattened canvas's first pixel: gray level and alpha. +std::pair<double, double> pixel(const EditorSession &session) +{ + const QImage image = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888); + return {image.constScanLine(0)[0] / 255.0, image.constScanLine(0)[3] / 255.0}; +} + +ImageLayer layerWith(const EditorSession &session, QUuid id) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such layer"); +} +} + +class LayerAppearanceTests : public QObject { + Q_OBJECT +private slots: + void hoverPreviewIsTemporaryAndNeverChangesSavedState(); + void blendModesAndOpacityMatchKnownPixels(); + void opacityDragIsOneUndoAndKeepsSources(); + void appearancePersistsThroughSaveResizeAndTransparentExport(); + void thePreviewBelongsToTheActiveLayerAlone(); + void opacityIsClampedAndRefusesWhatIsNoNumber(); + void selectedLayersTakeOneOpacityInOneStep(); + void theBlendModeCyclesBothWaysAndWraps(); + void onlyOnePixelLayerHasAnAppearanceToEdit(); + void whatEndsATransformEndsThePreviewAndTheDrag(); + void moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo(); +}; + +void LayerAppearanceTests::hoverPreviewIsTemporaryAndNeverChangesSavedState() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.8)); + session.history.markSaved(); + const QUuid id = session.activeLayerID().value(); + const int count = session.history.undoCount(); + for (const LayerBlendMode mode : allLayerBlendModes) { + session.previewBlendMode(mode, id); + QCOMPARE(session.displayedBlendMode(session.activeLayer().value()), mode); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal); + QCOMPARE(session.projectSnapshot().value().manifest.layers.back().blendMode, std::optional(LayerBlendMode::normal)); + QVERIFY(!session.isModified() && session.history.undoCount() == count); + } + session.previewBlendMode(std::nullopt, std::nullopt); + QCOMPARE(session.displayedBlendMode(session.activeLayer().value()), LayerBlendMode::normal); + session.previewBlendMode(LayerBlendMode::multiply, id); + session.setLayerBlendMode(LayerBlendMode::multiply); + QVERIFY(!session.blendPreview().has_value()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Layer Blend Mode")); + session.undo(); + QVERIFY(session.activeLayer().value().blendMode == LayerBlendMode::normal && !session.isModified()); + // With no preview a layer shows the mode it holds. + session.redo(); + QVERIFY(!session.blendPreview().has_value()); + QCOMPARE(session.displayedBlendMode(session.activeLayer().value()), LayerBlendMode::multiply); + session.previewBlendMode(LayerBlendMode::screen, id); + session.previewBlendMode(std::nullopt, std::nullopt); + QCOMPARE(session.displayedBlendMode(session.activeLayer().value()), LayerBlendMode::multiply); +} + +void LayerAppearanceTests::blendModesAndOpacityMatchKnownPixels() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.4)); + session.insert(asset(0.8)); + const std::vector<std::pair<LayerBlendMode, double>> expected{ + {LayerBlendMode::normal, 0.8}, {LayerBlendMode::multiply, 0.32}, {LayerBlendMode::screen, 0.88}, + {LayerBlendMode::overlay, 0.64}, {LayerBlendMode::darken, 0.4}, {LayerBlendMode::lighten, 0.8}, + {LayerBlendMode::difference, 0.4}, {LayerBlendMode::colorDodge, 1}, {LayerBlendMode::colorBurn, 0.25}}; + for (const auto &[mode, value] : expected) { + session.setLayerBlendMode(mode); + QCOMPARE(layerBlendMode(rawValue(mode)), std::optional(mode)); + const auto [gray, alpha] = pixel(session); + QVERIFY2(std::abs(gray - value) < 0.02, qPrintable(rawValue(mode) + " " + QString::number(gray))); + QCOMPARE(alpha, 1.0); + } + session.setLayerBlendMode(LayerBlendMode::normal); + session.setLayerOpacity(0.5); + QVERIFY2(std::abs(pixel(session).first - 0.6) < 0.02, qPrintable(QString::number(pixel(session).first))); + session.setLayerOpacity(0); + QVERIFY2(std::abs(pixel(session).first - 0.4) < 0.02, qPrintable(QString::number(pixel(session).first))); +} + +void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExport() +{ + EditorSession session; + session.createDocument(4, 4); + session.addGroup(); + session.insert(asset(0.8)); + const QUuid id = session.activeLayerID().value(); + session.setLayerOpacity(0.25); + session.setLayerBlendMode(LayerBlendMode::multiply); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + QVERIFY2(std::abs(pixel(session).second - 0.25) < 0.01, qPrintable(QString::number(pixel(session).second))); + QTemporaryDir folder; + const QString path = folder.filePath("Appearance.comp"); + ProjectStore::save(snapshot, path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QCOMPARE(loaded.manifest.version, qint64(7)); + const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72}); + const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12}); + const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(), + [&](const ProjectLayerRecord &layer) { return layer.id == id; }); + QVERIFY(record != canvas.manifest.layers.end()); + QCOMPARE(record->opacity, std::optional(0.25)); + QCOMPARE(record->blendMode, std::optional(LayerBlendMode::multiply)); + QVERIFY(record->parentID.has_value()); +} + +void LayerAppearanceTests::opacityDragIsOneUndoAndKeepsSources() +{ + EditorSession session; + session.createDocument(4, 4); + const ImportedImage source = asset(0.8); + session.insert(source); + const int count = session.history.undoCount(); + session.beginOpacityEdit(); + for (int value = 9; value >= 2; --value) + session.setLayerOpacity(value / 10.0); + session.finishOpacityEdit(); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Layer Opacity")); + QVERIFY(session.activeLayer().value().asset.value().identity() == source.identity()); + session.undo(); + QCOMPARE(session.activeLayer().value().opacity, 1.0); + session.redo(); + QVERIFY(std::abs(session.activeLayer().value().opacity - 0.2) < 0.001); +} + +void LayerAppearanceTests::thePreviewBelongsToTheActiveLayerAlone() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.2)); + const QUuid below = session.activeLayerID().value(); + session.insert(asset(0.8)); + const QUuid above = session.activeLayerID().value(); + // Another layer's id clears what was shown. + session.previewBlendMode(LayerBlendMode::screen, above); + QCOMPARE(session.blendPreview(), std::optional(EditorSession::BlendPreview{above, LayerBlendMode::screen})); + session.previewBlendMode(LayerBlendMode::multiply, below); + QVERIFY(!session.blendPreview().has_value()); + // A preview left behind shows on no other layer. + session.previewBlendMode(LayerBlendMode::screen, above); + QCOMPARE(session.displayedBlendMode(layerWith(session, below)), LayerBlendMode::normal); + session.setActiveLayerID(below); + QCOMPARE(session.displayedBlendMode(layerWith(session, above)), LayerBlendMode::normal); + QCOMPARE(session.displayedBlendMode(layerWith(session, below)), LayerBlendMode::normal); + // No mode, or no id, clears it too. + session.setActiveLayerID(above); + session.previewBlendMode(std::nullopt, above); + QVERIFY(!session.blendPreview().has_value()); + session.previewBlendMode(LayerBlendMode::screen, above); + session.previewBlendMode(LayerBlendMode::screen, std::nullopt); + QVERIFY(!session.blendPreview().has_value()); + // A refused mode change still drops the preview. + session.previewBlendMode(LayerBlendMode::screen, above); + session.setIsImporting(true); + session.setLayerBlendMode(LayerBlendMode::overlay); + QVERIFY(!session.blendPreview().has_value()); + QCOMPARE(layerWith(session, above).blendMode, LayerBlendMode::normal); + session.previewBlendMode(LayerBlendMode::screen, above); + QVERIFY(!session.blendPreview().has_value()); + // Each layer shows its own stored mode, active or not. + session.setIsImporting(false); + session.setLayerBlendMode(LayerBlendMode::screen); + session.selectLayer(below); + session.setLayerBlendMode(LayerBlendMode::multiply); + QCOMPARE(session.displayedBlendMode(layerWith(session, above)), LayerBlendMode::screen); + QCOMPARE(session.displayedBlendMode(layerWith(session, below)), LayerBlendMode::multiply); + session.previewBlendMode(LayerBlendMode::hue, below); + QCOMPARE(session.displayedBlendMode(layerWith(session, above)), LayerBlendMode::screen); + QCOMPARE(session.displayedBlendMode(layerWith(session, below)), LayerBlendMode::hue); +} + +void LayerAppearanceTests::opacityIsClampedAndRefusesWhatIsNoNumber() +{ + EditorSession session; + session.setLayerOpacity(0.5); + session.setSelectedLayersOpacity(0.5); + session.createDocument(4, 4); + session.insert(asset(0.8)); + const int count = session.history.undoCount(); + session.setLayerOpacity(1.5); + QCOMPARE(session.activeLayer().value().opacity, 1.0); + QCOMPARE(session.history.undoCount(), count); + session.setLayerOpacity(-2); + QCOMPARE(session.activeLayer().value().opacity, 0.0); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Layer Opacity")); + // What is no finite number is refused by both setters. + session.setLayerOpacity(0.5); + QSignalSpy changes(&session, &EditorSession::changed); + const double infinity = std::numeric_limits<double>::infinity(); + for (const double value : {std::nan(""), infinity, -infinity}) { + session.setLayerOpacity(value); + session.setSelectedLayersOpacity(value); + } + QCOMPARE(session.activeLayer().value().opacity, 0.5); + QCOMPARE(session.history.undoCount(), count + 2); + QCOMPARE(int(changes.count()), 0); + session.setLayerOpacity(0); + session.setSelectedLayersOpacity(7); + QCOMPARE(session.activeLayer().value().opacity, 1.0); + session.setSelectedLayersOpacity(-7); + QCOMPARE(session.activeLayer().value().opacity, 0.0); + session.setIsImporting(true); + session.setLayerOpacity(0.5); + session.setSelectedLayersOpacity(0.5); + session.beginOpacityEdit(); + QCOMPARE(session.activeLayer().value().opacity, 0.0); + session.setIsImporting(false); + QVERIFY(session.canUndo()); +} + +void LayerAppearanceTests::selectedLayersTakeOneOpacityInOneStep() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.2)); + const QUuid first = session.activeLayerID().value(); + session.insert(asset(0.8)); + const QUuid second = session.activeLayerID().value(); + session.insert(asset(0.5)); + const QUuid apart = session.activeLayerID().value(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.selectLayers({first, second, folder}, second); + const int count = session.history.undoCount(); + session.setSelectedLayersOpacity(0.5); + QCOMPARE(layerWith(session, first).opacity, 0.5); + QCOMPARE(layerWith(session, second).opacity, 0.5); + QCOMPARE(layerWith(session, folder).opacity, 1.0); + QCOMPARE(layerWith(session, apart).opacity, 1.0); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Layer Opacity")); + // The same value again is no step. + session.setSelectedLayersOpacity(0.5); + QCOMPARE(session.history.undoCount(), count + 1); + // One layer that differs is enough for a step. + session.selectLayer(first); + session.setLayerOpacity(0.25); + session.selectLayers({first, second}, first); + session.setSelectedLayersOpacity(0.5); + QCOMPARE(layerWith(session, first).opacity, 0.5); + QCOMPARE(session.history.undoCount(), count + 3); + session.undo(); + QCOMPARE(layerWith(session, first).opacity, 0.25); + QCOMPARE(layerWith(session, second).opacity, 0.5); + // Several layers have no single appearance to edit. + session.selectLayers({first, second}, first); + QVERIFY(!session.canEditAppearance()); + session.setLayerOpacity(0.9); + QCOMPARE(layerWith(session, first).opacity, 0.25); +} + +void LayerAppearanceTests::theBlendModeCyclesBothWaysAndWraps() +{ + EditorSession session; + session.cycleBlendMode(true); + session.createDocument(4, 4); + session.insert(asset(0.2)); + const QUuid below = session.activeLayerID().value(); + session.insert(asset(0.8)); + const int count = session.history.undoCount(); + session.cycleBlendMode(true); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::multiply); + // The mode is the active layer's alone. + QCOMPARE(layerWith(session, below).blendMode, LayerBlendMode::normal); + session.cycleBlendMode(false); + session.cycleBlendMode(false); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::luminosity); + session.cycleBlendMode(true); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::normal); + QCOMPARE(session.history.undoCount(), count + 4); + // A cycle starts from the stored mode, not a preview. + const QUuid top = session.activeLayerID().value(); + session.setLayerBlendMode(LayerBlendMode::screen); + session.previewBlendMode(LayerBlendMode::hue, top); + session.cycleBlendMode(true); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay); + QVERIFY(!session.blendPreview().has_value()); + // A refused cycle leaves a preview where it was. + session.previewBlendMode(LayerBlendMode::hue, top); + session.setIsImporting(true); + QSignalSpy changes(&session, &EditorSession::changed); + session.cycleBlendMode(true); + QCOMPARE(session.blendPreview(), std::optional(EditorSession::BlendPreview{top, LayerBlendMode::hue})); + QCOMPARE(int(changes.count()), 0); + QCOMPARE(session.activeLayer().value().blendMode, LayerBlendMode::overlay); + QCOMPARE(int(allLayerBlendModes.size()), 13); + for (size_t index = 0; index < allLayerBlendModes.size(); ++index) + QCOMPARE(int(allLayerBlendModes[index]), int(index)); +} + +void LayerAppearanceTests::onlyOnePixelLayerHasAnAppearanceToEdit() +{ + EditorSession session; + QVERIFY(!session.canEditAppearance()); + session.createDocument(4, 4); + QVERIFY(!session.canEditAppearance()); + session.insert(asset(0.8)); + QVERIFY(session.canEditAppearance()); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(!session.canEditAppearance()); + session.setLayerBlendMode(LayerBlendMode::screen); + session.setLayerOpacity(0.5); + session.previewBlendMode(LayerBlendMode::screen, folder); + QVERIFY(!session.blendPreview().has_value()); + QCOMPARE(layerWith(session, folder).blendMode, LayerBlendMode::normal); + QCOMPARE(layerWith(session, folder).opacity, 1.0); + session.addBlankLayer(); + QVERIFY(session.canEditAppearance()); + session.setShowsImporter(true); + QVERIFY(!session.canEditAppearance()); +} + +void LayerAppearanceTests::whatEndsATransformEndsThePreviewAndTheDrag() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.2)); + const QUuid below = session.activeLayerID().value(); + session.insert(asset(0.8)); + const QUuid above = session.activeLayerID().value(); + const int count = session.history.undoCount(); + // Another layer ends the drag: its values are one step. + session.beginOpacityEdit(); + session.beginOpacityEdit(); + session.setLayerOpacity(0.7); + session.setLayerOpacity(0.6); + session.previewBlendMode(LayerBlendMode::screen, above); + QVERIFY(!session.canUndo()); + session.selectLayer(below); + QVERIFY(!session.blendPreview().has_value()); + QVERIFY(session.canUndo()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(layerWith(session, above).opacity, 0.6); + // A mode, or all selected layers, end a drag too. + session.beginOpacityEdit(); + session.setLayerOpacity(0.4); + session.setLayerBlendMode(LayerBlendMode::darken); + QCOMPARE(session.history.undoCount(), count + 3); + session.beginOpacityEdit(); + session.setLayerOpacity(0.3); + session.setSelectedLayersOpacity(0.9); + QCOMPARE(session.history.undoCount(), count + 5); + QVERIFY(session.canUndo()); + // Selected layers already at the value leave a drag open. + session.beginOpacityEdit(); + session.setLayerOpacity(0.9); + session.setSelectedLayersOpacity(0.9); + QVERIFY(!session.canUndo()); + session.finishOpacityEdit(); + QCOMPARE(session.history.undoCount(), count + 5); + // The drag keeps its layer though another turns active. + session.beginOpacityEdit(); + session.setActiveLayerID(above); + session.setLayerOpacity(0.1); + QCOMPARE(layerWith(session, below).opacity, 0.1); + QCOMPARE(layerWith(session, above).opacity, 0.6); + session.finishOpacityEdit(); + session.finishOpacityEdit(); + QCOMPARE(session.history.undoCount(), count + 6); + // A drag whose layer is deleted changes nothing more. + session.selectLayer(std::nullopt); + session.insert(asset(0.5)); + const QUuid doomed = session.activeLayerID().value(); + session.beginOpacityEdit(); + session.setLayerOpacity(0.8); + session.setActiveLayerID(above); + session.deleteLayer(doomed); + const std::optional<CanvasDocument> left = session.document(); + const int steps = session.history.undoCount(); + QSignalSpy changes(&session, &EditorSession::changed); + session.setLayerOpacity(0.2); + QCOMPARE(session.document(), left); + QCOMPARE(session.history.undoCount(), steps); + QCOMPARE(int(changes.count()), 0); + // The drag and the deletion inside it are one step. + session.finishOpacityEdit(); + QCOMPARE(session.history.undoCount(), count + 8); + session.undo(); + session.undo(); + QCOMPARE(session.history.undoCount(), count + 6); + // A transform committed in a drag: the drag ends first. + session.selectLayer(above); + const LayerTransform placed = layerWith(session, above).transform; + session.beginOpacityEdit(); + session.setLayerOpacity(0.35); + session.beginTransform(); + session.nudgeLayer(3, 0); + session.commitTransform(); + QVERIFY(session.canUndo()); + QCOMPARE(session.history.undoCount(), count + 8); + QCOMPARE(session.history.undoName(), QString("Transform Layer")); + session.undo(); + QCOMPARE(layerWith(session, above).transform, placed); + QCOMPARE(layerWith(session, above).opacity, 0.35); + QCOMPARE(session.history.undoName(), QString("Layer Opacity")); + session.undo(); + QCOMPARE(layerWith(session, above).opacity, 0.6); +} + +void LayerAppearanceTests::moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo() +{ + EditorSession session; + session.createDocument(4, 4); + session.insert(asset(0.2)); + const QUuid first = session.activeLayerID().value(); + session.insert(asset(0.8)); + const QUuid second = session.activeLayerID().value(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + session.selectLayers({first, second, folder}, second); + const int count = session.history.undoCount(); + session.typeOpacityDigit(5, 10); + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == first || layer.id == second) + QCOMPARE(layer.opacity, 0.5); + if (layer.id == folder) + QCOMPARE(layer.opacity, 1.0); + } + QCOMPARE(session.history.undoCount(), count + 1); +} + +QTEST_GUILESS_MAIN(LayerAppearanceTests) +#include "LayerAppearanceTests.moc" diff --git a/tests/LayerDragFixtures.h b/tests/LayerDragFixtures.h new file mode 100644 index 0000000..b262f15 --- /dev/null +++ b/tests/LayerDragFixtures.h @@ -0,0 +1,76 @@ +#pragma once +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectStore.h" +#include "Rendering/EditorCanvas.h" +#include "UI/NativeLayerList.h" +#include "SessionFixtures.h" +#include <QDragEnterEvent> +#include <QDragLeaveEvent> +#include <QDragMoveEvent> +#include <QDropEvent> +#include <QMenu> +#include <QMimeData> +#include <QScrollBar> +#include <QtTest> + +// Shared by the drag tests: a shown list, its drops. +struct Shown { + EditorSession &session; + NativeLayerList list; + explicit Shown(EditorSession &session) : session(session), list(session) + { + list.resize(252, 400); + list.show(); + if (!QTest::qWaitForWindowActive(&list)) + throw std::runtime_error("the list never became active"); + } + QUuid id(int row) { return list.cells().at(size_t(row))->layerID(); } + // A row's top edge, its middle, or below every row. + QPoint top(int row) { return list.cells().at(size_t(row))->mapTo(&list, QPoint(100, 2)); } + QPoint middle(int row) { return list.cells().at(size_t(row))->mapTo(&list, QPoint(100, LayerCell::rowHeight / 2)); } + QPoint below() { return list.cells().back()->mapTo(&list, QPoint(100, LayerCell::rowHeight + 30)); } + QMimeData *rows(std::initializer_list<int> which) + { + QStringList lines; + for (const int row : which) + lines << uuidString(id(row)); + auto *data = new QMimeData; + data->setData(ProjectWorkspace::layerType, lines.join('\n').toUtf8()); + data->setData(NativeLayerList::sourceType, list.dragToken().toUtf8()); + return data; + } + // The drag events a drop brings, offering as the source. + bool drop(QMimeData *data, Qt::DropActions offered, QPoint listPoint, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + const QPointF inViewport = list.viewport()->mapFromParent(listPoint); + QDragEnterEvent enter(inViewport.toPoint(), offered, data, Qt::LeftButton, modifiers); + QApplication::sendEvent(list.viewport(), &enter); + QDropEvent drop(inViewport, offered, data, Qt::LeftButton, modifiers); + QApplication::sendEvent(list.viewport(), &drop); + delete data; + lastAction = drop.dropAction(); + return drop.isAccepted(); + } + Qt::DropAction lastAction = Qt::IgnoreAction; + std::vector<QString> names() + { + std::vector<QString> result; + for (const LayerHierarchy::Entry &entry : session.layerRows()) + result.push_back(entry.layer.name); + return result; + } +}; + +inline std::unique_ptr<EditorSession> sessionWithLayers(int count) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600); + for (int each = 0; each < count; ++each) + session->addBlankLayer(); + return session; +} + +inline QImage image(const QCursor &cursor) +{ + return cursor.pixmap().toImage(); +} diff --git a/tests/LayerDragGestureTests.cpp b/tests/LayerDragGestureTests.cpp new file mode 100644 index 0000000..2db3414 --- /dev/null +++ b/tests/LayerDragGestureTests.cpp @@ -0,0 +1,303 @@ +#include "LayerDragFixtures.h" +#include <QDrag> + +namespace { +// What a started drag offered: no drag loop runs offscreen. +struct StartedDrag { + QByteArray rows; + QByteArray mask; + QByteArray source; + Qt::DropActions supported; + Qt::DropAction preferred; +}; +std::vector<StartedDrag> startedDrags; +} + +// Takes libQt6Gui's place in this executable, as ProjectReplaceTests do libc's. +Qt::DropAction QDrag::exec(Qt::DropActions supportedActions, Qt::DropAction defaultDropAction) +{ + const QMimeData *data = mimeData(); + startedDrags.push_back({data->data(ProjectWorkspace::layerType), data->data(NativeLayerList::maskType), data->data(NativeLayerList::sourceType), + supportedActions, defaultDropAction}); + return Qt::IgnoreAction; +} + +Qt::DropAction QDrag::exec(Qt::DropActions supportedActions) +{ + return exec(supportedActions, Qt::IgnoreAction); +} + +// Presses and hovers in the list: drags start, cursors follow. +class LayerDragGestureTests : public QObject { + Q_OBJECT +private slots: + void altShowsTheClippingAndDuplicateCursors(); + void pressesArmADragThatAMoveStartsFromRowsAndThumbnails(); + void hoverOverRowsAndCtrlKeepTheCursorRight(); + void aSpentPressArmsNothingLater(); + void aMaskPressReleasedInAPopupIsOver(); + void altClicksOnAMaskThroughTheWindowChooseAndToggle(); + void anotherButtonsReleaseLeavesAMaskPress(); +}; + +void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + const double ratio = shown.list.devicePixelRatio(); + const QPoint strip = shown.list.cells().at(0)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4)); + const QPoint body = shown.list.cells().at(0)->mapTo(&shown.list, QPoint(200, 20)); + QCOMPARE(image(shown.list.cursorFor(strip, Qt::AltModifier)), image(NativeLayerList::clippingCursor(false, ratio))); + // The badge: a plus to create, a minus to release. + QVERIFY(image(NativeLayerList::clippingCursor(false, 1)).pixelColor(25, 10).lightness() > 128); + QVERIFY(image(NativeLayerList::clippingCursor(true, 1)).pixelColor(25, 10).lightness() < 128); + session->toggleClippingMask(shown.id(0)); + QCOMPARE(image(shown.list.cursorFor(strip, Qt::AltModifier)), image(NativeLayerList::clippingCursor(true, ratio))); + QCOMPARE(image(shown.list.cursorFor(body, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + QCOMPARE(shown.list.cursorFor(body, Qt::AltModifier | Qt::ControlModifier).shape(), Qt::ArrowCursor); + QCOMPARE(shown.list.cursorFor(body, Qt::NoModifier).shape(), Qt::ArrowCursor); + // Ctrl over a thumbnail loads a selection: the pointing hand. + LayerCell &first = *shown.list.cells().at(0); + const QPoint picture = first.mapTo(&shown.list, first.thumbnail().geometry().center()); + QCOMPARE(image(shown.list.cursorFor(picture, Qt::ControlModifier)), image(CanvasView::loadSelectionCursor(ratio))); + QCOMPARE(image(shown.list.cursorFor(picture, Qt::ControlModifier | Qt::AltModifier)), image(CanvasView::loadSelectionCursor(ratio))); + QCOMPARE(shown.list.cursorFor(body, Qt::ControlModifier).shape(), Qt::ArrowCursor); + QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(11, 8).lightness() > 128); + QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 18).alpha() > 0); + QCOMPARE(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 21).alpha(), 0); + // The bottom row clips to nothing; a folder duplicates not. + const QPoint lastStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4)); + QCOMPARE(shown.list.cursorFor(lastStrip, Qt::AltModifier).shape(), Qt::ArrowCursor); + session->selectLayer(shown.id(1)); + session->addGroup(); + const QPoint folderBody = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, 20)); + QCOMPARE(shown.list.cursorFor(folderBody, Qt::AltModifier).shape(), Qt::ArrowCursor); + // A mask thumbnail copies, or loads with Ctrl; busy, nothing. + session->selectLayer(shown.id(0)); + session->addLayerMask(); + LayerCell &top = *shown.list.cells().at(0); + const QPoint mask = top.mapTo(&shown.list, top.maskThumbnail().geometry().center()); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::ControlModifier)), image(CanvasView::loadSelectionCursor(ratio))); + session->setIsImporting(true); + QCOMPARE(shown.list.cursorFor(mask, Qt::AltModifier).shape(), Qt::ArrowCursor); + QCOMPARE(shown.list.cursorFor(mask, Qt::ControlModifier).shape(), Qt::ArrowCursor); + QCOMPARE(shown.list.cursorFor(body, Qt::AltModifier).shape(), Qt::ArrowCursor); + session->setIsImporting(false); + // The viewport's cursor follows the pointer with Alt held. + QTest::keyPress(shown.list.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QTest::mouseMove(shown.list.viewport(), shown.list.viewport()->mapFromParent(body)); + QCOMPARE(image(shown.list.viewport()->cursor()), image(CanvasView::duplicateCursor(ratio))); + QTest::keyRelease(shown.list.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.list.viewport()->cursor().shape(), Qt::ArrowCursor); +} + +void LayerDragGestureTests::pressesArmADragThatAMoveStartsFromRowsAndThumbnails() +{ + const auto session = sessionWithLayers(3); + Shown shown(*session); + startedDrags.clear(); + LayerCell &row = *shown.list.cells().at(1); + const int far = QApplication::startDragDistance() + 2; + const QPoint name(row.width() - 20, 20); + // Short of the distance, no drag; past it, the row. + QTest::mousePress(&row, Qt::LeftButton, Qt::NoModifier, name); + QTest::mouseMove(&row, name + QPoint(2, 0)); + QCOMPARE(startedDrags.size(), size_t(0)); + QTest::mouseMove(&row, name + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(1)); + QCOMPARE(startedDrags.back().rows, uuidString(shown.id(1)).toUtf8()); + QCOMPARE(startedDrags.back().source, shown.list.dragToken().toUtf8()); + QCOMPARE(startedDrags.back().supported, Qt::MoveAction | Qt::CopyAction); + QCOMPARE(startedDrags.back().preferred, Qt::MoveAction); + QTest::mouseRelease(&row, Qt::LeftButton, Qt::NoModifier, name + QPoint(far, 0)); + // A released press is spent: the middle button drags nothing. + QTest::mousePress(&row, Qt::MiddleButton, Qt::NoModifier, name); + QTest::mouseMove(&row, name + QPoint(far, 0)); + QTest::mouseRelease(&row, Qt::MiddleButton, Qt::NoModifier, name + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(1)); + // Alt at the press offers a copy alone. + QTest::mousePress(&row, Qt::LeftButton, Qt::AltModifier, name); + QTest::mouseMove(&row, name + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(2)); + QCOMPARE(startedDrags.back().supported, Qt::DropActions(Qt::CopyAction)); + QTest::mouseRelease(&row, Qt::LeftButton, Qt::AltModifier, name + QPoint(far, 0)); + // Alt on the strip clips and drags nothing. + const QPoint strip(row.width() - 20, LayerCell::rowHeight - 4); + QTest::mousePress(&row, Qt::LeftButton, Qt::AltModifier, strip); + QTest::mouseMove(&row, strip + QPoint(far, 0)); + QTest::mouseRelease(&row, Qt::LeftButton, Qt::AltModifier, strip + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(2)); + QVERIFY(layerWith(*session, shown.id(1)).maskSourceID.has_value()); + // Thumbnails drag their row too, plain and with Alt. + LayerThumbnailButton &picture = shown.list.cells().at(0)->thumbnail(); + QTest::mousePress(&picture, Qt::LeftButton, Qt::NoModifier, QPoint(5, 5)); + QTest::mouseMove(&picture, QPoint(5 + far, 5)); + QTest::mouseRelease(&picture, Qt::LeftButton, Qt::NoModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(3)); + QCOMPARE(startedDrags.back().rows, uuidString(shown.id(0)).toUtf8()); + QTest::mousePress(&picture, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + QTest::mouseMove(&picture, QPoint(5 + far, 5)); + QTest::mouseRelease(&picture, Qt::LeftButton, Qt::AltModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(4)); + QCOMPARE(startedDrags.back().supported, Qt::DropActions(Qt::CopyAction)); + // A thumbnail click spent, the middle button drags nothing either. + QTest::mouseClick(&picture, Qt::LeftButton, Qt::NoModifier, QPoint(5, 5)); + QTest::mousePress(&picture, Qt::MiddleButton, Qt::NoModifier, QPoint(5, 5)); + QTest::mouseMove(&picture, QPoint(5 + far, 5)); + QTest::mouseRelease(&picture, Qt::MiddleButton, Qt::NoModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(4)); + // A mask thumbnail under Alt drags its mask. + session->selectLayer(shown.id(0)); + session->addLayerMask(); + LayerThumbnailButton &mask = shown.list.cells().at(0)->maskThumbnail(); + QTest::mousePress(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + QTest::mouseMove(&mask, QPoint(7, 5)); + QCOMPARE(startedDrags.size(), size_t(4)); + QTest::mouseMove(&mask, QPoint(5 + far, 5)); + QTest::mouseRelease(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(5)); + QCOMPARE(startedDrags.back().mask, uuidString(shown.id(0)).toUtf8()); + QCOMPARE(startedDrags.back().supported, Qt::DropActions(Qt::CopyAction)); + // Busy, a press drags nothing. + session->setIsImporting(true); + QTest::mousePress(&row, Qt::LeftButton, Qt::NoModifier, name); + QTest::mouseMove(&row, name + QPoint(far, 0)); + QTest::mouseRelease(&row, Qt::LeftButton, Qt::NoModifier, name + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(5)); +} + +void LayerDragGestureTests::hoverOverRowsAndCtrlKeepTheCursorRight() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + const double ratio = shown.list.devicePixelRatio(); + LayerCell &row = *shown.list.cells().at(0); + QWindow *window = shown.list.windowHandle(); + // A hover with Alt over a row's control, as reported. + const auto hover = [](QWidget &under, QPoint at) { + QMouseEvent move(QEvent::MouseMove, QPointF(at), under.mapToGlobal(at), Qt::NoButton, Qt::NoButton, Qt::AltModifier); + QApplication::sendEvent(&under, &move); + }; + // Every control of a row reports its hover. + for (const QWidget *child : row.findChildren<QWidget *>()) + QVERIFY2(child->hasMouseTracking(), qPrintable(child->objectName())); + QVERIFY(row.hasMouseTracking()); + QTest::keyPress(window, Qt::Key_Alt, Qt::AltModifier); + hover(row, QPoint(200, 20)); + QCOMPARE(image(shown.list.viewport()->cursor()), image(CanvasView::duplicateCursor(ratio))); + // Onto the strip, over the row itself: the clipping cursor. + hover(row, QPoint(200, LayerCell::rowHeight - 4)); + QCOMPARE(image(shown.list.viewport()->cursor()), image(NativeLayerList::clippingCursor(false, ratio))); + hover(row.thumbnail(), QPoint(5, 5)); + QCOMPARE(image(shown.list.viewport()->cursor()), image(CanvasView::duplicateCursor(ratio))); + // Ctrl joining Alt over a thumbnail: the load cursor. + qt_handleKeyEvent(window, QEvent::KeyPress, Qt::Key_Control, Qt::AltModifier | Qt::ControlModifier); + QCOMPARE(image(shown.list.viewport()->cursor()), image(CanvasView::loadSelectionCursor(ratio))); + qt_handleKeyEvent(window, QEvent::KeyRelease, Qt::Key_Control, Qt::AltModifier); + QCOMPARE(image(shown.list.viewport()->cursor()), image(CanvasView::duplicateCursor(ratio))); + QTest::keyRelease(shown.list.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QCOMPARE(shown.list.viewport()->cursor().shape(), Qt::ArrowCursor); +} + +void LayerDragGestureTests::aSpentPressArmsNothingLater() +{ + const auto session = sessionWithLayers(3); + Shown shown(*session); + startedDrags.clear(); + LayerCell &row = *shown.list.cells().at(0); + const int far = QApplication::startDragDistance() + 2; + const QPoint name(row.width() - 20, 20), strip(row.width() - 20, LayerCell::rowHeight - 4); + QTest::mouseClick(&row, Qt::LeftButton, Qt::NoModifier, name); + // Presses that arm nothing: the strip, Ctrl, Shift. + QTest::mousePress(&row, Qt::LeftButton, Qt::AltModifier, strip); + QTest::mouseMove(&row, strip + QPoint(far, 0)); + QTest::mouseRelease(&row, Qt::LeftButton, Qt::AltModifier, strip + QPoint(far, 0)); + QCOMPARE(startedDrags.size(), size_t(0)); + QTest::mouseClick(&row, Qt::LeftButton, Qt::NoModifier, name); + LayerThumbnailButton &picture = row.thumbnail(); + QTest::mousePress(&picture, Qt::LeftButton, Qt::ControlModifier, QPoint(5, 5)); + QTest::mouseMove(&picture, QPoint(5 + far, 5)); + QTest::mouseRelease(&picture, Qt::LeftButton, Qt::ControlModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(0)); + session->selectLayer(shown.id(0)); + session->addLayerMask(); + QTest::mouseClick(&row, Qt::LeftButton, Qt::NoModifier, name); + LayerThumbnailButton &mask = row.maskThumbnail(); + QTest::mousePress(&mask, Qt::LeftButton, Qt::ShiftModifier, QPoint(5, 5)); + QTest::mouseMove(&mask, QPoint(5 + far, 5)); + QTest::mouseRelease(&mask, Qt::LeftButton, Qt::ShiftModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(0)); +} + +void LayerDragGestureTests::aMaskPressReleasedInAPopupIsOver() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + startedDrags.clear(); + session->selectLayer(shown.id(0)); + session->addLayerMask(); + LayerThumbnailButton &mask = shown.list.cells().at(0)->maskThumbnail(); + const int far = QApplication::startDragDistance() + 2; + QTest::mousePress(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + QMenu menu; + menu.addAction(QStringLiteral("Nothing")); + menu.popup(shown.list.mapToGlobal(QPoint(100, 100))); + QTRY_VERIFY(menu.isVisible()); + QTest::mouseRelease(&menu, Qt::LeftButton, Qt::NoModifier, QPoint(4, 4)); + menu.close(); + // Neither a hover nor a plain press drags the mask. + QMouseEvent hover(QEvent::MouseMove, QPointF(5 + far, 5), mask.mapToGlobal(QPoint(5 + far, 5)), Qt::NoButton, Qt::NoButton, Qt::AltModifier); + QApplication::sendEvent(&mask, &hover); + QCOMPARE(startedDrags.size(), size_t(0)); + QTest::mousePress(&mask, Qt::LeftButton, Qt::NoModifier, QPoint(5, 5)); + QTest::mouseMove(&mask, QPoint(5 + far, 5)); + QTest::mouseRelease(&mask, Qt::LeftButton, Qt::NoModifier, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(1)); + QVERIFY(startedDrags.back().mask.isEmpty()); + QCOMPARE(startedDrags.back().rows, uuidString(shown.id(0)).toUtf8()); +} + +void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + startedDrags.clear(); + session->selectLayer(shown.id(0)); + session->addLayerMask(); + session->selectLayerTarget(shown.id(0), false); + LayerThumbnailButton &mask = shown.list.cells().at(0)->maskThumbnail(); + const QPoint at = mask.mapTo(&shown.list, QPoint(5, 5)); + // The window sees the release first; the click still lands. + QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, at); + QVERIFY(session->isMaskSelected()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); + QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, at); + QVERIFY(session->isMaskSelected()); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + QCOMPARE(startedDrags.size(), size_t(0)); +} + +void LayerDragGestureTests::anotherButtonsReleaseLeavesAMaskPress() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + startedDrags.clear(); + session->selectLayer(shown.id(0)); + session->addLayerMask(); + session->selectLayerTarget(shown.id(0), false); + LayerThumbnailButton &mask = shown.list.cells().at(0)->maskThumbnail(); + const int far = QApplication::startDragDistance() + 2; + QTest::mousePress(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + // The middle button pressed and released chooses nothing. + QTest::mousePress(&mask, Qt::MiddleButton, Qt::AltModifier, QPoint(5, 5)); + QTest::mouseRelease(&mask, Qt::MiddleButton, Qt::AltModifier, QPoint(5, 5)); + QVERIFY(!session->isMaskSelected()); + QTest::mouseMove(&mask, QPoint(5 + far, 5)); + QCOMPARE(startedDrags.size(), size_t(1)); + QCOMPARE(startedDrags.back().mask, uuidString(shown.id(0)).toUtf8()); + QTest::mouseRelease(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5 + far, 5)); +} + +QTEST_MAIN(LayerDragGestureTests) +#include "LayerDragGestureTests.moc" diff --git a/tests/LayerDragTests.cpp b/tests/LayerDragTests.cpp new file mode 100644 index 0000000..d7733ea --- /dev/null +++ b/tests/LayerDragTests.cpp @@ -0,0 +1,326 @@ +#include "LayerDragFixtures.h" + +// Layer list drops: rows and masks moved or copied. +class LayerDragTests : public QObject { + Q_OBJECT +private slots: + void aDragCarriesTheRowOrTheSelectionItBelongsTo(); + void dropsMoveAboveARowIntoAFolderOrToTheBottom(); + void dropsAreRefusedWhereTheyCannotLand(); + void anAltDragDropsDuplicates(); + void aMaskDragCopiesTheMaskOntoTheRowUnderIt(); + void theIndicatorShowsWhereTheDropLands(); + void anotherListsRowsLandNowhere(); + void aDragNearTheEdgeScrollsTheList(); + void aDragHeldAtTheEdgeScrollsOnAndAClipRefreshesTheCursor(); + void leavingOrDroppingAtTheEdgeStopsTheScrolling(); +}; + +void LayerDragTests::aDragCarriesTheRowOrTheSelectionItBelongsTo() +{ + const auto session = sessionWithLayers(3); + Shown shown(*session); + session->selectLayers({shown.id(0), shown.id(2)}, shown.id(2)); + const auto ids = [&](const QMimeData &data) { return QString::fromUtf8(data.data(ProjectWorkspace::layerType)).split('\n'); }; + // The selection in list order, or the pressed row alone. + QCOMPARE(ids(*shown.list.dragData(*shown.list.cells().at(0))), (QStringList{uuidString(shown.id(0)), uuidString(shown.id(2))})); + QCOMPARE(ids(*shown.list.dragData(*shown.list.cells().at(2))), (QStringList{uuidString(shown.id(0)), uuidString(shown.id(2))})); + QCOMPARE(ids(*shown.list.dragData(*shown.list.cells().at(1))), QStringList{uuidString(shown.id(1))}); + QVERIFY(shown.list.dragData(*shown.list.cells().at(1))->hasFormat(ProjectWorkspace::layerType)); +} + +void LayerDragTests::dropsMoveAboveARowIntoAFolderOrToTheBottom() +{ + const auto session = sessionWithLayers(3); + Shown shown(*session); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 3", "Layer 2", "Layer 1"})); + // A plain row's middle: the lower half lands below. + QVERIFY(shown.drop(shown.rows({2}), Qt::MoveAction | Qt::CopyAction, shown.middle(0))); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 3", "Layer 1", "Layer 2"})); + const QPoint lower = shown.list.cells().at(2)->mapTo(&shown.list, QPoint(100, LayerCell::rowHeight - 4)); + QVERIFY(shown.drop(shown.rows({0}), Qt::MoveAction, lower)); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1", "Layer 2", "Layer 3"})); + session->undo(); + session->undo(); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 3", "Layer 2", "Layer 1"})); + const int steps = session->history.undoCount(); + // Dropped above the top row: a move, whatever Ctrl proposes. + QVERIFY(shown.drop(shown.rows({2}), Qt::MoveAction | Qt::CopyAction, shown.top(0), Qt::ControlModifier)); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1", "Layer 3", "Layer 2"})); + QCOMPARE(shown.lastAction, Qt::MoveAction); + QCOMPARE(session->history.undoName(), QString("Move Layer")); + QCOMPARE(session->history.undoCount(), steps + 1); + QCOMPARE(session->selectedLayerIDs(), QSet<QUuid>{shown.id(0)}); + // Two rows dropped below every row keep their order. + QVERIFY(shown.drop(shown.rows({0, 1}), Qt::MoveAction | Qt::CopyAction, shown.below())); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 2", "Layer 1", "Layer 3"})); + QCOMPARE(session->history.undoName(), QString("Move Layers")); + QCOMPARE(session->selectedLayerIDs(), (QSet<QUuid>{shown.id(1), shown.id(2)})); + QCOMPARE(session->activeLayerID(), std::optional(shown.id(1))); + // Into a folder's middle: each lands on top, in order. + session->selectLayer(shown.id(0)); + session->addGroup(); + QCOMPARE(shown.names().front(), QString("Folder 1")); + const QUuid folder = shown.id(0); + QVERIFY(shown.drop(shown.rows({2, 3}), Qt::MoveAction | Qt::CopyAction, shown.middle(0))); + QCOMPARE(layerWith(*session, shown.id(1)).parentID, std::optional(folder)); + QCOMPARE(layerWith(*session, shown.id(2)).parentID, std::optional(folder)); + QCOMPARE(shown.names(), (std::vector<QString>{"Folder 1", "Layer 1", "Layer 3", "Layer 2"})); + // Above a child lands inside; the folder's top, outside. + QVERIFY(shown.drop(shown.rows({3}), Qt::MoveAction | Qt::CopyAction, shown.top(2))); + QCOMPARE(layerWith(*session, shown.id(2)).parentID, std::optional(folder)); + QCOMPARE(shown.names(), (std::vector<QString>{"Folder 1", "Layer 1", "Layer 2", "Layer 3"})); + QVERIFY(shown.drop(shown.rows({3}), Qt::MoveAction | Qt::CopyAction, shown.top(0))); + QVERIFY(!layerWith(*session, shown.id(0)).parentID.has_value()); + QCOMPARE(shown.names().front(), QString("Layer 3")); + session->undo(); + QCOMPARE(shown.names().front(), QString("Folder 1")); +} + +void LayerDragTests::dropsAreRefusedWhereTheyCannotLand() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + session->selectLayer(shown.id(0)); + session->groupSelectedLayers(); + const QUuid folder = shown.id(0); + // A folder into itself, or into its own child's row. + QVERIFY(!shown.drop(shown.rows({0}), Qt::MoveAction | Qt::CopyAction, shown.middle(0))); + QVERIFY(!shown.drop(shown.rows({0, 1}), Qt::MoveAction | Qt::CopyAction, shown.middle(0))); + QCOMPARE(layerWith(*session, shown.id(1)).parentID, std::optional(folder)); + // Strangers, another type, an empty payload: nothing lands. + auto *stranger = new QMimeData; + stranger->setData(ProjectWorkspace::layerType, uuidString(QUuid::createUuid()).toUtf8()); + QVERIFY(!shown.drop(stranger, Qt::MoveAction | Qt::CopyAction, shown.top(2))); + auto *text = new QMimeData; + text->setText(uuidString(shown.id(2))); + QVERIFY(!shown.drop(text, Qt::MoveAction | Qt::CopyAction, shown.top(0))); + QVERIFY(!shown.drop(shown.rows({}), Qt::MoveAction | Qt::CopyAction, shown.top(0))); + // A folder's contents ride with it, placed once. + QVERIFY(shown.drop(shown.rows({0, 1}), Qt::MoveAction | Qt::CopyAction, shown.below())); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1", "Folder 1", "Layer 2"})); + QCOMPARE(layerWith(*session, shown.id(2)).parentID, std::optional(folder)); + QCOMPARE(session->history.undoName(), QString("Move Layer")); + // Busy, no drop; a drop changing nothing records nothing. + const int steps = session->history.undoCount(); + session->setIsImporting(true); + QVERIFY(!shown.drop(shown.rows({0}), Qt::MoveAction | Qt::CopyAction, shown.below())); + session->setIsImporting(false); + QCOMPARE(session->history.undoCount(), steps); +} + +void LayerDragTests::anAltDragDropsDuplicates() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + const int steps = session->history.undoCount(); + QVERIFY(shown.drop(shown.rows({1}), Qt::CopyAction, shown.top(0))); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1 copy", "Layer 2", "Layer 1"})); + QCOMPARE(shown.lastAction, Qt::CopyAction); + QCOMPARE(session->history.undoName(), QString("Duplicate Layer")); + QCOMPARE(session->history.undoCount(), steps + 1); + QCOMPARE(session->activeLayerID(), std::optional(shown.id(0))); + QVERIFY(shown.drop(shown.rows({1, 2}), Qt::CopyAction, shown.below())); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1 copy", "Layer 2", "Layer 1", "Layer 2 copy", "Layer 1 copy"})); + QCOMPARE(session->history.undoName(), QString("Duplicate Layers")); + session->undo(); + QCOMPARE(shown.names().size(), size_t(3)); + // A folder among the dragged rows: no copy at all. + session->selectLayer(shown.id(2)); + session->addGroup(); + QVERIFY(!shown.drop(shown.rows({0, 2}), Qt::CopyAction, shown.below())); + QCOMPARE(shown.names().size(), size_t(4)); +} + +void LayerDragTests::aMaskDragCopiesTheMaskOntoTheRowUnderIt() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + session->selectLayer(shown.id(1)); + session->addLayerMask(); + const auto mask = [&](int row) { + auto *data = new QMimeData; + data->setData(NativeLayerList::maskType, uuidString(shown.id(row)).toUtf8()); + return data; + }; + QVERIFY(!shown.drop(mask(1), Qt::CopyAction, shown.middle(1))); + QVERIFY(!shown.drop(mask(0), Qt::CopyAction, shown.middle(1))); + QVERIFY(!layerWith(*session, shown.id(0)).mask.has_value()); + QVERIFY(shown.drop(mask(1), Qt::CopyAction, shown.middle(0))); + QVERIFY(layerWith(*session, shown.id(0)).mask.has_value()); + QCOMPARE(session->history.undoName(), QString("Copy Layer Mask")); + QVERIFY(!shown.drop(mask(1), Qt::CopyAction, shown.below())); + // No-id payloads name no mask, zeros included. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, shown.id(1)).id = QUuid(); }); + QCOMPARE(shown.id(1), QUuid()); + session->selectLayer(QUuid()); + session->addLayerMask(); + QVERIFY(session->canCopyMask(QUuid(), shown.id(0))); + const int steps = session->history.undoCount(); + auto *garbage = new QMimeData; + garbage->setData(NativeLayerList::maskType, "invalid mask id"); + QVERIFY(!shown.drop(garbage, Qt::CopyAction, shown.middle(0))); + QCOMPARE(session->history.undoCount(), steps); +} + +void LayerDragTests::theIndicatorShowsWhereTheDropLands() +{ + const auto session = sessionWithLayers(3); + Shown shown(*session); + session->selectLayer(shown.id(0)); + session->addGroup(); + auto *column = shown.list.findChild<LayerColumn *>(); + QVERIFY(column != nullptr); + const auto enter = [&](QMimeData *data, QPoint listPoint) { + const QPoint inViewport = shown.list.viewport()->mapFromParent(listPoint); + QDragEnterEvent event(inViewport, Qt::MoveAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &event); + delete data; + return event.isAccepted(); + }; + // A line above the row; a folder's middle fills it. + QVERIFY(enter(shown.rows({3}), shown.top(2))); + QCOMPARE(column->indicator, std::optional(QRect(0, shown.list.cells().at(2)->geometry().top() - 2, column->width(), 2))); + QVERIFY(!column->indicatorFills); + QVERIFY(enter(shown.rows({3}), shown.middle(0))); + QCOMPARE(column->indicator, std::optional(shown.list.cells().at(0)->geometry())); + QVERIFY(column->indicatorFills); + QVERIFY(enter(shown.rows({3}), shown.below())); + QCOMPARE(column->indicator, std::optional(QRect(0, shown.list.cells().back()->geometry().bottom(), column->width(), 2))); + // Refused, nothing shows; left, nothing stays. + QVERIFY(!enter(shown.rows({0}), shown.middle(0))); + QVERIFY(!column->indicator.has_value()); + QVERIFY(enter(shown.rows({3}), shown.top(2))); + QDragLeaveEvent leave; + QApplication::sendEvent(shown.list.viewport(), &leave); + QVERIFY(!column->indicator.has_value()); +} + +void LayerDragTests::anotherListsRowsLandNowhere() +{ + const auto session = sessionWithLayers(2); + Shown shown(*session); + NativeLayerList other(*session); + // The same ids, dragged from another list: refused. + std::unique_ptr<QMimeData> foreign = other.dragData(*other.cells().at(1)); + auto *copy = new QMimeData; + for (const QString &format : foreign->formats()) + copy->setData(format, foreign->data(format)); + QVERIFY(!shown.drop(copy, Qt::MoveAction | Qt::CopyAction, shown.top(0))); + std::unique_ptr<QMimeData> own = shown.list.dragData(*shown.list.cells().at(1)); + auto *mine = new QMimeData; + for (const QString &format : own->formats()) + mine->setData(format, own->data(format)); + QVERIFY(shown.drop(mine, Qt::MoveAction | Qt::CopyAction, shown.top(0))); + QCOMPARE(shown.names(), (std::vector<QString>{"Layer 1", "Layer 2"})); +} + +void LayerDragTests::aDragNearTheEdgeScrollsTheList() +{ + const auto session = sessionWithLayers(15); + Shown shown(*session); + shown.list.resize(252, 200); + QTRY_VERIFY(shown.list.verticalScrollBar()->maximum() > 0); + QMimeData *data = shown.rows({0}); + const QPoint edge(100, shown.list.viewport()->height() - 2); + QDragEnterEvent enter(edge, Qt::MoveAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &enter); + // Held at the bottom edge, each move scrolls further. + for (int each = 0; each < 3; ++each) { + QDragMoveEvent move(edge, Qt::MoveAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &move); + } + QCOMPARE(shown.list.verticalScrollBar()->value(), 42); + auto *column = shown.list.findChild<LayerColumn *>(); + QVERIFY(column->indicator.has_value()); + QDragMoveEvent top(QPoint(100, 1), Qt::MoveAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &top); + QCOMPARE(shown.list.verticalScrollBar()->value(), 27); + QDragLeaveEvent leave; + QApplication::sendEvent(shown.list.viewport(), &leave); + delete data; +} + +void LayerDragTests::aDragHeldAtTheEdgeScrollsOnAndAClipRefreshesTheCursor() +{ + const auto session = sessionWithLayers(15); + Shown shown(*session); + shown.list.resize(252, 200); + QTRY_VERIFY(shown.list.verticalScrollBar()->maximum() > 0); + QMimeData *data = shown.rows({0}); + const QPoint edge(100, shown.list.viewport()->height() - 2); + QDragEnterEvent enter(edge, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &enter); + QDragMoveEvent move(edge, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &move); + // One move, then a still pointer: the list scrolls on. + QCOMPARE(shown.list.verticalScrollBar()->value(), 14); + QVERIFY(shown.list.autoscrolling()); + QTRY_VERIFY(shown.list.verticalScrollBar()->value() >= 70); + auto *column = shown.list.findChild<LayerColumn *>(); + QVERIFY(column->indicator.has_value()); + // Back inside the scrolling rests; left, it stops. + QDragMoveEvent inside(QPoint(100, 100), Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &inside); + QVERIFY(!shown.list.autoscrolling()); + const int resting = shown.list.verticalScrollBar()->value(); + QTest::qWait(150); + QCOMPARE(shown.list.verticalScrollBar()->value(), resting); + QDragLeaveEvent leave; + QApplication::sendEvent(shown.list.viewport(), &leave); + const int rested = shown.list.verticalScrollBar()->value(); + QTest::qWait(150); + QCOMPARE(shown.list.verticalScrollBar()->value(), rested); + QVERIFY(!column->indicator.has_value()); + delete data; + // An Alt click on the strip clips; the cursor follows. + shown.list.verticalScrollBar()->setValue(0); + LayerCell &row = *shown.list.cells().at(0); + const QPoint strip(row.width() - 20, LayerCell::rowHeight - 4); + QTest::mouseClick(&row, Qt::LeftButton, Qt::AltModifier, strip); + QVERIFY(layerWith(*session, shown.id(0)).maskSourceID.has_value()); + QCOMPARE(image(shown.list.viewport()->cursor()), image(NativeLayerList::clippingCursor(true, shown.list.devicePixelRatio()))); + QTest::mouseClick(&row, Qt::LeftButton, Qt::AltModifier, strip); + QCOMPARE(image(shown.list.viewport()->cursor()), image(NativeLayerList::clippingCursor(false, shown.list.devicePixelRatio()))); +} + +void LayerDragTests::leavingOrDroppingAtTheEdgeStopsTheScrolling() +{ + const auto session = sessionWithLayers(15); + Shown shown(*session); + shown.list.resize(252, 200); + QTRY_VERIFY(shown.list.verticalScrollBar()->maximum() > 0); + const QPoint edge(100, shown.list.viewport()->height() - 2); + const auto scrollAtTheEdge = [&](QMimeData *data) { + QDragEnterEvent enter(edge, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &enter); + QDragMoveEvent move(edge, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &move); + QVERIFY(shown.list.autoscrolling()); + }; + // Left at the edge, the timer stops for good. + QMimeData *data = shown.rows({0}); + scrollAtTheEdge(data); + QDragLeaveEvent leave; + QApplication::sendEvent(shown.list.viewport(), &leave); + QVERIFY(!shown.list.autoscrolling()); + delete data; + const int left = shown.list.verticalScrollBar()->value(); + QTest::qWait(120); + QCOMPARE(shown.list.verticalScrollBar()->value(), left); + // Dropped at the edge, the same; the row lands. + data = shown.rows({0}); + scrollAtTheEdge(data); + QDropEvent drop(edge, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(shown.list.viewport(), &drop); + QVERIFY(drop.isAccepted()); + QVERIFY(!shown.list.autoscrolling()); + delete data; + const int dropped = shown.list.verticalScrollBar()->value(); + QTest::qWait(120); + QCOMPARE(shown.list.verticalScrollBar()->value(), dropped); + QCOMPARE(session->history.undoName(), QString("Move Layer")); +} + +QTEST_MAIN(LayerDragTests) +#include "LayerDragTests.moc" diff --git a/tests/LayerEffectRowTests.cpp b/tests/LayerEffectRowTests.cpp new file mode 100644 index 0000000..5335b83 --- /dev/null +++ b/tests/LayerEffectRowTests.cpp @@ -0,0 +1,492 @@ +#include "Document/ProjectWorkspace.h" +#include "Rendering/EditorCanvas.h" +#include "UI/LayerIcons.h" +#include "UI/NativeLayerList.h" +#include "SessionFixtures.h" +#include <QAccessible> +#include <QDragEnterEvent> +#include <QDropEvent> +#include <QDrag> +#include <QMimeData> +#include <QtTest> + +namespace { +// What a started drag offered: no drag loop runs offscreen. +struct StartedDrag { + QByteArray effect; + QByteArray source; + Qt::DropActions supported; + QImage cursor; + QSize pixmap; +}; +std::vector<StartedDrag> startedDrags; +} + +// Takes libQt6Gui's place here, as the gesture tests do. +Qt::DropAction QDrag::exec(Qt::DropActions supportedActions, Qt::DropAction) +{ + startedDrags.push_back({mimeData()->data(NativeLayerList::effectType), mimeData()->data(NativeLayerList::sourceType), supportedActions, + dragCursor(Qt::CopyAction).toImage(), pixmap().size()}); + return Qt::IgnoreAction; +} + +Qt::DropAction QDrag::exec(Qt::DropActions supportedActions) +{ + return exec(supportedActions, Qt::IgnoreAction); +} + +namespace { +ImportedImage white(const QString &name) +{ + QImage image(40, 30, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + return ImportedImage(image, image, name); +} + +// A shown list of white layers, the top one first. +struct Listed { + EditorSession session; + NativeLayerList list{session}; + explicit Listed(int layers = 2) + { + session.createDocument(40, 30); + for (int each = 0; each < layers; ++each) + session.insert(white(QStringLiteral("White %1").arg(each + 1))); + list.resize(252, 400); + list.show(); + if (!QTest::qWaitForWindowActive(&list)) + throw std::runtime_error("the list never became active"); + } + LayerCell &cell(int row) { return *list.cells().at(size_t(row)); } + QUuid id(int row) { return cell(row).layerID(); } + // The effect rows a cell shows, top down. + std::vector<LayerEffectRow *> effects(int row) + { + std::vector<LayerEffectRow *> shown; + for (LayerEffectRow *each : cell(row).findChildren<LayerEffectRow *>()) { + if (each->isVisibleTo(&cell(row))) + shown.push_back(each); + } + std::sort(shown.begin(), shown.end(), [](const LayerEffectRow *a, const LayerEffectRow *b) { return a->y() < b->y(); }); + return shown; + } + QToolButton &eye(LayerEffectRow &row) { return *row.findChild<QToolButton *>("effectEye"); } + QLabel &label(LayerEffectRow &row) { return *row.findChild<QLabel *>(); } + // Presses reach the window, as real clicks do. + QPoint at(QWidget &widget, QPoint point) { return widget.mapTo(&list, point); } + void press(QWidget &widget, QPoint point, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QTest::mousePress(list.windowHandle(), Qt::LeftButton, modifiers, at(widget, point)); + } + void release(QWidget &widget, QPoint point, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QTest::mouseRelease(list.windowHandle(), Qt::LeftButton, modifiers, at(widget, point)); + } + void click(QWidget &widget, QPoint point, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QTest::mouseClick(list.windowHandle(), Qt::LeftButton, modifiers, at(widget, point)); + } + std::optional<LayerEffectSelection> chosen() { return session.selectedEffect(); } + int row(QUuid id) + { + for (size_t each = 0; each < list.cells().size(); ++each) { + if (list.cells()[each]->layerID() == id) + return int(each); + } + throw std::runtime_error("no such row"); + } + // The eye shows this glyph in the faint ink. + bool eyeShows(LayerEffectRow &row, LayerIcon glyph) + { + const double ratio = row.devicePixelRatio(); + const QImage expected = LayerIcons::pixmap(glyph, 16, row.palette().color(QPalette::PlaceholderText), ratio).toImage(); + return eye(row).icon().pixmap(QSize(16, 16), ratio).toImage().convertToFormat(expected.format()) == expected; + } + // The drag events a drop brings, offering a copy alone. + bool drop(QMimeData *data, QPoint listPoint) + { + const QPointF inViewport = list.viewport()->mapFromParent(listPoint); + QDragEnterEvent enter(inViewport.toPoint(), Qt::CopyAction, data, Qt::LeftButton, Qt::AltModifier); + QApplication::sendEvent(list.viewport(), &enter); + QDropEvent drop(inViewport, Qt::CopyAction, data, Qt::LeftButton, Qt::AltModifier); + QApplication::sendEvent(list.viewport(), &drop); + delete data; + lastAction = drop.dropAction(); + return drop.isAccepted(); + } + Qt::DropAction lastAction = Qt::IgnoreAction; +}; + +LayerEffects strokeAndShadow() +{ + return LayerEffects{.stroke = StrokeEffect(), .shadow = ShadowEffect()}; +} + +QMimeData *effectDrag(const NativeLayerList &list, const QString &payload) +{ + auto *data = new QMimeData; + data->setData(NativeLayerList::effectType, payload.toUtf8()); + data->setData(NativeLayerList::sourceType, list.dragToken().toUtf8()); + return data; +} +} + +// The effects listed under their layer: rows, presses, copies. +class LayerEffectRowTests : public QObject { + Q_OBJECT +private slots: + void rowsListTheLayersEffects(); + void aClickChoosesAndADoubleClickEdits(); + void theEyeShowsOrHidesItsEffect(); + void pressesFollowSwiftsOrder(); + void aPressBelowTheRowsLetsGoOfEverything(); + void anAltDragCopiesTheEffect(); + void effectDropsLandOnTheRowUnderThePointer(); + void aRowRebuiltUnderItsOwnPressLivesOn(); + void aScreenReaderPressChoosesTheEffect(); + void altShowsTheCursorsOverATallerRow(); +}; + +void LayerEffectRowTests::rowsListTheLayersEffects() +{ + Listed listed; + const QUuid top = listed.id(0); + QCOMPARE(listed.cell(0).height(), LayerCell::rowHeight); + listed.session.setEffects(strokeAndShadow(), top); + // Each effect adds a row under the layer, Swift's order. + QCOMPARE(listed.cell(0).height(), 100); + const std::vector<LayerEffectRow *> rows = listed.effects(0); + QVERIFY(rows.size() == 2 && rows[0]->kind == LayerEffectKind::stroke && rows[1]->kind == LayerEffectKind::shadow && rows[1]->layerID == top); + QCOMPARE(rows[0]->geometry(), QRect(0, 52, listed.cell(0).width(), 24)); + QCOMPARE(rows[1]->geometry(), QRect(0, 76, listed.cell(0).width(), 24)); + LayerEffectRow &shadow = *rows[1]; + QCOMPARE(listed.eye(shadow).geometry(), QRect(38, 1, 20, 22)); + QVERIFY(listed.label(shadow).text() == "Drop Shadow" && listed.label(shadow).font().pixelSize() == 11); + QCOMPARE(listed.label(shadow).geometry(), QRect(66, 0, listed.cell(0).width() - 74, 24)); + QCOMPARE(shadow.toolTip(), QString("Click to select; double-click to edit; Alt-drag to copy drop shadow")); + QVERIFY(shadow.accessibleName() == "Drop Shadow effect" && listed.eye(shadow).accessibleName() == "Hide Drop Shadow"); + QVERIFY(listed.eyeShows(shadow, LayerIcon::eye) && listed.label(shadow).foregroundRole() == QPalette::WindowText); + // A flat eye; every control reports its hover. + QVERIFY(listed.eye(shadow).autoRaise() && shadow.hasMouseTracking()); + for (const QWidget *child : shadow.findChildren<QWidget *>()) + QVERIFY2(child->hasMouseTracking(), qPrintable(child->objectName())); + // The row's edge moves down with it. + const QImage cell = listed.cell(0).grab().toImage(); + QVERIFY(cell.pixel(200, 99) != cell.pixel(200, 98) && cell.pixel(200, 51) == cell.pixel(200, 50)); + // Hidden, the effect dims; rows stay while kinds do. + listed.session.toggleEffect(LayerEffectKind::shadow, top); + QVERIFY(listed.effects(0) == rows && listed.eye(shadow).accessibleName() == "Show Drop Shadow"); + QVERIFY(listed.eyeShows(shadow, LayerIcon::eyeSlash) && listed.label(shadow).foregroundRole() == QPalette::PlaceholderText); + listed.session.selectEffect(LayerEffectKind::stroke, top); + QVERIFY(listed.effects(0) == rows); + // Other kinds make new rows; the old go later. + const QPointer<LayerEffectRow> old = rows[0]; + listed.session.setEffects(LayerEffects{.colorOverlay = ColorOverlayEffect()}, top); + QVERIFY(listed.effects(0).size() == 1 && listed.effects(0)[0]->kind == LayerEffectKind::colorOverlay && listed.cell(0).height() == 76); + QTRY_VERIFY(!old); + // A clipped layer's effects step in with it. + listed.session.toggleClippingMask(top); + QCOMPARE(listed.eye(*listed.effects(0)[0]).x(), 62); + // Narrow, a name ends in an ellipsis. + listed.session.setEffects(strokeAndShadow(), top); + listed.list.resize(150, 400); + QVERIFY(listed.label(*listed.effects(0)[1]).text().endsWith(QChar(0x2026)) && listed.label(*listed.effects(0)[1]).width() == 150 - 98); + // Without effects the row is 52 again. + listed.session.setEffects(LayerEffects(), top); + QVERIFY(listed.effects(0).empty() && listed.cell(0).height() == 52); +} + +void LayerEffectRowTests::aClickChoosesAndADoubleClickEdits() +{ + Listed listed; + const QUuid top = listed.id(0), below = listed.id(1); + listed.session.setEffects(strokeAndShadow(), top); + listed.session.selectLayer(below); + const QImage unselected = listed.cell(0).grab().toImage(); + LayerEffectRow &stroke = *listed.effects(0)[0]; + // A click chooses the effect and its layer, taking keys. + listed.list.clearFocus(); + listed.click(stroke, QPoint(150, 12)); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::stroke})); + QVERIFY(listed.session.selectedLayerIDs() == QSet<QUuid>{top} && !listed.session.effectsEditing() && listed.list.hasFocus()); + // The chosen row shows the accent; the layer's highlight hides. + const QImage chosen = listed.cell(0).grab().toImage(); + QVERIFY(chosen.pixel(200, 20) == unselected.pixel(200, 20) && chosen.pixel(200, 88) == unselected.pixel(200, 88)); + const QColor under = unselected.pixelColor(200, 64), accent = stroke.palette().color(QPalette::Highlight), shown = chosen.pixelColor(200, 64); + QVERIFY(std::abs(shown.red() - (0.3 * accent.red() + 0.7 * under.red())) <= 1.5); + QVERIFY(std::abs(shown.blue() - (0.3 * accent.blue() + 0.7 * under.blue())) <= 1.5); + // Other buttons choose nothing. + listed.session.dropEffectSelection(); + QTest::mousePress(&stroke, Qt::RightButton, Qt::NoModifier, QPoint(150, 12)); + QTest::mouseRelease(&stroke, Qt::RightButton, Qt::NoModifier, QPoint(150, 12)); + QVERIFY(!listed.chosen()); + // Let go of, the layer's highlight comes back. + listed.click(listed.cell(0), QPoint(listed.cell(0).width() - 20, 20)); + QVERIFY(!listed.chosen() && listed.cell(0).grab().toImage().pixel(200, 20) != unselected.pixel(200, 20)); + // A double click opens the effect's panel, and renames nothing. + QTest::mouseDClick(listed.list.windowHandle(), Qt::LeftButton, Qt::NoModifier, listed.at(*listed.effects(0)[1], QPoint(150, 12))); + QVERIFY(listed.session.effectsEditing() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow}) && !listed.session.renamingLayerID()); +} + +void LayerEffectRowTests::theEyeShowsOrHidesItsEffect() +{ + Listed listed; + const QUuid top = listed.id(0); + listed.session.setEffects(strokeAndShadow(), top); + LayerEffectRow &stroke = *listed.effects(0)[0]; + // One step each way; the choice stays as it was. + listed.click(listed.eye(stroke), QPoint(10, 11)); + QVERIFY(!layerWith(listed.session, top).effects->stroke->isEnabled() && listed.session.history.undoName() == "Hide Stroke"); + QVERIFY(!listed.chosen() && listed.eye(stroke).accessibleName() == "Show Stroke"); + listed.click(listed.eye(stroke), QPoint(10, 11)); + QVERIFY(layerWith(listed.session, top).effects->stroke->isEnabled() && listed.session.history.undoName() == "Show Stroke"); + // It rests while layers cannot be edited. + listed.session.setIsProjectBusy(true); + QVERIFY(!listed.eye(stroke).isEnabled()); + listed.click(listed.eye(stroke), QPoint(10, 11)); + QVERIFY(layerWith(listed.session, top).effects->stroke->isEnabled() && !listed.chosen()); + listed.session.setIsProjectBusy(false); + QVERIFY(listed.eye(stroke).isEnabled()); +} + +void LayerEffectRowTests::pressesFollowSwiftsOrder() +{ + Listed listed; + const QUuid top = listed.id(0), below = listed.id(1); + listed.session.setEffects(strokeAndShadow(), top); + LayerEffectRow &stroke = *listed.effects(0)[0]; + LayerEffectRow &shadow = *listed.effects(0)[1]; + // Alt on the strip, over the last effect, clips. + listed.session.selectEffect(LayerEffectKind::shadow, top); + listed.click(shadow, QPoint(150, 20), Qt::AltModifier); + QVERIFY(layerWith(listed.session, top).maskSourceID == below && !listed.chosen()); + // Above it, Alt arms a copy; the release chooses. + listed.press(shadow, QPoint(150, 5), Qt::AltModifier); + QVERIFY(!listed.chosen()); + listed.release(shadow, QPoint(150, 5), Qt::AltModifier); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow}) && layerWith(listed.session, top).maskSourceID == below); + // Alt on the eye is the row's: nothing hides. + listed.session.dropEffectSelection(); + listed.press(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier); + listed.release(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier); + QVERIFY(layerWith(listed.session, top).effects->shadow->isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + // An Alt double click on the eye hides nothing either. + listed.session.dropEffectSelection(); + QTest::mouseDClick(listed.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, listed.at(listed.eye(shadow), QPoint(12, 5))); + QVERIFY(layerWith(listed.session, top).effects->shadow->isEnabled() && listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + // On the strip, the eye's Alt press clips too. + listed.click(listed.eye(shadow), QPoint(10, 20), Qt::AltModifier); + QVERIFY(!layerWith(listed.session, top).maskSourceID && !listed.chosen() && layerWith(listed.session, top).effects->shadow->isEnabled()); + // Its own row's press lets go, keeping the layer. + listed.session.selectEffect(LayerEffectKind::stroke, top); + listed.click(listed.cell(0), QPoint(listed.cell(0).width() - 20, 20)); + QVERIFY(!listed.chosen() && listed.session.selectedLayerIDs() == QSet<QUuid>{top}); + // Ctrl on another row lets go too, extending the layers. + listed.session.selectEffect(LayerEffectKind::stroke, top); + listed.click(listed.cell(1), QPoint(listed.cell(1).width() - 20, 20), Qt::ControlModifier); + QVERIFY(!listed.chosen() && listed.session.selectedLayerIDs() == (QSet<QUuid>{top, below})); + // A thumbnail's Ctrl-click loads a selection and keeps the choice. + listed.session.selectEffect(LayerEffectKind::stroke, top); + listed.click(listed.cell(0).thumbnail(), QPoint(5, 5), Qt::ControlModifier); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::stroke}) && listed.session.selection()); + // Ctrl with Alt copies nothing: rows choose, eyes hide. + listed.press(shadow, QPoint(150, 5), Qt::AltModifier | Qt::ControlModifier); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::shadow})); + listed.release(shadow, QPoint(150, 5), Qt::AltModifier | Qt::ControlModifier); + listed.click(listed.eye(shadow), QPoint(10, 5), Qt::AltModifier | Qt::ControlModifier); + QVERIFY(!layerWith(listed.session, top).effects->shadow->isEnabled()); + // A plain press on another effect chooses it at once. + listed.press(stroke, QPoint(150, 12)); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::stroke})); + listed.release(stroke, QPoint(150, 12)); +} + +void LayerEffectRowTests::aPressBelowTheRowsLetsGoOfEverything() +{ + Listed listed; + const QUuid top = listed.id(0); + listed.session.setEffects(strokeAndShadow(), top); + const QPoint under = listed.at(listed.cell(1), QPoint(100, listed.cell(1).height() + 30)); + // Ctrl or Shift keep the layers, not the effect. + for (const Qt::KeyboardModifier modifier : {Qt::ControlModifier, Qt::ShiftModifier}) { + listed.session.selectEffect(LayerEffectKind::stroke, top); + QTest::mouseClick(listed.list.windowHandle(), Qt::LeftButton, modifier, under); + QVERIFY(!listed.chosen() && listed.session.selectedLayerIDs() == QSet<QUuid>{top}); + } + // Between rows, as before, a press changes nothing. + listed.session.selectEffect(LayerEffectKind::stroke, top); + QTest::mouseClick(listed.list.windowHandle(), Qt::LeftButton, Qt::NoModifier, listed.at(listed.cell(0), QPoint(100, listed.cell(0).height()))); + QVERIFY(listed.chosen() && listed.session.selectedLayerIDs() == QSet<QUuid>{top}); + // A plain press deselects every layer, as Swift's table does. + listed.list.clearFocus(); + QTest::mouseClick(listed.list.windowHandle(), Qt::LeftButton, Qt::NoModifier, under); + QVERIFY(!listed.chosen() && listed.session.selectedLayerIDs().isEmpty() && !listed.session.activeLayerID() && listed.list.hasFocus()); + // An empty list takes the press too. + Listed empty(0); + QTest::mouseClick(empty.list.windowHandle(), Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + QVERIFY(empty.session.selectedLayerIDs().isEmpty()); + // Other buttons leave everything. + listed.session.selectEffect(LayerEffectKind::stroke, top); + QTest::mouseClick(listed.list.windowHandle(), Qt::RightButton, Qt::NoModifier, under); + QVERIFY(listed.chosen() && listed.session.selectedLayerIDs() == QSet<QUuid>{top}); +} + +void LayerEffectRowTests::anAltDragCopiesTheEffect() +{ + Listed listed; + const QUuid top = listed.id(0); + listed.session.setEffects(strokeAndShadow(), top); + startedDrags.clear(); + LayerEffectRow &stroke = *listed.effects(0)[0]; + const int far = QApplication::startDragDistance() + 2; + const QPoint at(150, 5); + const auto move = [](QWidget &widget, QPoint point, Qt::MouseButtons buttons, Qt::KeyboardModifiers modifiers) { + QMouseEvent event(QEvent::MouseMove, QPointF(point), widget.mapToGlobal(QPointF(point)), Qt::NoButton, buttons, modifiers); + QApplication::sendEvent(&widget, &event); + }; + // Short of the distance nothing; at it, the effect goes. + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + move(stroke, at + QPoint(QApplication::startDragDistance() - 1, 0), Qt::LeftButton, Qt::AltModifier); + QCOMPARE(startedDrags.size(), size_t(0)); + move(stroke, at + QPoint(QApplication::startDragDistance(), 0), Qt::LeftButton, Qt::AltModifier); + QCOMPARE(startedDrags.size(), size_t(1)); + QCOMPARE(startedDrags.back().effect, (uuidString(top) + ":Stroke").toUtf8()); + QCOMPARE(startedDrags.back().source, listed.list.dragToken().toUtf8()); + QCOMPARE(startedDrags.back().supported, Qt::DropActions(Qt::CopyAction)); + QCOMPARE(startedDrags.back().cursor, CanvasView::duplicateCursor(listed.list.devicePixelRatio()).pixmap().toImage()); + QCOMPARE(startedDrags.back().pixmap, stroke.size() * stroke.devicePixelRatio()); + // The drag spends the press: its release chooses nothing. + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::AltModifier, at + QPoint(far, 0)); + QVERIFY(!listed.chosen()); + // Alt let go before the move: no drag, nothing chosen. + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + move(stroke, at + QPoint(far, 0), Qt::LeftButton, Qt::NoModifier); + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::NoModifier, at + QPoint(far, 0)); + QVERIFY(startedDrags.size() == 1 && !listed.chosen()); + // A hover drags nothing; another button's release chooses nothing. + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + move(stroke, at + QPoint(far, 0), Qt::NoButton, Qt::AltModifier); + QCOMPARE(startedDrags.size(), size_t(1)); + QTest::mouseRelease(&stroke, Qt::RightButton, Qt::AltModifier, at); + QVERIFY(!listed.chosen()); + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::AltModifier, at); + QVERIFY(listed.chosen() == (LayerEffectSelection{top, LayerEffectKind::stroke})); + // A release lost, the next press starts afresh. + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + QTest::mousePress(&stroke, Qt::LeftButton, Qt::NoModifier, at); + move(stroke, at + QPoint(far, 0), Qt::LeftButton, Qt::AltModifier); + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::NoModifier, at); + QCOMPARE(startedDrags.size(), size_t(1)); + // From the eye, Alt drags the effect too. + QTest::mousePress(&listed.eye(stroke), Qt::LeftButton, Qt::AltModifier, QPoint(10, 5)); + move(listed.eye(stroke), QPoint(10 + far, 5), Qt::LeftButton, Qt::AltModifier); + QCOMPARE(startedDrags.size(), size_t(2)); + QTest::mouseRelease(&listed.eye(stroke), Qt::LeftButton, Qt::AltModifier, QPoint(10 + far, 5)); + // Layers resting, Alt arms nothing; resting later, nothing goes. + listed.session.dropEffectSelection(); + listed.session.setIsProjectBusy(true); + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + listed.session.setIsProjectBusy(false); + move(stroke, at + QPoint(far, 0), Qt::LeftButton, Qt::AltModifier); + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::AltModifier, at); + QVERIFY(startedDrags.size() == 2 && !listed.chosen()); + QTest::mousePress(&stroke, Qt::LeftButton, Qt::AltModifier, at); + listed.session.setIsProjectBusy(true); + move(stroke, at + QPoint(far, 0), Qt::LeftButton, Qt::AltModifier); + listed.session.setIsProjectBusy(false); + QTest::mouseRelease(&stroke, Qt::LeftButton, Qt::AltModifier, at); + QVERIFY(startedDrags.size() == 2 && !listed.chosen()); +} + +void LayerEffectRowTests::effectDropsLandOnTheRowUnderThePointer() +{ + Listed listed(3); + const QUuid top = listed.id(0), middle = listed.id(1), bottom = listed.id(2); + StrokeEffect wide; + wide.size = 12; + listed.session.setEffects(LayerEffects{.stroke = wide}, top); + const QString payload = uuidString(top) + QStringLiteral(":Stroke"); + const QPoint onTop = listed.at(listed.cell(0), QPoint(100, 20)), onMiddle = listed.at(listed.cell(1), QPoint(100, 20)); + // The row under the pointer takes a copy. + const std::unique_ptr<QMimeData> probe(effectDrag(listed.list, payload)); + QCOMPARE(listed.list.dropTarget(*probe, Qt::CopyAction, onMiddle).value(), (LayerDropTarget{.row = 1, .onRow = true, .atBottom = false, .copying = true})); + QVERIFY(listed.drop(effectDrag(listed.list, payload), onMiddle)); + QVERIFY(listed.lastAction == Qt::CopyAction && layerWith(listed.session, middle).effects->stroke->size == 12); + QVERIFY(listed.session.history.undoName() == "Copy Stroke" && listed.chosen() == (LayerEffectSelection{middle, LayerEffectKind::stroke})); + // Refused: its own row, a missing kind, strangers, bad words. + QVERIFY(!listed.drop(effectDrag(listed.list, payload), onTop)); + QVERIFY(!listed.drop(effectDrag(listed.list, uuidString(bottom) + QStringLiteral(":Stroke")), onMiddle)); + auto *stranger = effectDrag(listed.list, payload); + stranger->setData(NativeLayerList::sourceType, QByteArray("another list")); + QVERIFY(!listed.drop(stranger, listed.at(listed.cell(2), QPoint(100, 20)))); + for (const QString &bad : {uuidString(top) + QStringLiteral(":Glow"), QStringLiteral("Stroke"), uuidString(top), uuidString(top) + QStringLiteral(":Stroke:x")}) + QVERIFY(!listed.drop(effectDrag(listed.list, bad), listed.at(listed.cell(2), QPoint(100, 20)))); + QVERIFY(!listed.drop(effectDrag(listed.list, payload), listed.at(listed.cell(2), QPoint(100, listed.cell(2).height() + 30)))); + QVERIFY(!layerWith(listed.session, bottom).effects); + // An empty layer and a folder hold no effects. + listed.session.selectLayer(bottom); + listed.session.addBlankLayer(); + const QUuid blank = listed.session.activeLayerID().value(); + QVERIFY(!listed.drop(effectDrag(listed.list, payload), listed.at(listed.cell(listed.row(blank)), QPoint(100, 20)))); + listed.session.addGroup(); + const QUuid folder = listed.session.activeLayerID().value(); + QVERIFY(!listed.drop(effectDrag(listed.list, payload), listed.at(listed.cell(listed.row(folder)), QPoint(100, 20)))); +} + +void LayerEffectRowTests::aRowRebuiltUnderItsOwnPressLivesOn() +{ + Listed listed; + const QUuid top = listed.id(0); + listed.session.setEffects(LayerEffects{.shadow = ShadowEffect()}, top); + // A new stroke's panel: its Cancel takes the stroke away. + listed.session.addEffect(LayerEffectKind::stroke); + const QPointer<LayerEffectRow> shadow = listed.effects(0)[1]; + QCOMPARE(shadow->kind, LayerEffectKind::shadow); + listed.list.clearFocus(); + // Editing the shadow cancels the stroke under the shadow's press. + QTest::mouseDClick(shadow.data(), Qt::LeftButton, Qt::NoModifier, QPoint(150, 12)); + QVERIFY(shadow && !shadow->isVisible()); + QVERIFY(listed.session.effectsEditing() == (LayerEffectSelection{top, LayerEffectKind::shadow}) && listed.list.hasFocus()); + QVERIFY(!layerWith(listed.session, top).effects->stroke && listed.effects(0).size() == 1); + QTRY_VERIFY(!shadow); +} + +void LayerEffectRowTests::aScreenReaderPressChoosesTheEffect() +{ + Listed listed; + listed.session.setEffects(strokeAndShadow(), listed.id(0)); + QAccessibleInterface *row = QAccessible::queryAccessibleInterface(listed.effects(0)[1]); + QVERIFY(row && row->role() == QAccessible::Grouping && row->text(QAccessible::Name) == "Drop Shadow effect"); + QCOMPARE(QAccessible::queryAccessibleInterface(&listed.cell(0))->role(), QAccessible::Client); + QCOMPARE(row->actionInterface()->actionNames(), QStringList{QAccessibleActionInterface::pressAction()}); + listed.list.clearFocus(); + row->actionInterface()->doAction(QAccessibleActionInterface::pressAction()); + QVERIFY(listed.chosen() == (LayerEffectSelection{listed.id(0), LayerEffectKind::shadow}) && listed.list.hasFocus()); +} + +void LayerEffectRowTests::altShowsTheCursorsOverATallerRow() +{ + Listed listed; + const QUuid top = listed.id(0); + listed.session.setEffects(strokeAndShadow(), top); + const double ratio = listed.list.devicePixelRatio(); + const auto image = [](const QCursor &cursor) { return cursor.pixmap().toImage(); }; + // The strip runs along the taller row's bottom. + QCOMPARE(image(listed.list.cursorFor(listed.at(listed.cell(0), QPoint(200, 96)), Qt::AltModifier)), image(NativeLayerList::clippingCursor(false, ratio))); + QCOMPARE(image(listed.list.cursorFor(listed.at(listed.cell(0), QPoint(200, 48)), Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + QCOMPARE(image(listed.list.cursorFor(listed.at(listed.cell(0), QPoint(200, 60)), Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + // Hovered with Alt, an effect shows the duplicate cursor. + QMouseEvent hover(QEvent::MouseMove, QPointF(150, 12), listed.effects(0)[0]->mapToGlobal(QPointF(150, 12)), Qt::NoButton, Qt::NoButton, + Qt::AltModifier); + QApplication::sendEvent(listed.effects(0)[0], &hover); + QCOMPARE(image(listed.list.viewport()->cursor()), image(CanvasView::duplicateCursor(ratio))); + // Where the strip was, Alt no longer clips. + listed.click(listed.cell(0), QPoint(200, 48), Qt::AltModifier); + QVERIFY(!layerWith(listed.session, top).maskSourceID); + listed.click(listed.cell(0), QPoint(200, 96), Qt::AltModifier); + QVERIFY(layerWith(listed.session, top).maskSourceID); +} + +QTEST_MAIN(LayerEffectRowTests) +#include "LayerEffectRowTests.moc" diff --git a/tests/LayerEffectsKernelTests.cpp b/tests/LayerEffectsKernelTests.cpp new file mode 100644 index 0000000..688309a --- /dev/null +++ b/tests/LayerEffectsKernelTests.cpp @@ -0,0 +1,320 @@ +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "Document/LayerEffects+Renderer.h" +#include "Rendering/LayerEffectsKernel.h" +#include "AddressSpaceLimit.h" +#include <QPainter> +#include <QtTest> +#include <cmath> + +// The CPU kernel against Metal's shader, replayed loop for loop. +namespace { +// Swift's MetalLayerEffects source, one kernel a function, naively. +struct Shader { + int width, height; + std::vector<float> shape; + float at(const std::vector<float> &values, int x, int y) const { return values[size_t(y * width + x)]; } + + std::vector<float> spread(const std::vector<float> &source, int reach, bool smallest, bool rows) const + { + std::vector<float> result(source.size()); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float best = smallest ? 1.f : 0.f; + for (int offset = -reach; offset <= reach; ++offset) { + const int sample = (rows ? x : y) + offset; + const bool outside = sample < 0 || sample >= (rows ? width : height); + const float value = outside ? 0.f : rows ? at(source, sample, y) : at(source, x, sample); + best = smallest ? std::min(best, value) : std::max(best, value); + } + result[size_t(y * width + x)] = best; + } + } + return result; + } + + std::vector<float> shift(const std::vector<float> &source, float dx, float dy) const + { + std::vector<float> result(source.size()); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const float sx = float(x) - dx, sy = float(y) - dy; + float value = 0.f; + if (sx >= 0.f && sy >= 0.f && sx <= float(width - 1) && sy <= float(height - 1)) { + const int x0 = int(std::floor(sx)), y0 = int(std::floor(sy)); + const int x1 = std::min(x0 + 1, width - 1), y1 = std::min(y0 + 1, height - 1); + const float fx = sx - float(x0), fy = sy - float(y0); + const float top = at(source, x0, y0) + (at(source, x1, y0) - at(source, x0, y0)) * fx; + const float bottom = at(source, x0, y1) + (at(source, x1, y1) - at(source, x0, y1)) * fx; + value = top + (bottom - top) * fy; + } + result[size_t(y * width + x)] = value; + } + } + return result; + } + + std::vector<float> blur(const std::vector<float> &source, float sigma, bool rows) const + { + const int radius = std::max(1, int(std::lround(sigma * 3))); + std::vector<float> result(source.size()); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float total = 0.f, weightSum = 0.f; + for (int offset = -radius; offset <= radius; ++offset) { + const float weight = std::exp(-float(offset * offset) / (2.f * sigma * sigma)); + const int sample = std::clamp((rows ? x : y) + offset, 0, (rows ? width : height) - 1); + total += weight * (rows ? at(source, sample, y) : at(source, x, sample)); + weightSum += weight; + } + result[size_t(y * width + x)] = total / weightSum; + } + } + return result; + } + + std::vector<float> softened(const std::vector<float> &source, float sigma) const + { + return sigma > 0.01f ? blur(blur(source, sigma, true), sigma, false) : source; + } +}; + +QImage replayed(const QImage &pixels, const LayerEffects &effects) +{ + Shader shader{pixels.width(), pixels.height(), {}}; + for (int y = 0; y < pixels.height(); ++y) { + for (int x = 0; x < pixels.width(); ++x) + shader.shape.push_back(float(pixels.constScanLine(y)[x * 4 + 3]) / 255.f); + } + const auto on = [](const auto &effect) { return effect && effect->isEnabled() && effect->opacity > 0; }; + const bool stroke = on(effects.stroke) && effects.stroke->size > 0; + std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()); + if (stroke) { + const int reach = std::max(1, int(std::lround(effects.stroke->size))); + const std::vector<float> moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke->inside, true), reach, effects.stroke->inside, false); + for (size_t index = 0; index < ring.size(); ++index) + ring[index] = std::clamp(effects.stroke->inside ? shader.shape[index] - moved[index] : moved[index] - shader.shape[index], 0.f, 1.f); + } + if (on(effects.shadow)) + cast = shader.softened(shader.shift(shader.shape, float(effects.shadow->offset().width()), float(effects.shadow->offset().height())), + float(effects.shadow->blur / 2)); + if (on(effects.innerShadow)) { + const std::vector<float> moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow->offset().width()), + float(effects.innerShadow->offset().height())), + float(effects.innerShadow->blur / 2)); + for (size_t index = 0; index < inner.size(); ++index) + inner[index] = std::clamp(shader.shape[index] * (1.f - moved[index]), 0.f, 1.f); + } + QImage result(pixels.size(), QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < pixels.height(); ++y) { + for (int x = 0; x < pixels.width(); ++x) { + const size_t index = size_t(y * pixels.width() + x); + float colour[3] = {0, 0, 0}, alpha = 0; + const auto blend = [&](const PaletteColor &paint, float coverage) { + const float mix[3] = {float(paint.red), float(paint.green), float(paint.blue)}; + for (int channel = 0; channel < 3; ++channel) + colour[channel] = mix[channel] * coverage + colour[channel] * (1.f - coverage); + alpha = coverage + alpha * (1.f - coverage); + }; + if (on(effects.shadow)) + blend(effects.shadow->color(), std::clamp(cast[index] * float(effects.shadow->opacity), 0.f, 1.f)); + const float strokeCoverage = stroke ? std::clamp(ring[index] * float(effects.stroke->opacity), 0.f, 1.f) : 0.f; + if (stroke && !effects.stroke->inside) + blend(effects.stroke->color(), strokeCoverage); + const uchar *source = pixels.constScanLine(y) + x * 4; + const float sourceAlpha = float(source[3]) / 255.f; + for (int channel = 0; channel < 3; ++channel) + colour[channel] = float(source[channel]) / 255.f + colour[channel] * (1.f - sourceAlpha); + alpha = sourceAlpha + alpha * (1.f - sourceAlpha); + if (on(effects.colorOverlay)) + blend(effects.colorOverlay->color(), std::clamp(shader.shape[index] * float(effects.colorOverlay->opacity), 0.f, 1.f)); + if (on(effects.innerShadow)) + blend(effects.innerShadow->color(), std::clamp(inner[index] * float(effects.innerShadow->opacity), 0.f, 1.f)); + if (stroke && effects.stroke->inside) + blend(effects.stroke->color(), strokeCoverage); + uchar *out = result.scanLine(y) + x * 4; + for (int channel = 0; channel < 3; ++channel) + out[channel] = uchar(std::clamp(colour[channel], 0.f, 1.f) * 255.f + 0.5f); + out[3] = uchar(std::clamp(alpha, 0.f, 1.f) * 255.f + 0.5f); + } + } + return result; +} + +// A soft disc and a hard bar touching the edge. +QImage figure(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(200, 40, 90)); + painter.drawEllipse(QRectF(width * 0.2, height * 0.2, width * 0.45, height * 0.5)); + painter.fillRect(QRectF(width * 0.7, 0, width * 0.15, height * 0.6), QColor(20, 180, 60, 160)); + // Wide shapes on every edge, alpha ramping to it. + painter.end(); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const bool bottom = y >= height - 5 && x < 14, right = x >= width - 6 && y >= 8 && y < 22, left = x < 6 && y >= 10 && y < 24; + const int alpha = bottom ? 255 - (height - 1 - y) * 30 : right ? 255 - (width - 1 - x) * 25 : left ? 250 - x * 30 : 0; + if (alpha > 0) + std::fill_n(image.scanLine(y) + x * 4, 4, uchar(alpha)); + } + } + return image; +} + +int gap(const QImage &lhs, const QImage &rhs) +{ + int worst = 0; + for (int y = 0; y < lhs.height(); ++y) { + for (int x = 0; x < lhs.width() * 4; ++x) + worst = std::max(worst, std::abs(int(lhs.constScanLine(y)[x]) - int(rhs.constScanLine(y)[x]))); + } + return worst; +} +} + +class LayerEffectsKernelTests : public QObject { + Q_OBJECT +private slots: + void everyEffectMatchesTheShader_data(); + void everyEffectMatchesTheShader(); + void switchedOffOrEmptyEffectsLeaveThePixels(); + void theKernelRefusesWhatItCannotHold(); + void aStarvedKernelThrowsWhereItsCallerCatches(); + void thinLayersAndTallOnesAreCheap(); +}; + +void LayerEffectsKernelTests::everyEffectMatchesTheShader_data() +{ + QTest::addColumn<LayerEffects>("effects"); + LayerEffects outside, inside, shadow, overlay, inner, all, wide; + outside.stroke = StrokeEffect{.size = 3, .red = 1, .green = 0.5, .blue = 0.25, .opacity = 0.8}; + inside.stroke = StrokeEffect{.size = 2.5, .red = 0.1, .green = 0.2, .blue = 0.9, .opacity = 1, .inside = true}; + shadow.shadow = ShadowEffect{.angle = 45, .distance = 3.5, .blur = 4, .red = 0.3, .green = 0.1, .blue = 0.6, .opacity = 0.75}; + overlay.colorOverlay = ColorOverlayEffect{.red = 0.9, .green = 0.8, .blue = 0.1, .opacity = 0.5}; + inner.innerShadow = InnerShadowEffect{.angle = -60, .distance = 2, .blur = 3, .red = 0, .green = 0.4, .blue = 0.2, .opacity = 0.9}; + all = outside; + all.shadow = shadow.shadow; + all.colorOverlay = overlay.colorOverlay; + all.innerShadow = inner.innerShadow; + all.shadow->blur = 0; + wide.stroke = StrokeEffect{.size = 9, .red = 1, .opacity = 1}; + wide.shadow = ShadowEffect{.angle = 200, .distance = 0.5, .blur = 0.01, .opacity = 1}; + QTest::newRow("outside stroke") << outside; + QTest::newRow("inside stroke") << inside; + QTest::newRow("drop shadow") << shadow; + QTest::newRow("colour overlay") << overlay; + QTest::newRow("inner shadow") << inner; + QTest::newRow("all, a sharp shadow") << all; + QTest::newRow("wide stroke, a barely blurred shadow") << wide; +} + +void LayerEffectsKernelTests::everyEffectMatchesTheShader() +{ + QFETCH(LayerEffects, effects); + const QImage pixels = figure(48, 34); + const QImage made = LayerEffectsKernel::render(pixels, effects); + QCOMPARE(made.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(made.size(), pixels.size()); + const QImage expected = replayed(pixels, effects); + QVERIFY2(gap(made, expected) == 0, qPrintable(QString::number(gap(made, expected)))); + QVERIFY(made != pixels); + // Another format reads as premultiplied RGBA. + QVERIFY(gap(LayerEffectsKernel::render(pixels.convertToFormat(QImage::Format_ARGB32_Premultiplied), effects), expected) == 0); +} + +void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels() +{ + const QImage pixels = figure(30, 20); + LayerEffects hidden; + hidden.stroke = StrokeEffect{.enabled = false, .size = 3, .red = 1}; + hidden.shadow = ShadowEffect{.enabled = false, .distance = 2, .blur = 0, .opacity = 1}; + hidden.colorOverlay = ColorOverlayEffect{.enabled = false, .red = 1}; + hidden.innerShadow = InnerShadowEffect{.enabled = false}; + LayerEffects thin; + thin.stroke = StrokeEffect{.size = 0, .red = 1}; + LayerEffects clear; + clear.shadow = ShadowEffect{.opacity = 0}; + for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear}) + QCOMPARE(gap(LayerEffectsKernel::render(pixels, effects), pixels), 0); + // Stroke sizes round to whole pixels, one at least. + LayerEffects fraction, one; + fraction.stroke = StrokeEffect{.size = 0.4, .red = 1}; + one.stroke = StrokeEffect{.size = 1, .red = 1}; + QCOMPARE(gap(LayerEffectsKernel::render(pixels, fraction), LayerEffectsKernel::render(pixels, one)), 0); + QVERIFY(gap(LayerEffectsKernel::render(pixels, one), pixels) > 100); +} + +void LayerEffectsKernelTests::theKernelRefusesWhatItCannotHold() +{ + // Past a hundred million pixels, before any pixel is read. + const QImage vast(10'001, 10'000, QImage::Format_Mono); + LayerEffects effects; + effects.stroke = StrokeEffect(); + try { + LayerEffectsKernel::render(vast, effects); + QFAIL("the kernel took a vast image"); + } catch (const ExportError &error) { + QCOMPARE(error.kind, ExportError::Kind::tooLarge); + } + try { + LayerEffectsKernel::render(QImage(), effects); + QFAIL("the kernel took an empty image"); + } catch (const ExportError &error) { + QCOMPARE(error.kind, ExportError::Kind::tooLarge); + } +} + +void LayerEffectsKernelTests::aStarvedKernelThrowsWhereItsCallerCatches() +{ + const QImage pixels = BrushRaster::context(4'000, 4'000, false); + LayerEffects effects; + effects.stroke = StrokeEffect(); + effects.shadow = ShadowEffect(); + effects.innerShadow = InnerShadowEffect(); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + std::optional<ExportError::Kind> failure; + try { + LayerEffectsKernel::render(pixels, effects); + } catch (const ExportError &error) { + failure = error.kind; + } + // The renderer's cache draws the layer bare instead. + const bool drawn = LayerEffectsRenderer::cached(pixels, std::nullopt, effects).has_value(); + // Past Metal's 80 million the CPU still takes it. + std::optional<ExportError::Kind> large; + try { + LayerEffectsKernel::render(QImage(9'000, 10'000, QImage::Format_Mono), effects); + } catch (const ExportError &error) { + large = error.kind; + } + limit.reset(); + QCOMPARE(failure, std::optional(ExportError::Kind::render)); + QVERIFY(!drawn && large == ExportError::Kind::render); +} + +void LayerEffectsKernelTests::thinLayersAndTallOnesAreCheap() +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.size = 2, .red = 1}; + // Queues sized by each pass: thin layers ask little. + const QImage thin = BrushRaster::context(30'000, 10, false); + LayerEffectsKernel::render(thin, effects); + std::optional<AddressSpaceLimit> limit(std::in_place, 64 * 1024 * 1024); + bool made = true; + try { + LayerEffectsKernel::render(thin, effects); + } catch (const ExportError &) { + made = false; + } + limit.reset(); + QVERIFY(made); + // Workers write through one pointer: no row detaches the image. + const QImage tall = LayerEffectsKernel::render(BrushRaster::context(8, 4'000, false), effects); + QVERIFY((tall.cacheKey() & 0xffffffff) < 8); +} + +QTEST_GUILESS_MAIN(LayerEffectsKernelTests) +#include "LayerEffectsKernelTests.moc" diff --git a/tests/LayerEffectsRendererTests.cpp b/tests/LayerEffectsRendererTests.cpp new file mode 100644 index 0000000..d4fddf1 --- /dev/null +++ b/tests/LayerEffectsRendererTests.cpp @@ -0,0 +1,243 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/LayerEffects+Renderer.h" +#include "IO/ImageExporter.h" +#include "Rendering/LayerEffectsKernel.h" +#include "SessionFixtures.h" +#include <QPainter> +#include <QtTest> +#include <cmath> + +// Swift's LayerEffectsRenderer and the effects in the export. +namespace { +QImage filled(int width, int height, const QColor &colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +LayerEffects stroke(double size, bool inside = false) +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1, .inside = inside}; + return effects; +} + +std::array<int, 4> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +QImage exported(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} +} + +class LayerEffectsRendererTests : public QObject { + Q_OBJECT +private slots: + void theMarginHoldsWhatReachesOut(); + void thePlacementGrowsAboutTheCentre(); + void renderingPadsMasksThenRunsTheKernel(); + void theCacheKeepsEightWithinItsBudget(); + void theExportAndTheCompositeDrawTheEffects(); +}; + +void LayerEffectsRendererTests::theMarginHoldsWhatReachesOut() +{ + QCOMPARE(LayerEffectsRenderer::margin(LayerEffects()), 2.0); + QCOMPARE(LayerEffectsRenderer::margin(stroke(4.2)), 7.0); + // An inside stroke, overlay or inner shadow reach nowhere. + LayerEffects within = stroke(40, true); + within.colorOverlay = ColorOverlayEffect(); + within.innerShadow = InnerShadowEffect{.distance = 90, .blur = 90}; + QCOMPARE(LayerEffectsRenderer::margin(within), 2.0); + // A shadow's distance and three blurs; the larger reach wins. + LayerEffects cast = stroke(10); + cast.shadow = ShadowEffect{.distance = 5, .blur = 2.5}; + QCOMPARE(LayerEffectsRenderer::margin(cast), 15.0); + cast.shadow->blur = 4; + QCOMPARE(LayerEffectsRenderer::margin(cast), 19.0); + // Switched off, it takes no room. + cast.shadow->enabled = false; + cast.stroke->enabled = false; + QCOMPARE(LayerEffectsRenderer::margin(cast), 2.0); +} + +void LayerEffectsRendererTests::thePlacementGrowsAboutTheCentre() +{ + const LayerTransform transform{.origin = QPointF(10, 20), .size = QSizeF(40, 20), .rotation = 30, .flipX = true, .sampling = LayerSampling::nearest}; + const LayerTransform grown = LayerEffectsRenderer::placed(transform, QImage(60, 30, QImage::Format_Alpha8), 5); + // The 50 by 20 inner pixels keep the layer's box. + QCOMPARE(grown.size, QSizeF(48, 30)); + QCOMPARE(grown.center(), transform.center()); + QVERIFY(grown.rotation == 30 && grown.flipX && !grown.flipY && grown.sampling == LayerSampling::nearest); + // No pixels inside the margin: the transform as it was. + QVERIFY(LayerEffectsRenderer::placed(transform, QImage(10, 30, QImage::Format_Alpha8), 5) == transform); + QVERIFY(LayerEffectsRenderer::placed(transform, QImage(60, 10, QImage::Format_Alpha8), 5) == transform); +} + +void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel() +{ + const QImage image = filled(20, 10, QColor(0, 0, 255)); + const LayerEffectsRenderer::Rendered made = LayerEffectsRenderer::render(image, std::nullopt, stroke(3)); + QCOMPARE(made.inset, 5.0); + QCOMPARE(made.image.size(), QSize(30, 20)); + // The kernel over the pixels padded by the inset. + QImage padded = filled(30, 20, Qt::transparent); + { + QPainter painter(&padded); + painter.drawImage(QRectF(5, 5, 20, 10), image); + } + QVERIFY(made.image == LayerEffectsKernel::render(padded, stroke(3))); + QVERIFY((pixel(made.image, 3, 10) == std::array{255, 255, 0, 255}) && (pixel(made.image, 1, 10)[3] == 0)); + // A mask hides pixels first: the stroke follows what shows. + QImage mask(20, 10, QImage::Format_Grayscale8); + mask.fill(0); + for (int y = 0; y < 10; ++y) + std::fill(mask.scanLine(y), mask.scanLine(y) + 10, uchar(255)); + const LayerEffectsRenderer::Rendered masked = LayerEffectsRenderer::render(image, mask, stroke(3)); + QVERIFY((pixel(masked.image, 10, 10) == std::array{0, 0, 255, 255}) && (pixel(masked.image, 20, 10) == std::array{0, 0, 0, 0})); + QVERIFY((pixel(masked.image, 16, 10) == std::array{255, 255, 0, 255}) && (pixel(masked.image, 18, 10)[3] == 0)); + // A small mask scales smoothly, as CoreGraphics clips. + QImage halves(2, 1, QImage::Format_Grayscale8); + halves.scanLine(0)[0] = 0; + halves.scanLine(0)[1] = 255; + const QImage soft = LayerEffectsRenderer::render(filled(64, 16, QColor(0, 0, 255)), halves, stroke(0)).image; + const int left = pixel(soft, 2 + 31, 10)[3], right = pixel(soft, 2 + 32, 10)[3]; + QVERIFY2(left > 100 && left < right && right < 160, qPrintable(QStringLiteral("%1 %2").arg(left).arg(right))); + QVERIFY(pixel(soft, 2, 10)[3] == 0 && pixel(soft, 65, 10)[3] == 255); + // Switched-off effects are none; invalid or vast ones refuse. + LayerEffects hidden = stroke(3); + hidden.stroke->enabled = false; + QCOMPARE(LayerEffectsRenderer::render(image, std::nullopt, hidden).image.size(), QSize(24, 14)); + // A switched-off effect is not judged. + LayerEffects wild = stroke(600); + wild.stroke->enabled = false; + QVERIFY(LayerEffectsRenderer::render(image, std::nullopt, wild).image == LayerEffectsRenderer::render(image, std::nullopt, hidden).image); + try { + LayerEffectsRenderer::render(image, std::nullopt, stroke(501)); + QFAIL("an invalid stroke rendered"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::invalid); + } + LayerEffects shadow; + shadow.shadow = ShadowEffect{.distance = 10}; + try { + LayerEffectsRenderer::render(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow); + QFAIL("a vast layer rendered"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::tooLarge); + } +} + +void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget() +{ + const QImage image = filled(12, 8, Qt::red); + // Nothing to draw, nothing made: the layer draws as is. + QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, std::nullopt)); + QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, LayerEffects())); + LayerEffects hidden = stroke(2); + hidden.stroke->enabled = false; + QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, hidden)); + QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, stroke(600))); + // The same pixels, mask and effects: the same result. + const auto first = LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value(); + QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey()); + QCOMPARE(first.inset, 4.0); + // Switched-off effects are left out of the key. + LayerEffects extra = stroke(2); + extra.shadow = ShadowEffect{.enabled = false}; + QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, extra).value().image.cacheKey(), first.image.cacheKey()); + // Equal pixels elsewhere, or another mask: made anew. + const QImage twin = image.copy(); + QVERIFY(LayerEffectsRenderer::cached(twin, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey()); + QImage mask(12, 8, QImage::Format_Grayscale8); + mask.fill(255); + const auto masked = LayerEffectsRenderer::cached(image, mask, stroke(2)).value(); + QVERIFY(masked.image.cacheKey() != first.image.cacheKey() && masked.image == first.image); + QCOMPARE(LayerEffectsRenderer::cached(image, mask, stroke(2)).value().image.cacheKey(), masked.image.cacheKey()); + // Another mask of the same size is another key. + QImage half = mask.copy(); + half.fill(128); + QVERIFY(LayerEffectsRenderer::cached(image, half, stroke(2)).value().image.cacheKey() != masked.image.cacheKey()); + // Eight are kept: the ninth pushes the first out. + for (int size = 3; size <= 6; ++size) + LayerEffectsRenderer::cached(image, std::nullopt, stroke(size)); + QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey()); + LayerEffectsRenderer::cached(image, std::nullopt, stroke(7)); + QVERIFY(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey()); + // Two 32 MiB entries fill it; a third evicts one. + const QImage one = filled(2'000, 2'000, Qt::red), two = filled(2'000, 2'000, Qt::green), three = filled(2'000, 2'000, Qt::blue); + const qint64 kept = LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey(); + const qint64 second = LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey(); + QCOMPARE(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey(), kept); + LayerEffectsRenderer::cached(three, std::nullopt, stroke(1)); + QCOMPARE(LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey(), second); + QVERIFY(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey() != kept); + // Past 64 MiB: made, not kept, nothing evicted. + const qint64 small = LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey(); + const QImage big = filled(3'000, 3'000, Qt::red); + const auto large = LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value(); + QVERIFY(LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value().image.cacheKey() != large.image.cacheKey()); + QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey(), small); + // Too large to make: the layer draws bare. + LayerEffects shadow; + shadow.shadow = ShadowEffect{.distance = 10}; + QVERIFY(!LayerEffectsRenderer::cached(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow)); +} + +void LayerEffectsRendererTests::theExportAndTheCompositeDrawTheEffects() +{ + EditorSession session; + session.createDocument(40, 30); + session.insert(ImportedImage(filled(10, 10, QColor(0, 0, 255)), QImage(), QStringLiteral("Square")), QPointF(20, 15)); + LayerEffects effects = stroke(2); + effects.shadow = ShadowEffect{.angle = 90, .distance = 6, .blur = 0, .red = 1, .green = 0, .blue = 0, .opacity = 1}; + session.setEffects(effects); + const QImage image = exported(session); + // Square 15..25 by 10..20; stroke two out; shadow six down. + QVERIFY((pixel(image, 20, 15) == std::array{0, 0, 255, 255}) && (pixel(image, 13, 15) == std::array{255, 255, 0, 255})); + QVERIFY((pixel(image, 20, 24) == std::array{255, 0, 0, 255}) && (pixel(image, 20, 8) == std::array{255, 255, 0, 255})); + QVERIFY(pixel(image, 20, 7)[3] == 0 && pixel(image, 12, 15)[3] == 0 && pixel(image, 20, 27)[3] == 0); + // A mask hides pixels, the effects follow; opacity fades all. + session.addLayerMask(false); + QVERIFY(pixel(exported(session), 13, 15)[3] == 0 && pixel(exported(session), 20, 24)[3] == 0); + session.deleteLayerMask(); + session.setLayerOpacity(0.5); + QVERIFY(std::abs(pixel(exported(session), 13, 15)[3] - 128) <= 1); + // Its blend mode applies to the effects too. + session.setLayerOpacity(1); + const QUuid square = session.activeLayerID().value(); + session.insert(ImportedImage(filled(40, 30, QColor(128, 128, 128)), QImage(), QStringLiteral("Gray"))); + session.moveActiveLayer(-1); + session.selectLayer(square); + session.setLayerBlendMode(LayerBlendMode::multiply); + QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 0, 255})); + // A folder's mask clips the effects inside it. + session.groupSelectedLayers(); + session.addLayerMask(false); + QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 128, 255})); + // The composite draws what the export does. + QImage live = BrushRaster::context(40, 30, false); + live.fill(0); + { + QPainter painter(&live); + session.drawLiveComposite(session.document().value(), painter); + } + QVERIFY(live == exported(session)); + session.deleteLayerMask(); + live.fill(0); + { + QPainter painter(&live); + session.drawLiveComposite(session.document().value(), painter); + } + QVERIFY(live == exported(session) && pixel(live, 13, 15) == (std::array{128, 128, 0, 255})); +} + +QTEST_GUILESS_MAIN(LayerEffectsRendererTests) +#include "LayerEffectsRendererTests.moc" diff --git a/tests/LayerEffectsSessionTests.cpp b/tests/LayerEffectsSessionTests.cpp new file mode 100644 index 0000000..73e6835 --- /dev/null +++ b/tests/LayerEffectsSessionTests.cpp @@ -0,0 +1,466 @@ +#include "Document/EditorSession.h" +#include "SessionFixtures.h" +#include "SessionRecord.h" +#include <QSignalSpy> +#include <QtTest> + +// Swift's LayerEffects session extension, driven as its panel drives it. +namespace { +ImportedImage filled(int width, int height, const QColor &colour) +{ + QImage pixels(width, height, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(colour); + return ImportedImage(pixels, pixels, QStringLiteral("Filled")); +} + +// Two pixel layers, bottom then top, the top one active. +struct Two { + EditorSession session; + QUuid bottom, top; + Two() + { + session.createDocument(20, 10); + session.insert(filled(20, 10, Qt::blue)); + bottom = session.activeLayerID().value(); + session.insert(filled(8, 4, Qt::red)); + top = session.activeLayerID().value(); + session.setBackgroundColor(PaletteColor{0.2, 0.4, 0.6}); + } + std::optional<LayerEffects> effects(QUuid id) const { return layerWith(session, id).effects; } +}; + +const LayerEffectSelection strokeOf(QUuid id) +{ + return {id, LayerEffectKind::stroke}; +} +} + +class LayerEffectsSessionTests : public QObject { + Q_OBJECT +private slots: + void addingAnEffectTakesItsDefaultsAndOpensItsPanel(); + void onlyPixelLayersTakeEffects(); + void cancelPutsBackThisPanelsEffectAlone(); + void setEffectsIsOneStepOnAValidChange(); + void panelEditsStayWithTheirLayer(); + void copyingAnEffectClosesTheTargetsPanelFirst(); + void togglingAndRemovingAnEffect(); + void choosingLayersDropsTheChosenEffect(); + void thePickerEditsTheOpenEffect(); + void everyChangeIsAnnouncedLast(); +}; + +void LayerEffectsSessionTests::addingAnEffectTakesItsDefaultsAndOpensItsPanel() +{ + Two two; + EditorSession &session = two.session; + QVERIFY(session.canEditEffects() && session.activeEffects().isEmpty()); + session.addEffect(LayerEffectKind::stroke); + // A stroke takes the background; its panel opens on it. + const StrokeEffect stroke = two.effects(two.top).value().stroke.value(); + QVERIFY((stroke == StrokeEffect{.red = 0.2, .green = 0.4, .blue = 0.6})); + QCOMPARE(session.history.undoName(), QString("Add Stroke")); + QVERIFY(session.effectsEditing() == strokeOf(two.top) && session.effectSelection() == strokeOf(two.top)); + QVERIFY(session.selectedEffect() == strokeOf(two.top) && session.effectsEditingOriginal() == LayerEffects()); + QVERIFY(session.editingEffects() == two.effects(two.top).value() && session.activeEffects() == two.effects(two.top).value()); + // The same panel again does nothing; another closes it, Cancel. + const int steps = session.history.undoCount(); + session.addEffect(LayerEffectKind::stroke); + QCOMPARE(session.history.undoCount(), steps); + session.addEffect(LayerEffectKind::shadow); + const LayerEffects added = two.effects(two.top).value(); + QVERIFY(!added.stroke && added.shadow == ShadowEffect()); + QCOMPARE(session.history.undoName(), QString("Add Drop Shadow")); + QVERIFY(session.effectsEditing() == (LayerEffectSelection{two.top, LayerEffectKind::shadow}) && session.effectsEditingOriginal() == LayerEffects()); + session.addEffect(LayerEffectKind::colorOverlay); + QVERIFY((two.effects(two.top).value().colorOverlay == ColorOverlayEffect{.red = 0.2, .green = 0.4, .blue = 0.6})); + session.finishEffectsEditing(true); + session.addEffect(LayerEffectKind::innerShadow); + QVERIFY(two.effects(two.top).value().innerShadow == InnerShadowEffect()); + session.finishEffectsEditing(true); + // One already there opens its panel with no step. + LayerEffects present = two.effects(two.top).value(); + present.shadow = ShadowEffect{.distance = 33}; + session.setEffects(present); + session.addEffect(LayerEffectKind::shadow); + QVERIFY(two.effects(two.top).value().shadow->distance == 33); + session.finishEffectsEditing(true); + const int kept = session.history.undoCount(); + session.addEffect(LayerEffectKind::colorOverlay); + QVERIFY(session.history.undoCount() == kept && session.effectsEditingOriginal() == two.effects(two.top)); + QVERIFY(session.effectsEditing() == (LayerEffectSelection{two.top, LayerEffectKind::colorOverlay})); +} + +void LayerEffectsSessionTests::onlyPixelLayersTakeEffects() +{ + Two two; + EditorSession &session = two.session; + session.addBlankLayer(); + QVERIFY(!session.canEditEffects()); + session.addEffect(LayerEffectKind::stroke); + QVERIFY(!session.activeLayer().value().effects && !session.effectsEditing()); + LayerEffects effects; + effects.shadow = ShadowEffect(); + session.setEffects(effects); + QVERIFY(!session.activeLayer().value().effects); + session.selectLayer(two.top); + session.groupSelectedLayers(); + QVERIFY(!session.canEditEffects()); + session.setEffects(effects, session.activeLayerID()); + QVERIFY(!session.activeLayer().value().effects); + session.selectLayer(two.top); + session.setIsProjectBusy(true); + QVERIFY(!session.canEditEffects()); + session.setEffects(effects); + QVERIFY(!two.effects(two.top)); + session.setIsProjectBusy(false); + session.setEffects(effects); + QVERIFY(two.effects(two.top) == effects); +} + +void LayerEffectsSessionTests::cancelPutsBackThisPanelsEffectAlone() +{ + Two two; + EditorSession &session = two.session; + LayerEffects start; + start.stroke = StrokeEffect{.size = 3}; + start.shadow = ShadowEffect{.distance = 5}; + session.setEffects(start); + session.selectEffect(LayerEffectKind::stroke, two.top, true); + QVERIFY(session.effectsEditingOriginal() == start); + session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 9; }); + QCOMPARE(session.history.undoName(), QString("Edit Stroke")); + // Another effect changed meanwhile keeps its change. + LayerEffects other = two.effects(two.top).value(); + other.shadow->distance = 30; + session.setEffects(other); + session.finishEffectsEditing(false); + const LayerEffects after = two.effects(two.top).value(); + QVERIFY(after.stroke->size == 3 && after.shadow->distance == 30); + QCOMPARE(session.history.undoName(), QString("Cancel Stroke")); + QVERIFY(!session.effectsEditing() && !session.effectsEditingOriginal()); + // The chosen effect stays chosen while its layer shows it. + QVERIFY(session.effectSelection() == strokeOf(two.top)); + // A new effect cancelled goes again; OK keeps a change. + session.addEffect(LayerEffectKind::innerShadow); + session.finishEffectsEditing(false); + QVERIFY(!two.effects(two.top).value().innerShadow); + QVERIFY(!session.effectSelection()); + session.selectEffect(LayerEffectKind::shadow, two.top, true); + session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 2; }); + const int steps = session.history.undoCount(); + session.finishEffectsEditing(true); + QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().shadow->blur == 2); + // Nothing open, nothing to finish. + session.finishEffectsEditing(false); + QCOMPARE(session.history.undoCount(), steps); + // Opening another panel cancels the open one's changes. + session.selectEffect(LayerEffectKind::stroke, two.top, true); + session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 40; }); + session.selectEffect(LayerEffectKind::shadow, two.top, true); + QVERIFY(two.effects(two.top).value().stroke->size == 3 && session.history.undoName() == QString("Cancel Stroke")); + session.finishEffectsEditing(true); + // An overlay's Cancel puts back the overlay alone. + session.addEffect(LayerEffectKind::colorOverlay); + session.finishEffectsEditing(true); + session.selectEffect(LayerEffectKind::colorOverlay, two.top, true); + session.changeEffects([](LayerEffects &effects) { effects.colorOverlay->opacity = 0.25; }); + session.finishEffectsEditing(false); + QCOMPARE(two.effects(two.top).value().colorOverlay->opacity, 1.0); + // Its layer's effects all gone, Cancel has nothing to restore. + session.selectEffect(LayerEffectKind::shadow, two.top, true); + session.setEffects(LayerEffects(), two.top); + session.finishEffectsEditing(false); + QVERIFY(!two.effects(two.top) && !session.effectsEditing() && !session.effectSelection()); + // Choosing an effect takes the target off the mask. + LayerEffects stroke; + stroke.stroke = StrokeEffect(); + session.setEffects(stroke, two.top); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + session.selectEffect(LayerEffectKind::stroke, two.top); + QVERIFY(!session.isMaskSelected() && session.effectSelection() == strokeOf(two.top)); +} + +void LayerEffectsSessionTests::setEffectsIsOneStepOnAValidChange() +{ + Two two; + EditorSession &session = two.session; + LayerEffects effects; + effects.stroke = StrokeEffect(); + const int steps = session.history.undoCount(); + session.setEffects(effects, two.bottom, QStringLiteral("Named")); + QVERIFY(session.history.undoCount() == steps + 1 && session.history.undoName() == QString("Named")); + QVERIFY(two.effects(two.bottom) == effects && !two.effects(two.top)); + // The same again, an invalid one, a missing layer: nothing. + session.setEffects(effects, two.bottom); + LayerEffects wild = effects; + wild.stroke->size = 501; + session.setEffects(wild, two.bottom); + session.setEffects(effects, QUuid::createUuid()); + QCOMPARE(session.history.undoCount(), steps + 1); + // Empty is none at all. + session.setEffects(LayerEffects(), two.bottom); + QVERIFY(!two.effects(two.bottom) && session.history.undoName() == QString("Layer Effects")); + // The same effects again leave an opacity drag alone. + session.beginOpacityEdit(); + session.setLayerOpacity(0.5); + session.setEffects(LayerEffects()); + QVERIFY(!session.canUndo()); + // A real change ends the drag first, its own step. + session.setEffects(effects); + QVERIFY(session.canUndo() && session.history.undoName() == QString("Layer Effects")); + session.undo(); + QVERIFY(!two.effects(two.top) && session.activeLayer().value().opacity == 0.5); +} + +void LayerEffectsSessionTests::panelEditsStayWithTheirLayer() +{ + Two two; + EditorSession &session = two.session; + session.addEffect(LayerEffectKind::stroke); + session.selectLayer(two.bottom); + session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 12; }); + QVERIFY(two.effects(two.top).value().stroke->size == 12 && !two.effects(two.bottom)); + // Once its effect is gone, edits have nowhere to go. + LayerEffects without = two.effects(two.top).value(); + without.stroke.reset(); + without.shadow = ShadowEffect(); + session.setEffects(without, two.top); + const int steps = session.history.undoCount(); + session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 1; }); + QCOMPARE(session.history.undoCount(), steps); + session.finishEffectsEditing(true); + session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 1; }); + QCOMPARE(session.history.undoCount(), steps); +} + +void LayerEffectsSessionTests::copyingAnEffectClosesTheTargetsPanelFirst() +{ + Two two; + EditorSession &session = two.session; + LayerEffects source; + source.shadow = ShadowEffect{.distance = 44}; + session.setEffects(source, two.top); + QVERIFY(session.canCopyEffect(LayerEffectKind::shadow, two.top, two.bottom)); + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, two.top, two.top) && !session.canCopyEffect(LayerEffectKind::stroke, two.top, two.bottom)); + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, two.top, QUuid::createUuid())); + // The target's own shadow is open: OK, then the copy. + LayerEffects target; + target.shadow = ShadowEffect{.distance = 2}; + target.stroke = StrokeEffect(); + session.setEffects(target, two.bottom); + session.selectEffect(LayerEffectKind::shadow, two.bottom, true); + session.changeEffects([](LayerEffects &effects) { effects.shadow->blur = 7; }); + session.copyEffect(LayerEffectKind::shadow, two.top, two.bottom); + QCOMPARE(session.history.undoName(), QString("Copy Drop Shadow")); + QVERIFY(!session.effectsEditing() && two.effects(two.bottom).value().shadow == source.shadow); + QVERIFY(two.effects(two.bottom).value().stroke == StrokeEffect()); + QVERIFY(session.effectSelection() == (LayerEffectSelection{two.bottom, LayerEffectKind::shadow}) && session.activeLayerID() == two.bottom); + // The open edit was kept: one undo brings it back. + session.undo(); + QVERIFY(two.effects(two.bottom).value().shadow->blur == 7); + session.redo(); + // No source, a blank or folder target, busy: no copy. + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, QUuid::createUuid(), two.bottom)); + session.addBlankLayer(); + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, two.top, session.activeLayerID().value())); + session.groupSelectedLayers(); + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, two.top, session.activeLayerID().value())); + session.setIsProjectBusy(true); + QVERIFY(!session.canCopyEffect(LayerEffectKind::shadow, two.top, two.bottom)); +} + +void LayerEffectsSessionTests::togglingAndRemovingAnEffect() +{ + Two two; + EditorSession &session = two.session; + session.addEffect(LayerEffectKind::stroke); + session.finishEffectsEditing(true); + session.toggleEffect(LayerEffectKind::stroke, two.top); + QVERIFY(two.effects(two.top).value().stroke->enabled == false && session.history.undoName() == QString("Hide Stroke")); + session.toggleEffect(LayerEffectKind::stroke, two.top); + QVERIFY(two.effects(two.top).value().stroke->enabled == true && session.history.undoName() == QString("Show Stroke")); + // Toggling a missing effect, or none at all, does nothing. + const int steps = session.history.undoCount(); + session.toggleEffect(LayerEffectKind::shadow, two.top); + session.toggleEffect(LayerEffectKind::stroke, two.bottom); + QCOMPARE(session.history.undoCount(), steps); + // Delete takes the chosen effect first, panel and picker too. + session.selectEffect(LayerEffectKind::stroke, two.top, true); + session.openEffectColorPicker(LayerEffectKind::stroke); + QVERIFY(session.colorPicker()); + session.deleteLayerOrMask(); + QVERIFY(!two.effects(two.top) && session.history.undoName() == QString("Remove Stroke")); + QVERIFY(!session.effectsEditing() && !session.effectSelection() && !session.colorPicker()); + QCOMPARE(session.document().value().layers.size(), size_t(2)); + // With none chosen, Delete takes the layer; the key likewise. + session.addEffect(LayerEffectKind::shadow); + session.finishEffectsEditing(true); + session.selectAll(); + const ImageIdentity pixels = layerWith(session, two.top).asset.value().identity(); + session.deleteKeyPressed(); + QVERIFY(!two.effects(two.top).value_or(LayerEffects()).shadow && session.history.undoName() == QString("Remove Drop Shadow")); + QVERIFY(layerWith(session, two.top).asset.value().identity() == pixels && !session.isProjectBusy()); + session.deselect(); + session.deleteKeyPressed(); + QCOMPARE(session.document().value().layers.size(), size_t(1)); + session.removeSelectedEffect(); + QCOMPARE(session.document().value().layers.size(), size_t(1)); + // Busy, a chosen effect stays. + session.setEffects(LayerEffects{.stroke = StrokeEffect()}, two.bottom); + session.selectEffect(LayerEffectKind::stroke, two.bottom); + session.setIsProjectBusy(true); + session.removeSelectedEffect(); + QVERIFY(two.effects(two.bottom) && session.selectedEffect()); +} + +void LayerEffectsSessionTests::choosingLayersDropsTheChosenEffect() +{ + for (int way = 0; way < 3; ++way) { + Two two; + EditorSession &session = two.session; + session.addEffect(LayerEffectKind::stroke); + session.finishEffectsEditing(true); + QVERIFY(session.effectSelection()); + // Swift clears it first, even when the choice is refused. + session.setIsProjectBusy(way == 2); + if (way == 0) + session.selectLayer(two.top); + else if (way == 1) + session.selectLayers({two.top}, two.top); + else + session.selectLayerTarget(two.top, false); + QVERIFY2(!session.effectSelection(), qPrintable(QString::number(way))); + QVERIFY(!session.selectedEffect() && two.effects(two.top)); + } + // The chosen effect needs its layer active and holding it. + Two two; + LayerEffects stroke; + stroke.stroke = StrokeEffect(); + two.session.setEffects(stroke, two.bottom); + two.session.addEffect(LayerEffectKind::stroke); + two.session.finishEffectsEditing(true); + two.session.setActiveLayerID(two.bottom); + QVERIFY(two.session.effectSelection() && !two.session.selectedEffect()); +} + +void LayerEffectsSessionTests::thePickerEditsTheOpenEffect() +{ + Two two; + EditorSession &session = two.session; + // Without an open panel no effect takes a picker. + session.openEffectColorPicker(LayerEffectKind::stroke); + QVERIFY(!session.colorPicker()); + session.addEffect(LayerEffectKind::stroke); + session.openEffectColorPicker(LayerEffectKind::stroke); + QCOMPARE(session.colorPicker().value().target.title(), QString("Color Picker (Stroke Color)")); + QVERIFY(session.colorPicker().value().original == (PaletteColor{0.2, 0.4, 0.6})); + // Its working colour previews on the layer, one step each. + session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + session.previewEffectColor(); + QVERIFY(two.effects(two.top).value().color(LayerEffectKind::stroke) == (PaletteColor{1, 0, 0})); + QCOMPARE(session.history.undoName(), QString("Edit Stroke")); + // Cancel puts the original back; OK keeps the choice. + session.closeColorPicker(false); + QVERIFY(two.effects(two.top).value().color(LayerEffectKind::stroke) == (PaletteColor{0.2, 0.4, 0.6})); + session.openEffectColorPicker(LayerEffectKind::stroke); + session.setColorPickerHSB(PickerHSB(PaletteColor{0, 1, 0})); + session.closeColorPicker(true); + QVERIFY(two.effects(two.top).value().color(LayerEffectKind::stroke) == (PaletteColor{0, 1, 0})); + // The panel's Cancel takes the picker with it and restores. + session.openEffectColorPicker(LayerEffectKind::stroke); + session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); + session.previewEffectColor(); + session.finishEffectsEditing(false); + QVERIFY(!session.colorPicker() && !two.effects(two.top)); + // Another picker's preview recolours no effect. + session.setEffects(LayerEffects{.stroke = StrokeEffect{.red = 1}}); + session.addEffect(LayerEffectKind::shadow); + session.openColorPicker(false); + QVERIFY(session.colorPicker()); + const int steps = session.history.undoCount(); + session.previewEffectColor(); + QVERIFY(session.history.undoCount() == steps && two.effects(two.top).value().stroke->red == 1); + session.openEffectColorPicker(LayerEffectKind::shadow); + QVERIFY(session.colorPicker().value().target.kind == ColorPickerTarget::Kind::palette); + session.closeColorPicker(false); + // A palette picker outlives the panel's close. + session.openColorPicker(false); + session.finishEffectsEditing(true); + QVERIFY(session.colorPicker().value().target.kind == ColorPickerTarget::Kind::palette); + session.closeColorPicker(false); + session.selectEffect(LayerEffectKind::shadow, two.top, true); + // A kind the layer lacks starts black, writes nothing. + session.openEffectColorPicker(LayerEffectKind::innerShadow); + QVERIFY(session.colorPicker().value().original == PaletteColor::black()); + const int unchanged = session.history.undoCount(); + session.setColorPickerHSB(PickerHSB(PaletteColor{0, 1, 1})); + session.closeColorPicker(true); + QVERIFY(session.history.undoCount() == unchanged && !two.effects(two.top).value().innerShadow); + // Busy, no picker opens. + session.setIsProjectBusy(true); + session.openEffectColorPicker(LayerEffectKind::shadow); + QVERIFY(!session.colorPicker()); + session.setIsProjectBusy(false); + // Opening another panel closes its effect picker, Cancel. + session.openEffectColorPicker(LayerEffectKind::shadow); + session.setColorPickerHSB(PickerHSB(PaletteColor{1, 1, 0})); + session.previewEffectColor(); + session.addEffect(LayerEffectKind::stroke); + QVERIFY(!session.colorPicker() && two.effects(two.top).value().shadow->color() == PaletteColor::black()); +} + +void LayerEffectsSessionTests::everyChangeIsAnnouncedLast() +{ + QStringList seen; + Two two; + EditorSession &session = two.session; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session.addEffect(LayerEffectKind::stroke); }), QString()); + QCOMPARE(stale([&] { session.changeEffects([](LayerEffects &effects) { effects.stroke->size = 6; }); }), QString()); + QCOMPARE(stale([&] { session.openEffectColorPicker(LayerEffectKind::stroke); }), QString()); + QCOMPARE(stale([&] { session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); }), QString()); + QCOMPARE(stale([&] { session.previewEffectColor(); }), QString()); + QCOMPARE(stale([&] { session.finishEffectsEditing(false); }), QString()); + QCOMPARE(stale([&] { session.addEffect(LayerEffectKind::shadow); }), QString()); + QCOMPARE(stale([&] { session.finishEffectsEditing(true); }), QString()); + QCOMPARE(stale([&] { session.toggleEffect(LayerEffectKind::shadow, two.top); }), QString()); + QCOMPARE(stale([&] { session.selectEffect(LayerEffectKind::shadow, two.top); }), QString()); + QCOMPARE(stale([&] { session.copyEffect(LayerEffectKind::shadow, two.top, two.bottom); }), QString()); + QCOMPARE(stale([&] { session.removeSelectedEffect(); }), QString()); + QCOMPARE(stale([&] { session.selectEffect(LayerEffectKind::shadow, two.top); }), QString()); + QCOMPARE(stale([&] { session.selectLayer(two.bottom); }), QString()); + // The writers the window uses since 9.6d. + QCOMPARE(stale([&] { session.selectEffect(LayerEffectKind::shadow, two.top); }), QString()); + QCOMPARE(stale([&] { session.dropEffectSelection(); }), QString()); + QCOMPARE(stale([&] { session.setEffectsEditing(strokeOf(two.top)); }), QString()); + QCOMPARE(stale([&] { session.setEffectsEditingOriginal(LayerEffects()); }), QString()); + QCOMPARE(stale([&] { session.setEffectsEditing(std::nullopt); }), QString()); + QCOMPARE(stale([&] { session.setEffectsEditingOriginal(std::nullopt); }), QString()); + // Refusals stay silent. + QSignalSpy changed(&session, &EditorSession::changed); + session.removeSelectedEffect(); + session.finishEffectsEditing(true); + session.selectEffect(LayerEffectKind::colorOverlay, two.top); + session.toggleEffect(LayerEffectKind::stroke, two.bottom); + session.setEffects(LayerEffects(), two.bottom); + session.previewEffectColor(); + session.dropEffectSelection(); + session.setEffectsEditing(std::nullopt); + session.setEffectsEditingOriginal(std::nullopt); + QCOMPARE(changed.count(), 0); + session.beginLevels(); + changed.clear(); + session.selectLayer(two.top); + QCOMPARE(changed.count(), 0); +} + +QTEST_GUILESS_MAIN(LayerEffectsSessionTests) +#include "LayerEffectsSessionTests.moc" diff --git a/tests/LayerEffectsSurfaceTests.cpp b/tests/LayerEffectsSurfaceTests.cpp new file mode 100644 index 0000000..700b318 --- /dev/null +++ b/tests/LayerEffectsSurfaceTests.cpp @@ -0,0 +1,192 @@ +#include "Document/LayerEffects+Renderer.h" +#include "Rendering/LayerEffectsSurface.h" +#include "RenderFixtures.h" +#include <QPainter> +#include <QtTest> + +// Swift's LayerEffectsSurface: a painted layer's effects, kept up. +namespace { +LayerEffects everyKind() +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.size = 5, .red = 1, .green = 1, .blue = 0, .opacity = 1}; + effects.shadow = ShadowEffect{.angle = 45, .distance = 4, .blur = 16, .red = 1, .green = 0, .blue = 0, .opacity = 0.8}; + effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5}; + effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 3, .blur = 60, .red = 0, .green = 0, .blue = 0, .opacity = 0.7}; + return effects; +} + +void addRows() +{ + QTest::addColumn<LayerEffects>("effects"); + const LayerEffects all = everyKind(); + LayerEffects outside, inside, cast, overlay, inner; + outside.stroke = all.stroke; + inside.stroke = StrokeEffect{.size = 10, .red = 1, .green = 0, .blue = 1, .opacity = 0.9, .inside = true}; + cast.shadow = all.shadow; + overlay.colorOverlay = all.colorOverlay; + inner.innerShadow = all.innerShadow; + QTest::newRow("outside stroke") << outside; + QTest::newRow("inside stroke") << inside; + QTest::newRow("shadow") << cast; + QTest::newRow("overlay") << overlay; + QTest::newRow("inner shadow") << inner; + QTest::newRow("every kind") << all; +} + +// A soft disc well inside a clear layer. +QImage disc() +{ + QImage image = BrushRaster::context(80, 60, false); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(40, 90, 200)); + painter.drawEllipse(QPointF(40, 30), 15.5, 13); + return image; +} + +// Opaque and translucent cells: a window cut short shows. +QImage checker(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const int alpha = (x / 8 + y / 8) % 2 ? 255 : 96; + image.setPixel(x, y, qRgba(40 * alpha / 255, 90 * alpha / 255, 200 * alpha / 255, alpha)); + } + } + return image; +} + +// A tile of `base` with a clear hole in it. +BrushPatch holed(const QImage &base, const QRect &tile, const QRect &hole) +{ + QImage image = base.copy(tile); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Clear); + painter.fillRect(hole.translated(-tile.topLeft()), Qt::transparent); + return {QRectF(tile), image}; +} + +std::unique_ptr<LayerEffectsSurface> surfaceFor(const QImage &image, const LayerEffects &effects) +{ + return LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(image.size()), QRectF(image.rect())); +} + +ImportedImage asset(const QImage &image) +{ + return ImportedImage(image, QImage(), QStringLiteral("Layer")); +} +} + +class LayerEffectsSurfaceTests : public QObject { + Q_OBJECT +private slots: + void theFirstUpdateDrawsWhatTheRendererDraws_data() { addRows(); } + void theFirstUpdateDrawsWhatTheRendererDraws(); + void newPaintIsRedoneAsFarAsItReaches_data() { addRows(); } + void newPaintIsRedoneAsFarAsItReaches(); + void tilesAlreadyTakenRedoNothing(); + void paintPastTheLayerShowsUnmasked(); + void aSmallerMaskScalesSmoothlyAsTheRenderersDoes(); + void aSurfaceHoldsEightyMillionPixelsAtMost(); + void aSurfaceFitsTheStrokeItWasMadeFor(); +}; + +void LayerEffectsSurfaceTests::theFirstUpdateDrawsWhatTheRendererDraws() +{ + QFETCH(LayerEffects, effects); + const QImage image = disc(); + const std::unique_ptr<LayerEffectsSurface> surface = surfaceFor(image, effects); + QVERIFY(surface && !surface->image()); + surface->update(asset(image), {}, std::nullopt); + const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(image, std::nullopt, effects); + QCOMPARE(surface->margin, expected.inset); + QVERIFY(surface->image().value() == expected.image); + // A painted base draws from its tiles, alike, never flattened. + const std::unique_ptr<LayerEffectsSurface> tiled = surfaceFor(image, effects); + const std::shared_ptr<const RasterSnapshot> raster = painted(image, {}); + tiled->update(ImportedImage(raster, QImage(), QStringLiteral("Painted")), {}, std::nullopt); + QVERIFY(tiled->image().value() == expected.image && !raster->hasMaterializedPixels()); +} + +void LayerEffectsSurfaceTests::newPaintIsRedoneAsFarAsItReaches() +{ + QFETCH(LayerEffects, effects); + // Two tiles painted at once, each with a hole. + const QImage base = checker(900, 900); + const std::vector<BrushPatch> patches{holed(base, QRect(0, 0, 256, 256), QRect(200, 180, 56, 76)), + holed(base, QRect(256, 0, 256, 256), QRect(266, 40, 20, 30))}; + const std::unique_ptr<LayerEffectsSurface> surface = surfaceFor(base, effects); + surface->update(asset(base), {}, std::nullopt); + surface->update(asset(base), patches, std::nullopt); + // Everything the hole reaches, past its tile too, is redone. + QVERIFY(surface->image().value() == LayerEffectsRenderer::render(composite(base, patches), std::nullopt, effects).image); +} + +void LayerEffectsSurfaceTests::tilesAlreadyTakenRedoNothing() +{ + const QImage base = solid(300, 300, qRgba(40, 90, 200, 255)); + const std::unique_ptr<LayerEffectsSurface> surface = surfaceFor(base, everyKind()); + const BrushPatch red{QRectF(0, 0, 256, 256), solid(256, 256, qRgba(255, 0, 0, 255))}; + surface->update(asset(base), {red}, std::nullopt); + const qint64 first = surface->image().value().cacheKey(); + surface->update(asset(base), {red}, std::nullopt); + QCOMPARE(surface->image().value().cacheKey(), first); + // A tile published anew is taken in again. + const BrushPatch green{red.rect, solid(256, 256, qRgba(0, 255, 0, 255))}; + surface->update(asset(base), {green}, std::nullopt); + QVERIFY(surface->image().value() == LayerEffectsRenderer::render(composite(base, {green}), std::nullopt, everyKind()).image); +} + +void LayerEffectsSurfaceTests::paintPastTheLayerShowsUnmasked() +{ + // The layer, wholly masked, sits inside a larger grid. + LayerEffects effects; + effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 1}; + const std::unique_ptr<LayerEffectsSurface> surface = LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(40, 30), QRectF(10, 10, 20, 10)); + const BrushPatch paint{QRectF(0, 0, 40, 30), solid(40, 30, qRgba(255, 0, 0, 255))}; + surface->update(asset(solid(20, 10, qRgba(0, 0, 255, 255))), {paint}, gray(20, 10, 0)); + const QImage image = surface->image().value(); + QVERIFY(surface->margin == 2 && image.size() == QSize(44, 34)); + QCOMPARE(image.pixel(4, 4), qRgba(0, 255, 0, 255)); + QCOMPARE(image.pixel(29, 29), qRgba(0, 255, 0, 255)); + QCOMPARE(qAlpha(image.pixel(12, 12)), 0); + QCOMPARE(qAlpha(image.pixel(31, 21)), 0); +} + +void LayerEffectsSurfaceTests::aSmallerMaskScalesSmoothlyAsTheRenderersDoes() +{ + const QImage image = solid(64, 16, qRgba(40, 90, 200, 255)); + QImage mask(2, 1, QImage::Format_Grayscale8); + mask.scanLine(0)[0] = 0; + mask.scanLine(0)[1] = 255; + const std::unique_ptr<LayerEffectsSurface> surface = surfaceFor(image, everyKind()); + surface->update(asset(image), {}, mask); + QVERIFY(surface->image().value() == LayerEffectsRenderer::render(image, mask, everyKind()).image); +} + +void LayerEffectsSurfaceTests::aSurfaceHoldsEightyMillionPixelsAtMost() +{ + LayerEffects effects; + effects.colorOverlay = ColorOverlayEffect(); + // A margin of two: 8,000 by 10,000 fits exactly. + QVERIFY(LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(7996, 9996), QRectF(0, 0, 7996, 9996))); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("an effects surface passes its pixel budget")); + QVERIFY(!LayerEffectsSurface::make(QUuid::createUuid(), effects, QSizeF(7997, 9996), QRectF(0, 0, 7997, 9996))); +} + +void LayerEffectsSurfaceTests::aSurfaceFitsTheStrokeItWasMadeFor() +{ + const QUuid id = QUuid::createUuid(); + const std::unique_ptr<LayerEffectsSurface> surface = LayerEffectsSurface::make(id, everyKind(), QSizeF(40, 30), QRectF(5, 5, 20, 10)); + QVERIFY(surface->matches(id, everyKind(), QSizeF(40, 30), QRectF(5, 5, 20, 10))); + QVERIFY(!surface->matches(QUuid::createUuid(), everyKind(), QSizeF(40, 30), QRectF(5, 5, 20, 10))); + QVERIFY(!surface->matches(id, LayerEffects(), QSizeF(40, 30), QRectF(5, 5, 20, 10))); + QVERIFY(!surface->matches(id, everyKind(), QSizeF(41, 30), QRectF(5, 5, 20, 10))); + QVERIFY(!surface->matches(id, everyKind(), QSizeF(40, 30), QRectF(6, 5, 20, 10))); +} + +QTEST_GUILESS_MAIN(LayerEffectsSurfaceTests) +#include "LayerEffectsSurfaceTests.moc" diff --git a/tests/LayerEffectsTests.cpp b/tests/LayerEffectsTests.cpp new file mode 100644 index 0000000..d151cce --- /dev/null +++ b/tests/LayerEffectsTests.cpp @@ -0,0 +1,337 @@ +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include "IO/ProjectStore+Json.h" +#include "SelectionFixtures.h" +#include "SessionFixtures.h" +#include <QJsonArray> +#include <QTemporaryDir> +#include <QtTest> +#include <cmath> +#include <limits> + +// Layer effects in the document, the manifest and the store. +namespace { +constexpr double notANumber = std::numeric_limits<double>::quiet_NaN(); +constexpr double infinity = std::numeric_limits<double>::infinity(); + +// Every effect, each with a value of its own. +LayerEffects every() +{ + LayerEffects effects; + effects.stroke = StrokeEffect{.enabled = false, .size = 7, .red = 0.1, .green = 0.2, .blue = 0.3, .opacity = 0.4, .inside = true}; + effects.shadow = ShadowEffect{.angle = 45, .distance = 12, .blur = 6, .red = 0.5, .green = 0.6, .blue = 0.7, .opacity = 0.8}; + effects.colorOverlay = ColorOverlayEffect{.enabled = true, .red = 0.9, .green = 0.25, .blue = 0.75, .opacity = 0.35}; + effects.innerShadow = InnerShadowEffect{.angle = -30, .distance = 3, .blur = 2, .red = 0.05, .green = 0.15, .blue = 0.95, .opacity = 0.65}; + return effects; +} + +ImportedImage filled(int width, int height, const QColor &colour) +{ + QImage pixels(width, height, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(colour); + return ImportedImage(pixels, pixels, QStringLiteral("Filled")); +} + +// A 20 by 10 blue layer carrying every effect. +std::unique_ptr<EditorSession> withEffects() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(20, 10); + session->insert(filled(20, 10, Qt::blue)); + session->setEffects(every()); + return session; +} + +bool refused(const QJsonObject &object) +{ + try { + ManifestJson::effects(object); + } catch (const ProjectError &) { + return true; + } + return false; +} + +bool awaited(const std::function<void(std::function<void()>)> &call) +{ + bool done = false; + call([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 10'000); +} +} + +class LayerEffectsTests : public QObject { + Q_OBJECT +private slots: + void kindsKeepSwiftsNamesAndOrder(); + void validityFollowsSwiftsBounds(); + void shadowsFallAwayFromTheLight(); + void theHelpersReadAndWriteOneKind(); + void theManifestWritesSwiftsKeys(); + void decodingRefusesWhatSwiftRefuses(); + void effectsSaveLoadAndExportWithTheirLayer(); + void duplicatesAndBrushCommitsKeepThem(); + void rebuildsCopiesAndResizesDropThem(); +}; + +void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder() +{ + const std::pair<LayerEffectKind, const char *> kinds[] = {{LayerEffectKind::stroke, "Stroke"}, {LayerEffectKind::shadow, "Drop Shadow"}, + {LayerEffectKind::colorOverlay, "Color Overlay"}, {LayerEffectKind::innerShadow, "Inner Shadow"}}; + QCOMPARE(allLayerEffectKinds.size(), std::size(kinds)); + for (size_t index = 0; index < std::size(kinds); ++index) { + QVERIFY(allLayerEffectKinds[index] == kinds[index].first); + QCOMPARE(rawValue(kinds[index].first), QString(kinds[index].second)); + } + // Swift's defaults. + QVERIFY((StrokeEffect() == StrokeEffect{std::nullopt, 4, 0, 0, 0, 1, false})); + QVERIFY((ShadowEffect() == ShadowEffect{std::nullopt, 90, 20, 20, 0, 0, 0, 0.5})); + QVERIFY((ColorOverlayEffect() == ColorOverlayEffect{std::nullopt, 0, 0, 0, 1})); + QVERIFY((InnerShadowEffect() == InnerShadowEffect{std::nullopt, 90, 10, 10, 0, 0, 0, 0.5})); + QCOMPARE(StrokeEffect::maxSize, 500.0); +} + +void LayerEffectsTests::validityFollowsSwiftsBounds() +{ + QVERIFY(every().isValid() && LayerEffects().isValid()); + // Each bound at its edge, then just past it. + for (const double size : {0.0, 500.0}) + QVERIFY(StrokeEffect{.size = size}.isValid()); + for (const double size : {-0.5, 500.5, notANumber, infinity}) + QVERIFY(!StrokeEffect{.size = size}.isValid()); + for (const double opacity : {-0.01, 1.01, notANumber}) + QVERIFY(!StrokeEffect{.opacity = opacity}.isValid() && !ShadowEffect{.opacity = opacity}.isValid() + && !ColorOverlayEffect{.opacity = opacity}.isValid() && !InnerShadowEffect{.opacity = opacity}.isValid()); + for (const double channel : {-0.01, 1.01, notANumber}) { + QVERIFY(!StrokeEffect{.red = channel}.isValid() && !ShadowEffect{.green = channel}.isValid()); + QVERIFY(!ColorOverlayEffect{.blue = channel}.isValid() && !InnerShadowEffect{.red = channel}.isValid()); + } + QVERIFY((ShadowEffect{.angle = -360, .distance = 5000, .blur = 500}.isValid() && InnerShadowEffect{.angle = 360, .distance = 0, .blur = 0}.isValid())); + QVERIFY(!ShadowEffect{.angle = 360.5}.isValid() && !ShadowEffect{.distance = 5000.5}.isValid() && !ShadowEffect{.blur = 500.5}.isValid()); + QVERIFY(!InnerShadowEffect{.angle = -360.5}.isValid() && !InnerShadowEffect{.distance = -1}.isValid() && !InnerShadowEffect{.blur = notANumber}.isValid()); + // One bad effect spoils the set. + LayerEffects effects = every(); + effects.innerShadow->blur = infinity; + QVERIFY(!effects.isValid()); + effects = every(); + effects.stroke->size = 501; + QVERIFY(!effects.isValid()); + effects = every(); + effects.shadow->distance = -1; + QVERIFY(!effects.isValid()); + effects = every(); + effects.colorOverlay->opacity = 2; + QVERIFY(!effects.isValid()); +} + +void LayerEffectsTests::shadowsFallAwayFromTheLight() +{ + // Light from above drops the shadow straight down, y growing. + const QSizeF down = ShadowEffect{.angle = 90, .distance = 20}.offset(); + QVERIFY(std::abs(down.width()) < 1e-12 && down.height() == 20); + const QSizeF left = ShadowEffect{.angle = 0, .distance = 8}.offset(); + QVERIFY(left.width() == -8 && std::abs(left.height()) < 1e-12); + const QSizeF inner = InnerShadowEffect{.angle = 180, .distance = 4}.offset(); + QVERIFY(std::abs(inner.width() - 4) < 1e-12 && std::abs(inner.height()) < 1e-12); + const QSizeF slant = InnerShadowEffect{.angle = -45, .distance = std::sqrt(2.0)}.offset(); + QVERIFY(std::abs(slant.width() + 1) < 1e-12 && std::abs(slant.height() + 1) < 1e-12); +} + +void LayerEffectsTests::theHelpersReadAndWriteOneKind() +{ + LayerEffects effects; + QVERIFY(effects.isEmpty() && effects.kinds().empty()); + effects.innerShadow = InnerShadowEffect(); + effects.stroke = StrokeEffect{.enabled = false}; + QVERIFY(!effects.isEmpty()); + // Swift's allCases order, whatever was added first. + QVERIFY((effects.kinds() == std::vector{LayerEffectKind::stroke, LayerEffectKind::innerShadow})); + QVERIFY(effects.contains(LayerEffectKind::stroke) && !effects.contains(LayerEffectKind::shadow)); + // Absent `enabled` is shown; false is hidden; missing is neither. + QVERIFY(!effects.isEnabled(LayerEffectKind::stroke) && effects.isEnabled(LayerEffectKind::innerShadow) && !effects.isEnabled(LayerEffectKind::shadow)); + QVERIFY(effects.color(LayerEffectKind::innerShadow) == PaletteColor::black() && !effects.color(LayerEffectKind::colorOverlay)); + effects.setColor(PaletteColor{0.2, 0.4, 0.6}, LayerEffectKind::stroke); + effects.setColor(PaletteColor{1, 1, 1}, LayerEffectKind::colorOverlay); + QVERIFY(effects.color(LayerEffectKind::stroke) == (PaletteColor{0.2, 0.4, 0.6}) && !effects.colorOverlay); + effects.setEnabled(true, LayerEffectKind::stroke); + effects.setEnabled(false, LayerEffectKind::innerShadow); + effects.setEnabled(true, LayerEffectKind::shadow); + QVERIFY(effects.stroke->enabled == true && effects.innerShadow->enabled == false && !effects.shadow); + // Visible keeps what is switched on. + const LayerEffects shown = effects.visible(); + QVERIFY(shown.stroke == effects.stroke && !shown.innerShadow && !shown.shadow && !shown.colorOverlay); + for (const LayerEffectKind kind : allLayerEffectKinds) { + LayerEffects full = every(); + full.remove(kind); + QVERIFY(!full.contains(kind) && full.kinds().size() == 3); + full.setColor(PaletteColor{1, 0, 0}, kind); + QVERIFY(!full.color(kind)); + } + // Each kind writes its own colour and switch alone. + for (const LayerEffectKind kind : allLayerEffectKinds) { + LayerEffects full = every(); + const LayerEffects before = full; + full.setColor(PaletteColor{0.5, 0.5, 0.25}, kind); + full.setEnabled(!before.isEnabled(kind), kind); + for (const LayerEffectKind other : allLayerEffectKinds) { + QCOMPARE(full.color(other) == (PaletteColor{0.5, 0.5, 0.25}), other == kind); + QCOMPARE(full.isEnabled(other) == before.isEnabled(other), other != kind); + } + } + const LayerEffects colours = every(); + QVERIFY(colours.color(LayerEffectKind::shadow) == (PaletteColor{0.5, 0.6, 0.7})); + QVERIFY(colours.color(LayerEffectKind::colorOverlay) == (PaletteColor{0.9, 0.25, 0.75})); + QVERIFY(colours.color(LayerEffectKind::innerShadow) == (PaletteColor{0.05, 0.15, 0.95})); + QVERIFY(colours.isEnabled(LayerEffectKind::colorOverlay) && colours.isEnabled(LayerEffectKind::shadow)); +} + +void LayerEffectsTests::theManifestWritesSwiftsKeys() +{ + const QJsonObject encoded = ManifestJson::encoded(every()); + QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerShadow", "shadow", "stroke"})); + const QJsonObject stroke = encoded.value("stroke").toObject(); + QCOMPARE(stroke.keys(), (QStringList{"blue", "enabled", "green", "inside", "opacity", "red", "size"})); + QVERIFY(stroke.value("enabled") == false && stroke.value("inside") == true && stroke.value("size") == 7); + // `enabled` stays out while absent, as Swift's encodeIfPresent. + QCOMPARE(encoded.value("shadow").toObject().keys(), (QStringList{"angle", "blue", "blur", "distance", "green", "opacity", "red"})); + QCOMPARE(encoded.value("colorOverlay").toObject().keys(), (QStringList{"blue", "enabled", "green", "opacity", "red"})); + QCOMPARE(encoded.value("innerShadow").toObject().value("angle"), QJsonValue(-30)); + QVERIFY(ManifestJson::effects(encoded) == every()); + // Absent effects stay out; null reads as absent. + LayerEffects one; + one.shadow = ShadowEffect(); + QCOMPARE(ManifestJson::encoded(one).keys(), QStringList{"shadow"}); + QVERIFY(ManifestJson::encoded(LayerEffects()).isEmpty()); + QJsonObject nulled = encoded; + nulled.insert("stroke", QJsonValue::Null); + nulled.insert("future", 3); + LayerEffects expected = every(); + expected.stroke.reset(); + QVERIFY(ManifestJson::effects(nulled) == expected); + // The record carries them through the manifest and back. + ProjectManifest manifest = withEffects()->projectSnapshot().value().manifest; + QVERIFY(manifest.layers[0].effects == every()); + QVERIFY(ProjectManifest::decoded(manifest.encoded()).layers[0].effects == every()); + manifest.layers[0].effects.reset(); + QVERIFY(!manifest.encoded().contains("effects")); +} + +void LayerEffectsTests::decodingRefusesWhatSwiftRefuses() +{ + const QJsonObject encoded = ManifestJson::encoded(every()); + QVERIFY(refused(QJsonObject{{"stroke", 3}}) && refused(QJsonObject{{"shadow", QJsonArray{}}})); + QVERIFY(!refused(QJsonObject{})); + // Every required key of every effect, taken away or mistyped. + for (const QString &effect : encoded.keys()) { + const QJsonObject keys = encoded.value(effect).toObject(); + for (const QString &key : keys.keys()) { + QJsonObject missing = keys, mistyped = keys; + missing.remove(key); + mistyped.insert(key, QStringLiteral("x")); + QJsonObject without = encoded, wrong = encoded; + without.insert(effect, missing); + wrong.insert(effect, mistyped); + QVERIFY2(refused(wrong), qPrintable(effect + " " + key)); + QCOMPARE(refused(without), key != QStringLiteral("enabled")); + } + } + // A number for a flag, a flag for a number. + QJsonObject stroke = encoded.value("stroke").toObject(); + stroke.insert("inside", 1); + QVERIFY(refused(QJsonObject{{"stroke", stroke}})); + QJsonObject shadow = encoded.value("shadow").toObject(); + shadow.insert("blur", true); + QVERIFY(refused(QJsonObject{{"shadow", shadow}})); + try { + ManifestJson::effects(QJsonValue(3)); + QFAIL("a number is no set of effects"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::invalid); + } +} + +void LayerEffectsTests::effectsSaveLoadAndExportWithTheirLayer() +{ + // Beyond Swift, whose snapshot drops them: they save and load. + const std::unique_ptr<EditorSession> session = withEffects(); + const QUuid id = session->activeLayerID().value(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + QVERIFY(snapshot.manifest.layers[0].effects == every()); + QTemporaryDir root; + ProjectStore::save(snapshot, root.filePath("Effects.comp")); + const ProjectSnapshot loaded = ProjectStore::load(root.filePath("Effects.comp")); + QVERIFY(loaded.manifest.layers[0].effects == every()); + EditorSession restored; + restored.installProject(loaded, root.filePath("Effects.comp")); + QVERIFY(layerWith(restored, id).effects == every()); + // A layer without them records none. + session->setEffects(LayerEffects()); + QVERIFY(!session->activeLayer().value().effects && !session->projectSnapshot().value().manifest.layers[0].effects); +} + +void LayerEffectsTests::duplicatesAndBrushCommitsKeepThem() +{ + const std::unique_ptr<EditorSession> session = withEffects(); + session->duplicateActiveLayer(); + QVERIFY(session->activeLayer().value().effects == every()); + // Swift's brush commit lists them: painted layers keep them. + session->selectTool(NavigationTool::brush); + session->beginBrush(QPointF(5, 5)); + session->continueBrush(QPointF(12, 5)); + session->finishBrushImmediately(); + QTRY_VERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoName(), QString("Brush Stroke")); + QVERIFY(session->activeLayer().value().effects == every()); + // So does a raster edit's commit. + session->selectAll(); + QVERIFY(awaited([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, std::move(done)); })); + QCOMPARE(session->history.undoName(), QString("Fill")); + QVERIFY(session->activeLayer().value().effects == every()); +} + +void LayerEffectsTests::rebuildsCopiesAndResizesDropThem() +{ + // Swift rebuilds these layers from lists that leave effects out. + const auto dropped = [](const std::function<void(EditorSession &)> &edit, const char *name) { + const std::unique_ptr<EditorSession> session = withEffects(); + edit(*session); + return session->history.undoName() == QString::fromLatin1(name) && !session->activeLayer().value().effects; + }; + QVERIFY(dropped([](EditorSession &session) { QVERIFY(awaited([&](std::function<void()> done) { session.invertPixels(std::move(done)); })); }, "Invert")); + QVERIFY(dropped([](EditorSession &session) { + session.beginLevels(); + LevelsSettings settings; + settings.ranges[0].outputWhite = 100; + session.updateLevels(settings, false); + QVERIFY(awaited([&](std::function<void()> done) { session.commitLevels(std::move(done)); })); + }, "Levels")); + QVERIFY(dropped([](EditorSession &session) { + session.beginHueSaturation(); + session.updateHueSaturation(HueSaturationSettings(90), false); + QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); })); + }, "Hue/Saturation")); + QVERIFY(dropped([](EditorSession &session) { + session.beginFilter(FilterKind::exposure); + FilterSettings settings = session.filterEdit().value().settings; + settings.exposure.exposure = 1; + session.updateFilter(settings, false); + QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); })); + }, "Exposure")); + QVERIFY(dropped([](EditorSession &session) { + session.applySelection(rectPath(QRectF(2, 2, 6, 4)), SelectionMode::replace, QStringLiteral("Select")); + QVERIFY(awaited([&](std::function<void()> done) { session.beginSelectionTransform(std::move(done)); })); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(3, 0); + session.previewTransform(draft); + session.commitTransform(); + }, "Transform Selection")); + // Both resizers rebuild their records without them, as Swift's. + const ProjectSnapshot snapshot = withEffects()->projectSnapshot().value(); + QVERIFY(!ImageResizer::resize(snapshot, ImageSizeOptions{.width = 40, .height = 20, .resolution = 72}).manifest.layers[0].effects); + QVERIFY(!CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = 30, .height = 12}).manifest.layers[0].effects); +} + +QTEST_GUILESS_MAIN(LayerEffectsTests) +#include "LayerEffectsTests.moc" diff --git a/tests/LayerFlipTests.cpp b/tests/LayerFlipTests.cpp new file mode 100644 index 0000000..21ec8c4 --- /dev/null +++ b/tests/LayerFlipTests.cpp @@ -0,0 +1,225 @@ +#include "SessionFixtures.h" +#include <QtTest> + +// LayerFlip: mirrored placements and the session's flips. +namespace { +QUuid insertImage(EditorSession &session, int width, int height, QPointF center) +{ + session.insert(ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), center); + return session.activeLayerID().value(); +} +} + +class LayerFlipTests : public QObject { + Q_OBJECT +private slots: + void mirroredCrossesTheLineAndTurnsTheAngle(); + void aLayerFlipsAboutItsOwnMiddleAsOneStep(); + void aSelectionAndAFolderFlipAboutTheirBox(); + void aLinkedMaskFollowsAndAnUnlinkedOneStays(); + void flipsAreRefusedWithoutATransformableLayer(); + void theCanvasFlipsEveryLayerMaskAndTheSelection(); + void aCanvasFlipEndsACropAndWaitsItsTurn(); +}; + +void LayerFlipTests::mirroredCrossesTheLineAndTurnsTheAngle() +{ + const LayerTransform placed{.origin = {10, 20}, .size = {30, 10}, .rotation = 15}; + const LayerTransform across = placed.mirrored(true, 100); + QCOMPARE(across.origin, QPointF(160, 20)); + QCOMPARE(across.size, placed.size); + QCOMPARE(across.rotation, -15.0); + QVERIFY(across.flipX && !across.flipY); + const LayerTransform over = placed.mirrored(false, 50); + QCOMPARE(over.origin, QPointF(10, 70)); + QCOMPARE(over.rotation, -15.0); + QVERIFY(!over.flipX && over.flipY); + // Twice across the same line is the placement itself. + QCOMPARE(across.mirrored(true, 100), placed); + QCOMPARE(over.mirrored(false, 50), placed); +} + +void LayerFlipTests::aLayerFlipsAboutItsOwnMiddleAsOneStep() +{ + EditorSession session; + session.createDocument(100, 100); + const QUuid id = insertImage(session, 30, 10, {25, 25}); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 15; }); + const LayerTransform before = layerWith(session, id).transform; + const int steps = session.history.undoCount(); + session.flipLayers(true); + LayerTransform flipped = layerWith(session, id).transform; + QCOMPARE(flipped.origin, before.origin); + QCOMPARE(flipped.rotation, -15.0); + QVERIFY(flipped.flipX); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Flip Horizontal")); + session.flipLayers(false); + flipped = layerWith(session, id).transform; + QCOMPARE(flipped.origin, before.origin); + QCOMPARE(flipped.rotation, 15.0); + QVERIFY(flipped.flipX && flipped.flipY); + QCOMPARE(session.history.undoName(), QString("Flip Vertical")); + session.undo(); + session.undo(); + QCOMPARE(layerWith(session, id).transform, before); + // An open transform commits first, then the flip lands. + session.beginTransform(); + LayerTransform moved = before; + moved.origin += QPointF(5, 0); + session.previewTransform(moved); + session.flipLayers(true); + QCOMPARE(layerWith(session, id).transform.origin, moved.origin); + QVERIFY(layerWith(session, id).transform.flipX); + QVERIFY(!session.transformEdit().has_value()); +} + +void LayerFlipTests::aSelectionAndAFolderFlipAboutTheirBox() +{ + EditorSession session; + session.createDocument(100, 100); + const QUuid left = insertImage(session, 10, 10, {5, 5}); + const QUuid right = insertImage(session, 10, 10, {95, 45}); + session.selectLayers({left, right}, right); + session.flipLayers(true); + // The box spans 0 to 100: the two swap sides. + QCOMPARE(layerWith(session, left).transform.origin, QPointF(90, 0)); + QCOMPARE(layerWith(session, right).transform.origin, QPointF(0, 40)); + QVERIFY(layerWith(session, left).transform.flipX && layerWith(session, right).transform.flipX); + QCOMPARE(session.history.undoName(), QString("Flip Horizontal")); + session.undo(); + QCOMPARE(layerWith(session, left).transform.origin, QPointF(0, 0)); + // A folder flips its contents about the box around them. + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(session.placeLayer(left, folder) && session.placeLayer(right, folder)); + session.selectLayer(folder); + session.flipLayers(false); + QCOMPARE(layerWith(session, left).transform.origin, QPointF(0, 40)); + QCOMPARE(layerWith(session, right).transform.origin, QPointF(90, 0)); + QVERIFY(layerWith(session, left).transform.flipY && !layerWith(session, left).transform.flipX); + QCOMPARE(layerWith(session, folder).transform.flipY, false); +} + +void LayerFlipTests::aLinkedMaskFollowsAndAnUnlinkedOneStays() +{ + EditorSession session; + session.createDocument(100, 100); + const QUuid linked = insertImage(session, 30, 10, {25, 25}); + const QUuid unlinked = insertImage(session, 30, 10, {75, 25}); + const LayerTransform placed{.origin = {12, 22}, .size = {10, 10}}; + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, linked, coverage()); + record(snapshot, linked).maskPlacement = placed; + setMask(snapshot, unlinked, coverage()); + record(snapshot, unlinked).maskLinked = false; + }); + session.selectLayer(linked); + session.flipLayers(true); + // The linked mask crosses the layer's middle, turned over. + const LayerTransform followed = layerWith(session, linked).mask.value().placement.value(); + QCOMPARE(followed.center(), QPointF(33, 27)); + QCOMPARE(followed.size, QSizeF(10, 10)); + QCOMPARE(followed.unitToDocument().map(QPointF(0, 0)), QPointF(38, 22)); + QCOMPARE(followed.unitToDocument().map(QPointF(1, 1)), QPointF(28, 32)); + QCOMPARE(followed.unitToDocument().map(QPointF(1, 0)), QPointF(28, 22)); + session.selectLayer(unlinked); + const LayerTransform before = layerWith(session, unlinked).transform; + session.flipLayers(true); + QVERIFY(layerWith(session, unlinked).transform.flipX); + QCOMPARE(layerWith(session, unlinked).mask.value().placement.value(), before); +} + +void LayerFlipTests::flipsAreRefusedWithoutATransformableLayer() +{ + EditorSession session; + session.flipLayers(true); + session.createDocument(100, 100); + session.addBlankLayer(); + QVERIFY(!session.canTransform()); + int steps = session.history.undoCount(); + session.flipLayers(true); + QCOMPARE(session.history.undoCount(), steps); + const QUuid id = insertImage(session, 10, 10, {5, 5}); + session.toggleLayerVisibility(id); + steps = session.history.undoCount(); + session.flipLayers(false); + QCOMPARE(session.history.undoCount(), steps); + QVERIFY(!layerWith(session, id).transform.flipY); + session.toggleLayerVisibility(id); + session.setIsProjectBusy(true); + session.flipLayers(false); + QVERIFY(!layerWith(session, id).transform.flipY); +} + +void LayerFlipTests::theCanvasFlipsEveryLayerMaskAndTheSelection() +{ + EditorSession session; + session.createDocument(100, 50); + const QUuid id = insertImage(session, 20, 10, {20, 10}); + const LayerTransform placed{.origin = {12, 22}, .size = {10, 10}}; + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, coverage()); + record(snapshot, id).maskPlacement = placed; + }); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + const LayerTransform folderBefore = layerWith(session, folder).transform; + QPainterPath outline; + outline.addRect(QRectF(10, 5, 20, 10)); + session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select")); + const CanvasDocument before = session.document().value(); + const int steps = session.history.undoCount(); + // Across the middle: every layer, a placed mask, the outline. + session.flipCanvas(true); + QCOMPARE(layerWith(session, id).transform.origin, QPointF(70, 5)); + QVERIFY(layerWith(session, id).transform.flipX); + QCOMPARE(layerWith(session, folder).transform, folderBefore.mirrored(true, 50)); + const LayerTransform mirrored = layerWith(session, id).mask.value().placement.value(); + QCOMPARE(mirrored.origin, QPointF(78, 22)); + QVERIFY(mirrored.flipX); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(70, 5, 20, 10)); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Flip Canvas Horizontal")); + session.flipCanvas(false); + QCOMPARE(layerWith(session, id).transform.origin, QPointF(70, 35)); + QVERIFY(layerWith(session, id).transform.flipY); + QCOMPARE(layerWith(session, id).mask.value().placement.value().origin, QPointF(78, 18)); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(70, 35, 20, 10)); + QCOMPARE(session.history.undoName(), QString("Flip Canvas Vertical")); + session.undo(); + session.undo(); + QVERIFY(session.document().value() == before); +} + +void LayerFlipTests::aCanvasFlipEndsACropAndWaitsItsTurn() +{ + EditorSession session; + session.flipCanvas(true); + session.createDocument(100, 50); + const QUuid id = insertImage(session, 20, 10, {20, 10}); + // A drawn frame goes, then the canvas flips. + session.selectTool(NavigationTool::crop); + session.setCropRect(QRectF(0, 0, 50, 50)); + session.flipCanvas(true); + QCOMPARE(session.cropRect(), std::nullopt); + QCOMPARE(layerWith(session, id).transform.origin, QPointF(70, 5)); + // An open transform commits first, then the flip lands. + session.selectTool(NavigationTool::move); + session.beginTransform(); + LayerTransform moved = layerWith(session, id).transform; + moved.origin += QPointF(5, 0); + session.previewTransform(moved); + session.flipCanvas(true); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, id).transform.origin, QPointF(5, 5)); + // Busy, the canvas stays. + const int steps = session.history.undoCount(); + session.setIsProjectBusy(true); + session.flipCanvas(false); + QCOMPARE(session.history.undoCount(), steps); + QVERIFY(!layerWith(session, id).transform.flipY); +} + +QTEST_GUILESS_MAIN(LayerFlipTests) +#include "LayerFlipTests.moc" diff --git a/tests/LayerListFocusTests.cpp b/tests/LayerListFocusTests.cpp new file mode 100644 index 0000000..0b33b3f --- /dev/null +++ b/tests/LayerListFocusTests.cpp @@ -0,0 +1,388 @@ +#include "UI/LayerAppearanceControls.h" +#include "UI/LayerIcons.h" +#include "UI/LayersPanel.h" +#include "UI/NativeLayerList.h" +#include "SessionFixtures.h" +#include <QLineEdit> +#include <QMainWindow> +#include <QMenu> +#include <QMenuBar> +#include <QScrollBar> +#include <QSlider> +#include <QtTest> + +// The list under menus, themes and a short viewport. +namespace { +std::unique_ptr<EditorSession> sessionWithLayers(int count) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600); + for (int each = 0; each < count; ++each) + session->addBlankLayer(); + return session; +} + +// A window with an Edit menu over the panel. +struct Framed { + QMainWindow window; + explicit Framed(QWidget *central) + { + window.menuBar()->addMenu(QStringLiteral("&Edit"))->addAction(QStringLiteral("Nothing")); + window.setCentralWidget(central); + window.resize(400, 300); + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + } + void openEditMenuAndClose() + { + QTest::keyClick(&window, Qt::Key_E, Qt::AltModifier); + QTRY_VERIFY(QApplication::activePopupWidget() != nullptr); + QTest::keyClick(QApplication::activePopupWidget(), Qt::Key_Escape); + QTest::keyClick(window.menuBar(), Qt::Key_Escape); + } +}; + +QImage iconImage(const QIcon &icon, int side) +{ + return icon.pixmap(QSize(side, side)).toImage(); +} +} + +class LayerListFocusTests : public QObject { + Q_OBJECT +private slots: + void aMenuBorrowingFocusKeepsTheRenameAndTheField(); + void aSwipeInterruptedByARebuildStillEndsItsStep(); + void aSwipeScrollsAShortListAndTheKeysKeepTheRowInView(); + void aNewPaletteRedrawsEveryIcon(); + void aSwipeReleasedOverAPopupStillEnds(); + void shiftArrowsGrowAndShrinkARangeFromTheAnchor(); + void altAndShiftOnAMaskThumbnailChooseAndToggleIt(); + void tabAndTheBrushKeysStayWithTheList(); + void aSliderDragReleasedOverAPopupStillEnds(); + void anotherButtonsReleaseLeavesTheSwipe(); + void altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask(); +}; + +void LayerListFocusTests::aMenuBorrowingFocusKeepsTheRenameAndTheField() +{ + const auto session = sessionWithLayers(2); + auto *panel = new LayersPanel(*session); + Framed framed(panel); + LayerCell &row = *panel->list().cells().at(0); + QTest::mouseDClick(&row, Qt::LeftButton, Qt::NoModifier, QPoint(row.width() - 20, 20)); + auto *editor = row.findChild<QLineEdit *>("layerNameEditor"); + QTRY_VERIFY(editor->hasFocus()); + QTest::keyClicks(editor, "Sky"); + framed.openEditMenuAndClose(); + QTRY_VERIFY(editor->hasFocus()); + QVERIFY(editor->isVisible() && session->renamingLayerID().has_value()); + QCOMPARE(layerWith(*session, row.layerID()).name, QString("Layer 2")); + QTest::keyClick(editor, Qt::Key_Return); + QCOMPARE(layerWith(*session, row.layerID()).name, QString("Sky")); + // The opacity field keeps its text and undo too. + auto *field = panel->findChild<QLineEdit *>("opacityPercent"); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, "40"); + framed.openEditMenuAndClose(); + QTRY_VERIFY(field->hasFocus()); + QCOMPARE(field->text(), QString("40")); + QCOMPARE(session->activeLayer().value().opacity, 1.0); + QVERIFY(field->isUndoAvailable()); + QTest::keyClick(field, Qt::Key_Return); + QCOMPARE(session->activeLayer().value().opacity, 0.4); +} + +void LayerListFocusTests::aSwipeInterruptedByARebuildStillEndsItsStep() +{ + const auto session = sessionWithLayers(2); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + auto *eye = list.cells().at(0)->findChild<EyeSwipeButton *>("layerEye"); + const int steps = session->history.undoCount(); + QTest::mousePress(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + QVERIFY(!session->canUndo()); + // The shortcut lands mid-swipe: the rows are made anew. + session->addBlankLayer(); + QCOMPARE(list.cells().size(), size_t(3)); + QTRY_VERIFY(session->canUndo()); + QCOMPARE(session->history.undoCount(), steps + 1); + QCOMPARE(session->document().value().layers.size(), size_t(3)); + session->undo(); + QCOMPARE(session->document().value().layers.size(), size_t(2)); + QVERIFY(session->document().value().layers.back().isVisible); +} + +void LayerListFocusTests::aSwipeScrollsAShortListAndTheKeysKeepTheRowInView() +{ + const auto session = sessionWithLayers(15); + NativeLayerList list(*session); + list.resize(252, 200); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + QCOMPARE(list.verticalScrollBar()->value(), 0); + auto *eye = list.cells().at(0)->findChild<EyeSwipeButton *>("layerEye"); + QTest::mousePress(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + // Held below the viewport, the list scrolls by the overshoot. + const QPoint below(10, 16 + 250); + QMouseEvent move(QEvent::MouseMove, QPointF(below), eye->mapToGlobal(below), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(eye, &move); + QTest::mouseRelease(eye, Qt::LeftButton, Qt::NoModifier, below); + QCOMPARE(list.verticalScrollBar()->value(), 76); + // The pointer sat 276 points down the list, now scrolled. + const int reached = list.rowAt(QPoint(10, 276)); + QCOMPARE(reached, 6); + QVERIFY(!layerWith(*session, list.cells().at(reached)->layerID()).isVisible); + QVERIFY(layerWith(*session, list.cells().at(reached + 1)->layerID()).isVisible); + session->undo(); + list.verticalScrollBar()->setValue(0); + // Down walks past the view and scrolls; Shift extends. + session->selectLayers({list.cells().at(0)->layerID()}, list.cells().at(0)->layerID()); + session->selectTool(NavigationTool::brush); + list.setFocus(); + for (int each = 0; each < 6; ++each) + QTest::keyClick(&list, Qt::Key_Down); + QCOMPARE(session->activeLayerID(), std::optional(list.cells().at(6)->layerID())); + QVERIFY(list.verticalScrollBar()->value() > 0); + const QRect shown = list.cells().at(6)->geometry().translated(0, -list.verticalScrollBar()->value()); + QVERIFY(list.viewport()->rect().contains(shown)); + QTest::keyClick(&list, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), (QSet<QUuid>{list.cells().at(6)->layerID(), list.cells().at(7)->layerID()})); + QCOMPARE(session->activeLayerID(), std::optional(list.cells().at(7)->layerID())); +} + +void LayerListFocusTests::aNewPaletteRedrawsEveryIcon() +{ + const auto session = sessionWithLayers(1); + session->addGroup(); + LayersPanel panel(*session); + panel.show(); + QVERIFY(QTest::qWaitForWindowExposed(&panel)); + const QPalette before = QApplication::palette(); + QPalette changed = before; + changed.setColor(QPalette::PlaceholderText, QColor(10, 200, 30)); + changed.setColor(QPalette::WindowText, QColor(200, 10, 30)); + QApplication::setPalette(changed); + const double ratio = panel.devicePixelRatio(); + auto *add = panel.findChild<QToolButton *>("addBlankLayer"); + QTRY_COMPARE(iconImage(add->icon(), 16), iconImage(LayerIcons::pixmap(LayerIcon::newLayer, 16, QColor(10, 200, 30), ratio), 16)); + QCOMPARE(iconImage(panel.findChild<QToolButton *>("addLayerMask")->icon(), 16), iconImage(LayerIcons::pixmap(LayerIcon::addMask, 16, QColor(10, 200, 30), ratio), 16)); + LayerCell &folder = *panel.list().cells().at(0); + QTRY_COMPARE(iconImage(folder.findChild<QToolButton *>("layerEye")->icon(), 16), iconImage(LayerIcons::pixmap(LayerIcon::eye, 16, QColor(200, 10, 30), ratio), 16)); + QCOMPARE(iconImage(folder.thumbnail().icon(), 36), iconImage(LayerIcons::pixmap(LayerIcon::folder, 36 * 0.8, QColor(200, 10, 30), ratio), 36)); + QApplication::setPalette(before); + QTRY_COMPARE(iconImage(add->icon(), 16), iconImage(LayerIcons::pixmap(LayerIcon::newLayer, 16, before.color(QPalette::PlaceholderText), ratio), 16)); +} + +void LayerListFocusTests::aSwipeReleasedOverAPopupStillEnds() +{ + const auto session = sessionWithLayers(2); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + auto *eye = list.cells().at(0)->findChild<EyeSwipeButton *>("layerEye"); + const int steps = session->history.undoCount(); + QTest::mousePress(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + QVERIFY(!session->canUndo()); + // A menu opened mid-press takes the release. + QMenu menu; + menu.addAction(QStringLiteral("Nothing")); + menu.popup(list.mapToGlobal(QPoint(100, 100))); + QTRY_VERIFY(menu.isVisible()); + QTest::mouseRelease(&menu, Qt::LeftButton, Qt::NoModifier, QPoint(4, 4)); + menu.close(); + QVERIFY(session->canUndo()); + QCOMPARE(session->history.undoCount(), steps + 1); + QVERIFY(!layerWith(*session, list.cells().at(0)->layerID()).isVisible); + // The next press begins a swipe of its own. + QTest::mouseClick(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + QCOMPARE(session->history.undoCount(), steps + 2); + QVERIFY(layerWith(*session, list.cells().at(0)->layerID()).isVisible); +} + +void LayerListFocusTests::shiftArrowsGrowAndShrinkARangeFromTheAnchor() +{ + const auto session = sessionWithLayers(4); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + session->selectTool(NavigationTool::hand); + const auto id = [&](int row) { return list.cells().at(size_t(row))->layerID(); }; + const auto ids = [&](std::initializer_list<int> rows) { + QSet<QUuid> set; + for (const int row : rows) + set.insert(id(row)); + return set; + }; + session->selectLayers({id(0)}, id(0)); + list.setFocus(); + QTest::keyClick(&list, Qt::Key_Down, Qt::ShiftModifier); + QTest::keyClick(&list, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({0, 1, 2})); + QCOMPARE(session->activeLayerID(), std::optional(id(2))); + // Back up: the range shrinks to its anchor, then past. + QTest::keyClick(&list, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({0, 1})); + QCOMPARE(session->activeLayerID(), std::optional(id(1))); + QTest::keyClick(&list, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({0})); + QTest::keyClick(&list, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({0})); + session->selectLayers({id(2)}, id(2)); + QTest::keyClick(&list, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({1, 2})); + QTest::keyClick(&list, Qt::Key_Down, Qt::ShiftModifier); + QTest::keyClick(&list, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session->selectedLayerIDs(), ids({2, 3})); + QCOMPARE(session->activeLayerID(), std::optional(id(3))); +} + +void LayerListFocusTests::altAndShiftOnAMaskThumbnailChooseAndToggleIt() +{ + const auto session = sessionWithLayers(1); + session->addLayerMask(); + session->selectLayerTarget(session->activeLayerID().value(), false); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + LayerThumbnailButton &mask = list.cells().at(0)->maskThumbnail(); + // An Alt click chooses the mask; a drag away, nothing. + QTest::mousePress(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + QVERIFY(!session->isMaskSelected()); + QTest::mouseRelease(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(200, 5)); + QVERIFY(!session->isMaskSelected()); + QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier, QPoint(5, 5)); + QVERIFY(session->isMaskSelected()); + // A Shift double click toggles twice and renames nothing. + QTest::mouseDClick(list.windowHandle(), Qt::LeftButton, Qt::ShiftModifier, mask.mapTo(&list, QPoint(5, 5))); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); + QVERIFY(!session->renamingLayerID().has_value()); + QTest::mouseClick(&mask, Qt::LeftButton, Qt::ShiftModifier, QPoint(5, 5)); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); +} + +void LayerListFocusTests::aSliderDragReleasedOverAPopupStillEnds() +{ + const auto session = sessionWithLayers(1); + LayerAppearanceControls controls(*session); + controls.show(); + QVERIFY(QTest::qWaitForWindowActive(&controls)); + auto *slider = controls.findChild<QSlider *>("opacitySlider"); + const int steps = session->history.undoCount(); + // The handle sits at the right end, at full opacity. + QTest::mousePress(slider, Qt::LeftButton, Qt::NoModifier, QPoint(slider->width() - 4, slider->height() / 2)); + QVERIFY(slider->isSliderDown() && !session->canUndo()); + QMenu menu; + menu.addAction(QStringLiteral("Nothing")); + menu.popup(controls.mapToGlobal(QPoint(50, 50))); + QTRY_VERIFY(menu.isVisible()); + QTest::mouseRelease(&menu, Qt::LeftButton, Qt::NoModifier, QPoint(4, 4)); + menu.close(); + QVERIFY(!slider->isSliderDown()); + QVERIFY(session->canUndo()); + QCOMPARE(session->history.undoCount(), steps); + // The next drag is its own step. + QTest::mousePress(slider, Qt::LeftButton, Qt::NoModifier, QPoint(slider->width() - 4, slider->height() / 2)); + QVERIFY(!session->canUndo()); + const QPoint middle(slider->width() / 2, slider->height() / 2); + QMouseEvent drag(QEvent::MouseMove, QPointF(middle), slider->mapToGlobal(middle), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(slider, &drag); + QTest::mouseRelease(slider, Qt::LeftButton, Qt::NoModifier, middle); + QVERIFY(session->canUndo()); + QCOMPARE(session->history.undoCount(), steps + 1); + QVERIFY(session->activeLayer().value().opacity < 0.6); +} + +void LayerListFocusTests::anotherButtonsReleaseLeavesTheSwipe() +{ + const auto session = sessionWithLayers(3); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + auto *eye = list.cells().at(0)->findChild<EyeSwipeButton *>("layerEye"); + QTest::mousePress(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + QTest::mousePress(eye, Qt::MiddleButton, Qt::NoModifier, QPoint(10, 16)); + QTest::mouseRelease(eye, Qt::MiddleButton, Qt::NoModifier, QPoint(10, 16)); + QVERIFY(!session->canUndo()); + // Still swiping: the next row takes the state. + const QPoint next(10, 16 + LayerCell::rowHeight); + QMouseEvent move(QEvent::MouseMove, QPointF(next), eye->mapToGlobal(next), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(eye, &move); + QVERIFY(!layerWith(*session, list.cells().at(1)->layerID()).isVisible); + QTest::mouseRelease(eye, Qt::LeftButton, Qt::NoModifier, next); + QVERIFY(session->canUndo()); +} + +void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask() +{ + const auto session = sessionWithLayers(2); + NativeLayerList list(*session); + list.resize(252, 300); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + LayerCell &top = *list.cells().at(0); + const QPoint strip = top.mapTo(&list, QPoint(top.width() - 20, LayerCell::rowHeight - 4)); + const int steps = session->history.undoCount(); + QTest::mouseDClick(list.windowHandle(), Qt::LeftButton, Qt::AltModifier, strip); + QVERIFY(!layerWith(*session, top.layerID()).maskSourceID.has_value()); + QVERIFY(!session->renamingLayerID().has_value()); + // Clipped and released again: two steps, no rename. + QCOMPARE(session->history.undoCount(), steps + 2); + session->undo(); + QCOMPARE(layerWith(*session, top.layerID()).maskSourceID, std::optional(list.cells().at(1)->layerID())); + session->redo(); + // Alt+Shift on a mask chooses it and toggles it. + session->selectLayers({top.layerID()}, top.layerID()); + session->addLayerMask(); + session->selectLayerTarget(top.layerID(), false); + QTest::mouseClick(&top.maskThumbnail(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, QPoint(5, 5)); + QVERIFY(session->isMaskSelected()); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); +} + +void LayerListFocusTests::tabAndTheBrushKeysStayWithTheList() +{ + const auto session = sessionWithLayers(2); + auto *panel = new QWidget; + auto *list = new NativeLayerList(*session, panel); + list->setGeometry(0, 0, 252, 200); + // A field beside it could take the focus from Tab. + (new QLineEdit(panel))->setGeometry(0, 210, 100, 20); + Framed framed(panel); + list->setFocus(); + QTRY_VERIFY(list->hasFocus()); + session->selectTool(NavigationTool::marquee); + // Tab and Shift-Tab both switch the shape, as Swift's table. + QTest::keyClick(list, Qt::Key_Tab); + QCOMPARE(session->marqueeKind(), LassoKind::ellipse); + QTest::keyClick(list, Qt::Key_Backtab, Qt::ShiftModifier); + QCOMPARE(session->marqueeKind(), LassoKind::rectangle); + QTest::keyClick(list, Qt::Key_Tab, Qt::ControlModifier); + QCOMPARE(session->marqueeKind(), LassoKind::rectangle); + QVERIFY(list->hasFocus()); + // E erases, B paints; Ctrl leaves the letter alone. + QTest::keyClick(list, Qt::Key_E); + QVERIFY(session->tool() == NavigationTool::brush && session->brushMode() == BrushToolMode::erase); + QTest::keyClick(list, Qt::Key_B); + QCOMPARE(session->brushMode(), BrushToolMode::paint); + QTest::keyClick(list, Qt::Key_E, Qt::ControlModifier); + QCOMPARE(session->brushMode(), BrushToolMode::paint); + QTest::keyClick(list, Qt::Key_4); + QCOMPARE(session->brushSettings().opacity, 0.4); + // Where digits set nothing, the key goes to the parent. + session->selectTool(NavigationTool::hand); + QKeyEvent digit(QEvent::KeyPress, Qt::Key_5, Qt::NoModifier, QStringLiteral("5")); + QApplication::sendEvent(list, &digit); + QVERIFY(!digit.isAccepted()); +} + +QTEST_MAIN(LayerListFocusTests) +#include "LayerListFocusTests.moc" diff --git a/tests/LayerMaskEditTests.cpp b/tests/LayerMaskEditTests.cpp new file mode 100644 index 0000000..eb566ee --- /dev/null +++ b/tests/LayerMaskEditTests.cpp @@ -0,0 +1,404 @@ +#include "SessionFixtures.h" +#include <QtTest> + +// Mask edits in the session beyond Swift's own cases. +class LayerMaskEditTests : public QObject { + Q_OBJECT +private slots: + void masksAreNamedStepsAndRefusedBehindGates(); + void aMaskCopiesToAnotherLayerWhereItSits(); + void theMaskTargetFollowsTheLayerAndItsMask(); + void aGroupMoveCarriesLinkedMasksAndLeavesUnlinkedOnes(); + void aMaskAloneHasNoPixelSizeAndItsOwnHandles(); + void maskStepsEndAnOpenOpacityDragFirst(); + void aTargetIsChosenOnAnyLayer(); + void whatOnlyNeedsEditableLayersWorksWithSeveralSelected(); +}; + +void LayerMaskEditTests::masksAreNamedStepsAndRefusedBehindGates() +{ + EditorSession empty; + empty.addLayerMask(); + empty.toggleLayerMask(); + empty.deleteLayerMask(); + empty.toggleMaskLink(QUuid::createUuid()); + QVERIFY(!empty.canEditMask() && !empty.document().has_value()); + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + // No mask: none to toggle, delete or unlink. + const int count = session->history.undoCount(); + session->toggleLayerMask(); + session->deleteLayerMask(); + session->toggleMaskLink(id); + QCOMPARE(session->history.undoCount(), count); + const QStringList names{"Add Reveal-All Mask", "Disable Layer Mask", "Enable Layer Mask", "Unlink Layer Mask", "Link Layer Mask", "Delete Layer Mask"}; + const std::vector<std::function<void()>> steps{[&] { session->addLayerMask(); }, [&] { session->toggleLayerMask(); }, + [&] { session->toggleLayerMask(); }, [&] { session->toggleMaskLink(id); }, + [&] { session->toggleMaskLink(id); }, [&] { session->deleteLayerMask(); }}; + for (size_t index = 0; index < steps.size(); ++index) { + steps[index](); + QCOMPARE(session->history.undoCount(), count + int(index) + 1); + QCOMPARE(session->history.undoName(), names[qsizetype(index)]); + } + session->addLayerMask(false); + QCOMPARE(session->history.undoName(), QString("Add Hide-All Mask")); + // A link change ends an open opacity drag first. + session->beginOpacityEdit(); + session->setLayerOpacity(0.5); + session->toggleMaskLink(id); + QVERIFY(session->canUndo()); + QCOMPARE(session->history.undoName(), QString("Unlink Layer Mask")); + session->undo(); + QCOMPARE(session->history.undoName(), QString("Layer Opacity")); + // Several layers, or a gate, and no mask is edited. + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Other")); + const QUuid other = session->activeLayerID().value(); + session->selectLayers({id, other}, id); + QVERIFY(!session->canEditMask()); + const std::optional<CanvasDocument> before = session->document(); + session->toggleLayerMask(); + session->deleteLayerMask(); + session->selectLayer(other); + session->setIsImporting(true); + session->addLayerMask(); + session->toggleMaskLink(id); + session->selectLayerTarget(id, true); + QCOMPARE(session->document(), before); + QCOMPARE(session->activeLayerID(), std::optional(other)); + session->setIsImporting(false); + session->setIsProjectBusy(true); + session->selectLayerTarget(id, true); + QCOMPARE(session->activeLayerID(), std::optional(other)); + QVERIFY(!session->isMaskSelected()); +} + +void LayerMaskEditTests::aMaskCopiesToAnotherLayerWhereItSits() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid source = session->activeLayerID().value(); + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Target"), QPointF(2, 1)); + const QUuid target = session->activeLayerID().value(); + session->addGroup(); + const QUuid folder = session->activeLayerID().value(); + // No mask yet; never onto itself or a folder. + QVERIFY(!session->canCopyMask(source, target)); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, source, coverage()); + record(snapshot, source).maskEnabled = false; + record(snapshot, source).maskLinked = false; + }); + QVERIFY(session->canCopyMask(source, target)); + QVERIFY(!session->canCopyMask(source, source) && !session->canCopyMask(source, folder)); + QVERIFY(!session->canCopyMask(target, source) && !session->canCopyMask(source, QUuid::createUuid())); + session->selectLayer(source); + session->beginOpacityEdit(); + session->copyMask(source, target); + QCOMPARE(session->history.undoName(), QString("Copy Layer Mask")); + QVERIFY(session->canUndo()); + // Same flags and pixels, placed where the source sits. + const LayerMask original = layerWith(*session, source).mask.value(); + const LayerMask copy = layerWith(*session, target).mask.value(); + QVERIFY(copy.asset.identity() == original.asset.identity()); + QVERIFY(!copy.isEnabled && !copy.isLinked); + QCOMPARE(copy.placement, std::optional(layerWith(*session, source).transform)); + QCOMPARE(original.placement, std::nullopt); + QCOMPARE(session->activeLayerID(), std::optional(target)); + QVERIFY(session->isMaskSelected()); + // Onto a masked layer the copy replaces the mask. + session->copyMask(target, source); + QCOMPARE(session->history.undoName(), QString("Replace Layer Mask")); + QCOMPARE(layerWith(*session, source).mask.value().placement, copy.placement); + // Neither end active: the named source gives pixels and place. + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Third")); + const QUuid third = session->activeLayerID().value(); + QImage flat(3, 3, QImage::Format_Grayscale8); + flat.fill(40); + const LayerTransform far{.origin = {9, 8}, .size = {3, 3}}; + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, third, LayerMask::assetFrom(flat)); + record(snapshot, third).maskPlacement = far; + }); + session->addBlankLayer(); + const QUuid active = session->activeLayerID().value(); + session->addLayerMask(false); + session->copyMask(third, target); + const LayerMask landed = layerWith(*session, target).mask.value(); + QVERIFY(landed.asset.identity() == layerWith(*session, third).mask.value().asset.identity()); + QCOMPARE(landed.placement, std::optional(far)); + QVERIFY(landed.isEnabled && landed.isLinked); + QVERIFY(layerWith(*session, active).mask.value().asset.identity() != landed.asset.identity()); + session->setShowsImporter(true); + QVERIFY(!session->canCopyMask(source, target)); +} + +void LayerMaskEditTests::theMaskTargetFollowsTheLayerAndItsMask() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + session->selectLayerTarget(id, true); + QVERIFY(!session->isMaskSelected()); + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + // Undo takes the mask away and the target with it. + session->undo(); + QVERIFY(!session->isMaskSelected()); + session->redo(); + QVERIFY(!session->isMaskSelected()); + session->selectLayerTarget(id, true); + session->toggleLayerMask(); + session->undo(); + // Layer and mask remain: the mask stays the target. + QVERIFY(session->isMaskSelected()); + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Other")); + QVERIFY(!session->isMaskSelected()); + session->undo(); + QVERIFY(!session->isMaskSelected()); + // Not the target before an undo, not the target after. + session->selectLayerTarget(id, false); + session->toggleLayerMask(); + session->undo(); + QVERIFY(!session->isMaskSelected()); + // An undo that brings back another active layer drops it. + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Second")); + const QUuid other = session->activeLayerID().value(); + session->selectLayerTarget(id, true); + session->copyMask(id, other); + QVERIFY(session->isMaskSelected() && session->activeLayerID() == std::optional(other)); + session->undo(); + QCOMPARE(session->activeLayerID(), std::optional(id)); + QVERIFY(session->activeLayer().value().mask.has_value() && !session->isMaskSelected()); + // The same layer keeps the target; another drops it. + session->selectLayerTarget(id, true); + session->selectLayer(id); + QVERIFY(session->isMaskSelected()); + session->selectLayer(std::nullopt); + QVERIFY(!session->isMaskSelected()); + session->selectLayerTarget(id, true); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + session->installProject(snapshot, "fixture.comp"); + QVERIFY(!session->isMaskSelected()); + session->selectLayerTarget(id, true); + session->clearProject(); + QVERIFY(!session->isMaskSelected()); +} + +void LayerMaskEditTests::aGroupMoveCarriesLinkedMasksAndLeavesUnlinkedOnes() +{ + EditorSession session; + session.createDocument(100, 100); + const auto inserted = [&](QPointF center) { + session.insert(ImportedImage(QImage(20, 10, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), center); + return session.activeLayerID().value(); + }; + const QUuid linked = inserted({20, 20}), unlinked = inserted({60, 60}), apart = inserted({50, 20}); + QImage gray(20, 10, QImage::Format_Grayscale8); + gray.fill(90); + const LayerTransform moved{.origin = {40, 5}, .size = {20, 10}}; + rewrite(session, [&](ProjectSnapshot &snapshot) { + for (const QUuid id : {linked, unlinked, apart}) + setMask(snapshot, id, LayerMask::assetFrom(gray)); + record(snapshot, linked).maskPlacement = moved; + record(snapshot, unlinked).maskLinked = false; + record(snapshot, apart).maskPlacement = LayerTransform{.origin = {3, 70}, .size = {10, 5}}; + // Switched off, a mask keeps its place and rides along. + record(snapshot, apart).maskEnabled = false; + record(snapshot, linked).maskEnabled = false; + }); + const std::optional<LayerTransform> aside = layerWith(session, apart).mask.value().placement; + // A layer without a mask has no placement to show. + const QUuid bare = inserted({80, 80}); + QCOMPARE(session.displayedMaskPlacement(layerWith(session, bare)), std::nullopt); + const LayerTransform stayed = layerWith(session, unlinked).transform; + session.selectLayers({linked, unlinked}, linked); + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(7, -3); + session.previewTransform(draft); + // A linked mask rides along; an unlinked one stays. + LayerTransform ridden = moved; + ridden.origin += QPointF(7, -3); + QCOMPARE(session.displayedMaskPlacement(layerWith(session, linked)), std::optional(ridden)); + QCOMPARE(session.displayedMaskPlacement(layerWith(session, unlinked)), std::optional(layerWith(session, unlinked).transform)); + // A mask outside the group keeps the place it has. + QCOMPARE(session.displayedMaskPlacement(layerWith(session, apart)), aside); + QVERIFY(aside.has_value()); + session.commitTransform(); + QCOMPARE(layerWith(session, linked).mask.value().placement, std::optional(ridden)); + QCOMPARE(layerWith(session, unlinked).mask.value().placement, std::optional(stayed)); + QCOMPARE(layerWith(session, apart).mask.value().placement, aside); + QCOMPARE(layerWith(session, unlinked).transform.origin, stayed.origin + QPointF(7, -3)); +} + +void LayerMaskEditTests::aMaskAloneHasNoPixelSizeAndItsOwnHandles() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + const LayerTransform placement{.origin = {1, 0}, .size = {4, 3}}; + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, coverage()); + record(snapshot, id).maskPlacement = placement; + }); + // Linked, the mask goes with its layer whatever is selected. + session->selectLayerTarget(id, true); + QVERIFY(!session->transformTargetsMask()); + QCOMPARE(session->transformPixelSize(), std::optional(QSizeF(2, 2))); + QCOMPARE(session->editedTransform(layerWith(*session, id)), layerWith(*session, id).transform); + session->toggleMaskLink(id); + // Unlinked: only with the mask as the target. + session->selectLayerTarget(id, false); + QVERIFY(!session->transformTargetsMask()); + session->selectLayerTarget(id, true); + QVERIFY(session->transformTargetsMask()); + QCOMPARE(session->transformPixelSize(), std::nullopt); + QCOMPARE(session->editedTransform(layerWith(*session, id)), placement); + session->beginTransform(); + QCOMPARE(session->transformEdit().value().draft, placement); + QVERIFY(session->transformEdit().value().mask); + // The open edit decides, not what is selected meanwhile. + session->selectLayerTarget(id, false); + QVERIFY(session->transformEdit().has_value() && !session->isMaskSelected()); + QVERIFY(session->transformTargetsMask()); + QCOMPARE(session->editedTransform(layerWith(*session, id)), placement); + QCOMPARE(session->transformPixelSize(), std::nullopt); + QCOMPARE(session->pendingTransform(layerWith(*session, id)), std::nullopt); + QCOMPARE(session->displayedMaskPlacement(layerWith(*session, id)), std::optional(placement)); + const int count = session->history.undoCount(); + session->commitTransform(); + QCOMPARE(session->history.undoCount(), count); + // Another masked layer neither shows nor takes this edit. + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Other")); + const QUuid other = session->activeLayerID().value(); + const LayerTransform elsewhere{.origin = {0, 1}, .size = {2, 2}}; + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, other, coverage()); + record(snapshot, other).maskPlacement = elsewhere; + record(snapshot, other).maskLinked = false; + }); + session->selectLayerTarget(id, true); + // Its handles stay on its own pixels meanwhile. + QVERIFY(session->transformTargetsMask()); + QCOMPARE(session->editedTransform(layerWith(*session, other)), layerWith(*session, other).transform); + session->beginTransform(); + session->nudgeLayer(3, 0); + QCOMPARE(session->displayedMaskPlacement(layerWith(*session, other)), std::optional(elsewhere)); + // A layer's own edit stays one, whatever gets selected. + session->commitTransform(); + session->selectLayerTarget(id, false); + session->beginTransform(); + session->selectLayerTarget(id, true); + QVERIFY(session->isMaskSelected() && !session->transformTargetsMask()); + session->cancelTransform(); + session->selectLayerTarget(id, true); + session->beginTransform(); + session->nudgeLayer(0, 0); + session->commitTransform(); + QCOMPARE(layerWith(*session, other).mask.value().placement, std::optional(elsewhere)); + QCOMPARE(layerWith(*session, id).mask.value().placement.value().origin, QPointF(4, 0)); + // Back over its layer the mask has no placement again. + session->beginTransform(); + session->previewTransform(layerWith(*session, id).transform); + QCOMPARE(session->displayedMaskPlacement(layerWith(*session, id)), std::nullopt); + session->commitTransform(); + QCOMPARE(layerWith(*session, id).mask.value().placement, std::nullopt); + QCOMPARE(session->history.undoCount(), count + 1); +} + +void LayerMaskEditTests::maskStepsEndAnOpenOpacityDragFirst() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const int count = session->history.undoCount(); + const std::vector<std::pair<QString, std::function<void()>>> steps{ + {"Add Reveal-All Mask", [&] { session->addLayerMask(); }}, + {"Disable Layer Mask", [&] { session->toggleLayerMask(); }}, + {"Delete Layer Mask", [&] { session->deleteLayerMask(); }}}; + int expected = count; + for (const auto &[name, step] : steps) { + // The drag is its own step, under the mask's. + session->beginOpacityEdit(); + session->setLayerOpacity(0.5 - 0.1 * (expected - count)); + step(); + expected += 2; + QVERIFY(session->canUndo()); + QCOMPARE(session->history.undoCount(), expected); + QCOMPARE(session->history.undoName(), name); + session->undo(); + QCOMPARE(session->history.undoName(), QString("Layer Opacity")); + session->redo(); + } +} + +void LayerMaskEditTests::aTargetIsChosenOnAnyLayer() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid masked = session->activeLayerID().value(); + session->addLayerMask(); + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Bare")); + const QUuid bare = session->activeLayerID().value(); + // The layer becomes active; its mask, if any, the target. + session->selectLayerTarget(masked, true); + QCOMPARE(session->activeLayerID(), std::optional(masked)); + QVERIFY(session->isMaskSelected()); + session->selectLayerTarget(bare, true); + QCOMPARE(session->activeLayerID(), std::optional(bare)); + QVERIFY(!session->isMaskSelected()); + session->selectLayerTarget(masked, false); + QCOMPARE(session->activeLayerID(), std::optional(masked)); + QVERIFY(!session->isMaskSelected()); + // The link toggles on the layer named, active or not. + session->selectLayer(bare); + session->addLayerMask(); + session->toggleMaskLink(masked); + QVERIFY(!layerWith(*session, masked).mask.value().isLinked); + QVERIFY(layerWith(*session, bare).mask.value().isLinked); + QCOMPARE(session->activeLayerID(), std::optional(bare)); + // An id the document lacks leaves no mask to target. + session->selectLayerTarget(QUuid::createUuid(), true); + QVERIFY(!session->isMaskSelected() && !session->activeLayer().has_value()); + // One id selected, yet no layer: no mask to edit. + QCOMPARE(int(session->selectedLayerIDs().size()), 1); + QVERIFY(session->canEditLayers() && !session->canEditMask()); + const std::optional<CanvasDocument> kept = session->document(); + session->addLayerMask(); + QCOMPARE(session->document(), kept); +} + +void LayerMaskEditTests::whatOnlyNeedsEditableLayersWorksWithSeveralSelected() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid red = session->activeLayerID().value(); + session->insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Target")); + const QUuid target = session->activeLayerID().value(); + session->selectLayer(std::nullopt); + session->addGroup(); + const QUuid folder = session->activeLayerID().value(); + const LayerTransform placed{.origin = {5, 6}, .size = {2, 2}}; + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, folder, coverage()); + record(snapshot, folder).maskPlacement = placed; + }); + // Several layers selected: no one mask to edit; these work. + session->selectLayers({red, target}, red); + QVERIFY(session->canEditLayers() && !session->canEditMask()); + // A folder's mask is a source like any other. + QVERIFY(session->canCopyMask(folder, target)); + session->copyMask(folder, target); + const LayerMask copy = layerWith(*session, target).mask.value(); + QVERIFY(copy.asset.identity() == layerWith(*session, folder).mask.value().asset.identity()); + QCOMPARE(copy.placement, std::optional(placed)); + QCOMPARE(session->history.undoName(), QString("Copy Layer Mask")); + session->selectLayers({red, target}, red); + session->toggleMaskLink(target); + QVERIFY(!layerWith(*session, target).mask.value().isLinked); + session->toggleMaskLink(folder); + QVERIFY(!layerWith(*session, folder).mask.value().isLinked); + QCOMPARE(session->history.undoName(), QString("Unlink Layer Mask")); + // A target is chosen even while a sheet is up. + session->setShowsImporter(true); + QVERIFY(!session->canEditLayers()); + session->selectLayerTarget(target, true); + QCOMPARE(session->activeLayerID(), std::optional(target)); + QVERIFY(session->isMaskSelected()); +} + +QTEST_GUILESS_MAIN(LayerMaskEditTests) +#include "LayerMaskEditTests.moc" diff --git a/tests/LayerMaskSessionTests.cpp b/tests/LayerMaskSessionTests.cpp new file mode 100644 index 0000000..6c8ad3b --- /dev/null +++ b/tests/LayerMaskSessionTests.cpp @@ -0,0 +1,287 @@ +#include "Document/BrushStroke.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include "SessionFixtures.h" +#include <QPainter> +#include <QTemporaryDir> +#include <QtTest> + +// The session cases of Swift's LayerMaskTests. +namespace { +QList<int> alphas(const QImage &image) +{ + QList<int> result; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + result << image.pixelColor(x, y).alpha(); + } + return result; +} + +QList<int> rendered(const EditorSession &session) +{ + const QList<int> exported = alphas(ImageExporter::render(session.projectSnapshot().value()).image); + // Export and the canvas's own composite must agree. + const CanvasDocument &document = session.document().value(); + QImage canvas = BrushRaster::context(document.width, document.height, false); + { + QPainter painter(&canvas); + session.drawLiveComposite(document, painter); + } + if (alphas(canvas) != exported) + throw std::runtime_error("the live composite differs from the export"); + return exported; +} + +bool within(const QList<int> &values, const QList<int> &expected, int tolerance) +{ + if (values.size() != expected.size()) + return false; + for (qsizetype index = 0; index < values.size(); ++index) { + if (std::abs(values[index] - expected[index]) > tolerance) + return false; + } + return true; +} + +bool isProjectError(const std::function<void()> &body) +{ + try { + body(); + } catch (const ProjectError &) { + return true; + } + return false; +} +} + +class LayerMaskSessionTests : public QObject { + Q_OBJECT +private slots: + void addDisableDeleteUndoAndTargetSelection(); + void coverageOpacityAndDisabledMasksRenderCorrectly(); + void transformedMaskResizeAndCanvasChangesStayAligned(); + void projectAndPNGPreserveCoverageAndDisabledState(); + void masksRejectOlderSchemaAndUnsafePaths(); + void folderMasksClipEveryLayerInsideAndMultiplyWithTheirOwnMasks(); + void folderMasksSaveResizeAndNeedTheNewFormat(); + void folderMaskCanBePaintedInvertedAndLoadedAsASelection(); +}; + +void LayerMaskSessionTests::addDisableDeleteUndoAndTargetSelection() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const ImageIdentity original = session->activeLayer().value().asset.value().identity(); + const int count = session->history.undoCount(); + session->addLayerMask(false); + QVERIFY(session->isMaskSelected() && session->activeLayer().value().mask.has_value()); + QCOMPARE(session->activeLayer().value().mask.value().asset.size().width(), 1); + QCOMPARE(session->history.undoCount(), count + 1); + // An existing mask is not overwritten. + session->addLayerMask(); + QCOMPARE(session->history.undoCount(), count + 1); + session->toggleLayerMask(); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + session->deleteLayerMask(); + QVERIFY(!session->activeLayer().value().mask.has_value() && !session->isMaskSelected()); + session->undo(); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + session->undo(); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); + session->undo(); + QVERIFY(!session->activeLayer().value().mask.has_value()); + session->redo(); + const QUuid id = session->activeLayerID().value(); + session->selectLayerTarget(id, true); + QVERIFY(session->isMaskSelected()); + session->selectLayerTarget(id, false); + QVERIFY(!session->isMaskSelected() && session->activeLayer().value().asset.value().identity() == original); + session->addGroup(); + session->addLayerMask(); + QVERIFY(session->activeLayer().value().isGroup && session->activeLayer().value().mask.has_value()); +} + +void LayerMaskSessionTests::coverageOpacityAndDisabledMasksRenderCorrectly() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + session->addLayerMask(); + QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255})); + session->deleteLayerMask(); + session->addLayerMask(false); + QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0})); + session->toggleLayerMask(); + QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255})); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, coverage()); + record(snapshot, id).maskEnabled = true; + }); + QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255})); + session->setLayerOpacity(0.5); + QVERIFY2(within(rendered(*session), {128, 0, 64, 128}, 1), qPrintable(QString::number(rendered(*session)[2]))); +} + +void LayerMaskSessionTests::transformedMaskResizeAndCanvasChangesStayAligned() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, coverage()); + record(snapshot, id).transform.flipX = true; + }); + const ProjectSnapshot input = session->projectSnapshot().value(); + QCOMPARE(alphas(ImageExporter::render(input).image), (QList<int>{0, 255, 255, 128})); + const ProjectSnapshot resized = ImageResizer::resize(input, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest}); + QCOMPARE(alphas(ImageExporter::render(resized).image), + (QList<int>{0, 0, 255, 255, 0, 0, 255, 255, 255, 255, 128, 128, 255, 255, 128, 128})); + const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 6, .height = 6}); + QVERIFY(canvas.masks.at(id).identity() == resized.masks.at(id).identity()); + session->applyDocumentSize(canvas, "Canvas Size"); + QVERIFY(session->activeLayer().value().mask.has_value()); + session->undo(); + QCOMPARE(session->document().value().width, 2); + QCOMPARE(session->activeLayer().value().mask.value().asset.size().width(), 2); + // Turned, the same coverage falls on other pixels. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 90; }); + QList<int> turned = rendered(*session); + std::sort(turned.begin(), turned.end()); + QCOMPARE(turned, (QList<int>{0, 128, 255, 255})); +} + +void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, coverage()); }); + QTemporaryDir folder; + const QString path = folder.filePath("Masks.comp"); + for (const bool enabled : {true, false}) { + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; }); + ProjectStore::save(session->projectSnapshot().value(), path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QCOMPARE(loaded.manifest.version, qint64(7)); + session->installProject(loaded, path); + QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled); + const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG"); + QCOMPARE(alphas(image), enabled ? (QList<int>{255, 0, 128, 255}) : (QList<int>{255, 255, 255, 255})); + } + // A missing mask file fails; it never reveals the image. + const QString maskFile = session->projectSnapshot().value().manifest.layers.front().maskFile.value(); + QVERIFY(QFile::remove(path + "/images/" + maskFile)); + QVERIFY(isProjectError([&] { ProjectStore::load(path); })); +} + +void LayerMaskSessionTests::masksRejectOlderSchemaAndUnsafePaths() +{ + const std::unique_ptr<EditorSession> session = redSession(); + session->addLayerMask(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + QTemporaryDir folder; + const QString path = folder.filePath("InvalidMask.comp"); + ProjectSnapshot old = snapshot; + old.manifest.version = 3; + QVERIFY(isProjectError([&] { ProjectStore::save(old, path); })); + old.manifest.version = 4; + old.manifest.layers[0].maskFile = "../outside.png"; + QVERIFY(isProjectError([&] { ProjectStore::save(old, path); })); + old.manifest.layers[0].maskFile = snapshot.manifest.layers[0].maskFile; + ProjectStore::save(old, path); +} + +void LayerMaskSessionTests::folderMasksClipEveryLayerInsideAndMultiplyWithTheirOwnMasks() +{ + const std::unique_ptr<EditorSession> session = redSession(); + const QUuid red = session->activeLayerID().value(); + session->groupSelectedLayers(); + const QUuid folder = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, folder).transform.sampling = LayerSampling::nearest; }); + QVERIFY(session->activeLayer().value().isGroup && session->canEditMask()); + const int count = session->history.undoCount(); + session->addLayerMask(false); + QVERIFY(session->isMaskSelected() && session->activeLayer().value().mask.has_value()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0})); + session->toggleLayerMask(); + QCOMPARE(rendered(*session), (QList<int>{255, 255, 255, 255})); + session->toggleLayerMask(); + // Soft folder coverage, then times the layer's own soft mask. + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, coverage()); }); + QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255})); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, coverage()); }); + QVERIFY2(within(rendered(*session), {255, 0, 64, 255}, 1), qPrintable(QString::number(rendered(*session)[2]))); + // A folder around the folder masks them all as well. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, red).maskFile = std::nullopt; + snapshot.masks.erase(red); + }); + session->selectLayer(folder); + session->groupSelectedLayers(); + QVERIFY(session->activeLayerID() != std::optional(folder) && session->activeLayer().value().isGroup); + session->addLayerMask(false); + QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0})); + session->deleteLayerMask(); + QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255})); + session->undo(); + QCOMPARE(rendered(*session), (QList<int>{0, 0, 0, 0})); +} + +void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat() +{ + const std::unique_ptr<EditorSession> session = redSession(); + session->groupSelectedLayers(); + const QUuid folder = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, folder).transform.sampling = LayerSampling::nearest; + setMask(snapshot, folder, coverage()); + }); + QTemporaryDir directory; + const QString path = directory.filePath("FolderMask.comp"); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + ProjectStore::save(snapshot, path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QVERIFY(loaded.manifest.version == 7 && loaded.masks.count(folder) == 1); + QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255})); + const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest}); + QCOMPARE(alphas(ImageExporter::render(resized).image), + (QList<int>{255, 255, 0, 0, 255, 255, 0, 0, 128, 128, 255, 255, 128, 128, 255, 255})); + // Earlier formats had no folder masks: such files are refused. + ProjectSnapshot old = snapshot; + old.manifest.version = 5; + QVERIFY(isProjectError([&] { ProjectStore::save(old, path); })); +} + +void LayerMaskSessionTests::folderMaskCanBePaintedInvertedAndLoadedAsASelection() +{ + EditorSession session; + session.createDocument(40, 20); + QImage red = BrushRaster::context(40, 20, false); + red.fill(Qt::red); + session.insert(ImportedImage(red, red, "Red")); + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + session.addLayerMask(); + QVERIFY(session.canPaint() && session.canInvert() && session.canCopyPixels()); + // Black on the folder's mask hides the layer there alone. + session.selectTool(NavigationTool::brush); + session.setBrushSettings(BrushSettings{.diameter = 8, .hardness = 1, .red = 0, .green = 0, .blue = 0}); + session.setMaskPaintWhite(false); + session.beginBrush(QPointF(10, 10)); + session.continueBrush(QPointF(11, 10)); + session.finishBrush(); + QList<int> values = alphas(ImageExporter::render(session.projectSnapshot().value()).image); + QVERIFY(values[10 * 40 + 10] == 0 && values[10 * 40 + 30] == 255); + QVERIFY(session.activeLayerID() == folder && session.isMaskSelected()); + // Invert swaps what the folder hides. + bool done = false; + session.invertPixels([&] { done = true; }); + QTRY_VERIFY(done); + values = alphas(ImageExporter::render(session.projectSnapshot().value()).image); + QVERIFY(values[10 * 40 + 10] == 255 && values[10 * 40 + 30] == 0); + // Ctrl-click on the folder's mask thumbnail selects its black. + session.loadMaskSelection(folder); + QVERIFY(session.selection().value().path.boundingRect().width() > 30); +} + +QTEST_GUILESS_MAIN(LayerMaskSessionTests) +#include "LayerMaskSessionTests.moc" diff --git a/tests/LayerMaskTests.cpp b/tests/LayerMaskTests.cpp new file mode 100644 index 0000000..8483930 --- /dev/null +++ b/tests/LayerMaskTests.cpp @@ -0,0 +1,415 @@ +#include "Document/BrushStroke.h" +#include "Document/DocumentHistory.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "Rendering/RasterSnapshot.h" +#include "AddressSpaceLimit.h" +#include <QtTest> + +namespace { +QImage gray(int width, int height, int value) +{ + QImage image = BrushRaster::context(width, height, true); + image.fill(value); + return image; +} + +int value(const QImage &image, int x, int y) +{ + return image.constScanLine(y)[x]; +} + +LayerMask wideMask() +{ + return LayerMask(LayerMask::assetFrom(gray(40, 20, 255))); +} +} + +class LayerMaskTests : public QObject { + Q_OBJECT +private slots: + void solidMasksAreOnePixelOfWhiteOrBlack(); + void validityRequiresEightBitGray(); + void assetFromRejectsOtherFormatsAndBuildsAThumbnail(); + void assetFromThrowsWhenTheThumbnailCannotBeAllocated(); + void paintedMasksCompareWithoutFlattening(); + void masksCompareByPixelIdentityAndFlags(); + void replacingKeepsFlagsAndPlacement(); + void enabledImageIsNilWhileDisabled(); + void uniformMasksNeverTakeAPlacement(); + void linkedMasksFollowTheirLayer(); + void unlinkedMasksStayOnTheDocument(); + void backgroundFollowsTheEdgeMajority(); + void placedResamplesAMaskIntoTheLayerGrid(); + void drawSmoothReplacesAndInterpolates(); + void maskTransformPrefersThePlacement(); + void layersCompareAndRecordTheirMasks(); + void historyCountsMaskPixels(); +}; + +void LayerMaskTests::solidMasksAreOnePixelOfWhiteOrBlack() +{ + const LayerMask reveal = LayerMask::solid(true), hide = LayerMask::solid(false); + QCOMPARE(reveal.asset.size(), QSize(1, 1)); + QCOMPARE(reveal.asset.image().format(), QImage::Format_Grayscale8); + QCOMPARE(value(reveal.asset.image(), 0, 0), 255); + QCOMPARE(value(hide.asset.image(), 0, 0), 0); + QCOMPARE(reveal.asset.name, QString("Layer Mask")); + QCOMPARE(reveal.asset.thumbnail.cacheKey(), reveal.asset.image().cacheKey()); + QVERIFY(reveal.isEnabled); + QVERIFY(reveal.isLinked); + QVERIFY(!reveal.placement.has_value()); +} + +void LayerMaskTests::validityRequiresEightBitGray() +{ + QVERIFY(LayerMask::isValid(gray(2, 2, 0))); + QVERIFY(!LayerMask::isValid(BrushRaster::context(2, 2, false))); + QVERIFY(!LayerMask::isValid(QImage(2, 2, QImage::Format_Alpha8))); + QVERIFY(!LayerMask::isValid(QImage(2, 2, QImage::Format_Grayscale16))); + QVERIFY(!LayerMask::isValid(QImage())); +} + +void LayerMaskTests::assetFromRejectsOtherFormatsAndBuildsAThumbnail() +{ + try { + LayerMask::assetFrom(BrushRaster::context(4, 4, false)); + QFAIL("expected ProjectError"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::invalid); + } + QImage halves = gray(200, 100, 0); + QPainter painter(&halves); + painter.fillRect(0, 0, 100, 100, Qt::white); + painter.end(); + const ImportedImage asset = LayerMask::assetFrom(halves); + QCOMPARE(asset.image().cacheKey(), halves.cacheKey()); + QCOMPARE(asset.name, QString("Layer Mask")); + QCOMPARE(asset.thumbnail.size(), QSize(96, 48)); + QCOMPARE(asset.thumbnail.format(), QImage::Format_Grayscale8); + QCOMPARE(value(asset.thumbnail, 10, 10), 255); + QCOMPARE(value(asset.thumbnail, 40, 40), 255); + QCOMPARE(value(asset.thumbnail, 56, 10), 0); + QCOMPARE(value(asset.thumbnail, 90, 40), 0); + QCOMPARE(LayerMask::assetFrom(gray(8, 4, 9)).thumbnail.size(), QSize(8, 4)); + QCOMPARE(value(LayerMask::assetFrom(gray(8, 4, 9)).thumbnail, 7, 3), 9); + QCOMPARE(LayerMask::assetFrom(gray(10, 4000, 0)).thumbnail.size(), QSize(1, 96)); + + QImage stripes = gray(200, 100, 0); + for (int y = 0; y < 100; ++y) { + for (int x = 0; x < 200; x += 2) + stripes.scanLine(y)[x] = 255; + } + const QImage averaged = LayerMask::assetFrom(stripes).thumbnail; + QCOMPARE(averaged.format(), QImage::Format_Grayscale8); + for (int x = 8; x < 88; x += 8) + QVERIFY(value(averaged, x, 24) > 60 && value(averaged, x, 24) < 195); +} + +void LayerMaskTests::assetFromThrowsWhenTheThumbnailCannotBeAllocated() +{ + const QImage large = gray(8192, 8192, 255); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + std::optional<ExportError::Kind> thrown; + try { + LayerMask::assetFrom(large); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); +} + +void LayerMaskTests::paintedMasksCompareWithoutFlattening() +{ + const auto raster = std::make_shared<const RasterSnapshot>(40, 20, QImage(), QRectF(), std::vector<BrushPatch>(), true); + const auto other = std::make_shared<const RasterSnapshot>(40, 20, QImage(), QRectF(), std::vector<BrushPatch>(), true); + const LayerMask mask(ImportedImage(raster, raster->thumbnail(), "Layer Mask")); + QVERIFY(mask == LayerMask(ImportedImage(raster, raster->thumbnail(), "Layer Mask"))); + QVERIFY(!(mask == LayerMask(ImportedImage(other, other->thumbnail(), "Layer Mask")))); + QVERIFY(!raster->hasMaterializedPixels()); + QVERIFY(!other->hasMaterializedPixels()); +} + +void LayerMaskTests::masksCompareByPixelIdentityAndFlags() +{ + const QImage pixels = gray(4, 4, 128); + const LayerMask mask{LayerMask::assetFrom(pixels)}; + const auto differs = [&](auto change) { + LayerMask other = mask; + change(other); + return !(mask == other); + }; + QVERIFY(mask == LayerMask(mask)); + QVERIFY(mask == LayerMask{LayerMask::assetFrom(pixels)}); + QVERIFY(differs([&](LayerMask &other) { other.asset = LayerMask::assetFrom(pixels.copy()); })); + QVERIFY(differs([](LayerMask &other) { other.isEnabled = false; })); + QVERIFY(differs([](LayerMask &other) { other.isLinked = false; })); + QVERIFY(differs([](LayerMask &other) { other.placement = LayerTransform{.origin = {1, 2}, .size = {3, 4}}; })); +} + +void LayerMaskTests::replacingKeepsFlagsAndPlacement() +{ + const LayerTransform apart{.origin = {5, 6}, .size = {7, 8}}; + const LayerMask hidden(LayerMask::assetFrom(gray(4, 4, 10)), false, apart, true); + QCOMPARE(hidden.isEnabled, false); + QCOMPARE(hidden.isLinked, true); + QCOMPARE(hidden.placement, std::optional(apart)); + const LayerMask unlinked(LayerMask::assetFrom(gray(4, 4, 10)), true, std::nullopt, false); + QCOMPARE(unlinked.isEnabled, true); + QCOMPARE(unlinked.isLinked, false); + QVERIFY(!unlinked.placement.has_value()); + + const QImage repainted = gray(4, 4, 200); + const LayerMask replaced = hidden.replacing(LayerMask::assetFrom(repainted)); + QCOMPARE(replaced.asset.image().cacheKey(), repainted.cacheKey()); + QCOMPARE(replaced.isEnabled, false); + QCOMPARE(replaced.isLinked, true); + QCOMPARE(replaced.placement, std::optional(apart)); + const LayerMask kept = unlinked.replacing(LayerMask::assetFrom(repainted)); + QCOMPARE(kept.isEnabled, true); + QCOMPARE(kept.isLinked, false); + QVERIFY(!kept.placement.has_value()); +} + +void LayerMaskTests::enabledImageIsNilWhileDisabled() +{ + const QImage pixels = gray(4, 4, 10); + LayerMask mask{LayerMask::assetFrom(pixels)}; + QCOMPARE(mask.enabledImage().value().cacheKey(), pixels.cacheKey()); + mask.isEnabled = false; + QVERIFY(!mask.enabledImage().has_value()); +} + +void LayerMaskTests::uniformMasksNeverTakeAPlacement() +{ + const LayerTransform old{.origin = {0, 0}, .size = {100, 50}}, moved{.origin = {30, 0}, .size = {100, 50}}; + LayerMask solid = LayerMask::solid(true); + solid.isLinked = false; + QVERIFY(!solid.placementMovingLayer(old, moved).has_value()); + LayerMask column{LayerMask::assetFrom(gray(1, 2, 255))}; + column.isLinked = false; + QCOMPARE(column.placementMovingLayer(old, moved), std::optional(old)); + LayerMask row{LayerMask::assetFrom(gray(2, 1, 255))}; + row.isLinked = false; + QCOMPARE(row.placementMovingLayer(old, moved), std::optional(old)); +} + +void LayerMaskTests::linkedMasksFollowTheirLayer() +{ + const LayerTransform old{.origin = {0, 0}, .size = {100, 50}}, moved{.origin = {30, -10}, .size = {100, 50}}; + LayerMask mask = wideMask(); + QVERIFY(!mask.placementMovingLayer(old, moved).has_value()); + mask.placement = LayerTransform{.origin = {10, 10}, .size = {40, 20}}; + QCOMPARE(mask.placementMovingLayer(old, moved), std::optional(LayerTransform{.origin = {40, 0}, .size = {40, 20}})); + mask.placement = LayerTransform{.origin = {0, 0}, .size = {100, 50}, .sampling = LayerSampling::nearest}; + QVERIFY(!mask.placementMovingLayer(old, moved).has_value()); +} + +void LayerMaskTests::unlinkedMasksStayOnTheDocument() +{ + const LayerTransform old{.origin = {0, 0}, .size = {100, 50}}, moved{.origin = {30, -10}, .size = {100, 50}}; + LayerMask mask = wideMask(); + mask.isLinked = false; + QCOMPARE(mask.placementMovingLayer(old, moved), std::optional(old)); + QVERIFY(!mask.placementMovingLayer(old, old).has_value()); + const LayerTransform apart{.origin = {10, 10}, .size = {40, 20}}; + mask.placement = apart; + QCOMPARE(mask.placementMovingLayer(old, moved), std::optional(apart)); + QVERIFY(!mask.placementMovingLayer(old, apart).has_value()); +} + +void LayerMaskTests::backgroundFollowsTheEdgeMajority() +{ + QCOMPARE(LayerMask::background(gray(6, 4, 255)), 1.0); + QCOMPARE(LayerMask::background(gray(6, 4, 0)), 0.0); + QCOMPARE(LayerMask::background(QImage()), 1.0); + QImage whiteRim = gray(6, 4, 255); + QImage blackRim = gray(6, 4, 0); + for (int y = 1; y < 3; ++y) { + for (int x = 1; x < 5; ++x) { + whiteRim.scanLine(y)[x] = 0; + blackRim.scanLine(y)[x] = 255; + } + } + QCOMPARE(LayerMask::background(whiteRim), 1.0); + QCOMPARE(LayerMask::background(blackRim), 0.0); + QImage tie = gray(2, 2, 0); + tie.scanLine(0)[0] = 255; + tie.scanLine(0)[1] = 255; + QCOMPARE(LayerMask::background(tie), 1.0); + tie.scanLine(0)[1] = 254; + QCOMPARE(LayerMask::background(tie), 0.0); + QImage wideInterior = gray(10, 10, 0); + for (int index = 0; index < 10; ++index) { + wideInterior.scanLine(0)[index] = wideInterior.scanLine(9)[index] = 255; + wideInterior.scanLine(index)[0] = wideInterior.scanLine(index)[9] = 255; + } + QCOMPARE(LayerMask::background(wideInterior), 1.0); + + // Each side's middle pixels tip an eight-of-sixteen tie. + const QPoint corners[] = {QPoint(0, 0), QPoint(4, 0), QPoint(0, 4), QPoint(4, 4)}; + const struct { QPoint middle[3]; QPoint extra; } sides[] = { + {{QPoint(1, 0), QPoint(2, 0), QPoint(3, 0)}, QPoint(0, 2)}, + {{QPoint(1, 4), QPoint(2, 4), QPoint(3, 4)}, QPoint(0, 2)}, + {{QPoint(0, 1), QPoint(0, 2), QPoint(0, 3)}, QPoint(2, 0)}, + {{QPoint(4, 1), QPoint(4, 2), QPoint(4, 3)}, QPoint(2, 0)}}; + for (const auto &side : sides) { + QImage edges = gray(5, 5, 0); + for (const QPoint point : corners) + edges.scanLine(point.y())[point.x()] = 255; + for (const QPoint point : side.middle) + edges.scanLine(point.y())[point.x()] = 255; + edges.scanLine(side.extra.y())[side.extra.x()] = 255; + QCOMPARE(LayerMask::background(edges), 1.0); + edges.scanLine(side.middle[1].y())[side.middle[1].x()] = 0; + QCOMPARE(LayerMask::background(edges), 0.0); + } + + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerMask::background(BrushRaster::context(4, 4, false))); +} + +void LayerMaskTests::placedResamplesAMaskIntoTheLayerGrid() +{ + const LayerTransform layer{.origin = {0, 0}, .size = {100, 100}}; + const LayerTransform placement{.origin = {50, 0}, .size = {50, 100}}; + const QImage black = gray(2, 2, 0); + const auto compose = [&](QPainter &context) { LayerMask::drawSmooth(black, QRectF(0, 0, 2, 2), context); }; + const QImage full = LayerMask::placed(100, 100, layer, placement, 2, 2, 1, compose); + QCOMPARE(full.format(), QImage::Format_Grayscale8); + QCOMPARE(full.size(), QSize(100, 100)); + QCOMPARE(value(full, 25, 50), 255); + QCOMPARE(value(full, 49, 99), 255); + QCOMPARE(value(full, 50, 0), 0); + QCOMPARE(value(full, 99, 99), 0); + const QImage half = LayerMask::placed(50, 50, layer, placement, 2, 2, 0, [&](QPainter &context) { + LayerMask::drawSmooth(gray(2, 2, 255), QRectF(0, 0, 2, 2), context); + }); + QCOMPARE(half.size(), QSize(50, 50)); + QCOMPARE(value(half, 12, 25), 0); + QCOMPARE(value(half, 37, 25), 255); + const LayerTransform turned{.origin = {25, 25}, .size = {50, 50}, .rotation = 45}; + const QImage diamond = LayerMask::placed(100, 100, layer, turned, 2, 2, 1, compose); + QCOMPARE(value(diamond, 50, 50), 0); + QCOMPARE(value(diamond, 50, 20), 0); + QCOMPARE(value(diamond, 28, 28), 255); + const int edge = value(diamond, 50, 14); + QVERIFY(edge > 0 && edge < 255); + + const LayerTransform wide{.origin = {0, 0}, .size = {200, 100}}; + const LayerTransform lowerHalf{.origin = {0, 50}, .size = {200, 50}}; + QImage leftBlack = gray(4, 2, 255); + for (int y = 0; y < 2; ++y) + leftBlack.scanLine(y)[0] = leftBlack.scanLine(y)[1] = 0; + const QImage oblong = LayerMask::placed(100, 50, wide, lowerHalf, 4, 2, 1, [&](QPainter &context) { + context.setRenderHint(QPainter::SmoothPixmapTransform, false); + context.drawImage(QRectF(0, 0, 4, 2), leftBlack); + }); + QCOMPARE(oblong.size(), QSize(100, 50)); + QCOMPARE(value(oblong, 25, 12), 255); + QCOMPARE(value(oblong, 75, 12), 255); + QCOMPARE(value(oblong, 25, 37), 0); + QCOMPARE(value(oblong, 48, 49), 0); + QCOMPARE(value(oblong, 52, 26), 255); + QCOMPARE(value(oblong, 99, 49), 255); + + QImage ramp = gray(2, 1, 0); + ramp.scanLine(0)[1] = 255; + const LayerTransform whole{.origin = {0, 0}, .size = {100, 100}}; + const QImage stretched = LayerMask::placed(100, 100, layer, whole, 2, 1, 1, [&](QPainter &context) { + context.drawImage(QRectF(0, 0, 2, 1), ramp); + }); + QCOMPARE(value(stretched, 2, 50), 0); + QCOMPARE(value(stretched, 97, 50), 255); + QVERIFY(value(stretched, 50, 50) > 60 && value(stretched, 50, 50) < 195); +} + +void LayerMaskTests::drawSmoothReplacesAndInterpolates() +{ + QImage surface = gray(8, 2, 200); + QPainter painter(&surface); + QImage ramp = gray(2, 1, 0); + ramp.scanLine(0)[1] = 255; + LayerMask::drawSmooth(ramp, QRectF(0, 0, 8, 1), painter); + QCOMPARE(painter.compositionMode(), QPainter::CompositionMode_SourceOver); + QVERIFY(!painter.testRenderHint(QPainter::SmoothPixmapTransform)); + painter.end(); + QCOMPARE(value(surface, 0, 0), 0); + QCOMPARE(value(surface, 7, 0), 255); + int between = 0; + for (int x = 1; x < 8; ++x) { + QVERIFY(value(surface, x, 0) >= value(surface, x - 1, 0)); + between += value(surface, x, 0) > 0 && value(surface, x, 0) < 255; + } + QVERIFY(between >= 2); + QCOMPARE(value(surface, 3, 1), 200); +} + +void LayerMaskTests::maskTransformPrefersThePlacement() +{ + ImageLayer layer("Layer 1", QSizeF(100, 50)); + QCOMPARE(layer.maskTransform(), layer.transform); + layer.mask = wideMask(); + QCOMPARE(layer.maskTransform(), layer.transform); + layer.mask->placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; + QCOMPARE(layer.maskTransform(), *layer.mask->placement); +} + +void LayerMaskTests::layersCompareAndRecordTheirMasks() +{ + ImageLayer layer("Layer 1", QSizeF(100, 50)); + layer.id = QUuid("e621e1f8-c36c-495a-93fc-0c247a3e6e5f"); + const ProjectLayerRecord bare = layer.hierarchyRecord(); + QCOMPARE(bare.maskFile, std::nullopt); + QCOMPARE(bare.maskEnabled, std::nullopt); + QCOMPARE(bare.maskPlacement, std::nullopt); + QCOMPARE(bare.maskLinked, std::nullopt); + + ImageLayer masked = layer; + masked.mask = wideMask(); + QVERIFY(!(masked == layer)); + QCOMPARE(masked, ImageLayer(masked)); + ImageLayer disabled = masked; + disabled.mask->isEnabled = false; + QVERIFY(!(disabled == masked)); + const ProjectLayerRecord hidden = disabled.hierarchyRecord(); + QCOMPARE(hidden.maskEnabled, std::optional<bool>(false)); + QCOMPARE(hidden.maskLinked, std::optional<bool>(true)); + + const ProjectLayerRecord record = masked.hierarchyRecord(); + QCOMPARE(record.maskFile, std::optional<QString>("E621E1F8-C36C-495A-93FC-0C247A3E6E5F.mask.png")); + QCOMPARE(record.maskEnabled, std::optional<bool>(true)); + QCOMPARE(record.maskPlacement, std::nullopt); + QCOMPARE(record.maskLinked, std::optional<bool>(true)); + ImageLayer unlinked = masked; + unlinked.mask->isLinked = false; + unlinked.mask->placement = LayerTransform{.origin = {5, 6}, .size = {7, 8}}; + const ProjectLayerRecord moved = unlinked.hierarchyRecord(); + QCOMPARE(moved.maskEnabled, std::optional<bool>(true)); + QCOMPARE(moved.maskLinked, std::optional<bool>(false)); + QCOMPARE(moved.maskPlacement, unlinked.mask->placement); +} + +void LayerMaskTests::historyCountsMaskPixels() +{ + DocumentHistory history; + std::optional<CanvasDocument> document = CanvasDocument(40, 20); + const ImportedImage pixels(BrushRaster::context(40, 20, false), QImage(), "Photo"); + document->layers.push_back(ImageLayer(pixels, QPointF(0, 0))); + document->layers[0].mask = LayerMask(LayerMask::assetFrom(gray(200, 100, 255))); + const qint64 maskBytes = document->layers[0].mask->asset.byteCount() + + document->layers[0].mask->asset.thumbnail.sizeInBytes(); + QCOMPARE(maskBytes, qint64(200 * 100 + 96 * 48)); + history.begin("Disable Layer Mask", document, std::nullopt); + document->layers[0].mask->isEnabled = false; + history.end(document, std::nullopt); + QCOMPARE(history.undoCount(), 1); + QCOMPARE(history.retainedBytes(document), 0); + history.begin("Delete Layer Mask", document, std::nullopt); + document->layers[0].mask.reset(); + history.end(document, std::nullopt); + QCOMPARE(history.retainedBytes(document), maskBytes); +} + +QTEST_APPLESS_MAIN(LayerMaskTests) +#include "LayerMaskTests.moc" diff --git a/tests/LayerMergeTests.cpp b/tests/LayerMergeTests.cpp new file mode 100644 index 0000000..0147e2b --- /dev/null +++ b/tests/LayerMergeTests.cpp @@ -0,0 +1,324 @@ +#include "Document/BrushStroke.h" +#include "SessionFixtures.h" +#include <QtTest> + +// LayerMerge: what Ctrl+E merges and what it bakes. +namespace { +QUuid insertColor(EditorSession &session, int width, int height, QPointF center, QColor color, const QString &name) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(color); + session.insert(ImportedImage(image, image, name), center); + return session.activeLayerID().value(); +} + +int layerCount(const EditorSession &session) +{ + return int(session.document().value().layers.size()); +} + +bool near(QColor lhs, QColor rhs) +{ + return std::abs(lhs.red() - rhs.red()) <= 1 && std::abs(lhs.green() - rhs.green()) <= 1 && std::abs(lhs.blue() - rhs.blue()) <= 1 + && std::abs(lhs.alpha() - rhs.alpha()) <= 1; +} + +// The document as the canvas and the export draw it. +QImage composite(const EditorSession &session) +{ + QImage full = BrushRaster::context(8, 8, false); + QPainter painter(&full); + session.drawLiveComposite(session.document().value(), painter); + return full; +} + +bool sameComposite(const QImage &before, const QImage &after) +{ + for (int y = 0; y < 8; ++y) { + for (int x = 0; x < 8; ++x) { + if (!near(before.pixelColor(x, y), after.pixelColor(x, y))) + return false; + } + } + return true; +} + +// Eight by eight, the left half revealed. +ImportedImage halfMask() +{ + QImage image(8, 8, QImage::Format_Grayscale8); + image.fill(0); + for (int y = 0; y < 8; ++y) + std::fill_n(image.scanLine(y), 4, uchar(255)); + return LayerMask::assetFrom(image); +} +} + +class LayerMergeTests : public QObject { + Q_OBJECT +private slots: + void mergeDownBakesTheLayerBeneathAsOneStep(); + void thePlanNamesWhatMergesAndRefusesTheRest(); + void selectedLayersMergeAtTheTopsPlaceAndKeepClips(); + void aFolderMergesItsContentsAndGoes(); + void hiddenLayersAndAnOpenTransformGoThroughTheCanvas(); + void masksBlendsAndClipsBakeAsTheCanvasShowsThem(); +}; + +void LayerMergeTests::mergeDownBakesTheLayerBeneathAsOneStep() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid lower = insertColor(session, 4, 4, {2, 2}, Qt::blue, "Lower"); + const QUuid upper = insertColor(session, 4, 4, {4, 4}, Qt::red, "Upper"); + session.setLayerOpacity(0.5); + QCOMPARE(session.mergeTitle(), QString("Merge Down")); + QVERIFY(session.canMergeLayers()); + const int steps = session.history.undoCount(); + session.mergeLayers(); + QCOMPARE(layerCount(session), 1); + const ImageLayer merged = session.activeLayer().value(); + QVERIFY(merged.id != lower && merged.id != upper); + QCOMPARE(merged.name, QString("Lower")); + QCOMPARE(merged.opacity, 1.0); + // Trimmed to the six by six both cover, in place. + QCOMPARE(merged.transform, (LayerTransform{.origin = {0, 0}, .size = {6, 6}})); + const QImage pixels = merged.asset.value().image(); + QCOMPARE(pixels.size(), QSize(6, 6)); + QCOMPARE(pixels.pixelColor(0, 0), QColor(Qt::blue)); + QVERIFY(near(pixels.pixelColor(3, 3), QColor(128, 0, 127))); + QVERIFY(near(pixels.pixelColor(5, 5), QColor(255, 0, 0, 128))); + QCOMPARE(pixels.pixelColor(5, 0).alpha(), 0); + QCOMPARE(merged.asset.value().thumbnail.size(), QSize(6, 6)); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Merge Down")); + session.undo(); + QCOMPARE(layerCount(session), 2); + QCOMPARE(session.document().value().layers[1].id, upper); + session.redo(); + QCOMPARE(layerCount(session), 1); +} + +void LayerMergeTests::thePlanNamesWhatMergesAndRefusesTheRest() +{ + EditorSession session; + QVERIFY(!session.canMergeLayers()); + QCOMPARE(session.mergeTitle(), QString("Merge Down")); + session.createDocument(8, 8); + const QUuid bottom = insertColor(session, 2, 2, {1, 1}, Qt::red, "Bottom"); + // The bottom layer has nothing beneath it. + QVERIFY(!session.canMergeLayers()); + session.mergeLayers(); + QCOMPARE(layerCount(session), 1); + session.addGroup(); + const QUuid empty = session.activeLayerID().value(); + QVERIFY(!session.canMergeLayers()); + // Inserted while the folder is active, the layer lands inside. + const QUuid above = insertColor(session, 2, 2, {1, 1}, Qt::red, "Above"); + QCOMPARE(layerWith(session, above).parentID, std::optional(empty)); + QVERIFY(!session.canMergeLayers()); + session.selectLayer(empty); + QCOMPARE(session.mergeTitle(), QString("Merge Group")); + // A folder in the selection brings what it holds. + session.selectLayers({bottom, empty}, empty); + QCOMPARE(session.mergeTitle(), QString("Merge Layers")); + session.mergeLayers(); + QCOMPARE(layerCount(session), 1); + QCOMPARE(session.activeLayer().value().name, QString("Folder 1")); + session.undo(); + // Folder and child together: the result has no folder. + session.selectLayers({empty, above}, above); + QCOMPARE(session.mergeTitle(), QString("Merge Layers")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("merge: the result is no valid hierarchy")); + session.mergeLayers(); + QCOMPARE(layerCount(session), 3); + // Outside its folder, the folder beneath is no partner. + QVERIFY(session.placeLayer(above, std::nullopt)); + session.selectLayer(above); + QCOMPARE(session.document().value().layers[1].id, empty); + QVERIFY(!session.canMergeLayers()); + session.mergeLayers(); + QCOMPARE(layerCount(session), 3); + // A root layer beneath in the list: another folder. + QVERIFY(session.placeLayer(bottom, std::nullopt) && session.placeLayer(above, empty)); + QCOMPARE(session.document().value().layers[1].id, bottom); + QCOMPARE(session.document().value().layers[2].id, above); + session.selectLayer(above); + QVERIFY(!session.canMergeLayers()); + // Folders without pixels have nothing to bake. + session.deleteActiveLayer(); + session.addGroup(); + const QUuid other = session.activeLayerID().value(); + session.selectLayers({empty, other}, empty); + QVERIFY(!session.canMergeLayers()); + QVERIFY(session.placeLayer(other, empty)); + session.selectLayer(empty); + QVERIFY(!session.canMergeLayers()); + session.selectLayers({bottom, empty}, empty); + QVERIFY(session.canMergeLayers()); + session.setIsProjectBusy(true); + QVERIFY(!session.canMergeLayers()); +} + +void LayerMergeTests::selectedLayersMergeAtTheTopsPlaceAndKeepClips() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = insertColor(session, 2, 2, {1, 1}, Qt::red, "A"); + const QUuid b = insertColor(session, 2, 2, {5, 1}, Qt::green, "B"); + const QUuid c = insertColor(session, 2, 2, {1, 5}, Qt::blue, "C"); + const QUuid d = insertColor(session, 2, 2, {5, 5}, Qt::white, "D"); + session.toggleClippingMask(d); + QCOMPARE(layerWith(session, d).maskSourceID, std::optional(c)); + session.selectLayers({a, c}, a); + session.mergeLayers(); + // The result takes the topmost selected layer's name and slot. + QCOMPARE(layerCount(session), 3); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(layers[0].id, b); + QCOMPARE(layers[1].name, QString("C")); + QCOMPARE(layers[2].id, d); + QCOMPARE(layers[2].maskSourceID, std::optional(layers[1].id)); + QCOMPARE(session.activeLayerID(), std::optional(layers[1].id)); + QCOMPARE(layers[1].transform, (LayerTransform{.origin = {0, 0}, .size = {2, 6}})); + QCOMPARE(session.history.undoName(), QString("Merge Layers")); + // Clipped to something outside the merge, a layer draws whole. + session.selectLayers({b, d}, d); + session.mergeLayers(); + QCOMPARE(layerCount(session), 2); + const ImageLayer bd = session.activeLayer().value(); + QCOMPARE(bd.name, QString("D")); + QCOMPARE(bd.maskSourceID, std::nullopt); + QCOMPARE(bd.transform, (LayerTransform{.origin = {4, 0}, .size = {2, 6}})); + QCOMPARE(bd.asset.value().image().pixelColor(0, 4), QColor(Qt::white)); + // Inside a folder the result stays inside, drawn whole. + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + const QUuid lowerC = session.document().value().layers[0].id; + QVERIFY(session.placeLayer(lowerC, folder) && session.placeLayer(bd.id, folder)); + session.selectLayer(bd.id); + QCOMPARE(session.mergeTitle(), QString("Merge Down")); + session.mergeLayers(); + QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); + QCOMPARE(session.activeLayer().value().name, QString("C")); + QCOMPARE(session.activeLayer().value().transform, (LayerTransform{.origin = {0, 0}, .size = {6, 6}})); +} + +void LayerMergeTests::aFolderMergesItsContentsAndGoes() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid under = insertColor(session, 2, 2, {1, 1}, Qt::red, "Under"); + const QUuid p = insertColor(session, 2, 2, {5, 1}, Qt::green, "P"); + const QUuid q = insertColor(session, 2, 2, {1, 5}, Qt::blue, "Q"); + session.addGroup(); + const QUuid outer = session.activeLayerID().value(); + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + QVERIFY(session.placeLayer(inner, outer) && session.placeLayer(p, outer) && session.placeLayer(q, inner)); + session.selectLayer(outer); + QCOMPARE(session.mergeTitle(), QString("Merge Group")); + session.mergeLayers(); + QCOMPARE(layerCount(session), 2); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(layers[0].id, under); + const ImageLayer merged = layers[1]; + QCOMPARE(merged.name, QString("Folder 1")); + QVERIFY(!merged.isGroup && merged.asset.has_value()); + QCOMPARE(merged.parentID, std::nullopt); + QVERIFY(merged.id != outer && merged.id != inner); + // Both children's pixels, the folder's box trimmed around them. + QCOMPARE(merged.transform, (LayerTransform{.origin = {0, 0}, .size = {6, 6}})); + QCOMPARE(merged.asset.value().image().pixelColor(5, 0), QColor(Qt::green)); + QCOMPARE(merged.asset.value().image().pixelColor(0, 5), QColor(Qt::blue)); + QCOMPARE(merged.asset.value().image().pixelColor(0, 0).alpha(), 0); + QCOMPARE(session.history.undoName(), QString("Merge Group")); + session.undo(); + QCOMPARE(layerCount(session), 5); +} + +void LayerMergeTests::hiddenLayersAndAnOpenTransformGoThroughTheCanvas() +{ + EditorSession session; + session.createDocument(8, 8); + insertColor(session, 2, 2, {1, 1}, Qt::red, "Lower"); + const QUuid upper = insertColor(session, 2, 2, {5, 5}, Qt::blue, "Upper"); + // A hidden layer merges as shown: not at all. + session.toggleLayerVisibility(upper); + session.mergeLayers(); + QCOMPARE(layerCount(session), 1); + QCOMPARE(session.activeLayer().value().transform.size, QSizeF(2, 2)); + session.undo(); + session.toggleLayerVisibility(upper); + // An open transform commits first: the moved pixels merge. + session.beginTransform(); + session.previewTransform({.origin = {2, 2}, .size = {2, 2}}); + session.mergeLayers(); + QCOMPARE(layerCount(session), 1); + QCOMPARE(session.activeLayer().value().transform.size, QSizeF(4, 4)); + QVERIFY(!session.transformEdit().has_value()); +} + +void LayerMergeTests::masksBlendsAndClipsBakeAsTheCanvasShowsThem() +{ + // A layer's own mask: the right half shows blue. + EditorSession masked; + masked.createDocument(8, 8); + insertColor(masked, 8, 8, {4, 4}, Qt::blue, "Blue"); + const QUuid red = insertColor(masked, 8, 8, {4, 4}, Qt::red, "Red"); + rewrite(masked, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, halfMask()); }); + masked.selectLayer(red); + QImage before = composite(masked); + masked.mergeLayers(); + QCOMPARE(layerCount(masked), 1); + QVERIFY(sameComposite(before, composite(masked))); + QImage pixels = masked.activeLayer().value().asset.value().image(); + QCOMPARE(pixels.pixelColor(1, 1), QColor(Qt::red)); + QCOMPARE(pixels.pixelColor(6, 6), QColor(Qt::blue)); + QVERIFY(!masked.activeLayer().value().mask.has_value()); + // A folder's mask clips what the folder bakes. + EditorSession folder; + folder.createDocument(8, 8); + const QUuid inside = insertColor(folder, 8, 8, {4, 4}, Qt::red, "Inside"); + folder.groupSelectedLayers(); + const QUuid group = folder.activeLayerID().value(); + rewrite(folder, [&](ProjectSnapshot &snapshot) { setMask(snapshot, group, halfMask()); }); + folder.selectLayer(group); + QCOMPARE(layerWith(folder, inside).parentID, std::optional(group)); + before = composite(folder); + folder.mergeLayers(); + QCOMPARE(layerCount(folder), 1); + QVERIFY(sameComposite(before, composite(folder))); + pixels = folder.activeLayer().value().asset.value().image(); + QCOMPARE(pixels.size(), QSize(4, 8)); + QCOMPARE(pixels.pixelColor(3, 7), QColor(Qt::red)); + QCOMPARE(folder.activeLayer().value().transform.origin, QPointF(0, 0)); + // A Multiply pair bakes the product; the result blends normally. + EditorSession multiply; + multiply.createDocument(8, 8); + insertColor(multiply, 8, 8, {4, 4}, QColor(255, 128, 0), "Base"); + insertColor(multiply, 8, 8, {4, 4}, QColor(128, 255, 255), "Top"); + multiply.setLayerBlendMode(LayerBlendMode::multiply); + before = composite(multiply); + multiply.mergeLayers(); + QVERIFY(sameComposite(before, composite(multiply))); + pixels = multiply.activeLayer().value().asset.value().image(); + QVERIFY(near(pixels.pixelColor(4, 4), QColor(128, 128, 0))); + QCOMPARE(multiply.activeLayer().value().blendMode, LayerBlendMode::normal); + // Clipped to a merged layer: shown only over it. + EditorSession clipped; + clipped.createDocument(8, 8); + insertColor(clipped, 2, 2, {4, 4}, Qt::blue, "Small"); + const QUuid wide = insertColor(clipped, 8, 8, {4, 4}, Qt::red, "Wide"); + clipped.toggleClippingMask(wide); + before = composite(clipped); + clipped.mergeLayers(); + QCOMPARE(layerCount(clipped), 1); + QVERIFY(sameComposite(before, composite(clipped))); + QCOMPARE(clipped.activeLayer().value().transform, (LayerTransform{.origin = {3, 3}, .size = {2, 2}})); + QCOMPARE(clipped.activeLayer().value().asset.value().image().pixelColor(0, 0), QColor(Qt::red)); + QCOMPARE(clipped.activeLayer().value().maskSourceID, std::nullopt); +} + +QTEST_GUILESS_MAIN(LayerMergeTests) +#include "LayerMergeTests.moc" diff --git a/tests/LayerRendererTests.cpp b/tests/LayerRendererTests.cpp new file mode 100644 index 0000000..212c4c6 --- /dev/null +++ b/tests/LayerRendererTests.cpp @@ -0,0 +1,489 @@ +#include "IO/ImageExporter.h" +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include <QPixmap> +#include "AddressSpaceLimit.h" +#include <QtTest> +#include <algorithm> + +class LayerRendererTests : public QObject { + Q_OBJECT +private slots: + void placesScalesRotatesAndFlips(); + void opacityScalesTheWholeLayer(); + void layerOpacityIgnoresThePaintersOwn(); + void aMaskMultipliesCoverage(); + void aWhiteMaskChangesNothing(); + void aBlackMaskHidesARotatedLayerCompletely(); + void aGrayMaskHalvesARotatedLayer(); + void aMaskedEdgeIsCoverageSquared(); + void maskedLayersFollowRotationAndPainterScale(); + void masksAreSampledLikeTheirLayer(); + void compositeReducesTheMaskByItsOwnWidth(); + void aMaskMustBeEightBitGray(); + void aMaskedLayerOutsideTheDeviceDrawsNothing(); + void aSurfaceThatCannotBeAllocatedThrows(); + void aThrowingDrawLeavesThePainterBalanced(); + void aShrinkingPainterDrawsFromHalvings(); + void oddSizedReductionsKeepTheirOverhang(); + void nearestLayersKeepHardEdges(); + void nearestStaysBlockyAndSmoothInterpolates(); + void drawingLeavesThePainterAsItFoundIt(); + void interpolationAndReductionRules(); +}; + +void LayerRendererTests::placesScalesRotatesAndFlips() +{ + const QImage image = coordinates(4, 2); + QImage surface = BrushRaster::context(12, 12, false); + QPainter painter(&surface); + const LayerTransform plain = placedAt({2, 3}, {4, 2}); + LayerRenderer::draw(image, plain, plain.center(), painter); + QCOMPARE(surface.pixel(2, 3), qRgba(0, 0, 0, 255)); + QCOMPARE(surface.pixel(5, 4), qRgba(30, 10, 0, 255)); + QCOMPARE(surface.pixel(6, 4), qRgba(0, 0, 0, 0)); + + surface.fill(0); + painter.save(); + painter.translate(5, 6); + LayerRenderer::draw(image, plain, plain.center(), painter); + painter.restore(); + QCOMPARE(surface.pixel(7, 9), qRgba(0, 0, 0, 255)); + QCOMPARE(surface.pixel(10, 10), qRgba(30, 10, 0, 255)); + QCOMPARE(surface.pixel(2, 3), qRgba(0, 0, 0, 0)); + + surface.fill(0); + LayerTransform flipped = plain; + flipped.flipX = true; + LayerRenderer::draw(image, flipped, flipped.center(), painter); + QCOMPARE(surface.pixel(2, 3), qRgba(30, 0, 0, 255)); + QCOMPARE(surface.pixel(5, 4), qRgba(0, 10, 0, 255)); + flipped.flipX = false; + flipped.flipY = true; + surface.fill(0); + LayerRenderer::draw(image, flipped, flipped.center(), painter); + QCOMPARE(surface.pixel(2, 3), qRgba(0, 10, 0, 255)); + QCOMPARE(surface.pixel(5, 4), qRgba(30, 0, 0, 255)); + + surface.fill(0); + LayerTransform turned = placedAt({4, 3}, {4, 2}); + turned.rotation = 90; + LayerRenderer::draw(image, turned, turned.center(), painter); + QCOMPARE(surface.pixel(6, 2), qRgba(0, 0, 0, 255)); + QCOMPARE(surface.pixel(5, 2), qRgba(0, 10, 0, 255)); + QCOMPARE(surface.pixel(6, 5), qRgba(30, 0, 0, 255)); + QCOMPARE(surface.pixel(7, 3), qRgba(0, 0, 0, 0)); + + surface.fill(0); + LayerRenderer::draw(image, plain, plain.center() * 2, painter, {.scale = 2}); + QCOMPARE(surface.pixel(4, 6), qRgba(0, 0, 0, 255)); + QCOMPARE(surface.pixel(11, 9), qRgba(30, 10, 0, 255)); + QCOMPARE(surface.pixel(3, 6), qRgba(0, 0, 0, 0)); +} + +void LayerRendererTests::opacityScalesTheWholeLayer() +{ + QImage surface = BrushRaster::context(4, 4, false); + QPainter painter(&surface); + const LayerTransform transform = placedAt({0, 0}, {4, 4}); + LayerRenderer::draw(solid(4, 4, qRgba(200, 100, 0, 255)), transform, transform.center(), painter, {.opacity = 0.5}); + const QRgb pixel = surface.pixel(1, 1); + QVERIFY(std::abs(qAlpha(pixel) - 128) <= 1); + QVERIFY(std::abs(qRed(pixel) - 100) <= 1); + QVERIFY(std::abs(qGreen(pixel) - 50) <= 1); + QCOMPARE(qBlue(pixel), 0); +} + +void LayerRendererTests::aMaskMultipliesCoverage() +{ + QImage mask = gray(4, 2, 255); + mask.scanLine(0)[1] = mask.scanLine(1)[1] = 128; + mask.scanLine(0)[2] = mask.scanLine(1)[2] = 0; + QImage surface = BrushRaster::context(4, 2, false); + QPainter painter(&surface); + const LayerTransform transform = placedAt({0, 0}, {4, 2}); + LayerRenderer::draw(solid(4, 2, qRgba(0, 200, 0, 255)), transform, transform.center(), painter, {.mask = mask}); + painter.end(); + QCOMPARE(surface.pixel(0, 0), qRgba(0, 200, 0, 255)); + QVERIFY(std::abs(qAlpha(surface.pixel(1, 1)) - 128) <= 1); + QVERIFY(std::abs(qGreen(surface.pixel(1, 1)) - 100) <= 1); + QCOMPARE(surface.pixel(2, 0), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(3, 1), qRgba(0, 200, 0, 255)); +} + +void LayerRendererTests::aMaskMustBeEightBitGray() +{ + QImage surface = BrushRaster::context(4, 4, false); + QPainter painter(&surface); + const LayerTransform transform = placedAt({0, 0}, {4, 4}); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(solid(4, 4, qRgba(9, 9, 9, 255)), transform, + transform.center(), painter, + {.mask = solid(4, 4, qRgba(255, 255, 255, 255))})); +} + +void LayerRendererTests::aMaskedLayerOutsideTheDeviceDrawsNothing() +{ + QImage surface = solid(4, 4, qRgba(1, 2, 3, 255)); + QPainter painter(&surface); + const LayerTransform away = placedAt({100, 100}, {4, 4}); + LayerRenderer::draw(solid(4, 4, qRgba(9, 9, 9, 255)), away, away.center(), painter, {.mask = gray(4, 4, 255)}); + LayerRenderer::draw(solid(4, 4, qRgba(9, 9, 9, 255)), away, away.center(), painter, {.blendMode = LayerBlendMode::hue}); + painter.end(); + QCOMPARE(surface, solid(4, 4, qRgba(1, 2, 3, 255))); +} + +void LayerRendererTests::aSurfaceThatCannotBeAllocatedThrows() +{ + QImage device = BrushRaster::context(8192, 8192, true); + QPainter painter(&device); + const LayerTransform transform = placedAt({0, 0}, {8192, 8192}); + const QImage image = solid(2, 2, qRgba(9, 9, 9, 255)), mask = gray(2, 2, 255); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + std::optional<ExportError::Kind> thrown; + try { + LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = mask}); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); + + // Room for the colour surface, not for the mask's quarter. + const qint64 surfaceBytes = qint64(8192) * 8192 * 4; + limit.emplace(surfaceBytes + surfaceBytes / 8); + std::optional<ExportError::Kind> maskThrown; + try { + LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = mask}); + } catch (const ExportError &error) { + maskThrown = error.kind; + } + limit.reset(); + QCOMPARE(maskThrown, std::optional(ExportError::Kind::render)); +} + +void LayerRendererTests::aShrinkingPainterDrawsFromHalvings() +{ + QImage stripes = BrushRaster::context(2048, 64, false); + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 2048; ++x) + stripes.setPixel(x, y, x % 4 == 1 || x % 4 == 2 ? qRgba(255, 255, 255, 255) : qRgba(0, 0, 0, 255)); + } + QImage surface = BrushRaster::context(256, 8, false); + QPainter painter(&surface); + painter.scale(0.125, 0.125); + const LayerTransform transform{.origin = {0, 0}, .size = {2048, 64}, .sampling = LayerSampling::high}; + LayerRenderer::draw(stripes, transform, transform.center(), painter); + painter.end(); + for (int x = 8; x < 248; ++x) + QVERIFY(std::abs(qRed(surface.pixel(x, 4)) - 127) <= 8); + + QImage fineMask = gray(2048, 64, 0); + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 2048; ++x) + fineMask.scanLine(y)[x] = x % 4 == 1 || x % 4 == 2 ? 255 : 0; + } + QImage masked = BrushRaster::context(256, 8, false); + QPainter masking(&masked); + masking.scale(0.125, 0.125); + LayerRenderer::draw(solid(2048, 64, qRgba(255, 255, 255, 255)), transform, transform.center(), masking, {.mask = fineMask}); + masking.end(); + for (int x = 8; x < 248; ++x) + QVERIFY(std::abs(qAlpha(masked.pixel(x, 4)) - 127) <= 8); +} + +void LayerRendererTests::oddSizedReductionsKeepTheirOverhang() +{ + // 1025 px halve twice to 257, covering 1028 source pixels. + QImage edge = BrushRaster::context(1025, 16, false); + QImage edgeMask = gray(1025, 16, 0); + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 1025; ++x) { + edge.setPixel(x, y, x < 1000 ? qRgba(0, 0, 0, 255) : qRgba(255, 255, 255, 255)); + edgeMask.scanLine(y)[x] = x < 1000 ? 0 : 255; + } + } + const LayerTransform transform{.origin = {0, 0}, .size = {1025, 16}, .sampling = LayerSampling::high}; + QImage direct = BrushRaster::context(260, 4, false), masked = direct; + QPainter first(&direct), second(&masked); + LayerRenderer::draw(edge, transform, transform.center() * 0.25, first, {.scale = 0.25}); + LayerRenderer::draw(solid(1025, 16, qRgba(255, 255, 255, 255)), transform, transform.center() * 0.25, second, + {.scale = 0.25, .mask = edgeMask}); + first.end(); + second.end(); + QVERIFY(qRed(direct.pixel(249, 2)) < 40); + QVERIFY(qRed(direct.pixel(250, 2)) > 215); + QVERIFY(qAlpha(masked.pixel(249, 2)) < 40); + QVERIFY(qAlpha(masked.pixel(250, 2)) > 215); + + QImage aside = BrushRaster::context(260, 4, false); + QPainter third(&aside); + LayerRenderer::draw(edge, transform, transform.center() * 0.25, third, {.scale = 0.25, .mask = gray(1025, 16, 255)}); + third.end(); + QVERIFY(qRed(aside.pixel(249, 2)) < 40); + QVERIFY(qRed(aside.pixel(250, 2)) > 215); +} + +void LayerRendererTests::nearestLayersKeepHardEdges() +{ + const auto partialPixels = [&](LayerSampling sampling) { + QImage surface = BrushRaster::context(24, 24, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {6, 6}, .size = {12, 12}, .rotation = 30, .sampling = sampling}; + LayerRenderer::draw(solid(12, 12, qRgba(255, 255, 255, 255)), transform, transform.center(), painter); + painter.end(); + int partial = 0; + for (int y = 0; y < 24; ++y) { + for (int x = 0; x < 24; ++x) + partial += qAlpha(surface.pixel(x, y)) > 0 && qAlpha(surface.pixel(x, y)) < 255; + } + return partial; + }; + QCOMPARE(partialPixels(LayerSampling::nearest), 0); + QVERIFY(partialPixels(LayerSampling::smooth) > 10); +} + +void LayerRendererTests::maskedLayersFollowRotationAndPainterScale() +{ + QImage mask = gray(2, 1, 255); + mask.scanLine(0)[1] = 0; + QImage surface = BrushRaster::context(40, 40, false); + QPainter painter(&surface); + painter.translate(20, 0); + painter.scale(2, 2); + LayerTransform transform = placedAt({0, 0}, {10, 4}); + transform.rotation = 90; + LayerRenderer::draw(solid(2, 1, qRgba(255, 0, 0, 255)), transform, transform.center(), painter, {.mask = mask}); + painter.end(); + QCOMPARE(surface.pixel(30, 0), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(30, 3), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(30, 9), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(24, 0), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(27, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(33, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(35, 1), qRgba(0, 0, 0, 0)); +} + +void LayerRendererTests::nearestStaysBlockyAndSmoothInterpolates() +{ + QImage pair = BrushRaster::context(2, 1, false); + pair.setPixel(0, 0, qRgba(0, 0, 0, 255)); + pair.setPixel(1, 0, qRgba(255, 255, 255, 255)); + const auto row = [&](LayerSampling sampling) { + QImage surface = BrushRaster::context(8, 1, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {0, 0}, .size = {8, 1}, .sampling = sampling}; + LayerRenderer::draw(pair, transform, transform.center(), painter); + painter.end(); + QList<int> values; + for (int x = 0; x < 8; ++x) + values << qRed(surface.pixel(x, 0)); + return values; + }; + QCOMPARE(row(LayerSampling::nearest), QList<int>({0, 0, 0, 0, 255, 255, 255, 255})); + for (LayerSampling sampling : {LayerSampling::smooth, LayerSampling::high}) { + const QList<int> smooth = row(sampling); + QVERIFY(std::is_sorted(smooth.begin(), smooth.end())); + QVERIFY(std::count_if(smooth.begin(), smooth.end(), [](int value) { return value > 0 && value < 255; }) >= 2); + } +} + +void LayerRendererTests::interpolationAndReductionRules() +{ + QCOMPARE(LayerRenderer::interpolation(LayerSampling::nearest, 0.5), InterpolationQuality::none); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::nearest, 4), InterpolationQuality::none); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1), InterpolationQuality::low); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.01), InterpolationQuality::high); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::smooth, 3), InterpolationQuality::low); + QCOMPARE(quality(LayerSampling::nearest), InterpolationQuality::none); + QCOMPARE(quality(LayerSampling::smooth), InterpolationQuality::low); + QCOMPARE(quality(LayerSampling::high), InterpolationQuality::high); + + const QImage image = coordinates(9, 5); + const LayerRenderer::Reduced kept = LayerRenderer::reduced(image, 2, 1, LayerSampling::nearest); + QCOMPARE(kept.level, 0); + QCOMPARE(kept.image.cacheKey(), image.cacheKey()); + const LayerRenderer::Reduced half = LayerRenderer::reduced(image, 2, 2, LayerSampling::high); + QCOMPARE(half.level, 1); + QCOMPARE(half.image.size(), QSize(5, 3)); + QCOMPARE(half.widthScale, 10.0 / 9); + QCOMPARE(half.heightScale, 6.0 / 5); + QCOMPARE(LayerRenderer::coverage(half, QRectF(-9, -5, 18, 10)), QRectF(-9, -5, 20, 12)); + QCOMPARE(LayerRenderer::reduced(image, 9, 1, LayerSampling::high).level, 0); + + QImage surface = BrushRaster::context(4, 4, false); + QPainter painter(&surface); + QCOMPARE(LayerRenderer::deviceScale(painter), 1.0); + painter.scale(3, 3); + painter.rotate(40); + QVERIFY(std::abs(LayerRenderer::deviceScale(painter) - 3) < 1e-9); +} + +void LayerRendererTests::layerOpacityIgnoresThePaintersOwn() +{ + const LayerTransform transform = placedAt({0, 0}, {2, 2}); + const QImage image = solid(2, 2, qRgba(200, 100, 0, 255)); + for (const QImage &mask : {QImage(), gray(2, 2, 255)}) { + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + painter.setOpacity(0.25); + LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = mask}); + QCOMPARE(painter.opacity(), 0.25); + painter.end(); + QCOMPARE(surface.pixel(0, 0), qRgba(200, 100, 0, 255)); + } +} + +void LayerRendererTests::aWhiteMaskChangesNothing() +{ + const QImage image = coordinates(6, 4, 40); + for (LayerBlendMode mode : {LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::colorBurn}) { + const LayerTransform transform{.origin = {1, 1}, .size = {6, 4}, .sampling = LayerSampling::smooth}; + const QImage paper = solid(20, 14, qRgba(40, 90, 160, 255)); + QImage direct = paper, masked = paper; + QPainter first(&direct), second(&masked); + for (QPainter *painter : {&first, &second}) { + painter->translate(2, 1); + painter->scale(2, 2); + } + LayerRenderer::draw(image, transform, transform.center(), first, {.opacity = 0.7, .blendMode = mode}); + LayerRenderer::draw(image, transform, transform.center(), second, + {.opacity = 0.7, .blendMode = mode, .mask = gray(6, 4, 255)}); + first.end(); + second.end(); + for (int y = 0; y < 14; ++y) { + for (int x = 0; x < 20 * 4; ++x) + QVERIFY(std::abs(direct.constScanLine(y)[x] - masked.constScanLine(y)[x]) <= 2); + } + QVERIFY(direct != paper); + } +} + +void LayerRendererTests::masksAreSampledLikeTheirLayer() +{ + QImage step = gray(2, 1, 0); + step.scanLine(0)[1] = 255; + const auto alphas = [&](LayerSampling sampling) { + QImage surface = BrushRaster::context(8, 1, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {0, 0}, .size = {8, 1}, .sampling = sampling}; + LayerRenderer::draw(solid(2, 1, qRgba(255, 255, 255, 255)), transform, transform.center(), painter, {.mask = step}); + painter.end(); + QList<int> values; + for (int x = 0; x < 8; ++x) + values << qAlpha(surface.pixel(x, 0)); + return values; + }; + QCOMPARE(alphas(LayerSampling::nearest), QList<int>({0, 0, 0, 0, 255, 255, 255, 255})); + const QList<int> smooth = alphas(LayerSampling::smooth); + QVERIFY(std::is_sorted(smooth.begin(), smooth.end())); + QVERIFY(std::count_if(smooth.begin(), smooth.end(), [](int value) { return value > 0 && value < 255; }) >= 2); +} + +void LayerRendererTests::compositeReducesTheMaskByItsOwnWidth() +{ + QImage stripes = gray(1024, 8, 0); + for (int y = 0; y < 8; ++y) { + for (int x = 0; x < 1024; ++x) + stripes.scanLine(y)[x] = x % 4 == 1 || x % 4 == 2 ? 255 : 0; + } + QImage surface = BrushRaster::context(1024, 8, false); + QPainter painter(&surface); + // The mask covers an eighth of the layer's extent. + const QRectF extent(0, 0, 1024, 8), maskBounds(0, 0, 128, 8); + LayerRenderer::composite(painter, QTransform(), extent, LayerSampling::high, InterpolationQuality::low, {.mask = stripes}, + maskBounds, [&](QPainter &aside) { aside.fillRect(extent, Qt::white); }); + painter.end(); + for (int x = 8; x < 120; ++x) + QVERIFY(std::abs(qAlpha(surface.pixel(x, 4)) - 127) <= 12); + QCOMPARE(qAlpha(surface.pixel(400, 4)), 0); +} + +void LayerRendererTests::aBlackMaskHidesARotatedLayerCompletely() +{ + QImage surface = BrushRaster::context(24, 24, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {8, 8}, .size = {8, 8}, .rotation = 30, .sampling = LayerSampling::smooth}; + LayerRenderer::draw(solid(8, 8, qRgba(255, 255, 255, 255)), transform, transform.center(), painter, {.mask = gray(8, 8, 0)}); + painter.end(); + QCOMPARE(surface, BrushRaster::context(24, 24, false)); +} + +void LayerRendererTests::aGrayMaskHalvesARotatedLayer() +{ + QImage surface = BrushRaster::context(24, 24, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {4, 4}, .size = {16, 16}, .rotation = 30, .sampling = LayerSampling::smooth}; + LayerRenderer::draw(solid(16, 16, qRgba(255, 255, 255, 255)), transform, transform.center(), painter, {.mask = gray(16, 16, 128)}); + painter.end(); + QVERIFY(std::abs(qAlpha(surface.pixel(12, 12)) - 128) <= 1); + QVERIFY(std::abs(qAlpha(surface.pixel(9, 14)) - 128) <= 1); + QCOMPARE(qAlpha(surface.pixel(0, 0)), 0); + for (int y = 0; y < 24; ++y) { + for (int x = 0; x < 24; ++x) + QVERIFY(qAlpha(surface.pixel(x, y)) <= 129); + } +} + +void LayerRendererTests::aMaskedEdgeIsCoverageSquared() +{ + // CoreGraphics antialiases the mask clip and the image alike. + QImage direct = BrushRaster::context(24, 24, false), masked = direct; + QPainter first(&direct), second(&masked); + const LayerTransform transform{.origin = {4, 4}, .size = {16, 16}, .rotation = 30, .sampling = LayerSampling::smooth}; + const QImage white = solid(16, 16, qRgba(255, 255, 255, 255)); + LayerRenderer::draw(white, transform, transform.center(), first); + LayerRenderer::draw(white, transform, transform.center(), second, {.mask = gray(16, 16, 255)}); + first.end(); + second.end(); + int partial = 0; + for (int y = 0; y < 24; ++y) { + for (int x = 0; x < 24; ++x) { + const int edge = qAlpha(direct.pixel(x, y)); + partial += edge > 30 && edge < 225; + QVERIFY(std::abs(qAlpha(masked.pixel(x, y)) - (edge * edge + 127) / 255) <= 2); + } + } + QVERIFY(partial >= 20); +} + +void LayerRendererTests::aThrowingDrawLeavesThePainterBalanced() +{ + QTest::failOnWarning(QRegularExpression(".*saved states.*")); + const LayerTransform transform = placedAt({0, 0}, {4, 4}); + const QImage image = solid(4, 4, qRgba(9, 9, 9, 255)); + QPixmap pixmap(4, 4); + QPainter painter(&pixmap); + painter.translate(1, 2); + const QTransform before = painter.worldTransform(); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(image, transform, transform.center(), painter, + {.blendMode = LayerBlendMode::hue})); + QVERIFY_THROWS_EXCEPTION(std::logic_error, LayerRenderer::draw(image, transform, transform.center(), painter, + {.mask = image})); + QCOMPARE(painter.worldTransform(), before); + QCOMPARE(painter.opacity(), 1.0); + QCOMPARE(painter.compositionMode(), QPainter::CompositionMode_SourceOver); + QVERIFY(painter.end()); +} + +void LayerRendererTests::drawingLeavesThePainterAsItFoundIt() +{ + QImage surface = BrushRaster::context(8, 8, false); + QPainter painter(&surface); + painter.translate(1, 2); + const QTransform before = painter.worldTransform(); + const LayerTransform transform{.origin = {0, 0}, .size = {4, 4}, .rotation = 10, .sampling = LayerSampling::smooth}; + const LayerRenderer::Options options[] = {{.opacity = 0.5, .blendMode = LayerBlendMode::multiply}, + {.opacity = 0.5, .blendMode = LayerBlendMode::screen, .mask = gray(4, 4, 200)}, + {.opacity = 0.5, .blendMode = LayerBlendMode::hue}}; + for (const LayerRenderer::Options &option : options) { + LayerRenderer::draw(solid(4, 4, qRgba(9, 9, 9, 255)), transform, transform.center(), painter, option); + QCOMPARE(painter.worldTransform(), before); + QCOMPARE(painter.opacity(), 1.0); + QCOMPARE(painter.compositionMode(), QPainter::CompositionMode_SourceOver); + QVERIFY(!painter.testRenderHint(QPainter::SmoothPixmapTransform)); + QVERIFY(!painter.testRenderHint(QPainter::Antialiasing)); + } +} + +QTEST_MAIN(LayerRendererTests) +#include "LayerRendererTests.moc" diff --git a/tests/LayerTests.cpp b/tests/LayerTests.cpp new file mode 100644 index 0000000..26c18a3 --- /dev/null +++ b/tests/LayerTests.cpp @@ -0,0 +1,342 @@ +#include "Document/EditorSession.h" +#include <QtTest> +#include <memory> + +class LayerTests : public QObject { + Q_OBJECT +private slots: + void importedLayerTakesItsSizeAndNameFromTheAsset(); + void blankLayerHasNoPixels(); + void layersCompareByImageIdentityNotContent(); + void layersCompareEveryField(); + void documentsCompareEveryField(); + void brushAndSelectionToolFamilies(); + void blankLayersAreTransparentAndInsertedAboveSelection(); + void deletionPreservesCanvasAndChoosesNeighbor(); + void renameAndVisibilityKeepIdentity(); + void reorderTranslatesVisibleOrderAndKeepsSelection(); + void unavailableActionsDoNotChangeDocument(); + void anEyeSwipeIsOneUndoStep(); + void newCanvasStartsWithOneSelectedEmptyLayer(); + void deletingAMultiSelectionRemovesEveryLayerInOneStep(); +}; + +void LayerTests::importedLayerTakesItsSizeAndNameFromTheAsset() +{ + const ImportedImage asset(QImage(320, 200, QImage::Format_RGBA8888_Premultiplied), QImage(), "Photo"); + const ImageLayer layer(asset, QPointF(40, -10)); + QVERIFY(!layer.id.isNull()); + QCOMPARE(layer.name, QString("Photo")); + QCOMPARE(layer.origin(), QPointF(40, -10)); + QCOMPARE(layer.size(), QSizeF(320, 200)); + QCOMPARE(layer.asset->image().size(), QSize(320, 200)); + QVERIFY(layer.isVisible); + QCOMPARE(layer.opacity, 1.0); + QCOMPARE(layer.blendMode, LayerBlendMode::normal); +} + +void LayerTests::blankLayerHasNoPixels() +{ + const ImageLayer layer("Layer 1", QSizeF(1200, 800)); + QVERIFY(!layer.asset.has_value()); + QCOMPARE(layer.origin(), QPointF(0, 0)); + QCOMPARE(layer.size(), QSizeF(1200, 800)); + QVERIFY(!(layer.id == ImageLayer("Layer 1", QSizeF(1200, 800)).id)); +} + +void LayerTests::layersCompareByImageIdentityNotContent() +{ + QImage pixels(4, 4, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::red); + const ImageLayer layer(ImportedImage(pixels, QImage(), "A"), QPointF(0, 0)); + ImageLayer same = layer; + QCOMPARE(same, layer); + same.opacity = 0.5; + QVERIFY(!(same == layer)); + ImageLayer lookalike = layer; + lookalike.asset = ImportedImage(pixels.copy(), QImage(), "A"); + QVERIFY(!(lookalike == layer)); + ImageLayer shared = layer; + shared.asset = ImportedImage(pixels, QImage(), "A"); + QCOMPARE(shared, layer); + ImageLayer emptied = layer; + emptied.asset.reset(); + QVERIFY(!(emptied == layer)); +} + +void LayerTests::layersCompareEveryField() +{ + const ImageLayer layer("Layer 1", QSizeF(10, 10)); + const auto differs = [&](auto change) { + ImageLayer other = layer; + change(other); + return !(layer == other); + }; + QVERIFY(differs([](ImageLayer &other) { other.id = QUuid::createUuid(); })); + QVERIFY(differs([](ImageLayer &other) { other.name = "Layer 2"; })); + QVERIFY(differs([](ImageLayer &other) { other.isVisible = false; })); + QVERIFY(differs([](ImageLayer &other) { other.transform.rotation = 1; })); + QVERIFY(differs([](ImageLayer &other) { other.parentID = QUuid::createUuid(); })); + QVERIFY(differs([](ImageLayer &other) { other.isGroup = true; })); + QVERIFY(differs([](ImageLayer &other) { other.opacity = 1 - 1e-13; })); + QVERIFY(differs([](ImageLayer &other) { other.blendMode = LayerBlendMode::multiply; })); +} + +void LayerTests::documentsCompareEveryField() +{ + CanvasDocument document(400, 300); + document.layers.push_back(ImageLayer("Layer 1", document.size())); + QCOMPARE(document.size(), QSizeF(400, 300)); + QCOMPARE(document.resolution, 72.0); + QVERIFY(!document.id.isNull()); + QVERIFY(document == CanvasDocument(document)); + const auto differs = [&](auto change) { + CanvasDocument other = document; + change(other); + return !(document == other); + }; + QVERIFY(differs([](CanvasDocument &other) { other.id = QUuid::createUuid(); })); + QVERIFY(differs([](CanvasDocument &other) { other.width = 401; })); + QVERIFY(differs([](CanvasDocument &other) { other.height = 301; })); + QVERIFY(differs([](CanvasDocument &other) { other.resolution = 300; })); + QVERIFY(differs([](CanvasDocument &other) { other.layers[0].isVisible = false; })); + QVERIFY(differs([](CanvasDocument &other) { other.layers.clear(); })); +} + +void LayerTests::brushAndSelectionToolFamilies() +{ + for (NavigationTool tool : {NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp, NavigationTool::blur}) + QVERIFY(isBrushTool(tool) && !isSelectionTool(tool)); + for (NavigationTool tool : {NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand}) + QVERIFY(isSelectionTool(tool) && !isBrushTool(tool)); + for (NavigationTool tool : {NavigationTool::move, NavigationTool::crop, NavigationTool::gradient, NavigationTool::shape, + NavigationTool::type, NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom, NavigationTool::idle}) + QVERIFY(!isBrushTool(tool) && !isSelectionTool(tool)); +} + +namespace { +// 800x600 with three blank layers; the last one is active. +std::unique_ptr<EditorSession> sessionWithThreeLayers() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600); + for (int index = 0; index < 3; ++index) + session->addBlankLayer(); + return session; +} + +QStringList names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result; +} +} + +void LayerTests::blankLayersAreTransparentAndInsertedAboveSelection() +{ + const auto session = sessionWithThreeLayers(); + session->setActiveLayerID(session->document().value().layers.front().id); + session->addBlankLayer(); + const std::vector<ImageLayer> layers = session->document().value().layers; + QCOMPARE(names(*session), (QStringList{"Layer 1", "Layer 4", "Layer 2", "Layer 3"})); + QCOMPARE(std::optional(layers[1].id), session->activeLayerID()); + QVERIFY(!layers[1].asset.has_value()); + QCOMPARE(layers[1].size(), QSizeF(800, 600)); + QCOMPARE(layers[1].origin(), QPointF(0, 0)); + QCOMPARE(session->history.undoName(), QString("New Blank Layer")); + // A freed number is used again; unselected, it tops. + session->deleteLayer(layers[2].id); + session->selectLayer(std::nullopt); + session->addBlankLayer(); + QCOMPARE(names(*session), (QStringList{"Layer 1", "Layer 4", "Layer 3", "Layer 2"})); +} + +void LayerTests::deletionPreservesCanvasAndChoosesNeighbor() +{ + const auto session = sessionWithThreeLayers(); + const std::vector<ImageLayer> layers = session->document().value().layers; + session->setActiveLayerID(layers[1].id); + session->deleteLayer(layers[0].id); + QCOMPARE(session->activeLayerID(), std::optional(layers[1].id)); + session->deleteActiveLayer(); + QCOMPARE(session->activeLayerID(), std::optional(layers[2].id)); + session->deleteActiveLayer(); + QCOMPARE(session->activeLayerID(), std::nullopt); + QVERIFY(session->document().value().layers.empty()); + QCOMPARE(session->document().value().size(), QSizeF(800, 600)); + session->addBlankLayer(); + QCOMPARE(int(session->document().value().layers.size()), 1); + // Deleting the top layer moves down to its neighbour. + const auto other = sessionWithThreeLayers(); + const std::vector<ImageLayer> three = other->document().value().layers; + other->deleteActiveLayer(); + QCOMPARE(other->activeLayerID(), std::optional(three[1].id)); + QCOMPARE(other->history.undoName(), QString("Delete Layer")); + other->deleteLayer(QUuid::createUuid()); + QCOMPARE(int(other->document().value().layers.size()), 2); + // Deleting below the active layer leaves the selection alone. + const auto third = sessionWithThreeLayers(); + const std::vector<ImageLayer> stack = third->document().value().layers; + third->deleteLayer(stack[0].id); + QCOMPARE(third->activeLayerID(), std::optional(stack[2].id)); + // What takes a deleted bottom layer's place is active. + third->undo(); + third->setActiveLayerID(stack[0].id); + third->deleteActiveLayer(); + QCOMPARE(third->activeLayerID(), std::optional(stack[1].id)); +} + +void LayerTests::renameAndVisibilityKeepIdentity() +{ + const auto session = sessionWithThreeLayers(); + const QUuid id = session->activeLayerID().value(); + session->renameLayer(id, " Foreground \n"); + session->renameLayer(id, " \n "); + QCOMPARE(session->activeLayer().value().name, QString("Foreground")); + QCOMPARE(session->history.undoName(), QString("Rename Layer")); + session->toggleLayerVisibility(id); + QCOMPARE(session->activeLayer().value().isVisible, false); + QCOMPARE(session->history.undoName(), QString("Hide Layer")); + QCOMPARE(session->activeLayerID(), std::optional(id)); + session->toggleLayerVisibility(id); + QCOMPARE(session->activeLayer().value().isVisible, true); + QCOMPARE(session->history.undoName(), QString("Show Layer")); + // Renaming waits out a project operation and an import. + session->setIsProjectBusy(true); + session->renameLayer(id, "Busy"); + session->setIsProjectBusy(false); + session->setIsImporting(true); + session->renameLayer(id, "Importing"); + session->renameLayer(QUuid::createUuid(), "Nobody"); + QCOMPARE(session->activeLayer().value().name, QString("Foreground")); +} + +void LayerTests::reorderTranslatesVisibleOrderAndKeepsSelection() +{ + const auto session = sessionWithThreeLayers(); + const std::optional<QUuid> active = session->activeLayerID(); + session->reorderLayers({0}, 3); + QCOMPARE(names(*session), (QStringList{"Layer 3", "Layer 1", "Layer 2"})); + QCOMPARE(session->activeLayerID(), active); + QVERIFY(!session->canMoveActiveLayer(-1)); + QVERIFY(session->canMoveActiveLayer(2)); + QVERIFY(!session->canMoveActiveLayer(3)); + session->moveActiveLayer(1); + QCOMPARE(names(*session), (QStringList{"Layer 1", "Layer 3", "Layer 2"})); + QCOMPARE(session->history.undoName(), QString("Reorder Layers")); + const int count = session->history.undoCount(); + session->reorderLayers({99}, 0); + session->reorderLayers({3}, 0); + session->reorderLayers({0}, 4); + session->reorderLayers({-1}, 0); + session->reorderLayers({0}, -1); + // One row out of range refuses the whole move. + session->reorderLayers({0, 3}, 3); + session->reorderLayers({-1, 0}, 3); + session->moveActiveLayer(2); + QCOMPARE(names(*session), (QStringList{"Layer 1", "Layer 3", "Layer 2"})); + QCOMPARE(session->history.undoCount(), count); + // Rows 0 and 2 land together before row 2. + session->reorderLayers({0, 2}, 2); + QCOMPARE(names(*session), (QStringList{"Layer 1", "Layer 2", "Layer 3"})); + session->reorderLayers({1, 2}, 0); + QCOMPARE(names(*session), (QStringList{"Layer 3", "Layer 1", "Layer 2"})); + session->reorderLayers({0}, 3); + QCOMPARE(names(*session), (QStringList{"Layer 2", "Layer 3", "Layer 1"})); +} + +void LayerTests::unavailableActionsDoNotChangeDocument() +{ + EditorSession session; + session.addBlankLayer(); + session.addGroup(); + QVERIFY(!session.document().has_value()); + session.createDocument(30'000, 30'000); + session.addBlankLayer(); + QVERIFY(!session.activeLayer().value().asset.has_value()); + session.setIsImporting(true); + session.addBlankLayer(); + session.addGroup(); + session.deleteActiveLayer(); + session.toggleLayerVisibility(session.activeLayerID().value()); + session.reorderLayers({0}, 1); + session.moveActiveLayer(0); + QVERIFY(!session.beginVisibilitySwipe(session.activeLayerID().value()).has_value()); + QCOMPARE(int(session.document().value().layers.size()), 1); + QVERIFY(session.activeLayer().value().isVisible); +} + +void LayerTests::anEyeSwipeIsOneUndoStep() +{ + const auto session = sessionWithThreeLayers(); + const std::vector<ImageLayer> layers = session->document().value().layers; + const int count = session->history.undoCount(); + QCOMPARE(session->beginVisibilitySwipe(layers[0].id), std::optional(false)); + session->setVisibilityInSwipe(layers[1].id, false); + session->setVisibilityInSwipe(layers[1].id, false); + session->setVisibilityInSwipe(QUuid::createUuid(), false); + session->endVisibilitySwipe(); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Hide Layer")); + QVERIFY(!session->document().value().layers[0].isVisible && !session->document().value().layers[1].isVisible); + QVERIFY(session->document().value().layers[2].isVisible); + // Pressing a hidden eye shows, and says so. + QCOMPARE(session->beginVisibilitySwipe(layers[1].id), std::optional(true)); + session->endVisibilitySwipe(); + QCOMPARE(session->history.undoName(), QString("Show Layer")); + QVERIFY(session->document().value().layers[1].isVisible); + QVERIFY(!session->beginVisibilitySwipe(QUuid::createUuid()).has_value()); +} + +void LayerTests::newCanvasStartsWithOneSelectedEmptyLayer() +{ + EditorSession session; + session.createNewProject(640, 480); + QCOMPARE(int(session.document().value().layers.size()), 1); + const ImageLayer active = session.activeLayer().value(); + QVERIFY(active.name == "Layer 1" && !active.asset.has_value()); + QCOMPARE(active.size(), QSizeF(640, 480)); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{active.id}); + QVERIFY(session.canPaint()); +} + +QTEST_GUILESS_MAIN(LayerTests) +#include "LayerTests.moc" + +void LayerTests::deletingAMultiSelectionRemovesEveryLayerInOneStep() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid keep = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid top = session.activeLayerID().value(); + const std::vector<ImageLayer> &layers = session.document().value().layers; + QCOMPARE(layers[size_t(indexOf(layers, child))].parentID, std::optional(folder)); + const std::optional<CanvasDocument> before = session.document(); + const int count = session.history.undoCount(); + session.selectLayers({folder, top}, top); + session.deleteLayerOrMask(); + // The folder, its child and the other layer are gone. + QCOMPARE(layers.size(), size_t(1)); + QCOMPARE(layers[0].id, keep); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Delete Layers")); + QCOMPARE(session.activeLayerID(), std::optional(keep)); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{keep}); + session.undo(); + QVERIFY(session.document() == before); + // A single selection still deletes just that layer. + session.selectLayers({keep}, keep); + session.deleteLayerOrMask(); + QVERIFY(indexOf(layers, keep) < 0); + QCOMPARE(session.history.undoName(), QString("Delete Layer")); +} diff --git a/tests/LayersPanelTests.cpp b/tests/LayersPanelTests.cpp new file mode 100644 index 0000000..c8b9aa9 --- /dev/null +++ b/tests/LayersPanelTests.cpp @@ -0,0 +1,213 @@ +#include "ContentView.h" +#include "UI/LayerAppearanceControls.h" +#include "UI/LayersPanel.h" +#include "UI/NativeLayerList.h" +#include <QLabel> +#include <QSettings> +#include <QStackedWidget> +#include <QToolButton> +#include <QtTest> +#include "SessionFixtures.h" + +// The Layers panel: heading, rows or empty words, footer, width. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +void drag(QWidget &edge, int fromX, int toX) +{ + const QPoint start(fromX, 20), end(toX, 20); + QTest::mousePress(&edge, Qt::LeftButton, Qt::NoModifier, start); + QMouseEvent move(QEvent::MouseMove, QPointF(end), edge.mapToGlobal(end), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&edge, &move); + QTest::mouseRelease(&edge, Qt::LeftButton, Qt::NoModifier, end); +} +} + +class LayersPanelTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void init(); + void theHeadingCountsAndTheEmptyWordsFollowTheDocument(); + void theFooterAddsGroupsAndDeletesWithSwiftsWords(); + void theEdgeResizesThePanelWithinRangeAndRemembersIt(); + void aStoredWidthOutOfRangeIsTheDefault(); + void theMaskButtonUsesTheSelection(); +}; + +void LayersPanelTests::initTestCase() +{ + // Settings land in ~/.qttest, not the user's own. + QStandardPaths::setTestModeEnabled(true); +} + +void LayersPanelTests::init() +{ + QSettings().remove(QStringLiteral("layersPanelWidth")); +} + +void LayersPanelTests::theHeadingCountsAndTheEmptyWordsFollowTheDocument() +{ + EditorSession session; + LayersPanel panel(session); + panel.show(); + QVERIFY(QTest::qWaitForWindowExposed(&panel)); + auto &count = find<QLabel>(panel, "layerCount"); + auto &hint = find<QLabel>(panel, "noLayersHint"); + auto &body = find<QStackedWidget>(panel, "layersBody"); + QCOMPARE(count.text(), QString("0")); + QCOMPARE(hint.text(), QString("Create a canvas or import an image.")); + QVERIFY(!panel.list().isVisible()); + session.createDocument(200, 100); + QCOMPARE(hint.text(), QString("Import an image or add a blank layer.")); + QCOMPARE(body.currentIndex(), 1); + session.addBlankLayer(); + session.addBlankLayer(); + QCOMPARE(count.text(), QString("2")); + QCOMPARE(body.currentIndex(), 0); + QVERIFY(panel.list().isVisible()); + QCOMPARE(panel.list().cells().size(), size_t(2)); + session.deleteSelectedLayers(); + session.deleteSelectedLayers(); + QCOMPARE(count.text(), QString("0")); + QCOMPARE(body.currentIndex(), 1); + // The appearance controls sit above the rows. + QVERIFY(panel.findChild<LayerAppearanceControls *>() != nullptr); + QVERIFY(panel.findChild<LayerAppearanceControls *>()->y() < body.y()); +} + +void LayersPanelTests::theFooterAddsGroupsAndDeletesWithSwiftsWords() +{ + EditorSession session; + LayersPanel panel(session); + panel.show(); + QVERIFY(QTest::qWaitForWindowExposed(&panel)); + auto &add = find<QToolButton>(panel, "addBlankLayer"); + auto &group = find<QToolButton>(panel, "groupLayers"); + auto &remove = find<QToolButton>(panel, "deleteLayer"); + auto &mask = find<QToolButton>(panel, "addLayerMask"); + QCOMPARE(add.toolTip(), QString("New blank layer (Ctrl+Shift+N)")); + QVERIFY(!add.isEnabled() && !group.isEnabled() && !remove.isEnabled() && !mask.isEnabled()); + session.createDocument(200, 100); + QVERIFY(add.isEnabled() && group.isEnabled() && !remove.isEnabled()); + QTest::mouseClick(&add, Qt::LeftButton); + QTest::mouseClick(&add, Qt::LeftButton); + QCOMPARE(session.document().value().layers.size(), size_t(2)); + QVERIFY(remove.isEnabled() && mask.isEnabled()); + QCOMPARE(remove.toolTip(), QString("Delete selected layer")); + QCOMPARE(remove.accessibleName(), QString("Delete selected layer")); + // With a mask selected, Delete takes the mask first. + QTest::mouseClick(&mask, Qt::LeftButton); + QVERIFY(session.activeLayer().value().mask.has_value() && session.isMaskSelected()); + QCOMPARE(remove.toolTip(), QString("Delete layer mask")); + QTest::mouseClick(&remove, Qt::LeftButton); + QVERIFY(!session.activeLayer().value().mask.has_value()); + QCOMPARE(session.document().value().layers.size(), size_t(2)); + // A multi-selection deletes as one step, and says so. + const QUuid top = session.document().value().layers.back().id, bottom = session.document().value().layers.front().id; + session.selectLayers({top, bottom}, top); + QCOMPARE(remove.toolTip(), QString("Delete selected layers")); + QTest::mouseClick(&remove, Qt::LeftButton); + QVERIFY(session.document().value().layers.empty()); + QCOMPARE(session.history.undoName(), QString("Delete Layers")); + // A folder from the footer, around the selection. + session.addBlankLayer(); + QTest::mouseClick(&group, Qt::LeftButton); + QCOMPARE(session.document().value().layers.size(), size_t(2)); + QVERIFY(session.activeLayer().value().isGroup); + // Busy, the footer waits. + session.setIsProjectBusy(true); + QVERIFY(!add.isEnabled() && !group.isEnabled() && !remove.isEnabled() && !mask.isEnabled()); +} + +void LayersPanelTests::theEdgeResizesThePanelWithinRangeAndRemembersIt() +{ + EditorSession session; + session.createDocument(200, 100); + ContentView view(session); + view.resize(1100, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + auto &edge = find<QWidget>(view, "layersPanelEdge"); + LayersPanel &panel = view.layersPanel(); + QCOMPARE(panel.width(), 252); + QCOMPARE(edge.width(), 8); + QCOMPARE(edge.cursor().shape(), Qt::SplitHCursor); + QCOMPARE(edge.toolTip(), QString("Drag to resize the panel")); + // The panel sits at the right, past the edge. + QCOMPARE(panel.x(), edge.x() + edge.width()); + QCOMPARE(panel.x() + panel.width(), view.width()); + drag(edge, 4, -36); + QCOMPARE(panel.width(), 292); + QCOMPARE(QSettings().value("layersPanelWidth").toDouble(), 292.0); + // Past the range, the panel stops at its bounds. + drag(edge, 4, -300); + QCOMPARE(panel.width(), int(LayersPanel::maximumWidth)); + drag(edge, 4, 400); + QCOMPARE(panel.width(), int(LayersPanel::minimumWidth)); + QCOMPARE(QSettings().value("layersPanelWidth").toDouble(), LayersPanel::minimumWidth); + // A right-button drag after the left one moves nothing. + QTest::mousePress(&edge, Qt::RightButton, Qt::NoModifier, QPoint(4, 20)); + QMouseEvent move(QEvent::MouseMove, QPointF(-50, 20), edge.mapToGlobal(QPoint(-50, 20)), Qt::NoButton, Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(&edge, &move); + QTest::mouseRelease(&edge, Qt::RightButton, Qt::NoModifier, QPoint(-50, 20)); + QCOMPARE(panel.width(), int(LayersPanel::minimumWidth)); + // The next editor starts at the remembered width. + ContentView next(session); + QCOMPARE(next.layersPanel().width(), int(LayersPanel::minimumWidth)); +} + +void LayersPanelTests::aStoredWidthOutOfRangeIsTheDefault() +{ + QSettings().setValue(QStringLiteral("layersPanelWidth"), 9000); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("layersPanelWidth 9000 is out of range, using 252")); + QCOMPARE(ContentView::layersPanelWidth(), 252.0); + QSettings().setValue(QStringLiteral("layersPanelWidth"), QStringLiteral("wide")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("layersPanelWidth 0 is out of range, using 252")); + QCOMPARE(ContentView::layersPanelWidth(), 252.0); +} + +QTEST_MAIN(LayersPanelTests) +#include "LayersPanelTests.moc" + +void LayersPanelTests::theMaskButtonUsesTheSelection() +{ + EditorSession session; + session.createDocument(100, 40, true); + QImage image(100, 40, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session.insert(ImportedImage(image, image, "Red")); + LayersPanel panel(session); + panel.show(); + QVERIFY(QTest::qWaitForWindowExposed(&panel)); + auto &mask = find<QToolButton>(panel, "addLayerMask"); + QCOMPARE(mask.toolTip(), QString("Add layer mask")); + QPainterPath box; + box.addRect(QRectF(20, 10, 30, 20)); + session.applySelection(box, SelectionMode::replace, "Select"); + QCOMPARE(mask.toolTip(), QString("Add layer mask (the selection becomes black)")); + QTest::mouseClick(&mask, Qt::LeftButton); + QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); + QVERIFY(!session.selection().has_value() && session.isMaskSelected() && !mask.isEnabled()); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); + // The row's menu: black shows only the selection. + session.undo(); + QVERIFY(session.selection().has_value()); + QAction *black = nullptr; + for (QAction *action : panel.list().cells().at(0)->actions()) { + if (action->objectName() == "addBlackMask") + black = action; + } + QVERIFY(black); + black->trigger(); + QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0); +} diff --git a/tests/LevelsCanvasTests.cpp b/tests/LevelsCanvasTests.cpp new file mode 100644 index 0000000..b94dced --- /dev/null +++ b/tests/LevelsCanvasTests.cpp @@ -0,0 +1,296 @@ +#include "MenuFixtures.h" +#include "SelectionCanvasFixtures.h" +#include "UI/FloatingPanel.h" +#include "UI/LevelsSheet.h" +#include <QLineEdit> +#include <QPushButton> + +// Levels in its panel and on the canvas. +namespace { +LevelsSettings inverted() +{ + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + return settings; +} + +// Gray 100 left, gray 200 right, filling the view. +struct Split : Canvas { + Split() + { + QImage image(400, 300, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor(100, 100, 100)); + for (int y = 0; y < 300; ++y) { + for (int x = 200; x < 400; ++x) + image.setPixelColor(x, y, QColor(200, 200, 200)); + } + session.insert(ImportedImage(image, image, QStringLiteral("Split"))); + } + void tool(NavigationTool tool) + { + session.selectTool(tool); + canvas->synchronizeDisplay(); + } + QColor shown(QPoint at) + { + canvas->synchronizeDisplay(); + return canvas->grab().toImage().pixelColor(at); + } +}; + +QWidget *levelsPanel() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == QStringLiteral("levelsPanel") && widget->isVisible()) + return widget; + } + return nullptr; +} + +// A window with one gray 64 layer, shown and active. +struct Editor : Bar { + Editor() + { + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + session().createDocument(4, 4, true); + QImage gray(4, 4, QImage::Format_RGBA8888_Premultiplied); + gray.fill(QColor(64, 64, 64)); + session().insert(ImportedImage(gray, gray, QStringLiteral("Gray"))); + } +}; +} + +class LevelsCanvasTests : public QObject { + Q_OBJECT +private slots: + void thePreviewStandsInForTheLayer(); + void aSampleClickSetsItsPointAndHandsThePanelTheKeys(); + void pressesWaitForSpaceTheHandAndTheZoom(); + void theCanvasKeysWhileLevelsIsOpen(); + void bracketsRestWhileLevelsIsOpen(); + void thePanelFollowsTheEdit(); + void returnInAnEntryGoesOnToOK(); + void returnFromAnyButtonGoesToOK(); +}; + +void LevelsCanvasTests::thePreviewStandsInForTheLayer() +{ + Split split; + split.session.beginLevels(); + split.session.updateLevels(inverted(), true); + QTRY_VERIFY(split.session.levels().value().preparedPreview); + QCOMPARE(split.shown(QPoint(100, 150)), QColor(155, 155, 155)); + QCOMPARE(split.shown(QPoint(300, 150)), QColor(55, 55, 55)); + // Off, the layer's own pixels show again. + split.session.updateLevels(inverted(), false); + QCOMPARE(split.shown(QPoint(100, 150)), QColor(100, 100, 100)); + split.session.updateLevels(inverted(), true); + QTRY_VERIFY(split.session.levels().value().preparedPreview); + split.session.cancelLevels(); + QCOMPARE(split.shown(QPoint(300, 150)), QColor(200, 200, 200)); +} + +void LevelsCanvasTests::aSampleClickSetsItsPointAndHandsThePanelTheKeys() +{ + Split split; + split.session.beginLevels(); + FloatingPanel panel(QStringLiteral("levelsPanel"), split.window); + panel.show(QStringLiteral("Levels"), new LevelsSheet(split.session)); + QTRY_COMPARE(QApplication::activeWindow(), levelsPanel()); + // Choosing an eyedropper puts it on the canvas. + QVERIFY(!split.shows(CanvasView::eyedropperCursor(split.canvas->devicePixelRatio()))); + split.session.setLevelsSampleMode(LevelsSample::white); + split.canvas->synchronizeDisplay(); + QVERIFY(split.shows(CanvasView::eyedropperCursor(split.canvas->devicePixelRatio()))); + split.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &split.window); + split.click(QPointF(300, 150)); + const LevelsSettings sampled = split.session.levels().value().settings; + for (size_t channel = 1; channel < 4; ++channel) + QCOMPARE(sampled.ranges[channel].white, 200.0); + QVERIFY(!split.session.lassoDraft() && !split.session.selection()); + QTRY_COMPARE(QApplication::activeWindow(), levelsPanel()); + // With Space held the press pans instead. + const QSizeF pan = split.session.viewport.pan; + QTest::keyPress(split.canvas, Qt::Key_Space); + split.drag(QPointF(100, 150), QPointF(110, 160)); + QTest::keyRelease(split.canvas, Qt::Key_Space); + QCOMPARE(split.session.viewport.pan, pan + QSizeF(10, 10)); + QCOMPARE(split.session.levels().value().settings, sampled); + // Put away, the canvas's own cursor returns. + split.session.setLevelsSampleMode(std::nullopt); + split.canvas->synchronizeDisplay(); + QVERIFY(!split.shows(CanvasView::eyedropperCursor(split.canvas->devicePixelRatio()))); + split.session.cancelLevels(); +} + +void LevelsCanvasTests::pressesWaitForSpaceTheHandAndTheZoom() +{ + Split split; + // Tools are chosen first: an open Levels keeps the tool. + split.session.beginLevels(); + split.drag(QPointF(50, 50), QPointF(150, 150)); + QVERIFY(!split.session.lassoDraft() && !split.session.selection()); + split.session.cancelLevels(); + split.tool(NavigationTool::brush); + split.session.beginLevels(); + split.press(QPointF(100, 100)); + QVERIFY(!split.session.brushStroke()); + split.release(QPointF(100, 100)); + split.session.cancelLevels(); + split.tool(NavigationTool::eyedropper); + split.session.beginLevels(); + const QString foreground = split.session.foregroundColor().hex(); + split.click(QPointF(300, 150)); + QCOMPARE(split.session.foregroundColor().hex(), foreground); + split.session.cancelLevels(); + // The Hand pans and the Zoom zooms, as ever. + split.tool(NavigationTool::hand); + split.session.beginLevels(); + const QSizeF pan = split.session.viewport.pan; + split.drag(QPointF(100, 100), QPointF(120, 90)); + QCOMPARE(split.session.viewport.pan, pan + QSizeF(20, -10)); + // A sample press samples alone, even with the Hand. + split.session.setLevelsSampleMode(LevelsSample::black); + const QString palette = split.session.foregroundColor().hex(); + split.drag(QPointF(300, 150), QPointF(320, 150)); + QCOMPARE(split.session.foregroundColor().hex(), palette); + QCOMPARE(split.session.viewport.pan, pan + QSizeF(20, -10)); + QCOMPARE(split.session.levels().value().settings.ranges[1].black, 200.0); + split.session.cancelLevels(); + split.tool(NavigationTool::zoom); + split.session.beginLevels(); + split.click(QPointF(200, 150)); + QVERIFY(split.session.viewport.zoom() > 1); + split.session.cancelLevels(); +} + +void LevelsCanvasTests::theCanvasKeysWhileLevelsIsOpen() +{ + Split split; + split.canvas->setFocus(); + QTRY_VERIFY(split.canvas->hasFocus()); + split.session.beginLevels(); + QTest::keyClick(split.canvas, Qt::Key_P, Qt::AltModifier); + QVERIFY(!split.session.levels().value().preview); + QTest::keyClick(split.canvas, Qt::Key_P, Qt::AltModifier); + QVERIFY(split.session.levels().value().preview); + QTest::keyClick(split.canvas, Qt::Key_P); + QVERIFY(split.session.levels().value().preview); + // Other keys pass on: B chooses no brush. + QTest::keyClick(split.canvas, Qt::Key_B); + QTest::keyClick(split.canvas, Qt::Key_Tab); + QCOMPARE(split.session.tool(), NavigationTool::marquee); + QCOMPARE(split.session.marqueeKind(), LassoKind::rectangle); + QTest::keyClick(split.canvas, Qt::Key_Escape); + QVERIFY(!split.session.levels()); + // Return and Enter apply. + for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter}) { + split.session.beginLevels(); + split.session.updateLevels(inverted(), false); + const int steps = split.session.history.undoCount(); + QTest::keyClick(split.canvas, key); + QTRY_VERIFY(!split.session.levels()); + QCOMPARE(split.session.history.undoCount(), steps + 1); + QCOMPARE(split.session.history.undoName(), QString("Levels")); + } + QCOMPARE(split.shown(QPoint(100, 150)), QColor(100, 100, 100)); +} + +void LevelsCanvasTests::bracketsRestWhileLevelsIsOpen() +{ + Split split; + split.tool(NavigationTool::brush); + auto *other = new QPushButton(QStringLiteral("Other"), &split.window); + other->show(); + other->setFocus(); + QTRY_VERIFY(other->hasFocus()); + const double diameter = split.session.brushSettings().diameter; + split.session.beginLevels(); + QTest::keyClick(other, Qt::Key_BracketRight); + split.canvas->setFocus(); + QTest::keyClick(split.canvas, Qt::Key_BracketRight); + QCOMPARE(split.session.brushSettings().diameter, diameter); + split.session.cancelLevels(); + other->setFocus(); + QTest::keyClick(other, Qt::Key_BracketRight); + QVERIFY(split.session.brushSettings().diameter > diameter); +} + +void LevelsCanvasTests::thePanelFollowsTheEdit() +{ + Editor editor; + QVERIFY(!levelsPanel()); + editor.session().beginLevels(); + QWidget *panel = levelsPanel(); + QVERIFY(panel); + QCOMPARE(panel->windowTitle(), QString("Levels")); + LevelsSheet *sheet = panel->findChild<LevelsSheet *>(); + QVERIFY(sheet->isVisible()); + // A change keeps the sheet; a new edit makes one. + editor.session().updateLevels(inverted(), false); + QVERIFY(sheet->isVisible()); + editor.session().cancelLevels(); + QVERIFY(!panel->isVisible()); + // Again with a fresh sheet; the old one goes. + editor.session().beginLevels(); + QVERIFY(panel->isVisible()); + QTRY_COMPARE(panel->findChildren<LevelsSheet *>().size(), 1); + // Its close button and Escape cancel. + panel->close(); + QVERIFY(!editor.session().levels()); + editor.session().beginLevels(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QTest::keyClick(panel, Qt::Key_Escape); + QVERIFY(!editor.session().levels() && !panel->isVisible()); +} + +void LevelsCanvasTests::returnInAnEntryGoesOnToOK() +{ + Editor editor; + editor.session().beginLevels(); + QWidget *panel = levelsPanel(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QLineEdit &black = *panel->findChild<QLineEdit *>(QStringLiteral("levelsInputblack")); + black.setFocus(); + QTRY_VERIFY(black.hasFocus()); + black.selectAll(); + QTest::keyClicks(&black, QStringLiteral("128")); + QTest::keyClick(&black, Qt::Key_Return); + QTRY_VERIFY(!editor.session().levels()); + QCOMPARE(editor.session().history.undoName(), QString("Levels")); + QCOMPARE(editor.session().activeLayer().value().asset.value().image().pixelColor(0, 0), QColor(Qt::black)); + QVERIFY(!panel->isVisible()); +} + +void LevelsCanvasTests::returnFromAnyButtonGoesToOK() +{ + Editor editor; + // Swift's default action: no other button takes Return. + for (const char *text : {"Black", "Gray", "White", "Contrast", "Color", "Color + neutral midtones", "Reset", "Cancel"}) { + // Window managers hand the keys back once a panel closes. + editor.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &editor.window); + editor.session().beginLevels(); + editor.session().updateLevels(inverted(), false); + QWidget *panel = levelsPanel(); + QTRY_COMPARE(QApplication::activeWindow(), panel); + QPushButton *button = nullptr; + for (QPushButton *found : panel->findChildren<QPushButton *>()) { + if (found->text() == QString::fromUtf8(text) && found->isVisible()) + button = found; + } + QTRY_VERIFY(button && button->isEnabled()); + button->setFocus(); + QTRY_VERIFY(button->hasFocus()); + const int steps = editor.session().history.undoCount(); + QTest::keyClick(button, Qt::Key_Return); + QTRY_VERIFY2(!editor.session().levels(), text); + QCOMPARE(editor.session().history.undoCount(), steps + 1); + } +} + +QTEST_MAIN(LevelsCanvasTests) +#include "LevelsCanvasTests.moc" diff --git a/tests/LevelsEdgeTests.cpp b/tests/LevelsEdgeTests.cpp new file mode 100644 index 0000000..fe639dd --- /dev/null +++ b/tests/LevelsEdgeTests.cpp @@ -0,0 +1,239 @@ +#include "LevelsFixtures.h" +#include "Rendering/RasterSnapshot.h" +#include <QSignalSpy> +#include <QThreadPool> + +// Levels at its edges: order, signals, stale runs, sizes, samples. +namespace { +LevelsSettings inverted() +{ + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + return settings; +} + +LevelsSettings brighter() +{ + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 2, 255, 0, 255}); + return settings; +} +} + +class LevelsEdgeTests : public QObject { + Q_OBJECT +private slots: + void beginEndsATransformAndALasso(); + void signalsFollowEveryStep(); + void turningPreviewOffDropsTheQueue(); + void anIdentityCommitIsImmediate(); + void stalePreviewsNeverLand(); + void aDoneRunLandsBeforeTheNextStarts(); + void theRebuildDropsText(); + void thePreviewIsTheEditsLayerAlone(); + void scaledSidesTruncateAsSwifts(); + void samplesStopAtTheDocumentAndClearPixels(); +}; + +void LevelsEdgeTests::beginEndsATransformAndALasso() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->selectTool(NavigationTool::move); + session->beginTransform(); + LayerTransform moved = session->activeLayer().value().transform; + moved.origin = QPointF(1, 0); + session->previewTransform(moved); + session->beginLevels(); + QVERIFY(!session->transformEdit()); + QCOMPARE(session->levels().value().transform.origin, QPointF(1, 0)); + // Placed: its pixels map through the new place. + QCOMPARE(session->levels().value().mapping.map(QPointF(0, 0)), QPointF(1, 0)); + QCOMPARE(session->levels().value().previewMapping, session->levels().value().mapping); + session->cancelLevels(); + session->selectTool(NavigationTool::lasso); + session->beginLasso(QPointF(0, 0), SelectionMode::replace); + QVERIFY(session->lassoDraft()); + session->beginLevels(); + QVERIFY(!session->lassoDraft()); + session->cancelLevels(); +} + +void LevelsEdgeTests::signalsFollowEveryStep() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + QSignalSpy changed(session.get(), &EditorSession::changed); + session->beginLevels(); + QCOMPARE(changed.count(), 1); + QTRY_VERIFY(session->levels().value().histogramReady); + QCOMPARE(changed.count(), 2); + changed.clear(); + session->updateLevels(inverted(), true); + QCOMPARE(changed.count(), 1); + // The preview lands with a redraw and a signal. + const int revision = session->brushRevision(); + QTRY_VERIFY(session->levels().value().preparedPreview); + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(changed.count(), 2); + changed.clear(); + session->updateLevels(inverted(), false); + QCOMPARE(changed.count(), 1); + QVERIFY(!session->levels().value().preview); + const int shown = session->brushRevision(); + changed.clear(); + session->cancelLevels(); + QCOMPARE(changed.count(), 1); + QCOMPARE(session->brushRevision(), shown + 1); + session->beginLevels(); + session->updateLevels(inverted(), false); + const int committing = session->brushRevision(); + commit(*session); + QCOMPARE(session->brushRevision(), committing + 1); +} + +void LevelsEdgeTests::turningPreviewOffDropsTheQueue() +{ + const std::unique_ptr<EditorSession> session = bigSession(); + session->beginLevels(); + session->updateLevels(brighter(), true); + session->updateLevels(inverted(), true); + QVERIFY(session->levels().value().pending); + session->updateLevels(inverted(), false); + QVERIFY(!session->levels().value().pending); + QTest::qWait(200); + QVERIFY(!session->levels().value().preparedPreview); + session->cancelLevels(); +} + +void LevelsEdgeTests::anIdentityCommitIsImmediate() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + bool done = false; + session->commitLevels([&done] { done = true; }); + QVERIFY(!session->levels() && !session->isProjectBusy()); + QTRY_VERIFY(done); +} + +void LevelsEdgeTests::stalePreviewsNeverLand() +{ + const std::unique_ptr<EditorSession> session = bigSession(); + // A closed edit's run never lands on the next one. + session->beginLevels(); + session->updateLevels(brighter(), true); + session->cancelLevels(); + session->beginLevels(); + session->updateLevels(inverted(), true); + int revision = session->brushRevision(); + QTRY_VERIFY(session->levels().value().preparedPreview); + QCOMPARE(session->brushRevision(), revision + 1); + QCOMPARE(session->levels().value().preparedPreview.value().pixelColor(0, 0), QColor(Qt::cyan)); + // Nor one turned off, then on again. + session->updateLevels(brighter(), true); + session->updateLevels(brighter(), false); + session->updateLevels(inverted(), true); + revision = session->brushRevision(); + QTRY_VERIFY(session->levels().value().preparedPreview && !session->levels().value().pending); + QCOMPARE(session->brushRevision(), revision + 1); + // Nor one a commit overtook, nor the queued one. + session->updateLevels(brighter(), true); + session->updateLevels(inverted(), true); + QVERIFY(session->levels().value().pending); + revision = session->brushRevision(); + bool done = false; + session->commitLevels([&done] { done = true; }); + QVERIFY(!session->levels().value().pending); + QTRY_VERIFY(done); + QCOMPARE(session->brushRevision(), revision + 1); +} + +void LevelsEdgeTests::aDoneRunLandsBeforeTheNextStarts() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + QTRY_VERIFY(session->levels().value().histogramReady); + const int revision = session->brushRevision(); + session->updateLevels(brighter(), true); + // Done but undelivered: Qt would drop it for the next. + QThreadPool::globalInstance()->waitForDone(); + session->updateLevels(inverted(), true); + QVERIFY(session->levels().value().pending); + QTRY_VERIFY(!session->levels().value().pending && session->brushRevision() == revision + 2); + QCOMPARE(session->levels().value().preparedPreview.value().pixelColor(0, 0), QColor(Qt::white)); + session->cancelLevels(); +} + +void LevelsEdgeTests::theRebuildDropsText() +{ + EditorSession session; + session.createDocument(200, 100); + session.selectTool(NavigationTool::type); + session.beginText(QPointF(10, 10), true); + TextDraft draft = session.textDraft().value(); + draft.style.content = QStringLiteral("Hi"); + session.setTextDraft(draft); + QVERIFY(session.finishText()); + QVERIFY(session.activeLayer().value().liveText()); + session.beginLevels(); + session.updateLevels(inverted(), false); + commit(session); + QVERIFY(!session.activeLayer().value().text); +} + +void LevelsEdgeTests::thePreviewIsTheEditsLayerAlone() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + session->updateLevels(inverted(), true); + QTRY_VERIFY(session->levels().value().preparedPreview); + const LevelsEdit &edit = session->levels().value(); + QVERIFY(edit.previewImage(edit.layerID)); + QVERIFY(!edit.previewImage(QUuid::createUuid())); + session->cancelLevels(); +} + +void LevelsEdgeTests::scaledSidesTruncateAsSwifts() +{ + // The long side sets the factor, tall or wide. + for (const auto &[size, preview] : {std::pair(QSize(2, 9000), QSize(1, 8000)), std::pair(QSize(8311, 2), QSize(7999, 1))}) { + EditorSession session; + session.createDocument(size.width(), size.height()); + QImage image(size, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session.insert(ImportedImage(image, image, QStringLiteral("Red"))); + session.beginLevels(); + QCOMPARE(session.levels().value().previewSource.size(), preview); + session.cancelLevels(); + } +} + +void LevelsEdgeTests::samplesStopAtTheDocumentAndClearPixels() +{ + // The ramp at twice its width reaches past the document. + EditorSession session; + session.createDocument(6, 1); + const QImage source = image(ramp); + session.insert(ImportedImage(source, source, QStringLiteral("Ramp"))); + LayerTransform wide = session.activeLayer().value().transform; + wide.size = QSizeF(12, 1); + session.beginTransform(); + session.previewTransform(wide); + session.commitTransform(); + session.beginLevels(); + session.setLevelsSampleMode(LevelsSample::black); + session.sampleLevels(QPointF(6, 0.5)); + QVERIFY(session.levels().value().settings.isIdentity()); + session.cancelLevels(); + // A clear pixel sets nothing; a soft one reads unpremultiplied. + const std::unique_ptr<EditorSession> plain = rampSession(); + plain->beginLevels(); + plain->setLevelsSampleMode(LevelsSample::black); + plain->sampleLevels(QPointF(5.5, 0.5)); + QVERIFY(plain->levels().value().settings.isIdentity()); + plain->sampleLevels(QPointF(4.5, 0.5)); + const LevelsSettings soft = plain->levels().value().settings; + QVERIFY(soft.ranges[1].black == 127.5 && soft.ranges[2].black == 63.75 && soft.ranges[3].black == 0); + plain->cancelLevels(); +} + +QTEST_MAIN(LevelsEdgeTests) +#include "LevelsEdgeTests.moc" diff --git a/tests/LevelsFailureTests.cpp b/tests/LevelsFailureTests.cpp new file mode 100644 index 0000000..a0b288d --- /dev/null +++ b/tests/LevelsFailureTests.cpp @@ -0,0 +1,95 @@ +#include "AddressSpaceLimit.h" +#include "LevelsFixtures.h" +#include "Rendering/RasterSnapshot.h" +#include <malloc.h> + +// Levels when memory runs out, each on a fresh heap. +namespace { +// A painted asset without full pixels: a two-pixel base. +std::unique_ptr<EditorSession> rasterSession(int width, int height) +{ + QImage base(2, 2, QImage::Format_RGBA8888_Premultiplied); + base.fill(Qt::red); + auto session = std::make_unique<EditorSession>(); + session->createDocument(20, 10); + session->insert(ImportedImage(std::make_shared<const RasterSnapshot>(width, height, base, QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}), + QImage(), QStringLiteral("Painted"))); + return session; +} + +LevelsSettings inverted() +{ + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + return settings; +} +} + +// Earlier tests leave free heap that big asks would reuse. +class LevelsFailureTests : public QObject { + Q_OBJECT +private slots: + // First: it leaves no free heap behind for the rest. + void aFailedCountOrPreviewLeavesNothing(); + void failuresReachTheSessionsError(); +}; + +void LevelsFailureTests::failuresReachTheSessionsError() +{ + // Workers allocate in their own arenas: only large asks fail. + { + // 10000 by 9999 in tiles: the scaled copy, 256 MB. + const std::unique_ptr<EditorSession> session = rasterSession(10000, 9999); + { + // Freed heap returns first: the room is real. + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + session->beginLevels(); + } + QVERIFY(!session->levels() && session->brushError()); + } + // 30000 by 3000: a small copy; the flatten, 360 MB. + const std::unique_ptr<EditorSession> session = rasterSession(30000, 3000); + session->beginLevels(); + QTRY_VERIFY(session->levels().value().histogramReady); + session->updateLevels(inverted(), false); + const ImageIdentity before = session->activeLayer().value().asset.value().identity(); + const int count = session->history.undoCount(); + session->setLevelsSampleMode(LevelsSample::gray); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + // A sample flattens the pixels first. + session->sampleLevels(QPointF(5, 5)); + QVERIFY(session->brushError()); + session->setBrushError(std::nullopt); + commit(*session); + } + QVERIFY(session->brushError() && !session->levels() && !session->isProjectBusy()); + QCOMPARE(session->activeLayer().value().asset.value().identity(), before); + QCOMPARE(session->history.undoCount(), count); +} + +void LevelsFailureTests::aFailedCountOrPreviewLeavesNothing() +{ + // Flattened first; the count's and preview's copies need 67 MB. + const std::unique_ptr<EditorSession> session = rasterSession(8000, 2100); + QVERIFY(!session->activeLayer().value().asset.value().image().isNull()); + { + malloc_trim(0); + const AddressSpaceLimit limit(16ll * 1024 * 1024); + session->beginLevels(); + QTRY_VERIFY(session->levels().value().histogramReady); + for (const std::array<double, 256> &channel : session->levels().value().histogram) + QVERIFY(std::all_of(channel.begin(), channel.end(), [](double bin) { return bin == 0; })); + // A failed preview lands as none, and redraws. + const int revision = session->brushRevision(); + session->updateLevels(inverted(), true); + QTRY_COMPARE(session->brushRevision(), revision + 1); + QVERIFY(!session->levels().value().preparedPreview); + } + session->cancelLevels(); +} + +QTEST_GUILESS_MAIN(LevelsFailureTests) +#include "LevelsFailureTests.moc" diff --git a/tests/LevelsFixtures.h b/tests/LevelsFixtures.h new file mode 100644 index 0000000..39cb540 --- /dev/null +++ b/tests/LevelsFixtures.h @@ -0,0 +1,62 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QtTest> +#include <array> +#include <memory> +#include <vector> + +// Shared by the Levels tests: pixels, ramp and commit. +inline QImage image(const std::vector<std::array<uchar, 4>> &pixels) +{ + QImage result(int(pixels.size()), 1, QImage::Format_RGBA8888_Premultiplied); + for (size_t index = 0; index < pixels.size(); ++index) + std::copy(pixels[index].begin(), pixels[index].end(), result.bits() + index * 4); + return result; +} + +// Premultiplied RGBA bytes, row after row. +inline std::vector<uchar> bytes(const QImage &image) +{ + const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector<uchar> result; + for (int y = 0; y < drawn.height(); ++y) + result.insert(result.end(), drawn.constScanLine(y), drawn.constScanLine(y) + drawn.width() * 4); + return result; +} + +inline const std::vector<std::array<uchar, 4>> ramp{{0, 0, 0, 255}, {64, 64, 64, 255}, {128, 128, 128, 255}, + {255, 255, 255, 255}, {64, 32, 0, 128}, {0, 0, 0, 0}}; + +// Swift's session: the ramp as a 6 by 1 layer. +inline std::unique_ptr<EditorSession> rampSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(6, 1); + const QImage source = image(ramp); + session->insert(ImportedImage(source, source, QStringLiteral("Ramp"))); + return session; +} + +// 3000 by 2000 red: long enough runs to queue behind. +inline std::unique_ptr<EditorSession> bigSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(3000, 2000); + QImage red(3000, 2000, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session->insert(ImportedImage(red, red, QStringLiteral("Red"))); + return session; +} + +inline LevelsJob job(const QImage &image, const LevelsSettings &settings = {}, std::optional<SelectionClip> selection = std::nullopt) +{ + return LevelsJob{image, settings, std::move(selection), QTransform()}; +} + +// Swift's `await commitLevels()`: back once the step has landed. +inline void commit(EditorSession &session) +{ + bool done = false; + session.commitLevels([&done] { done = true; }); + QTRY_VERIFY(done); +} diff --git a/tests/LevelsHandleTests.cpp b/tests/LevelsHandleTests.cpp new file mode 100644 index 0000000..dc5bb16 --- /dev/null +++ b/tests/LevelsHandleTests.cpp @@ -0,0 +1,153 @@ +#include "LevelsSheetFixtures.h" + +// The sheet's handles: where they sit, draw and drag. +class LevelsHandleTests : public QObject { + Q_OBJECT +private slots: + void handlesDrawSwiftsTriangles(); + void handlesDragTheirOwnLevels(); + void theTopmostHandleTakesThePress(); + void aHandleReplacesItsEntrysTyping(); +}; + +void LevelsHandleTests::handlesDrawSwiftsTriangles() +{ + Sheet shown; + QVERIFY(shown.show()); + const QWidget &input = shown.child<QWidget>("levelsInputHandles"); + const QColor window = shown.sheet->palette().color(QPalette::Window); + // Black, gray, white inside; the window around them. + const std::pair<const char *, QColor> fills[] = {{"Input black", Qt::black}, {"Gamma", QColor(142, 142, 147)}, {"Input white", Qt::white}}; + for (const auto &[handle, fill] : fills) { + const QImage drawn = shown.handle(handle).grab().toImage(); + QCOMPARE(drawn.pixelColor(11, 12), fill); + for (const QPoint outside : {QPoint(11, 3), QPoint(11, 16), QPoint(3, 14), QPoint(19, 14)}) + QCOMPARE(drawn.pixelColor(outside), window); + } + // Twelve wide at the base: its corners' pixels stay light. + const QImage black = shown.handle("Input black").grab().toImage(); + QVERIFY(black.pixelColor(4, 14).red() > 200 && black.pixelColor(17, 14).red() > 200); + QVERIFY(black.pixelColor(6, 14).red() < 100 && black.pixelColor(15, 14).red() < 100); + // Under the base: the rim's three quarters, then the fill's. + const QColor rim = shown.handle("Input black").grab().toImage().pixelColor(11, 15); + QVERIFY2(std::abs(rim.red() - 0.75 * (0.75 * 142 + 0.25 * window.red())) <= 2, qPrintable(rim.name())); + QVERIFY2(std::abs(rim.blue() - 0.75 * (0.75 * 147 + 0.25 * window.blue())) <= 2, qPrintable(rim.name())); + // Past the track's edge the triangle still shows. + QCOMPARE(shown.sheet->grab().toImage().pixelColor(21, input.y() + 12), QColor(Qt::black)); +} + +void LevelsHandleTests::handlesDragTheirOwnLevels() +{ + Sheet shown; + QVERIFY(shown.show()); + const QWidget &input = shown.child<QWidget>("levelsInputHandles"), &output = shown.child<QWidget>("levelsOutputHandles"); + QWidget &black = shown.handle("Input black"), &gamma = shown.handle("Gamma"), &white = shown.handle("Input white"); + // Centred on each tone, a point above the track. + QCOMPARE(black.pos(), QPoint(13, input.y() - 1)); + QCOMPARE(gamma.pos(), QPoint(209, input.y() - 1)); + QCOMPARE(white.pos(), QPoint(405, input.y() - 1)); + QCOMPARE(shown.handle("Output white").pos(), QPoint(405, output.y() - 1)); + QTest::mousePress(&black, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + QCOMPARE(shown.range(), LevelRange()); + drag(black, input, 51.4); + QVERIFY(shown.range().black == 51); + QCOMPARE(shown.child<QLineEdit>("levelsInputblack").text(), QString("51")); + QCOMPARE(black.x(), 91); + drag(black, input, 400); + QCOMPARE(shown.range().black, 254.0); + // Black stays a tone under white. + shown.set(LevelRange{0, 1, 100, 0, 255}); + drag(black, input, 150); + QCOMPARE(shown.range(), (LevelRange{99, 1, 100, 0, 255})); + QTest::mouseRelease(&black, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + // White stays above black; whole tones, rounded. + shown.set(LevelRange()); + QTest::mousePress(&white, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(white, input, 100.6); + QCOMPARE(shown.range().white, 101.0); + shown.set(LevelRange{30, 1, 101, 0, 255}); + drag(white, input, -40); + QCOMPARE(shown.range().white, 31.0); + QTest::mouseRelease(&white, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + // The gamma follows the tone's share of the range. + shown.set(LevelRange()); + QTest::mousePress(&gamma, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(gamma, input, 191.25); + QVERIFY(std::abs(shown.range().gamma - std::log(0.75) / std::log(0.5)) < 1e-9); + QCOMPARE(shown.child<QLineEdit>("levelsGamma").text(), QString("0.42")); + QCOMPARE(gamma.x(), 307); + drag(gamma, input, 0); + QVERIFY(std::abs(shown.range().gamma - std::log(0.001) / std::log(0.5)) < 1e-9); + drag(gamma, input, 255); + QCOMPARE(shown.range().gamma, 0.1); + // The share is of the range between black and white. + shown.set(LevelRange{51, 1, 255, 0, 255}); + drag(gamma, input, 204); + QVERIFY(std::abs(shown.range().gamma - std::log(0.75) / std::log(0.5)) < 1e-9); + QTest::mouseRelease(&gamma, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + // Outputs cross freely; other buttons drag nothing. + shown.set(LevelRange()); + QWidget &outputBlack = shown.handle("Output black"), &outputWhite = shown.handle("Output white"); + QTest::mousePress(&outputWhite, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(outputWhite, output, 64.4); + QTest::mouseRelease(&outputWhite, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + QTest::mousePress(&outputBlack, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(outputBlack, output, 128); + QTest::mouseRelease(&outputBlack, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + QCOMPARE(shown.range(), (LevelRange{0, 1, 255, 128, 64})); + QTest::mousePress(&outputBlack, Qt::RightButton, Qt::NoModifier, QPoint(0, 10)); + drag(outputBlack, output, 20, Qt::RightButton); + QTest::mouseRelease(&outputBlack, Qt::RightButton, Qt::NoModifier, QPoint(0, 10)); + QCOMPARE(shown.range().outputBlack, 128.0); + QCOMPARE(outputBlack.pos(), QPoint(210, output.y() - 1)); + // A drag keeps Preview as it stands. + QVERIFY(!shown.session.levels().value().preview); +} + +void LevelsHandleTests::theTopmostHandleTakesThePress() +{ + Sheet shown; + QVERIFY(shown.show()); + shown.set(LevelRange{100, 1, 101, 0, 255}); + const QWidget &input = shown.child<QWidget>("levelsInputHandles"), &output = shown.child<QWidget>("levelsOutputHandles"); + // All three overlap; white lies on top, as Swift's last. + QWidget *pressed = shown.sheet->childAt(179, input.y() + 9); + QCOMPARE(pressed, &shown.handle("Input white")); + QTest::mousePress(pressed, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(*pressed, input, 200); + QTest::mouseRelease(pressed, Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + QCOMPARE(shown.range(), (LevelRange{100, 1, 200, 0, 255})); + // Between handles the track takes nothing. + QCOMPARE(shown.sheet->childAt(300, input.y() + 9), &input); + // The output handles lie on the gradient's last row. + QCOMPARE(shown.sheet->childAt(24, output.y() - 1), &shown.handle("Output black")); +} + +void LevelsHandleTests::aHandleReplacesItsEntrysTyping() +{ + Sheet shown; + QVERIFY(shown.show()); + const QWidget &input = shown.child<QWidget>("levelsInputHandles"), &output = shown.child<QWidget>("levelsOutputHandles"); + QLineEdit &black = shown.child<QLineEdit>("levelsInputblack"), &white = shown.child<QLineEdit>("levelsOutputwhite"); + // A handle takes no focus; its drag replaces the typing. + type(black, QStringLiteral("20")); + QTest::mousePress(&shown.handle("Input black"), Qt::LeftButton, Qt::NoModifier, QPoint(20, 10)); + QVERIFY(black.hasFocus() && black.text() == "6" && !black.isModified() && shown.range().black == 6); + drag(shown.handle("Input black"), input, 51.4); + QVERIFY(black.text() == "51" && !black.isModified()); + white.setFocus(); + QCOMPARE(shown.range().black, 51.0); + // Another handle leaves the typing, which then applies. + type(white, QStringLiteral("200")); + QTest::mousePress(&shown.handle("Output black"), Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(shown.handle("Output black"), output, 30); + QVERIFY(white.text() == "200" && white.isModified() && shown.range().outputBlack == 30); + QTest::mousePress(&shown.handle("Output white"), Qt::LeftButton, Qt::NoModifier, QPoint(11, 10)); + drag(shown.handle("Output white"), output, 180); + QVERIFY(white.text() == "180" && !white.isModified()); + black.setFocus(); + QCOMPARE(shown.range().outputWhite, 180.0); +} + +QTEST_MAIN(LevelsHandleTests) +#include "LevelsHandleTests.moc" diff --git a/tests/LevelsModelTests.cpp b/tests/LevelsModelTests.cpp new file mode 100644 index 0000000..4f0e7c8 --- /dev/null +++ b/tests/LevelsModelTests.cpp @@ -0,0 +1,170 @@ +#include "LevelsFixtures.h" +#include <QtTest> +#include <cmath> + +// Levels' rules to the value: ranges, the scale, auto, sampling. +class LevelsModelTests : public QObject { + Q_OBJECT +private slots: + void rangesClampIntoRangeAndResetWhatIsNoNumber(); + void applyClampsTheInputBetweenBlackAndWhite(); + void settingsStoreAndCompareNormalized(); + void theScaleReadsTheSortedFiniteInterior(); + void autoCutsAThousandthAtEitherEnd(); + void aSingleToneChannelKeepsItsRange(); + void contrastSpansTheWidestChannel(); + void neutralCentresTheMeanWithinItsFloor(); + void samplingCalibratesEachChannelAndResetsTheRest(); + void histogramRowsKeepTheirOwnCoverage(); +}; + +void LevelsModelTests::rangesClampIntoRangeAndResetWhatIsNoNumber() +{ + const LevelRange high = LevelRange{300, 20, 400, 300, 300}.normalized(); + QVERIFY(high.black == 254 && high.white == 255 && high.gamma == 9.99 && high.outputBlack == 255 && high.outputWhite == 255); + const LevelRange low = LevelRange{-5, 0.05, 100, -1, -1}.normalized(); + QVERIFY(low.black == 0 && low.gamma == 0.1 && low.outputBlack == 0 && low.outputWhite == 0); + // White stays a step above black. + QCOMPARE((LevelRange{100, 1, 50, 0, 255}.normalized().white), 101.0); + // What is no number takes its default, infinity included. + QVERIFY((LevelRange{INFINITY, NAN, NAN, NAN, -INFINITY}.normalized()) == LevelRange()); +} + +void LevelsModelTests::applyClampsTheInputBetweenBlackAndWhite() +{ + // Past white is output white; under black, output black. + QCOMPARE((LevelRange{0, 1, 128, 0, 200}.apply(1)), 200.0 / 255); + QCOMPARE((LevelRange{128, 1, 255, 50, 255}.apply(0)), 50.0 / 255); + QCOMPARE((LevelRange{128, 1, 255, 50, 255}.apply(1)), 1.0); + // Gamma 2 lifts a quarter to a half. + QVERIFY(std::abs(LevelRange{0, 2, 255, 0, 255}.apply(0.25) - 0.5) < 1e-12); + // It applies itself normalized: black 300 is 254. + QCOMPARE((LevelRange{300, 1, 255, 0, 255}.apply(254.0 / 255)), 0.0); +} + +void LevelsModelTests::settingsStoreAndCompareNormalized() +{ + LevelsSettings settings; + settings.channel = LevelsChannel::green; + settings.setCurrent(LevelRange{300, 1, 255, 0, 255}); + QCOMPARE(settings.ranges[2].black, 254.0); + QCOMPARE(settings.current().black, 254.0); + // A raw range that normalizes to the identity is one. + LevelsSettings raw; + raw.ranges[1] = LevelRange{-5, NAN, 400, -1, 300}; + QVERIFY(raw.isIdentity()); +} + +void LevelsModelTests::theScaleReadsTheSortedFiniteInterior() +{ + // An infinite bin counts for nothing. + std::array<double, 256> spiked; + spiked.fill(100); + spiked[128] = INFINITY; + QCOMPARE(LevelsHistogramDisplay::scale(spiked), 100.0); + // The ends stay out of the typical peak. + std::array<double, 256> first{}, last{}; + first[0] = last[255] = 1; + first[128] = last[128] = 100; + QCOMPARE(LevelsHistogramDisplay::scale(first), 100.0); + QCOMPARE(LevelsHistogramDisplay::scale(last), 100.0); + // Falling bins: the sorted 95th percentile, 241, times four. + std::array<double, 256> falling{}; + for (size_t index = 1; index < 255; ++index) + falling[index] = double(255 - index); + falling[0] = 1e6; + QCOMPARE(LevelsHistogramDisplay::scale(falling), 964.0); +} + +void LevelsModelTests::autoCutsAThousandthAtEitherEnd() +{ + LevelsHistogram tails{}; + for (size_t channel = 1; channel < 4; ++channel) { + tails[channel][5] = 0.5; + tails[channel][100] = 99; + tails[channel][250] = 0.5; + } + const LevelsSettings contrast = settings(LevelsAuto::contrast, tails); + QVERIFY(contrast.ranges[0].black == 5 && contrast.ranges[0].white == 250); + const LevelsSettings colour = settings(LevelsAuto::color, tails); + QVERIFY(colour.ranges[3].black == 5 && colour.ranges[3].white == 250); +} + +void LevelsModelTests::aSingleToneChannelKeepsItsRange() +{ + LevelsHistogram single{}; + for (size_t channel = 1; channel < 4; ++channel) + single[channel][128] = 100; + for (const LevelsAuto mode : allLevelsAutos) + QVERIFY(settings(mode, single).isIdentity()); +} + +void LevelsModelTests::contrastSpansTheWidestChannel() +{ + LevelsHistogram spread{}; + spread[1][30] = spread[1][240] = 100; + spread[2][40] = spread[2][200] = 100; + spread[3][50] = spread[3][210] = 100; + const LevelsSettings contrast = settings(LevelsAuto::contrast, spread); + QVERIFY(contrast.ranges[0].black == 30 && contrast.ranges[0].white == 240); + QVERIFY(contrast.ranges[1] == LevelRange() && contrast.ranges[3] == LevelRange()); +} + +void LevelsModelTests::neutralCentresTheMeanWithinItsFloor() +{ + // A quarter of the weight at white: gamma 2 halves. + LevelsHistogram quarter{}; + for (size_t channel = 1; channel < 4; ++channel) { + quarter[channel][0] = 75; + quarter[channel][255] = 25; + } + QVERIFY(std::abs(settings(LevelsAuto::neutral, quarter).ranges[1].gamma - 2) < 1e-12); + QCOMPARE(settings(LevelsAuto::color, quarter).ranges[1].gamma, 1.0); + // A mean near white stops at gamma 0.1. + LevelsHistogram bright{}; + for (size_t channel = 1; channel < 4; ++channel) { + bright[channel][10] = 0.2; + bright[channel][250] = 99.8; + } + QCOMPARE(settings(LevelsAuto::neutral, bright).ranges[2].gamma, 0.1); +} + +void LevelsModelTests::samplingCalibratesEachChannelAndResetsTheRest() +{ + LevelsSettings settings; + settings.ranges[0] = LevelRange{20, 1.5, 230, 10, 240}; + settings.ranges[1] = LevelRange{0, 1, 100, 10, 240}; + settings.ranges[2] = LevelRange{150, 1, 255, 0, 255}; + settings.ranges[3] = LevelRange{0, 1, 255, 10, 255}; + // Black past a channel's white stops a step below it. + const LevelsSettings black = settings.sampling({200.0 / 255, 0.2, 0.2}, LevelsSample::black); + QCOMPARE(black.ranges[0], LevelRange()); + QVERIFY(black.ranges[1].black == 99 && black.ranges[1].white == 100); + QVERIFY(black.ranges[1].outputBlack == 0 && black.ranges[1].outputWhite == 255); + // White under a channel's black stops a step above it. + QCOMPARE(settings.sampling({0.2, 100.0 / 255, 0.2}, LevelsSample::white).ranges[2].white, 151.0); + // Gray at or past white leaves that channel whole. + const LevelsSettings gray = settings.sampling({1, 0.5, 0.5}, LevelsSample::gray); + QCOMPARE(gray.ranges[1], settings.ranges[1]); + QCOMPARE(gray.ranges[0], LevelRange()); + // Gray just under white is stored normalized: gamma 0.1. + QCOMPARE(settings.sampling({0.5, 0.5, 254.0 / 255}, LevelsSample::gray).ranges[3].gamma, 0.1); +} + +void LevelsModelTests::histogramRowsKeepTheirOwnCoverage() +{ + // Three wide: mask rows are padded, pixel rows not. + QImage source(3, 2, QImage::Format_RGBA8888_Premultiplied); + for (int x = 0; x < 3; ++x) { + source.setPixelColor(x, 0, Qt::red); + source.setPixelColor(x, 1, Qt::green); + } + QPainterPath lower; + lower.addRect(0, 1, 3, 1); + const LevelsHistogram bins = LevelsFilter::histogram(job(source, {}, DocumentSelection{lower, false}.clip(QSizeF(3, 2)))); + QVERIFY(bins[1][255] == 0 && bins[1][0] == 3 && bins[2][255] == 3 && bins[3][0] == 3); + QVERIFY(std::abs(bins[0][0] - 2) < 1e-12 && std::abs(bins[0][255] - 1) < 1e-12); +} + +QTEST_GUILESS_MAIN(LevelsModelTests) +#include "LevelsModelTests.moc" diff --git a/tests/LevelsSessionTests.cpp b/tests/LevelsSessionTests.cpp new file mode 100644 index 0000000..75cf185 --- /dev/null +++ b/tests/LevelsSessionTests.cpp @@ -0,0 +1,228 @@ +#include "LevelsFixtures.h" +#include "Rendering/RasterSnapshot.h" +#include <QSignalSpy> + +// The session around an open Levels edit: gates, queue, sampling. +class LevelsSessionTests : public QObject { + Q_OBJECT +private slots: + void anOpenEditHoldsTheSession(); + void aCommitHoldsTheProjectBusy(); + void aPendingGradientLandsFirst(); + void bigLayersPreviewAScaledCopy(); + void theRebuildDropsShapeAndText(); + void previewsRunOneAtATime(); + void autoWaitsForTheHistogramAndEndsSampling(); + void sampleModeRedrawsAndAnnounces(); + void samplesComeFromTheLayersOwnPixel(); +}; + +void LevelsSessionTests::anOpenEditHoldsTheSession() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + QVERIFY(!session->canAdjustColors()); + // A second Levels waits for the first. + const QUuid first = session->levels().value().id; + session->beginLevels(); + QCOMPARE(session->levels().value().id, first); + // No other tool or layer while it is open. + const NavigationTool tool = session->tool(); + session->selectTool(NavigationTool::brush); + QCOMPARE(session->tool(), tool); + const std::optional<QUuid> layer = session->activeLayerID(); + session->selectLayer(std::nullopt); + QCOMPARE(session->activeLayerID(), layer); + // A file request waits, then runs once it closes. + bool asked = false; + session->waitForFileRequest([&asked] { asked = true; }); + QTest::qWait(20); + QVERIFY(!asked); + session->cancelLevels(); + QTRY_VERIFY(asked); + QVERIFY(session->canAdjustColors()); +} + +void LevelsSessionTests::aCommitHoldsTheProjectBusy() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + session->updateLevels(settings, false); + bool done = false; + session->commitLevels([&done] { done = true; }); + QVERIFY(session->isProjectBusy() && session->levels().value().committing); + // Meanwhile every change is refused; a second commit returns. + session->updateLevels(LevelsSettings(), true); + QVERIFY(session->levels().value().settings == settings); + session->cancelLevels(); + QVERIFY(session->levels()); + bool again = false; + session->commitLevels([&again] { again = true; }); + QTRY_VERIFY(done && again); + QVERIFY(!session->isProjectBusy() && !session->levels()); + QCOMPARE(bytes(session->activeLayer().value().asset.value().image())[0], uchar(255)); +} + +void LevelsSessionTests::aPendingGradientLandsFirst() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->selectTool(NavigationTool::gradient); + session->beginGradient(QPointF(0, 0.5)); + session->moveGradient(std::nullopt, QPointF(6, 0.5)); + QVERIFY(session->gradientEdit()); + const int count = session->history.undoCount(); + session->beginLevels(); + QTRY_VERIFY(session->levels()); + QVERIFY(!session->gradientEdit()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Gradient")); + session->cancelLevels(); +} + +void LevelsSessionTests::bigLayersPreviewAScaledCopy() +{ + EditorSession session; + session.createDocument(9000, 2); + QImage wide(9000, 2, QImage::Format_RGBA8888_Premultiplied); + wide.fill(Qt::red); + session.insert(ImportedImage(wide, wide, QStringLiteral("Wide"))); + session.beginLevels(); + // Past 8000 a side: 8000 across, the height truncated. + const LevelsEdit &edit = session.levels().value(); + QCOMPARE(edit.previewSource.size(), QSize(8000, 1)); + QCOMPARE(edit.previewSource.pixelColor(4000, 0), QColor(Qt::red)); + QCOMPARE(edit.previewMapping.map(QPointF(8000, 1)), QPointF(9000, 2)); + QCOMPARE(edit.mapping.map(QPointF(9000, 2)), QPointF(9000, 2)); + session.cancelLevels(); + // A painted layer previews from its tiles, never flattened. + session.selectTool(NavigationTool::brush); + session.beginBrush(QPointF(100, 1)); + session.finishBrush(); + const ImportedImage painted = session.activeLayer().value().asset.value(); + QVERIFY(painted.raster && !painted.raster->hasMaterializedPixels()); + session.beginLevels(); + QVERIFY(!painted.raster->hasMaterializedPixels()); + QCOMPARE(session.levels().value().previewSource.pixelColor(89, 0), QColor(Qt::black)); + session.cancelLevels(); + // One pixel tall stays one pixel tall. + EditorSession thin; + thin.createDocument(9000, 1); + QImage line(9000, 1, QImage::Format_RGBA8888_Premultiplied); + line.fill(Qt::red); + thin.insert(ImportedImage(line, line, QStringLiteral("Line"))); + thin.beginLevels(); + QCOMPARE(thin.levels().value().previewSource.size(), QSize(8000, 1)); + thin.cancelLevels(); + // Smaller layers preview their own pixels. + const std::unique_ptr<EditorSession> small = rampSession(); + small->beginLevels(); + QCOMPARE(small->levels().value().previewSource.cacheKey(), small->activeLayer().value().asset.value().image().cacheKey()); + QCOMPARE(small->levels().value().previewMapping, small->levels().value().mapping); +} + +void LevelsSessionTests::theRebuildDropsShapeAndText() +{ + EditorSession session; + session.createDocument(20, 20); + session.selectTool(NavigationTool::shape); + session.beginShape(QPointF(2, 2)); + session.dragShape(QPointF(12, 12), false, false); + session.finishShape(); + QVERIFY(session.activeLayer().value().shape); + session.beginLevels(); + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + session.updateLevels(settings, false); + commit(session); + QVERIFY(!session.activeLayer().value().shape); + QCOMPARE(session.history.undoName(), QString("Levels")); +} + +void LevelsSessionTests::previewsRunOneAtATime() +{ + const std::unique_ptr<EditorSession> session = bigSession(); + session->beginLevels(); + LevelsSettings brighter, inverted; + brighter.setCurrent(LevelRange{0, 2, 255, 0, 255}); + inverted.setCurrent(LevelRange{0, 1, 255, 255, 0}); + // The second waits for the first; the newest lands last. + session->updateLevels(brighter, true); + session->updateLevels(inverted, true); + QVERIFY(session->levels().value().pending); + QTRY_VERIFY(!session->levels().value().pending && session->levels().value().preparedPreview + && session->levels().value().preparedPreview.value().pixelColor(0, 0) == QColor(Qt::cyan)); + // Identity settings show the layer itself. + const int revision = session->brushRevision(); + session->updateLevels(LevelsSettings(), true); + QVERIFY(!session->levels().value().preparedPreview && session->brushRevision() > revision); + session->cancelLevels(); +} + +void LevelsSessionTests::autoWaitsForTheHistogramAndEndsSampling() +{ + EditorSession session; + session.createDocument(2, 1); + const QImage source = image({{50, 50, 50, 255}, {200, 200, 200, 255}}); + session.insert(ImportedImage(source, source, QStringLiteral("Gray"))); + session.beginLevels(); + session.setLevelsSampleMode(LevelsSample::white); + // Its count arrives from the event loop: not yet. + session.autoLevels(LevelsAuto::contrast); + QVERIFY(session.levels().value().settings.isIdentity() && session.levels().value().sampleMode); + QTRY_VERIFY(session.levels().value().histogramReady); + session.autoLevels(LevelsAuto::contrast); + QVERIFY(!session.levels().value().sampleMode); + QVERIFY(session.levels().value().settings.ranges[0].black == 50 && session.levels().value().settings.ranges[0].white == 200); + session.cancelLevels(); +} + +void LevelsSessionTests::sampleModeRedrawsAndAnnounces() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + QSignalSpy changed(session.get(), &EditorSession::changed); + // Without an edit it is refused, silently. + session->setLevelsSampleMode(LevelsSample::black); + QCOMPARE(changed.count(), 0); + session->beginLevels(); + changed.clear(); + const int revision = session->brushRevision(); + session->setLevelsSampleMode(LevelsSample::black); + QCOMPARE(changed.count(), 1); + QVERIFY(session->brushRevision() > revision); + QCOMPARE(session->levels().value().sampleMode, std::optional(LevelsSample::black)); + session->setLevelsSampleMode(std::nullopt); + QVERIFY(!session->levels().value().sampleMode); + // No mode, no sample. + session->sampleLevels(QPointF(2.5, 0.5)); + QVERIFY(session->levels().value().settings.isIdentity()); + session->cancelLevels(); +} + +void LevelsSessionTests::samplesComeFromTheLayersOwnPixel() +{ + EditorSession session; + session.createDocument(8, 4); + // Black then white, scaled twice and moved by one. + const QImage source = image({{0, 0, 0, 255}, {255, 255, 255, 255}}); + session.insert(ImportedImage(source, source, QStringLiteral("Pair"))); + LayerTransform placed = session.activeLayer().value().transform; + placed.origin = QPointF(1, 1); + placed.size = QSizeF(4, 2); + session.beginTransform(); + session.previewTransform(placed); + session.commitTransform(); + session.beginLevels(); + session.setLevelsSampleMode(LevelsSample::white); + // Outside the layer, inside the document: nothing. + session.sampleLevels(QPointF(0.5, 0.5)); + QVERIFY(session.levels().value().settings.isIdentity()); + // Document point (2, 2) is black: white stops at 1. + session.sampleLevels(QPointF(2, 2)); + QCOMPARE(session.levels().value().settings.ranges[1].white, 1.0); + session.cancelLevels(); +} + +QTEST_GUILESS_MAIN(LevelsSessionTests) +#include "LevelsSessionTests.moc" diff --git a/tests/LevelsSheetFixtures.h b/tests/LevelsSheetFixtures.h new file mode 100644 index 0000000..39412ab --- /dev/null +++ b/tests/LevelsSheetFixtures.h @@ -0,0 +1,125 @@ +#pragma once +#include "LevelsFixtures.h" +#include "UI/LevelsSheet.h" +#include <QAccessible> +#include <QApplication> +#include <QLineEdit> + +// Shared by the sheet's tests: a sheet and its parts. +namespace { +// Gray 64 twice, a warm pixel and 65: known peaks. +struct Sheet { + EditorSession session; + std::unique_ptr<LevelsSheet> sheet; + Sheet() + { + session.createDocument(4, 1); + const QImage source = image({{64, 64, 64, 255}, {64, 64, 64, 255}, {192, 96, 0, 255}, {65, 65, 65, 255}}); + session.insert(ImportedImage(source, source, QStringLiteral("Four"))); + session.beginLevels(); + sheet = std::make_unique<LevelsSheet>(session); + } + bool show() + { + sheet->show(); + return QTest::qWaitForWindowActive(sheet.get()) && QTest::qWaitFor([this] { return session.levels().value().histogramReady; }); + } + template <typename Widget> Widget &child(const char *name) const + { + auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; + } + QPushButton &button(const QString &text) const + { + for (QPushButton *button : sheet->findChildren<QPushButton *>()) { + if (button->text() == text) + return *button; + } + throw std::runtime_error(text.toStdString()); + } + QLabel &label(const QString &text) const + { + for (QLabel *label : sheet->findChildren<QLabel *>()) { + if (label->text() == text) + return *label; + } + throw std::runtime_error(text.toStdString()); + } + // A handle sits on the sheet itself, over its track. + QWidget &handle(const QString &name) const + { + for (QWidget *widget : sheet->findChildren<QWidget *>(QString(), Qt::FindDirectChildrenOnly)) { + if (widget->accessibleName() == name) + return *widget; + } + throw std::runtime_error(name.toStdString()); + } + LevelRange range() const { return session.levels().value().settings.current(); } + void set(const LevelRange &range) + { + LevelsSettings settings = session.levels().value().settings; + settings.setCurrent(range); + session.updateLevels(settings, false); + } +}; + +// Moves a pressed handle to a tone along its track. +inline void drag(QWidget &handle, const QWidget &track, double tone, Qt::MouseButtons buttons = Qt::LeftButton) +{ + const QPointF at(track.x() + tone / 255 * track.width() - handle.x(), 10); + QMouseEvent event(QEvent::MouseMove, at, at, handle.mapToGlobal(at), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(&handle, &event); +} + +inline void type(QLineEdit &field, const QString &text) +{ + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); +} + +// The strongest pixel of an icon, and its colour. +inline QColor ink(const QIcon &icon) +{ + const QImage drawn = icon.pixmap(QSize(14, 14)).toImage(); + QColor strongest(Qt::transparent); + for (int y = 0; y < drawn.height(); ++y) { + for (int x = 0; x < drawn.width(); ++x) { + if (drawn.pixelColor(x, y).alpha() > strongest.alpha()) + strongest = drawn.pixelColor(x, y); + } + } + return strongest; +} + +inline QString name(QWidget &widget) +{ + return QAccessible::queryAccessibleInterface(&widget)->text(QAccessible::Name); +} + +// Unites the regions a widget repaints. +class Repaints : public QObject { +public: + explicit Repaints(QWidget &widget) { widget.installEventFilter(this); } + QRegion painted; + +protected: + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::Paint) + painted += static_cast<QPaintEvent *>(event)->region(); + return false; + } +}; + +// The window's colour under a black quarter, as Qt blends. +inline QColor shaded(const QColor &window) +{ + QImage blended(1, 1, QImage::Format_ARGB32_Premultiplied); + blended.fill(window); + QPainter(&blended).fillRect(0, 0, 1, 1, QColor(0, 0, 0, 64)); + return blended.pixelColor(0, 0); +} +} diff --git a/tests/LevelsSheetTests.cpp b/tests/LevelsSheetTests.cpp new file mode 100644 index 0000000..8e7108d --- /dev/null +++ b/tests/LevelsSheetTests.cpp @@ -0,0 +1,359 @@ +#include "LevelsSheetFixtures.h" +#include "Rendering/EyedropperIcon.h" +#include <QFrame> + +// Swift's LevelsSheet: its controls, entries and states. +class LevelsSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetShowsTheEditAsSwifts(); + void theSheetIsLaidOutAsSwifts(); + void theHistogramDrawsTheChannelsBins(); + void entriesApplyAsSwiftsBindings(); + void entriesReadAndWriteTheLocale(); + void eyedroppersToggleAndShowTheirNote(); + void autoResetAndPreview(); + void cancelOkAndTheCommitsRest(); + void aNewThemeRecoloursTheEyedroppers(); +}; + +void LevelsSheetTests::theSheetShowsTheEditAsSwifts() +{ + Sheet shown; + // Until the bins land: the note, and Auto rests. + QLabel &loading = shown.label(QStringLiteral("Loading histogram…")); + QVERIFY(!loading.isHidden()); + QCOMPARE(loading.size(), loading.sizeHint()); + QCOMPARE(name(shown.child<QWidget>("levelsHistogram")), QString("Original RGB histogram")); + QCOMPARE(shown.child<QLineEdit>("levelsGamma").text(), QString("1.00")); + QCOMPARE(loading.pos(), QPoint(8, 8)); + QCOMPARE(loading.foregroundRole(), QPalette::WindowText); + QCOMPARE(loading.font().pixelSize(), 10); + QVERIFY(!shown.button("Contrast").isEnabled()); + QVERIFY(shown.show()); + QVERIFY(loading.isHidden()); + for (const char *text : {"Contrast", "Color", "Color + neutral midtones"}) + QVERIFY(shown.button(text).isEnabled()); + QWidget &histogram = shown.child<QWidget>("levelsHistogram"); + QCOMPARE(histogram.toolTip(), QString("Linear histogram with automatic vertical scaling. Tall spikes may extend beyond the graph; all tones " + "from 0 to 255 remain included.")); + QComboBox &channel = *shown.sheet->findChild<QComboBox *>(); + QCOMPARE(channel.count(), 4); + QCOMPARE(channel.itemText(3), QString("Blue")); + // Swift's names, identifiers, decimals and trailing text. + const std::pair<const char *, const char *> entries[] = {{"levelsInputblack", "0"}, {"levelsGamma", "1.00"}, {"levelsInputwhite", "255"}, + {"levelsOutputblack", "0"}, {"levelsOutputwhite", "255"}}; + const QStringList names{"Input black", "Gamma", "Input white", "Output black", "Output white"}; + for (qsizetype index = 0; index < names.size(); ++index) { + QLineEdit &entry = shown.child<QLineEdit>(entries[index].first); + QCOMPARE(entry.text(), QString(entries[index].second)); + QCOMPARE(entry.placeholderText(), names[index]); + QCOMPARE(name(entry), names[index]); + QCOMPARE(entry.alignment(), Qt::AlignRight); + QCOMPARE(name(shown.handle(names[index])), names[index]); + QCOMPARE(shown.handle(names[index]).size(), QSize(22, 20)); + // Each entry's caption above it, five points apart. + auto *title = entry.parentWidget()->findChild<QLabel *>(); + QCOMPARE(title->text(), names[index]); + QCOMPARE(title->foregroundRole(), QPalette::PlaceholderText); + QCOMPARE(title->font().pixelSize(), 10); + QCOMPARE(entry.y() - title->geometry().bottom() - 1, 5); + QCOMPARE(title->x(), 0); + } + // Linux names a combo box by its label's relation. + const auto labels = QAccessible::queryAccessibleInterface(&channel)->relations(QAccessible::Label); + QCOMPARE(labels.size(), 1); + QCOMPARE(labels.first().first->text(QAccessible::Name), QString("Channel")); + QCOMPARE(name(histogram), QString("Original RGB histogram")); + QCOMPARE(shown.sheet->findChild<QCheckBox *>()->text(), QString("Preview")); + QVERIFY(shown.sheet->findChild<QCheckBox *>()->isChecked()); + QVERIFY(shown.button("OK").isDefault() && !shown.button("Cancel").isDefault() && !shown.button("Reset").isDefault()); + QLabel &caption = shown.label(QStringLiteral("Original pixels · alpha-weighted histogram")); + QCOMPARE(caption.foregroundRole(), QPalette::PlaceholderText); + QCOMPARE(caption.font().pixelSize(), 10); + // Levels over a selection says so; closed, no bars. + shown.session.cancelLevels(); + QCOMPARE(histogram.grab().toImage().pixelColor(98, 0), shaded(shown.sheet->palette().color(QPalette::Window))); + QPainterPath first; + first.addRect(0, 0, 1, 1); + shown.session.applySelection(first, SelectionMode::replace, QStringLiteral("Select")); + shown.session.beginLevels(); + QCOMPARE(caption.text(), QString("Original pixels · selection and alpha-weighted histogram")); +} + +void LevelsSheetTests::theSheetIsLaidOutAsSwifts() +{ + Sheet shown; + QVERIFY(shown.show()); + // Padding 24, width 440, rows 16 apart. + QCOMPARE(shown.sheet->width(), 440); + const QWidget &picker = *shown.sheet->findChild<QComboBox *>()->parentWidget(); + QCOMPARE(picker.geometry().topLeft(), QPoint(24, 24)); + QCOMPARE(picker.width(), 180); + QCOMPARE(shown.sheet->findChild<QComboBox *>()->geometry().right(), 179); + QWidget &histogram = shown.child<QWidget>("levelsHistogram"); + QCOMPARE(histogram.geometry(), QRect(24, picker.geometry().bottom() + 17, 392, 150)); + const QWidget &input = shown.child<QWidget>("levelsInputHandles"), &output = shown.child<QWidget>("levelsOutputHandles"); + QCOMPARE(input.geometry(), QRect(24, histogram.y() + 150, 392, 20)); + const QWidget &black = *shown.child<QLineEdit>("levelsInputblack").parentWidget(); + QCOMPARE(black.geometry().topLeft(), QPoint(24, input.geometry().bottom() + 17)); + QCOMPARE(shown.child<QLineEdit>("levelsInputblack").width(), 80); + // A row's last entry ends at the right padding. + QCOMPARE(shown.child<QLineEdit>("levelsInputwhite").parentWidget()->geometry().right(), 415); + QCOMPARE(shown.child<QLineEdit>("levelsOutputwhite").parentWidget()->geometry().right(), 415); + // Samples and Auto pack to the left; Reset sits right. + QVERIFY(shown.button("White").geometry().right() < 400 && shown.button("Color + neutral midtones").geometry().right() < 400); + QCOMPARE(shown.button("Black").width(), shown.button("Black").sizeHint().width()); + QCOMPARE(shown.button("Reset").geometry().right(), 415); + QCOMPARE(shown.button("Cancel").x(), 24); + // Black to white across, fourteen points tall. + QWidget *gradient = shown.sheet->childAt(220, output.y() - 7); + QCOMPARE(gradient->geometry(), QRect(24, black.geometry().bottom() + 17, 392, 14)); + QCOMPARE(output.geometry(), QRect(24, gradient->y() + 14, 392, 20)); + const QImage ramp = gradient->grab().toImage(); + QCOMPARE(ramp.pixelColor(0, 7), QColor(Qt::black)); + QVERIFY(ramp.pixelColor(391, 7).red() >= 254 && std::abs(ramp.pixelColor(196, 7).red() - 128) <= 2); + // Auto's caption sits six points over its buttons. + const QLabel &autoTitle = shown.label(QStringLiteral("Auto")); + QCOMPARE(shown.button("Contrast").y() - autoTitle.geometry().bottom() - 1, 6); + QCOMPARE(shown.label(QStringLiteral("Sample")).font().pixelSize(), 10); + // Swift's divider, and the small spinner beside OK. + QFrame *divider = nullptr; + for (QFrame *frame : shown.sheet->findChildren<QFrame *>()) { + if (frame->frameShape() == QFrame::HLine) + divider = frame; + } + QVERIFY(divider && divider->foregroundRole() == QPalette::Mid); + const QProgressBar &spinner = *shown.sheet->findChild<QProgressBar *>(); + QVERIFY(spinner.isHidden() && spinner.minimum() == 0 && spinner.maximum() == 0 && !spinner.isTextVisible()); + QCOMPARE(spinner.width(), 40); + QCOMPARE(shown.button("OK").geometry().right(), 415); +} + +void LevelsSheetTests::theHistogramDrawsTheChannelsBins() +{ + Sheet shown; + QVERIFY(shown.show()); + QWidget &histogram = shown.child<QWidget>("levelsHistogram"); + const QColor background = shaded(shown.sheet->palette().color(QPalette::Window)), gray(142, 142, 147); + // RGB takes a third of each channel: 96 a twelfth. + QImage drawn = histogram.grab().toImage(); + QCOMPARE(drawn.pixelColor(98, 0), gray); + QCOMPARE(drawn.pixelColor(147, 126), gray); + QCOMPARE(drawn.pixelColor(147, 124), background); + QCOMPARE(drawn.pixelColor(200, 149), background); + // Neighbouring bars join; their edges are smooth. + QCOMPARE(drawn.pixelColor(99, 149), gray); + QVERIFY(drawn.pixelColor(99, 0) != gray && drawn.pixelColor(99, 0) != background); + // Red: 64 twice, 192 once, in SwiftUI's red. + QComboBox &channel = *shown.sheet->findChild<QComboBox *>(); + channel.setFocus(); + QTest::keyClick(&channel, Qt::Key_Down); + QCOMPARE(shown.session.levels().value().settings.channel, LevelsChannel::red); + QCOMPARE(name(histogram), QString("Original Red histogram")); + drawn = histogram.grab().toImage(); + QCOMPARE(drawn.pixelColor(98, 0), QColor(255, 66, 69)); + QCOMPARE(drawn.pixelColor(294, 75), QColor(255, 66, 69)); + QCOMPARE(drawn.pixelColor(294, 74), background); + // A new channel repaints the whole graph. + Repaints repaints(histogram); + QTest::keyClick(&channel, Qt::Key_Down); + QTRY_VERIFY(repaints.painted.contains(histogram.rect())); + QCOMPARE(histogram.grab().toImage().pixelColor(147, 75), QColor(48, 209, 88)); + QTest::keyClick(&channel, Qt::Key_Down); + QCOMPARE(histogram.grab().toImage().pixelColor(98, 0), QColor(0, 145, 255)); + // The session's channel shows in the picker. + LevelsSettings settings = shown.session.levels().value().settings; + settings.channel = LevelsChannel::green; + shown.session.updateLevels(settings, true); + QCOMPARE(channel.currentText(), QString("Green")); +} + +void LevelsSheetTests::entriesApplyAsSwiftsBindings() +{ + Sheet shown; + QVERIFY(shown.show()); + QLineEdit &black = shown.child<QLineEdit>("levelsInputblack"), &gamma = shown.child<QLineEdit>("levelsGamma"); + QLineEdit &white = shown.child<QLineEdit>("levelsInputwhite"), &outputWhite = shown.child<QLineEdit>("levelsOutputwhite"); + type(black, "30"); + QCOMPARE(shown.range().black, 0.0); + QTest::keyClick(&black, Qt::Key_Return); + QCOMPARE(shown.range().black, 30.0); + // Clamped as the binding stores it, and shown so. + type(white, "300"); + QTest::keyClick(&white, Qt::Key_Return); + QCOMPARE(shown.range().white, 255.0); + QCOMPARE(white.text(), QString("255")); + type(gamma, "2.5"); + gamma.clearFocus(); + QCOMPARE(shown.range().gamma, 2.5); + QCOMPARE(gamma.text(), QString("2.50")); + // Text that is no number goes back to the level. + type(white, "abc"); + white.clearFocus(); + QCOMPARE(white.text(), QString("255")); + QCOMPARE(shown.range().white, 255.0); + // Leaving untouched writes nothing back, not even rounded. + shown.set(LevelRange{0, std::log(0.75) / std::log(0.5), 255, 0, 255}); + QCOMPARE(gamma.text(), QString("0.42")); + gamma.setFocus(); + gamma.setCursorPosition(0); + shown.set(LevelRange{0, std::log(0.75) / std::log(0.5), 255, 0, 200}); + QCOMPARE(gamma.cursorPosition(), 0); + gamma.clearFocus(); + QCOMPARE(shown.range().gamma, std::log(0.75) / std::log(0.5)); + // Typing waits out a change from elsewhere, then applies. + type(outputWhite, "12"); + shown.set(LevelRange{40, 2.5, 255, 0, 255}); + QCOMPARE(outputWhite.text(), QString("12")); + QCOMPARE(black.text(), QString("40")); + QTest::keyClick(&outputWhite, Qt::Key_Return); + QCOMPARE(shown.range(), (LevelRange{40, 2.5, 255, 0, 12})); + // White stays above black. + type(black, "250"); + QTest::keyClick(&black, Qt::Key_Return); + type(white, "100"); + QTest::keyClick(&white, Qt::Key_Return); + QCOMPARE(shown.range().white, 251.0); + QCOMPARE(white.text(), QString("251")); + // Typing left as Levels closes elsewhere applies nowhere. + type(black, "77"); + shown.session.cancelLevels(); + black.clearFocus(); + QVERIFY(!shown.session.levels()); +} + +void LevelsSheetTests::entriesReadAndWriteTheLocale() +{ + // Swift's number format follows the user's locale. + Sheet shown; + shown.sheet->setLocale(QLocale(QLocale::German, QLocale::Germany)); + QVERIFY(shown.show()); + shown.set(LevelRange{0, 2.5, 255, 0, 255}); + QLineEdit &gamma = shown.child<QLineEdit>("levelsGamma"); + QCOMPARE(gamma.text(), QString("2,50")); + type(gamma, "1,25"); + QTest::keyClick(&gamma, Qt::Key_Return); + QCOMPARE(shown.range().gamma, 1.25); + QCOMPARE(gamma.text(), QString("1,25")); +} + +void LevelsSheetTests::eyedroppersToggleAndShowTheirNote() +{ + Sheet shown; + QVERIFY(shown.show()); + QPushButton &black = shown.button("Black"), &gray = shown.button("Gray"), &white = shown.button("White"); + QLabel *note = nullptr; + for (QLabel *label : shown.sheet->findChildren<QLabel *>()) { + if (label->wordWrap()) + note = label; + } + QVERIFY(note && note->isHidden()); + const QColor accent = shown.sheet->palette().color(QPalette::Highlight), secondary = shown.sheet->palette().color(QPalette::PlaceholderText); + QCOMPARE(ink(black.icon()).rgb(), secondary.rgb()); + const int revision = shown.session.brushRevision(); + black.click(); + QCOMPARE(shown.session.levels().value().sampleMode, std::optional(LevelsSample::black)); + QCOMPARE(shown.session.brushRevision(), revision + 1); + QVERIFY(black.isChecked() && !gray.isChecked() && !white.isChecked()); + QVERIFY(!note->isHidden()); + QCOMPARE(note->text(), QString("Click the original layer to set black. Click the eyedropper again to stop.")); + QCOMPARE(note->foregroundRole(), QPalette::PlaceholderText); + // The rail's glyph at 14 points, in the accent. + QCOMPARE(black.icon().availableSizes(), QList<QSize>{QSize(14, 14)}); + QImage glyph(14, 14, QImage::Format_ARGB32_Premultiplied); + glyph.fill(Qt::transparent); + { + QPainter painter(&glyph); + painter.setRenderHint(QPainter::Antialiasing); + painter.scale(14.0 / 18, 14.0 / 18); + painter.setPen(QPen(accent, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + EyedropperIcon::paint(painter); + } + QCOMPARE(black.icon().pixmap(QSize(14, 14)).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied), glyph); + QCOMPARE(ink(gray.icon()).rgb(), secondary.rgb()); + // Another takes over; a second click stops. + gray.click(); + white.click(); + QCOMPARE(shown.session.levels().value().sampleMode, std::optional(LevelsSample::white)); + QVERIFY(!black.isChecked() && !gray.isChecked() && white.isChecked()); + QCOMPARE(note->text(), QString("Click the original layer to set white. Click the eyedropper again to stop.")); + white.click(); + QVERIFY(!shown.session.levels().value().sampleMode); + QVERIFY(!white.isChecked() && note->isHidden()); +} + +void LevelsSheetTests::autoResetAndPreview() +{ + Sheet shown; + QVERIFY(shown.show()); + const LevelsHistogram histogram = shown.session.levels().value().histogram; + // Each button asks its own mode and drops the eyedropper. + shown.session.setLevelsSampleMode(LevelsSample::gray); + const std::pair<const char *, LevelsAuto> autos[] = { + {"Contrast", LevelsAuto::contrast}, {"Color", LevelsAuto::color}, {"Color + neutral midtones", LevelsAuto::neutral}}; + std::vector<LevelsSettings> made; + for (const auto &[text, mode] : autos) { + shown.button(text).click(); + QCOMPARE(shown.session.levels().value().settings, settings(mode, histogram)); + QVERIFY(!shown.session.levels().value().sampleMode); + made.push_back(shown.session.levels().value().settings); + } + QVERIFY(made[0] != made[1] && made[1] != made[2] && made[0] != made[2]); + // Preview by the box, and Alt+P, keeping the settings. + QCheckBox &preview = *shown.sheet->findChild<QCheckBox *>(); + preview.click(); + QVERIFY(!shown.session.levels().value().preview); + QCOMPARE(shown.session.levels().value().settings, made[2]); + QTest::keyClick(shown.sheet.get(), Qt::Key_P, Qt::AltModifier); + QTRY_VERIFY(shown.session.levels().value().preview); + QVERIFY(preview.isChecked()); + // Reset: no eyedropper, identity, the preview kept. + shown.session.setLevelsSampleMode(LevelsSample::black); + shown.button("Reset").click(); + QVERIFY(!shown.session.levels().value().sampleMode); + QCOMPARE(shown.session.levels().value().settings, LevelsSettings()); + QVERIFY(shown.session.levels().value().preview); +} + +void LevelsSheetTests::cancelOkAndTheCommitsRest() +{ + { + Sheet shown; + QVERIFY(shown.show()); + shown.button("Cancel").click(); + QVERIFY(!shown.session.levels()); + } + Sheet shown; + QVERIFY(shown.show()); + shown.set(LevelRange{0, 1, 128, 0, 255}); + const int steps = shown.session.history.undoCount(); + shown.button("OK").click(); + // Committing: the spinner shows and every control rests. + QVERIFY(shown.session.levels().value().committing); + QVERIFY(!shown.sheet->isEnabled()); + QProgressBar &spinner = *shown.sheet->findChild<QProgressBar *>(); + QVERIFY(!spinner.isHidden()); + QVERIFY(spinner.x() > shown.button("Cancel").geometry().right() + 150 && spinner.geometry().right() < shown.button("OK").x()); + QTRY_VERIFY(!shown.session.levels()); + QCOMPARE(shown.session.history.undoCount(), steps + 1); + QCOMPARE(shown.session.history.undoName(), QString("Levels")); + QCOMPARE(bytes(shown.session.activeLayer().value().asset.value().image()), + (std::vector<uchar>{128, 128, 128, 255, 128, 128, 128, 255, 255, 191, 0, 255, 129, 129, 129, 255})); +} + +void LevelsSheetTests::aNewThemeRecoloursTheEyedroppers() +{ + Sheet shown; + QVERIFY(shown.show()); + shown.session.setLevelsSampleMode(LevelsSample::gray); + QPalette theme = shown.sheet->palette(); + theme.setColor(QPalette::Highlight, QColor(200, 30, 40)); + theme.setColor(QPalette::PlaceholderText, QColor(20, 160, 60)); + shown.sheet->setPalette(theme); + QCOMPARE(ink(shown.button("Gray").icon()).rgb(), QColor(200, 30, 40).rgb()); + QCOMPARE(ink(shown.button("White").icon()).rgb(), QColor(20, 160, 60).rgb()); +} + +QTEST_MAIN(LevelsSheetTests) +#include "LevelsSheetTests.moc" diff --git a/tests/LevelsTests.cpp b/tests/LevelsTests.cpp new file mode 100644 index 0000000..fbab9ac --- /dev/null +++ b/tests/LevelsTests.cpp @@ -0,0 +1,216 @@ +#include "LevelsFixtures.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include <QTemporaryDir> +#include <cmath> +#include <numeric> + +// Swift's LevelsTests. +class LevelsTests : public QObject { + Q_OBJECT +private slots: + void identityAndChannelSelectionAreExactNoOps(); + void inputClippingGammaOutputInversionAndAlpha(); + void channelsCoexistAndUseDocumentedOrder(); + void histogramExcludesTransparencyAndWeightsSelection(); + void selectionPreviewCancelCommitUndoAndPersistence(); + void stalePreviewCannotReturnAfterOffOrReopen(); + void histogramDisplayKeepsDistributionVisibleBesideClippingSpikes(); + void autoAlgorithmsAndEyedropperCalibration(); + void eyedropperSamplesOriginalAndRejectsTransparentPixels(); +}; + +void LevelsTests::identityAndChannelSelectionAreExactNoOps() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + const std::optional<CanvasDocument> before = session->document(); + const QImage source = session->activeLayer().value().asset.value().image(); + QCOMPARE(LevelsFilter::run(job(source)).cacheKey(), source.cacheKey()); + const int count = session->history.undoCount(); + session->beginLevels(); + LevelsSettings settings; + settings.channel = LevelsChannel::blue; + session->updateLevels(settings, true); + QVERIFY(!session->canEditLayers() && !session->canUseHistory() && !session->canStartProjectOperation()); + commit(*session); + QVERIFY(session->document() == before && session->history.undoCount() == count && !session->levels()); +} + +void LevelsTests::inputClippingGammaOutputInversionAndAlpha() +{ + const QImage source = image(ramp); + LevelsSettings settings; + settings.setCurrent(LevelRange{64, 1, 128, 0, 255}); + const std::vector<uchar> clipped = bytes(LevelsFilter::run(job(source, settings))); + QCOMPARE(std::vector<uchar>(clipped.begin(), clipped.begin() + 12), (std::vector<uchar>{0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255})); + settings.setCurrent(LevelRange{0, 2, 255, 0, 255}); + const std::vector<uchar> brightened = bytes(LevelsFilter::run(job(source, settings))); + QVERIFY(std::abs(int(brightened[4]) - 128) <= 1); + QVERIFY(std::abs(int(brightened[8]) - 181) <= 1); + QVERIFY(brightened[19] == 128 && brightened[23] == 0); + QVERIFY(brightened[16] <= 128 && brightened[17] <= 128); + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + const std::vector<uchar> inverted = bytes(LevelsFilter::run(job(source, settings))); + QVERIFY(inverted[0] == 255 && inverted[12] == 0); + // A soft edge inverts as its colour, alpha kept. + QVERIFY(inverted[16] == 64 && inverted[17] == 96 && inverted[18] == 128 && inverted[19] == 128); +} + +void LevelsTests::channelsCoexistAndUseDocumentedOrder() +{ + LevelsSettings settings; + settings.channel = LevelsChannel::red; + settings.setCurrent(LevelRange{0, 2, 255, 0, 255}); + settings.channel = LevelsChannel::rgb; + settings.setCurrent(LevelRange{40, 1, 210, 0, 255}); + const std::vector<uchar> result = bytes(LevelsFilter::run(job(image({{64, 64, 64, 255}}), settings))); + const double expectedRed = LevelRange{40, 1, 210, 0, 255}.apply(LevelRange{0, 2, 255, 0, 255}.apply(64.0 / 255)); + QVERIFY(std::abs(result[0] - expectedRed * 255) <= 1); + QVERIFY(result[1] == result[2] && result[0] > result[1]); + const LevelRange invalid = LevelRange{300, NAN, -1, -100, 400}.normalized(); + QVERIFY(invalid.black < invalid.white && invalid.gamma == 1 && invalid.outputBlack == 0 && invalid.outputWhite == 255); +} + +void LevelsTests::histogramExcludesTransparencyAndWeightsSelection() +{ + const QImage source = image({{255, 0, 0, 255}, {0, 128, 0, 128}, {0, 0, 0, 0}}); + const LevelsHistogram bins = LevelsFilter::histogram(job(source)); + QVERIFY(bins[1][255] == 1 && std::abs(bins[2][255] - 128.0 / 255) < 0.00001); + const double total = std::accumulate(bins[0].begin(), bins[0].end(), 0.0); + QVERIFY(std::abs(total - (1.0 + 128.0 / 255.0)) < 0.00001); + QPainterPath first; + first.addRect(0, 0, 1, 1); + const SelectionClip selection = DocumentSelection{first, false}.clip(QSizeF(3, 1)); + const LevelsHistogram selected = LevelsFilter::histogram(job(source, {}, selection)); + QVERIFY(selected[1][255] == 1 && selected[2][255] == 0); + const LevelsHistogram empty = LevelsFilter::histogram(job(source, {}, SelectionClip{QRectF(), std::nullopt})); + for (const std::array<double, 256> &channel : empty) + QVERIFY(std::all_of(channel.begin(), channel.end(), [](double bin) { return bin == 0; })); +} + +void LevelsTests::selectionPreviewCancelCommitUndoAndPersistence() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + QPainterPath two; + two.addRect(0, 0, 2, 1); + session->applySelection(two, SelectionMode::replace, QStringLiteral("Select")); + const std::optional<CanvasDocument> before = session->document(); + const int count = session->history.undoCount(); + session->beginLevels(); + QTRY_VERIFY(session->levels().value().histogramReady); + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 1, 255, 255, 0}); + session->updateLevels(settings, true); + QTRY_VERIFY(session->levels().value().preparedPreview); + const std::vector<uchar> preview = bytes(session->levels().value().preparedPreview.value()); + QVERIFY(preview[0] == 255 && preview[8] == 128); + QVERIFY(session->document() == before); + session->updateLevels(settings, false); + QVERIFY(!session->levels().value().previewImage(session->levels().value().layerID)); + session->cancelLevels(); + QVERIFY(session->document() == before && session->history.undoCount() == count); + session->beginLevels(); + session->updateLevels(settings, false); + commit(*session); + QVERIFY(session->history.undoCount() == count + 1 && session->history.undoName() == QString("Levels")); + const QImage committed = session->activeLayer().value().asset.value().image(); + QCOMPARE(bytes(committed), preview); + session->undo(); + QVERIFY(session->document() == before); + session->redo(); + QCOMPARE(session->activeLayer().value().asset.value().image().cacheKey(), committed.cacheKey()); + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("Levels.comp")); + ProjectStore::save(session->projectSnapshot().value(), path); + QCOMPARE(bytes(ImageExporter::render(ProjectStore::load(path)).image), preview); +} + +void LevelsTests::stalePreviewCannotReturnAfterOffOrReopen() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + LevelsSettings settings; + settings.setCurrent(LevelRange{0, 2, 255, 0, 255}); + session->updateLevels(settings, true); + const QUuid old = session->levels().value().id; + session->updateLevels(settings, false); + // The run turned off finishes unseen. + QTest::qWait(100); + QVERIFY(!session->levels().value().preparedPreview); + session->updateLevels(settings, true); + session->cancelLevels(); + session->beginLevels(); + QTest::qWait(100); + QVERIFY(session->levels().value().id != old && !session->levels().value().preparedPreview); + session->cancelLevels(); +} + +void LevelsTests::histogramDisplayKeepsDistributionVisibleBesideClippingSpikes() +{ + std::array<double, 256> bins; + bins.fill(100); + bins[255] = 100'000; + QCOMPARE(LevelsHistogramDisplay::scale(bins), 400.0); + bins[0] = 200'000; + QCOMPARE(LevelsHistogramDisplay::scale(bins), 400.0); + // A lone spike away from the ends keeps it readable. + bins[128] = 500'000; + QCOMPARE(LevelsHistogramDisplay::scale(bins), 400.0); + QCOMPARE(bins[128], 500'000.0); + std::array<double, 256> flat; + flat.fill(100); + QCOMPARE(LevelsHistogramDisplay::scale(flat), 100.0); + std::array<double, 256> sparse{}; + QCOMPARE(LevelsHistogramDisplay::scale(sparse), 0.0); + sparse[255] = 50; + QCOMPARE(LevelsHistogramDisplay::scale(sparse), 50.0); + sparse[0] = 100; + QCOMPARE(LevelsHistogramDisplay::scale(sparse), 100.0); + sparse[128] = 200; + QCOMPARE(LevelsHistogramDisplay::scale(sparse), 200.0); +} + +void LevelsTests::autoAlgorithmsAndEyedropperCalibration() +{ + LevelsHistogram bins{}; + for (size_t channel = 1; channel <= 3; ++channel) { + bins[channel][20 * channel] = 100; + bins[channel][200 + channel * 10] = 100; + } + const LevelsSettings linked = settings(LevelsAuto::contrast, bins); + QVERIFY(linked.ranges[0].black == 20 && linked.ranges[0].white == 230); + const LevelsSettings colour = settings(LevelsAuto::color, bins); + QVERIFY(colour.ranges[1].black == 20 && colour.ranges[3].black == 60); + QVERIFY(colour.ranges[0] == LevelRange()); + QCOMPARE(settings(LevelsAuto::neutral, bins).ranges[1].gamma, 1.0); + for (const LevelsAuto mode : allLevelsAutos) + QVERIFY(settings(mode, LevelsHistogram{}).isIdentity()); + const std::array<double, 3> rgb{0.25, 0.4, 0.6}; + const std::array<LevelsChannel, 3> channels{LevelsChannel::red, LevelsChannel::green, LevelsChannel::blue}; + for (const LevelsSample mode : allLevelsSamples) { + const LevelsSettings sampled = LevelsSettings().sampling(rgb, mode); + const double target = mode == LevelsSample::black ? 0 : mode == LevelsSample::white ? 1 : 0.5; + for (size_t index = 0; index < 3; ++index) + QVERIFY(std::abs(sampled.apply(rgb[index], channels[index]) - target) < 0.0001); + } +} + +void LevelsTests::eyedropperSamplesOriginalAndRejectsTransparentPixels() +{ + const std::unique_ptr<EditorSession> session = rampSession(); + session->beginLevels(); + session->setLevelsSampleMode(LevelsSample::gray); + session->sampleLevels(QPointF(2.5, 0.5)); + const LevelsSettings first = session->levels().value().settings; + QVERIFY(!first.isIdentity()); + session->sampleLevels(QPointF(2.5, 0.5)); + QVERIFY(session->levels().value().settings == first); + session->sampleLevels(QPointF(5.5, 0.5)); + QVERIFY(session->levels().value().settings == first); + session->sampleLevels(QPointF(-1, 0.5)); + QVERIFY(session->levels().value().settings == first); + session->cancelLevels(); +} + +QTEST_GUILESS_MAIN(LevelsTests) +#include "LevelsTests.moc" diff --git a/tests/LiveMaskBakeTests.cpp b/tests/LiveMaskBakeTests.cpp new file mode 100644 index 0000000..c832a28 --- /dev/null +++ b/tests/LiveMaskBakeTests.cpp @@ -0,0 +1,232 @@ +#include "Document/LiveLayerMask.h" +#include "Document/PixelAdjust.h" +#include "SessionFixtures.h" +#include <QtTest> + +// The baker: a live mask pressed into a layer's pixels. +class LiveMaskBakeTests : public QObject { + Q_OBJECT +private slots: + void aBakeMapsTheSourceIntoTheTargetsOwnPixels(); + void aBakeKeepsTheSourcesOwnMaskInAnyShape(); + void aStretchedSourceBakesThroughItsOwnGrid(); + void aSourceWithoutPixelsHidesTheTarget(); + void aSnapshotThatRepeatsAnIdIsRefused(); + void thumbnailsShrinkToNinetySixAndNeverGrow(); +}; + +void LiveMaskBakeTests::aBakeMapsTheSourceIntoTheTargetsOwnPixels() +{ + // The source at the origin; the target one pixel right. + QImage alpha(2, 2, QImage::Format_RGBA8888_Premultiplied); + const int alphas[] = {255, 0, 128, 255}; + for (int index = 0; index < 4; ++index) + alpha.setPixelColor(index % 2, index / 2, QColor(255, 0, 0, alphas[index])); + QImage opaque(2, 2, QImage::Format_RGBA8888_Premultiplied); + opaque.fill(Qt::blue); + EditorSession session; + session.createDocument(4, 2); + session.insert(ImportedImage(alpha, QImage(), "Source"), QPointF(1, 1)); + const QUuid source = session.activeLayerID().value(); + session.insert(ImportedImage(opaque, QImage(), "Target"), QPointF(2, 1)); + const QUuid target = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &layer : snapshot.manifest.layers) + layer.transform.sampling = LayerSampling::nearest; + // The target's own opacity and mask stay out. + record(snapshot, target).opacity = 0.5; + record(snapshot, source).opacity = 0.5; + QImage gray(2, 2, QImage::Format_Grayscale8); + gray.fill(128); + setMask(snapshot, target, LayerMask::assetFrom(gray)); + }); + QVERIFY(session.linkMask(source, target)); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value(); + QCOMPARE(baked.size(), QSize(2, 2)); + QCOMPARE(baked.name, QString("Target")); + QCOMPARE(baked.thumbnail.size(), QSize(2, 2)); + // Its left column meets the source's right: 0 and 128. + const QImage pixels = baked.image(); + QCOMPARE(pixels.pixelColor(0, 0).alpha(), 0); + QVERIFY2(std::abs(pixels.pixelColor(0, 1).alpha() - 128) <= 1, qPrintable(QString::number(pixels.pixelColor(0, 1).alpha()))); + QCOMPARE(pixels.pixelColor(0, 1).blue(), 255); + QCOMPARE(pixels.pixelColor(1, 0).alpha(), 0); + QCOMPARE(pixels.pixelColor(1, 1).alpha(), 0); + // A source clipped in turn: both alphas, each in place. + QImage half(2, 2, QImage::Format_RGBA8888_Premultiplied); + half.fill(QColor(0, 255, 0, 128)); + session.selectLayer(std::nullopt); + session.insert(ImportedImage(half, QImage(), "Second"), QPointF(1, 1)); + const QUuid secondSource = session.activeLayerID().value(); + QVERIFY(session.linkMask(secondSource, source)); + QTest::failOnWarning(QRegularExpression(".*")); + const QImage chained = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value().image(); + QCOMPARE(chained.pixelColor(0, 0).alpha(), 0); + QVERIFY2(std::abs(chained.pixelColor(0, 1).alpha() - 64) <= 1, qPrintable(QString::number(chained.pixelColor(0, 1).alpha()))); + QCOMPARE(chained.pixelColor(1, 1).alpha(), 0); + // No pixels, or no such layer: nothing to bake. + QVERIFY(!LiveMaskBaker::bake(snapshot, QUuid::createUuid()).has_value()); + ProjectSnapshot bare = snapshot; + bare.images.erase(target); + QVERIFY(!LiveMaskBaker::bake(bare, target).has_value()); +} + +void LiveMaskBakeTests::aBakeKeepsTheSourcesOwnMaskInAnyShape() +{ + // Three by two: a square would hide swapped sides. + QImage red(3, 2, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + QImage blue(3, 2, QImage::Format_RGBA8888_Premultiplied); + blue.fill(Qt::blue); + QImage gray(3, 2, QImage::Format_Grayscale8); + const uchar values[] = {255, 0, 128, 0, 255, 64}; + for (int index = 0; index < 6; ++index) + gray.scanLine(index / 3)[index % 3] = values[index]; + EditorSession session; + session.createDocument(3, 2); + session.insert(ImportedImage(red, QImage(), "Source")); + const QUuid source = session.activeLayerID().value(); + session.insert(ImportedImage(blue, QImage(), "Target")); + const QUuid target = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &layer : snapshot.manifest.layers) + layer.transform.sampling = LayerSampling::nearest; + setMask(snapshot, source, LayerMask::assetFrom(gray)); + }); + QVERIFY(session.linkMask(source, target)); + const ImportedImage baked = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value(); + QCOMPARE(baked.size(), QSize(3, 2)); + QCOMPARE(baked.thumbnail.size(), QSize(3, 2)); + // The source's own mask hides what it hides. + const QImage pixels = baked.image(); + for (int index = 0; index < 6; ++index) { + const QColor pixel = pixels.pixelColor(index % 3, index / 3); + QVERIFY2(std::abs(pixel.alpha() - values[index]) <= 1, qPrintable(QString("%1: %2").arg(index).arg(pixel.alpha()))); + if (values[index] == 255) + QCOMPARE(pixel, QColor(Qt::blue)); + } + // Moved a pixel right, the mask fills the source's grid. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).maskPlacement = LayerTransform{.origin = {1, 0}, .size = {3, 2}}; }); + const QImage moved = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value().image(); + const int shifted[] = {0, 255, 0, 0, 0, 255}; + for (int index = 0; index < 6; ++index) { + const int alpha = moved.pixelColor(index % 3, index / 3).alpha(); + QVERIFY2(std::abs(alpha - shifted[index]) <= 1, qPrintable(QString("%1: %2").arg(index).arg(alpha))); + } +} + +void LiveMaskBakeTests::aStretchedSourceBakesThroughItsOwnGrid() +{ + // Three by two pixels stretched over six by four. + QImage red(3, 2, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + QImage blue(6, 4, QImage::Format_RGBA8888_Premultiplied); + blue.fill(Qt::blue); + QImage gray(3, 2, QImage::Format_Grayscale8); + const uchar values[] = {255, 0, 128, 0, 255, 64}; + for (int index = 0; index < 6; ++index) + gray.scanLine(index / 3)[index % 3] = values[index]; + EditorSession session; + session.createDocument(6, 4); + session.insert(ImportedImage(red, QImage(), "Source")); + const QUuid source = session.activeLayerID().value(); + session.insert(ImportedImage(blue, QImage(), "Target")); + const QUuid target = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &layer : snapshot.manifest.layers) + layer.transform.sampling = LayerSampling::nearest; + record(snapshot, source).transform.origin = {0, 0}; + record(snapshot, source).transform.size = {6, 4}; + // The mask sits one source pixel to the right. + setMask(snapshot, source, LayerMask::assetFrom(gray)); + record(snapshot, source).maskPlacement = LayerTransform{.origin = {2, 0}, .size = {6, 4}}; + }); + QVERIFY(session.linkMask(source, target)); + ProjectSnapshot snapshot = session.projectSnapshot().value(); + // An older project states no opacity: that means one. + record(snapshot, source).opacity = std::nullopt; + const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value(); + QCOMPARE(baked.size(), QSize(6, 4)); + const QImage pixels = baked.image(); + const int grid[] = {0, 255, 0, 0, 0, 255}; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 6; ++x) { + const int alpha = pixels.pixelColor(x, y).alpha(); + QVERIFY2(alpha == grid[y / 2 * 3 + x / 2], qPrintable(QString("%1,%2: %3").arg(x).arg(y).arg(alpha))); + } + } +} + +void LiveMaskBakeTests::aSourceWithoutPixelsHidesTheTarget() +{ + EditorSession session; + session.createDocument(2, 2); + session.addBlankLayer(); + const QUuid blank = session.activeLayerID().value(); + QImage blue(2, 2, QImage::Format_RGBA8888_Premultiplied); + blue.fill(Qt::blue); + session.insert(ImportedImage(blue, QImage(), "Target")); + const QUuid target = session.activeLayerID().value(); + QVERIFY(session.linkMask(blank, target)); + QTest::failOnWarning(QRegularExpression(".*")); + // A blank source covers nothing: all of the target goes. + ProjectSnapshot snapshot = session.projectSnapshot().value(); + QVERIFY(!snapshot.images.contains(blank)); + const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value(); + QCOMPARE(baked.size(), QSize(2, 2)); + for (int index = 0; index < 4; ++index) + QCOMPARE(baked.image().pixelColor(index % 2, index / 2).alpha(), 0); + // So does a link to a layer the snapshot lacks, + const QUuid stranger = QUuid::createUuid(); + record(snapshot, target).maskSourceID = stranger; + const QImage orphan = LiveMaskBaker::bake(snapshot, target).value().image(); + for (int index = 0; index < 4; ++index) + QCOMPARE(orphan.pixelColor(index % 2, index / 2).alpha(), 0); + // even when stray pixels carry that layer's id. + snapshot.images.insert({stranger, ImportedImage(blue, QImage(), "Stray")}); + const QImage stray = LiveMaskBaker::bake(snapshot, target).value().image(); + for (int index = 0; index < 4; ++index) + QCOMPARE(stray.pixelColor(index % 2, index / 2).alpha(), 0); + // Pixels without a record are no layer to bake. + QVERIFY(!LiveMaskBaker::bake(snapshot, stranger).has_value()); +} + +void LiveMaskBakeTests::aSnapshotThatRepeatsAnIdIsRefused() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Only")); + const QUuid only = session.activeLayerID().value(); + ProjectSnapshot twice = session.projectSnapshot().value(); + twice.manifest.layers.push_back(twice.manifest.layers.front()); + // Swift's dictionary of unique keys traps here. + QVERIFY_EXCEPTION_THROWN(LiveMaskBaker::bake(twice, only), std::logic_error); + // Nothing to bake is settled first, as in Swift. + QVERIFY(!LiveMaskBaker::bake(twice, QUuid::createUuid()).has_value()); + ProjectSnapshot bare = twice; + bare.images.erase(only); + QVERIFY(!LiveMaskBaker::bake(bare, only).has_value()); +} + +void LiveMaskBakeTests::thumbnailsShrinkToNinetySixAndNeverGrow() +{ + const auto size = [](int width, int height) { return PixelAdjust::thumbnail(QImage(width, height, QImage::Format_RGBA8888_Premultiplied)).size(); }; + QCOMPARE(size(960, 480), QSize(96, 48)); + QCOMPARE(size(480, 960), QSize(48, 96)); + // Truncated, never under one, never enlarged. + QCOMPARE(size(1000, 15), QSize(96, 1)); + QCOMPARE(size(4000, 10), QSize(96, 1)); + QCOMPARE(size(97, 50), QSize(96, 49)); + QCOMPARE(size(100, 58), QSize(96, 55)); + QCOMPARE(size(96, 96), QSize(96, 96)); + QCOMPARE(size(8, 4), QSize(8, 4)); + QImage red(200, 100, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + const QImage small = PixelAdjust::thumbnail(red); + QCOMPARE(small.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(small.pixelColor(95, 47), QColor(Qt::red)); +} + +QTEST_GUILESS_MAIN(LiveMaskBakeTests) +#include "LiveMaskBakeTests.moc" diff --git a/tests/LiveMaskDeleteTests.cpp b/tests/LiveMaskDeleteTests.cpp new file mode 100644 index 0000000..107db3f --- /dev/null +++ b/tests/LiveMaskDeleteTests.cpp @@ -0,0 +1,293 @@ +#include "Rendering/RasterSnapshot.h" +#include "SessionFixtures.h" +#include "SessionRecord.h" +#include <QAbstractButton> +#include <QApplication> +#include <QMessageBox> +#include <QSemaphore> +#include <QThreadPool> +#include <QTimer> +#include <QtTest> + +// Deleting a base others clip to: the alert's three answers. +namespace { +struct Asked { + int times = 0; + QMessageBox::Icon icon = QMessageBox::NoIcon; + QString text; + QString details; + QStringList buttons; +}; + +// Clicks `answer` on the alert once it is up. +void answer(Asked &asked, const QString &reply) +{ + auto *timer = new QTimer(qApp); + QObject::connect(timer, &QTimer::timeout, qApp, [&asked, reply, timer] { + auto *alert = qobject_cast<QMessageBox *>(QApplication::activeModalWidget()); + if (!alert) + return; + timer->deleteLater(); + asked.times += 1; + asked.icon = alert->icon(); + asked.text = alert->text(); + asked.details = alert->informativeText(); + for (const QAbstractButton *button : alert->buttons()) + asked.buttons << button->text(); + for (QAbstractButton *button : alert->buttons()) { + if (button->text() == reply) + button->click(); + } + }); + timer->start(0); +} + +QImage solid(QColor colour, int alpha) +{ + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + colour.setAlpha(alpha); + image.fill(colour); + return image; +} + +// Blue, opaque, clipped to a half-clear red base. +std::unique_ptr<EditorSession> clipped(QUuid &base, QUuid &top) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(2, 2); + session->insert(ImportedImage(solid(Qt::red, 128), QImage(), "Base")); + base = session->activeLayerID().value(); + session->insert(ImportedImage(solid(Qt::blue, 255), QImage(), "Top")); + top = session->activeLayerID().value(); + session->toggleClippingMask(top); + return session; +} + +int alphaOf(const EditorSession &session, QUuid id) +{ + return layerWith(session, id).asset.value().image().pixelColor(0, 0).alpha(); +} +} + +class LiveMaskDeleteTests : public QObject { + Q_OBJECT +private slots: + void aLayerNothingDependsOnGoesUnasked(); + void removingLinksDeletesAndRevealsThePixels(); + void cancelChangesNothing(); + void bakingKeepsTheLookInTheDependentsPixels(); + void theBakeQueuesForAWorkerAndTheCallerGoesOn(); + void severalLayersAreAskedAboutOnce(); + void aBakeThatFailsDeletesNothingAndSaysWhy(); +}; + +void LiveMaskDeleteTests::aLayerNothingDependsOnGoesUnasked() +{ + QUuid base, top; + const std::unique_ptr<EditorSession> session = clipped(base, top); + // The clipped layer supplies no mask: no question. + QVERIFY(!session->deleteWithLiveMaskChoice({top})); + QCOMPARE(int(session->document().value().layers.size()), 2); + session->deleteLayer(top); + QCOMPARE(int(session->document().value().layers.size()), 1); + QCOMPARE(session->history.undoName(), QString("Delete Layer")); + // Without a canvas nothing depends on anything. + EditorSession empty; + QVERIFY(!empty.deleteWithLiveMaskChoice({top})); +} + +void LiveMaskDeleteTests::removingLinksDeletesAndRevealsThePixels() +{ + QUuid base, top; + const std::unique_ptr<EditorSession> session = clipped(base, top); + const ImageIdentity pixels = layerWith(*session, top).asset.value().identity(); + const int count = session->history.undoCount(); + Asked asked; + answer(asked, "Remove Links and Delete"); + session->deleteLayer(base); + QCOMPARE(asked.times, 1); + QCOMPARE(asked.icon, QMessageBox::Warning); + QCOMPARE(asked.text, QString("This layer supplies a live mask")); + QCOMPARE(asked.details, QString("Bake keeps the current masked appearance in the dependent layers’ pixels. " + "Remove Links reveals their pixels. You can undo either choice.")); + QCOMPARE(asked.buttons, (QStringList{"Bake and Delete", "Cancel", "Remove Links and Delete"})); + QCOMPARE(int(session->document().value().layers.size()), 1); + QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt); + QVERIFY(layerWith(*session, top).asset.value().identity() == pixels); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), count + 1); + session->undo(); + QCOMPARE(layerWith(*session, top).maskSourceID, std::optional(base)); +} + +void LiveMaskDeleteTests::cancelChangesNothing() +{ + QUuid base, top; + const std::unique_ptr<EditorSession> session = clipped(base, top); + const std::optional<CanvasDocument> before = session->document(); + const int count = session->history.undoCount(); + Asked asked; + answer(asked, "Cancel"); + session->deleteLayer(base); + QCOMPARE(asked.times, 1); + QCOMPARE(session->document(), before); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(!session->isProjectBusy()); +} + +void LiveMaskDeleteTests::bakingKeepsTheLookInTheDependentsPixels() +{ + QUuid base, top; + const std::unique_ptr<EditorSession> session = clipped(base, top); + const ImageIdentity pixels = layerWith(*session, top).asset.value().identity(); + QCOMPARE(alphaOf(*session, top), 255); + const int count = session->history.undoCount(); + Asked asked; + QStringList seen; + bool freeTooSoon = false; + connect(session.get(), &EditorSession::changed, this, [&] { + seen = described(*session); + // Free again only once the layers are gone. + freeTooSoon = freeTooSoon || (asked.times == 1 && !session->isProjectBusy() && session->document().value().layers.size() == 2); + }); + answer(asked, "Bake and Delete"); + QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion"); + session->deleteLayer(base); + QCOMPARE(asked.times, 1); + QCOMPARE(seen, described(*session)); + QVERIFY(seen.contains("flags 1000")); + // The bake runs on a worker; the project is busy. + QVERIFY(session->isProjectBusy()); + QCOMPARE(int(session->document().value().layers.size()), 2); + QVERIFY(QTest::qWaitFor([&] { return !session->isProjectBusy(); }, 10'000)); + // The last signal sees the deletion and a free project. + QCOMPARE(seen, described(*session)); + QVERIFY(!freeTooSoon); + QVERIFY(seen.contains("flags 0000")); + QCOMPARE(int(session->document().value().layers.size()), 1); + QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt); + QVERIFY(layerWith(*session, top).asset.value().identity() != pixels); + QCOMPARE(layerWith(*session, top).asset.value().name, QString("Top")); + // Half the base's alpha is in the pixels now. + QVERIFY2(std::abs(alphaOf(*session, top) - 128) <= 1, qPrintable(QString::number(alphaOf(*session, top)))); + QCOMPARE(layerWith(*session, top).asset.value().image().pixelColor(0, 0).blue(), 255); + QCOMPARE(session->brushError(), std::nullopt); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Delete Layer")); + // Undo brings back the base, the link, the old pixels. + session->undo(); + QCOMPARE(int(session->document().value().layers.size()), 2); + QCOMPARE(layerWith(*session, top).maskSourceID, std::optional(base)); + QVERIFY(layerWith(*session, top).asset.value().identity() == pixels); +} + +void LiveMaskDeleteTests::theBakeQueuesForAWorkerAndTheCallerGoesOn() +{ + // A painted base: its pixels flatten on first use. + const auto raster = std::make_shared<const RasterSnapshot>(2, 2, solid(Qt::red, 128), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}); + EditorSession session; + session.createDocument(2, 2); + session.insert(ImportedImage(raster, QImage(), "Base")); + const QUuid base = session.activeLayerID().value(); + session.insert(ImportedImage(solid(Qt::blue, 255), QImage(), "Top")); + const QUuid top = session.activeLayerID().value(); + session.toggleClippingMask(top); + // Every worker is held: the bake has to queue. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + // A failed check must not strand the held workers. + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + Asked asked; + answer(asked, "Bake and Delete"); + QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion"); + session.deleteLayer(base); + // Back at once: busy, nothing flattened, nothing deleted. + QCOMPARE(asked.times, 1); + QVERIFY(session.isProjectBusy()); + QVERIFY(!raster->hasMaterializedPixels()); + gate.release(workers); + // No event runs in this wait: a worker flattens. + QElapsedTimer clock; + clock.start(); + while (!raster->hasMaterializedPixels() && clock.elapsed() < 10'000) + QTest::qSleep(5); + QVERIFY(raster->hasMaterializedPixels()); + QVERIFY(session.isProjectBusy()); + QCOMPARE(int(session.document().value().layers.size()), 2); + QVERIFY(QTest::qWaitFor([&] { return !session.isProjectBusy(); }, 10'000)); + QCOMPARE(int(session.document().value().layers.size()), 1); + QVERIFY2(std::abs(alphaOf(session, top) - 128) <= 1, qPrintable(QString::number(alphaOf(session, top)))); +} + +void LiveMaskDeleteTests::severalLayersAreAskedAboutOnce() +{ + QUuid base, top; + const std::unique_ptr<EditorSession> session = clipped(base, top); + session->selectLayer(std::nullopt); + session->insert(ImportedImage(solid(Qt::green, 255), QImage(), "Other")); + const QUuid other = session->activeLayerID().value(); + session->selectLayers({base, other}, other); + Asked asked; + answer(asked, "Remove Links and Delete"); + session->deleteSelectedLayers(); + QCOMPARE(asked.times, 1); + QCOMPARE(asked.text, QString("These layers supply live masks")); + QCOMPARE(int(session->document().value().layers.size()), 1); + QCOMPARE(session->history.undoName(), QString("Delete Layers")); + QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt); + // A folder that holds the base counts as supplying it. + session->undo(); + session->selectLayers({base}, base); + session->groupSelectedLayers(); + const QUuid folder = session->activeLayerID().value(); + QVERIFY(session->linkMask(base, top)); + Asked again; + answer(again, "Cancel"); + session->deleteLayer(folder); + QCOMPARE(again.times, 1); + QCOMPARE(again.text, QString("This layer supplies a live mask")); + QCOMPARE(int(session->document().value().layers.size()), 4); + // A folder holding base and dependent leaves nobody behind. + QVERIFY(session->placeLayer(top, folder)); + QVERIFY(session->linkMask(base, top)); + QVERIFY(!session->deleteWithLiveMaskChoice({folder})); + session->deleteLayer(folder); + QCOMPARE(int(session->document().value().layers.size()), 1); +} + +void LiveMaskDeleteTests::aBakeThatFailsDeletesNothingAndSaysWhy() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(ImportedImage(solid(Qt::red, 128), QImage(), "Base")); + const QUuid base = session.activeLayerID().value(); + // An asset without pixels: no surface can hold it. + session.insert(ImportedImage(QImage(), QImage(), "Void")); + const QUuid hollow = session.activeLayerID().value(); + QVERIFY(session.linkMask(base, hollow)); + const std::optional<CanvasDocument> before = session.document(); + Asked asked; + answer(asked, "Bake and Delete"); + QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion"); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot bake the live masks: .*")); + session.deleteLayer(base); + QVERIFY(QTest::qWaitFor([&] { return !session.isProjectBusy(); }, 30'000)); + QCOMPARE(session.document(), before); + QCOMPARE(session.brushError(), std::optional(QString("The canvas could not be rendered. Try a smaller canvas."))); + session.setBrushError(std::nullopt); + QCOMPARE(session.brushError(), std::nullopt); +} + +QTEST_MAIN(LiveMaskDeleteTests) +#include "LiveMaskDeleteTests.moc" diff --git a/tests/LiveMaskEditTests.cpp b/tests/LiveMaskEditTests.cpp new file mode 100644 index 0000000..0faee3a --- /dev/null +++ b/tests/LiveMaskEditTests.cpp @@ -0,0 +1,346 @@ +#include "Document/LiveLayerMask.h" +#include "SessionFixtures.h" +#include <QtTest> + +// Clipping in the session beyond Swift's own cases. +namespace { +QUuid image(EditorSession &session, const QString &name) +{ + session.insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), name)); + return session.activeLayerID().value(); +} + +// Each layer's base by name, bottom up; "-" for none. +QStringList bases(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) { + QString base = "-"; + for (const ImageLayer &other : session.document().value().layers) { + if (layer.maskSourceID == std::optional(other.id)) + base = other.name; + } + result << layer.name + ":" + base; + } + return result; +} +} + +class LiveMaskEditTests : public QObject { + Q_OBJECT +private slots: + void linksAreRefusedForFoldersStrangersAndCycles(); + void aLinkIsOneNamedStepAndTheSameLinkIsNone(); + void releasingABaseFreesItsStackAndAChildOnlyWhatIsAbove(); + void clippingStaysAmongSiblings(); + void aLayerDroppedIntoAStackJoinsIt(); + void adoptionNeedsTheStackOnBothSides(); + void aStackBrokenByAMoveLetsGo(); + void deletingABaseFreesItsDependentsUnbakedOrBaked(); + void aFolderWithTheNullIdKeepsItsOwnRuns(); + void clippingWorksWithSeveralLayersSelected(); +}; + +void LiveMaskEditTests::linksAreRefusedForFoldersStrangersAndCycles() +{ + EditorSession empty; + QVERIFY(!empty.canLinkMask(QUuid::createUuid(), QUuid::createUuid()) && !empty.linkMask(QUuid::createUuid(), QUuid::createUuid())); + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(session.canLinkMask(a, b) && session.canLinkMask(b, a)); + QVERIFY(!session.canLinkMask(a, a)); + QVERIFY(!session.canLinkMask(folder, a) && !session.canLinkMask(a, folder)); + QVERIFY(!session.canLinkMask(QUuid::createUuid(), a) && !session.canLinkMask(a, QUuid::createUuid())); + // A chain is fine; closing it into a ring fails. + QVERIFY(session.linkMask(a, b)); + QVERIFY(session.linkMask(b, c)); + QVERIFY(!session.canLinkMask(c, a) && !session.linkMask(c, a)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:B", "Folder 1:-"})); + session.setShowsImporter(true); + QVERIFY(!session.canLinkMask(a, c) && !session.canToggleClippingMask(b)); + const std::optional<CanvasDocument> kept = session.document(); + session.toggleClippingMask(b); + session.removeLiveMask(b); + QCOMPARE(session.document(), kept); +} + +void LiveMaskEditTests::aLinkIsOneNamedStepAndTheSameLinkIsNone() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"); + const int count = session.history.undoCount(); + QVERIFY(session.linkMask(a, c)); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Create Clipping Mask")); + // Linked already: true, and no step. + QVERIFY(session.linkMask(a, c)); + QCOMPARE(session.history.undoCount(), count + 1); + // Another source replaces the first. + QVERIFY(session.linkMask(b, c)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:B"})); + session.removeLiveMask(c); + QCOMPARE(session.history.undoName(), QString("Release Clipping Mask")); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:-"})); + // Nothing clipped, nothing to release. + const int steps = session.history.undoCount(); + session.removeLiveMask(c); + session.removeLiveMask(QUuid::createUuid()); + QCOMPARE(session.history.undoCount(), steps); +} + +void LiveMaskEditTests::releasingABaseFreesItsStackAndAChildOnlyWhatIsAbove() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"), d = image(session, "D"); + for (const QUuid id : {b, c, d}) + session.toggleClippingMask(id); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A", "D:A"})); + // From C up: B below stays clipped. + session.toggleClippingMask(c); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:-", "D:-"})); + session.undo(); + // A layer clipped to another base above ends the run. + QVERIFY(session.linkMask(b, d)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A", "D:B"})); + session.removeLiveMask(b); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:-", "D:B"})); + Q_UNUSED(a); +} + +void LiveMaskEditTests::clippingStaysAmongSiblings() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid below = image(session, "Below"); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + const QUuid first = image(session, "First"), second = image(session, "Second"); + session.selectLayer(std::nullopt); + const QUuid above = image(session, "Above"); + // The lowest in a folder has nothing to clip to. + QVERIFY(!session.canToggleClippingMask(first) && session.canToggleClippingMask(second)); + session.toggleClippingMask(first); + QCOMPARE(layerWith(session, first).maskSourceID, std::nullopt); + session.toggleClippingMask(second); + QCOMPARE(layerWith(session, second).maskSourceID, std::optional(first)); + // Nothing clips to a folder below; no folder clips. + QVERIFY(!session.canToggleClippingMask(above) && !session.canToggleClippingMask(folder)); + const int steps = session.history.undoCount(); + session.toggleClippingMask(above); + session.toggleClippingMask(folder); + QCOMPARE(layerWith(session, above).maskSourceID, std::nullopt); + QCOMPARE(layerWith(session, folder).maskSourceID, std::nullopt); + QCOMPARE(session.history.undoCount(), steps); + QVERIFY(!session.canToggleClippingMask(below) && !session.canToggleClippingMask(QUuid::createUuid())); + // A clipped layer can always be freed. + QVERIFY(session.canToggleClippingMask(second)); + // Releasing in the folder leaves the root's stacks alone. + session.selectLayer(std::nullopt); + const QUuid top = image(session, "Top"); + session.toggleClippingMask(top); + QCOMPARE(layerWith(session, top).maskSourceID, std::optional(above)); + session.toggleClippingMask(second); + QCOMPARE(layerWith(session, second).maskSourceID, std::nullopt); + QCOMPARE(layerWith(session, top).maskSourceID, std::optional(above)); + // A folder's child lying between breaks no root run. + EditorSession mixed; + mixed.createDocument(8, 8); + mixed.addGroup(); + const QUuid holder = mixed.activeLayerID().value(); + mixed.selectLayer(std::nullopt); + const QUuid a = image(mixed, "A"), b = image(mixed, "B"); + mixed.selectLayer(holder); + const QUuid x = image(mixed, "X"); + mixed.selectLayer(b); + const QUuid c = image(mixed, "C"); + mixed.toggleClippingMask(b); + mixed.toggleClippingMask(c); + QCOMPARE(bases(mixed), (QStringList{"Folder 1:-", "A:-", "B:A", "X:-", "C:A"})); + mixed.removeLiveMask(b); + QCOMPARE(bases(mixed), (QStringList{"Folder 1:-", "A:-", "B:-", "X:-", "C:-"})); + // Nor is it freed when it shares the base. + mixed.undo(); + QVERIFY(mixed.linkMask(a, x)); + mixed.removeLiveMask(b); + QCOMPARE(bases(mixed), (QStringList{"Folder 1:-", "A:-", "B:-", "X:A", "C:-"})); +} + +void LiveMaskEditTests::aLayerDroppedIntoAStackJoinsIt() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"), loose = image(session, "Loose"); + session.toggleClippingMask(b); + session.toggleClippingMask(c); + // Between B and C, both clipped to A. + QVERIFY(session.placeLayer(loose, std::nullopt, b)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "Loose:A", "C:A"})); + session.undo(); + // Between the base itself and its first clipped layer. + QVERIFY(session.placeLayer(loose, std::nullopt, a)); + QCOMPARE(bases(session), (QStringList{"A:-", "Loose:A", "B:A", "C:A"})); + session.undo(); + // On top of the stack there is nothing to join. + QVERIFY(session.placeLayer(loose, std::nullopt, c)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A", "Loose:-"})); + // A folder never joins. + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(session.placeLayer(folder, std::nullopt, b)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "Folder 1:-", "C:-", "Loose:-"})); +} + +void LiveMaskEditTests::adoptionNeedsTheStackOnBothSides() +{ + // The rule by itself: placeLayer's release would hide it. + const auto layer = [](const QString &name) { return ImageLayer(name, QSizeF(2, 2)); }; + std::vector<ImageLayer> layers{layer("S"), layer("X"), layer("Loose"), layer("C")}; + const QUuid base = layers[0].id, loose = layers[2].id; + layers[3].maskSourceID = base; + // Below sits a stranger to the stack: no joining. + EditorSession::adoptClipping(loose, layers); + QCOMPARE(layers[2].maskSourceID, std::nullopt); + // Below sits a member of the stack: it joins. + layers[1].maskSourceID = base; + EditorSession::adoptClipping(loose, layers); + QCOMPARE(layers[2].maskSourceID, std::optional(base)); + // The base itself never clips to itself. + std::vector<ImageLayer> moved{layer("B"), layer("A"), layer("C")}; + const QUuid own = moved[1].id; + moved[0].maskSourceID = own; + moved[2].maskSourceID = own; + EditorSession::adoptClipping(own, moved); + QCOMPARE(moved[1].maskSourceID, std::nullopt); + // An id the list lacks changes nothing. + EditorSession::adoptClipping(QUuid::createUuid(), moved); + QCOMPARE(moved[1].maskSourceID, std::nullopt); +} + +void LiveMaskEditTests::aStackBrokenByAMoveLetsGo() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"); + session.toggleClippingMask(b); + session.toggleClippingMask(c); + // The base moved to the top: nothing sits above it. + QVERIFY(session.placeLayer(a, std::nullopt)); + QCOMPARE(bases(session), (QStringList{"B:-", "C:-", "A:-"})); + session.undo(); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A"})); + // Moved into a folder, a clipped layer leaves its base. + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(session.placeLayer(c, folder)); + QCOMPARE(layerWith(session, c).maskSourceID, std::nullopt); + QCOMPARE(layerWith(session, b).maskSourceID, std::optional(a)); + // A freed layer starts its own run: D keeps C. + EditorSession chained; + chained.createDocument(8, 8); + image(chained, "A"); + const QUuid second = image(chained, "B"), third = image(chained, "C"), fourth = image(chained, "D"); + chained.toggleClippingMask(second); + chained.toggleClippingMask(third); + QVERIFY(chained.linkMask(third, fourth)); + chained.selectLayer(std::nullopt); + chained.addGroup(); + QVERIFY(chained.placeLayer(chained.activeLayerID().value(), std::nullopt, second)); + QCOMPARE(bases(chained), (QStringList{"A:-", "B:A", "Folder 1:-", "C:-", "D:C"})); + // Runs count among siblings: a folder's child between breaks none. + EditorSession mixed; + mixed.createDocument(8, 8); + mixed.addGroup(); + const QUuid holder = mixed.activeLayerID().value(); + mixed.selectLayer(std::nullopt); + const QUuid lower = image(mixed, "A"); + mixed.selectLayer(holder); + image(mixed, "X"); + mixed.selectLayer(lower); + const QUuid upper = image(mixed, "B"); + mixed.toggleClippingMask(upper); + QCOMPARE(bases(mixed), (QStringList{"Folder 1:-", "A:-", "X:-", "B:A"})); + mixed.selectLayer(std::nullopt); + const QUuid extra = image(mixed, "C"); + QVERIFY(mixed.placeLayer(extra, std::nullopt, std::nullopt, true)); + QCOMPARE(bases(mixed), (QStringList{"C:-", "Folder 1:-", "A:-", "X:-", "B:A"})); +} + +void LiveMaskEditTests::deletingABaseFreesItsDependentsUnbakedOrBaked() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"); + session.toggleClippingMask(b); + session.toggleClippingMask(c); + const ImageIdentity pixelsB = layerWith(session, b).asset.value().identity(), pixelsC = layerWith(session, c).asset.value().identity(); + const ImportedImage baked(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Baked"); + // Only C was baked: B keeps its own pixels. + session.finishDeletingLayer(a, {{c, baked}}); + QCOMPARE(bases(session), (QStringList{"B:-", "C:-"})); + QVERIFY(layerWith(session, b).asset.value().identity() == pixelsB); + QVERIFY(layerWith(session, c).asset.value().identity() == baked.identity()); + QCOMPARE(session.history.undoName(), QString("Delete Layer")); + session.undo(); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A"})); + QVERIFY(layerWith(session, c).asset.value().identity() == pixelsC); + // A baked image for an unclipped layer is ignored. + session.finishDeletingLayers({b, QUuid::createUuid()}, {{c, baked}}); + QCOMPARE(bases(session), (QStringList{"A:-", "C:A"})); + QVERIFY(layerWith(session, c).asset.value().identity() == pixelsC); + QCOMPARE(session.history.undoName(), QString("Delete Layers")); +} + +void LiveMaskEditTests::aFolderWithTheNullIdKeepsItsOwnRuns() +{ + // All zeros is a valid id, not the root. + const auto layer = [](const QString &name) { return ImageLayer(name, QSizeF(2, 2)); }; + std::vector<ImageLayer> layers{layer("Folder"), layer("A"), layer("X"), layer("B")}; + layers[0].id = QUuid(); + layers[0].isGroup = true; + layers[2].parentID = QUuid(); + layers[3].maskSourceID = layers[1].id; + EditorSession::releaseDetachedClipping(layers); + QCOMPARE(layers[3].maskSourceID, std::optional(layers[1].id)); + // A folder's child clipped across parents lets go. + layers[2].maskSourceID = layers[1].id; + EditorSession::releaseDetachedClipping(layers); + QCOMPARE(layers[2].maskSourceID, std::nullopt); + QCOMPARE(layers[3].maskSourceID, std::optional(layers[1].id)); +} + +void LiveMaskEditTests::clippingWorksWithSeveralLayersSelected() +{ + EditorSession session; + session.createDocument(8, 8); + const QUuid a = image(session, "A"), b = image(session, "B"), c = image(session, "C"); + // Two selected: no one mask to edit, yet clipping works. + session.selectLayers({a, b}, a); + QVERIFY(session.canEditLayers() && !session.canEditMask()); + const int steps = session.history.undoCount(); + QVERIFY(session.canLinkMask(a, c) && session.linkMask(a, c)); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:A"})); + QVERIFY(session.canToggleClippingMask(b)); + session.toggleClippingMask(b); + QCOMPARE(bases(session), (QStringList{"A:-", "B:A", "C:A"})); + // The toggle frees B and what shares its base above. + QVERIFY(session.canToggleClippingMask(b)); + session.toggleClippingMask(b); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:-"})); + QVERIFY(session.linkMask(a, b)); + session.removeLiveMask(b); + QCOMPARE(bases(session), (QStringList{"A:-", "B:-", "C:-"})); + QCOMPARE(session.history.undoCount(), steps + 5); + QCOMPARE(session.history.undoName(), QString("Release Clipping Mask")); + QVERIFY(!session.canEditMask()); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{a, b})); +} + +QTEST_GUILESS_MAIN(LiveMaskEditTests) +#include "LiveMaskEditTests.moc" diff --git a/tests/LiveMaskFixtures.h b/tests/LiveMaskFixtures.h new file mode 100644 index 0000000..2003526 --- /dev/null +++ b/tests/LiveMaskFixtures.h @@ -0,0 +1,83 @@ +#pragma once +#include "Rendering/LayerRenderer.h" +#include "Rendering/LiveMaskRenderer.h" +#include "RenderFixtures.h" +#include <QHash> +#include <array> + +struct Layer { + QImage image; + LayerTransform transform; + double opacity = 1; + LayerBlendMode blendMode = LayerBlendMode::normal; + QImage mask = QImage(); + std::optional<QUuid> source = std::nullopt; + std::optional<QUuid> parent = std::nullopt; +}; + +// A 2x2 red image with the given alphas, as Swift's. +inline QImage asset(std::array<int, 4> alpha, int color = 255) +{ + QImage image = BrushRaster::context(2, 2, false); + for (int index = 0; index < 4; ++index) + image.setPixel(index % 2, index / 2, qRgba(color * alpha[index] / 255, 0, 0, alpha[index])); + return image; +} + +// A 3x2 image with six alphas; full red, or black. +inline QImage wide(std::array<int, 6> alpha, int color = 255) +{ + QImage image = BrushRaster::context(3, 2, false); + for (int index = 0; index < 6; ++index) + image.setPixel(index % 3, index / 3, qRgba(color * alpha[index] / 255, 0, 0, alpha[index])); + return image; +} + +inline std::array<int, 4> alphas(const QImage &image) +{ + return {qAlpha(image.pixel(0, 0)), qAlpha(image.pixel(1, 0)), qAlpha(image.pixel(0, 1)), qAlpha(image.pixel(1, 1))}; +} + +inline std::array<int, 4> reds(const QImage &image) +{ + return {qRed(image.pixel(0, 0)), qRed(image.pixel(1, 0)), qRed(image.pixel(0, 1)), qRed(image.pixel(1, 1))}; +} + +// One render's layers, their draw counts, and the renderer's callbacks. +struct Scene { + QHash<QUuid, Layer> layers; + QHash<QUuid, int> drawn; + + QUuid add(Layer layer) + { + const QUuid id = QUuid::createUuid(); + layers.insert(id, std::move(layer)); + return id; + } + LiveMaskRenderer renderer(qint64 budget = LiveMaskRenderer::pixelBudget) + { + return LiveMaskRenderer([this](QUuid id) { return layers[id].source; }, + [this](QUuid id, QPainter &painter, const QImage &clip) { + const Layer &layer = layers[id]; + drawn[id] += 1; + LayerRenderer::draw(layer.image, layer.transform, layer.transform.center(), painter, + {.opacity = layer.opacity, .blendMode = layer.blendMode, .mask = layer.mask, .clip = clip}); + }, + budget); + } + QImage render(const std::vector<QUuid> &ids, QImage surface = BrushRaster::context(2, 2, false)) + { + LiveMaskRenderer live = renderer(); + live.prepareStacks(ids, [this](QUuid id) { return layers[id].parent; }, [this](QUuid id) { return layers[id].blendMode; }); + QPainter painter(&surface); + for (const QUuid &id : ids) + live.drawComposite(id, painter); + painter.end(); + return surface; + } +}; + +inline Layer placed(const QImage &image) +{ + return {.image = image, .transform = placedAt({0, 0}, {2, 2})}; +} diff --git a/tests/LiveMaskRendererTests.cpp b/tests/LiveMaskRendererTests.cpp new file mode 100644 index 0000000..69ef722 --- /dev/null +++ b/tests/LiveMaskRendererTests.cpp @@ -0,0 +1,380 @@ +#include "AddressSpaceLimit.h" +#include "IO/ImageExporter.h" +#include "LiveMaskFixtures.h" +#include <QtTest> + +class LiveMaskRendererTests : public QObject { + Q_OBJECT +private slots: + void init(); + void aHiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply(); + void movingTheSourceMovesCoverageAndChainsMultiply(); + void cyclesAndEndlessChainsDrawNothing(); + void coverageIsBuiltOncePerSource(); + void anOuterClipMultipliesIn(); + void aScaledPainterKeepsCoverageInDevicePixels(); + void pastThePixelBudgetStacksAndCoverageGiveWay(); + void rectangularDevicesKeepRowsAndColumnsApart(); + void drawOwnCannotLeakPainterState(); + void anAdjustmentAdjustsWithinItsBudgetAndStack(); + void anAdjustmentTakesAnyImageTarget(); + void surfacesThatCannotBeAllocatedAreLoggedAndTheRendererStaysUsable(); + void aClipThatCannotBeCombinedThrows(); +}; + +void LiveMaskRendererTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void LiveMaskRendererTests::aHiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply() +{ + Scene scene; + const QUuid target = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + scene.layers[target].source = source; + // The source is hidden: only the target is drawn. + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{255, 0, 128, 255})); + QCOMPARE(reds(scene.render({target})), (std::array<int, 4>{255, 0, 128, 255})); + scene.layers[target].opacity = 0.5; + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{127, 0, 64, 127})); + scene.layers[target].mask = gray(2, 2, 128); + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{64, 0, 32, 64})); + // The source's mask counts; its colour and visibility do not. + scene.layers[target].opacity = 1; + scene.layers[target].mask = QImage(); + scene.layers[source].mask = gray(2, 2, 128); + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{128, 0, 64, 128})); +} + +void LiveMaskRendererTests::movingTheSourceMovesCoverageAndChainsMultiply() +{ + Scene scene; + const QUuid target = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + scene.layers[target].source = source; + scene.layers[source].transform.origin.rx() += 1; + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{0, 255, 0, 128})); + scene.layers[source].transform.origin.rx() -= 1; + const QUuid second = scene.add(placed(asset({0, 255, 255, 255}))); + scene.layers[source].source = second; + QCOMPARE(alphas(scene.render({target})), (std::array<int, 4>{0, 0, 128, 255})); +} + +void LiveMaskRendererTests::cyclesAndEndlessChainsDrawNothing() +{ + Scene scene; + const QUuid first = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid second = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[first].source = second; + scene.layers[second].source = first; + QCOMPARE(scene.render({first, second}), BrushRaster::context(2, 2, false)); + // The cycle ends at once, not at the depth limit. + QCOMPARE(scene.drawn[first], 1); + QCOMPARE(scene.drawn[second], 2); + + // A chain past 256 sources stops instead of recursing on. + Scene chain; + std::vector<QUuid> ids; + for (int index = 0; index < 300; ++index) { + ids.push_back(chain.add(placed(asset({255, 255, 255, 255})))); + if (index > 0) + chain.layers[ids[index - 1]].source = ids[index]; + } + QCOMPARE(chain.render({ids[0]}), BrushRaster::context(2, 2, false)); + // 255 sources deep still draws. + QCOMPARE(alphas(chain.render({ids[44]})), (std::array<int, 4>{255, 255, 255, 255})); +} + +void LiveMaskRendererTests::coverageIsBuiltOncePerSource() +{ + Scene scene; + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + const QUuid first = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid second = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[first].source = source; + scene.layers[second].source = source; + LiveMaskRenderer live = scene.renderer(); + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + live.draw(first, painter); + live.draw(second, painter); + live.draw(first, painter); + painter.end(); + QCOMPARE(scene.drawn[source], 1); + QCOMPARE(scene.drawn[first], 2); + QCOMPARE(alphas(surface), (std::array<int, 4>{255, 0, 224, 255})); +} + +void LiveMaskRendererTests::anOuterClipMultipliesIn() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid target = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + scene.layers[top].source = base; + scene.layers[target].source = source; + QImage folder(2, 2, QImage::Format_Alpha8); + folder.fill(128); + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage stack = BrushRaster::context(2, 2, false), linked = stack, plain = stack; + { + QPainter painter(&stack); + live.drawComposite(base, painter, folder); + } + QCOMPARE(alphas(stack), (std::array<int, 4>{128, 64, 16, 0})); + { + QPainter painter(&linked); + live.drawComposite(target, painter, folder); + } + QCOMPARE(alphas(linked), (std::array<int, 4>{128, 0, 64, 128})); + { + QPainter painter(&plain); + live.drawComposite(source, painter, folder); + } + QCOMPARE(alphas(plain), (std::array<int, 4>{128, 0, 64, 128})); +} + +void LiveMaskRendererTests::aScaledPainterKeepsCoverageInDevicePixels() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid target = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + scene.layers[top].source = base; + scene.layers[target].source = source; + for (const bool stacked : {true, false}) { + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(6, 6, false); + QPainter painter(&surface); + painter.translate(1, 2); + painter.scale(2, 2); + live.drawComposite(stacked ? base : target, painter); + painter.end(); + // Each layer pixel covers 2x2 device pixels, offset (1,2). + const std::array<int, 4> expected = stacked ? std::array<int, 4>{255, 128, 32, 0} : std::array<int, 4>{255, 0, 128, 255}; + for (int index = 0; index < 4; ++index) { + // Full-strength red: the child lies where the base does. + QCOMPARE(surface.pixel(1 + index % 2 * 2, 2 + index / 2 * 2), qRgba(expected[index], 0, 0, expected[index])); + QCOMPARE(surface.pixel(2 + index % 2 * 2, 3 + index / 2 * 2), qRgba(expected[index], 0, 0, expected[index])); + } + QCOMPARE(qAlpha(surface.pixel(0, 2)), 0); + QCOMPARE(qAlpha(surface.pixel(5, 5)), 0); + } +} + +void LiveMaskRendererTests::pastThePixelBudgetStacksAndCoverageGiveWay() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[top].source = base; + LiveMaskRenderer live = scene.renderer(3); + live.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + // No group: the base draws alone; its child lacks coverage. + live.drawComposite(base, painter); + QTest::ignoreMessage(QtWarningMsg, "clip coverage passes its pixel budget: 2 x 2"); + live.drawComposite(top, painter); + painter.end(); + QCOMPARE(alphas(surface), (std::array<int, 4>{255, 128, 32, 0})); + QCOMPARE(reds(surface), (std::array<int, 4>{0, 0, 0, 0})); + QCOMPARE(scene.drawn[top], 0); + + // A budget of exactly the device's four pixels is enough. + LiveMaskRenderer fitting = scene.renderer(4); + fitting.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage shared = BrushRaster::context(2, 2, false); + QPainter second(&shared); + fitting.drawComposite(base, second); + second.end(); + QCOMPARE(reds(shared), (std::array<int, 4>{255, 128, 32, 0})); +} + +void LiveMaskRendererTests::rectangularDevicesKeepRowsAndColumnsApart() +{ + // A 3x2 layer in a 4x3 device's far corner. + const int alpha[6] = {255, 200, 150, 100, 50, 25}; + const Layer soft{.image = wide({255, 200, 150, 100, 50, 25}), .transform = placedAt({1, 1}, {3, 2})}; + QImage surface(4, 3, QImage::Format_RGBA8888_Premultiplied); + surface.fill(0); + // Half grey over full red, then the base's alpha. + Scene stack; + const QUuid base = stack.add(soft); + const QUuid top = stack.add({.image = solid(3, 2, qRgba(128, 128, 128, 255)), .transform = placedAt({1, 1}, {3, 2}), .opacity = 0.5}); + stack.layers[top].source = base; + const QImage grouped = stack.render({base, top}, surface); + for (int index = 0; index < 6; ++index) { + const int a = alpha[index]; + QCOMPARE(grouped.pixel(1 + index % 3, 1 + index / 3), qRgba((192 * a + 127) / 255, (64 * a + 127) / 255, (64 * a + 127) / 255, a)); + } + QCOMPARE(grouped.pixel(0, 0), qRgba(0, 0, 0, 0)); + // A hidden source. + Scene linked; + const QUuid target = linked.add({.image = wide({255, 255, 255, 255, 255, 255}), .transform = placedAt({1, 1}, {3, 2})}); + const QUuid source = linked.add(soft); + linked.layers[target].source = source; + const QImage through = linked.render({target}, surface); + for (int index = 0; index < 6; ++index) + QCOMPARE(through.pixel(1 + index % 3, 1 + index / 3), qRgba(alpha[index], 0, 0, alpha[index])); + QCOMPARE(through.pixel(0, 2), qRgba(0, 0, 0, 0)); +} + +void LiveMaskRendererTests::drawOwnCannotLeakPainterState() +{ + const QImage red = solid(2, 2, qRgba(255, 0, 0, 255)); + const QUuid plain = QUuid::createUuid(), linked = QUuid::createUuid(), source = QUuid::createUuid(), failing = QUuid::createUuid(); + // Every draw moves and clips the painter, as callbacks might. + LiveMaskRenderer live([&](QUuid id) { return id == linked ? std::optional(source) : std::nullopt; }, + [&](QUuid id, QPainter &painter, const QImage &clip) { + if (id == failing) + painter.translate(3, 3); + if (id == failing) + throw std::runtime_error("drawOwn failed"); + LayerRenderer::draw(red, placedAt({0, 0}, {2, 2}), QPointF(1, 1), painter, {.clip = clip}); + painter.translate(2, 0); + painter.setClipRect(QRectF(0, 0, 1, 1)); + }); + QImage surface = BrushRaster::context(6, 2, false); + QPainter painter(&surface); + live.draw(plain, painter); + QCOMPARE(painter.worldTransform(), QTransform()); + QVERIFY(!painter.hasClipping()); + live.draw(linked, painter); + QCOMPARE(painter.worldTransform(), QTransform()); + QVERIFY(!painter.hasClipping()); + QVERIFY_EXCEPTION_THROWN(live.draw(failing, painter), std::runtime_error); + QCOMPARE(painter.worldTransform(), QTransform()); + painter.end(); + QCOMPARE(surface.pixel(1, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(2, 0), qRgba(0, 0, 0, 0)); +} + +void LiveMaskRendererTests::surfacesThatCannotBeAllocatedAreLoggedAndTheRendererStaysUsable() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 128, 32, 0}, 0))); + const QUuid top = scene.add(placed(asset({255, 255, 255, 255}))); + scene.layers[top].source = base; + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks({base, top}, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + // A device whose colour surfaces need 256 MB each. + QImage surface(8192, 8192, QImage::Format_Alpha8); + surface.fill(0); + QPainter painter(&surface); + std::optional<AddressSpaceLimit> limit(std::in_place, 96 * 1024 * 1024); + QTest::ignoreMessage(QtWarningMsg, "a clipping stack could not be allocated: QSize(8192, 8192)"); + live.drawComposite(base, painter); + QTest::ignoreMessage(QtWarningMsg, "clip coverage could not be allocated: QSize(8192, 8192)"); + live.drawComposite(top, painter); + limit.reset(); + QCOMPARE(scene.drawn[base], 1); + QCOMPARE(scene.drawn[top], 0); + // With room again the child draws through the base. + live.drawComposite(top, painter); + painter.end(); + QCOMPARE(scene.drawn[top], 1); + QCOMPARE(int(surface.constScanLine(0)[1]), 192); +} + +void LiveMaskRendererTests::aClipThatCannotBeCombinedThrows() +{ + Scene scene; + const QUuid target = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid source = scene.add(placed(asset({255, 0, 128, 255}, 0))); + scene.layers[target].source = source; + LiveMaskRenderer live = scene.renderer(); + QImage surface(8192, 8192, QImage::Format_Alpha8), folder(8192, 8192, QImage::Format_Alpha8); + surface.fill(0); + folder.fill(255); + QPainter painter(&surface); + // The first draw caches the source's coverage. + live.draw(target, painter); + std::optional<AddressSpaceLimit> limit(std::in_place, 32 * 1024 * 1024); + std::optional<ExportError::Kind> thrown; + try { + live.draw(target, painter, folder); + } catch (const ExportError &error) { + thrown = error.kind; + } + limit.reset(); + painter.end(); + QCOMPARE(thrown, std::optional(ExportError::Kind::render)); + QCOMPARE(scene.drawn[target], 1); +} + +void LiveMaskRendererTests::anAdjustmentAdjustsWithinItsBudgetAndStack() +{ + Scene scene; + const QUuid base = scene.add(placed(asset({255, 255, 255, 255}))); + const QUuid levels = scene.add(Layer()); + LayerAdjustment darker{AdjustmentKind::levels}; + darker.levels.ranges[0].outputWhite = 0; + const auto render = [&](qint64 budget) { + LiveMaskRenderer live = scene.renderer(budget); + live.adjustment = [&](QUuid id) { return id == levels ? std::optional(darker) : std::nullopt; }; + live.prepareStacks({base, levels}, [&](QUuid id) { return scene.layers[id].parent; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(2, 2, false); + QPainter painter(&surface); + live.drawComposite(base, painter); + live.drawComposite(levels, painter); + painter.end(); + return surface; + }; + // Four pixels fit: red turns black; three warn instead. + QCOMPARE(reds(render(4)), (std::array<int, 4>{0, 0, 0, 0})); + QTest::ignoreMessage(QtWarningMsg, "an adjustment passes its pixel budget: QSize(2, 2)"); + QCOMPARE(reds(render(3)), (std::array<int, 4>{255, 255, 255, 255})); + // Clipped outside any stack, under another parent, it changes nothing. + scene.layers[levels].source = base; + scene.layers[levels].parent = QUuid::createUuid(); + QCOMPARE(reds(render(4)), (std::array<int, 4>{255, 255, 255, 255})); +} + +void LiveMaskRendererTests::anAdjustmentTakesAnyImageTarget() +{ + // Four colours, so every channel and position tells. + QImage colours = BrushRaster::context(2, 2, false); + colours.setPixel(0, 0, qRgba(200, 100, 50, 255)); + colours.setPixel(1, 0, qRgba(30, 160, 220, 255)); + colours.setPixel(0, 1, qRgba(90, 90, 90, 255)); + colours.setPixel(1, 1, qRgba(250, 240, 10, 255)); + Scene scene; + const QUuid base = scene.add(placed(colours)); + const QUuid curve = scene.add(Layer()); + LayerAdjustment lifted{AdjustmentKind::curves}; + lifted.curves.channels[0] = {{0, 40}, {255, 200}}; + const auto draw = [&](QImage target, LayerBlendMode mode, double scale) { + LiveMaskRenderer live = scene.renderer(); + live.adjustment = [&](QUuid id) { return id == curve ? std::optional(lifted) : std::nullopt; }; + live.prepareStacks({base, curve}, [](QUuid) { return std::nullopt; }, [&](QUuid id) { return id == curve ? mode : LayerBlendMode::normal; }); + target.fill(0); + QPainter painter(&target); + painter.scale(scale, scale); + live.drawComposite(base, painter); + live.drawComposite(curve, painter); + painter.end(); + return target.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + }; + const QImage untouched = draw(BrushRaster::context(2, 2, false), LayerBlendMode::normal, 1); + for (const LayerBlendMode mode : {LayerBlendMode::normal, LayerBlendMode::multiply}) { + const QImage plain = draw(BrushRaster::context(2, 2, false), mode, 1); + QVERIFY(plain != colours && (mode == LayerBlendMode::normal || plain != untouched)); + // ARGB32 byte order: it reads colours, not bytes. + QCOMPARE(draw(QImage(2, 2, QImage::Format_ARGB32_Premultiplied), mode, 1), plain); + // Pixel ratio two draws as a painter scale of two. + QImage sharp = BrushRaster::context(4, 4, false); + sharp.setDevicePixelRatio(2); + QImage ratio = draw(sharp, mode, 1); + ratio.setDevicePixelRatio(1); + QCOMPARE(ratio, draw(BrushRaster::context(4, 4, false), mode, 2)); + } +} + +QTEST_MAIN(LiveMaskRendererTests) +#include "LiveMaskRendererTests.moc" diff --git a/tests/LiveMaskSessionTests.cpp b/tests/LiveMaskSessionTests.cpp new file mode 100644 index 0000000..2c5c4ad --- /dev/null +++ b/tests/LiveMaskSessionTests.cpp @@ -0,0 +1,218 @@ +#include "Document/LiveLayerMask.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include <QTemporaryDir> +#include <QtTest> + +// The session cases of Swift's LiveMaskTests. +namespace { +// Two by two, red by `color`, with these alphas, premultiplied. +ImportedImage asset(const QList<int> &alpha, int color = 255) +{ + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + for (int index = 0; index < 4; ++index) { + uchar *pixel = image.scanLine(index / 2) + (index % 2) * 4; + pixel[0] = uchar(color * alpha[index] / 255); + pixel[1] = 0; + pixel[2] = 0; + pixel[3] = uchar(alpha[index]); + } + return ImportedImage(image, image, "Fixture"); +} + +QList<int> alphas(const QImage &image) +{ + QList<int> result; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + result << image.pixelColor(x, y).alpha(); + } + return result; +} + +QList<int> rendered(const EditorSession &session) +{ + return alphas(ImageExporter::render(session.projectSnapshot().value()).image); +} + +std::vector<QUuid> ids(const EditorSession &session) +{ + std::vector<QUuid> result; + for (const ImageLayer &layer : session.document().value().layers) + result.push_back(layer.id); + return result; +} + +std::optional<QUuid> base(const EditorSession &session, size_t index) +{ + return session.document().value().layers.at(index).maskSourceID; +} + +void sampleNearest(EditorSession &session) +{ + rewrite(session, [](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &layer : snapshot.manifest.layers) + layer.transform.sampling = LayerSampling::nearest; + }); +} + +// An opaque layer clipped to a hidden, mixed-alpha one. +std::unique_ptr<EditorSession> fixture() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(2, 2); + session->insert(asset({255, 255, 255, 255})); + session->insert(asset({255, 0, 128, 255}, 0)); + const std::vector<QUuid> layers = ids(*session); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &layer : snapshot.manifest.layers) + layer.transform.sampling = LayerSampling::nearest; + record(snapshot, layers[1]).isVisible = false; + }); + if (!session->linkMask(layers[1], layers[0])) + throw std::runtime_error("the fixture cannot link its mask"); + return session; +} +} + +class LiveMaskSessionTests : public QObject { + Q_OBJECT +private slots: + void clippingColorPreservesSoftBaseAlphaWithoutBlackFringe(); + void optionClickCreatesSharedStackAndDragOutReleases(); + void hiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply(); + void cyclesUndoPersistenceBakeAndDelete(); + void movingSourceChangesCoverageAndChainsMultiply(); +}; + +void LiveMaskSessionTests::clippingColorPreservesSoftBaseAlphaWithoutBlackFringe() +{ + EditorSession session; + session.createDocument(2, 2); + session.insert(asset({255, 128, 32, 0}, 0)); + session.insert(asset({255, 255, 255, 255})); + const QUuid id = session.activeLayerID().value(); + session.toggleClippingMask(id); + sampleNearest(session); + const QImage result = ImageExporter::render(session.projectSnapshot().value()).image; + QCOMPARE(alphas(result), (QList<int>{255, 128, 32, 0})); + // Red times alpha, no black fringe: red equals alpha. + const QImage flat = result.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + for (int index = 0; index < 4; ++index) { + const uchar *pixel = flat.constScanLine(index / 2) + (index % 2) * 4; + QCOMPARE(int(pixel[0]), int(pixel[3])); + QVERIFY(pixel[1] == 0 && pixel[2] == 0); + } + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).opacity = 0.5; }); + QCOMPARE(rendered(session), (QList<int>{255, 128, 32, 0})); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).opacity = 1; }); + QImage white(2, 2, QImage::Format_RGBA8888_Premultiplied); + white.fill(Qt::white); + session.insert(ImportedImage(white, white, "White")); + const QUuid background = session.activeLayerID().value(); + QVERIFY(session.placeLayer(background, std::nullopt, std::nullopt, true)); + const QImage flattened = ImageExporter::render(session.projectSnapshot().value()).image; + QCOMPARE(alphas(flattened), (QList<int>{255, 255, 255, 255})); + for (int index = 0; index < 4; ++index) + QCOMPARE(flattened.pixelColor(index % 2, index / 2).red(), 255); +} + +void LiveMaskSessionTests::optionClickCreatesSharedStackAndDragOutReleases() +{ + EditorSession session; + session.createDocument(2, 2); + for (int index = 0; index < 3; ++index) + session.insert(asset({255, 255, 255, 255})); + const std::vector<QUuid> layers = ids(session); + QVERIFY(!session.canToggleClippingMask(layers[0]) && session.canToggleClippingMask(layers[1])); + session.toggleClippingMask(layers[0]); + QCOMPARE(base(session, 0), std::nullopt); + session.toggleClippingMask(layers[1]); + session.toggleClippingMask(layers[2]); + QCOMPARE(base(session, 1), std::optional(layers[0])); + QCOMPARE(base(session, 2), std::optional(layers[0])); + QVERIFY(session.canToggleClippingMask(layers[2])); + session.toggleClippingMask(layers[2]); + QCOMPARE(base(session, 2), std::nullopt); + QCOMPARE(base(session, 1), std::optional(layers[0])); + session.toggleClippingMask(layers[2]); + session.toggleClippingMask(layers[1]); + QVERIFY(!base(session, 1).has_value() && !base(session, 2).has_value()); + session.undo(); + QCOMPARE(base(session, 2), std::optional(layers[0])); + QVERIFY(session.placeLayer(layers[2], std::nullopt, std::nullopt, true)); + QVERIFY(session.document().value().layers.front().id == layers[2] && !base(session, 0).has_value()); + QCOMPARE(session.document().value().layers.back().maskSourceID, std::optional(layers[0])); + session.undo(); + QVERIFY(session.document().value().layers.back().id == layers[2]); + QCOMPARE(session.document().value().layers.back().maskSourceID, std::optional(layers[0])); +} + +void LiveMaskSessionTests::hiddenBlackSourceSuppliesAlphaAndRasterMasksMultiply() +{ + const std::unique_ptr<EditorSession> session = fixture(); + const std::vector<QUuid> layers = ids(*session); + QCOMPARE(rendered(*session), (QList<int>{255, 0, 128, 255})); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).opacity = 0.5; }); + const QList<int> half = rendered(*session); + QVERIFY2(std::abs(half[0] - 128) <= 1 && half[1] == 0 && std::abs(half[2] - 64) <= 1, qPrintable(QString::number(half[0]))); + QImage gray(2, 2, QImage::Format_Grayscale8); + gray.fill(128); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, layers[0], LayerMask::assetFrom(gray)); }); + QVERIFY(rendered(*session)[0] < half[0]); +} + +void LiveMaskSessionTests::cyclesUndoPersistenceBakeAndDelete() +{ + const std::unique_ptr<EditorSession> fixed = fixture(); + EditorSession &session = *fixed; + const QUuid target = ids(session)[0], source = ids(session)[1]; + QCOMPARE(session.history.undoName(), QString("Create Clipping Mask")); + QVERIFY(!session.linkMask(target, source)); + QVERIFY(!session.linkMask(target, target)); + session.undo(); + QCOMPARE(base(session, 0), std::nullopt); + session.redo(); + QCOMPARE(base(session, 0), std::optional(source)); + const ProjectSnapshot snapshot = session.projectSnapshot().value(); + QTemporaryDir folder; + const QString path = folder.filePath("LiveMask.comp"); + ProjectStore::save(snapshot, path); + const ProjectSnapshot loaded = ProjectStore::load(path); + QCOMPARE(loaded.manifest.layers[0].maskSourceID, std::optional(source)); + const QList<int> before = alphas(ImageExporter::render(loaded).image); + const ImportedImage baked = LiveMaskBaker::bake(loaded, target).value(); + session.finishDeletingLayer(source, {{target, baked}}); + QCOMPARE(rendered(session), before); + QCOMPARE(base(session, 0), std::nullopt); + session.undo(); + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(base(session, 0), std::optional(source)); + std::vector<ProjectLayerRecord> bad = snapshot.manifest.layers; + bad[1].maskSourceID = target; + bool refused = false; + try { + LiveMaskGraph::validate(bad); + } catch (const ProjectError &) { + refused = true; + } + QVERIFY(refused); +} + +void LiveMaskSessionTests::movingSourceChangesCoverageAndChainsMultiply() +{ + const std::unique_ptr<EditorSession> session = fixture(); + const std::vector<QUuid> layers = ids(*session); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).transform.origin.rx() += 1; }); + QCOMPARE(rendered(*session), (QList<int>{0, 255, 0, 128})); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, layers[1]).transform.origin.rx() -= 1; }); + session->selectLayer(layers[1]); + session->insert(asset({0, 255, 255, 255})); + const QUuid third = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, third).isVisible = false; }); + QVERIFY(session->linkMask(third, layers[1])); + QCOMPARE(rendered(*session), (QList<int>{0, 0, 128, 255})); +} + +QTEST_GUILESS_MAIN(LiveMaskSessionTests) +#include "LiveMaskSessionTests.moc" diff --git a/tests/LiveMaskTests.cpp b/tests/LiveMaskTests.cpp new file mode 100644 index 0000000..698807a --- /dev/null +++ b/tests/LiveMaskTests.cpp @@ -0,0 +1,115 @@ +#include "Document/EditorSession.h" +#include "Document/LiveLayerMask.h" +#include <QtTest> + +namespace { +ProjectLayerRecord record(const QString &name, std::optional<QUuid> source = std::nullopt) +{ + return {.id = QUuid::createUuid(), .name = name, .isVisible = true, + .transform = LayerTransform{.origin = {0, 0}, .size = {10, 10}}, .imageFile = name + ".png", + .maskSourceID = source}; +} + +bool isInvalid(const std::vector<ProjectLayerRecord> &layers) +{ + try { + LiveMaskGraph::validate(layers); + } catch (const ProjectError &error) { + return error.kind == ProjectError::Kind::invalid; + } + return false; +} + +// Each record clips to the one before it. +std::vector<ProjectLayerRecord> chain(int count) +{ + std::vector<ProjectLayerRecord> layers; + for (int index = 0; index < count; ++index) + layers.push_back(record(QString::number(index), index == 0 ? std::nullopt : std::optional(layers.back().id))); + return layers; +} +} + +class LiveMaskTests : public QObject { + Q_OBJECT +private slots: + void sharedBasesAndChainsAreValid(); + void cyclesAndSelfLinksAreRejected(); + void missingSourcesAndDuplicateIdsAreRejected(); + void foldersNeitherClipNorSupplyAMask(); + void chainsStopAtTwoHundredFiftySixLayers(); + void layersCompareAndRecordTheirClippingBase(); +}; + +void LiveMaskTests::sharedBasesAndChainsAreValid() +{ + const ProjectLayerRecord base = record("Base"); + const ProjectLayerRecord first = record("First", base.id), second = record("Second", base.id); + const ProjectLayerRecord chained = record("Chained", first.id); + LiveMaskGraph::validate({base, first, second, chained}); + LiveMaskGraph::validate({chained, second, first, base}); + LiveMaskGraph::validate({}); +} + +void LiveMaskTests::cyclesAndSelfLinksAreRejected() +{ + ProjectLayerRecord target = record("Target"), source = record("Source"); + target.maskSourceID = source.id; + LiveMaskGraph::validate({target, source}); + source.maskSourceID = target.id; + QVERIFY(isInvalid({target, source})); + ProjectLayerRecord loop = record("Loop"); + loop.maskSourceID = loop.id; + QVERIFY(isInvalid({loop})); + ProjectLayerRecord a = record("A"), b = record("B"), c = record("C"); + a.maskSourceID = b.id; + b.maskSourceID = c.id; + c.maskSourceID = a.id; + QVERIFY(isInvalid({a, b, c})); +} + +void LiveMaskTests::missingSourcesAndDuplicateIdsAreRejected() +{ + QVERIFY(isInvalid({record("Orphan", QUuid::createUuid())})); + const ProjectLayerRecord layer = record("Layer"); + QVERIFY(isInvalid({layer, layer})); +} + +void LiveMaskTests::foldersNeitherClipNorSupplyAMask() +{ + ProjectLayerRecord base = record("Base"); + ProjectLayerRecord clipped = record("Clipped", base.id); + LiveMaskGraph::validate({base, clipped}); + clipped.isGroup = false; + base.isGroup = false; + LiveMaskGraph::validate({base, clipped}); + clipped.isGroup = true; + QVERIFY(isInvalid({base, clipped})); + clipped.isGroup = std::nullopt; + base.isGroup = true; + QVERIFY(isInvalid({base, clipped})); +} + +void LiveMaskTests::chainsStopAtTwoHundredFiftySixLayers() +{ + LiveMaskGraph::validate(chain(256)); + QVERIFY(isInvalid(chain(257))); +} + +void LiveMaskTests::layersCompareAndRecordTheirClippingBase() +{ + ImageLayer layer("Layer 1", QSizeF(10, 10)); + QCOMPARE(layer.hierarchyRecord().maskSourceID, std::nullopt); + ImageLayer clipped = layer; + clipped.maskSourceID = QUuid::createUuid(); + QVERIFY(!(clipped == layer)); + QCOMPARE(clipped, ImageLayer(clipped)); + QCOMPARE(clipped.hierarchyRecord().maskSourceID, clipped.maskSourceID); + ImageLayer rebased = clipped; + rebased.maskSourceID = QUuid::createUuid(); + QVERIFY(!(rebased == clipped)); + QCOMPARE(rebased.hierarchyRecord().maskSourceID, rebased.maskSourceID); +} + +QTEST_APPLESS_MAIN(LiveMaskTests) +#include "LiveMaskTests.moc" diff --git a/tests/MagicWandTests.cpp b/tests/MagicWandTests.cpp new file mode 100644 index 0000000..20aacbd --- /dev/null +++ b/tests/MagicWandTests.cpp @@ -0,0 +1,390 @@ +#include "CanvasFixtures.h" +#include "Document/MagicWand.h" +#include "RenderFixtures.h" +#include "SelectionFixtures.h" +#include "AddressSpaceLimit.h" +#include <malloc.h> +#include <set> + +// The Magic Wand: matching, tracing, the session's click, the canvas's. +namespace { +const std::array<uchar, 4> red{255, 0, 0, 255}, blue{0, 0, 255, 255}; + +// Premultiplied RGBA pixels, top row first. +QImage picture(int width, int height, const std::function<std::array<uchar, 4>(int, int)> &colour) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const std::array<uchar, 4> pixel = colour(x, y); + std::copy(pixel.begin(), pixel.end(), image.scanLine(y) + x * 4); + } + } + return image; +} + +// Pixel indices (y × width + x) an outline covers. +std::set<int> pixels(const std::optional<QPainterPath> &path, int width, int height) +{ + std::set<int> covered; + if (!path) + return covered; + const QImage coverage = DocumentSelection{path.value(), false}.coverage(width, height); + for (int index = 0; index < width * height; ++index) { + if (coverage.constScanLine(index / width)[index % width] >= 128) + covered.insert(index); + } + return covered; +} + +WandSettings settings(int tolerance = 32, WandSampleSize size = WandSampleSize::point, bool contiguous = true) +{ + WandSettings result; + result.tolerance = tolerance; + result.sampleSize = size; + result.contiguous = contiguous; + return result; +} + +std::set<int> block(int fromColumn, int toColumn, int fromRow, int toRow, int width) +{ + std::set<int> result; + for (int y = fromRow; y < toRow; ++y) { + for (int x = fromColumn; x < toColumn; ++x) + result.insert(y * width + x); + } + return result; +} + +std::set<int> all(int count) +{ + std::set<int> result; + for (int index = 0; index < count; ++index) + result.insert(index); + return result; +} + +std::set<int> without(std::set<int> from, const std::set<int> &taken) +{ + for (const int index : taken) + from.erase(index); + return from; +} + +// Runs the wand and waits for its step. +void wand(EditorSession &session, QPointF point, SelectionMode mode) +{ + bool done = false; + session.magicWand(point, mode, [&] { done = true; }); + QTRY_VERIFY(done); +} +} + +class MagicWandTests : public QObject { + Q_OBJECT +private slots: + void contiguousStopsAtOtherColorsWhileNonContiguousFindsEveryMatch(); + void toleranceAppliesToEveryChannelIncludingAlpha(); + void sampleSizeAveragesThePixelsAroundTheClick(); + void outlinesReproduceTheirPixelsWithHolesAndCornerTouches(); + void theWandReadsTheActiveLayerOrEveryVisibleLayerAndCombinesModes(); + void clickingInsideASelectionMakesANewWandSelectionRatherThanDeselecting(); + void theWandRefusesOutsideBusyAndMidMoveAndDropsAReplacedDocument(); + void aTooDetailedOutlineIsReportedAndTitlesAreSwifts(); + void thisLayerReadsItsOwnPixelsWhereverAndHoweverTheyShow(); + void aSampleThatCannotFlattenAndAnotherDocumentLeaveTheSelection(); +}; + +void MagicWandTests::contiguousStopsAtOtherColorsWhileNonContiguousFindsEveryMatch() +{ + const QImage stripes = picture(10, 4, [](int x, int) { return x < 3 || x >= 6 ? red : blue; }); + const std::set<int> left = block(0, 3, 0, 4, 10), right = block(6, 10, 0, 4, 10); + const std::optional<QPainterPath> connected = MagicWand::select(stripes, QPointF(1.5, 2.5), settings()); + QVERIFY(pixels(connected, 10, 4) == left); + QVERIFY(connected.value().fillRule() == Qt::WindingFill); + const std::optional<QPainterPath> everywhere = MagicWand::select(stripes, QPointF(1.5, 2.5), settings(32, WandSampleSize::point, false)); + std::set<int> both = left; + both.insert(right.begin(), right.end()); + QVERIFY(pixels(everywhere, 10, 4) == both); + // Rows keep their order: the top row selects itself. + const QImage banded = picture(4, 3, [](int, int y) { return y == 0 ? red : blue; }); + QVERIFY(pixels(MagicWand::select(banded, QPointF(1, 0), settings()), 4, 3) == std::set<int>({0, 1, 2, 3})); + // The pixel under the point, never the one rounded to. + QVERIFY(pixels(MagicWand::select(stripes, QPointF(2.7, 0.2), settings()), 10, 4) == left); + QVERIFY(!MagicWand::select(banded, QPointF(9, 0), settings()).has_value()); + QVERIFY(!MagicWand::select(banded, QPointF(std::nan(""), 0), settings()).has_value()); + QVERIFY(!MagicWand::select(banded, QPointF(-0.5, 0), settings()).has_value()); +} + +void MagicWandTests::toleranceAppliesToEveryChannelIncludingAlpha() +{ + const std::array<std::array<uchar, 4>, 4> columns{{{100, 100, 100, 255}, {132, 100, 100, 255}, {133, 100, 100, 255}, {100, 100, 100, 222}}}; + const QImage row = picture(4, 1, [&](int x, int) { return columns[size_t(x)]; }); + const auto run = [&](int tolerance) { + return pixels(MagicWand::select(row, QPointF(0.5, 0.5), settings(tolerance, WandSampleSize::point, false)), 4, 1); + }; + QVERIFY(run(0) == std::set<int>({0})); + QVERIFY(run(32) == std::set<int>({0, 1})); + QVERIFY(run(33) == std::set<int>({0, 1, 2, 3})); + // A wild tolerance clamps to the range. + QVERIFY(run(900) == std::set<int>({0, 1, 2, 3})); + QVERIFY(run(-5) == std::set<int>({0})); +} + +void MagicWandTests::sampleSizeAveragesThePixelsAroundTheClick() +{ + const QImage dot = picture(5, 5, [](int x, int y) { return x == 2 && y == 2 ? std::array<uchar, 4>{255, 255, 255, 255} : std::array<uchar, 4>{0, 0, 0, 255}; }); + const QPointF center(2.5, 2.5); + QVERIFY(pixels(MagicWand::select(dot, center, settings(10, WandSampleSize::point, false)), 5, 5) == std::set<int>({12})); + // Averaged 3 × 3 to 28: black fits, white not. + const std::optional<QPainterPath> averaged = MagicWand::select(dot, center, settings(30, WandSampleSize::threeByThree, false)); + QVERIFY(pixels(averaged, 5, 5) == without(all(25), {12})); + QCOMPARE(radius(WandSampleSize::point), 0); + QCOMPARE(radius(WandSampleSize::threeByThree), 1); + QCOMPARE(radius(WandSampleSize::fiveByFive), 2); +} + +void MagicWandTests::outlinesReproduceTheirPixelsWithHolesAndCornerTouches() +{ + const int width = 8, height = 6; + std::vector<uchar> mask(size_t(width * height), 0); + // A ring round a corner hole; a diagonal touch. + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 3; ++x) { + if (!(x == 1 && y == 1)) + mask[size_t(y * width + x)] = 255; + } + } + mask[size_t(4 * width + 5)] = 255; + mask[size_t(5 * width + 6)] = 255; + std::set<int> expected; + for (int index = 0; index < width * height; ++index) { + if (mask[size_t(index)]) + expected.insert(index); + } + QVERIFY(pixels(MagicWand::outline(mask, width, height), width, height) == expected); + QVERIFY(!MagicWand::outline(std::vector<uchar>(4, 0), 2, 2).has_value()); + // A mask of the wrong size outlines nothing. + QVERIFY(!MagicWand::outline(std::vector<uchar>(3, 255), 2, 2).has_value()); +} + +void MagicWandTests::theWandReadsTheActiveLayerOrEveryVisibleLayerAndCombinesModes() +{ + EditorSession session; + session.createDocument(20, 10); + const QImage halves = picture(20, 10, [](int x, int) { return x < 10 ? red : blue; }); + session.insert(ImportedImage(halves, halves, "Halves")); + session.addBlankLayer(); + session.selectTool(NavigationTool::wand); + const std::set<int> left = block(0, 10, 0, 10, 20); + const auto selected = [&] { return pixels(session.selection() ? std::optional(session.selection().value().path) : std::nullopt, 20, 10); }; + // A blank active layer is clear: the whole canvas matches. + wand(session, QPointF(2, 2), SelectionMode::replace); + QCOMPARE(selected().size(), size_t(200)); + QCOMPARE(session.history.undoName(), QString("Magic Wand")); + QVERIFY(!session.isProjectBusy()); + WandSettings everything = session.wandSettings(); + everything.sampleAllLayers = true; + session.setWandSettings(everything); + wand(session, QPointF(2, 2), SelectionMode::replace); + QVERIFY(selected() == left); + // A new outline takes the Anti-alias setting of the moment. + QVERIFY(session.selection().value().antialiased); + session.setSelectionAntialiased(false); + wand(session, QPointF(2, 2), SelectionMode::replace); + QVERIFY(!session.selection().value().antialiased); + session.setSelectionAntialiased(true); + const int count = session.history.undoCount(); + wand(session, QPointF(15, 5), SelectionMode::add); + QCOMPARE(selected().size(), size_t(200)); + QCOMPARE(session.history.undoCount(), count + 1); + wand(session, QPointF(2, 2), SelectionMode::subtract); + QVERIFY(selected() == without(all(200), left)); + session.undo(); + QCOMPARE(selected().size(), size_t(200)); + // Hidden, a layer leaves the composite to the one beneath. + session.toggleLayerVisibility(session.activeLayerID().value()); + session.selectLayer(session.document().value().layers[0].id); + session.applySelection(rectPath(QRectF(0, 0, 5, 5)), SelectionMode::replace, "Select"); + wand(session, QPointF(2, 2), SelectionMode::replace); + QVERIFY(selected() == left); + session.toggleLayerVisibility(session.activeLayerID().value()); + wand(session, QPointF(2, 2), SelectionMode::replace); + QCOMPARE(selected().size(), size_t(200)); + // A folder as the active layer reads as clear. + session.setWandSettings(settings()); + session.addGroup(); + wand(session, QPointF(15, 5), SelectionMode::replace); + QCOMPARE(selected().size(), size_t(200)); + // No match: New deselects, Add changes nothing. + const auto dotted = std::make_unique<EditorSession>(); + dotted->createDocument(5, 5); + const QImage dot = picture(5, 5, [](int x, int y) { return x == 2 && y == 2 ? std::array<uchar, 4>{255, 255, 255, 255} : std::array<uchar, 4>{0, 0, 0, 255}; }); + dotted->insert(ImportedImage(dot, dot, "Dot")); + dotted->setWandSettings(settings(30, WandSampleSize::threeByThree, true)); + dotted->selectAll(); + wand(*dotted, QPointF(2.5, 2.5), SelectionMode::add); + QVERIFY(dotted->selection().has_value()); + QCOMPARE(dotted->history.undoName(), QString("Select All")); + wand(*dotted, QPointF(2.5, 2.5), SelectionMode::replace); + QVERIFY(!dotted->selection().has_value()); + QCOMPARE(dotted->history.undoName(), QString("Deselect")); +} + +void MagicWandTests::clickingInsideASelectionMakesANewWandSelectionRatherThanDeselecting() +{ + Shown shown(QSize(20, 10), QSize(400, 200)); + EditorSession &session = shown.session; + shown.settle(); + const QImage halves = picture(20, 10, [](int x, int) { return x < 10 ? red : blue; }); + session.insert(ImportedImage(halves, halves, "Halves")); + session.selectTool(NavigationTool::wand); + WandSettings everything = session.wandSettings(); + everything.sampleAllLayers = true; + session.setWandSettings(everything); + session.selectAll(); + shown.canvas->synchronizeDisplay(); + const QPointF spot = session.viewport.viewPoint(QPointF(3.5, 5.5), shown.documentSize()); + QTest::mouseClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, spot.toPoint()); + const std::set<int> left = block(0, 10, 0, 10, 20); + QTRY_VERIFY(!session.isProjectBusy() && session.selection().has_value() && pixels(session.selection().value().path, 20, 10) == left); + // A Shift click adds through the wand as well. + const QPointF other = session.viewport.viewPoint(QPointF(15.5, 5.5), shown.documentSize()); + QTest::mouseClick(shown.canvas, Qt::LeftButton, Qt::ShiftModifier, other.toPoint()); + QTRY_VERIFY(!session.isProjectBusy() && pixels(session.selection().value().path, 20, 10).size() == 200); + QCOMPARE(session.history.undoName(), QString("Magic Wand")); +} + +void MagicWandTests::theWandRefusesOutsideBusyAndMidMoveAndDropsAReplacedDocument() +{ + EditorSession session; + bool done = false; + session.magicWand(QPointF(1, 1), SelectionMode::replace, [&] { done = true; }); + QTRY_VERIFY(done); + session.createDocument(20, 10); + const QImage halves = picture(20, 10, [](int x, int) { return x < 10 ? red : blue; }); + session.insert(ImportedImage(halves, halves, "Halves")); + session.selectAll(); + const int count = session.history.undoCount(); + // Outside the canvas, busy, or mid-move: refused, the selection kept. + for (const QPointF point : {QPointF(-1, 1), QPointF(20, 1), QPointF(1, 10)}) { + wand(session, point, SelectionMode::replace); + QCOMPARE(pixels(session.selection().value().path, 20, 10).size(), size_t(200)); + } + session.setIsProjectBusy(true); + wand(session, QPointF(1, 1), SelectionMode::replace); + QCOMPARE(pixels(session.selection().value().path, 20, 10).size(), size_t(200)); + session.setIsProjectBusy(false); + QVERIFY(session.beginSelectionMove()); + wand(session, QPointF(1, 1), SelectionMode::replace); + session.endSelectionMove(); + QCOMPARE(pixels(session.selection().value().path, 20, 10).size(), size_t(200)); + QCOMPARE(session.history.undoCount(), count); + // No number for a point: refused, as one outside. + wand(session, QPointF(std::nan(""), 1), SelectionMode::replace); + QCOMPARE(pixels(session.selection().value().path, 20, 10).size(), size_t(200)); + QCOMPARE(session.history.undoCount(), count); + // The document gone mid-wand: the outline is dropped. + session.deselect(); + done = false; + session.magicWand(QPointF(1, 1), SelectionMode::replace, [&] { done = true; }); + QVERIFY(session.isProjectBusy()); + session.clearProject(); + QTRY_VERIFY(done); + QVERIFY(!session.document().has_value() && !session.isProjectBusy()); +} + +void MagicWandTests::aTooDetailedOutlineIsReportedAndTitlesAreSwifts() +{ + QCOMPARE(title(WandSampleSize::point), QString("Point Sample")); + QCOMPARE(title(WandSampleSize::threeByThree), QString("3 by 3 Average")); + QCOMPARE(title(WandSampleSize::fiveByFive), QString("5 by 5 Average")); + QCOMPARE(QString::fromUtf8(MagicWandError(MagicWandError::Kind::memory).what()), QString("There isn’t enough memory to make that selection.")); + // A checkerboard of eight million pixels has too many edges. + QImage board(4000, 2000, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 2000; ++y) { + uchar *row = board.scanLine(y); + for (int x = 0; x < 4000; ++x) { + const uchar value = (x + y) % 2 ? 255 : 0; + row[x * 4] = value; + row[x * 4 + 1] = value; + row[x * 4 + 2] = value; + row[x * 4 + 3] = 255; + } + } + QVERIFY_THROWS_EXCEPTION(MagicWandError, MagicWand::select(board, QPointF(0.5, 0.5), settings(0, WandSampleSize::point, false))); + EditorSession session; + session.createDocument(4000, 2000); + session.insert(ImportedImage(board, QImage(), "Board")); + session.setWandSettings(settings(0, WandSampleSize::point, false)); + wand(session, QPointF(0.5, 0.5), SelectionMode::replace); + QVERIFY(!session.selection().has_value() && !session.isProjectBusy()); + QCOMPARE(session.brushError(), std::optional(QString("That selection is too detailed to outline. Try a different Tolerance, or turn on Contiguous."))); +} + +void MagicWandTests::thisLayerReadsItsOwnPixelsWhereverAndHoweverTheyShow() +{ + EditorSession session; + session.createDocument(30, 20); + const QImage square = picture(10, 5, [](int, int) { return red; }); + session.insert(ImportedImage(square, square, "Red")); + const QUuid id = session.activeLayerID().value(); + // Moved, scaled, hidden, masked, faint: its pixels still read. + QImage mask = BrushRaster::context(10, 5, true); + mask.fill(Qt::black); + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {5, 2}, .size = {20, 10}, .sampling = LayerSampling::nearest}; + record(snapshot, id).isVisible = false; + record(snapshot, id).opacity = 0.2; + setMask(snapshot, id, LayerMask::assetFrom(mask)); + }); + session.selectTool(NavigationTool::wand); + session.setWandSettings(settings(0, WandSampleSize::point, false)); + wand(session, QPointF(10, 5), SelectionMode::replace); + QVERIFY(pixels(session.selection().value().path, 30, 20) == block(5, 25, 2, 12, 30)); + // Outside its placed pixels, the layer alone reads as clear. + session.setWandSettings(settings(0, WandSampleSize::point, true)); + wand(session, QPointF(1, 1), SelectionMode::replace); + QVERIFY(pixels(session.selection().value().path, 30, 20) == without(all(600), block(5, 25, 2, 12, 30))); + // Its pixels read opaque, not within 200 of clear. + session.setWandSettings(settings(200, WandSampleSize::point, false)); + wand(session, QPointF(1, 1), SelectionMode::replace); + QVERIFY(pixels(session.selection().value().path, 30, 20) == without(all(600), block(5, 25, 2, 12, 30))); +} + +void MagicWandTests::aSampleThatCannotFlattenAndAnotherDocumentLeaveTheSelection() +{ + // A painted base on a tiny canvas: only it fails. + const auto raster = std::make_shared<const RasterSnapshot>(4000, 3000, solid(2, 2, qRgba(255, 0, 0, 255)), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}); + EditorSession session; + session.createDocument(20, 10); + session.insert(ImportedImage(raster, QImage(), "Painted")); + session.applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select"); + const int count = session.history.undoCount(); + bool done = false; + { + // Freed heap returns first: the room is real. + malloc_trim(0); + const AddressSpaceLimit limit(16 * 1024 * 1024); + session.magicWand(QPointF(1, 1), SelectionMode::replace, [&] { done = true; }); + } + QTRY_VERIFY(done); + QVERIFY(!session.isProjectBusy() && !raster->hasMaterializedPixels()); + QCOMPARE(session.history.undoCount(), count); + QCOMPARE(bounds(session), QRectF(0, 0, 10, 10)); + // Another document installed mid-wand: its outline never lands. + const auto small = std::make_unique<EditorSession>(); + small->createDocument(20, 10); + const QImage halves = picture(20, 10, [](int x, int) { return x < 10 ? red : blue; }); + small->insert(ImportedImage(halves, halves, "Halves")); + done = false; + small->magicWand(QPointF(1, 1), SelectionMode::replace, [&] { done = true; }); + QVERIFY(small->isProjectBusy()); + rewrite(*small, [](ProjectSnapshot &snapshot) { snapshot.manifest.documentID = QUuid::createUuid(); }); + QTRY_VERIFY(done); + QVERIFY(!small->selection().has_value() && !small->isProjectBusy()); + QCOMPARE(small->history.undoCount(), 0); +} + +QTEST_MAIN(MagicWandTests) +#include "MagicWandTests.moc" diff --git a/tests/MaskPlacementTests.cpp b/tests/MaskPlacementTests.cpp new file mode 100644 index 0000000..dfbfc29 --- /dev/null +++ b/tests/MaskPlacementTests.cpp @@ -0,0 +1,222 @@ +#include "Document/LayerMask.h" +#include "IO/ImageExporter.h" +#include "Rendering/LayerRenderer.h" +#include "RenderFixtures.h" +#include "AddressSpaceLimit.h" +#include <QtTest> + +namespace { +int value(const QImage &image, int x, int y) +{ + return image.constScanLine(y)[x]; +} + +// White rim, black middle: reveals beyond its own pixels. +QImage ringMask(int side) +{ + QImage mask = gray(side, side, 255); + for (int y = 1; y < side - 1; ++y) { + for (int x = 1; x < side - 1; ++x) + mask.scanLine(y)[x] = 0; + } + return mask; +} + +const LayerTransform layer{.origin = {0, 0}, .size = {100, 100}}; +const LayerTransform rightHalf{.origin = {50, 0}, .size = {50, 100}}; + +std::optional<QImage> tiny() +{ + return gray(1, 1, 7); +} +} + +class MaskPlacementTests : public QObject { + Q_OBJECT +private slots: + void aMaskCoveringItsLayerClipsAsItIs(); + void aPlacedMaskIsResampledIntoTheLayerGrid(); + void theBackgroundComesFromTheThumbnail(); + void aLimitShrinksThePlacedGrid(); + void placedMasksAreCached(); + void aPlacedMaskThatCannotBeRenderedIsLoggedAndNil(); + void theCacheKeysOnEveryPartOfThePlacement(); + void aFailedBuildIsNeverCached(); + void hugeGridsAreReturnedButNotKept(); + void theNinthEntryEvictsTheLeastRecentlyUsed(); + void thePixelBudgetEvictsToo(); + void fineMasksArePlacedFromHalvings(); +}; + +void MaskPlacementTests::aMaskCoveringItsLayerClipsAsItIs() +{ + const QImage pixels = ringMask(8); + LayerMask mask(LayerMask::assetFrom(pixels)); + QCOMPARE(mask.clipImage(std::nullopt, layer, 100, 100).value().cacheKey(), pixels.cacheKey()); + LayerTransform sameSpot = layer; + sameSpot.sampling = LayerSampling::nearest; + QCOMPARE(mask.clipImage(sameSpot, layer, 100, 100).value().cacheKey(), pixels.cacheKey()); + QCOMPARE(mask.clipImage(rightHalf, layer, 0, 100).value().cacheKey(), pixels.cacheKey()); + QCOMPARE(mask.clipImage(rightHalf, layer, 100, 0).value().cacheKey(), pixels.cacheKey()); + mask.isEnabled = false; + QVERIFY(!mask.clipImage(rightHalf, layer, 100, 100).has_value()); +} + +void MaskPlacementTests::aPlacedMaskIsResampledIntoTheLayerGrid() +{ + const LayerMask mask(LayerMask::assetFrom(ringMask(10))); + const QImage placed = mask.clipImage(rightHalf, layer, 200, 100).value(); + QCOMPARE(placed.format(), QImage::Format_Grayscale8); + QCOMPARE(placed.size(), QSize(200, 100)); + QCOMPARE(value(placed, 20, 50), 255); + QCOMPARE(value(placed, 150, 50), 0); + QCOMPARE(value(placed, 102, 50), 255); + QCOMPARE(value(placed, 150, 2), 255); + const LayerMask hiding(LayerMask::assetFrom(gray(10, 10, 0))); + QCOMPARE(value(hiding.clipImage(rightHalf, layer, 200, 100).value(), 20, 50), 0); +} + +void MaskPlacementTests::theBackgroundComesFromTheThumbnail() +{ + const LayerMask whiteRimBlackThumbnail(ImportedImage(ringMask(10), gray(4, 4, 0), "Layer Mask")); + QCOMPARE(value(whiteRimBlackThumbnail.clipImage(rightHalf, layer, 200, 100).value(), 20, 50), 0); + const LayerMask blackMaskWhiteThumbnail(ImportedImage(gray(10, 10, 0), gray(4, 4, 255), "Layer Mask")); + QCOMPARE(value(blackMaskWhiteThumbnail.clipImage(rightHalf, layer, 200, 100).value(), 20, 50), 255); +} + +void MaskPlacementTests::aLimitShrinksThePlacedGrid() +{ + const LayerMask mask(LayerMask::assetFrom(ringMask(10))); + QCOMPARE(mask.clipImage(rightHalf, layer, 4000, 2000, 100).value().size(), QSize(100, 50)); + QCOMPARE(mask.clipImage(rightHalf, layer, 4001, 2000, 100).value().size(), QSize(100, 50)); + QCOMPARE(mask.clipImage(rightHalf, layer, 300, 90, 100).value().size(), QSize(100, 30)); + QCOMPARE(mask.clipImage(rightHalf, layer, 80, 40, 100).value().size(), QSize(80, 40)); + QCOMPARE(mask.clipImage(rightHalf, layer, 80, 40, 0.5).value().size(), QSize(1, 1)); + const QImage shrunk = mask.clipImage(rightHalf, layer, 4000, 2000, 100).value(); + QCOMPARE(value(shrunk, 10, 25), 255); + QCOMPARE(value(shrunk, 75, 25), 0); +} + +void MaskPlacementTests::placedMasksAreCached() +{ + const LayerMask mask(LayerMask::assetFrom(ringMask(10))); + const QImage first = mask.clipImage(rightHalf, layer, 64, 64).value(); + QCOMPARE(mask.clipImage(rightHalf, layer, 64, 64).value().cacheKey(), first.cacheKey()); + LayerTransform nudged = rightHalf; + nudged.origin.rx() += 1; + QVERIFY(mask.clipImage(nudged, layer, 64, 64).value().cacheKey() != first.cacheKey()); +} + +void MaskPlacementTests::aPlacedMaskThatCannotBeRenderedIsLoggedAndNil() +{ + const LayerMask mask(LayerMask::assetFrom(ringMask(10))); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("a placed mask could not be rendered: .*")); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + const bool rendered = mask.clipImage(rightHalf, layer, 20000, 20000).has_value(); + limit.reset(); + QVERIFY(!rendered); +} + +void MaskPlacementTests::theCacheKeysOnEveryPartOfThePlacement() +{ + MaskPlacementCache cache; + const QImage mask = gray(2, 2, 9); + int builds = 0; + const auto build = [&] { builds += 1; return tiny(); }; + QCOMPARE(cache.image(mask, rightHalf, layer, 10, 20, build).value().size(), QSize(1, 1)); + cache.image(mask, rightHalf, layer, 10, 20, build); + QCOMPARE(builds, 1); + cache.image(mask.copy(), rightHalf, layer, 10, 20, build); + QCOMPARE(builds, 2); + cache.image(mask, layer, layer, 10, 20, build); + QCOMPARE(builds, 3); + cache.image(mask, rightHalf, rightHalf, 10, 20, build); + QCOMPARE(builds, 4); + cache.image(mask, rightHalf, layer, 11, 20, build); + QCOMPARE(builds, 5); + cache.image(mask, rightHalf, layer, 10, 21, build); + QCOMPARE(builds, 6); + cache.image(mask, rightHalf, layer, 10, 20, build); + QCOMPARE(builds, 6); +} + +void MaskPlacementTests::aFailedBuildIsNeverCached() +{ + MaskPlacementCache cache; + int builds = 0; + const auto failing = [&]() -> std::optional<QImage> { builds += 1; return std::nullopt; }; + QVERIFY(!cache.image(gray(2, 2, 9), rightHalf, layer, 10, 20, failing).has_value()); + QVERIFY(!cache.image(gray(2, 2, 9), rightHalf, layer, 10, 20, failing).has_value()); + QCOMPARE(builds, 2); +} + +void MaskPlacementTests::hugeGridsAreReturnedButNotKept() +{ + MaskPlacementCache cache; + const QImage mask = gray(2, 2, 9); + int builds = 0; + const auto build = [&] { builds += 1; return tiny(); }; + cache.image(mask, rightHalf, layer, 10, 10, build); + QVERIFY(cache.image(mask, rightHalf, layer, 8001, 8000, build).has_value()); + cache.image(mask, rightHalf, layer, 8001, 8000, build); + QCOMPARE(builds, 3); + cache.image(mask, rightHalf, layer, 10, 10, build); + QCOMPARE(builds, 3); + cache.image(mask, rightHalf, layer, 7000, 8000, build); + cache.image(mask, rightHalf, layer, 7000, 8000, build); + QCOMPARE(builds, 4); +} + +void MaskPlacementTests::theNinthEntryEvictsTheLeastRecentlyUsed() +{ + MaskPlacementCache cache; + const QImage mask = gray(2, 2, 9); + int builds = 0; + const auto build = [&] { builds += 1; return tiny(); }; + for (int width = 1; width <= 8; ++width) + cache.image(mask, rightHalf, layer, width, 1, build); + cache.image(mask, rightHalf, layer, 1, 1, build); + QCOMPARE(builds, 8); + cache.image(mask, rightHalf, layer, 9, 1, build); + QCOMPARE(builds, 9); + cache.image(mask, rightHalf, layer, 1, 1, build); + QCOMPARE(builds, 9); + cache.image(mask, rightHalf, layer, 2, 1, build); + QCOMPARE(builds, 10); +} + +void MaskPlacementTests::thePixelBudgetEvictsToo() +{ + MaskPlacementCache cache; + const QImage mask = gray(2, 2, 9); + int builds = 0; + const auto build = [&] { builds += 1; return tiny(); }; + for (int height = 4000; height < 4003; ++height) + cache.image(mask, rightHalf, layer, 4000, height, build); + cache.image(mask, rightHalf, layer, 4000, 4000, build); + QCOMPARE(builds, 3); + cache.image(mask, rightHalf, layer, 4000, 4003, build); + QCOMPARE(builds, 4); + cache.image(mask, rightHalf, layer, 4000, 4000, build); + QCOMPARE(builds, 4); + cache.image(mask, rightHalf, layer, 4000, 4001, build); + QCOMPARE(builds, 5); +} + +void MaskPlacementTests::fineMasksArePlacedFromHalvings() +{ + QImage stripes = gray(1024, 16, 0); + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 1024; ++x) + stripes.scanLine(y)[x] = x % 4 == 1 || x % 4 == 2 ? 255 : 0; + } + const LayerMask mask(LayerMask::assetFrom(stripes)); + const LayerTransform wide{.origin = {0, 0}, .size = {256, 16}}; + const LayerTransform narrow{.origin = {64, 0}, .size = {128, 16}}; + const QImage placed = mask.clipImage(narrow, wide, 256, 16).value(); + for (int x = 72; x < 184; ++x) + QVERIFY(std::abs(value(placed, x, 8) - 127) <= 12); +} + +QTEST_MAIN(MaskPlacementTests) +#include "MaskPlacementTests.moc" diff --git a/tests/MaskSignalTests.cpp b/tests/MaskSignalTests.cpp new file mode 100644 index 0000000..bcd280d --- /dev/null +++ b/tests/MaskSignalTests.cpp @@ -0,0 +1,172 @@ +#include "SessionFixtures.h" +#include "SessionRecord.h" +#include <QSignalSpy> +#include <QtTest> + +// The `changed()` contract for masks in the session. +namespace { +std::unique_ptr<EditorSession> twoLayers(QUuid &lower, QUuid &upper) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(8, 8); + for (QUuid *id : {&lower, &upper}) { + session->insert(ImportedImage(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image")); + *id = session->activeLayerID().value(); + } + return session; +} +} + +class MaskSignalTests : public QObject { + Q_OBJECT +private slots: + void masksAnnounceAndRefuseInSilence(); + void theLastSignalOfEveryMaskChangeSeesWhatItLeaves(); + void clippingAnnouncesAndRefusesInSilence(); + void theLastSignalOfEveryClippingChangeSeesWhatItLeaves(); +}; + +void MaskSignalTests::masksAnnounceAndRefuseInSilence() +{ + QUuid lower, upper; + const std::unique_ptr<EditorSession> session = twoLayers(lower, upper); + QSignalSpy changes(session.get(), &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + // No mask yet: nothing to toggle, delete, unlink or copy. + QVERIFY(!announced([&] { session->toggleLayerMask(); })); + QVERIFY(!announced([&] { session->deleteLayerMask(); })); + QVERIFY(!announced([&] { session->toggleMaskLink(upper); })); + QVERIFY(!announced([&] { session->copyMask(upper, lower); })); + QVERIFY(announced([&] { session->addLayerMask(); })); + QVERIFY(!announced([&] { session->addLayerMask(); })); + QVERIFY(announced([&] { session->toggleLayerMask(); })); + QVERIFY(announced([&] { session->toggleMaskLink(upper); })); + QVERIFY(!announced([&] { session->toggleMaskLink(QUuid::createUuid()); })); + QVERIFY(announced([&] { session->selectLayerTarget(upper, false); })); + QVERIFY(announced([&] { session->selectLayerTarget(upper, true); })); + QVERIFY(announced([&] { session->copyMask(upper, lower); })); + QVERIFY(!announced([&] { session->copyMask(upper, upper); })); + QVERIFY(announced([&] { session->deleteLayerMask(); })); + session->setIsImporting(true); + QVERIFY(!announced([&] { session->selectLayerTarget(upper, true); })); + QVERIFY(!announced([&] { session->addLayerMask(); })); + QVERIFY(!announced([&] { session->toggleMaskLink(upper); })); +} + +void MaskSignalTests::theLastSignalOfEveryMaskChangeSeesWhatItLeaves() +{ + QUuid lower, upper; + const std::unique_ptr<EditorSession> session = twoLayers(lower, upper); + QStringList seen; + connect(session.get(), &EditorSession::changed, this, [&] { seen = described(*session); }); + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(*session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session->addLayerMask(false); }), QString()); + QVERIFY(seen.contains("mask target 1") && seen.filter(" mask ").size() == 1); + QCOMPARE(stale([&] { session->toggleLayerMask(); }), QString()); + QCOMPARE(stale([&] { session->selectLayerTarget(upper, false); }), QString()); + QVERIFY(seen.contains("mask target 0")); + QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString()); + QVERIFY(seen.contains("mask target 1")); + QCOMPARE(stale([&] { session->toggleMaskLink(upper); }), QString()); + // The mask alone: begun, moved, committed. + QCOMPARE(stale([&] { session->beginTransform(); }), QString()); + QVERIFY(seen.filter(" mask").filter("edit ").size() == 1); + QCOMPARE(stale([&] { session->nudgeLayer(2, 1); }), QString()); + QCOMPARE(stale([&] { session->commitTransform(); }), QString()); + QVERIFY(seen.contains("names Transform Layer Mask/") && seen.filter("edit ").isEmpty()); + QCOMPARE(stale([&] { session->copyMask(upper, lower); }), QString()); + QVERIFY(seen.contains("active " + lower.toString()) && seen.contains("mask target 1") && seen.filter(" mask ").size() == 2); + // Another layer, an undo, a project: the target is dropped. + QCOMPARE(stale([&] { session->selectLayer(upper); }), QString()); + QVERIFY(seen.contains("mask target 0")); + QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString()); + QCOMPARE(stale([&] { session->deleteLayerMask(); }), QString()); + QVERIFY(seen.contains("mask target 0") && seen.filter(" mask ").size() == 1); + QCOMPARE(stale([&] { session->undo(); }), QString()); + QVERIFY(seen.filter(" mask ").size() == 2); + QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString()); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + QCOMPARE(stale([&] { session->installProject(snapshot, "masks.comp"); }), QString()); + QVERIFY(seen.contains("mask target 0")); + QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString()); + QCOMPARE(stale([&] { session->clearProject(); }), QString()); + QVERIFY(seen.contains("mask target 0")); +} + +void MaskSignalTests::clippingAnnouncesAndRefusesInSilence() +{ + QUuid lower, upper; + const std::unique_ptr<EditorSession> session = twoLayers(lower, upper); + QSignalSpy changes(session.get(), &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + // A layer nobody knows: nothing to clip, free or delete. + const QUuid stranger = QUuid::createUuid(); + QVERIFY(!announced([&] { session->toggleClippingMask(stranger); })); + QVERIFY(!announced([&] { session->removeLiveMask(stranger); })); + QVERIFY(!announced([&] { session->finishDeletingLayer(stranger, {}); })); + QVERIFY(!announced([&] { session->finishDeletingLayers({}, {}); })); + QVERIFY(!session->canToggleClippingMask(stranger)); + // Nothing below to clip to; no link to free. + QVERIFY(!announced([&] { session->toggleClippingMask(lower); })); + QVERIFY(!announced([&] { session->removeLiveMask(upper); })); + QVERIFY(!announced([&] { session->linkMask(upper, upper); })); + QVERIFY(announced([&] { session->toggleClippingMask(upper); })); + // The same link again is no change. + QVERIFY(!announced([&] { session->linkMask(lower, upper); })); + QVERIFY(!announced([&] { session->linkMask(upper, lower); })); + QVERIFY(announced([&] { session->removeLiveMask(upper); })); + QVERIFY(announced([&] { session->linkMask(lower, upper); })); + QVERIFY(announced([&] { session->toggleClippingMask(upper); })); + QVERIFY(announced([&] { session->setBrushError(QString("failed")); })); + QVERIFY(announced([&] { session->setBrushError(std::nullopt); })); + session->setIsImporting(true); + QVERIFY(!announced([&] { session->toggleClippingMask(upper); })); + QVERIFY(!announced([&] { session->linkMask(lower, upper); })); +} + +void MaskSignalTests::theLastSignalOfEveryClippingChangeSeesWhatItLeaves() +{ + QUuid lower, upper; + const std::unique_ptr<EditorSession> session = twoLayers(lower, upper); + QStringList seen; + connect(session.get(), &EditorSession::changed, this, [&] { seen = described(*session); }); + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(*session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session->toggleClippingMask(upper); }), QString()); + QVERIFY(seen.filter("clipped to " + lower.toString()).size() == 1); + QCOMPARE(stale([&] { session->toggleClippingMask(upper); }), QString()); + QVERIFY(seen.filter("clipped to " + lower.toString()).isEmpty()); + QCOMPARE(stale([&] { QVERIFY(session->linkMask(lower, upper)); }), QString()); + QCOMPARE(stale([&] { session->removeLiveMask(upper); }), QString()); + QCOMPARE(stale([&] { QVERIFY(session->linkMask(lower, upper)); }), QString()); + // A move that breaks the stack frees it at once. + QCOMPARE(stale([&] { QVERIFY(session->placeLayer(lower, std::nullopt)); }), QString()); + QVERIFY(seen.filter("clipped to none").size() == 2); + QCOMPARE(stale([&] { session->undo(); }), QString()); + // Base gone, baked pixels in, link gone: one state. + const ImportedImage baked(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Baked"); + QCOMPARE(stale([&] { session->finishDeletingLayer(lower, {{upper, baked}}); }), QString()); + QVERIFY(seen.filter(QStringLiteral("pixels %1").arg(baked.identity().cacheKey)).size() == 1); + QCOMPARE(stale([&] { session->setBrushError(QString("failed")); }), QString()); + QVERIFY(seen.contains("tool error failed")); +} + +QTEST_GUILESS_MAIN(MaskSignalTests) +#include "MaskSignalTests.moc" diff --git a/tests/MaskTracingTests.cpp b/tests/MaskTracingTests.cpp new file mode 100644 index 0000000..330e489 --- /dev/null +++ b/tests/MaskTracingTests.cpp @@ -0,0 +1,186 @@ +#include "Document/BrushStroke.h" +#include "Document/MaskTracing.h" +#include "AddressSpaceLimit.h" +#include <QtTest> + +namespace { +// '#' marks dark mask pixels and opaque layer pixels. +QImage mask(const QStringList &rows, int dark = 0, int light = 255) +{ + QImage image = BrushRaster::context(rows[0].size(), rows.size(), true); + for (int y = 0; y < rows.size(); ++y) { + for (int x = 0; x < rows[y].size(); ++x) + image.scanLine(y)[x] = rows[y][x] == '#' ? dark : light; + } + return image; +} + +QImage layer(const QStringList &rows, int opaque = 255, int clear = 0) +{ + QImage image = BrushRaster::context(rows[0].size(), rows.size(), false); + for (int y = 0; y < rows.size(); ++y) { + for (int x = 0; x < rows[y].size(); ++x) { + const int alpha = rows[y][x] == '#' ? opaque : clear; + image.setPixel(x, y, qRgba(0, 0, 0, alpha)); + } + } + return image; +} + +QStringList filled(const QPainterPath &path, int width, int height) +{ + QImage surface = BrushRaster::context(width, height, true); + QPainter painter(&surface); + painter.fillPath(path, Qt::white); + painter.end(); + QStringList rows; + for (int y = 0; y < height; ++y) { + QString row; + for (int x = 0; x < width; ++x) + row += surface.constScanLine(y)[x] == 255 ? '#' : '.'; + rows << row; + } + return rows; +} + +// The corners of a path with one closed subpath. +QList<QPointF> corners(const QPainterPath &path) +{ + QList<QPointF> result; + for (int index = 0; index + 1 < path.elementCount(); ++index) + result << QPointF(path.elementAt(index)); + return result; +} +} + +class MaskTracingTests : public QObject { + Q_OBJECT +private slots: + void aSinglePixelBecomesAClockwiseSquare(); + void aBlockKeepsOnlyItsFourCorners(); + void anEllShapeHasSixCorners(); + void aRingTracesItsHoleTheOtherWay(); + void everyPatternFillsBackToItself_data(); + void everyPatternFillsBackToItself(); + void darkMeansBelowHalfGray(); + void opaqueMeansAtLeastHalfAlpha(); + void nothingToTraceIsNil(); + void otherPixelFormatsAreConverted(); + void transparentPixelsCountAsDarkOverTheClearedBitmap(); + void allocationFailureIsNil(); + void aDevicePixelRatioNeverShrinksTheTrace(); +}; + +void MaskTracingTests::aSinglePixelBecomesAClockwiseSquare() +{ + const QPainterPath path = MaskTracing::darkPixels(mask({"....", "..#.", "...."})).value(); + QCOMPARE(path.fillRule(), Qt::WindingFill); + QCOMPARE(corners(path), QList<QPointF>({QPointF(2, 1), QPointF(3, 1), QPointF(3, 2), QPointF(2, 2)})); +} + +void MaskTracingTests::aBlockKeepsOnlyItsFourCorners() +{ + const QPainterPath path = MaskTracing::darkPixels(mask({".....", ".###.", ".###.", "....."})).value(); + QCOMPARE(corners(path), QList<QPointF>({QPointF(1, 1), QPointF(4, 1), QPointF(4, 3), QPointF(1, 3)})); +} + +void MaskTracingTests::anEllShapeHasSixCorners() +{ + const QPainterPath path = MaskTracing::darkPixels(mask({"#..", "#..", "###"})).value(); + QCOMPARE(corners(path), QList<QPointF>({QPointF(0, 0), QPointF(1, 0), QPointF(1, 2), QPointF(3, 2), + QPointF(3, 3), QPointF(0, 3)})); +} + +void MaskTracingTests::aRingTracesItsHoleTheOtherWay() +{ + const QStringList ring{"#####", "#...#", "#...#", "#####"}; + const QPainterPath path = MaskTracing::darkPixels(mask(ring)).value(); + QCOMPARE(filled(path, 5, 4), ring); + QVERIFY(path.contains(QPointF(0.5, 0.5))); + QVERIFY(!path.contains(QPointF(2.5, 1.5))); + int moves = 0; + for (int index = 0; index < path.elementCount(); ++index) + moves += path.elementAt(index).isMoveTo(); + QCOMPARE(moves, 2); +} + +void MaskTracingTests::everyPatternFillsBackToItself_data() +{ + QTest::addColumn<QStringList>("rows"); + QTest::newRow("diagonal touch") << QStringList{"#..", ".#.", "..#"}; + QTest::newRow("checkerboard") << QStringList{"#.#.#", ".#.#.", "#.#.#"}; + QTest::newRow("padded width") << QStringList{"##..#", ".###.", "#...#"}; + QTest::newRow("nested") << QStringList{"#######", "#.....#", "#.###.#", "#.#.#.#", "#.###.#", "#.....#", "#######"}; + QTest::newRow("full") << QStringList{"###", "###"}; + QTest::newRow("comb") << QStringList{"#.#.#.#", "#.#.#.#", "#######"}; +} + +void MaskTracingTests::everyPatternFillsBackToItself() +{ + QFETCH(QStringList, rows); + const int width = rows[0].size(), height = rows.size(); + QCOMPARE(filled(MaskTracing::darkPixels(mask(rows)).value(), width, height), rows); + QCOMPARE(filled(MaskTracing::opaquePixels(layer(rows)).value(), width, height), rows); +} + +void MaskTracingTests::darkMeansBelowHalfGray() +{ + QCOMPARE(filled(MaskTracing::darkPixels(mask({"#.", ".."}, 127, 128)).value(), 2, 2), QStringList({"#.", ".."})); + QVERIFY(!MaskTracing::darkPixels(mask({"#.", ".."}, 128, 255)).has_value()); +} + +void MaskTracingTests::opaqueMeansAtLeastHalfAlpha() +{ + QCOMPARE(filled(MaskTracing::opaquePixels(layer({"#.", ".."}, 128, 127)).value(), 2, 2), QStringList({"#.", ".."})); + QVERIFY(!MaskTracing::opaquePixels(layer({"#.", ".."}, 127, 0)).has_value()); +} + +void MaskTracingTests::nothingToTraceIsNil() +{ + QVERIFY(!MaskTracing::darkPixels(mask({"..", ".."})).has_value()); + QVERIFY(!MaskTracing::opaquePixels(layer({"..", ".."})).has_value()); + QVERIFY(!MaskTracing::darkPixels(QImage()).has_value()); + QVERIFY(!MaskTracing::opaquePixels(QImage()).has_value()); +} + +void MaskTracingTests::otherPixelFormatsAreConverted() +{ + const QStringList rows{"#..", ".##"}; + QCOMPARE(filled(MaskTracing::opaquePixels(layer(rows).convertToFormat(QImage::Format_ARGB32)).value(), 3, 2), rows); + QCOMPARE(filled(MaskTracing::darkPixels(mask(rows).convertToFormat(QImage::Format_RGB32)).value(), 3, 2), rows); +} + +void MaskTracingTests::transparentPixelsCountAsDarkOverTheClearedBitmap() +{ + QImage faint = BrushRaster::context(2, 1, false); + faint.setPixel(0, 0, qRgba(16, 16, 16, 16)); + faint.setPixel(1, 0, qRgba(255, 255, 255, 255)); + QCOMPARE(filled(MaskTracing::darkPixels(faint).value(), 2, 1), QStringList({"#."})); + QImage half = BrushRaster::context(2, 1, false); + half.setPixel(0, 0, qRgba(127, 127, 127, 127)); + half.setPixel(1, 0, qRgba(128, 128, 128, 128)); + QCOMPARE(filled(MaskTracing::darkPixels(half).value(), 2, 1), QStringList({"#."})); +} + +void MaskTracingTests::allocationFailureIsNil() +{ + const QImage large = BrushRaster::context(8192, 8192, true); + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + const bool traced = MaskTracing::opaquePixels(large).has_value(); + limit.reset(); + QVERIFY(!traced); +} + +void MaskTracingTests::aDevicePixelRatioNeverShrinksTheTrace() +{ + const QStringList rows{"####", "#..#"}; + QImage dense = layer(rows); + dense.setDevicePixelRatio(2); + QCOMPARE(filled(MaskTracing::opaquePixels(dense).value(), 4, 2), rows); + QImage denseMask = mask(rows); + denseMask.setDevicePixelRatio(2); + QCOMPARE(filled(MaskTracing::darkPixels(denseMask).value(), 4, 2), rows); +} + +QTEST_APPLESS_MAIN(MaskTracingTests) +#include "MaskTracingTests.moc" diff --git a/tests/MaskTransformTests.cpp b/tests/MaskTransformTests.cpp new file mode 100644 index 0000000..ade4659 --- /dev/null +++ b/tests/MaskTransformTests.cpp @@ -0,0 +1,162 @@ +#include "SessionFixtures.h" +#include <QPainter> +#include <QtTest> + +namespace { +const QRect square(100, 50, 100, 100); + +// A red layer whose mask is white but for `square`. +std::unique_ptr<EditorSession> maskedSession(bool revealing = true) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 200); + QImage pixels(400, 200, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::red); + session->insert(ImportedImage(pixels, pixels, "Layer")); + QImage mask(400, 200, QImage::Format_Grayscale8); + mask.fill(revealing ? 255 : 0); + QPainter(&mask).fillRect(square, revealing ? Qt::black : Qt::white); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); }); + return session; +} + +int gray(const QImage &image, int x, int y) +{ + return image.convertToFormat(QImage::Format_Grayscale8).constScanLine(y)[x]; +} + +std::optional<LayerTransform> move(EditorSession &session, double dx) +{ + session.beginTransform(); + if (!session.transformEdit().has_value()) + return std::nullopt; + LayerTransform moved = session.transformEdit().value().draft; + moved.origin.rx() += dx; + session.previewTransform(moved); + return moved; +} + +QImage clip(const ImageLayer &layer) +{ + return layer.mask.value().clipImage(layer.mask.value().placement, layer.transform, 400, 200).value(); +} +} + +class MaskTransformTests : public QObject { + Q_OBJECT +private slots: + void aLinkedMaskMovesWithItsLayerWhicheverThumbnailIsSelected(); + void unlinkedTheLayerMovesAloneAndItsMaskStaysOnTheCanvas(); + void unlinkedTheMaskMovesAloneAndRelinkedTheyMoveTogether(); + void aMovedHideAllMaskKeepsHidingPastItsPixels(); + void placementAndLinkAreSavedAndPaintingFollowsThePlacement(); +}; + +void MaskTransformTests::aLinkedMaskMovesWithItsLayerWhicheverThumbnailIsSelected() +{ + const std::unique_ptr<EditorSession> session = maskedSession(); + const ImageLayer layer = session->activeLayer().value(); + QCOMPARE(layer.transform.origin, QPointF(0, 0)); + QVERIFY(layer.mask.value().isLinked); + session->selectLayerTarget(layer.id, true); + QVERIFY(session->isMaskSelected() && !session->transformTargetsMask()); + const LayerTransform moved = move(*session, 100).value(); + QVERIFY(!session->transformEdit().value().mask); + QCOMPARE(session->displayedMaskPlacement(layer), std::nullopt); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().transform, moved); + QCOMPARE(session->activeLayer().value().mask.value().placement, std::nullopt); + QVERIFY(session->activeLayer().value().mask.value().asset.identity() == layer.mask.value().asset.identity()); +} + +void MaskTransformTests::unlinkedTheLayerMovesAloneAndItsMaskStaysOnTheCanvas() +{ + const std::unique_ptr<EditorSession> session = maskedSession(); + const ImageLayer layer = session->activeLayer().value(); + session->toggleMaskLink(layer.id); + QVERIFY(!session->activeLayer().value().mask.value().isLinked); + session->selectLayerTarget(layer.id, false); + const LayerTransform moved = move(*session, 100).value(); + // The mask stays while the layer drags. + QCOMPARE(session->displayedMaskPlacement(session->activeLayer().value()), std::optional(layer.transform)); + session->commitTransform(); + const ImageLayer after = session->activeLayer().value(); + QCOMPARE(after.transform, moved); + QCOMPARE(after.mask.value().placement, std::optional(layer.transform)); + // The square stayed put: layer x 0 to 100. + const QImage clipped = clip(after); + QVERIFY(gray(clipped, 50, 100) < 5); + QVERIFY(gray(clipped, 150, 100) > 250); + // Past its pixels the mask reveals, like its white edges. + QVERIFY(gray(clipped, 350, 100) > 250); + session->undo(); + QCOMPARE(session->activeLayer().value().transform, layer.transform); + QCOMPARE(session->activeLayer().value().mask.value().placement, std::nullopt); + QVERIFY(!session->activeLayer().value().mask.value().isLinked); +} + +void MaskTransformTests::unlinkedTheMaskMovesAloneAndRelinkedTheyMoveTogether() +{ + const std::unique_ptr<EditorSession> session = maskedSession(); + const ImageLayer layer = session->activeLayer().value(); + session->toggleMaskLink(layer.id); + session->selectLayerTarget(layer.id, true); + QVERIFY(session->transformTargetsMask()); + const LayerTransform moved = move(*session, 100).value(); + QVERIFY(session->transformEdit().value().mask); + // The layer stays put; the handles follow the mask. + QCOMPARE(session->displayedTransform(layer), layer.transform); + QCOMPARE(session->editedTransform(layer), moved); + QCOMPARE(session->displayedMaskPlacement(layer), std::optional(moved)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().transform, layer.transform); + QCOMPARE(session->activeLayer().value().mask.value().placement, std::optional(moved)); + // Moving a mask never resamples it. + QVERIFY(session->activeLayer().value().mask.value().asset.identity() == layer.mask.value().asset.identity()); + QCOMPARE(session->history.undoName(), QString("Transform Layer Mask")); + session->toggleMaskLink(layer.id); + session->selectLayerTarget(layer.id, false); + QVERIFY(move(*session, 50).has_value()); + session->commitTransform(); + const LayerTransform placement = session->activeLayer().value().mask.value().placement.value(); + QVERIFY(std::abs(placement.origin.x() - (moved.origin.x() + 50)) < 0.001 && std::abs(placement.origin.y() - moved.origin.y()) < 0.001); + QVERIFY(std::abs(placement.size.width() - moved.size.width()) < 0.001 && std::abs(placement.size.height() - moved.size.height()) < 0.001); +} + +void MaskTransformTests::aMovedHideAllMaskKeepsHidingPastItsPixels() +{ + const std::unique_ptr<EditorSession> session = maskedSession(false); + const ImageLayer layer = session->activeLayer().value(); + session->toggleMaskLink(layer.id); + session->selectLayerTarget(layer.id, true); + QVERIFY(move(*session, 100).has_value()); + session->commitTransform(); + const QImage clipped = clip(session->activeLayer().value()); + // The uncovered edge stays hidden; the revealed square moved. + QVERIFY(gray(clipped, 20, 100) < 5); + QVERIFY(gray(clipped, 250, 100) > 250); +} + +void MaskTransformTests::placementAndLinkAreSavedAndPaintingFollowsThePlacement() +{ + const std::unique_ptr<EditorSession> session = maskedSession(); + const ImageLayer layer = session->activeLayer().value(); + session->toggleMaskLink(layer.id); + session->selectLayerTarget(layer.id, true); + const LayerTransform moved = move(*session, 100).value(); + session->commitTransform(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + // Through the manifest's JSON and back. + const ProjectManifest decoded = ProjectManifest::decoded(snapshot.manifest.encoded()); + const LayerMask restored = snapshot.mask(decoded.layers.front()).value(); + QCOMPARE(restored.placement, std::optional(moved)); + QVERIFY(!restored.isLinked); + // Mask pixels map through the placement. + const std::unique_ptr<BrushStroke> stroke = session->makeRasterEdit(session->activeLayer().value()); + const QPointF corner = stroke->pixelToDocument.map(QPointF(0, 0)); + QVERIFY(std::abs(corner.x() - moved.origin.x()) < 0.001 && std::abs(corner.y() - moved.origin.y()) < 0.001); +} + +QTEST_GUILESS_MAIN(MaskTransformTests) +#include "MaskTransformTests.moc" diff --git a/tests/MenuFixtures.h b/tests/MenuFixtures.h new file mode 100644 index 0000000..5d76032 --- /dev/null +++ b/tests/MenuFixtures.h @@ -0,0 +1,26 @@ +#pragma once +#include "UI/CompositorMenus.h" +#include "UI/ProjectWorkspaceView.h" +#include <QtTest> + +// Shared by the menu tests: a window and its bar. +struct Bar { + ProjectWorkspace workspace; + ProjectWorkspaceView window{workspace}; + CompositorMenus *menus = window.findChild<CompositorMenus *>(); + EditorSession &session() { return workspace.current().session; } + QAction &action(const char *name) + { + QAction *found = menus->action(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no entry named ") + name); + return *found; + } +}; + +inline ImportedImage white() +{ + QImage image(1, 1, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + return ImportedImage(image, QImage(), "White"); +} diff --git a/tests/NativeLayerListTests.cpp b/tests/NativeLayerListTests.cpp new file mode 100644 index 0000000..ee823ee --- /dev/null +++ b/tests/NativeLayerListTests.cpp @@ -0,0 +1,488 @@ +#include "UI/NativeLayerList.h" +#include "SelectionFixtures.h" +#include <QAction> +#include <QtTest> + +// The layer rows: what they show, select, rename and key. +namespace { +ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(QColor::fromRgba(premultiplied)); + return ImportedImage(image, image, name); +} + +std::unique_ptr<EditorSession> sessionWithThreeLayers() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600); + for (int each = 0; each < 3; ++each) + session->addBlankLayer(); + return session; +} + +// A shown list in an active window. +struct Shown { + EditorSession &session; + NativeLayerList list; + explicit Shown(EditorSession &session) : session(session), list(session) + { + list.resize(252, 400); + list.show(); + if (!QTest::qWaitForWindowActive(&list)) + throw std::runtime_error("the list never became active"); + } + LayerCell &row(int index) { return *list.cells().at(size_t(index)); } + QString name(int index) { return row(index).findChild<QLabel *>("layerName")->text(); } + QString dimensions(int index) { return row(index).findChild<QLabel *>("layerDimensions")->text(); } + // A click on the row's name, away from every control. + void click(int index, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QTest::mouseClick(&row(index), Qt::LeftButton, modifiers, QPoint(row(index).width() - 20, 20)); + } + QSet<QUuid> selected() { return session.selectedLayerIDs(); } + QUuid id(int index) { return row(index).layerID(); } +}; +} + +class NativeLayerListTests : public QObject { + Q_OBJECT +private slots: + void rowsFollowTheHierarchyTopDown(); + void nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity(); + void clicksSelectExtendAndRangeAndTargetPixelsOverAMask(); + void thumbnailsTargetTheLayerOrItsMaskAndShiftTogglesIt(); + void theEyeTogglesAndSwipesInOneStep(); + void disclosureLinkAndTheMenuReachTheSession(); + void renamingTypesInTheRowAndReturnsFocus(); + void layerListToolKeysAndTransformNudge(); + void altClickOnTheStripClipsToTheLayerBelow(); + void thumbnailsRedrawOnlyWhenTheirPictureChanges(); + void ctrlClicksOnThumbnailsLoadSelections(); + void deleteClearsASelectionOrDeletesTheLayer(); +}; + +void NativeLayerListTests::rowsFollowTheHierarchyTopDown() +{ + EditorSession session; + Shown shown(session); + QCOMPARE(shown.list.cells().size(), size_t(0)); + session.createDocument(400, 200); + session.insert(filled(100, 50, qRgba(255, 0, 0, 255), "Red")); + session.addBlankLayer(); + QCOMPARE(shown.list.cells().size(), size_t(2)); + // The top of the document is the top row. + QCOMPARE(shown.name(0), QString("Layer 1")); + QCOMPARE(shown.name(1), QString("Red")); + QCOMPARE(shown.dimensions(0), QString("400 × 200 px")); + // A name shows as typed, never as markup. + QCOMPARE(shown.row(0).findChild<QLabel *>("layerName")->textFormat(), Qt::PlainText); + QCOMPARE(shown.row(0).findChild<QLabel *>("layerDimensions")->textFormat(), Qt::PlainText); + QCOMPARE(shown.dimensions(1), QString("100 × 50 px")); + // The canvas box, 36 wide, at ratio 2. + QCOMPARE(shown.row(1).thumbnail().icon().availableSizes(), QList<QSize>{QSize(72, 36)}); + QVERIFY(!shown.row(1).maskThumbnail().isVisible()); + QVERIFY(!shown.row(1).findChild<QToolButton *>("layerDisclosure")->isVisible()); + // A folder: its child steps in; collapsed, the child goes. + session.selectLayer(shown.id(1)); + session.groupSelectedLayers(); + QCOMPARE(shown.list.cells().size(), size_t(3)); + QCOMPARE(shown.dimensions(1), QString("Folder")); + QVERIFY(shown.row(1).findChild<QToolButton *>("layerDisclosure")->isVisible()); + QCOMPARE(shown.name(2), QString("Red")); + const int stepped = shown.row(2).findChild<QToolButton *>("layerDisclosure")->x(); + QCOMPARE(stepped - shown.row(1).findChild<QToolButton *>("layerDisclosure")->x(), 24); + session.toggleGroupExpansion(shown.id(1)); + QCOMPARE(shown.list.cells().size(), size_t(2)); + session.toggleGroupExpansion(shown.id(1)); + // A clipped layer shows its mark and its source. + session.selectLayer(shown.id(0)); + session.addBlankLayer(); + QVERIFY(session.linkMask(shown.id(1), shown.id(0))); + QCOMPARE(shown.name(0), QString("↳ Layer 2")); + QCOMPARE(shown.dimensions(0), QString("Clipped to Layer 1")); + QCOMPARE(shown.row(0).thumbnail().x() - shown.row(1).thumbnail().x(), 24); + // A hidden layer fades; a mask shows thumbnail and link. + session.toggleLayerVisibility(shown.id(1)); + QCOMPARE(shown.row(1).graphicsEffect()->property("opacity").toDouble(), 0.35); + QCOMPARE(shown.row(0).graphicsEffect()->property("opacity").toDouble(), 1.0); + session.selectLayer(shown.id(1)); + session.addLayerMask(); + QVERIFY(shown.row(1).maskThumbnail().isVisible()); + QVERIFY(shown.row(1).findChild<QToolButton *>("maskLink")->isVisible()); + QCOMPARE(shown.row(1).maskThumbnail().icon().availableSizes(), QList<QSize>{QSize(60, 30)}); + session.toggleLayerMask(); + QVERIFY(shown.row(1).findChild<QLabel *>("maskDisabledMark")->isVisible()); +} + +void NativeLayerListTests::nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + const std::vector<LayerCell *> cells = shown.list.cells(); + const QUuid bottom = session->document().value().layers.front().id; + shown.click(2); + QCOMPARE(session->activeLayerID(), std::optional(bottom)); + QCOMPARE(shown.list.cells(), cells); + QVERIFY(session->placeLayer(bottom, std::nullopt)); + QCOMPARE(shown.id(0), bottom); + QVERIFY(session->selectedLayerIDs().contains(bottom)); + QVERIFY(session->placeLayer(bottom, std::nullopt, std::nullopt, true)); + QCOMPARE(shown.id(2), bottom); + QVERIFY(!session->placeLayer(QUuid::createUuid(), std::nullopt)); + QVERIFY(!session->placeLayer(bottom, std::nullopt, QUuid::createUuid())); + session->setIsImporting(true); + QVERIFY(!session->placeLayer(bottom, std::nullopt)); +} + +void NativeLayerListTests::clicksSelectExtendAndRangeAndTargetPixelsOverAMask() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + shown.click(0); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(0)}); + QVERIFY(shown.list.hasFocus()); + shown.click(2, Qt::ControlModifier); + QCOMPARE(shown.selected(), (QSet<QUuid>{shown.id(0), shown.id(2)})); + shown.click(2, Qt::ControlModifier); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(0)}); + shown.click(2, Qt::ShiftModifier); + QCOMPARE(shown.selected(), (QSet<QUuid>{shown.id(0), shown.id(1), shown.id(2)})); + QCOMPARE(session->activeLayerID(), std::optional(shown.id(2))); + // A plain click inside a multi-selection keeps it. + shown.click(1); + QCOMPARE(shown.selected().size(), 3); + shown.click(1, Qt::ControlModifier); + QCOMPARE(shown.selected().size(), 2); + shown.click(1); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(1)}); + // With the mask targeted, the name takes the layer back. + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + shown.click(1); + QVERIFY(!session->isMaskSelected()); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(1)}); + // Inside a multi-selection the mask target stays put. + QTest::mouseClick(&shown.row(1).maskThumbnail(), Qt::LeftButton); + session->selectLayers({shown.id(1), shown.id(2)}, shown.id(1)); + QVERIFY(session->isMaskSelected()); + shown.click(1); + QVERIFY(session->isMaskSelected()); + QCOMPARE(shown.selected(), (QSet<QUuid>{shown.id(1), shown.id(2)})); +} + +void NativeLayerListTests::thumbnailsTargetTheLayerOrItsMaskAndShiftTogglesIt() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + shown.click(0); + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + QTest::mouseClick(&shown.row(0).thumbnail(), Qt::LeftButton); + QVERIFY(!session->isMaskSelected()); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(0)}); + QTest::mouseClick(&shown.row(0).maskThumbnail(), Qt::LeftButton); + QVERIFY(session->isMaskSelected()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); + QTest::mouseClick(&shown.row(0).maskThumbnail(), Qt::LeftButton, Qt::ShiftModifier); + QVERIFY(!session->activeLayer().value().mask.value().isEnabled && session->isMaskSelected()); + QVERIFY(shown.row(0).maskThumbnail().isTargeted() && !shown.row(0).thumbnail().isTargeted()); + // Shift on the layer thumbnail ranges the rows, targets nothing. + QTest::mouseClick(&shown.row(2).thumbnail(), Qt::LeftButton, Qt::ShiftModifier); + QCOMPARE(shown.selected(), (QSet<QUuid>{shown.id(0), shown.id(1), shown.id(2)})); + QVERIFY(!layerWith(*session, shown.id(0)).mask.value().isEnabled); + QCOMPARE(session->activeLayerID(), std::optional(shown.id(2))); + // Alt on a thumbnail is the row's; the target stays. + session->selectLayers({shown.id(0)}, shown.id(0)); + QTest::mouseClick(&shown.row(0).maskThumbnail(), Qt::LeftButton); + QTest::mouseClick(&shown.row(0).thumbnail(), Qt::LeftButton, Qt::AltModifier); + QVERIFY(session->isMaskSelected()); + // Another row's thumbnail selects that row; Ctrl selects none. + QTest::mouseClick(&shown.row(0).thumbnail(), Qt::LeftButton); + QTest::mouseClick(&shown.row(2).thumbnail(), Qt::LeftButton); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(2)}); + QVERIFY(!session->isMaskSelected()); + QVERIFY(shown.row(2).thumbnail().isTargeted() && !shown.row(0).thumbnail().isTargeted()); + // A multi-selection shows no ring. + session->extendSelection(shown.id(0)); + QVERIFY(!shown.row(2).thumbnail().isTargeted() && !shown.row(0).thumbnail().isTargeted()); + session->selectLayers({shown.id(2)}, shown.id(2)); + QTest::mouseClick(&shown.row(0).thumbnail(), Qt::LeftButton, Qt::ControlModifier); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(2)}); + // Importing, thumbnails answer no click. + session->setIsImporting(true); + QVERIFY(!shown.row(0).thumbnail().isEnabled() && !shown.row(0).maskThumbnail().isEnabled()); + QTest::mouseClick(&shown.row(0).thumbnail(), Qt::LeftButton); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(2)}); +} + +void NativeLayerListTests::theEyeTogglesAndSwipesInOneStep() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + auto *eye = shown.row(0).findChild<EyeSwipeButton *>("layerEye"); + QCOMPARE(eye->accessibleName(), QString("Hide Layer 3")); + QTest::mouseClick(eye, Qt::LeftButton); + QVERIFY(!session->document().value().layers.back().isVisible); + QCOMPARE(eye->accessibleName(), QString("Show Layer 3")); + QCOMPARE(session->history.undoName(), QString("Hide Layer")); + // Dragged down a row: the eyes passed change, no other. + session->toggleLayerVisibility(shown.id(1)); + session->toggleLayerVisibility(shown.id(2)); + const int steps = session->history.undoCount(); + const QPoint end(10, 16 + LayerCell::rowHeight); + QTest::mousePress(eye, Qt::LeftButton, Qt::NoModifier, QPoint(10, 16)); + QMouseEvent move(QEvent::MouseMove, QPointF(end), eye->mapToGlobal(end), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(eye, &move); + QTest::mouseRelease(eye, Qt::LeftButton, Qt::NoModifier, end); + QVERIFY(layerWith(*session, shown.id(0)).isVisible && layerWith(*session, shown.id(1)).isVisible); + QVERIFY(!layerWith(*session, shown.id(2)).isVisible); + 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. + session->setIsProjectBusy(true); + QVERIFY(!eye->isEnabled()); +} + +void NativeLayerListTests::disclosureLinkAndTheMenuReachTheSession() +{ + EditorSession session; + session.createDocument(20, 10); + session.insert(filled(4, 4, qRgba(0, 0, 255, 255), "Blue")); + session.insert(filled(4, 4, qRgba(0, 255, 0, 255), "Green")); + session.groupSelectedLayers(); + Shown shown(session); + const QUuid folder = shown.id(0), green = shown.id(1), blue = shown.id(2); + QTest::mouseClick(shown.row(0).findChild<QToolButton *>("layerDisclosure"), Qt::LeftButton); + QVERIFY(session.collapsedGroupIDs().contains(folder)); + QCOMPARE(shown.list.cells().size(), size_t(2)); + QTest::mouseClick(shown.row(0).findChild<QToolButton *>("layerDisclosure"), Qt::LeftButton); + QCOMPARE(shown.list.cells().size(), size_t(3)); + // The green row's menu acts on green, whatever is active. + session.selectLayer(blue); + const auto trigger = [&](int row, const char *name) { shown.row(row).findChild<QAction *>(QString::fromLatin1(name))->trigger(); }; + trigger(1, "addBlackMask"); + QVERIFY(layerWith(session, green).mask.has_value()); + QCOMPARE(layerWith(session, green).mask.value().asset.image().pixelColor(0, 0), QColor(0, 0, 0)); + QCOMPARE(session.activeLayerID(), std::optional(green)); + QTest::mouseClick(shown.row(1).findChild<QToolButton *>("maskLink"), Qt::LeftButton); + QVERIFY(!layerWith(session, green).mask.value().isLinked); + trigger(1, "toggleMask"); + QVERIFY(!layerWith(session, green).mask.value().isEnabled); + trigger(1, "deleteMask"); + QVERIFY(!layerWith(session, green).mask.has_value()); + trigger(1, "addWhiteMask"); + QCOMPARE(layerWith(session, green).mask.value().asset.image().pixelColor(0, 0), QColor(255, 255, 255)); + trigger(1, "toggleVisibility"); + QVERIFY(!layerWith(session, green).isVisible); + QCOMPARE(layerWith(session, green).parentID, std::optional(folder)); + trigger(1, "moveOut"); + QVERIFY(!layerWith(session, green).parentID.has_value()); + const auto rowOf = [&](QUuid id) { + for (int row = 0; row < 3; ++row) { + if (shown.id(row) == id) + return row; + } + throw std::runtime_error("no row"); + }; + trigger(rowOf(folder), "renameLayer"); + QCOMPARE(session.renamingLayerID(), std::optional(folder)); + session.setRenamingLayerID(std::nullopt); + // Delete takes the row's layer, or its whole selection. + session.selectLayers({green, blue}, blue); + trigger(rowOf(green), "deleteLayer"); + QCOMPARE(session.document().value().layers.size(), size_t(1)); + trigger(0, "deleteLayer"); + QVERIFY(session.document().value().layers.empty()); +} + +void NativeLayerListTests::renamingTypesInTheRowAndReturnsFocus() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + shown.click(1); + QTest::mouseDClick(&shown.row(1), Qt::LeftButton, Qt::NoModifier, QPoint(shown.row(1).width() - 20, 20)); + QCOMPARE(session->renamingLayerID(), std::optional(shown.id(1))); + auto *editor = shown.row(1).findChild<QLineEdit *>("layerNameEditor"); + QTRY_VERIFY(editor->isVisible() && editor->hasFocus()); + QCOMPARE(editor->selectedText(), QString("Layer 2")); + QTest::keyClicks(editor, "Sky"); + QTest::keyClick(editor, Qt::Key_Return); + QCOMPARE(layerWith(*session, shown.id(1)).name, QString("Sky")); + QVERIFY(!session->renamingLayerID().has_value()); + QVERIFY(!editor->isVisible() && shown.list.hasFocus()); + QCOMPARE(shown.name(1), QString("Sky")); + // Escape keeps the old name; leaving keeps what was typed. + session->setRenamingLayerID(shown.id(1)); + QTRY_VERIFY(editor->isVisible()); + QTest::keyClicks(editor, "Nope"); + QTest::keyClick(editor, Qt::Key_Escape); + QCOMPARE(layerWith(*session, shown.id(1)).name, QString("Sky")); + QVERIFY(!session->renamingLayerID().has_value()); + session->setRenamingLayerID(shown.id(1)); + QTRY_VERIFY(editor->isVisible()); + editor->selectAll(); + QTest::keyClicks(editor, "Sea"); + shown.list.setFocus(); + QCOMPARE(layerWith(*session, shown.id(1)).name, QString("Sea")); + // A pending rename closes the edits: nothing moves under it. + session->setRenamingLayerID(shown.id(1)); + QTRY_VERIFY(editor->isVisible()); + QVERIFY(!session->placeLayer(shown.id(1), std::nullopt)); + QVERIFY(!session->canEditLayers()); + QTest::keyClick(editor, Qt::Key_Escape); + // A pixel thumbnail's double click renames; busy, nothing. + QTest::mouseDClick(&shown.row(1).thumbnail(), Qt::LeftButton); + QCOMPARE(session->renamingLayerID(), std::optional(shown.id(1))); + QTest::keyClick(editor, Qt::Key_Escape); + session->setIsProjectBusy(true); + session->setRenamingLayerID(shown.id(1)); + QVERIFY(!editor->isVisible()); +} + +void NativeLayerListTests::layerListToolKeysAndTransformNudge() +{ + EditorSession session; + session.createDocument(100, 100); + session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red")); + Shown shown(session); + shown.list.setFocus(); + QTest::keyClick(&shown.list, Qt::Key_H); + QCOMPARE(session.tool(), NavigationTool::hand); + QTest::keyClick(&shown.list, Qt::Key_V); + QCOMPARE(session.tool(), NavigationTool::move); + session.beginTransform(); + QTest::keyClick(&shown.list, Qt::Key_Right); + QCOMPARE(session.transformEdit().value().draft.origin.x(), 46.0); + QCOMPARE(session.activeLayer().value().origin().x(), 45.0); + QTest::keyClick(&shown.list, Qt::Key_Escape); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.activeLayer().value().origin().x(), 45.0); + QTest::keyClick(&shown.list, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.activeLayer().value().origin().y(), 55.0); + QTest::keyClick(&shown.list, Qt::Key_Return); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.activeLayer().value().origin().y(), 55.0); + // Ctrl-letters are the menu's; other tools' arrows walk rows. + QTest::keyClick(&shown.list, Qt::Key_H, Qt::ControlModifier); + QCOMPARE(session.tool(), NavigationTool::move); + session.addBlankLayer(); + session.selectTool(NavigationTool::brush); + QCOMPARE(session.activeLayerID(), std::optional(shown.id(0))); + QTest::keyClick(&shown.list, Qt::Key_Down); + QCOMPARE(session.activeLayerID(), std::optional(shown.id(1))); + QTest::keyClick(&shown.list, Qt::Key_Down); + QCOMPARE(session.activeLayerID(), std::optional(shown.id(1))); + QTest::keyClick(&shown.list, Qt::Key_Up); + QCOMPARE(session.activeLayerID(), std::optional(shown.id(0))); + QTest::keyClick(&shown.list, Qt::Key_E); + QCOMPARE(session.tool(), NavigationTool::brush); +} + +void NativeLayerListTests::altClickOnTheStripClipsToTheLayerBelow() +{ + const auto session = sessionWithThreeLayers(); + Shown shown(*session); + const QPoint strip(shown.row(0).width() - 20, LayerCell::rowHeight - 4); + QTest::mouseClick(&shown.row(0), Qt::LeftButton, Qt::AltModifier, strip); + QCOMPARE(layerWith(*session, shown.id(0)).maskSourceID, std::optional(shown.id(1))); + QCOMPARE(shown.name(0), QString("↳ Layer 3")); + QTest::mouseClick(&shown.row(0), Qt::LeftButton, Qt::AltModifier, strip); + QVERIFY(!layerWith(*session, shown.id(0)).maskSourceID.has_value()); + // Above the strip Alt selects plainly; Ctrl-Alt too. + QTest::mouseClick(&shown.row(2), Qt::LeftButton, Qt::AltModifier, QPoint(shown.row(2).width() - 20, 20)); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(2)}); + QVERIFY(!layerWith(*session, shown.id(2)).maskSourceID.has_value()); + QTest::mouseClick(&shown.row(0), Qt::LeftButton, Qt::AltModifier, QPoint(shown.row(0).width() - 20, LayerCell::rowHeight - 14)); + QCOMPARE(shown.selected(), QSet<QUuid>{shown.id(0)}); + QVERIFY(!layerWith(*session, shown.id(0)).maskSourceID.has_value()); + QTest::mouseClick(&shown.row(0), Qt::LeftButton, Qt::AltModifier | Qt::ControlModifier, strip); + QVERIFY(!layerWith(*session, shown.id(0)).maskSourceID.has_value()); +} + +void NativeLayerListTests::thumbnailsRedrawOnlyWhenTheirPictureChanges() +{ + EditorSession session; + session.createDocument(40, 20); + session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red")); + Shown shown(session); + const qint64 before = shown.row(0).thumbnail().icon().cacheKey(); + session.renameLayer(shown.id(0), "Rose"); + session.selectLayer(shown.id(0)); + session.setLayerOpacity(0.5); + QCOMPARE(shown.row(0).thumbnail().icon().cacheKey(), before); + session.nudgeLayer(1, 0); + QVERIFY(shown.row(0).thumbnail().icon().cacheKey() != before); + const qint64 moved = shown.row(0).thumbnail().icon().cacheKey(); + session.addLayerMask(); + QCOMPARE(shown.row(0).thumbnail().icon().cacheKey(), moved); + const qint64 mask = shown.row(0).maskThumbnail().icon().cacheKey(); + session.toggleLayerMask(); + QCOMPARE(shown.row(0).maskThumbnail().icon().cacheKey(), mask); +} + +void NativeLayerListTests::deleteClearsASelectionOrDeletesTheLayer() +{ + const auto session = sessionWithThreeLayers(); + QImage red(8, 8, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session->insert(ImportedImage(red, red, "Red")); + Shown shown(*session); + shown.list.setFocus(); + // The red sits mid-canvas; the selection takes its left half. + const QPointF origin = session->activeLayer().value().transform.origin; + session->applySelection(rectPath(QRectF(origin, QSizeF(4, 8))), SelectionMode::replace, "Select"); + QTest::keyClick(&shown.list, Qt::Key_Delete); + QTRY_COMPARE(session->history.undoName(), QString("Clear")); + session->deselect(); + const int layers = int(session->document().value().layers.size()); + QTest::keyClick(&shown.list, Qt::Key_Backspace); + QCOMPARE(int(session->document().value().layers.size()), layers - 1); + // With Ctrl the key is not Delete's. + QTest::keyClick(&shown.list, Qt::Key_Delete, Qt::ControlModifier); + QCOMPARE(int(session->document().value().layers.size()), layers - 1); +} + +QTEST_MAIN(NativeLayerListTests) +#include "NativeLayerListTests.moc" + +void NativeLayerListTests::ctrlClicksOnThumbnailsLoadSelections() +{ + EditorSession session; + session.createDocument(400, 200); + session.insert(filled(100, 50, qRgba(255, 0, 0, 255), "Red"), QPointF(100, 100)); + const QUuid red = session.activeLayerID().value(); + QImage block(100, 50, QImage::Format_Grayscale8); + block.fill(Qt::white); + for (int y = 10; y < 30; ++y) + std::fill_n(block.scanLine(y) + 10, 20, uchar(0)); + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, red, LayerMask::assetFrom(block)); }); + session.selectLayerTarget(red, false); + Shown shown(session); + LayerCell &row = shown.row(0); + QCOMPARE(row.thumbnail().toolTip(), QString("Select image pixels")); + QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + // Ctrl on the picture: its opaque pixels, the row untouched. + QTest::mouseClick(&row.thumbnail(), Qt::LeftButton, Qt::ControlModifier); + QCOMPARE(session.history.undoName(), QString("Load Layer Selection")); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(50, 75, 100, 50)); + QVERIFY(!session.isMaskSelected() && row.thumbnail().isTargeted()); + // Ctrl-Alt on the mask subtracts; Ctrl-Shift adds back. + QTest::mouseClick(&row.maskThumbnail(), Qt::LeftButton, Qt::ControlModifier | Qt::AltModifier); + QCOMPARE(session.history.undoName(), QString("Load Mask Selection")); + QCOMPARE(int(session.selection().value().coverage(400, 200).constScanLine(90)[70]), 0); + QCOMPARE(int(session.selection().value().coverage(400, 200).constScanLine(90)[120]), 255); + QVERIFY(!session.isMaskSelected()); + session.applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select"); + QTest::mouseClick(&row.maskThumbnail(), Qt::LeftButton, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(0, 0, 80, 105)); + // Plain Ctrl replaces; a Ctrl double click loads twice. + session.selectAll(); + QTest::mouseClick(&row.maskThumbnail(), Qt::LeftButton, Qt::ControlModifier); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(60, 85, 20, 20)); + const int count = session.history.undoCount(); + QTest::mouseDClick(&row.thumbnail(), Qt::LeftButton, Qt::ControlModifier); + QCOMPARE(session.history.undoCount(), count + 1); + QVERIFY(!row.isRenaming()); +} diff --git a/tests/NavigationToolHeaderTests.cpp b/tests/NavigationToolHeaderTests.cpp new file mode 100644 index 0000000..dc8c501 --- /dev/null +++ b/tests/NavigationToolHeaderTests.cpp @@ -0,0 +1,230 @@ +#include "UI/NavigationToolHeader.h" +#include <QtTest> + +// The Hand and Zoom tools' bar, and Zoom's percentage field. +namespace { +struct Bar { + EditorSession session; + std::unique_ptr<NavigationToolHeader> header; + QLineEdit *field = nullptr; + + explicit Bar(bool withCanvas = true) + { + if (withCanvas) + session.createDocument(800, 600); + session.selectTool(NavigationTool::zoom); + header = std::make_unique<NavigationToolHeader>(session); + field = header->findChild<QLineEdit *>("zoomPercentage"); + header->show(); + // Focus is real only in an active window. + header->activateWindow(); + active = QTest::qWaitForWindowActive(header.get()); + } + bool active = false; + + void type(const QString &text) + { + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, text); + } +}; +} + +class NavigationToolHeaderTests : public QObject { + Q_OBJECT +private slots: + void theTitleAndTheFieldFollowTheTool(); + void theFieldShowsTheZoomWithoutIdleZeros(); + void returnEscapeAndLeavingAllApplyWhatWasTyped(); + void whatIsNoZoomIsPutBack(); + void arrowsStepOnePercentAndShiftTen(); + void theFieldNeedsACanvasAndAnIdleSession(); + void anOutsideZoomLeavesTypingAlone(); +}; + +void NavigationToolHeaderTests::theTitleAndTheFieldFollowTheTool() +{ + Bar bar; + QVERIFY(bar.active); + QCOMPARE(bar.header->title->text(), QString("Zoom")); + QVERIFY(bar.field->isVisible()); + QCOMPARE(bar.field->width(), 72); + QCOMPARE(bar.field->alignment(), Qt::AlignRight | Qt::AlignVCenter); + QCOMPARE(bar.field->accessibleName(), QString("Zoom percentage")); + QCOMPARE(bar.field->toolTip(), QString("Zoom percentage (0.1–3200%). Press Return to apply.")); + QCOMPARE(bar.header->height(), 42); + // The unit sits right of its field, two pixels away. + const QList<QLabel *> labels = bar.header->findChildren<QLabel *>(); + const auto unit = std::find_if(labels.begin(), labels.end(), [](const QLabel *label) { return label->text() == "%"; }); + QVERIFY(unit != labels.end()); + QCoreApplication::processEvents(); + QCOMPARE((*unit)->geometry().left() - bar.field->geometry().right() - 1, 2); + bar.session.selectTool(NavigationTool::hand); + QCOMPARE(bar.header->title->text(), QString("Pan")); + QVERIFY(!bar.field->isVisible() && !(*unit)->isVisible()); + bar.session.selectTool(NavigationTool::zoom); + QVERIFY(bar.field->isVisible() && (*unit)->isVisible()); +} + +void NavigationToolHeaderTests::theFieldShowsTheZoomWithoutIdleZeros() +{ + Bar bar; + // A field in use is left alone; not this one. + bar.field->clearFocus(); + // A bar made over a zoomed session shows that zoom. + bar.session.zoom(0.5); + NavigationToolHeader late(bar.session); + QCOMPARE(late.findChild<QLineEdit *>("zoomPercentage")->text(), QString("50")); + const auto shown = [&](double zoom) { + bar.session.zoom(zoom); + return bar.field->text(); + }; + QCOMPARE(shown(1), QString("100")); + QCOMPARE(shown(0.125), QString("12.5")); + QCOMPARE(shown(1.0 / 3), QString("33.33")); + QCOMPARE(shown(2.5), QString("250")); + QCOMPARE(shown(0.001), QString("0.1")); + QCOMPARE(shown(32), QString("3200")); + QCOMPARE(shown(0.1), QString("10")); +} + +void NavigationToolHeaderTests::returnEscapeAndLeavingAllApplyWhatWasTyped() +{ + Bar bar; + bar.type("250"); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), 2.5); + QVERIFY(!bar.field->hasFocus()); + QCOMPARE(bar.session.canvasFocusRequest(), 1); + // Applied once: Return asks for the canvas, zooms no more. + QSignalSpy changes(&bar.session, &EditorSession::changed); + bar.field->setFocus(); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(changes.count(), 1); + QCOMPARE(bar.session.viewport.zoom(), 2.5); + QCOMPARE(bar.session.canvasFocusRequest(), 2); + bar.type(" 50% "); + QTest::keyClick(bar.field, Qt::Key_Escape); + QCOMPARE(bar.session.viewport.zoom(), 0.5); + QCOMPARE(bar.field->text(), QString("50")); + QVERIFY(!bar.field->hasFocus()); + QCOMPARE(bar.session.canvasFocusRequest(), 3); + bar.type("75"); + QTest::keyClick(bar.field, Qt::Key_Enter); + QCOMPARE(bar.session.viewport.zoom(), 0.75); + QCOMPARE(bar.session.canvasFocusRequest(), 4); + // Leaving the field applies as well, asking for no focus. + bar.type("125"); + bar.field->clearFocus(); + QCOMPARE(bar.session.viewport.zoom(), 1.25); + QCOMPARE(bar.session.canvasFocusRequest(), 4); + // Past the range the viewport decides; the field follows. + bar.type("99999"); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), 32.0); + QCOMPARE(bar.field->text(), QString("3200")); + // Behind another window nothing loses focus: Return applies. + Bar behind; + QWidget front; + front.show(); + front.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&front)); + behind.field->setFocus(); + behind.field->selectAll(); + QTest::keyClicks(behind.field, "42"); + QTest::keyClick(behind.field, Qt::Key_Return); + QCOMPARE(behind.session.viewport.zoom(), 0.42); +} + +void NavigationToolHeaderTests::whatIsNoZoomIsPutBack() +{ + Bar bar; + bar.session.zoom(2); + QSignalSpy changes(&bar.session, &EditorSession::changed); + for (const QString &bad : {QString("wide"), QString(""), QString("0"), QString("-40"), QString("nan"), QString("inf"), QString("1e999")}) { + bar.type(bad); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), 2.0); + QCOMPARE(bar.field->text(), QString("200")); + } + // Each Return asks for the canvas; none zooms. + QCOMPARE(changes.count(), 7); + QCOMPARE(bar.session.canvasFocusRequest(), 7); + // A busy project takes no zoom from the field. + bar.session.setIsProjectBusy(true); + bar.type("300"); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), 2.0); + QCOMPARE(bar.field->text(), QString("200")); + // Untouched text is not applied: no rounding creeps in. + bar.session.setIsProjectBusy(false); + bar.session.zoom(1.0 / 3); + const double third = bar.session.viewport.zoom(); + bar.field->setFocus(); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), third); +} + +void NavigationToolHeaderTests::arrowsStepOnePercentAndShiftTen() +{ + Bar bar; + bar.session.zoom(1); + bar.field->setFocus(); + QTest::keyClick(bar.field, Qt::Key_Up); + QCOMPARE(bar.session.viewport.zoom(), 1.01); + QCOMPARE(bar.field->text(), QString("101")); + QVERIFY(bar.field->hasFocus()); + QTest::keyClick(bar.field, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(bar.session.viewport.zoom(), 0.91); + QTest::keyClick(bar.field, Qt::Key_Up, Qt::ShiftModifier); + QTest::keyClick(bar.field, Qt::Key_Down); + QCOMPARE(bar.field->text(), QString("100")); + // A step starts from what is typed, percent sign included. + bar.type("40%"); + QTest::keyClick(bar.field, Qt::Key_Up); + QCOMPARE(bar.session.viewport.zoom(), 0.41); + // The ends of the range hold. + bar.type("3195"); + QTest::keyClick(bar.field, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(bar.field->text(), QString("3200")); + bar.type("0.5"); + QTest::keyClick(bar.field, Qt::Key_Down); + QCOMPARE(bar.field->text(), QString("0.1")); + // Text that is no number steps from the current zoom. + bar.session.zoom(2); + bar.type("wide"); + QTest::keyClick(bar.field, Qt::Key_Up); + QCOMPARE(bar.field->text(), QString("201")); +} + +void NavigationToolHeaderTests::theFieldNeedsACanvasAndAnIdleSession() +{ + Bar bare(false); + QVERIFY(!bare.field->isEnabled()); + bare.session.createDocument(8, 8); + QVERIFY(bare.field->isEnabled()); + // Busy dims only once it shows, a quarter second in. + bare.session.setIsProjectBusy(true); + QVERIFY(bare.field->isEnabled()); + QTRY_VERIFY(!bare.field->isEnabled()); + bare.session.setIsProjectBusy(false); + QVERIFY(bare.field->isEnabled()); +} + +void NavigationToolHeaderTests::anOutsideZoomLeavesTypingAlone() +{ + Bar bar; + bar.type("77"); + // A zoom from elsewhere arrives while the user types. + bar.session.zoom(3); + QVERIFY(bar.field->hasFocus()); + QCOMPARE(bar.field->text(), QString("77")); + QTest::keyClick(bar.field, Qt::Key_Return); + QCOMPARE(bar.session.viewport.zoom(), 0.77); + bar.session.zoom(3); + QCOMPARE(bar.field->text(), QString("300")); +} + +QTEST_MAIN(NavigationToolHeaderTests) +#include "NavigationToolHeaderTests.moc" diff --git a/tests/NewCanvasSheetTests.cpp b/tests/NewCanvasSheetTests.cpp new file mode 100644 index 0000000..531f93b --- /dev/null +++ b/tests/NewCanvasSheetTests.cpp @@ -0,0 +1,231 @@ +#include "UI/NewCanvasSheet.h" +#include <QBuffer> +#include <QClipboard> +#include <QImageReader> +#include <QImageWriter> +#include <QtTest> + +// The welcome sheet: sizes, its three ways on, the clipboard. +namespace { +struct Sheet { + EditorSession session; + QList<QSize> created; + int opened = 0; + NewCanvasSheet sheet{session, [this](int width, int height) { created << QSize(width, height); }, [this] { opened += 1; }}; + QLineEdit &width = *sheet.findChild<QLineEdit *>("widthInput"); + QLineEdit &height = *sheet.findChild<QLineEdit *>("heightInput"); + QLabel ¬e = *sheet.findChild<QLabel *>("canvasNote"); + QPushButton &create = *sheet.findChild<QPushButton *>("createCanvas"); +}; + +QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone) +{ + QByteArray bytes; + QBuffer buffer(&bytes); + QImageWriter writer(&buffer, format); + writer.setTransformation(turned); + if (!writer.write(image)) + throw std::runtime_error("the fixture could not be encoded"); + return bytes; +} +} + +class NewCanvasSheetTests : public QObject { + Q_OBJECT +private slots: + void cleanup(); + void itStartsAtFullHighDefinition(); + void onlyWholeNumbersInRangeMayCreate(); + void returnInEitherFieldCreates(); + void theOtherTwoButtonsOpenAndImport(); + void itGivesWayWhileTheSessionWorks(); + void aCopiedPicturesSizeIsSuggestedOnce(); + void theFirstTabSkipsTheClipboardOnce(); + void clipboardSizesComeFromHeadersAlone(); +}; + +void NewCanvasSheetTests::cleanup() +{ + QApplication::clipboard()->clear(); +} + +void NewCanvasSheetTests::itStartsAtFullHighDefinition() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + QCOMPARE(fixture.width.text(), QString("1920")); + QCOMPARE(fixture.height.text(), QString("1080")); + QCOMPARE(fixture.note.text(), QString("Transparent canvas · sRGB")); + QCOMPARE(fixture.note.foregroundRole(), QPalette::PlaceholderText); + QVERIFY(fixture.create.isEnabled() && fixture.create.isDefault() && fixture.width.hasFocus()); + QCOMPARE(fixture.sheet.width(), 500); + QTest::mouseClick(&fixture.create, Qt::LeftButton); + QCOMPARE(fixture.created, (QList<QSize>{QSize(1920, 1080)})); +} + +void NewCanvasSheetTests::onlyWholeNumbersInRangeMayCreate() +{ + Sheet fixture; + // Shown and active, so that Return reaches the sheet. + fixture.sheet.show(); + fixture.sheet.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&fixture.sheet)); + const auto valid = [&](const QString &width, const QString &height) { + fixture.width.setText(width); + fixture.height.setText(height); + return fixture.create.isEnabled(); + }; + QVERIFY(valid("1", "30000")); + QVERIFY(valid("30000", "1")); + QVERIFY(valid(" 640 ", "480")); + // Either field alone refuses, by button, Return and Enter. + const auto refused = [&] { + const bool warned = fixture.note.text() == "Enter whole numbers from 1 to 30,000 pixels." && fixture.note.foregroundRole() == QPalette::BrightText; + fixture.create.click(); + QTest::keyClick(&fixture.width, Qt::Key_Return); + QTest::keyClick(&fixture.height, Qt::Key_Enter); + fixture.sheet.findChild<QPushButton *>("openProject")->setFocus(); + QTest::keyClick(&fixture.sheet, Qt::Key_Return); + return warned && fixture.created.isEmpty(); + }; + for (const QString &bad : {QString("0"), QString("30001"), QString(""), QString("12.5"), QString("-4"), QString("1e3"), QString("wide")}) { + QVERIFY2(!valid(bad, "480") && refused(), qPrintable(bad)); + QVERIFY2(!valid("640", bad) && refused(), qPrintable(bad)); + } + QVERIFY(fixture.created.isEmpty() && fixture.opened == 0); + QVERIFY(valid("640", "480")); + QCOMPARE(fixture.note.text(), QString("Transparent canvas · sRGB")); +} + +void NewCanvasSheetTests::returnInEitherFieldCreates() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + fixture.width.setText("300"); + fixture.height.setText(" 200"); + QTest::keyClick(&fixture.width, Qt::Key_Return); + QTest::keyClick(&fixture.height, Qt::Key_Return); + QCOMPARE(fixture.created, (QList<QSize>{QSize(300, 200), QSize(300, 200)})); + // Return with a button in focus creates as well. + fixture.sheet.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&fixture.sheet)); + fixture.sheet.findChild<QPushButton *>("openProject")->setFocus(); + QTest::keyClick(&fixture.sheet, Qt::Key_Return); + fixture.sheet.findChild<QPushButton *>("importImage")->setFocus(); + QTest::keyClick(&fixture.sheet, Qt::Key_Enter); + QCOMPARE(fixture.created.size(), 4); + QCOMPARE(fixture.opened, 0); + QVERIFY(!fixture.session.showsImporter()); +} + +void NewCanvasSheetTests::theOtherTwoButtonsOpenAndImport() +{ + Sheet fixture; + QSignalSpy changes(&fixture.session, &EditorSession::changed); + fixture.sheet.findChild<QPushButton *>("openProject")->click(); + QCOMPARE(fixture.opened, 1); + QCOMPARE(changes.count(), 0); + QVERIFY(!fixture.session.showsImporter()); + fixture.sheet.findChild<QPushButton *>("importImage")->click(); + QVERIFY(fixture.session.showsImporter()); + QVERIFY(fixture.created.isEmpty() && fixture.opened == 1); +} + +void NewCanvasSheetTests::itGivesWayWhileTheSessionWorks() +{ + Sheet fixture; + QVERIFY(fixture.sheet.isEnabled()); + fixture.session.setIsImporting(true); + QVERIFY(!fixture.sheet.isEnabled()); + fixture.session.setIsImporting(false); + QVERIFY(fixture.sheet.isEnabled()); + // Busy dims only once it shows, a quarter second in. + fixture.session.setIsProjectBusy(true); + QVERIFY(fixture.sheet.isEnabled()); + QTRY_VERIFY(!fixture.sheet.isEnabled()); + fixture.session.setIsProjectBusy(false); + QVERIFY(fixture.sheet.isEnabled()); +} + +void NewCanvasSheetTests::aCopiedPicturesSizeIsSuggestedOnce() +{ + auto *copied = new QMimeData; + copied->setData("image/png", encoded(QImage(640, 360, QImage::Format_RGBA8888), "png")); + QApplication::clipboard()->setMimeData(copied); + Sheet fixture; + QCOMPARE(fixture.width.text(), QString("1920")); + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + QCOMPARE(fixture.width.text(), QString("640")); + QCOMPARE(fixture.height.text(), QString("360")); + // Shown again, it keeps what the user has typed since. + fixture.width.setText("111"); + fixture.height.setFocus(); + fixture.sheet.hide(); + fixture.sheet.show(); + QCOMPARE(fixture.width.text(), QString("111")); + // Shown again, the width is the field to type in. + QCOMPARE(fixture.sheet.focusWidget(), &fixture.width); +} + +void NewCanvasSheetTests::theFirstTabSkipsTheClipboardOnce() +{ + auto *copied = new QMimeData; + copied->setData("image/png", encoded(QImage(640, 360, QImage::Format_RGBA8888), "png")); + QApplication::clipboard()->setMimeData(copied); + Sheet first; + first.session.skipsInitialClipboardCanvasSize = true; + first.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&first.sheet)); + QCOMPARE(first.width.text(), QString("1920")); + QVERIFY(!first.session.skipsInitialClipboardCanvasSize); + // The session's next sheet reads the clipboard again. + NewCanvasSheet later(first.session, [](int, int) {}, [] {}); + later.show(); + QVERIFY(QTest::qWaitForWindowExposed(&later)); + QCOMPARE(later.findChild<QLineEdit *>("widthInput")->text(), QString("640")); +} + +void NewCanvasSheetTests::clipboardSizesComeFromHeadersAlone() +{ + QVERIFY(!NewCanvasSheet::clipboardDimensions(nullptr).has_value()); + QMimeData clipboard; + QVERIFY(!NewCanvasSheet::clipboardDimensions(&clipboard).has_value()); + clipboard.setText("640 x 360"); + QVERIFY(!NewCanvasSheet::clipboardDimensions(&clipboard).has_value()); + // A tall TIFF; a PNG beside it comes first. + clipboard.setData("image/tiff", encoded(QImage(30, 50, QImage::Format_RGBA8888), "tiff")); + QCOMPARE(NewCanvasSheet::clipboardDimensions(&clipboard), std::optional(QSize(30, 50))); + clipboard.setData("image/png", encoded(QImage(70, 20, QImage::Format_RGBA8888), "png")); + QCOMPARE(NewCanvasSheet::clipboardDimensions(&clipboard), std::optional(QSize(70, 20))); + // No pixel is decoded: past Qt's limit it still counts. + const int limit = QImageReader::allocationLimit(); + QImageReader::setAllocationLimit(1); + QMimeData large; + large.setData("image/png", encoded(QImage(2000, 1000, QImage::Format_RGBA8888), "png")); + const std::optional<QSize> measured = NewCanvasSheet::clipboardDimensions(&large); + QImageReader::setAllocationLimit(limit); + QCOMPARE(measured, std::optional(QSize(2000, 1000))); + // Too large for a canvas: the next type is tried. + QMimeData vast; + vast.setData("image/png", encoded(QImage(30001, 1, QImage::Format_Grayscale8), "png")); + QVERIFY(!NewCanvasSheet::clipboardDimensions(&vast).has_value()); + vast.setData("image/tiff", encoded(QImage(30, 50, QImage::Format_RGBA8888), "tiff")); + QCOMPARE(NewCanvasSheet::clipboardDimensions(&vast), std::optional(QSize(30, 50))); + QMimeData broken; + broken.setData("image/png", "not a picture"); + QVERIFY(!NewCanvasSheet::clipboardDimensions(&broken).has_value()); + // Too tall as well as too wide is refused. + QMimeData tall; + tall.setData("image/png", encoded(QImage(1, 30001, QImage::Format_Grayscale8), "png")); + QVERIFY(!NewCanvasSheet::clipboardDimensions(&tall).has_value()); + // A TIFF stored on its side swaps its sides. + QMimeData turned; + turned.setData("image/tiff", encoded(QImage(30, 50, QImage::Format_RGBA8888), "tiff", QImageIOHandler::TransformationRotate90)); + QCOMPARE(NewCanvasSheet::clipboardDimensions(&turned), std::optional(QSize(50, 30))); +} + +QTEST_MAIN(NewCanvasSheetTests) +#include "NewCanvasSheetTests.moc" diff --git a/tests/OmarchyThemeTests.cpp b/tests/OmarchyThemeTests.cpp new file mode 100644 index 0000000..578d827 --- /dev/null +++ b/tests/OmarchyThemeTests.cpp @@ -0,0 +1,395 @@ +#include "UI/OmarchyTheme.h" +#include "Document/ProjectWorkspace.h" +#include "UI/ProjectWorkspaceView.h" +#include <QApplication> +#include <QDir> +#include <QLabel> +#include <QStyle> +#include <QStyleFactory> +#include <QTemporaryDir> +#include <QToolButton> +#include <cstdio> +#include <sys/syscall.h> +#include <unistd.h> +#include <QtTest> + +// The desktop's theme: read, applied, followed through a switch. +namespace { +const char *latte = R"(# Catppuccin Latte +mode = "light" + +accent = "#1e66f5" # blue +selection = "#ccd0da" +muted = "#acb0be" +background = "#eff1f5" +dark_background = "#e3e4e8" +darker_background = "#d7d8dc" +lighter_background = "#dce0e8" +foreground = "#4c4f69" +dark_foreground = "#9ca0b0" +light_foreground = "#5c5f77" +bright_foreground = "#4c4f69" +red = "#d20f39" +yellow = "#df8e1d" +orange = "#d84e2b" +green = "#40a02b" +)"; + +const char *macchiato = R"(mode = "dark" +accent = "#8aadf4" +selection = "#494d64" +muted = "#5b6078" +background = "#24273a" +dark_background = "#1e2030" +darker_background = "#181926" +lighter_background = "#363a4f" +foreground = "#cad3f5" +dark_foreground = "#6e738d" +light_foreground = "#b8c0e0" +bright_foreground = "#cad3f5" +red = "#ed8796" +yellow = "#eed49f" +)"; + +void write(const QString &path, const QString &text) +{ + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(text.toUtf8()) < 0) + throw std::runtime_error("could not write " + path.toStdString()); +} + +// While set, a colors.toml read gives 64 bytes, then fails. +bool shortReads = false; + +// A window filled with the palette's window colour. +struct Swatch { + QLabel label; + Swatch() + { + label.setAutoFillBackground(true); + label.resize(40, 40); + label.show(); + } + QRgb pixel() { return label.grab().toImage().pixel(20, 20); } +}; +} + +extern "C" ssize_t read(int descriptor, void *buffer, size_t count) +{ + char path[512] = {}; + const QByteArray link = "/proc/self/fd/" + QByteArray::number(descriptor); + const bool theme = shortReads && readlink(link.constData(), path, sizeof(path) - 1) > 0 && QByteArray(path).endsWith("colors.toml"); + static int reads = 0; + if (theme && ++reads > 1) { + errno = EIO; + return -1; + } + if (!theme) + reads = 0; + return syscall(SYS_read, descriptor, buffer, theme ? std::min<size_t>(count, 64) : count); +} + +class OmarchyThemeTests : public QObject { + Q_OBJECT +private slots: + void theFileParsesIntoColoursAndRefusesWhatIsMissingOrMalformed(); + void lightAndDarkThemesFillThePalette(); + void withoutAThemeTheBuiltInDarkApplies(); + void aSwitchRetintsTheWindowsWhileTheyShow(); + void anUnreadableSwitchKeepsThePalette(); + void aMalformedFileAtStartupLeavesTheBuiltInDark(); + void aFileThatCannotBeOpenedKeepsThePalette(); + void anAbsentStateDirectoryIsWatchedForItsBirth(); + void aBarredStateDirectoryIsWatchedFromAbove(); + void aReadCutShortKeepsThePalette(); + void aSwitchReachesTheWindowsTabsAndCanvas(); +}; + +void OmarchyThemeTests::theFileParsesIntoColoursAndRefusesWhatIsMissingOrMalformed() +{ + const OmarchyColors colours = OmarchyColors::parse(QString::fromLatin1(latte)); + QVERIFY(!colours.dark); + QCOMPARE(colours.accent, QColor(0x1e, 0x66, 0xf5)); + QCOMPARE(colours.selection, QColor(0xcc, 0xd0, 0xda)); + QCOMPARE(colours.muted, QColor(0xac, 0xb0, 0xbe)); + QCOMPARE(colours.background, QColor(0xef, 0xf1, 0xf5)); + QCOMPARE(colours.darkBackground, QColor(0xe3, 0xe4, 0xe8)); + QCOMPARE(colours.darkerBackground, QColor(0xd7, 0xd8, 0xdc)); + QCOMPARE(colours.lighterBackground, QColor(0xdc, 0xe0, 0xe8)); + QCOMPARE(colours.foreground, QColor(0x4c, 0x4f, 0x69)); + QCOMPARE(colours.darkForeground, QColor(0x9c, 0xa0, 0xb0)); + QCOMPARE(colours.lightForeground, QColor(0x5c, 0x5f, 0x77)); + QCOMPARE(colours.brightForeground, QColor(0x4c, 0x4f, 0x69)); + QCOMPARE(colours.red, QColor(0xd2, 0x0f, 0x39)); + // Orange is the warning where present, else yellow. + QCOMPARE(colours.warning, QColor(0xd8, 0x4e, 0x2b)); + const OmarchyColors dark = OmarchyColors::parse(QString::fromLatin1(macchiato)); + QVERIFY(dark.dark); + QCOMPARE(dark.warning, QColor(0xee, 0xd4, 0x9f)); + QCOMPARE(dark.background, QColor(0x24, 0x27, 0x3a)); + const auto refused = [](const QString &toml) { + try { + OmarchyColors::parse(toml); + } catch (const std::runtime_error &error) { + return QString::fromLatin1(error.what()); + } + return QString(); + }; + QVERIFY(refused(QString::fromLatin1(macchiato).replace("mode = \"dark\"", "mode = \"dim\"")).contains("mode")); + QVERIFY(refused(QString::fromLatin1(macchiato).replace("accent = \"#8aadf4\"\n", "")).contains("accent")); + QVERIFY(refused(QString::fromLatin1(macchiato).replace("#ed8796", "#ed879")).contains("red")); + QVERIFY(refused(QString::fromLatin1(macchiato).replace("#ed8796", "ed8796")).contains("red")); + QVERIFY(refused(QString()).contains("mode")); + // Single quotes read the same. + QCOMPARE(OmarchyColors::parse(QString::fromLatin1(latte).replace('"', '\'')).accent, QColor(0x1e, 0x66, 0xf5)); + QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace('"', '\'')).dark, true); + QVERIFY(refused(QString::fromLatin1(macchiato).replace("\"dark\"", "'dark\"")).contains("mode")); + // Quoted keys read too; bare or half-quoted values refuse. + QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace("mode = ", "\"mode\" = ")).dark, true); + QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace("accent = ", "'accent' = ")).accent, QColor(0x8a, 0xad, 0xf4)); + QVERIFY(refused(QString::fromLatin1(macchiato).replace("mode = \"dark\"", "mode = dark")).contains("mode is no quoted")); + QVERIFY(refused(QString::fromLatin1(latte).replace("orange = \"#d84e2b\"", "orange = 123")).contains("orange is no quoted")); + QVERIFY(refused(QString::fromLatin1(latte).replace("orange = \"#d84e2b\"", "orange = \"#d84e2b'")).contains("orange")); + QVERIFY(refused(QString::fromLatin1(latte).replace("red = \"#d20f39\"", "red = \"#d20f39\" \"twice\"")).contains("red is no quoted")); + // A trailing comment and a bare unknown key are fine. + QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato) + "hyprland_border = 1\n").red, QColor(0xed, 0x87, 0x96)); + // A later line wins; an unknown key is ignored. + const OmarchyColors twice = OmarchyColors::parse(QString::fromLatin1(macchiato) + "\nred = \"#000000\"\nhyprland_active_border = \"#123456\"\n"); + QCOMPARE(twice.red, QColor(0, 0, 0)); +} + +void OmarchyThemeTests::lightAndDarkThemesFillThePalette() +{ + const QPalette light = OmarchyColors::parse(QString::fromLatin1(latte)).palette(); + QCOMPARE(light.color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); + QCOMPARE(light.color(QPalette::WindowText), QColor(0x4c, 0x4f, 0x69)); + QCOMPARE(light.color(QPalette::Base), QColor(0xd7, 0xd8, 0xdc)); + QCOMPARE(light.color(QPalette::AlternateBase), QColor(0xe3, 0xe4, 0xe8)); + QCOMPARE(light.color(QPalette::Text), QColor(0x4c, 0x4f, 0x69)); + QCOMPARE(light.color(QPalette::Button), QColor(0xdc, 0xe0, 0xe8)); + QCOMPARE(light.color(QPalette::ButtonText), QColor(0x4c, 0x4f, 0x69)); + QCOMPARE(light.color(QPalette::Highlight), QColor(0x1e, 0x66, 0xf5)); + QCOMPARE(light.color(QPalette::HighlightedText), QColor(0xef, 0xf1, 0xf5)); + QCOMPARE(light.color(QPalette::Link), QColor(0x1e, 0x66, 0xf5)); + QCOMPARE(light.color(QPalette::PlaceholderText), QColor(0x9c, 0xa0, 0xb0)); + QCOMPARE(light.color(QPalette::BrightText), QColor(0xd8, 0x4e, 0x2b)); + QCOMPARE(light.color(QPalette::Mid), QColor(0xac, 0xb0, 0xbe)); + QCOMPARE(light.color(QPalette::Dark), QColor(0xd7, 0xd8, 0xdc)); + QCOMPARE(light.color(QPalette::Light), QColor(0xdc, 0xe0, 0xe8)); + QCOMPARE(light.color(QPalette::ToolTipBase), QColor(0xe3, 0xe4, 0xe8)); + QCOMPARE(light.color(QPalette::ToolTipText), QColor(0x4c, 0x4f, 0x69)); + for (const QPalette::ColorRole role : {QPalette::WindowText, QPalette::Text, QPalette::ButtonText}) + QCOMPARE(light.color(QPalette::Disabled, role), QColor(0xac, 0xb0, 0xbe)); + const QPalette dark = OmarchyColors::parse(QString::fromLatin1(macchiato)).palette(); + QCOMPARE(dark.color(QPalette::Window), QColor(0x24, 0x27, 0x3a)); + QCOMPARE(dark.color(QPalette::HighlightedText), QColor(0x24, 0x27, 0x3a)); + QCOMPARE(dark.color(QPalette::BrightText), QColor(0xee, 0xd4, 0x9f)); + QCOMPARE(dark.color(QPalette::PlaceholderText), QColor(0x6e, 0x73, 0x8d)); +} + +void OmarchyThemeTests::withoutAThemeTheBuiltInDarkApplies() +{ + QTemporaryDir state; + // Whatever style the desktop set, the palette needs Fusion. + QApplication::setStyle(QStyleFactory::create(QStringLiteral("Windows"))); + QCOMPARE(QApplication::style()->objectName(), QString("windows")); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("no Omarchy theme at .*colors.toml - built-in dark")); + OmarchyTheme theme(state.path()); + QCOMPARE(theme.colors(), OmarchyColors::builtInDark()); + QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0x24, 0x24, 0x24)); + QCOMPARE(QApplication::palette().color(QPalette::Base), QColor(0x1b, 0x1b, 0x1b)); + QCOMPARE(QApplication::palette().color(QPalette::PlaceholderText), QColor(0x8a, 0x8a, 0x8a)); + QCOMPARE(QApplication::palette().color(QPalette::BrightText), QColor(0xff, 0x9f, 0x0a)); + QCOMPARE(QApplication::style()->objectName(), QString("fusion")); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); + // A theme appearing later is taken up, unnamed or not. + QTest::failOnWarning(QRegularExpression("QIODevice")); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("Omarchy theme \\(unnamed\\) light from")); + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); +} + +void OmarchyThemeTests::aSwitchRetintsTheWindowsWhileTheyShow() +{ + QTemporaryDir state; + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); + write(state.path() + "/theme.name", "catppuccin-macchiato\n"); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("Omarchy theme catppuccin-macchiato dark from .*colors.toml")); + OmarchyTheme theme(state.path()); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); + // omarchy-theme-set: remove, move the next in, write the name. + write(state.path() + "/next-theme/colors.toml", QString::fromLatin1(latte)); + QVERIFY(QDir(state.path() + "/theme").removeRecursively()); + QVERIFY(QDir().rename(state.path() + "/next-theme", state.path() + "/theme")); + write(state.path() + "/theme.name", "catppuccin-latte\n"); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + QVERIFY(!theme.colors().dark); + QCOMPARE(QApplication::palette().color(QPalette::Highlight), QColor(0x1e, 0x66, 0xf5)); + // Edited in place the file counts too, twice running. + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); + QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + // Replaced under a held handle, then edited: still seen. + QFile held(state.path() + "/theme/colors.toml"); + QVERIFY(held.open(QIODevice::ReadOnly)); + write(state.path() + "/theme/next.toml", QString::fromLatin1(macchiato)); + QCOMPARE(::rename(QFile::encodeName(state.path() + "/theme/next.toml").constData(), QFile::encodeName(state.path() + "/theme/colors.toml").constData()), 0); + QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); +} + +void OmarchyThemeTests::anUnreadableSwitchKeepsThePalette() +{ + QTemporaryDir state; + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); + OmarchyTheme theme(state.path()); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: colors.toml lacks accent")); + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato).replace("accent = \"#8aadf4\"\n", "")); + QTest::qWait(400); + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); + QVERIFY(theme.colors().dark); + // Mended, it applies again. + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); +} + +void OmarchyThemeTests::aMalformedFileAtStartupLeavesTheBuiltInDark() +{ + QTemporaryDir state; + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato).replace("mode = \"dark\"\n", "")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: colors.toml lacks mode")); + OmarchyTheme theme(state.path()); + QCOMPARE(theme.colors(), OmarchyColors::builtInDark()); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); +} + +void OmarchyThemeTests::aFileThatCannotBeOpenedKeepsThePalette() +{ + QTemporaryDir state; + const QString path = state.path() + "/theme/colors.toml"; + write(path, QString::fromLatin1(latte)); + OmarchyTheme theme(state.path()); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + // A file that will not open is no absent file. + QVERIFY(QFile::setPermissions(path, QFileDevice::Permissions())); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); + theme.apply(); + QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); + QVERIFY(!theme.colors().dark); + QVERIFY(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + // An unsearchable folder hides the file: kept too. + const QString folder = state.path() + "/theme"; + QVERIFY(QFile::setPermissions(folder, QFileDevice::Permissions())); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); + theme.apply(); + QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); + QVERIFY(QFile::setPermissions(folder, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); +} + +void OmarchyThemeTests::anAbsentStateDirectoryIsWatchedForItsBirth() +{ + QTemporaryDir root; + const QString state = root.path() + "/omarchy/current"; + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("no Omarchy theme at .*colors.toml - built-in dark")); + OmarchyTheme theme(state); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); + // Omarchy installed later: the theme arrives with its folders. + auto &watcher = *theme.findChild<QFileSystemWatcher *>(); + QCOMPARE(watcher.directories(), QStringList{root.path()}); + write(state + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + // Found, the state directory is watched, its ancestor let go. + QVERIFY(watcher.directories().contains(state) && !watcher.directories().contains(root.path())); + write(state + "/theme/colors.toml", QString::fromLatin1(macchiato)); + QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); +} + +void OmarchyThemeTests::aBarredStateDirectoryIsWatchedFromAbove() +{ + QTemporaryDir root; + const QString state = root.path() + "/current"; + write(state + "/theme/colors.toml", QString::fromLatin1(latte)); + QVERIFY(QFile::setPermissions(state, QFileDevice::Permissions())); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); + OmarchyTheme theme(state); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); + auto &watcher = *theme.findChild<QFileSystemWatcher *>(); + QCOMPARE(watcher.directories(), QStringList{root.path()}); + // Opened up again, the theme is found and followed. + QVERIFY(QFile::setPermissions(state, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + QTRY_VERIFY(watcher.directories().contains(state) && !watcher.directories().contains(root.path())); + write(state + "/theme/colors.toml", QString::fromLatin1(macchiato)); + QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); +} + +void OmarchyThemeTests::aReadCutShortKeepsThePalette() +{ + QTemporaryDir state; + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + OmarchyTheme theme(state.path()); + Swatch swatch; + QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + // Sixty-four bytes, then an error: a prefix, not the file. + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); + shortReads = true; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: read 64 of [0-9]+ bytes")); + theme.apply(); + QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); + QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); + shortReads = false; + theme.apply(); + QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0x24, 0x27, 0x3a)); + // Widgets take the new palette from the event loop. + QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); +} + +void OmarchyThemeTests::aSwitchReachesTheWindowsTabsAndCanvas() +{ + QTemporaryDir state; + write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); + OmarchyTheme theme(state.path()); + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + // The lightest or darkest pixel of a widget: its text. + const auto extreme = [](QWidget &widget, bool lightest) { + const QImage image = widget.grab().toImage(); + int best = lightest ? 0 : 255; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + best = lightest ? std::max(best, qGray(image.pixel(x, y))) : std::min(best, qGray(image.pixel(x, y))); + } + return best; + }; + auto &tab = *window.findChild<QToolButton *>("selectTab"); + auto &canvas = *window.findChild<QWidget *>("editorCanvas"); + QVERIFY(std::abs(extreme(tab, true) - qGray(qRgb(0xca, 0xd3, 0xf5))) <= 12); + QCOMPARE(canvas.grab().toImage().pixelColor(5, 5), QColor(0x18, 0x19, 0x26)); + // Light: the tab's title turns dark, the canvas surround light. + write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); + QTRY_COMPARE(canvas.grab().toImage().pixelColor(5, 5), QColor(0xd7, 0xd8, 0xdc)); + QVERIFY(std::abs(extreme(tab, false) - qGray(qRgb(0x4c, 0x4f, 0x69))) <= 12); + // The row's margin shows the toolbar, not a fill. + auto &strip = *window.findChild<QWidget *>("projectTabs"); + const QImage bar = window.grab().toImage(); + const QPoint inRow = strip.mapTo(&window, QPoint(6, 1)), beside = strip.mapTo(&window, QPoint(strip.width() + 8, 1)); + QCOMPARE(bar.pixelColor(inRow), bar.pixelColor(beside)); +} + +QTEST_MAIN(OmarchyThemeTests) +#include "OmarchyThemeTests.moc" diff --git a/tests/PickingTests.cpp b/tests/PickingTests.cpp new file mode 100644 index 0000000..bf5aeaf --- /dev/null +++ b/tests/PickingTests.cpp @@ -0,0 +1,279 @@ +#include "EyedropperFixtures.h" + +// Picking beside tools, the open picker and lost releases. +class PickingTests : public QObject { + Q_OBJECT +private slots: + void altPicksUnderTheBrushSpotHealingAndTheGradient(); + void altLeavesAStrokeOrAGradientDragAlone(); + void theOpenPickerTakesSamplesAndTheKeysBack(); + void pickingEndingEndsTheSampling(); + void altFirstSeenInThePressSamplesOn(); + void aRightDragSizesTheTipWhilePicking(); + void aLostReleaseEndsTheSampling(); + void theInlineEditorStandsAsideForThePicker(); + void aPolygonalLassoWaitsForThePicker(); +}; + +void PickingTests::altPicksUnderTheBrushSpotHealingAndTheGradient() +{ + Picking shown; + for (const NavigationTool tool : {NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::gradient}) { + shown.tool(tool); + shown.session.resetPaletteColors(); + shown.press(QPointF(100, 150), Qt::AltModifier); + QVERIFY(shown.ring().frame()); + QVERIFY(!shown.session.brushStroke() && !shown.session.gradientEdit()); + shown.release(QPointF(100, 150), Qt::AltModifier); + QCOMPARE(shown.foreground(), QString("FF0000")); + } + // Clone Stamp's Alt sets its source instead. + shown.tool(NavigationTool::cloneStamp); + shown.session.resetPaletteColors(); + shown.click(QPointF(300, 150), Qt::AltModifier); + QVERIFY(shown.session.cloneSource()); + QCOMPARE(shown.foreground(), QString("000000")); + // The other tools take Alt for their own. + shown.tool(NavigationTool::shape); + shown.click(QPointF(300, 150), Qt::AltModifier); + QCOMPARE(shown.foreground(), QString("000000")); +} + +void PickingTests::altLeavesAStrokeOrAGradientDragAlone() +{ + Picking shown; + // A stroke begun paints on under Alt. + shown.tool(NavigationTool::brush); + shown.press(QPointF(100, 150)); + QVERIFY(shown.session.brushStroke()); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(!shown.picks()); + shown.move(QPointF(300, 150), Qt::AltModifier); + QVERIFY(shown.session.brushStroke() && !shown.ring().frame()); + shown.session.cancelBrush(); + shown.release(QPointF(300, 150), Qt::AltModifier); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + QCOMPARE(shown.foreground(), QString("000000")); + // So does a gradient's end being dragged. + shown.tool(NavigationTool::gradient); + shown.press(QPointF(100, 150)); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(!shown.picks()); + shown.move(QPointF(300, 150), Qt::AltModifier); + QCOMPARE(shown.session.gradientEdit().value().end, QPointF(300, 150)); + QVERIFY(!shown.ring().frame()); + shown.release(QPointF(300, 150), Qt::AltModifier); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + QCOMPARE(shown.foreground(), QString("000000")); + // A refused gradient press drags nothing: Alt picks there. + shown.session.cancelGradient(); + shown.session.toggleLayerVisibility(shown.session.activeLayerID().value()); + shown.press(QPointF(100, 150)); + QVERIFY(!shown.session.gradientEdit()); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + QVERIFY(shown.picks()); + shown.release(QPointF(100, 150), Qt::AltModifier); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); +} + +void PickingTests::theOpenPickerTakesSamplesAndTheKeysBack() +{ + Picking shown; + shown.tool(NavigationTool::brush); + ColorPickerPanelController controller(shown.window); + shown.session.openColorPicker(false); + controller.show(shown.session.colorPicker().value(), shown.session); + QWidget *panel = pickerPanel(); + QVERIFY(panel); + QTRY_COMPARE(QApplication::activeWindow(), panel); + // Any tool picks while it is open. + shown.canvas->synchronizeDisplay(); + QVERIFY(shown.picks()); + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + // The ring's original is the picker's colour, not the swatch's. + shown.session.setColorPickerHSB(PickerHSB(120, 1, 1)); + shown.press(QPointF(300, 150)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("0000FF")); + QCOMPARE(shown.foreground(), QString("000000")); + QCOMPARE(shown.ring().original(), (PaletteColor{0, 1, 0})); + QCOMPARE(shown.ring().sampled().hex(), QString("0000FF")); + QVERIFY(!shown.session.brushStroke()); + // The release hands the picker the keys back. + shown.release(QPointF(300, 150)); + QTRY_COMPARE(QApplication::activeWindow(), panel); + // A pan's release, sampling nothing, leaves them. + shown.window.activateWindow(); + QTRY_COMPARE(QApplication::activeWindow(), &shown.window); + QTest::keyPress(shown.canvas, Qt::Key_Space); + shown.drag(QPointF(300, 150), QPointF(310, 150)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QTest::qWait(50); + QCOMPARE(QApplication::activeWindow(), &shown.window); + // Closed, the Brush paints again. + shown.session.closeColorPicker(false); + controller.close(); + shown.canvas->synchronizeDisplay(); + QVERIFY(!shown.picks()); +} + +void PickingTests::pickingEndingEndsTheSampling() +{ + Picking shown; + shown.tool(NavigationTool::brush); + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + shown.press(QPointF(100, 150), Qt::AltModifier); + QVERIFY(shown.ring().frame()); + // Alt let go mid-drag: the ring goes, sampling stops. + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + QVERIFY(!shown.ring().frame()); + shown.move(QPointF(300, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + QVERIFY(!shown.session.brushStroke()); + shown.release(QPointF(300, 150)); + // Another tool ends it too. + shown.tool(NavigationTool::eyedropper); + shown.press(QPointF(300, 150)); + shown.tool(NavigationTool::brush); + QVERIFY(!shown.ring().frame()); + shown.move(QPointF(100, 150)); + QCOMPARE(shown.foreground(), QString("0000FF")); + shown.release(QPointF(100, 150)); +} + +void PickingTests::altFirstSeenInThePressSamplesOn() +{ + // As ContentView makes it: every change syncs the canvas. + Picking shown; + QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); }); + shown.tool(NavigationTool::brush); + shown.press(QPointF(100, 150), Qt::AltModifier); + QVERIFY(shown.ring().frame()); + shown.move(QPointF(300, 150), Qt::AltModifier); + QCOMPARE(shown.foreground(), QString("0000FF")); + QCOMPARE(shown.ring().frame().value(), QRectF(242, 92, 116, 116)); + shown.release(QPointF(300, 150), Qt::AltModifier); + QVERIFY(!shown.ring().frame()); + // Alt let go unseen mid-drag: the move ends the sampling. + shown.press(QPointF(100, 150), Qt::AltModifier); + shown.move(QPointF(300, 150)); + QVERIFY(!shown.ring().frame()); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.release(QPointF(300, 150)); +} + +void PickingTests::aRightDragSizesTheTipWhilePicking() +{ + Picking shown; + shown.tool(NavigationTool::brush); + const auto rightDrag = [&shown](Qt::KeyboardModifiers modifiers) { + QTest::mousePress(shown.canvas, Qt::RightButton, modifiers, QPoint(100, 150)); + QMouseEvent move(QEvent::MouseMove, QPointF(120, 150), QPointF(120, 150), shown.canvas->mapToGlobal(QPoint(120, 150)), Qt::NoButton, + Qt::RightButton, modifiers); + QApplication::sendEvent(shown.canvas, &move); + QTest::mouseRelease(shown.canvas, Qt::RightButton, modifiers, QPoint(120, 150)); + }; + // Alt held: the right-drag still sizes the tip, as Swift's. + const double diameter = shown.session.brushSettings().diameter; + QTest::keyPress(shown.canvas, Qt::Key_Alt, Qt::AltModifier); + rightDrag(Qt::AltModifier); + QCOMPARE(shown.session.brushSettings().diameter, diameter + 40); + QTest::keyRelease(shown.canvas, Qt::Key_Alt); + // So with the picker open. + shown.session.openColorPicker(false); + shown.canvas->synchronizeDisplay(); + rightDrag(Qt::NoModifier); + QCOMPARE(shown.session.brushSettings().diameter, diameter + 80); + shown.session.closeColorPicker(false); +} + +void PickingTests::aLostReleaseEndsTheSampling() +{ + Picking shown; + // A move without the button: its release was lost. + shown.press(QPointF(100, 150)); + shown.hover(QPointF(300, 150)); + QVERIFY(!shown.ring().frame()); + QCOMPARE(shown.foreground(), QString("FF0000")); + shown.hover(QPointF(310, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + // A lost focus ends it too. + shown.press(QPointF(300, 150)); + QVERIFY(shown.ring().frame()); + shown.canvas->clearFocus(); + QVERIFY(!shown.ring().frame()); + shown.move(QPointF(100, 150)); + QCOMPARE(shown.foreground(), QString("0000FF")); + // So does the next press: with Space it pans instead. + shown.canvas->setFocus(); + shown.press(QPointF(100, 150)); + QTest::keyPress(shown.canvas, Qt::Key_Space); + const QPointF before = shown.session.viewport.documentPoint(QPointF(200, 150), shown.documentSize()); + shown.press(QPointF(300, 150)); + QVERIFY(!shown.ring().frame()); + shown.move(QPointF(320, 150)); + QCOMPARE(shown.foreground(), QString("FF0000")); + QCOMPARE(shown.session.viewport.documentPoint(QPointF(200, 150), shown.documentSize()), before - QPointF(20, 0)); + shown.release(QPointF(320, 150)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); +} + +void PickingTests::theInlineEditorStandsAsideForThePicker() +{ + Picking shown; + QObject::connect(&shown.session, &EditorSession::changed, shown.canvas, [&shown] { shown.canvas->synchronizeDisplay(); }); + shown.tool(NavigationTool::type); + shown.session.beginText(QPointF(20, 30), true); + QTest::keyClicks(shown.canvas, QStringLiteral("Hello there")); + QTest::keyClick(shown.canvas, Qt::Key_Home); + const InlineTextEditor &editor = *shown.canvas->inlineTextEditor(); + const QPointF word = editor.textTransform().map(QPointF(LayerTextStyle::padding + 4, LayerTextStyle::padding + 4)); + const QImage before = shown.canvas->grab(QRect(0, 0, 200, 140)).toImage(); + shown.session.openTextColorPicker(); + // A sampling ring elsewhere leaves the editor drawn whole. + shown.press(QPointF(300, 200)); + QVERIFY(shown.ring().frame()); + QCOMPARE(shown.canvas->grab(QRect(0, 0, 200, 140)).toImage(), before); + shown.release(QPointF(300, 200)); + // Over the text the eyedropper shows; clicks sample. + shown.hover(word); + QVERIFY(shown.picks()); + shown.click(word); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + QVERIFY(editor.anchor() == 0 && editor.caretPosition() == 0); + // A double click samples too, selecting no word. + shown.session.setColorPickerHSB(PickerHSB(240, 1, 1)); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, word.toPoint()); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + QVERIFY(editor.anchor() == 0 && editor.caretPosition() == 0); + // Closed, the text view takes clicks again. + shown.session.closeColorPicker(false); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, word.toPoint()); + QVERIFY(editor.anchor() == 0 && editor.caretPosition() == 5); +} + +void PickingTests::aPolygonalLassoWaitsForThePicker() +{ + Picking shown; + shown.tool(NavigationTool::lasso); + shown.session.setLassoKind(LassoKind::polygonal); + shown.click(QPointF(50, 50)); + shown.hover(QPointF(80, 60)); + const LassoDraft draft = shown.session.lassoDraft().value(); + shown.session.openColorPicker(false); + shown.canvas->synchronizeDisplay(); + // A sampling drag adds no corner, moves no rubber band. + shown.press(QPointF(300, 150)); + shown.move(QPointF(310, 160)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("0000FF")); + QCOMPARE(shown.session.lassoDraft().value(), draft); + shown.release(QPointF(310, 160)); + // A double click samples, the outline still open. + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(100, 150)); + QCOMPARE(shown.session.colorPicker().value().color().hex(), QString("FF0000")); + QCOMPARE(shown.session.lassoDraft().value(), draft); + shown.session.closeColorPicker(false); +} + +QTEST_MAIN(PickingTests) +#include "PickingTests.moc" diff --git a/tests/PixelEditTests.cpp b/tests/PixelEditTests.cpp new file mode 100644 index 0000000..13dd0d1 --- /dev/null +++ b/tests/PixelEditTests.cpp @@ -0,0 +1,290 @@ +#include "BrushFixtures.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "SelectionFixtures.h" +#include "Rendering/RasterSnapshot.h" +#include "SessionRecord.h" +#include <QTemporaryDir> + +// The session's pixel edits: strokes, erase, clears, fills. +namespace { +// Swift's makeSession: a blank layer, the Brush, hard red 20. +std::unique_ptr<EditorSession> makeSession(int width = 600, int height = 80) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height, true); + session->addBlankLayer(); + session->selectTool(NavigationTool::brush); + session->setBrushSettings(brush(20, 1, 1, 0, 0)); + return session; +} + +QImage render(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} + +QImage live(const EditorSession &session) +{ + return preview(*session.brushStroke(), session.document().value().size()); +} + +void select(EditorSession &session, const QRectF &rect) +{ + session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); +} +} + +class PixelEditTests : public QObject { + Q_OBJECT +private slots: + void aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded(); + void eraseClearsAndDeleteFillsAndClears(); + void brushCallsAnnounceAndRefuseInSilence(); + void aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer(); + void aMaskSwitchedOffMeanwhileStaysOff(); +}; + +void PixelEditTests::aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + QVERIFY(!session->shiftLineStart().has_value()); + session->addLayerMask(true); + session->selectLayerTarget(id, false); + session->beginBrush(QPointF(20, 40)); + // While the stroke lasts nothing else moves. + QVERIFY(!session->canEditLayers() && !session->canUseHistory() && !session->canStartProjectOperation()); + QVERIFY(!session->canInvert() && !session->canAdjustColors() && !session->canContentAwareFill()); + const int count = session->history.undoCount(); + bool inverted = false; + session->invertPixels([&] { inverted = true; }); + QTRY_VERIFY(inverted); + QVERIFY(session->brushStroke() && session->history.undoCount() == count); + session->selectTool(NavigationTool::move); + QCOMPARE(session->tool(), NavigationTool::brush); + session->selectLayer(session->document().value().layers.front().id); + QCOMPARE(session->activeLayerID(), std::optional(id)); + session->selectLayerTarget(id, true); + QVERIFY(!session->isMaskSelected()); + session->continueBrush(QPointF(100, 40)); + QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40))); + // Busy, the mouse-up waits; free, it commits. + session->setIsProjectBusy(true); + QVERIFY(!session->finishBrushImmediately()); + session->setIsProjectBusy(false); + QVERIFY(session->finishBrushImmediately()); + QVERIFY(session->finishBrushImmediately()); + QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40))); + session->selectLayer(session->document().value().layers.front().id); + QVERIFY(!session->shiftLineStart().has_value()); + session->selectLayer(id); + session->selectLayerTarget(id, true); + QVERIFY(session->isMaskSelected() && !session->shiftLineStart().has_value()); + // A Shift-click's line: the old end to the new point. + session->selectLayerTarget(id, false); + session->beginBrush(session->shiftLineStart().value()); + session->continueBrush(QPointF(300, 40)); + session->finishBrush(); + QCOMPARE(alpha(render(*session), 200, 40), 255); + // Two selected layers paint nothing; an import ends a stroke. + const QUuid other = session->document().value().layers.front().id; + session->selectLayers({id, other}, id); + QVERIFY(!session->canPaint()); + session->selectLayer(id); + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("dot.png")); + QImage dot(2, 2, QImage::Format_RGBA8888_Premultiplied); + dot.fill(Qt::green); + QVERIFY(dot.save(path)); + session->beginBrush(QPointF(400, 40)); + bool imported = false; + session->importImages({QUrl::fromLocalFile(path)}, std::nullopt, [&] { imported = true; }); + QVERIFY(!session->brushStroke()); + QCOMPARE(session->history.undoName(), QString("Brush Stroke")); + QTRY_VERIFY(imported); + QCOMPARE(session->activeLayer().value().name, QString("dot")); +} + +void PixelEditTests::eraseClearsAndDeleteFillsAndClears() +{ + const auto session = makeSession(20, 10); + QImage red = BrushRaster::context(20, 10, false); + red.fill(Qt::red); + session->insert(ImportedImage(red, red, "Red")); + session->selectTool(NavigationTool::brush); + // Pixels to invert or fill, refused while a stroke lasts. + QVERIFY(session->canInvert() && session->canAdjustColors()); + session->beginBrush(QPointF(15, 5)); + QVERIFY(!session->canInvert() && !session->canAdjustColors()); + session->cancelBrush(); + QVERIFY(session->canInvert() && session->canAdjustColors()); + // Erase takes the pixels out; the mask keeps painting. + session->setBrushMode(BrushToolMode::erase); + session->setBrushSettings(brush(4, 1, 0, 0, 1)); + session->beginBrush(QPointF(5, 5)); + QCOMPARE(alpha(live(*session), 5, 5), 0); + session->finishBrush(); + QCOMPARE(session->history.undoName(), QString("Erase")); + QCOMPARE(alpha(render(*session), 5, 5), 0); + QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255})); + session->undo(); + // Delete with a selection clears; without, it deletes. + session->selectTool(NavigationTool::marquee); + select(*session, QRectF(0, 0, 10, 10)); + bool done = false; + session->clearSelectedPixels([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoName(), QString("Clear")); + QCOMPARE(alpha(render(*session), 5, 5), 0); + QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255})); + session->undo(); + done = false; + session->fillSelection(EditorSession::FillSource::background, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoName(), QString("Fill")); + QCOMPARE(pixel(render(*session), 5, 5), (std::vector<int>{255, 255, 255, 255})); + session->undo(); + session->deleteKeyPressed(); + QTRY_COMPARE(session->history.undoName(), QString("Clear")); + session->deselect(); + const int layers = int(session->document().value().layers.size()); + session->deleteKeyPressed(); + QCOMPARE(int(session->document().value().layers.size()), layers - 1); + // Back past the delete, deselect and clear: red again. + session->undo(); + session->undo(); + session->undo(); + QCOMPARE(pixel(render(*session), 5, 5), (std::vector<int>{255, 0, 0, 255})); + // A mask target fills with the background: black's is white. + session->selectLayer(session->document().value().layers.back().id); + session->addLayerMask(true); + select(*session, QRectF(0, 0, 10, 10)); + session->setMaskPaintWhite(false); + done = false; + session->clearSelectedPixels([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoName(), QString("Fill Mask")); + QCOMPARE(alpha(render(*session), 5, 5), 255); + session->setMaskPaintWhite(true); + done = false; + session->clearSelectedPixels([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(alpha(render(*session), 5, 5), 0); +} + +void PixelEditTests::brushCallsAnnounceAndRefuseInSilence() +{ + const auto session = makeSession(); + QSignalSpy changes(session.get(), &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + QVERIFY(!announced([&] { session->continueBrush(QPointF(1, 1)); })); + QVERIFY(announced([&] { session->changeBrushSize(true); })); + QVERIFY(announced([&] { session->setBrushMode(BrushToolMode::erase); })); + QVERIFY(announced([&] { session->setMaskPaintWhite(true); })); + QVERIFY(announced([&] { session->typeOpacityDigit(5, 1); })); + QVERIFY(announced([&] { session->beginBrush(QPointF(10, 10)); })); + QVERIFY(!announced([&] { session->selectTool(NavigationTool::move); })); + QVERIFY(!announced([&] { session->typeOpacityDigit(5, 2); })); + QVERIFY(announced([&] { session->continueBrush(QPointF(20, 10)); })); + QVERIFY(announced([&] { session->cancelBrush(); })); + // The last signal of a change sees what it leaves. + QStringList seen; + connect(session.get(), &EditorSession::changed, session.get(), [&] { seen = described(*session); }); + session->beginBrush(QPointF(10, 10)); + QCOMPARE(seen, described(*session)); + session->continueBrush(QPointF(50, 10)); + QCOMPARE(seen, described(*session)); + session->finishBrush(); + QCOMPARE(seen, described(*session)); + session->changeBrushHardness(false); + QCOMPARE(seen, described(*session)); +} + +void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer() +{ + const auto session = makeSession(20, 10); + QImage red = BrushRaster::context(20, 10, false); + red.fill(Qt::red); + session->insert(ImportedImage(red, red, "Red")); + const QUuid id = session->activeLayerID().value(); + QImage white = BrushRaster::context(20, 10, true); + white.fill(Qt::white); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); }); + session->selectLayerTarget(id, false); + select(*session, QRectF(0, 0, 10, 10)); + // The mask replaced while the clear flies: nothing lands. + bool done = false; + session->clearSelectedPixels([&] { done = true; }); + QVERIFY(session->isProjectBusy()); + QImage black = BrushRaster::context(20, 10, true); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(black)); }); + QTRY_VERIFY(done); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), 0); + QCOMPARE(int(layerWith(*session, id).mask.value().asset.image().constScanLine(5)[5]), 0); + QCOMPARE(pixel(layerWith(*session, id).asset.value().image(), 5, 5), (std::vector<int>{255, 0, 0, 255})); + // Six layers of 16 megapixels leave a stroke 4 million. + const auto big = std::make_unique<EditorSession>(); + big->createDocument(4000, 4000, true); + for (int i = 0; i < 6; ++i) { + const auto raster = std::make_shared<const RasterSnapshot>(4000, 4000, BrushRaster::context(1, 1, false), QRectF(0, 0, 4000, 4000), std::vector<BrushPatch>{}); + big->insert(ImportedImage(raster, QImage(), QStringLiteral("Big %1").arg(i))); + } + big->addBlankLayer(); + big->selectTool(NavigationTool::brush); + QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(4'000'000)); + // A 2000 px tip needs more tiles than that: refused. + big->setBrushSettings(brush(2000, 1, 1, 0, 0)); + big->beginBrush(QPointF(2000, 2000)); + QVERIFY(!big->brushStroke() && big->brushError().has_value()); + big->setBrushError(std::nullopt); + big->setBrushSettings(brush(20, 1, 1, 0, 0)); + big->beginBrush(QPointF(2000, 2000)); + QVERIFY(big->brushStroke()); + big->cancelBrush(); + // A masked layer's budget counts the other masks too. + QImage mask = BrushRaster::context(4000, 4000, true); + const QUuid last = big->activeLayerID().value(); + rewrite(*big, [&](ProjectSnapshot &snapshot) { + for (ProjectLayerRecord &record : snapshot.manifest.layers) + setMask(snapshot, record.id, LayerMask::assetFrom(mask)); + }); + big->selectLayerTarget(last, false); + // Seven other 16-million masks: the budget runs below zero. + QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(-12'000'000)); +} + +void PixelEditTests::aMaskSwitchedOffMeanwhileStaysOff() +{ + const auto session = makeSession(20, 10); + QImage red = BrushRaster::context(20, 10, false); + red.fill(Qt::red); + session->insert(ImportedImage(red, red, "Red")); + const QUuid id = session->activeLayerID().value(); + QImage white = BrushRaster::context(20, 10, true); + white.fill(Qt::white); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); }); + session->selectLayerTarget(id, false); + select(*session, QRectF(0, 0, 10, 10)); + bool done = false; + session->clearSelectedPixels([&] { done = true; }); + QVERIFY(session->isProjectBusy()); + // Only the switch changes meanwhile: same pixels, same place. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = false; }); + QTRY_VERIFY(done); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), 1); + QCOMPARE(session->history.undoName(), QString("Clear")); + QVERIFY(!layerWith(*session, id).mask.value().isEnabled); + // The commit crops the cleared half away: read the document. + QCOMPARE(pixel(render(*session), 5, 5)[3], 0); + QCOMPARE(pixel(render(*session), 15, 5), (std::vector<int>{255, 0, 0, 255})); +} + +QTEST_GUILESS_MAIN(PixelEditTests) +#include "PixelEditTests.moc" diff --git a/tests/PixelInvertTests.cpp b/tests/PixelInvertTests.cpp new file mode 100644 index 0000000..f824849 --- /dev/null +++ b/tests/PixelInvertTests.cpp @@ -0,0 +1,116 @@ +#include "Document/BrushStroke.h" +#include "Document/PixelAdjust.h" +#include "Document/PixelInvert.h" +#include <QtTest> + +// The invert kernel and the selection blend behind it. +namespace { +SelectionClip leftHalf(int width, int height) +{ + QImage cover = BrushRaster::context(width / 2, height, true); + cover.fill(Qt::white); + return SelectionClip{QRectF(0, 0, width / 2, height), cover}; +} +} + +class PixelInvertTests : public QObject { + Q_OBJECT +private slots: + void invertKeepsAlphaAndSwapsMaskGray(); + void coverageFollowsTheLayersPixelGrid(); + void blendWeighsByCoverageAndRefusesMismatchedImages(); + void aSelectionLimitsTheInvert(); +}; + +void PixelInvertTests::invertKeepsAlphaAndSwapsMaskGray() +{ + QImage image = BrushRaster::context(2, 1, false); + image.setPixel(0, 0, qRgba(200, 40, 0, 200)); + image.setPixel(1, 0, qRgba(0, 0, 0, 0)); + const QImage inverted = PixelInvert::run({image, false, QTransform(), std::nullopt}); + QCOMPARE(inverted.format(), QImage::Format_RGBA8888_Premultiplied); + const uchar *first = inverted.constScanLine(0); + QCOMPARE(int(first[0]), 0); + QCOMPARE(int(first[1]), 160); + QCOMPARE(int(first[2]), 200); + QCOMPARE(int(first[3]), 200); + // Clear stays clear. + QCOMPARE(int(first[7]), 0); + QImage mask(3, 1, QImage::Format_Grayscale8); + mask.scanLine(0)[0] = 0; + mask.scanLine(0)[1] = 100; + mask.scanLine(0)[2] = 255; + const QImage swapped = PixelInvert::run({mask, true, QTransform(), std::nullopt}); + QCOMPARE(swapped.format(), QImage::Format_Grayscale8); + QCOMPARE(int(swapped.constScanLine(0)[0]), 255); + QCOMPARE(int(swapped.constScanLine(0)[1]), 155); + QCOMPARE(int(swapped.constScanLine(0)[2]), 0); + // Another format is read as premultiplied first. + QImage plain(1, 1, QImage::Format_ARGB32); + plain.setPixel(0, 0, qRgba(255, 0, 0, 128)); + const QImage halved = PixelInvert::run({plain, false, QTransform(), std::nullopt}); + const uchar *half = halved.constScanLine(0); + QCOMPARE(int(half[0]), 0); + QCOMPARE(int(half[1]), 128); + QCOMPARE(int(half[3]), 128); +} + +void PixelInvertTests::coverageFollowsTheLayersPixelGrid() +{ + // A 4×2 image at 1:1: the left half selected, exactly. + const SelectionClip half = leftHalf(4, 2); + const QImage exact = PixelAdjust::coverage(half, 4, 2, BrushRaster::pixelToDocument({.origin = {0, 0}, .size = {4, 2}}, 4, 2)); + QCOMPARE(int(exact.constScanLine(1)[1]), 255); + QCOMPARE(int(exact.constScanLine(1)[2]), 0); + // The image placed 2×: the selection lands at half size. + const QImage placed = PixelAdjust::coverage(half, 4, 2, BrushRaster::pixelToDocument({.origin = {0, 0}, .size = {8, 4}}, 4, 2)); + QCOMPARE(int(placed.constScanLine(0)[0]), 255); + QCOMPARE(int(placed.constScanLine(0)[1]), 0); + // A black-and-white clip enlarged 2×: its boundary turns soft. + QImage both = BrushRaster::context(10, 2, true); + for (int y = 0; y < 2; ++y) + std::fill_n(both.scanLine(y), 5, uchar(255)); + const SelectionClip inside{QRectF(0, 0, 10, 2), both}; + const QImage fine = PixelAdjust::coverage(inside, 20, 4, BrushRaster::pixelToDocument({.origin = {0, 0}, .size = {10, 2}}, 20, 4)); + QCOMPARE(int(fine.constScanLine(1)[2]), 255); + QCOMPARE(int(fine.constScanLine(1)[15]), 0); + for (const int column : {9, 10}) { + const int boundary = fine.constScanLine(1)[column]; + QVERIFY2(boundary > 0 && boundary < 255, qPrintable(QString::number(boundary))); + } + // A clip with no coverage covers nothing. + const QImage none = PixelAdjust::coverage({QRectF(), std::nullopt}, 4, 2, QTransform()); + QCOMPARE(int(none.constScanLine(0)[0]), 0); +} + +void PixelInvertTests::blendWeighsByCoverageAndRefusesMismatchedImages() +{ + QImage top = BrushRaster::context(2, 1, false), bottom = BrushRaster::context(2, 1, false); + top.fill(QColor::fromRgba(qRgba(255, 255, 255, 255))); + bottom.fill(QColor::fromRgba(qRgba(100, 100, 100, 255))); + QImage cover = BrushRaster::context(2, 1, true); + cover.scanLine(0)[0] = 255; + cover.scanLine(0)[1] = 64; + const SelectionClip clip{QRectF(0, 0, 2, 1), cover}; + const QTransform grid = BrushRaster::pixelToDocument({.origin = {0, 0}, .size = {2, 1}}, 2, 1); + const QImage blended = PixelAdjust::blend(top, bottom, clip, grid, false); + QCOMPARE(int(blended.constScanLine(0)[0]), 255); + // 64/255 of white over gray 100, rounded up from 138.9. + QCOMPARE(int(blended.constScanLine(0)[4]), 139); + QCOMPARE(int(blended.constScanLine(0)[7]), 255); + QImage gray = BrushRaster::context(2, 1, true); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::blend(top, gray, clip, grid, false)); + QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::blend(top, BrushRaster::context(3, 1, false), clip, grid, false)); +} + +void PixelInvertTests::aSelectionLimitsTheInvert() +{ + QImage image = BrushRaster::context(4, 2, false); + image.fill(Qt::red); + const QImage result = PixelInvert::run({image, false, BrushRaster::pixelToDocument({.origin = {0, 0}, .size = {4, 2}}, 4, 2), leftHalf(4, 2)}); + QCOMPARE(result.pixelColor(0, 0), QColor(Qt::cyan)); + QCOMPARE(result.pixelColor(3, 1), QColor(Qt::red)); +} + +QTEST_GUILESS_MAIN(PixelInvertTests) +#include "PixelInvertTests.moc" diff --git a/tests/PixelMoveTests.cpp b/tests/PixelMoveTests.cpp new file mode 100644 index 0000000..7160d79 --- /dev/null +++ b/tests/PixelMoveTests.cpp @@ -0,0 +1,326 @@ +#include "Document/BrushStroke.h" +#include "Rendering/RasterSnapshot.h" +#include "IO/ImageExporter.h" +#include "SelectionFixtures.h" +#include <QRegularExpression> + +// Selected pixels moved by a Ctrl-drag or a Ctrl-arrow. +namespace { +// Swift's twoColorLayer: red on the left half, blue right. +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(100, 40, true); + QImage image = BrushRaster::context(100, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 50, 40), Qt::red); + painter.fillRect(QRect(50, 0, 50, 40), Qt::blue); + painter.end(); + session->insert(ImportedImage(image, image, "Two")); + session->deleteLayer(session->document().value().layers[0].id); + return session; +} + +void select(EditorSession &session, const QRectF &rect) +{ + session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); +} + +std::vector<int> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +QImage render(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} + +bool finished(EditorSession &session) +{ + bool done = false; + session.finishPixelMove([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 10000); +} +} + +class PixelMoveTests : public QObject { + Q_OBJECT +private slots: + void cmdDragMovesSelectedPixelsAndOutlineAsOneUndo(); + void duplicatePixelDragPreservesSourceAndUndoesTogether(); + void cmdArrowNudgesPixelsAndMasksRefuse(); + void pixelMoveNeverShowsTheOutlineAtItsOldSpot(); + void aPixelMoveHoldsTheSessionAndAFailedMoveCancels(); + void aMoveAcrossTilesLeavesNoGhost(); + void aScaledLayerMovesInItsOwnPixels(); + void aFractionalLandingIsResampled(); + void aMoveNeedsVisiblePixelsAndEndsAnOpacityDrag(); + void aPaintedLayerMovesWithoutFlattening(); +}; + +void PixelMoveTests::cmdDragMovesSelectedPixelsAndOutlineAsOneUndo() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + const int count = session->history.undoCount(); + QVERIFY(session->beginPixelMove()); + session->movePixels(QSizeF(30.4, 0)); + session->movePixels(QSizeF(60.2, 5)); + QVERIFY(finished(*session)); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Move Pixels")); + QCOMPARE(session->selection().value().path.boundingRect(), QRectF(70, 15, 10, 10)); + const QImage result = render(*session); + QCOMPARE(pixel(result, 15, 15)[3], 0); + QCOMPARE(pixel(result, 75, 20), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 85, 20), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(pixel(result, 5, 5), (std::vector<int>{255, 0, 0, 255})); + session->undo(); + QCOMPARE(session->selection().value().path.boundingRect(), QRectF(10, 10, 10, 10)); + QCOMPARE(pixel(render(*session), 15, 15), (std::vector<int>{255, 0, 0, 255})); +} + +void PixelMoveTests::duplicatePixelDragPreservesSourceAndUndoesTogether() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + const int count = session->history.undoCount(); + QVERIFY(session->beginPixelMove(true)); + session->movePixels(QSizeF(60, 5)); + QVERIFY(finished(*session)); + const QImage result = render(*session); + QVERIFY(pixel(result, 15, 15)[0] > 250); + QVERIFY(pixel(result, 75, 20)[0] > 250); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Duplicate Pixels")); + session->undo(); + const QImage restored = render(*session); + QVERIFY(pixel(restored, 15, 15)[0] > 250); + QVERIFY(pixel(restored, 75, 20)[2] > 250); +} + +void PixelMoveTests::cmdArrowNudgesPixelsAndMasksRefuse() +{ + const auto session = makeSession(); + // The last red column band moves ten to the right. + select(*session, QRectF(40, 0, 10, 40)); + bool done = false; + session->nudgePixels(10, 0, [&] { done = true; }); + QTRY_VERIFY(done); + const QImage result = render(*session); + QCOMPARE(pixel(result, 45, 20)[3], 0); + QCOMPARE(pixel(result, 55, 20), (std::vector<int>{255, 0, 0, 255})); + session->addLayerMask(); + QVERIFY(session->isMaskSelected() && !session->beginPixelMove()); + session->cancelPixelMove(); + // Refused, a nudge still calls back and records nothing. + const int count = session->history.undoCount(); + done = false; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no selected pixels to nudge"))); + session->nudgePixels(1, 0, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoCount(), count); +} + +void PixelMoveTests::pixelMoveNeverShowsTheOutlineAtItsOldSpot() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(session->beginPixelMove()); + session->movePixels(QSizeF(30, 0)); + const QRectF moved(40, 10, 10, 10); + QCOMPARE(session->displayedSelection().value().path.boundingRect(), moved); + bool done = false; + session->finishPixelMove([&] { done = true; }); + // Mid-commit the outline stays at its new place. + QVERIFY(session->isProjectBusy() && session->pixelMove()); + QCOMPARE(session->displayedSelection().value().path.boundingRect(), moved); + QTRY_VERIFY(done); + QCOMPARE(session->selection().value().path.boundingRect(), moved); + QVERIFY(!session->pixelMove()); +} + +void PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels() +{ + const auto session = makeSession(); + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(session->canEditLayers() && session->canInvert() && session->canAdjustColors()); + QVERIFY(session->beginPixelMove()); + // Unmoved, the outline stays where it was. + QCOMPARE(session->displayedSelection().value().path.boundingRect(), QRectF(10, 10, 10, 10)); + QVERIFY(!session->canEditLayers() && !session->canInvert() && !session->canAdjustColors()); + QVERIFY(!session->beginPixelMove()); + // Unmoved, the finish records nothing and frees the session. + const int count = session->history.undoCount(); + QVERIFY(finished(*session)); + QVERIFY(!session->pixelMove() && session->canEditLayers()); + QCOMPARE(session->history.undoCount(), count); + // A move past the budget cancels and shows the error. + QVERIFY(session->beginPixelMove()); + session->pixelMove()->raster->pixelLimit = 1000; + const int revision = session->brushRevision(); + session->movePixels(QSizeF(60, 0)); + QVERIFY(!session->pixelMove() && session->brushError().has_value()); + QVERIFY(session->brushRevision() > revision); + QCOMPARE(session->history.undoCount(), count); + QCOMPARE(pixel(render(*session), 15, 15), (std::vector<int>{255, 0, 0, 255})); + // A hard outline stays hard, moving and moved. + session->setSelectionAntialiased(false); + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(session->beginPixelMove()); + session->movePixels(QSizeF(5, 0)); + QVERIFY(!session->displayedSelection().value().antialiased); + QVERIFY(finished(*session)); + QVERIFY(!session->selection().value().antialiased); +} + +void PixelMoveTests::aMoveAcrossTilesLeavesNoGhost() +{ + EditorSession session; + session.createDocument(600, 40, true); + QImage image = BrushRaster::context(600, 40, false); + image.fill(Qt::blue); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 100, 40), Qt::red); + painter.end(); + session.insert(ImportedImage(image, image, "Wide")); + const std::vector<int> red{255, 0, 0, 255}, blue{0, 0, 255, 255}; + // One tile over: the hole is cut in the first. + select(session, QRectF(20, 10, 40, 20)); + QVERIFY(session.beginPixelMove()); + session.movePixels(QSizeF(300, 0)); + QVERIFY(finished(session)); + QCOMPARE(pixel(render(session), 330, 15), red); + QCOMPARE(pixel(render(session), 30, 15)[3], 0); + // Out to the third tile and back: no ghost stays. + QVERIFY(session.beginPixelMove()); + session.movePixels(QSizeF(200, 0)); + session.movePixels(QSizeF(-200, 0)); + QVERIFY(finished(session)); + const QImage result = render(session); + QCOMPARE(pixel(result, 130, 15), red); + QCOMPARE(pixel(result, 530, 15), blue); + QCOMPARE(pixel(result, 330, 15)[3], 0); +} + +void PixelMoveTests::aScaledLayerMovesInItsOwnPixels() +{ + EditorSession session; + session.createDocument(200, 100, true); + QImage image = BrushRaster::context(50, 20, false); + image.fill(Qt::blue); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 25, 20), Qt::red); + painter.end(); + session.insert(ImportedImage(image, image, "Scaled")); + const QUuid id = session.activeLayerID().value(); + // Doubled past the top left, so the grid starts off-canvas. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {-10, -10}, .size = {100, 40}}; }); + session.selectLayer(id); + select(session, QRectF(0, 0, 20, 20)); + QVERIFY(session.beginPixelMove()); + session.movePixels(QSizeF(60, 0)); + QVERIFY(finished(session)); + QImage result = render(session); + QCOMPARE(pixel(result, 70, 10), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 70, 25), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(pixel(result, 10, 10)[3], 0); +} + +void PixelMoveTests::aFractionalLandingIsResampled() +{ + EditorSession session; + session.createDocument(100, 40, true); + // Four times as large; red pixels 10..15 by 2..6. + QImage image = BrushRaster::context(25, 10, false); + image.fill(Qt::blue); + QPainter painter(&image); + painter.fillRect(QRect(10, 2, 5, 4), Qt::red); + painter.end(); + session.insert(ImportedImage(image, image, "Fourfold")); + const QUuid id = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {100, 40}}; }); + session.selectLayer(id); + select(session, QRectF(40, 8, 20, 16)); + QVERIFY(session.beginPixelMove()); + // A quarter and half a pixel: weights 1/8 and 3/8. + session.movePixels(QSizeF(-1, -2)); + QVERIFY(finished(session)); + const ImageLayer layer = layerWith(session, id); + QCOMPARE(layer.transform, (LayerTransform{.origin = {0, 0}, .size = {100, 40}})); + const QImage moved = layer.asset.value().image(); + QCOMPARE(pixel(moved, 9, 1), (std::vector<int>{32, 0, 223, 255})); + QCOMPARE(pixel(moved, 10, 1), (std::vector<int>{128, 0, 127, 255})); + QCOMPARE(pixel(moved, 9, 2), (std::vector<int>{64, 0, 191, 255})); + QCOMPARE(pixel(moved, 10, 2), (std::vector<int>{255, 0, 0, 255})); +} + +void PixelMoveTests::aMoveNeedsVisiblePixelsAndEndsAnOpacityDrag() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + select(*session, QRectF(10, 10, 10, 10)); + // Hidden, the layer takes no move. + session->toggleLayerVisibility(id); + QVERIFY(!session->beginPixelMove()); + session->toggleLayerVisibility(id); + // A small layer the selection misses lifts nothing. + QImage dot = BrushRaster::context(10, 10, false); + dot.fill(Qt::red); + session->insert(ImportedImage(dot, dot, "Dot"), QPointF(85, 5)); + select(*session, QRectF(10, 20, 10, 10)); + QVERIFY(!session->beginPixelMove() && !session->pixelMove()); + QVERIFY(!session->brushError().has_value()); + // An opacity drag closes as its own step first. + select(*session, QRectF(82, 2, 4, 4)); + const int count = session->history.undoCount(); + session->beginOpacityEdit(); + session->setLayerOpacity(0.5); + QVERIFY(session->beginPixelMove()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Layer Opacity")); + // Every step moves the canvas's counter. + const int revision = session->brushRevision(); + session->movePixels(QSizeF(1, 0)); + QCOMPARE(session->brushRevision(), revision + 1); + // A second finish while the first commits returns at once. + bool first = false, second = false; + session->finishPixelMove([&] { first = true; }); + QVERIFY(session->isProjectBusy()); + session->finishPixelMove([&] { second = true; }); + QTRY_VERIFY(first && second); + QCOMPARE(session->history.undoCount(), count + 2); + QCOMPARE(session->history.undoName(), QString("Move Pixels")); +} + +void PixelMoveTests::aPaintedLayerMovesWithoutFlattening() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + // A white dab makes the layer a painted raster. + session->selectTool(NavigationTool::brush); + session->setBrushSettings(BrushSettings{.diameter = 6, .hardness = 1, .red = 1, .green = 1, .blue = 1}); + session->beginBrush(QPointF(80, 30)); + QVERIFY(session->finishBrushImmediately()); + const ImportedImage painted = layerWith(*session, id).asset.value(); + QVERIFY(painted.raster && !painted.raster->hasMaterializedPixels()); + // Lift, move and commit read tiles, never the whole raster. + select(*session, QRectF(10, 10, 10, 10)); + QVERIFY(session->beginPixelMove()); + QVERIFY(!painted.raster->hasMaterializedPixels()); + session->movePixels(QSizeF(60, 0)); + QVERIFY(finished(*session)); + QVERIFY(!painted.raster->hasMaterializedPixels()); + QCOMPARE(session->history.undoName(), QString("Move Pixels")); + // Red lands in the blue half: only moved pixels show. + const QImage result = render(*session); + QCOMPARE(pixel(result, 75, 15), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 15, 15)[3], 0); + QCOMPARE(pixel(result, 80, 30), (std::vector<int>{255, 255, 255, 255})); +} + +QTEST_GUILESS_MAIN(PixelMoveTests) +#include "PixelMoveTests.moc" diff --git a/tests/PoolMapTests.cpp b/tests/PoolMapTests.cpp new file mode 100644 index 0000000..3e18414 --- /dev/null +++ b/tests/PoolMapTests.cpp @@ -0,0 +1,49 @@ +#include "Document/PixelAdjust.h" +#include "Rendering/HslBlend.h" +#include "Rendering/PoolMap.h" +#include <QtTest> +#include <atomic> +#include <cstdio> + +// Parallel kernels in a worker finish on a one-thread pool. +class PoolMapTests : public QObject { + Q_OBJECT +private slots: + void aPoolThreadLendsItsPlaceWhileItWaits(); +}; + +namespace { +// On the pool; a hang ends the binary, else waiting. +void finishes(const char *name, const std::function<void()> &work) +{ + QFuture<void> job = QtConcurrent::run(work); + if (!QTest::qWaitFor([&job] { return job.isFinished(); }, 10'000)) { + std::fprintf(stderr, "%s hung on a one-thread pool\n", name); + std::_Exit(1); + } +} +} + +void PoolMapTests::aPoolThreadLendsItsPlaceWhileItWaits() +{ + QThreadPool *pool = QThreadPool::globalInstance(); + const int threads = pool->maxThreadCount(); + pool->setMaxThreadCount(1); + QImage image(48, 40, QImage::Format_RGBA8888_Premultiplied); + image.fill(qRgba(40, 80, 120, 255)); + QImage blended, blurred; + finishes("the HSL blend", [&] { blended = HslBlend::blend(image, image, LayerBlendMode::hue, 1); }); + finishes("the Gaussian blur", [&] { blurred = PixelAdjust::gaussianBlur(image, 3, false); }); + QCOMPARE(blended.size(), image.size()); + QCOMPARE(blurred.size(), image.size()); + // The lent place comes back: the pool counts right again. + std::atomic<int> seen = 0; + std::vector<int> items(64); + finishes("a map in a worker", [&] { PoolMap::blocking(items, [&seen](int &) { ++seen; }); }); + QCOMPARE(seen.load(), 64); + QCOMPARE(pool->activeThreadCount(), 0); + pool->setMaxThreadCount(threads); +} + +QTEST_GUILESS_MAIN(PoolMapTests) +#include "PoolMapTests.moc" diff --git a/tests/ProjectControllerTests.cpp b/tests/ProjectControllerTests.cpp new file mode 100644 index 0000000..06b0b93 --- /dev/null +++ b/tests/ProjectControllerTests.cpp @@ -0,0 +1,390 @@ +#include "DialogDesk.h" +#include "IO/ProjectController.h" +#include "IO/ProjectStore.h" +#include "SessionRecord.h" +#include <QFutureWatcher> +#include <QTemporaryDir> +#include <QtTest> + +// Saving, and the question before unsaved work is dropped. +namespace { +const QString savePanel = QStringLiteral("panel|Save Project|save|file|Untitled.comp|comp"); +const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save"); + +// Eight by six with one red layer: unsaved work. +void paint(EditorSession &session) +{ + session.createDocument(8, 6); + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session.insert(ImportedImage(image, QImage(), "Red")); +} + +// The answer a call hands its completion, once it comes. +std::optional<bool> answered(const std::function<void(std::function<void(bool)>)> &call) +{ + std::optional<bool> result; + call([&](bool value) { result = value; }); + // An answer that never comes stays empty and fails. + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} + +int layersOnDisk(const QString &path) +{ + return int(ProjectStore::load(path).manifest.layers.size()); +} +} + +class ProjectControllerTests : public QObject { + Q_OBJECT +private slots: + void theFirstSaveAsksWhereAndLaterSavesDoNot(); + void saveAsAsksAgainUnderTheCurrentName(); + void aProjectAlwaysEndsInItsSuffix(); + void aCancelledSaveLeavesEverythingAsItWas(); + void nothingToSaveOrABusySessionIsRefusedUnasked(); + void aFailedSaveSaysWhyAndStaysModified(); + void savingEndsAnOpenTransformFirst(); + void theLastSignalOfASaveSeesItDone(); + void aCompletionNeverRunsInsideTheCall(); + void aCallWithoutACompletionIsWhole(); + void quittingAsksAboutUnsavedWork(); + void aNewCanvasAsksThenClears(); + void closingAsksThenClearsAndClosesTheWindow(); + void aSessionThatEndsMidSaveCallsNobody(); +}; + +void ProjectControllerTests::theFirstSaveAsksWhereAndLaterSavesDoNot() +{ + QTemporaryDir folder; + EditorSession session; + paint(session); + ProjectController controller(session); + QVERIFY(controller.canStart() && session.isModified()); + DialogDesk desk; + desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); }; + // Typed without its suffix: the panel adds it. + desk.replies = {folder.filePath("First")}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + const QString path = folder.filePath("First.comp"); + QCOMPARE(desk.seen, (QStringList{savePanel + "|busy"})); + QCOMPARE(session.projectPath(), std::optional(path)); + QVERIFY(!session.isModified() && !session.isProjectBusy() && controller.canStart()); + QCOMPARE(layersOnDisk(path), 1); + // The second save goes to the same place unasked. + session.addBlankLayer(); + QVERIFY(session.isModified()); + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + QCOMPARE(desk.seen.size(), 1); + QCOMPARE(layersOnDisk(path), 2); + QVERIFY(!session.isModified() && !session.isProjectBusy()); + // Each save's watcher goes once it has answered. + QTRY_VERIFY(controller.findChildren<QFutureWatcherBase *>().isEmpty()); +} + +void ProjectControllerTests::saveAsAsksAgainUnderTheCurrentName() +{ + QTemporaryDir folder; + EditorSession session; + paint(session); + ProjectController controller(session); + DialogDesk desk; + desk.replies = {folder.filePath("First.comp"), folder.filePath("Second.comp")}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + session.addBlankLayer(); + QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{savePanel, "panel|Save Project As|save|file|First.comp|comp"})); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Second.comp"))); + QVERIFY(!session.isModified()); + // The first project stays as it was saved. + QCOMPARE(layersOnDisk(folder.filePath("First.comp")), 1); + QCOMPARE(layersOnDisk(folder.filePath("Second.comp")), 2); +} + +void ProjectControllerTests::aProjectAlwaysEndsInItsSuffix() +{ + QTemporaryDir folder; + EditorSession session; + paint(session); + ProjectController controller(session); + DialogDesk desk; + // A foreign suffix stays in the name; the project's follows. + desk.replies = {folder.filePath("Misnamed.png"), folder.filePath("Shouted.COMP")}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Misnamed.png.comp"))); + QVERIFY(!QFileInfo::exists(folder.filePath("Misnamed.png"))); + QCOMPARE(layersOnDisk(folder.filePath("Misnamed.png.comp")), 1); + // The suffix counts in any case, as drops read it. + QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(true)); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Shouted.COMP"))); + QCOMPARE(layersOnDisk(folder.filePath("Shouted.COMP")), 1); + // The name as completed meets a project: that one stays. + session.addBlankLayer(); + desk.seen.clear(); + desk.replies = {folder.filePath("Misnamed.png"), "OK"}; + QTest::ignoreMessage(QtWarningMsg, "Couldn’t save the project: “Misnamed.png.comp” already exists. Choose another name."); + QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(false)); + QCOMPARE(desk.seen.value(1), QString("alert|2|Couldn’t save the project|“Misnamed.png.comp” already exists. Choose another name.|OK")); + QCOMPARE(layersOnDisk(folder.filePath("Misnamed.png.comp")), 1); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Shouted.COMP"))); + QVERIFY(session.isModified() && !session.isProjectBusy()); + // A link that leads nowhere is in the way too. + QVERIFY(QFile::link(folder.filePath("nowhere"), folder.filePath("Dangling.png.comp"))); + desk.replies = {folder.filePath("Dangling.png"), "OK"}; + QTest::ignoreMessage(QtWarningMsg, "Couldn’t save the project: “Dangling.png.comp” already exists. Choose another name."); + QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(false)); + QVERIFY(QFileInfo(folder.filePath("Dangling.png.comp")).isSymLink()); +} + +void ProjectControllerTests::aCancelledSaveLeavesEverythingAsItWas() +{ + EditorSession session; + paint(session); + ProjectController controller(session); + const QStringList before = described(session); + DialogDesk desk; + desk.replies = {"<cancel>"}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(false)); + QCOMPARE(desk.seen, (QStringList{savePanel})); + QCOMPARE(described(session), before); + QVERIFY(session.isModified() && !session.isProjectBusy()); +} + +void ProjectControllerTests::nothingToSaveOrABusySessionIsRefusedUnasked() +{ + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + QSignalSpy changes(&session, &EditorSession::changed); + // No canvas: nothing to save, and nothing turns busy. + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(false)); + QCOMPARE(changes.count(), 0); + paint(session); + session.setIsProjectBusy(true); + QVERIFY(!controller.canStart()); + // Once dimmed, the record holds however long the rest takes. + QTRY_VERIFY(session.showsBusy()); + const QStringList before = described(session); + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(false)); + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QTemporaryDir folder; + ProjectStore::save(session.projectSnapshot().value(), folder.filePath("Ready.comp")); + QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Ready.comp"), done); }), std::optional(false)); + controller.newCanvas(); + QWidget window; + window.show(); + controller.close(&window); + QTest::qWait(50); + QCOMPARE(described(session), before); + QVERIFY(window.isVisible() && desk.seen.isEmpty()); +} + +void ProjectControllerTests::aFailedSaveSaysWhyAndStaysModified() +{ + QTemporaryDir folder; + QFile plain(folder.filePath("plain.txt")); + QVERIFY(plain.open(QIODevice::WriteOnly)); + plain.close(); + EditorSession session; + paint(session); + ProjectController controller(session); + // No folder can be made under a plain file. + const QString path = folder.filePath("plain.txt/Lost.comp"); + session.setProjectPath(path); + DialogDesk desk; + desk.replies = {"OK"}; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^could not create .+")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t save the project: .+")); + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), 1); + const QStringList alert = desk.seen.first().split('|'); + QCOMPARE(alert.mid(0, 3), (QStringList{"alert", "2", "Couldn’t save the project"})); + QVERIFY(!alert.value(3).isEmpty()); + QCOMPARE(alert.value(4), QString("OK")); + QVERIFY(session.isModified() && !session.isProjectBusy()); + QCOMPARE(session.projectPath(), std::optional(path)); +} + +void ProjectControllerTests::savingEndsAnOpenTransformFirst() +{ + QTemporaryDir folder; + EditorSession session; + paint(session); + ProjectController controller(session); + session.beginTransform(); + LayerTransform moved = session.transformEdit().value().draft; + moved.origin += QPointF(2, 1); + session.previewTransform(moved); + DialogDesk desk; + desk.replies = {folder.filePath("Moved.comp")}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(ProjectStore::load(folder.filePath("Moved.comp")).manifest.layers.front().transform.origin, moved.origin); + QVERIFY(!session.isModified()); +} + +void ProjectControllerTests::theLastSignalOfASaveSeesItDone() +{ + QTemporaryDir folder; + EditorSession session; + paint(session); + ProjectController controller(session); + QStringList seen; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + DialogDesk desk; + desk.replies = {folder.filePath("Seen.comp")}; + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + QCOMPARE(seen, described(session)); + QVERIFY(seen.contains("path " + folder.filePath("Seen.comp"))); +} + +void ProjectControllerTests::aCompletionNeverRunsInsideTheCall() +{ + EditorSession session; + ProjectController controller(session); + // Even a refusal answers from the event loop. + int calls = 0; + controller.save(false, [&](bool) { calls += 1; }); + controller.confirmQuit([&](bool) { calls += 1; }); + controller.open(QString("/nowhere.comp"), [&](bool) { calls += 1; }); + QCOMPARE(calls, 0); + DialogDesk desk; + desk.replies = {"OK"}; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); + QTRY_COMPARE(calls, 3); +} + +void ProjectControllerTests::aCallWithoutACompletionIsWhole() +{ + QTemporaryDir folder; + EditorSession session; + ProjectController controller(session); + // Refused, with nobody to tell: the posted answer is nothing. + controller.save(); + controller.confirmQuit({}); + QTest::qWait(20); + paint(session); + DialogDesk desk; + desk.replies = {folder.filePath("Quiet.comp")}; + controller.save(); + QTRY_VERIFY(!session.isModified() && !session.isProjectBusy()); + QCOMPARE(layersOnDisk(folder.filePath("Quiet.comp")), 1); + EditorSession other; + ProjectController opener(other); + opener.open(folder.filePath("Quiet.comp")); + QTRY_VERIFY(other.document().has_value() && !other.isProjectBusy()); + QTest::qWait(20); + QCOMPARE(other.projectPath(), std::optional(folder.filePath("Quiet.comp"))); +} + +void ProjectControllerTests::quittingAsksAboutUnsavedWork() +{ + QTemporaryDir folder; + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + // Nothing unsaved: no question. + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(true)); + // A saved canvas undone: changed, yet nothing to save. + session.createDocument(8, 6); + session.history.markSaved(); + session.undo(); + QVERIFY(session.isModified() && !session.document().has_value()); + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(true)); + QVERIFY(desk.seen.isEmpty()); + paint(session); + desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); }; + desk.replies = {"Cancel", "Don’t Save", "Save", folder.filePath("Kept.comp")}; + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QVERIFY(!session.isProjectBusy()); + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(true)); + QVERIFY(session.isModified() && !session.isProjectBusy()); + // Save asks where, since this work has no place yet. + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{question.arg("Untitled") + "|busy", question.arg("Untitled") + "|busy", + question.arg("Untitled") + "|busy", savePanel + "|busy"})); + QVERIFY(!session.isModified() && !session.isProjectBusy()); + QCOMPARE(layersOnDisk(folder.filePath("Kept.comp")), 1); + // A saved project is asked about by its name. + session.addBlankLayer(); + desk.replies = {"Save"}; + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(true)); + QCOMPARE(desk.seen.last(), question.arg("Kept.comp") + "|busy"); + QCOMPARE(layersOnDisk(folder.filePath("Kept.comp")), 2); + // A save that is called off calls the quit off. + session.addBlankLayer(); + session.setProjectPath(std::nullopt); + desk.replies = {"Save", "<cancel>"}; + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QVERIFY(session.isModified() && !session.isProjectBusy()); + // Escape means Cancel; Return means Save. + desk.replies = {"<escape>", "<return>", "<cancel>"}; + const qsizetype asked = desk.seen.size(); + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), asked + 1); + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), asked + 3); + QVERIFY(desk.seen.last().startsWith("panel|Save Project|")); +} + +void ProjectControllerTests::aNewCanvasAsksThenClears() +{ + EditorSession session; + paint(session); + ProjectController controller(session); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + controller.newCanvas(); + QTRY_COMPARE(desk.seen.size(), 1); + QTRY_VERIFY(!session.isProjectBusy()); + QVERIFY(session.document().has_value()); + controller.newCanvas(); + QTRY_VERIFY(!session.document().has_value()); + QVERIFY(!session.isProjectBusy() && !session.isModified()); + QCOMPARE(desk.seen.size(), 2); +} + +void ProjectControllerTests::closingAsksThenClearsAndClosesTheWindow() +{ + EditorSession session; + paint(session); + ProjectController controller(session); + QWidget window; + window.show(); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + controller.close(&window); + QTRY_COMPARE(desk.seen.size(), 1); + QTRY_VERIFY(!session.isProjectBusy()); + QVERIFY(window.isVisible() && session.document().has_value()); + controller.close(&window); + QTRY_VERIFY(!window.isVisible()); + QVERIFY(!session.document().has_value() && !session.isProjectBusy()); + // A window that is gone by then is left alone. + paint(session); + auto gone = std::make_unique<QWidget>(); + desk.replies = {"Don’t Save"}; + controller.close(gone.get()); + gone.reset(); + QTRY_VERIFY(!session.document().has_value()); +} + +void ProjectControllerTests::aSessionThatEndsMidSaveCallsNobody() +{ + QTemporaryDir folder; + auto session = std::make_unique<EditorSession>(); + paint(*session); + session->setProjectPath(folder.filePath("Orphan.comp")); + auto controller = std::make_unique<ProjectController>(*session); + int calls = 0; + controller->save(false, [&](bool) { calls += 1; }); + controller.reset(); + session.reset(); + // The worker still ends; its answer has nowhere to go. + QVERIFY(QThreadPool::globalInstance()->waitForDone(10'000)); + QTest::qWait(50); + QCOMPARE(calls, 0); +} + +QTEST_MAIN(ProjectControllerTests) +#include "ProjectControllerTests.moc" diff --git a/tests/ProjectCopyTests.cpp b/tests/ProjectCopyTests.cpp new file mode 100644 index 0000000..06279fe --- /dev/null +++ b/tests/ProjectCopyTests.cpp @@ -0,0 +1,420 @@ +#include "Document/ProjectWorkspace.h" +#include "Rendering/RasterSnapshot.h" +#include "SessionFixtures.h" +#include <QFutureWatcher> +#include <QMimeData> +#include <QSemaphore> +#include <QThreadPool> +#include <QtTest> + +// Layers copied from one open project into another. +namespace { +ImportedImage solid(QColor colour, int alpha, const QString &name) +{ + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + colour.setAlpha(alpha); + image.fill(colour); + return ImportedImage(image, QImage(), name); +} + +// A layer that claims this many pixels and holds one. +ImportedImage vast(int width, int height) +{ + QImage dot(1, 1, QImage::Format_RGBA8888_Premultiplied); + dot.fill(Qt::red); + return ImportedImage(std::make_shared<const RasterSnapshot>(width, height, dot, QRectF(0, 0, 1, 1), std::vector<BrushPatch>{}), QImage(), "Vast"); +} + +bool copied(ProjectWorkspace &workspace, QUuid id, std::optional<QUuid> destination, std::optional<QPointF> point = std::nullopt) +{ + bool done = false; + workspace.copyLayer(id, destination, point, [&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 10'000); +} + +QStringList names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result; +} +} + +class ProjectCopyTests : public QObject { + Q_OBJECT +private slots: + void whatMayNotBeCopiedChangesNothing(); + void theHundredMegapixelsCountAcrossBothProjects(); + void aFolderTravelsWithItsContentsAndItsLinks(); + void aMaskFromOutsideTheCopyIsBakedIn(); + void anAdjustmentLetsGoOfItsBaseUnbaked(); + void effectsStayBehindAsSwiftsListLeavesThem(); + void theCopyLandsAtThePointOrTheCentre(); + void aNewTabGetsTheSourcesCanvasInOneStep(); + void bothProjectsAreBusyWhileTheCopyBakes(); + void aBakeThatFailsCopiesNothingAndSaysWhy(); + void aDroppedRowMayCarryTheNullId(); + void ofTwoTabsWithTheLayerTheFirstIsTheSource(); +}; + +void ProjectCopyTests::whatMayNotBeCopiedChangesNothing() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(8, 8); + from.session.insert(solid(Qt::red, 255, "Red")); + const QUuid id = from.session.activeLayerID().value(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(8, 8); + target.session.insert(solid(Qt::blue, 255, "Blue")); + QSignalSpy changes(&workspace, &ProjectWorkspace::changed); + QSignalSpy edits(&target.session, &EditorSession::changed); + // A stranger, the layer's own tab, a missing tab. + QVERIFY(copied(workspace, QUuid::createUuid(), target.id)); + QVERIFY(copied(workspace, id, from.id)); + QVERIFY(copied(workspace, id, QUuid::createUuid())); + // A source or a target amid a transform. + target.session.beginTransform(); + QVERIFY(copied(workspace, id, target.id)); + target.session.cancelTransform(); + from.session.selectLayer(id); + from.session.beginTransform(); + QVERIFY(copied(workspace, id, target.id)); + from.session.cancelTransform(); + QCOMPARE(names(target.session), (QStringList{"Blue"})); + QCOMPARE(int(workspace.tabs().size()), 2); + QCOMPARE(changes.count(), 0); + // A target under a sheet or error; a busy front. + const qsizetype before = edits.count(); + target.session.setShowsImporter(true); + QVERIFY(copied(workspace, id, target.id)); + target.session.setShowsImporter(false); + workspace.select(from.id); + target.session.setImportError(QString("earlier")); + QVERIFY(target.session.canEditLayers() && copied(workspace, id, target.id)); + target.session.setImportError(std::nullopt); + workspace.select(target.id); + workspace.current().session.setIsProjectBusy(true); + QVERIFY(copied(workspace, id, std::nullopt)); + workspace.current().session.setIsProjectBusy(false); + QCOMPARE(names(target.session), (QStringList{"Blue"})); + QCOMPARE(int(workspace.tabs().size()), 2); + QCOMPARE(changes.count(), 2); + QCOMPARE(edits.count(), before + 6); +} + +void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(8, 8); + from.session.insert(vast(10'000, 10'000)); + const QUuid id = from.session.activeLayerID().value(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(8, 8); + // Exactly the limit fits. + QVERIFY(copied(workspace, id, target.id)); + QCOMPARE(names(target.session), (QStringList{"Vast"})); + QCOMPARE(target.session.importError(), std::nullopt); + // One pixel more, counted across both, does not. + target.session.undo(); + target.session.insert(solid(Qt::blue, 255, "Dot")); + target.session.setRenamingLayerID(std::nullopt); + QVERIFY(copied(workspace, id, target.id)); + QCOMPARE(names(target.session), (QStringList{"Dot"})); + QCOMPARE(target.session.importError(), std::optional(QString("The copied layers exceed this project’s 100-megapixel limit."))); + QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); + // While that error shows, the front tab lets nothing start. + QVERIFY(!workspace.canSwitch() && copied(workspace, id, std::nullopt)); + QCOMPARE(int(workspace.tabs().size()), 2); + target.session.setImportError(std::nullopt); + // Refused for a new tab, that tab stays. + from.session.insert(vast(1, 1)); + const QUuid extra = from.session.activeLayerID().value(); + from.session.addGroup(); + const QUuid folder = from.session.activeLayerID().value(); + QVERIFY(from.session.placeLayer(id, folder)); + QVERIFY(from.session.placeLayer(extra, folder)); + QVERIFY(copied(workspace, folder, std::nullopt)); + QCOMPARE(int(workspace.tabs().size()), 3); + QVERIFY(!workspace.current().session.document().has_value()); + QVERIFY(workspace.current().session.importError().has_value()); +} + +void ProjectCopyTests::aFolderTravelsWithItsContentsAndItsLinks() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(8, 8); + from.session.insert(solid(Qt::green, 255, "Stays")); + from.session.addGroup(); + const QUuid folder = from.session.activeLayerID().value(); + from.session.insert(solid(Qt::red, 255, "Base")); + const QUuid base = from.session.activeLayerID().value(); + from.session.insert(solid(Qt::blue, 255, "Clipped")); + const QUuid clipped = from.session.activeLayerID().value(); + from.session.toggleClippingMask(clipped); + const std::optional<CanvasDocument> before = from.session.document(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(8, 8); + target.session.insert(solid(Qt::white, 255, "Own")); + // Copied from the tab in front into one behind it. + workspace.select(from.id); + bool switchable = false; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { switchable = workspace.canSwitch(); }); + QVERIFY(copied(workspace, folder, target.id)); + QVERIFY(switchable && workspace.canSwitch()); + // The source is untouched; the copy is whole and renumbered. + QCOMPARE(from.session.document(), before); + QCOMPARE(names(target.session), (QStringList{"Own", "Folder 1", "Base", "Clipped"})); + const std::vector<ImageLayer> layers = target.session.document().value().layers; + QVERIFY(layers[1].id != folder && layers[2].id != base && layers[3].id != clipped); + QVERIFY(layers[1].isGroup && layers[1].parentID == std::nullopt); + QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); + QCOMPARE(layers[3].parentID, std::optional(layers[1].id)); + QCOMPARE(layers[3].maskSourceID, std::optional(layers[2].id)); + // The same pixels: nothing inside the copy needed baking. + QVERIFY(layers[3].asset.value().identity() == before.value().layers[3].asset.value().identity()); + QCOMPARE(target.session.activeLayerID(), std::optional(layers[1].id)); + QCOMPARE(workspace.selectedID(), target.id); + QCOMPARE(target.session.history.undoName(), QString("Copy Layers from Project")); + // A child copied alone leaves its folder behind. + QVERIFY(copied(workspace, base, target.id)); + QCOMPARE(target.session.document().value().layers.back().parentID, std::nullopt); + // Each copy's watcher goes once it has answered. + QTRY_VERIFY(workspace.findChildren<QFutureWatcherBase *>().isEmpty()); +} + +void ProjectCopyTests::aMaskFromOutsideTheCopyIsBakedIn() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(2, 2); + from.session.insert(solid(Qt::red, 128, "Base")); + from.session.insert(solid(Qt::blue, 255, "Top")); + const QUuid top = from.session.activeLayerID().value(); + from.session.toggleClippingMask(top); + const ImageIdentity pixels = from.session.activeLayer().value().asset.value().identity(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(2, 2); + QVERIFY(copied(workspace, top, target.id)); + const ImageLayer copy = target.session.document().value().layers.front(); + QCOMPARE(copy.maskSourceID, std::nullopt); + QVERIFY(copy.asset.value().identity() != pixels); + const QColor pixel = copy.asset.value().image().pixelColor(0, 0); + QVERIFY2(std::abs(pixel.alpha() - 128) <= 1, qPrintable(QString::number(pixel.alpha()))); + QCOMPARE(pixel.blue(), 255); + // The source keeps its link and its own pixels. + const ImageLayer kept = layerWith(from.session, top); + QVERIFY(kept.maskSourceID.has_value() && kept.asset.value().identity() == pixels); +} + +void ProjectCopyTests::anAdjustmentLetsGoOfItsBaseUnbaked() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(2, 2); + from.session.insert(solid(Qt::red, 255, "Base")); + from.session.addAdjustment(AdjustmentKind::curves); + from.session.setAdjustmentEditingID(std::nullopt); + const QUuid adjustment = from.session.activeLayerID().value(); + from.session.toggleClippingMask(adjustment); + QVERIFY(from.session.activeLayer().value().maskSourceID.has_value()); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(2, 2); + QVERIFY(copied(workspace, adjustment, target.id)); + // Settings travel; no pixels to bake the base into. + const ImageLayer copy = target.session.document().value().layers.front(); + QVERIFY(!copy.maskSourceID && !copy.asset && copy.adjustment == LayerAdjustment{AdjustmentKind::curves}); +} + +void ProjectCopyTests::effectsStayBehindAsSwiftsListLeavesThem() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(2, 2); + from.session.insert(solid(Qt::red, 255, "Framed")); + LayerEffects effects; + effects.stroke = StrokeEffect(); + from.session.setEffects(effects); + const QUuid framed = from.session.activeLayerID().value(); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(2, 2); + QVERIFY(copied(workspace, framed, target.id)); + const ImageLayer copy = target.session.document().value().layers.front(); + QVERIFY(copy.name == QString("Framed") && !copy.effects); + QVERIFY(layerWith(from.session, framed).effects == effects); +} + +void ProjectCopyTests::theCopyLandsAtThePointOrTheCentre() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(20, 10); + from.session.insert(solid(Qt::red, 255, "Red"), QPointF(5, 5)); + const QUuid id = from.session.activeLayerID().value(); + // A mask placed two to the right rides along. + rewrite(from.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, coverage()); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {6, 4}, .size = {2, 2}}; + }); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(40, 30); + QVERIFY(copied(workspace, id, target.id)); + QVERIFY(copied(workspace, id, target.id, QPointF(7, 9))); + const std::vector<ImageLayer> layers = target.session.document().value().layers; + QCOMPARE(layers[0].transform.center(), QPointF(20, 15)); + QCOMPARE(layers[0].mask.value().placement.value().origin, QPointF(21, 14)); + QCOMPARE(layers[1].transform.center(), QPointF(7, 9)); + QCOMPARE(layers[1].mask.value().placement.value().origin, QPointF(8, 8)); + QCOMPARE(layers[1].mask.value().placement.value().size, QSizeF(2, 2)); + // The layer asked for is the anchor, first or not. + from.session.addGroup(); + const QUuid folder = from.session.activeLayerID().value(); + QVERIFY(from.session.placeLayer(id, folder)); + rewrite(from.session, [&](ProjectSnapshot &snapshot) { std::reverse(snapshot.manifest.layers.begin(), snapshot.manifest.layers.end()); }); + QCOMPARE(from.session.document().value().layers.front().id, id); + const QPointF offset = layerWith(from.session, id).transform.center() - layerWith(from.session, folder).transform.center(); + QVERIFY(copied(workspace, folder, target.id, QPointF(30, 20))); + const std::vector<ImageLayer> placed = target.session.document().value().layers; + QCOMPARE(placed[placed.size() - 1].transform.center(), QPointF(30, 20)); + QCOMPARE(placed[placed.size() - 2].transform.center(), QPointF(30, 20) + offset); + QVERIFY(!offset.isNull()); +} + +void ProjectCopyTests::aNewTabGetsTheSourcesCanvasInOneStep() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(20, 10); + from.session.insert(solid(Qt::red, 255, "Red"), QPointF(5, 5)); + const QUuid id = from.session.activeLayerID().value(); + QVERIFY(copied(workspace, id, std::nullopt)); + QCOMPARE(int(workspace.tabs().size()), 2); + EditorSession &made = workspace.current().session; + QVERIFY(&made != &from.session); + QCOMPARE(made.document().value().size(), QSizeF(20, 10)); + QCOMPARE(names(made), (QStringList{"Red"})); + QCOMPARE(made.document().value().layers.front().transform.center(), QPointF(10, 5)); + QCOMPARE(made.history.undoCount(), 1); + QCOMPARE(made.history.undoName(), QString("Copy Layers from Project")); + made.undo(); + QVERIFY(!made.document().has_value()); +} + +void ProjectCopyTests::bothProjectsAreBusyWhileTheCopyBakes() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(2, 2); + // A painted base: its pixels flatten on first use. + const auto raster = std::make_shared<const RasterSnapshot>(2, 2, solid(Qt::red, 128, "").image(), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}); + from.session.insert(ImportedImage(raster, QImage(), "Base")); + from.session.insert(solid(Qt::blue, 255, "Top")); + const QUuid top = from.session.activeLayerID().value(); + from.session.toggleClippingMask(top); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(2, 2); + // Every worker is held: the bake has to queue. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + bool done = false; + workspace.copyLayer(top, target.id, std::nullopt, [&] { done = true; }); + // Back at once: both busy, nothing baked, nothing placed. + QVERIFY(workspace.isManaging() && from.session.isProjectBusy() && target.session.isProjectBusy()); + QVERIFY(target.session.document().value().layers.empty() && !done && !raster->hasMaterializedPixels()); + gate.release(workers); + // No event runs in this wait: a worker flattens. + QElapsedTimer clock; + clock.start(); + while (!raster->hasMaterializedPixels() && clock.elapsed() < 10'000) + QTest::qSleep(5); + QVERIFY(raster->hasMaterializedPixels() && target.session.isProjectBusy()); + QTRY_VERIFY(done); + QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); + QCOMPARE(names(target.session), (QStringList{"Top"})); +} + +void ProjectCopyTests::aBakeThatFailsCopiesNothingAndSaysWhy() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(2, 2); + from.session.insert(solid(Qt::red, 128, "Base")); + const QUuid base = from.session.activeLayerID().value(); + // An asset without pixels: no surface can hold it. + from.session.insert(ImportedImage(QImage(), QImage(), "Void")); + const QUuid hollow = from.session.activeLayerID().value(); + QVERIFY(from.session.linkMask(base, hollow)); + ProjectTab &target = workspace.addTab(); + target.session.createDocument(2, 2); + // From the front tab: the last signal sees it free. + workspace.select(from.id); + bool switchable = false; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { switchable = workspace.canSwitch(); }); + QTest::ignoreMessage(QtWarningMsg, "cannot copy the layers: The canvas could not be rendered. Try a smaller canvas."); + QVERIFY(copied(workspace, hollow, target.id)); + QVERIFY(switchable && workspace.canSwitch()); + QVERIFY(target.session.document().value().layers.empty()); + QCOMPARE(target.session.importError(), std::optional(QString("The canvas could not be rendered. Try a smaller canvas."))); + QCOMPARE(target.session.history.undoCount(), 1); + QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); +} + +void ProjectCopyTests::aDroppedRowMayCarryTheNullId() +{ + ProjectWorkspace workspace; + ProjectTab &from = workspace.current(); + from.session.createDocument(8, 8); + from.session.insert(solid(Qt::red, 255, "Zero")); + const QUuid old = from.session.activeLayerID().value(); + // All zeros is an id a project file may hold. + rewrite(from.session, [&](ProjectSnapshot &snapshot) { + record(snapshot, old).id = QUuid(); + snapshot.manifest.activeLayerID = QUuid(); + auto pixels = snapshot.images.extract(old); + pixels.key() = QUuid(); + snapshot.images.insert(std::move(pixels)); + }); + QMimeData provider; + provider.setData(ProjectWorkspace::layerType, uuidString(QUuid()).toUtf8()); + bool done = false; + workspace.receiveProviders(provider, std::nullopt, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 2); + QCOMPARE(names(workspace.current().session), (QStringList{"Zero"})); + QVERIFY(!workspace.current().session.activeLayerID().value().isNull()); +} + +void ProjectCopyTests::ofTwoTabsWithTheLayerTheFirstIsTheSource() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + first.session.createDocument(8, 8); + first.session.insert(solid(Qt::red, 255, "One")); + const QUuid id = first.session.activeLayerID().value(); + // A project saved twice and opened twice shares its ids. + ProjectTab &second = workspace.addTab(); + ProjectSnapshot twin = first.session.projectSnapshot().value(); + record(twin, id).name = "Two"; + second.session.installProject(twin, QString("twin.comp")); + QVERIFY(copied(workspace, id, std::nullopt)); + QCOMPARE(int(workspace.tabs().size()), 3); + QCOMPARE(names(workspace.current().session), (QStringList{"One"})); +} + +QTEST_MAIN(ProjectCopyTests) +#include "ProjectCopyTests.moc" diff --git a/tests/ProjectDropTests.cpp b/tests/ProjectDropTests.cpp new file mode 100644 index 0000000..260ac88 --- /dev/null +++ b/tests/ProjectDropTests.cpp @@ -0,0 +1,245 @@ +#include "IO/ProjectStore.h" +#include "SessionFixtures.h" +#include "UI/ProjectWorkspaceView.h" +#include <QDragEnterEvent> +#include <QDragLeaveEvent> +#include <QDropEvent> +#include <QMimeData> +#include <QtTest> + +// A layer dragged to another tab, the canvas, or New. +namespace { +QMimeData *rowsOf(EditorSession &session) +{ + QStringList lines; + for (const ImageLayer &layer : session.document().value().layers) + lines << uuidString(layer.id); + auto *data = new QMimeData; + data->setData(ProjectWorkspace::layerType, lines.join('\n').toUtf8()); + return data; +} + +// A drag entering a widget, then dropping or leaving. +bool enter(QWidget &target, QMimeData *data, QPoint at = QPoint(5, 5)) +{ + // Shift makes Qt propose the move, as plain drags do. + QDragEnterEvent event(at, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::ShiftModifier); + QApplication::sendEvent(&target, &event); + return event.proposedAction() == Qt::MoveAction && event.isAccepted() && event.dropAction() == Qt::CopyAction; +} + +// A row drag proposes a move; project targets copy. +bool drop(QWidget &target, QMimeData *data, QPoint at = QPoint(5, 5)) +{ + const bool entered = enter(target, data, at); + QDropEvent event(at, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::ShiftModifier); + QApplication::sendEvent(&target, &event); + delete data; + return entered && event.isAccepted() && event.dropAction() == Qt::CopyAction; +} + +// A move after entering; only a valid one stays accepted. +bool move(QWidget &target, QMimeData *data, QPoint at = QPoint(5, 5)) +{ + QDragMoveEvent event(at, Qt::MoveAction | Qt::CopyAction, data, Qt::LeftButton, Qt::ShiftModifier); + QApplication::sendEvent(&target, &event); + return event.isAccepted() && event.dropAction() == Qt::CopyAction; +} + +void leave(QWidget &target) +{ + QDragLeaveEvent event; + QApplication::sendEvent(&target, &event); +} + +int layerCount(ProjectTab &tab) +{ + return tab.session.document() ? int(tab.session.document().value().layers.size()) : 0; +} + +struct Desk { + ProjectWorkspace workspace; + ProjectWorkspaceView window{workspace}; + ProjectTab &first = workspace.current(); + ProjectTab &second = workspace.addTab(false); + Desk() + { + first.session.createDocument(8, 8); + first.session.addBlankLayer(); + first.session.addBlankLayer(); + second.session.createDocument(8, 8); + workspace.select(first.id); + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("the window never became active"); + } + ProjectTabButton &button(ProjectTab &tab) + { + for (ProjectTabButton *button : window.tabs()->buttons()) { + if (button->tab->id == tab.id) + return *button; + } + throw std::runtime_error("no button for the tab"); + } +}; +} + +class ProjectDropTests : public QObject { + Q_OBJECT +private slots: + void theWorkspaceKnowsADragsSourceTab(); + void aTabTakesAnotherTabsLayersAndRefusesItsOwn(); + void theCanvasTakesAnotherTabsLayerAtAPoint(); + void theNewButtonOpensATabForADroppedLayer(); + void altDragsAndBusyWorkspacesAreRefused(); +}; + +void ProjectDropTests::theWorkspaceKnowsADragsSourceTab() +{ + Desk desk; + const QMimeData *data = rowsOf(desk.first.session); + QCOMPARE(desk.workspace.draggedLayerSource(*data), std::optional(desk.first.id)); + QVERIFY(!desk.workspace.canReceiveDrag(*data, desk.first.id)); + QVERIFY(desk.workspace.canReceiveDrag(*data, desk.second.id)); + QVERIFY(desk.workspace.canReceiveDrag(*data, std::nullopt)); + delete data; + // A stranger's id has no source: every target takes it. + QMimeData stranger; + stranger.setData(ProjectWorkspace::layerType, uuidString(QUuid::createUuid()).toUtf8()); + QVERIFY(!desk.workspace.draggedLayerSource(stranger).has_value()); + QVERIFY(desk.workspace.canReceiveDrag(stranger, desk.first.id)); + QMimeData files; + files.setText(QStringLiteral("not a layer")); + QVERIFY(desk.workspace.canReceiveDrag(files, desk.first.id)); + // A line that is no id names nothing, zeros included. + const QUuid zeroed = desk.first.session.activeLayerID().value(); + rewrite(desk.first.session, [&](ProjectSnapshot &snapshot) { record(snapshot, zeroed).id = QUuid(); }); + QVERIFY(indexOf(desk.first.session.document().value().layers, QUuid()) >= 0); + QMimeData garbage; + garbage.setData(ProjectWorkspace::layerType, "not an id"); + QVERIFY(!desk.workspace.draggedLayerSource(garbage).has_value()); + QMimeData zero; + zero.setData(ProjectWorkspace::layerType, uuidString(QUuid()).toUtf8()); + QCOMPARE(desk.workspace.draggedLayerSource(zero), std::optional(desk.first.id)); +} + +void ProjectDropTests::aTabTakesAnotherTabsLayersAndRefusesItsOwn() +{ + Desk desk; + ProjectTabButton &own = desk.button(desk.first), &other = desk.button(desk.second); + QVERIFY(!enter(own, rowsOf(desk.first.session))); + QCOMPARE(own.findChild<QToolButton *>("selectTab")->toolTip(), desk.first.title()); + // Targeted, the other tab says so; left, it stops. + QMimeData *data = rowsOf(desk.first.session); + QVERIFY(enter(other, data)); + QCOMPARE(other.findChild<QToolButton *>("selectTab")->toolTip(), QStringLiteral("Add to %1").arg(desk.second.title())); + leave(other); + QCOMPARE(other.findChild<QToolButton *>("selectTab")->toolTip(), desk.second.title()); + delete data; + // Dropped, both layers are copied, one after the other. + QVERIFY(drop(other, rowsOf(desk.first.session))); + QTRY_COMPARE(layerCount(desk.second), 2); + QCOMPARE(layerCount(desk.first), 2); + QCOMPARE(desk.workspace.current().id, desk.second.id); + QCOMPARE(desk.second.session.document().value().layers.front().name, QString("Layer 1")); + QVERIFY(!drop(other, rowsOf(desk.second.session))); +} + +void ProjectDropTests::theCanvasTakesAnotherTabsLayerAtAPoint() +{ + Desk desk; + ContentView &editor = *desk.window.content(); + QVERIFY(!editor.acceptsDrop(*std::unique_ptr<QMimeData>(rowsOf(desk.first.session)))); + desk.workspace.select(desk.second.id); + ContentView &target = *desk.window.content(); + // The canvas fits its view from the event loop first. + QCoreApplication::processEvents(); + QVERIFY(target.acceptsDrop(*std::unique_ptr<QMimeData>(rowsOf(desk.first.session)))); + auto *ring = target.findChild<QWidget *>("dropRing"); + QVERIFY(ring != nullptr && !ring->isVisible()); + QMimeData *data = rowsOf(desk.first.session); + QVERIFY(enter(target, data)); + QVERIFY(ring->isVisible()); + leave(target); + QVERIFY(!ring->isVisible()); + delete data; + // Dropped on the canvas, the copy lands at that point. + CanvasView &canvas = *target.findChild<CanvasView *>("editorCanvas"); + const QPoint at = canvas.mapTo(&target, canvas.rect().center()); + const QPointF centre = desk.second.session.viewport.documentPoint(canvas.rect().center(), QSizeF(8, 8)); + QVERIFY(std::abs(centre.x() - 4) < 1 && std::abs(centre.y() - 4) < 1); + QVERIFY(drop(target, rowsOf(desk.first.session), at)); + QTRY_COMPARE(layerCount(desk.second), 2); + QVERIFY(!ring->isVisible()); + const ImageLayer copy = desk.second.session.document().value().layers.back(); + QCOMPARE(copy.origin() + QPointF(copy.size().width() / 2, copy.size().height() / 2), centre); + // A rename open in the target refuses the drop. + desk.second.session.setRenamingLayerID(copy.id); + QVERIFY(!target.acceptsDrop(*std::unique_ptr<QMimeData>(rowsOf(desk.first.session)))); +} + +void ProjectDropTests::theNewButtonOpensATabForADroppedLayer() +{ + Desk desk; + auto *button = desk.window.findChild<NewCanvasButton *>("newCanvasButton"); + QVERIFY(button != nullptr); + const QString plain = button->toolTip(); + QMimeData *data = rowsOf(desk.first.session); + QVERIFY(enter(*button, data)); + QCOMPARE(button->toolTip(), QString("Open in a new project tab")); + leave(*button); + QCOMPARE(button->toolTip(), plain); + delete data; + // Each dropped row opens its own tab, as Swift. + QVERIFY(drop(*button, rowsOf(desk.first.session))); + QTRY_COMPARE(int(desk.workspace.tabs().size()), 4); + QTRY_COMPARE(layerCount(desk.workspace.current()), 1); + QCOMPARE(layerCount(*desk.workspace.tabs().at(2)), 1); + QVERIFY(desk.workspace.current().id != desk.first.id); + // The action still makes a canvas on a click. + QVERIFY(desk.window.findChild<QAction *>("newCanvasToolbar") == button->defaultAction()); +} + +void ProjectDropTests::altDragsAndBusyWorkspacesAreRefused() +{ + Desk desk; + ProjectTabButton &other = desk.button(desk.second); + auto *button = desk.window.findChild<NewCanvasButton *>("newCanvasButton"); + // Alt makes a layer drag a duplicate in its panel. + QTest::keyPress(desk.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QVERIFY(!enter(other, rowsOf(desk.first.session))); + QVERIFY(!enter(*button, rowsOf(desk.first.session))); + QTest::keyRelease(desk.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + QVERIFY(enter(other, rowsOf(desk.first.session))); + leave(other); + // Entered, then Alt pressed: Qt routes moves to that widget. + QMimeData *data = rowsOf(desk.first.session); + for (QWidget *target : {static_cast<QWidget *>(&other), static_cast<QWidget *>(button)}) { + QVERIFY(enter(*target, data) && move(*target, data)); + QTest::keyPress(desk.window.windowHandle(), Qt::Key_Alt, Qt::AltModifier); + QVERIFY(!move(*target, data)); + QTest::keyRelease(desk.window.windowHandle(), Qt::Key_Alt, Qt::NoModifier); + leave(*target); + } + // The canvas entered, then its session busy: the same. + desk.workspace.select(desk.second.id); + ContentView &target = *desk.window.content(); + QVERIFY(enter(target, data) && move(target, data)); + desk.second.session.setIsProjectBusy(true); + QVERIFY(!move(target, data)); + desk.second.session.setIsProjectBusy(false); + leave(target); + delete data; + desk.workspace.select(desk.first.id); + // A busy source, and a workspace that cannot switch. + desk.first.session.setIsProjectBusy(true); + QVERIFY(!enter(other, rowsOf(desk.first.session))); + QVERIFY(!enter(*button, rowsOf(desk.first.session))); + desk.first.session.setIsProjectBusy(false); + QVERIFY(enter(other, rowsOf(desk.first.session))); + leave(other); + QCOMPARE(layerCount(desk.second), 0); +} + +QTEST_MAIN(ProjectDropTests) +#include "ProjectDropTests.moc" diff --git a/tests/ProjectExportFailureTests.cpp b/tests/ProjectExportFailureTests.cpp new file mode 100644 index 0000000..f9be425 --- /dev/null +++ b/tests/ProjectExportFailureTests.cpp @@ -0,0 +1,177 @@ +#include "AddressSpaceLimit.h" +#include "DialogDesk.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectController.h" +#include <QApplication> +#include <QSemaphore> +#include <QTemporaryDir> +#include <QThreadPool> +#include <QtConcurrent> +#include <QtTest> +#include <malloc.h> + +// Exports that run out of memory: a render error, explained. +class ProjectExportFailureTests : public QObject { + Q_OBJECT +private slots: + void aRenderThatFindsNoMemoryIsARenderError(); + void aSnapshotThatFindsNoMemoryIsExplained(); + void aJPEGRenderThatFindsNoMemoryIsExplained(); + void aPanelsHandOffThatFindsNoMemoryIsExplained(); + void thePanelOpensWithoutCopyingTheSnapshot(); + void aRefusedNameCopiesNoSnapshot(); +}; + +namespace { +// A thousand layers on four pixels: copies need a megabyte. +ProjectSnapshot manyLayers() +{ + ProjectSnapshot snapshot{.manifest = {.documentID = QUuid::createUuid(), .width = 2, .height = 2, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + for (int index = 0; index < 1024; ++index) + snapshot.manifest.layers.push_back({.id = QUuid::createUuid(), .name = QStringLiteral("Blank"), .isVisible = true, + .transform = {.origin = {0, 0}, .size = {2, 2}}, .imageFile = std::nullopt}); + snapshot.manifest.activeLayerID = snapshot.manifest.layers.front().id; + return snapshot; +} + +struct Desk { + EditorSession session; + ProjectController controller{session}; + QWidget window; + DialogDesk alerts; + bool done = false; + Desk() + { + session.installProject(manyLayers(), QStringLiteral("Many.comp")); + window.show(); + controller.window = &window; + alerts.replies = {"OK"}; + alerts.note = [this] { return QStringLiteral("busy %1").arg(session.isProjectBusy()); }; + } + QStringList explained(const QString &title) const + { + return {QStringLiteral("alert|2|%1|%2|OK|busy 1").arg(title, QString::fromUtf8(ExportError(ExportError::Kind::render).what()))}; + } +}; +} + +void ProjectExportFailureTests::aRenderThatFindsNoMemoryIsARenderError() +{ + const ProjectSnapshot snapshot = manyLayers(); + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + try { + ImageExporter::render(snapshot); + QFAIL("the render found room"); + } catch (const ExportError &error) { + QCOMPARE(error.kind, ExportError::Kind::render); + } +} + +void ProjectExportFailureTests::aSnapshotThatFindsNoMemoryIsExplained() +{ + for (const bool jpeg : {false, true}) { + Desk desk; + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + if (jpeg) + desk.controller.exportJPEG([&desk] { desk.done = true; }); + else + desk.controller.exportPNG([&desk] { desk.done = true; }); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen, desk.explained(jpeg ? QStringLiteral("Couldn’t export JPEG") : QStringLiteral("Couldn’t export PNG"))); + QVERIFY(!desk.session.isProjectBusy()); + } +} + +void ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained() +{ + Desk desk; + // The worker waits until the limit is in place. + QThreadPool *pool = QThreadPool::globalInstance(); + pool->setMaxThreadCount(1); + QSemaphore entered, release; + const QFuture<void> held = QtConcurrent::run([&] { + entered.release(); + release.acquire(); + }); + entered.acquire(); + desk.controller.exportJPEG([&desk] { desk.done = true; }); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + release.release(); + pool->waitForDone(); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen, desk.explained(QStringLiteral("Couldn’t export JPEG"))); + QVERIFY(!desk.session.isProjectBusy()); +} + +void ProjectExportFailureTests::aPanelsHandOffThatFindsNoMemoryIsExplained() +{ + Desk desk; + QTemporaryDir folder; + desk.alerts.replies = {folder.filePath("many.png"), "OK"}; + desk.controller.exportPNG([&desk] { desk.done = true; }); + // The panel is answered and its copy fails, limited. + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QTRY_COMPARE(desk.alerts.seen.size(), 2); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen.value(1), desk.explained(QStringLiteral("Couldn’t export PNG")).value(0)); + QVERIFY(!desk.session.isProjectBusy() && !QFileInfo::exists(folder.filePath("many.png"))); +} + +// A copy needs a megabyte; the panel far less. +void ProjectExportFailureTests::thePanelOpensWithoutCopyingTheSnapshot() +{ + Desk desk; + desk.alerts.replies = {"<cancel>"}; + { + malloc_trim(0); + const AddressSpaceLimit limit(2048ll * 1024); + desk.controller.exportPNG([&desk] { desk.done = true; }); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen.size(), 1); + QVERIFY(desk.alerts.seen.value(0).startsWith("panel|Export PNG|save|file|Many.png|png|")); + QVERIFY(!desk.session.isProjectBusy()); +} + +// The refusal's alert fits where a copy would not. +void ProjectExportFailureTests::aRefusedNameCopiesNoSnapshot() +{ + Desk desk; + QTemporaryDir folder; + QFile taken(folder.filePath("taken.jpg.png")); + QVERIFY(taken.open(QIODevice::WriteOnly)); + taken.close(); + desk.alerts.replies = {folder.filePath("taken.jpg"), "OK"}; + desk.controller.exportPNG([&desk] { desk.done = true; }); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QTRY_COMPARE(desk.alerts.seen.size(), 2); + } + QTRY_VERIFY(desk.done); + QVERIFY(desk.alerts.seen.value(1).contains("“taken.jpg.png” already exists.")); + QVERIFY(!desk.session.isProjectBusy()); +} + +int main(int argc, char **argv) +{ + // Before any thread: workers share the heap the limit counts. + mallopt(M_ARENA_MAX, 1); + // Large asks always map memory. + mallopt(M_MMAP_THRESHOLD, 64 * 1024); + QApplication app(argc, argv); + ProjectExportFailureTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "ProjectExportFailureTests.moc" diff --git a/tests/ProjectExportTests.cpp b/tests/ProjectExportTests.cpp new file mode 100644 index 0000000..29d77c0 --- /dev/null +++ b/tests/ProjectExportTests.cpp @@ -0,0 +1,261 @@ +#include "DialogDesk.h" +#include "MenuFixtures.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectController.h" +#include "UI/JPEGExportSheet.h" +#include <QDialog> +#include <QPushButton> +#include <QSettings> +#include <QSlider> +#include <QStandardPaths> +#include <QtTest> + +// The PNG and JPEG exports: panels, the sheet, files, alerts. +class ProjectExportTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void aPNGGoesWhereThePanelSays(); + void aNameTakesTheTypesSuffix(); + void aCancelledExportWritesNothing(); + void aFailedWriteIsExplained(); + void aJPEGPassesThroughItsSheet(); + void aJPEGNeedsAWindowAndADocument(); + void theFileMenuOffersBothExports(); +}; + +namespace { +struct Desk { + EditorSession session; + ProjectController controller{session}; + QWidget window; + DialogDesk dialogs; + bool done = false; + Desk() + { + session.createDocument(40, 20); + QImage red(10, 10, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + session.insert(ImportedImage(red, QImage(), "Red"), QPointF(25, 10)); + window.resize(600, 400); + window.show(); + controller.window = &window; + dialogs.note = [this] { return QStringLiteral("busy %1").arg(session.isProjectBusy()); }; + } + std::function<void()> finished() + { + done = false; + return [this] { done = true; }; + } + static QString path(const QString &name) { return QDir::current().absoluteFilePath(name); } + // The shown sheet; a closed one may await deletion. + JPEGExportSheet *sheet() + { + for (JPEGExportSheet *shown : window.findChildren<JPEGExportSheet *>()) + if (shown->isVisible()) + return shown; + return nullptr; + } +}; + +// The shown panel's type, which the desk does not record. +QString panelFilters() +{ + for (QWidget *widget : QApplication::topLevelWidgets()) + if (auto *panel = qobject_cast<QFileDialog *>(widget); panel && panel->isVisible()) + return panel->nameFilters().join(QLatin1Char(';')); + return QString(); +} + +QByteArray contents(const QString &path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} +} + +void ProjectExportTests::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + QSettings().clear(); +} + +void ProjectExportTests::aPNGGoesWhereThePanelSays() +{ + Desk desk; + const int steps = desk.session.history.undoCount(); + // Named in full: panels open where the last went. + desk.dialogs.replies = {Desk::path("shot")}; + desk.dialogs.note = panelFilters; + desk.controller.exportPNG(desk.finished()); + QVERIFY(desk.session.isProjectBusy()); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export PNG|save|file|Untitled.png|png|PNG image (*.png)"}); + QVERIFY(!desk.session.isProjectBusy()); + const QByteArray written = contents(Desk::path("shot.png")); + QCOMPARE(written, ImageExporter::pngData(desk.session.projectSnapshot().value())); + const QImage image = QImage::fromData(written, "png"); + QCOMPARE(image.size(), QSize(40, 20)); + QCOMPARE(image.pixelColor(25, 10), QColor(255, 0, 0)); + QCOMPARE(image.pixelColor(5, 5).alpha(), 0); + QCOMPARE(desk.session.history.undoCount(), steps); +} + +void ProjectExportTests::aNameTakesTheTypesSuffix() +{ + Desk desk; + desk.session.setProjectPath(Desk::path("Poster.comp")); + // A name with another suffix gains the type's own. + desk.dialogs.replies = {Desk::path("poster.jpg")}; + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export PNG|save|file|Poster.png|png|busy 1"}); + QVERIFY(QFileInfo::exists(Desk::path("poster.jpg.png")) && !QFileInfo::exists(Desk::path("poster.jpg"))); + // One in any case keeps it. + desk.dialogs.seen.clear(); + desk.dialogs.replies = {Desk::path("LOUD.PNG")}; + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QVERIFY(QFileInfo::exists(Desk::path("LOUD.PNG")) && !QFileInfo::exists(Desk::path("LOUD.PNG.png"))); + // Anything at the completed name stays, even a dangling link. + QVERIFY(QFile::link(Desk::path("nowhere"), Desk::path("taken.jpg.png"))); + desk.dialogs.seen.clear(); + desk.dialogs.replies = {Desk::path("taken.jpg"), "OK"}; + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen.value(1), QString("alert|2|Couldn’t export PNG|“taken.jpg.png” already exists. Choose another name.|OK|busy 1")); + QVERIFY(QFileInfo(Desk::path("taken.jpg.png")).isSymLink() && !QFileInfo::exists(Desk::path("nowhere"))); + QVERIFY(!desk.session.isProjectBusy()); +} + +void ProjectExportTests::aCancelledExportWritesNothing() +{ + Desk desk; + // Without a window the panel stands alone. + desk.controller.window = nullptr; + desk.dialogs.replies = {"<cancel>"}; + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export PNG|save|file|Untitled.png|png|busy 1"}); + QVERIFY(!desk.session.isProjectBusy()); + QCOMPARE(QDir::current().entryList(QDir::Files), QStringList()); + // A JPEG cancelled at its sheet opens no panel. + desk.controller.window = &desk.window; + desk.controller.exportJPEG(desk.finished()); + QTRY_VERIFY(desk.sheet()); + QTest::keyClick(desk.sheet(), Qt::Key_Escape); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen.size(), 1); + QVERIFY(!desk.session.isProjectBusy()); + QVERIFY(!QSettings().contains(JPEGExportSheet::qualityKey)); + // Cancelled at its panel, nothing is written. + desk.dialogs.replies = {"<cancel>"}; + desk.controller.exportJPEG(desk.finished()); + QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("jpegExport")->isEnabled()); + desk.sheet()->findChild<QPushButton *>("jpegExport")->click(); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen.value(1), QString("panel|Export JPEG|save|file|Untitled.jpg|jpeg|busy 1")); + QCOMPARE(QDir::current().entryList(QDir::Files), QStringList()); + QVERIFY(!desk.session.isProjectBusy()); +} + +void ProjectExportTests::aFailedWriteIsExplained() +{ + Desk desk; + QVERIFY(QDir::current().mkdir("locked")); + QVERIFY(QFile::setPermissions(Desk::path("locked"), QFile::ReadOwner | QFile::ExeOwner)); + desk.dialogs.replies = {Desk::path("locked/shot.png"), "OK"}; + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen.size(), 2); + QVERIFY(desk.dialogs.seen.value(1).startsWith("alert|2|Couldn’t export PNG|")); + QVERIFY(desk.dialogs.seen.value(1).endsWith("|OK|busy 1")); + QVERIFY(!QFileInfo::exists(Desk::path("locked/shot.png"))); + QVERIFY(!desk.session.isProjectBusy()); + QVERIFY(QFile::setPermissions(Desk::path("locked"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); +} + +void ProjectExportTests::aJPEGPassesThroughItsSheet() +{ + Desk desk; + QSettings().remove(JPEGExportSheet::qualityKey); + desk.dialogs.replies = {Desk::path("photo")}; + desk.dialogs.note = panelFilters; + desk.controller.exportJPEG(desk.finished()); + QVERIFY(desk.session.isProjectBusy()); + // The render runs aside; then the sheet holds the window. + QTRY_VERIFY(desk.sheet()); + auto *dialog = qobject_cast<QDialog *>(desk.sheet()->parentWidget()); + QVERIFY(dialog && dialog->isVisible() && dialog->windowModality() == Qt::WindowModal); + QCOMPARE(dialog->windowTitle(), QString("Export JPEG")); + QCOMPARE(dialog->minimumSize(), dialog->maximumSize()); + auto *exporting = desk.sheet()->findChild<QPushButton *>("jpegExport"); + desk.sheet()->findChild<QSlider *>("jpegQuality")->setValue(50); + QTRY_VERIFY(exporting->isEnabled()); + exporting->click(); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.dialogs.seen, QStringList{"panel|Export JPEG|save|file|Untitled.jpg|jpeg|JPEG image (*.jpeg *.jpg *.jpe)"}); + const ExportRaster raster = ImageExporter::render(desk.session.projectSnapshot().value()); + QCOMPARE(contents(Desk::path("photo.jpeg")), ImageExporter::jpeg(raster, {.quality = 0.5}).data); + QCOMPARE(QSettings().value(JPEGExportSheet::qualityKey).toDouble(), 0.5); + QVERIFY(!desk.session.isProjectBusy()); + // Every JPEG suffix is one; another gains the preferred. + for (const QString &name : {QStringLiteral("a.jpg"), QStringLiteral("b.JPE"), QStringLiteral("c.png")}) { + desk.dialogs.replies = {Desk::path(name)}; + desk.controller.exportJPEG(desk.finished()); + QTRY_VERIFY(desk.sheet() && desk.sheet()->findChild<QPushButton *>("jpegExport")->isEnabled()); + desk.sheet()->findChild<QPushButton *>("jpegExport")->click(); + QTRY_VERIFY(desk.done); + } + QVERIFY(QFileInfo::exists(Desk::path("a.jpg")) && QFileInfo::exists(Desk::path("b.JPE")) && QFileInfo::exists(Desk::path("c.png.jpeg"))); +} + +void ProjectExportTests::aJPEGNeedsAWindowAndADocument() +{ + Desk desk; + desk.controller.window = nullptr; + desk.controller.exportJPEG(desk.finished()); + QVERIFY(!desk.session.isProjectBusy()); + QTRY_VERIFY(desk.done); + EditorSession empty; + ProjectController idle(empty); + idle.window = &desk.window; + bool refused = false; + idle.exportJPEG([&refused] { refused = true; }); + idle.exportPNG([&refused] { refused = true; }); + QTRY_VERIFY(refused); + QVERIFY(desk.dialogs.seen.isEmpty() && !empty.isProjectBusy()); + // A busy project starts nothing either. + desk.controller.window = &desk.window; + desk.session.setIsProjectBusy(true); + desk.controller.exportPNG(desk.finished()); + QTRY_VERIFY(desk.done); + QVERIFY(desk.dialogs.seen.isEmpty()); + desk.session.setIsProjectBusy(false); +} + +// Enabled with a document while the controller can start. +void ProjectExportTests::theFileMenuOffersBothExports() +{ + QTemporaryDir folder; + Bar bar; + QVERIFY(!bar.action("exportPNG").isEnabled() && !bar.action("exportJPEG").isEnabled()); + bar.session().createDocument(8, 8); + QVERIFY(bar.action("exportPNG").isEnabled() && bar.action("exportJPEG").isEnabled()); + bar.session().setIsProjectBusy(true); + QVERIFY(!bar.action("exportPNG").isEnabled() && !bar.action("exportJPEG").isEnabled()); + bar.session().setIsProjectBusy(false); + DialogDesk desk; + desk.replies = {folder.filePath("Shot.png")}; + bar.action("exportPNG").trigger(); + QTRY_VERIFY(QFileInfo::exists(folder.filePath("Shot.png")) && !bar.session().isProjectBusy()); + bar.window.show(); + bar.action("exportJPEG").trigger(); + QTRY_VERIFY(bar.window.findChild<JPEGExportSheet *>()); + QTest::keyClick(bar.window.findChild<JPEGExportSheet *>(), Qt::Key_Escape); + QTRY_VERIFY(!bar.session().isProjectBusy()); + QCOMPARE(desk.seen.size(), 1); +} + +QTEST_MAIN(ProjectExportTests) +#include "ProjectExportTests.moc" diff --git a/tests/ProjectFixtures.h b/tests/ProjectFixtures.h new file mode 100644 index 0000000..26418f0 --- /dev/null +++ b/tests/ProjectFixtures.h @@ -0,0 +1,63 @@ +#pragma once +#include "IO/ProjectStore.h" +#include "RenderFixtures.h" +#include <QDir> +#include <QFile> + +// A 64x32 image: red on the left, clear right. +inline QImage halfRed() +{ + QImage image = BrushRaster::context(64, 32, false); + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 32; ++x) + image.setPixel(x, y, qRgba(255, 0, 0, 255)); + } + return image; +} + +inline ImportedImage asset(const QImage &image, const QString &name) +{ + return ImportedImage(image, image, name); +} + +// A hidden, turned, flipped image layer under a blank one. +inline ProjectSnapshot twoLayers() +{ + const QUuid imageID = QUuid::createUuid(), blankID = QUuid::createUuid(); + const LayerTransform turned{.origin = {-27.5, 88.25}, .size = {123, 47}, .rotation = 38, .flipX = true, .flipY = true, + .sampling = LayerSampling::nearest}; + const ProjectLayerRecord image{.id = imageID, .name = QStringLiteral("Paint & sky \U0001F324"), .isVisible = false, .transform = turned, + .imageFile = uuidString(imageID) + ".png"}; + const ProjectLayerRecord blank{.id = blankID, .name = "Layer 2", .isVisible = true, + .transform = {.origin = {0, 0}, .size = {100, 80}}, .imageFile = std::nullopt}; + return {.manifest = {.resolution = 300, .documentID = QUuid::createUuid(), .width = 100, .height = 80, .activeLayerID = blankID, + .layers = {image, blank}}, + .images = {{imageID, asset(halfRed(), "source")}}}; +} + +inline QByteArray contents(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + throw std::runtime_error("cannot read " + path.toStdString()); + return file.readAll(); +} + +inline void overwrite(const QString &path, const QByteArray &data) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(data) != data.size()) + throw std::runtime_error("cannot write " + path.toStdString()); +} + +// The kind of ProjectError `body` throws, if it throws one. +template <typename Body> +std::optional<ProjectError::Kind> projectError(Body body) +{ + try { + body(); + } catch (const ProjectError &error) { + return error.kind; + } + return std::nullopt; +} diff --git a/tests/ProjectManifestTests.cpp b/tests/ProjectManifestTests.cpp new file mode 100644 index 0000000..c8bcc97 --- /dev/null +++ b/tests/ProjectManifestTests.cpp @@ -0,0 +1,473 @@ +#include "ProjectFixtures.h" +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QTemporaryDir> +#include <QtTest> +#include <limits> + +namespace { +// A manifest as macOS writes it: spaced colons, escaped slashes. +const char *fromMacOS = R"({ + "activeLayerID" : "0F0E0D0C-0B0A-4908-8706-050403020100", + "colorSpace" : "sRGB", + "documentID" : "00112233-4455-4677-8899-AABBCCDDEEFF", + "format" : "com.compositor.project", + "height" : 80, + "layers" : [ + { + "blendMode" : "Color Dodge", + "id" : "0F0E0D0C-0B0A-4908-8706-050403020100", + "imageFile" : "0F0E0D0C-0B0A-4908-8706-050403020100.png", + "isVisible" : false, + "maskEnabled" : false, + "maskFile" : "0F0E0D0C-0B0A-4908-8706-050403020100.mask.png", + "maskLinked" : false, + "maskPlacement" : { + "flipX" : false, + "flipY" : true, + "origin" : [ 3, 4.5 ], + "rotation" : 10, + "sampling" : "Smooth", + "size" : [ 50, 60 ] + }, + "name" : "Paint \/ sky", + "opacity" : 0.25, + "text" : { + "alignment" : "Center", + "blue" : 0.75, + "boxSize" : [ 300, 150 ], + "content" : "Two \/ lines\nof text", + "fontName" : "Helvetica-Bold", + "fontSize" : 36.5, + "green" : 0.5, + "leading" : 0, + "red" : 0.25, + "tracking" : -2 + }, + "transform" : { + "flipX" : true, + "flipY" : false, + "origin" : [ -27.5, 88.25 ], + "rotation" : 38, + "sampling" : "Nearest", + "size" : [ 123, 47 ] + } + } + ], + "resolution" : 300, + "version" : 7, + "width" : 100 +})"; + +QJsonObject object(const QByteArray &json) +{ + return QJsonDocument::fromJson(json).object(); +} + +QByteArray json(const QJsonObject &object) +{ + return QJsonDocument(object).toJson(); +} + +// Sets `key` on the first layer; undefined removes it. +QByteArray withLayer(const QByteArray &manifest, const QString &key, const QJsonValue &value) +{ + QJsonObject whole = object(manifest); + QJsonArray layers = whole.value("layers").toArray(); + QJsonObject layer = layers.at(0).toObject(); + value.isUndefined() ? layer.remove(key) : void(layer.insert(key, value)); + layers.replace(0, layer); + whole.insert("layers", layers); + return json(whole); +} + +QByteArray with(const QByteArray &manifest, const QString &key, const QJsonValue &value) +{ + QJsonObject whole = object(manifest); + value.isUndefined() ? whole.remove(key) : void(whole.insert(key, value)); + return json(whole); +} + +bool decodes(const QByteArray &manifest) +{ + return !projectError([&] { ProjectManifest::decoded(manifest); }).has_value(); +} +} + +class ProjectManifestTests : public QObject { + Q_OBJECT +private slots: + void aMacOSManifestDecodesFieldByField(); + void encodingWritesSwiftsKeysAndLeavesAbsentOnesOut(); + void everyRawValueRoundTrips(); + void decodingRefusesWhatSwiftRefuses(); + void validationFollowsTheFormatsVersions_data(); + void validationFollowsTheFormatsVersions(); +}; + +void ProjectManifestTests::aMacOSManifestDecodesFieldByField() +{ + const ProjectManifest manifest = ProjectManifest::decoded(fromMacOS); + QCOMPARE(manifest.format, QString("com.compositor.project")); + QCOMPARE(manifest.version, 7); + QCOMPARE(manifest.colorSpace, QString("sRGB")); + QCOMPARE(manifest.resolution, std::optional<double>(300)); + QCOMPARE(manifest.documentID, QUuid("{00112233-4455-4677-8899-aabbccddeeff}")); + QCOMPARE(manifest.width, 100); + QCOMPARE(manifest.height, 80); + QCOMPARE(manifest.activeLayerID, std::optional(QUuid("{0f0e0d0c-0b0a-4908-8706-050403020100}"))); + QCOMPARE(int(manifest.layers.size()), 1); + const ProjectLayerRecord &layer = manifest.layers[0]; + QCOMPARE(layer.id, QUuid("{0f0e0d0c-0b0a-4908-8706-050403020100}")); + QCOMPARE(layer.name, QString("Paint / sky")); + QCOMPARE(layer.isVisible, false); + QCOMPARE(layer.transform, (LayerTransform{.origin = {-27.5, 88.25}, .size = {123, 47}, .rotation = 38, .flipX = true, .flipY = false, + .sampling = LayerSampling::nearest})); + QCOMPARE(layer.imageFile, std::optional(QString("0F0E0D0C-0B0A-4908-8706-050403020100.png"))); + QCOMPARE(layer.opacity, std::optional(0.25)); + QCOMPARE(layer.blendMode, std::optional(LayerBlendMode::colorDodge)); + QCOMPARE(layer.maskFile, std::optional(QString("0F0E0D0C-0B0A-4908-8706-050403020100.mask.png"))); + QCOMPARE(layer.maskEnabled, std::optional(false)); + QCOMPARE(layer.maskLinked, std::optional(false)); + QCOMPARE(layer.maskPlacement.value(), (LayerTransform{.origin = {3, 4.5}, .size = {50, 60}, .rotation = 10, .flipX = false, .flipY = true, + .sampling = LayerSampling::smooth})); + QVERIFY(!layer.parentID && !layer.isGroup && !layer.maskSourceID); + const LayerTextStyle &text = layer.text.value(); + QCOMPARE(text.content, QString("Two / lines\nof text")); + QVERIFY(text.fontName == QString("Helvetica-Bold") && text.fontSize == 36.5 && text.alignment == TextAlignment::center); + QVERIFY(text.red == 0.25 && text.green == 0.5 && text.blue == 0.75 && text.tracking == -2 && text.leading == 0); + QCOMPARE(text.boxSize, std::optional(QSizeF(300, 150))); + // What it writes back, the decoder reads the same. + QCOMPARE(ProjectManifest::decoded(manifest.encoded()).encoded(), manifest.encoded()); +} + +void ProjectManifestTests::encodingWritesSwiftsKeysAndLeavesAbsentOnesOut() +{ + ProjectSnapshot snapshot = twoLayers(); + snapshot.manifest.resolution = std::nullopt; + snapshot.manifest.activeLayerID = std::nullopt; + // Two spaces a level, as Swift prints it. + QVERIFY(snapshot.manifest.encoded().startsWith("{\n \"colorSpace\": \"sRGB\",\n \"documentID\": ")); + QVERIFY(snapshot.manifest.encoded().contains("\n \"id\": ")); + QVERIFY(snapshot.manifest.encoded().endsWith("\n}\n")); + const QJsonObject bare = object(snapshot.manifest.encoded()); + QCOMPARE(bare.keys(), (QStringList{"colorSpace", "documentID", "format", "height", "layers", "version", "width"})); + QCOMPARE(bare.value("documentID").toString(), uuidString(snapshot.manifest.documentID)); + QCOMPARE(bare.value("documentID").toString(), bare.value("documentID").toString().toUpper()); + const QJsonObject blank = bare.value("layers").toArray().at(1).toObject(); + QCOMPARE(blank.keys(), (QStringList{"id", "isVisible", "name", "transform"})); + const QJsonObject transform = bare.value("layers").toArray().at(0).toObject().value("transform").toObject(); + QCOMPARE(transform.keys(), (QStringList{"flipX", "flipY", "origin", "rotation", "sampling", "size"})); + QCOMPARE(transform.value("origin"), QJsonValue(QJsonArray{-27.5, 88.25})); + QCOMPARE(transform.value("size"), QJsonValue(QJsonArray{123, 47})); + QCOMPARE(transform.value("sampling").toString(), QString("Nearest")); + // Every optional, once present, under its own key. + const QUuid parent = QUuid::createUuid(), source = QUuid::createUuid(); + ProjectLayerRecord full = snapshot.manifest.layers[0]; + full.parentID = parent; + full.isGroup = false; + full.opacity = 0.5; + full.blendMode = LayerBlendMode::colorBurn; + full.maskFile = "m.png"; + full.maskEnabled = true; + full.maskSourceID = source; + full.maskPlacement = LayerTransform{.origin = {1, 2}, .size = {3, 4}}; + full.maskLinked = false; + snapshot.manifest.layers = {full}; + snapshot.manifest.resolution = 144.5; + snapshot.manifest.activeLayerID = full.id; + const QJsonObject whole = object(snapshot.manifest.encoded()); + QCOMPARE(whole.value("resolution").toDouble(), 144.5); + QCOMPARE(whole.value("activeLayerID").toString(), uuidString(full.id)); + const QJsonObject layer = whole.value("layers").toArray().at(0).toObject(); + QCOMPARE(layer.keys(), (QStringList{"blendMode", "id", "imageFile", "isGroup", "isVisible", "maskEnabled", "maskFile", "maskLinked", + "maskPlacement", "maskSourceID", "name", "opacity", "parentID", "transform"})); + QCOMPARE(layer.value("parentID").toString(), uuidString(parent)); + QCOMPARE(layer.value("maskSourceID").toString(), uuidString(source)); + QCOMPARE(layer.value("blendMode").toString(), QString("Color Burn")); + QCOMPARE(layer.value("isGroup"), QJsonValue(false)); + QCOMPARE(layer.value("maskEnabled"), QJsonValue(true)); + QCOMPARE(layer.value("maskLinked"), QJsonValue(false)); + QCOMPARE(layer.value("opacity").toDouble(), 0.5); + QCOMPARE(layer.value("maskPlacement").toObject().value("size"), QJsonValue(QJsonArray{3, 4})); + const ProjectLayerRecord back = ProjectManifest::decoded(snapshot.manifest.encoded()).layers[0]; + QCOMPARE(back.parentID, std::optional(parent)); + QCOMPARE(back.maskSourceID, std::optional(source)); + QCOMPARE(back.isGroup, std::optional(false)); + QCOMPARE(back.maskEnabled, std::optional(true)); + QCOMPARE(back.maskLinked, std::optional(false)); + QCOMPARE(back.maskPlacement.value(), (LayerTransform{.origin = {1, 2}, .size = {3, 4}})); +} + +void ProjectManifestTests::everyRawValueRoundTrips() +{ + const QStringList blends{"Normal", "Multiply", "Screen", "Overlay", "Darken", "Lighten", "Difference", "Color Dodge", "Color Burn", + "Hue", "Saturation", "Color", "Luminosity"}; + for (int index = 0; index < blends.size(); ++index) { + QCOMPARE(rawValue(LayerBlendMode(index)), blends[index]); + QCOMPARE(layerBlendMode(blends[index]), std::optional(LayerBlendMode(index))); + } + QCOMPARE(layerBlendMode("normal"), std::nullopt); + QCOMPARE(layerBlendMode(""), std::nullopt); + const QStringList samplings{"Nearest", "Smooth", "High quality"}; + for (int index = 0; index < samplings.size(); ++index) { + QCOMPARE(rawValue(LayerSampling(index)), samplings[index]); + QCOMPARE(layerSampling(samplings[index]), std::optional(LayerSampling(index))); + } + QCOMPARE(layerSampling("High Quality"), std::nullopt); + QCOMPARE(uuidString(QUuid("{0f0e0d0c-0b0a-4908-8706-0504030201ab}")), QString("0F0E0D0C-0B0A-4908-8706-0504030201AB")); +} + +void ProjectManifestTests::decodingRefusesWhatSwiftRefuses() +{ + const QByteArray good = fromMacOS; + QVERIFY(decodes(good)); + // Required keys, each missing in turn. + for (const char *key : {"format", "version", "colorSpace", "documentID", "width", "height", "layers"}) + QVERIFY2(!decodes(with(good, key, QJsonValue::Undefined)), key); + for (const char *key : {"id", "name", "isVisible", "transform"}) + QVERIFY2(!decodes(withLayer(good, key, QJsonValue::Undefined)), key); + for (const char *key : {"origin", "size", "rotation", "flipX", "flipY", "sampling"}) { + QJsonObject transform = object(good).value("layers").toArray().at(0).toObject().value("transform").toObject(); + transform.remove(key); + QVERIFY2(!decodes(withLayer(good, "transform", transform)), key); + } + // Optional keys may be missing or null. + for (const char *key : {"resolution", "activeLayerID"}) { + QVERIFY2(decodes(with(good, key, QJsonValue::Undefined)), key); + QVERIFY2(decodes(with(good, key, QJsonValue::Null)), key); + } + for (const char *key : {"imageFile", "parentID", "isGroup", "opacity", "blendMode", "maskFile", "maskEnabled", "maskSourceID", + "maskPlacement", "maskLinked"}) { + QVERIFY2(decodes(withLayer(good, key, QJsonValue::Undefined)), key); + QVERIFY2(decodes(withLayer(good, key, QJsonValue::Null)), key); + } + QVERIFY(!ProjectManifest::decoded(withLayer(good, "opacity", QJsonValue::Null)).layers[0].opacity.has_value()); + // The wrong type under each key. + QVERIFY(!decodes(with(good, "format", 7))); + QVERIFY(!decodes(with(good, "colorSpace", true))); + QVERIFY(!decodes(with(good, "version", "7"))); + QVERIFY(!decodes(with(good, "width", 100.5))); + QVERIFY(decodes(with(good, "width", 100.0))); + // Swift's Int holds 64 bits: wild sizes decode, validation refuses. + QCOMPARE(ProjectManifest::decoded(with(good, "height", 1e12)).height, qint64(1'000'000'000'000)); + QCOMPARE(ProjectManifest::decoded(with(good, "width", -3e9)).width, qint64(-3'000'000'000)); + // Every bit of a whole token survives: no double between. + for (const qint64 exact : {qint64(9007199254740993), std::numeric_limits<qint64>::max(), std::numeric_limits<qint64>::min()}) + QCOMPARE(ProjectManifest::decoded(with(good, "height", QJsonValue(exact))).height, exact); + QVERIFY(!decodes(QByteArray(good).replace("\"width\" : 100", "\"width\" : 9223372036854775808"))); + QVERIFY(!decodes(with(good, "height", 1e19))); + QVERIFY(!decodes(with(good, "height", -1e19))); + QVERIFY(!decodes(with(good, "version", 7.5))); + QVERIFY(!decodes(with(good, "resolution", "300"))); + QVERIFY(!decodes(with(good, "layers", QJsonObject()))); + QVERIFY(!decodes(with(good, "layers", QJsonArray{7}))); + QVERIFY(!decodes(withLayer(good, "name", 7))); + QVERIFY(!decodes(withLayer(good, "isVisible", 1))); + QVERIFY(!decodes(withLayer(good, "isGroup", "yes"))); + QVERIFY(!decodes(withLayer(good, "opacity", "0.5"))); + QVERIFY(!decodes(withLayer(good, "imageFile", 7))); + QVERIFY(!decodes(withLayer(good, "blendMode", "Glow"))); + QVERIFY(!decodes(withLayer(good, "blendMode", 3))); + QVERIFY(!decodes(withLayer(good, "transform", "upright"))); + QVERIFY(!decodes(withLayer(good, "maskPlacement", QJsonArray{1, 2}))); + // UUIDs: Swift's form only, in either case. + QVERIFY(decodes(with(good, "documentID", "00112233-4455-4677-8899-aabbccddeeff"))); + QVERIFY(!decodes(with(good, "documentID", "{00112233-4455-4677-8899-AABBCCDDEEFF}"))); + QVERIFY(!decodes(with(good, "documentID", "001122334455467788 99AABBCCDDEEFF"))); + QVERIFY(!decodes(with(good, "documentID", "00112233-4455-4677-8899-AABBCCDDEEFG"))); + QVERIFY(!decodes(with(good, "documentID", 7))); + QVERIFY(!decodes(with(good, "documentID", "00112233-4455-4677-8899-AABBCCDDEEFF\n"))); + QVERIFY(!decodes(with(good, "documentID", "\n00112233-4455-4677-8899-AABBCCDDEEFF"))); + QVERIFY(!decodes(withLayer(good, "parentID", "not-a-uuid"))); + // Points and sizes are two-number arrays; a third is ignored. + QJsonObject transform = object(good).value("layers").toArray().at(0).toObject().value("transform").toObject(); + transform.insert("origin", QJsonArray{1, 2, 3}); + QCOMPARE(ProjectManifest::decoded(withLayer(good, "transform", transform)).layers[0].transform.origin, QPointF(1, 2)); + transform.insert("origin", QJsonArray{1}); + QVERIFY(!decodes(withLayer(good, "transform", transform))); + transform.insert("origin", QJsonArray{"1", 2}); + QVERIFY(!decodes(withLayer(good, "transform", transform))); + transform.insert("origin", QJsonObject{{"x", 1}, {"y", 2}}); + QVERIFY(!decodes(withLayer(good, "transform", transform))); + transform.insert("origin", QJsonArray{1, 2}); + transform.insert("sampling", "Bicubic"); + QVERIFY(!decodes(withLayer(good, "transform", transform))); + transform.insert("sampling", "High quality"); + transform.insert("flipX", 0); + QVERIFY(!decodes(withLayer(good, "transform", transform))); + transform.insert("flipX", false); + transform.insert("rotation", "38"); + QVERIFY(!decodes(withLayer(good, "transform", transform))); +} + +void ProjectManifestTests::validationFollowsTheFormatsVersions_data() +{ + QTest::addColumn<QString>("change"); + QTest::addColumn<int>("version"); + QTest::addColumn<std::optional<ProjectError::Kind>>("refused"); + const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge); + const std::optional<ProjectError::Kind> fine; + for (int version = 1; version <= 7; ++version) + QTest::addRow("plain at version %d", version) << "" << version << fine; + QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version); + QTest::newRow("version 8") << "" << 8 << std::optional(ProjectError::Kind::version); + QTest::newRow("another format") << "format" << 7 << invalid; + QTest::newRow("another colour space") << "colorSpace" << 7 << invalid; + QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid; + QTest::newRow("resolution 9601") << "resolution=9601" << 7 << invalid; + QTest::newRow("resolution 1") << "resolution=1" << 1 << fine; + QTest::newRow("resolution 9600") << "resolution=9600" << 1 << fine; + QTest::newRow("width 0") << "width=0" << 7 << tooLarge; + QTest::newRow("width 30001") << "width=30001" << 7 << tooLarge; + QTest::newRow("width 30000") << "width=30000" << 7 << fine; + QTest::newRow("height 0") << "height=0" << 7 << tooLarge; + QTest::newRow("height 30001") << "height=30001" << 7 << tooLarge; + QTest::newRow("height 30000") << "height=30000" << 7 << fine; + QTest::newRow("10001 layers") << "layers=10001" << 7 << tooLarge; + QTest::newRow("10000 layers") << "layers=10000" << 7 << fine; + QTest::newRow("opacity before version 3") << "opacity=0.5" << 2 << invalid; + QTest::newRow("opacity at version 3") << "opacity=0.5" << 3 << fine; + QTest::newRow("full opacity before version 3") << "opacity=1" << 2 << fine; + QTest::newRow("opacity above one") << "opacity=1.5" << 7 << invalid; + QTest::newRow("opacity below zero") << "opacity=-0.5" << 7 << invalid; + QTest::newRow("a blend mode before version 3") << "blend" << 2 << invalid; + QTest::newRow("a blend mode at version 3") << "blend" << 3 << fine; + QTest::newRow("a mask before version 4") << "mask" << 3 << invalid; + QTest::newRow("a mask at version 4") << "mask" << 4 << fine; + QTest::newRow("a mask under another name") << "maskName" << 7 << invalid; + QTest::newRow("mask enabled without a mask") << "maskEnabledAlone" << 7 << invalid; + QTest::newRow("a placement without a mask") << "placementAlone" << 7 << invalid; + QTest::newRow("a placement that is no transform") << "placementInvalid" << 7 << invalid; + QTest::newRow("a placed mask") << "placement" << 7 << fine; + QTest::newRow("a clipping link before version 5") << "link" << 4 << invalid; + QTest::newRow("a clipping link at version 5") << "link" << 5 << fine; + QTest::newRow("a clipping link to nothing") << "linkNowhere" << 7 << invalid; + QTest::newRow("a folder at version 1") << "folder" << 1 << invalid; + QTest::newRow("a folder at version 2") << "folder" << 2 << fine; + QTest::newRow("an empty folder at version 1") << "folderEmpty" << 1 << invalid; + QTest::newRow("an empty folder at version 2") << "folderEmpty" << 2 << fine; + QTest::newRow("a folder that blends") << "folderBlend" << 7 << invalid; + QTest::newRow("a folder that fades") << "folderOpacity" << 7 << invalid; + QTest::newRow("a folder with pixels") << "folderPixels" << 7 << invalid; + QTest::newRow("a folder mask before version 6") << "folderMask" << 5 << invalid; + QTest::newRow("a folder mask at version 6") << "folderMask" << 6 << fine; + QTest::newRow("a parent that is no folder") << "parentLayer" << 7 << invalid; + QTest::newRow("the same id twice") << "duplicate" << 7 << invalid; + QTest::newRow("a transform of no size") << "transform" << 7 << invalid; + QTest::newRow("a blank name") << "name= \n\t" << 7 << invalid; + QTest::newRow("a name over 16384 bytes") << "nameLong" << 7 << invalid; + QTest::newRow("a name of 16384 bytes") << "nameFits" << 7 << fine; + QTest::newRow("a shorter name of 16386 bytes") << "nameWide" << 7 << invalid; + QTest::newRow("an image under another name") << "imageName" << 7 << invalid; + QTest::newRow("an image named in lower case") << "imageLower" << 7 << invalid; + QTest::newRow("an active layer that is not there") << "active" << 7 << invalid; +} + +void ProjectManifestTests::validationFollowsTheFormatsVersions() +{ + QFETCH(QString, change); + QFETCH(int, version); + QFETCH(std::optional<ProjectError::Kind>, refused); + ProjectSnapshot snapshot = twoLayers(); + ProjectManifest &manifest = snapshot.manifest; + manifest.version = version; + manifest.resolution = std::nullopt; + ProjectLayerRecord &image = manifest.layers[0], &blank = manifest.layers[1]; + const QString value = change.section('=', 1); + const auto folder = [&] { + blank.isGroup = true; + image.parentID = blank.id; + std::swap(manifest.layers[0], manifest.layers[1]); + }; + if (change == "format") + manifest.format = "com.example.project"; + else if (change == "colorSpace") + manifest.colorSpace = "Display P3"; + else if (change.startsWith("resolution=")) + manifest.resolution = value.toDouble(); + else if (change.startsWith("width=")) + manifest.width = value.toInt(); + else if (change.startsWith("height=")) + manifest.height = value.toInt(); + else if (change.startsWith("layers=")) { + while (int(manifest.layers.size()) < value.toInt()) + manifest.layers.push_back({.id = QUuid::createUuid(), .name = "n", .isVisible = true, + .transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt}); + } else if (change.startsWith("opacity=")) + blank.opacity = value.toDouble(); + else if (change == "blend") + blank.blendMode = LayerBlendMode::multiply; + else if (change == "mask" || change == "placement" || change == "placementInvalid") { + image.maskFile = uuidString(image.id) + ".mask.png"; + image.maskEnabled = true; + snapshot.masks.insert({image.id, asset(gray(4, 4, 255), "mask")}); + if (change != "mask") + image.maskPlacement = LayerTransform{.origin = {0, 0}, .size = {change == "placement" ? 5.0 : 0.0, 5}}; + } else if (change == "maskName") { + image.maskFile = "mask.png"; + snapshot.masks.insert({image.id, asset(gray(4, 4, 255), "mask")}); + } else if (change == "maskEnabledAlone") + image.maskEnabled = true; + else if (change == "placementAlone") + image.maskPlacement = LayerTransform{.origin = {0, 0}, .size = {5, 5}}; + else if (change == "link") + blank.maskSourceID = image.id; + else if (change == "linkNowhere") + blank.maskSourceID = QUuid::createUuid(); + else if (change == "folder") + folder(); + else if (change == "folderEmpty") + blank.isGroup = true; + else if (change == "folderBlend") { + folder(); + manifest.layers[0].blendMode = LayerBlendMode::screen; + } else if (change == "folderOpacity") { + folder(); + manifest.layers[0].opacity = 0.5; + } else if (change == "folderPixels") { + folder(); + manifest.layers[0].imageFile = uuidString(manifest.layers[0].id) + ".png"; + snapshot.images.insert({manifest.layers[0].id, asset(halfRed(), "folder")}); + } else if (change == "folderMask") { + folder(); + manifest.layers[0].maskFile = uuidString(manifest.layers[0].id) + ".mask.png"; + snapshot.masks.insert({manifest.layers[0].id, asset(gray(4, 4, 255), "mask")}); + } else if (change == "parentLayer") + blank.parentID = image.id; + else if (change == "duplicate") + blank.id = image.id; + else if (change == "transform") + blank.transform.size = QSizeF(0, 5); + else if (change.startsWith("name=")) + blank.name = value; + else if (change == "nameLong") + blank.name = QString(16'385, 'n'); + else if (change == "nameFits") + blank.name = QString(8'192, QChar(0x00E9)); + else if (change == "nameWide") + blank.name = QString(8'193, QChar(0x00E9)); + else if (change == "imageName") + image.imageFile = "image.png"; + else if (change == "imageLower") + image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png"; + else if (change == "active") + manifest.activeLayerID = QUuid::createUuid(); + else + QVERIFY(change.isEmpty()); + // The same rules guard saving and loading. + QTemporaryDir root; + QCOMPARE(projectError([&] { ProjectStore::save(snapshot, root.filePath("Rules.comp")); }), refused); + ProjectSnapshot plain = twoLayers(); + ProjectStore::save(plain, root.filePath("Read.comp")); + for (const auto &[id, mask] : snapshot.masks) + QVERIFY(mask.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".mask.png")); + for (const auto &[id, pixels] : snapshot.images) + QVERIFY(pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png")); + overwrite(root.filePath("Read.comp/manifest.json"), manifest.encoded()); + QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Read.comp")); }), refused); +} + +QTEST_MAIN(ProjectManifestTests) +#include "ProjectManifestTests.moc" diff --git a/tests/ProjectOpenTests.cpp b/tests/ProjectOpenTests.cpp new file mode 100644 index 0000000..63b488d --- /dev/null +++ b/tests/ProjectOpenTests.cpp @@ -0,0 +1,305 @@ +#include "DialogDesk.h" +#include "IO/ProjectController.h" +#include "IO/ProjectStore.h" +#include "SessionRecord.h" +#include <QFutureWatcher> +#include <QTemporaryDir> +#include <QtTest> +#include <atomic> + +// Opening a project: validated first, asked second, installed last. +namespace { +const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save"); +const QString damaged = QStringLiteral("alert|2|Couldn’t open the project|This is not a valid OmaPhoto project, or its metadata is damaged.|OK"); + +// A canvas with `layers` blank layers, as unsaved work. +void paint(EditorSession &session, int layers) +{ + session.createDocument(8, 6); + for (int index = 0; index < layers; ++index) + session.addBlankLayer(); +} + +// A project of `layers` layers saved at `path`. +void store(const QString &path, int layers) +{ + EditorSession session; + paint(session, layers); + ProjectStore::save(session.projectSnapshot().value(), path); +} + +std::optional<bool> answered(const std::function<void(std::function<void(bool)>)> &call) +{ + std::optional<bool> result; + call([&](bool value) { result = value; }); + // An answer that never comes stays empty and fails. + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} + +int layerCount(const EditorSession &session) +{ + return int(session.document().value().layers.size()); +} + +// How often the store read, and the threads it used. +std::atomic<int> loads = 0; +std::atomic<QThread *> loadingThread = nullptr, savingThread = nullptr; +QtMessageHandler forward = nullptr; +void countLoads(QtMsgType type, const QMessageLogContext &context, const QString &message) +{ + if (message.startsWith("loaded ")) { + loads += 1; + loadingThread = QThread::currentThread(); + } + if (message.startsWith("saved ")) + savingThread = QThread::currentThread(); + forward(type, context, message); +} +} + +class ProjectOpenTests : public QObject { + Q_OBJECT +private slots: + void init(); + void cleanup(); + void aSuppliedPathIsInstalledUnasked(); + void withoutAPathAFolderIsAskedFor(); + void aDamagedOrMissingProjectKeepsTheLiveDocument(); + void unsavedWorkIsAskedAboutBeforeItGoes(); + void aSaveInTheQuestionReloadsTheVeryProjectBeingOpened(); + void onlyASaveOverTheOpenedPlaceReloads(); + void theLastSignalOfAnOpenSeesItDone(); + void theStoreWorksOffTheCallersThread(); + void aTrailingSlashLeavesTheProjectItsName(); +}; + +void ProjectOpenTests::init() +{ + loads = 0; + forward = qInstallMessageHandler(countLoads); +} + +void ProjectOpenTests::cleanup() +{ + qInstallMessageHandler(forward); +} + +void ProjectOpenTests::aSuppliedPathIsInstalledUnasked() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Three.comp"); + store(path, 3); + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + bool busyMeanwhile = false; + connect(&session, &EditorSession::changed, this, [&] { busyMeanwhile = busyMeanwhile || session.isProjectBusy(); }); + QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true)); + QVERIFY(desk.seen.isEmpty() && busyMeanwhile); + QCOMPARE(layerCount(session), 3); + QCOMPARE(session.projectPath(), std::optional(path)); + QVERIFY(!session.isModified() && !session.isProjectBusy()); + QCOMPARE(loads, 1); + // Saved work goes unasked as well. + const QString other = folder.filePath("One.comp"); + store(other, 1); + QCOMPARE(answered([&](auto done) { controller.open(other, done); }), std::optional(true)); + QVERIFY(desk.seen.isEmpty()); + QCOMPARE(layerCount(session), 1); + QCOMPARE(session.projectPath(), std::optional(other)); + // Each read's watcher goes once it has answered. + QTRY_VERIFY(controller.findChildren<QFutureWatcherBase *>().isEmpty()); +} + +void ProjectOpenTests::withoutAPathAFolderIsAskedFor() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Asked.comp"); + store(path, 2); + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); }; + // Typed with a slash at its end, as folders are. + desk.replies = {"<cancel>", path + "/"}; + const QStringList before = described(session); + QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(false)); + QCOMPARE(described(session), before); + QCOMPARE(loads, 0); + QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||busy", "panel|Open Project|open|folder|||busy"})); + QCOMPARE(layerCount(session), 2); + QCOMPARE(session.projectPath(), std::optional(path)); + QVERIFY(!session.isProjectBusy()); +} + +void ProjectOpenTests::aDamagedOrMissingProjectKeepsTheLiveDocument() +{ + QTemporaryDir folder; + const QString broken = folder.filePath("Broken.comp"); + QVERIFY(QDir().mkpath(broken)); + QFile manifest(broken + "/manifest.json"); + QVERIFY(manifest.open(QIODevice::WriteOnly)); + manifest.write("{ not a manifest"); + manifest.close(); + EditorSession session; + paint(session, 2); + ProjectController controller(session); + const QStringList before = described(session); + DialogDesk desk; + desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); }; + desk.replies = {"OK", "OK"}; + QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged."); + QCOMPARE(answered([&](auto done) { controller.open(broken, done); }), std::optional(false)); + // Validation came first: the unsaved work was never in question. + QCOMPARE(desk.seen, (QStringList{damaged + "|busy"})); + QCOMPARE(described(session), before); + QVERIFY(session.isModified() && !session.isProjectBusy()); + QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged."); + QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), 2); + QCOMPARE(described(session), before); +} + +void ProjectOpenTests::unsavedWorkIsAskedAboutBeforeItGoes() +{ + QTemporaryDir folder; + const QString incoming = folder.filePath("Incoming.comp"), kept = folder.filePath("Kept.comp"); + store(incoming, 3); + EditorSession session; + paint(session, 1); + ProjectController controller(session); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + const QStringList before = described(session); + QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(false)); + QCOMPARE(described(session), before); + QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{question.arg("Untitled"), question.arg("Untitled")})); + QCOMPARE(layerCount(session), 3); + QVERIFY(!session.isModified()); + // Save first: the work reaches the disk, then goes. + paint(session, 2); + session.setProjectPath(kept); + desk.replies = {"Save"}; + QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true)); + QCOMPARE(desk.seen.last(), question.arg("Kept.comp")); + QCOMPARE(int(ProjectStore::load(kept).manifest.layers.size()), 2); + QCOMPARE(layerCount(session), 3); + QCOMPARE(session.projectPath(), std::optional(incoming)); + QVERIFY(!session.isModified() && !session.isProjectBusy()); +} + +void ProjectOpenTests::aSaveInTheQuestionReloadsTheVeryProjectBeingOpened() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Same.comp"), link = folder.filePath("Link.comp"); + store(path, 1); + QVERIFY(QFile::link(path, link)); + EditorSession session; + ProjectController controller(session); + QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true)); + session.addBlankLayer(); + QCOMPARE(layerCount(session), 2); + // Opened again through a link, and saved in the question. + DialogDesk desk; + desk.replies = {"Save"}; + loads = 0; + QCOMPARE(answered([&](auto done) { controller.open(link, done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{question.arg("Same.comp")})); + // What was read before the save is stale: read again. + QCOMPARE(loads, 2); + QCOMPARE(layerCount(session), 2); + QCOMPARE(session.projectPath(), std::optional(link)); + QVERIFY(!session.isModified() && !session.isProjectBusy()); +} + +void ProjectOpenTests::onlyASaveOverTheOpenedPlaceReloads() +{ + QTemporaryDir folder; + const QString first = folder.filePath("First.comp"), second = folder.filePath("Second.comp"); + store(first, 1); + store(second, 3); + EditorSession session; + ProjectController controller(session); + QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true)); + // The same place, nothing saved: one read is enough. + session.addBlankLayer(); + DialogDesk desk; + desk.replies = {"Don’t Save", "Save"}; + loads = 0; + QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true)); + QCOMPARE(loads, 1); + QCOMPARE(layerCount(session), 1); + // Saved, but to another place than the one opened. + session.addBlankLayer(); + loads = 0; + QCOMPARE(answered([&](auto done) { controller.open(second, done); }), std::optional(true)); + QCOMPARE(loads, 1); + QCOMPARE(layerCount(session), 3); + QCOMPARE(int(ProjectStore::load(first).manifest.layers.size()), 2); +} + +void ProjectOpenTests::theLastSignalOfAnOpenSeesItDone() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Seen.comp"); + store(path, 2); + EditorSession session; + ProjectController controller(session); + QStringList seen; + connect(&session, &EditorSession::changed, this, [&] { seen = described(session); }); + QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true)); + QCOMPARE(seen, described(session)); + QVERIFY(seen.contains("path " + path) && seen.contains("flags 0000")); +} + +void ProjectOpenTests::theStoreWorksOffTheCallersThread() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Threads.comp"); + store(path, 1); + EditorSession session; + ProjectController controller(session); + loadingThread = savingThread = nullptr; + QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true)); + session.addBlankLayer(); + QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true)); + // The store logs from the thread that did the work. + QVERIFY(loadingThread.load() != nullptr && savingThread.load() != nullptr); + QVERIFY(loadingThread.load() != QThread::currentThread()); + QVERIFY(savingThread.load() != QThread::currentThread()); + QCOMPARE(controller.thread(), QThread::currentThread()); +} + +void ProjectOpenTests::aTrailingSlashLeavesTheProjectItsName() +{ + QTemporaryDir folder; + const QString path = folder.filePath("Friendly.COMP"); + store(path, 1); + EditorSession session; + ProjectController controller(session); + // A shell completes a folder's name with a slash. + QCOMPARE(answered([&](auto done) { controller.open(path + "/", done); }), std::optional(true)); + QCOMPARE(session.projectPath(), std::optional(path)); + session.addBlankLayer(); + DialogDesk desk; + desk.replies = {"Cancel", "<cancel>"}; + QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false)); + QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(false)); + QCOMPARE(desk.seen, (QStringList{question.arg("Friendly.COMP"), "panel|Save Project As|save|file|Friendly.COMP|comp"})); + // Only the end is trimmed: `link/..` keeps its meaning. + QVERIFY(QDir().mkpath(folder.filePath("remote/deep"))); + QVERIFY(QFile::link(folder.filePath("remote/deep"), folder.filePath("link"))); + store(folder.filePath("Other.comp"), 1); + store(folder.filePath("remote/Other.comp"), 3); + EditorSession linked; + ProjectController opener(linked); + const QString through = folder.filePath("link/../Other.comp"); + QCOMPARE(answered([&](auto done) { opener.open(through + "//", done); }), std::optional(true)); + QCOMPARE(layerCount(linked), 3); + QCOMPARE(linked.projectPath(), std::optional(through)); +} + +QTEST_MAIN(ProjectOpenTests) +#include "ProjectOpenTests.moc" diff --git a/tests/ProjectReceiveTests.cpp b/tests/ProjectReceiveTests.cpp new file mode 100644 index 0000000..15c3812 --- /dev/null +++ b/tests/ProjectReceiveTests.cpp @@ -0,0 +1,264 @@ +#include "DialogDesk.h" +#include "IO/ProjectController.h" +#include "IO/ProjectStore.h" +#include "SessionRecord.h" +#include <QSemaphore> +#include <QTemporaryDir> +#include <QThreadPool> +#include <QtTest> + +// Files dropped on a project: a queue of requests. +namespace { +const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save"); + +// A four by two picture file of that name. +QUrl picture(const QTemporaryDir &folder, const QString &name) +{ + QImage image(4, 2, QImage::Format_RGBA8888); + image.fill(Qt::green); + const QString path = folder.filePath(name + ".png"); + if (!image.save(path)) + throw std::runtime_error("the fixture could not be written"); + return QUrl::fromLocalFile(path); +} + +// A project of one blank layer on sixteen by twelve. +QUrl project(const QTemporaryDir &folder, const QString &name) +{ + EditorSession session; + session.createDocument(16, 12); + session.addBlankLayer(); + ProjectStore::save(session.projectSnapshot().value(), folder.filePath(name)); + return QUrl::fromLocalFile(folder.filePath(name)); +} + +QStringList names(const EditorSession &session) +{ + QStringList result; + for (const ImageLayer &layer : session.document().value().layers) + result << layer.name; + return result; +} + +QPointF centre(const EditorSession &session, const QString &name) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.name == name) + return layer.transform.center(); + } + throw std::runtime_error("no such layer"); +} +} + +class ProjectReceiveTests : public QObject { + Q_OBJECT +private slots: + void picturesGoInAtThePoint(); + void aProjectOpensFirstAndItsPicturesGoToTheCentre(); + void aProjectIsKnownByItsSuffixInAnyCase(); + void twoProjectsAtOnceAreRefused(); + void aProjectThatDoesNotOpenKeepsItsPicturesOut(); + void requestsWaitForTheGateAndKeepTheirOrder(); + void anEmptyDropAnswersFromTheEventLoop(); + void aControllerThatEndsLeavesTheSessionSafe(); +}; + +void ProjectReceiveTests::picturesGoInAtThePoint() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(16, 12); + ProjectController controller(session); + DialogDesk desk; + bool done = false; + controller.receive({picture(folder, "One"), picture(folder, "Two")}, QPointF(10, 5), [&] { done = true; }); + // Nothing runs inside the call, not even the import's start. + QVERIFY(!done && !session.isImporting() && session.document().value().layers.empty()); + QTRY_VERIFY(done); + QCOMPARE(names(session), (QStringList{"One", "Two"})); + QCOMPARE(centre(session, "One"), QPointF(10, 5)); + QCOMPARE(centre(session, "Two"), QPointF(10, 5)); + QVERIFY(desk.seen.isEmpty() && !session.isImporting()); + // No callback is fine too. + controller.receive({picture(folder, "Three")}); + QTRY_COMPARE(names(session).size(), 3); + QCOMPARE(centre(session, "Three"), QPointF(8, 6)); +} + +void ProjectReceiveTests::aProjectOpensFirstAndItsPicturesGoToTheCentre() +{ + QTemporaryDir folder; + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + bool done = false; + controller.receive({picture(folder, "Before"), project(folder, "Base.comp"), picture(folder, "After")}, QPointF(1, 1), [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Base.comp"))); + QCOMPARE(names(session), (QStringList{"Layer 1", "Before", "After"})); + // The point belonged to another canvas: the centre it is. + QCOMPARE(centre(session, "Before"), QPointF(8, 6)); + QCOMPARE(centre(session, "After"), QPointF(8, 6)); + QVERIFY(desk.seen.isEmpty() && !session.isProjectBusy()); + // A project alone, over the unsaved pictures: asked, then opened. + done = false; + desk.replies = {"Don’t Save"}; + controller.receive({project(folder, "Alone.comp")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen, (QStringList{question.arg("Base.comp")})); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Alone.comp"))); + QCOMPARE(names(session), (QStringList{"Layer 1"})); +} + +void ProjectReceiveTests::aProjectIsKnownByItsSuffixInAnyCase() +{ + QTemporaryDir folder; + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + bool done = false; + // Upper case, and a folder's address ending in a slash. + const QUrl shouted = project(folder, "Shouted.COMP"); + controller.receive({QUrl(shouted.toString() + "/")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(session.document().has_value()); + QCOMPARE(session.projectPath(), std::optional(folder.filePath("Shouted.COMP"))); + QCOMPARE(names(session), (QStringList{"Layer 1"})); + QVERIFY(desk.seen.isEmpty()); +} + +void ProjectReceiveTests::twoProjectsAtOnceAreRefused() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(16, 12); + ProjectController controller(session); + const QStringList before = described(session); + DialogDesk desk; + desk.replies = {"OK"}; + bool done = false; + QTest::ignoreMessage(QtWarningMsg, "Open one project at a time: This is not a valid OmaPhoto project, or its metadata is damaged."); + controller.receive({project(folder, "A.comp"), picture(folder, "Picture"), project(folder, "B.comp")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen, (QStringList{"alert|2|Open one project at a time|This is not a valid OmaPhoto project, or its metadata is damaged.|OK"})); + QCOMPARE(described(session), before); +} + +void ProjectReceiveTests::aProjectThatDoesNotOpenKeepsItsPicturesOut() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(16, 12); + session.addBlankLayer(); + ProjectController controller(session); + const QStringList before = described(session); + DialogDesk desk; + desk.replies = {"Cancel", "OK"}; + bool done = false; + controller.receive({project(folder, "Refused.comp"), picture(folder, "Picture")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen, (QStringList{question.arg("Untitled")})); + QCOMPARE(described(session), before); + // An address that is no local file fails to open. + done = false; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); + controller.receive({QUrl("https://example.com/Far.comp"), picture(folder, "Other")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen.size(), 2); + QCOMPARE(described(session), before); +} + +void ProjectReceiveTests::requestsWaitForTheGateAndKeepTheirOrder() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(16, 12); + ProjectController controller(session); + // A sheet is up: file requests wait. + session.setShowsNewDocument(true); + QStringList order; + // A caller resumes once the queue has moved on. + controller.receive({picture(folder, "First")}, std::nullopt, [&] { order << (session.isImporting() ? "first" : "first, queue idle"); }); + controller.receive({picture(folder, "Second")}, std::nullopt, [&] { order << (session.isImporting() ? "second, queue busy" : "second"); }); + QTest::qWait(100); + QVERIFY(order.isEmpty() && session.document().value().layers.empty()); + session.setShowsNewDocument(false); + QTRY_COMPARE(order, (QStringList{"first", "second"})); + QCOMPARE(names(session), (QStringList{"First", "Second"})); + // The queue starts again for a later request. + controller.receive({picture(folder, "Third")}, std::nullopt, [&] { order << "third"; }); + QTRY_COMPARE(order.size(), 3); + QCOMPARE(names(session), (QStringList{"First", "Second", "Third"})); + // A project's own pictures go in before the next request's. + DialogDesk desk; + desk.replies = {"Don’t Save"}; + controller.receive({project(folder, "Base.comp"), picture(folder, "Own")}, std::nullopt, [&] { order << "fourth"; }); + controller.receive({picture(folder, "Next")}, std::nullopt, [&] { order << "fifth"; }); + QTRY_COMPARE(order.size(), 5); + QCOMPARE(order.mid(3), (QStringList{"fourth", "fifth"})); + QCOMPARE(names(session), (QStringList{"Layer 1", "Own", "Next"})); +} + +void ProjectReceiveTests::anEmptyDropAnswersFromTheEventLoop() +{ + EditorSession session; + ProjectController controller(session); + QSignalSpy changes(&session, &EditorSession::changed); + bool done = false; + controller.receive({}, std::nullopt, [&] { done = true; }); + QVERIFY(!done); + QTRY_VERIFY(done); + controller.receive({}); + QTest::qWait(20); + QCOMPARE(changes.count(), 0); + // It never queues: a closed gate does not hold it. + session.setShowsNewDocument(true); + done = false; + controller.receive({}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); +} + +void ProjectReceiveTests::aControllerThatEndsLeavesTheSessionSafe() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(16, 12); + auto controller = std::make_unique<ProjectController>(session); + // Behind a sheet the session itself holds the waiting request. + session.setShowsNewDocument(true); + bool done = false; + controller->receive({picture(folder, "Waiting")}, std::nullopt, [&] { done = true; }); + QTest::qWait(50); + controller.reset(); + session.setShowsNewDocument(false); + QTest::qWait(200); + QVERIFY(!done && !session.isImporting() && session.document().value().layers.empty()); + // Mid-import: every worker is held, so the decode waits. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + controller = std::make_unique<ProjectController>(session); + controller->receive({picture(folder, "Landing")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(session.isImporting()); + controller.reset(); + gate.release(workers); + // The picture still lands; its request has nobody to tell. + QTRY_COMPARE(names(session), (QStringList{"Landing"})); + QTRY_VERIFY(!session.isImporting()); + QTest::qWait(50); + QVERIFY(!done); +} + +QTEST_MAIN(ProjectReceiveTests) +#include "ProjectReceiveTests.moc" diff --git a/tests/ProjectReplaceTests.cpp b/tests/ProjectReplaceTests.cpp new file mode 100644 index 0000000..c5bdd4c --- /dev/null +++ b/tests/ProjectReplaceTests.cpp @@ -0,0 +1,372 @@ +#include "ProjectFixtures.h" +#include <QTemporaryDir> +#include <QtTest> +#include <cerrno> +#include <csignal> +#include <fcntl.h> +#include <sys/resource.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <unistd.h> + +namespace { +// What the store's system calls must do, and did. +struct Faults { + // The errno exchanges fail with, from the nth one on. + int exchangeError = 0; + int firstFailingExchange = 1; + int failingRename = 0; + bool fileSyncFails = false; + // The nth folder sync fails; 0 for none. + int failingFolderSync = 0; + // Manifest reads come 4096 bytes at most, then fail. + bool manifestReadsFail = false; + int renames = 0; + int exchanges = 0; + int folderSyncs = 0; + int manifestReads = 0; + QStringList trace; +} faults; + +const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden; + +// Counts critical logs on their way to Qt Test. +int criticals = 0; +QtMessageHandler forward = nullptr; +void countCriticals(QtMsgType type, const QMessageLogContext &context, const QString &message) +{ + criticals += type == QtCriticalMsg; + forward(type, context, message); +} +} + +// These take libc's place for the store linked in here. +extern "C" int renameat2(int oldDirectory, const char *from, int newDirectory, const char *to, unsigned int flags) +{ + if (flags != 0 && faults.exchangeError != 0 && ++faults.exchanges >= faults.firstFailingExchange) { + errno = faults.exchangeError; + return -1; + } + faults.trace << (flags != 0 ? "exchange" : "rename"); + return int(syscall(SYS_renameat2, oldDirectory, from, newDirectory, to, flags)); +} + +extern "C" int rename(const char *from, const char *to) +{ + if (++faults.renames == faults.failingRename) { + errno = EIO; + return -1; + } + faults.trace << "rename"; + return int(syscall(SYS_renameat2, AT_FDCWD, from, AT_FDCWD, to, 0)); +} + +extern "C" int fsync(int descriptor) +{ + struct stat info; + const bool folder = fstat(descriptor, &info) == 0 && S_ISDIR(info.st_mode); + if (folder ? ++faults.folderSyncs == faults.failingFolderSync : faults.fileSyncFails) { + errno = EIO; + return -1; + } + faults.trace << (folder ? "sync folder" : "sync file"); + return int(syscall(SYS_fsync, descriptor)); +} + +extern "C" ssize_t read(int descriptor, void *buffer, size_t count) +{ + char path[512] = {}; + const QByteArray link = "/proc/self/fd/" + QByteArray::number(descriptor); + const bool manifest = faults.manifestReadsFail && readlink(link.constData(), path, sizeof(path) - 1) > 0 + && QByteArray(path).endsWith("manifest.json"); + if (manifest && ++faults.manifestReads > 1) { + errno = EIO; + return -1; + } + return syscall(SYS_read, descriptor, buffer, manifest ? std::min<size_t>(count, 4096) : count); +} + +class ProjectReplaceTests : public QObject { + Q_OBJECT +private slots: + void init(); + void everythingIsSyncedBeforeTheSwap(); + void withoutAnExchangeTheProjectIsReplacedInTwoSteps_data(); + void withoutAnExchangeTheProjectIsReplacedInTwoSteps(); + void aFailedSwapLeavesTheOldProject_data(); + void aFailedSwapLeavesTheOldProject(); + void aSyncFailingAfterTheSwapPutsTheOldProjectBack_data(); + void aSyncFailingAfterTheSwapPutsTheOldProjectBack(); + void aFirstSaveThatCannotBeSyncedIsTakenAway(); + void aManifestThatStopsReadingIsRefused(); + void aDiskThatTakesNoMoreBytesFailsTheSave(); + void savesLeakNoDescriptors(); +}; + +void ProjectReplaceTests::init() +{ + faults = Faults(); +} + +void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap() +{ + QTemporaryDir root; + const QString path = root.filePath("Durable.comp"); + ProjectStore::save(twoLayers(), path); + // The manifest, the image, both folders, the move, the parent. + QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "rename", "sync folder"})); + faults.trace.clear(); + ProjectStore::save(twoLayers(), path); + QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"})); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"}); +} + +void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data() +{ + QTest::addColumn<int>("error"); + QTest::newRow("EINVAL, a file system without it") << EINVAL; + QTest::newRow("ENOSYS, a kernel without it") << ENOSYS; + QTest::newRow("ENOTSUP") << ENOTSUP; +} + +void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps() +{ + QFETCH(int, error); + QTemporaryDir root; + const QString path = root.filePath("Stick.comp"); + ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, path); + faults.exchangeError = error; + // A first save needs no exchange. + ProjectStore::save(snapshot, root.filePath("Fresh.comp")); + QCOMPARE(contents(root.filePath("Fresh.comp/manifest.json")), contents(path + "/manifest.json")); + // An overwrite falls back, says so, and leaves nothing behind. + snapshot.manifest.layers.erase(snapshot.manifest.layers.begin()); + snapshot.images.clear(); + faults.trace.clear(); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; replacing \".*Stick.comp\" in two steps")); + ProjectStore::save(snapshot, path); + // The old copy goes only once the swap is synced. + QCOMPARE(faults.trace, (QStringList{"sync file", "sync folder", "sync folder", "rename", "rename", "sync folder", "rename"})); + const ProjectSnapshot loaded = ProjectStore::load(path); + QCOMPARE(int(loaded.manifest.layers.size()), 1); + QVERIFY(QDir(path + "/images").entryList(QDir::Files).isEmpty()); + QCOMPARE(QDir(root.path()).entryList(everything), (QStringList{"Fresh.comp", "Stick.comp"})); + // A plain file in the way goes the same road. + overwrite(root.filePath("WasAFile.comp"), "x"); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*")); + ProjectStore::save(snapshot, root.filePath("WasAFile.comp")); + QCOMPARE(int(ProjectStore::load(root.filePath("WasAFile.comp")).manifest.layers.size()), 1); + QCOMPARE(QDir(root.path()).entryList(everything), (QStringList{"Fresh.comp", "Stick.comp", "WasAFile.comp"})); +} + +void ProjectReplaceTests::aFailedSwapLeavesTheOldProject_data() +{ + QTest::addColumn<int>("exchangeError"); + QTest::addColumn<int>("failingRename"); + QTest::addColumn<bool>("fileSyncFails"); + QTest::addColumn<int>("failingFolderSync"); + QTest::addColumn<QString>("message"); + QTest::newRow("the exchange itself fails") << EIO << 0 << false << 0 << "could not replace .*Kept.comp: Input/output error"; + QTest::newRow("the old project cannot move aside") << EINVAL << 1 << false << 0 << "could not replace .*Kept.comp: Input/output error"; + QTest::newRow("the new project cannot move in") << EINVAL << 2 << false << 0 << "could not replace .*Kept.comp: Input/output error"; + QTest::newRow("a file cannot be synced") << 0 << 0 << true << 0 << "could not write .*manifest.json: Input/output error"; + QTest::newRow("the images folder cannot be synced") << 0 << 0 << false << 1 << "could not sync .*images: Input/output error"; + QTest::newRow("the sibling cannot be synced") << 0 << 0 << false << 2 << "could not sync .*tmp: Input/output error"; +} + +void ProjectReplaceTests::aFailedSwapLeavesTheOldProject() +{ + QFETCH(int, exchangeError); + QFETCH(int, failingRename); + QFETCH(bool, fileSyncFails); + QFETCH(int, failingFolderSync); + QFETCH(QString, message); + QTemporaryDir root; + const QString path = root.filePath("Kept.comp"); + ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, path); + const QByteArray original = contents(path + "/manifest.json"); + snapshot.manifest.layers.erase(snapshot.manifest.layers.begin()); + snapshot.images.clear(); + faults = Faults(); + faults.exchangeError = exchangeError; + faults.failingRename = failingRename; + faults.fileSyncFails = fileSyncFails; + faults.failingFolderSync = failingFolderSync; + if (exchangeError == EINVAL) + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(message)); + try { + ProjectStore::save(snapshot, path); + QFAIL("the save went through"); + } catch (const std::runtime_error &error) { + QVERIFY2(QRegularExpression(message).match(error.what()).hasMatch(), error.what()); + } + faults = Faults(); + QCOMPARE(contents(path + "/manifest.json"), original); + QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Kept.comp"}); + // A first save that cannot move in leaves nothing. + faults.failingRename = 1; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not move the project to .*First.comp: Input/output error")); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("First.comp")), std::runtime_error); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Kept.comp"}); +} + +void ProjectReplaceTests::aSyncFailingAfterTheSwapPutsTheOldProjectBack_data() +{ + QTest::addColumn<int>("exchangeError"); + QTest::addColumn<int>("firstFailingExchange"); + QTest::addColumn<int>("failingRename"); + // Where the old project stays, and what else is left. + QTest::addColumn<QString>("kept"); + QTest::addColumn<QStringList>("left"); + QTest::newRow("after an exchange") << 0 << 1 << 0 << "" << QStringList{"Kept.comp"}; + QTest::newRow("after two moves") << EINVAL << 1 << 0 << "" << QStringList{"Kept.comp"}; + QTest::newRow("an exchange that cannot be undone") << EIO << 2 << 0 << ".tmp" << QStringList{".tmp", "Kept.comp"}; + // The third move takes the new project away again. + QTest::newRow("two moves, the new project stuck") << EINVAL << 1 << 3 << ".tmp.old" << QStringList{".tmp.old", "Kept.comp"}; + QTest::newRow("two moves, the old project stuck") << EINVAL << 1 << 4 << ".tmp.old" << QStringList{".tmp", ".tmp.old"}; +} + +void ProjectReplaceTests::aSyncFailingAfterTheSwapPutsTheOldProjectBack() +{ + QFETCH(int, exchangeError); + QFETCH(int, firstFailingExchange); + QFETCH(int, failingRename); + QFETCH(QString, kept); + QFETCH(QStringList, left); + QTemporaryDir root; + const QString path = root.filePath("Kept.comp"); + ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, path); + const QByteArray original = contents(path + "/manifest.json"); + const QImage pixels = snapshot.images.begin()->second.image(); + snapshot.manifest.layers.erase(snapshot.manifest.layers.begin()); + snapshot.images.clear(); + faults = Faults(); + faults.exchangeError = exchangeError; + faults.firstFailingExchange = firstFailingExchange; + faults.failingRename = failingRename; + // The third folder synced is the parent, after the swap. + faults.failingFolderSync = 3; + if (exchangeError == EINVAL) + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*")); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error")); + const QString sibling = QStringLiteral("\\.Kept\\.comp\\.[0-9A-F-]{36}"); + if (!kept.isEmpty()) { + const QString stays = "^the previous project could not be put back; it stays at " + QRegularExpression::escape(root.path()) + "/" + + sibling + QRegularExpression::escape(kept) + "$"; + QTest::ignoreMessage(QtCriticalMsg, QRegularExpression(stays)); + } + criticals = 0; + forward = qInstallMessageHandler(countCriticals); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, path), std::runtime_error); + qInstallMessageHandler(forward); + faults = Faults(); + // A way back that worked raises no alarm. + QCOMPARE(criticals, kept.isEmpty() ? 0 : 1); + // Nothing is deleted when the way back fails. + QMap<QString, QString> paths; + for (const QString &name : QDir(root.path()).entryList(everything)) { + const QRegularExpressionMatch match = QRegularExpression("^" + sibling + "(\\.tmp(\\.old)?)$").match(name); + paths.insert(match.hasMatch() ? match.captured(1) : name, root.filePath(name)); + } + QCOMPARE(QStringList(paths.keys()), left); + // The old project opens whole from where it stays. + const QString previous = paths.value(kept.isEmpty() ? "Kept.comp" : kept); + QCOMPARE(contents(previous + "/manifest.json"), original); + const ProjectSnapshot old = ProjectStore::load(previous); + QCOMPARE(int(old.manifest.layers.size()), 2); + QCOMPARE(old.images.begin()->second.image(), pixels); + // The new one stays too, wherever the moves left it. + if (!kept.isEmpty()) + QCOMPARE(int(ProjectStore::load(paths.value(left.contains("Kept.comp") ? "Kept.comp" : ".tmp")).manifest.layers.size()), 1); +} + +void ProjectReplaceTests::aFirstSaveThatCannotBeSyncedIsTakenAway() +{ + QTemporaryDir root; + const QString path = root.filePath("First.comp"); + faults.failingFolderSync = 3; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error")); + criticals = 0; + forward = qInstallMessageHandler(countCriticals); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(twoLayers(), path), std::runtime_error); + qInstallMessageHandler(forward); + QCOMPARE(criticals, 0); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList()); + // What cannot be taken away stays whole, and is named. + faults = Faults(); + faults.failingFolderSync = 3; + faults.failingRename = 2; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error")); + QTest::ignoreMessage(QtCriticalMsg, QRegularExpression("^the unsynced project could not be taken from " + QRegularExpression::escape(path) + "$")); + forward = qInstallMessageHandler(countCriticals); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(twoLayers(), path), std::runtime_error); + qInstallMessageHandler(forward); + QCOMPARE(criticals, 1); + faults = Faults(); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"First.comp"}); + QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2); +} + +void ProjectReplaceTests::aManifestThatStopsReadingIsRefused() +{ + QTemporaryDir root; + const QString path = root.filePath("Read.comp"); + ProjectStore::save(twoLayers(), path); + // Whole in 4096 bytes, then padded: a short read parses. + const QByteArray manifest = contents(path + "/manifest.json"); + QVERIFY(manifest.size() < 4096); + overwrite(path + "/manifest.json", manifest + QByteArray(8192, ' ')); + QCOMPARE(projectError([&] { ProjectStore::load(path); }), std::nullopt); + faults.manifestReadsFail = true; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not read \".*manifest.json\" : .*")); + const std::optional<ProjectError::Kind> refused = projectError([&] { ProjectStore::load(path); }); + faults = Faults(); + QCOMPARE(refused, std::optional(ProjectError::Kind::invalid)); +} + +void ProjectReplaceTests::aDiskThatTakesNoMoreBytesFailsTheSave() +{ + QTemporaryDir root; + const QString path = root.filePath("Full.comp"); + ProjectStore::save(twoLayers(), path); + const QByteArray original = contents(path + "/manifest.json"); + // No file may pass 100 bytes: every write stops short. + std::signal(SIGXFSZ, SIG_IGN); + rlimit previous; + QCOMPARE(getrlimit(RLIMIT_FSIZE, &previous), 0); + rlimit tight = previous; + tight.rlim_cur = 100; + QCOMPARE(setrlimit(RLIMIT_FSIZE, &tight), 0); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not write .*manifest.json: .*")); + bool thrown = false; + try { + ProjectStore::save(twoLayers(), path); + } catch (const std::runtime_error &) { + thrown = true; + } + QCOMPARE(setrlimit(RLIMIT_FSIZE, &previous), 0); + QVERIFY(thrown); + QCOMPARE(contents(path + "/manifest.json"), original); + QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Full.comp"}); +} + +void ProjectReplaceTests::savesLeakNoDescriptors() +{ + QTemporaryDir root; + const auto open = [] { return QDir("/proc/self/fd").entryList(QDir::NoDotAndDotDot | QDir::AllEntries).size(); }; + ProjectStore::save(twoLayers(), root.filePath("Again.comp")); + const qsizetype before = open(); + for (int round = 0; round < 5; ++round) { + ProjectStore::save(twoLayers(), root.filePath("Again.comp")); + ProjectStore::load(root.filePath("Again.comp")); + } + QCOMPARE(open(), before); +} + +QTEST_MAIN(ProjectReplaceTests) +#include "ProjectReplaceTests.moc" diff --git a/tests/ProjectResizeFailureTests.cpp b/tests/ProjectResizeFailureTests.cpp new file mode 100644 index 0000000..a5f03f0 --- /dev/null +++ b/tests/ProjectResizeFailureTests.cpp @@ -0,0 +1,148 @@ +#include "AddressSpaceLimit.h" +#include "DialogDesk.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include "IO/ProjectController.h" +#include "UI/ColorPickerSheet.h" +#include <QDialog> +#include <QSemaphore> +#include <QtConcurrent> +#include <QtTest> +#include <malloc.h> + +// Canvas and image sizes out of memory, a binary alone. +class ProjectResizeFailureTests : public QObject { + Q_OBJECT +private slots: + void anImageResizeThatFindsNoMemoryIsARenderError(); + void aSnapshotThatFindsNoMemoryIsExplained(); + void aWorkerThatFindsNoMemoryIsExplained(); + void aLandingThatFindsNoMemoryIsExplained(); +}; + +namespace { +// A thousand layers: every copy of them needs a megabyte. +ProjectSnapshot manyLayers() +{ + ProjectSnapshot snapshot{.manifest = {.documentID = QUuid::createUuid(), .width = 400, .height = 300, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + for (int index = 0; index < 1024; ++index) + snapshot.manifest.layers.push_back({.id = QUuid::createUuid(), .name = QStringLiteral("Blank"), .isVisible = true, + .transform = {.origin = {0, 0}, .size = {400, 300}}, .imageFile = std::nullopt}); + snapshot.manifest.activeLayerID = snapshot.manifest.layers.front().id; + return snapshot; +} + +struct Desk { + EditorSession session; + ProjectController controller{session}; + QWidget window; + DialogDesk alerts; + bool done = false; + Desk() + { + session.installProject(manyLayers(), QStringLiteral("Many.comp")); + window.show(); + controller.window = &window; + alerts.replies = {"OK"}; + alerts.note = [this] { return QStringLiteral("busy %1").arg(session.isProjectBusy()); }; + } + void open() { controller.canvasSize([this] { done = true; }); } + // The sheet's width typed, not yet committed. + PickerField &width() + { + auto *field = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("canvasWidth")); + field->setText(QStringLiteral("200")); + field->setModified(true); + return *field; + } + QStringList explained() const + { + return {QStringLiteral("alert|2|Couldn’t change canvas size|%1|OK|busy 1").arg(QString::fromUtf8(ExportError(ExportError::Kind::render).what()))}; + } +}; +} + +void ProjectResizeFailureTests::anImageResizeThatFindsNoMemoryIsARenderError() +{ + const ProjectSnapshot snapshot = manyLayers(); + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, ImageResizer::resize(snapshot, {.width = 200, .height = 150, .resolution = 72})); +} + +void ProjectResizeFailureTests::aSnapshotThatFindsNoMemoryIsExplained() +{ + Desk desk; + const CanvasDocument before = desk.session.document().value(); + desk.open(); + PickerField &field = desk.width(); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QTest::keyClick(&field, Qt::Key_Return); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen, desk.explained()); + QVERIFY(!desk.session.isProjectBusy()); + QVERIFY(desk.session.document().value() == before); +} + +void ProjectResizeFailureTests::aWorkerThatFindsNoMemoryIsExplained() +{ + Desk desk; + const CanvasDocument before = desk.session.document().value(); + // The worker waits until the limit is in place. + QThreadPool *pool = QThreadPool::globalInstance(); + pool->setMaxThreadCount(1); + QSemaphore entered, release; + const QFuture<void> held = QtConcurrent::run([&] { + entered.release(); + release.acquire(); + }); + entered.acquire(); + desk.open(); + QTest::keyClick(&desk.width(), Qt::Key_Return); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + release.release(); + pool->waitForDone(); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen, desk.explained()); + QVERIFY(desk.session.document().value() == before); +} + +void ProjectResizeFailureTests::aLandingThatFindsNoMemoryIsExplained() +{ + Desk desk; + desk.session.renameLayer(desk.session.activeLayerID().value(), QStringLiteral("Renamed")); + const CanvasDocument before = desk.session.document().value(); + desk.open(); + QTest::keyClick(&desk.width(), Qt::Key_Return); + // The worker finishes with room; its result lands without. + QThreadPool::globalInstance()->waitForDone(); + { + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QCoreApplication::processEvents(); + } + QTRY_VERIFY(desk.done); + QCOMPARE(desk.alerts.seen, desk.explained()); + QVERIFY(desk.session.document().value() == before); + QCOMPARE(desk.session.history.undoName(), QString("Rename Layer")); + QVERIFY(desk.session.canUndo()); +} + +int main(int argc, char **argv) +{ + // Before any thread: workers share the heap the limit counts. + mallopt(M_ARENA_MAX, 1); + // Large asks always map memory. + mallopt(M_MMAP_THRESHOLD, 64 * 1024); + QApplication app(argc, argv); + ProjectResizeFailureTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "ProjectResizeFailureTests.moc" diff --git a/tests/ProjectResizeTests.cpp b/tests/ProjectResizeTests.cpp new file mode 100644 index 0000000..13ac882 --- /dev/null +++ b/tests/ProjectResizeTests.cpp @@ -0,0 +1,163 @@ +#include "DialogDesk.h" +#include "IO/ProjectController.h" +#include "UI/CanvasSizeSheet.h" +#include "UI/ColorPickerSheet.h" +#include "UI/ImageSizeSheet.h" +#include <QDialog> +#include <QPushButton> +#include <QtTest> + +// The controller's Canvas Size and Image Size: sheet, resize, step. +class ProjectResizeTests : public QObject { + Q_OBJECT +private slots: + void theCanvasSizeSheetResizesTheCanvasAsOneStep(); + void aCancelledOrRefusedSheetChangesNothing(); + void theImageSizeSheetScalesThePixelsAsOneStep(); + void aResizeThatFailsIsExplained(); +}; + +namespace { +struct Desk { + EditorSession session; + ProjectController controller{session}; + QWidget window; + bool done = false; + Desk() + { + session.createDocument(100, 50); + session.insert(ImportedImage(QImage(10, 10, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), QPointF(45, 25)); + window.resize(600, 400); + window.show(); + controller.window = &window; + } + QDialog &dialog() + { + auto *shown = window.findChild<QDialog *>(); + if (!shown) + throw std::runtime_error("no sheet shown"); + return *shown; + } + // Typed, then Return: the field commits, then the default button. + void type(const char *name, const QString &text) + { + auto *field = dialog().findChild<PickerField *>(QString::fromLatin1(name)); + if (!field) + throw std::runtime_error(std::string("no field named ") + name); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, text); + QTest::keyClick(field, Qt::Key_Return); + } + std::function<void()> finished() + { + done = false; + return [this] { done = true; }; + } +}; +} + +void ProjectResizeTests::theCanvasSizeSheetResizesTheCanvasAsOneStep() +{ + Desk desk; + const int steps = desk.session.history.undoCount(); + desk.controller.canvasSize(desk.finished()); + // The sheet holds the window; the project is busy meanwhile. + QVERIFY(desk.session.isProjectBusy()); + QCOMPARE(desk.dialog().windowTitle(), QString("Canvas Size")); + QVERIFY(desk.dialog().isVisible() && desk.dialog().windowModality() == Qt::WindowModal); + QCOMPARE(desk.dialog().minimumSize(), desk.dialog().maximumSize()); + QVERIFY(desk.dialog().findChild<CanvasSizeSheet *>()); + desk.type("canvasWidth", "200"); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.session.document().value().width, 200); + QCOMPARE(desk.session.document().value().height, 50); + // Centred: the layer moves half the growth to the right. + QCOMPARE(desk.session.activeLayer().value().origin(), QPointF(90, 20)); + QCOMPARE(desk.session.history.undoCount(), steps + 1); + QCOMPARE(desk.session.history.undoName(), QString("Canvas Size")); + QVERIFY(!desk.session.isProjectBusy()); + QTRY_VERIFY(!desk.window.findChild<QDialog *>()); +} + +void ProjectResizeTests::aCancelledOrRefusedSheetChangesNothing() +{ + Desk desk; + const CanvasDocument before = desk.session.document().value(); + // Escape and Cancel close the sheet; the project frees. + desk.controller.canvasSize(desk.finished()); + QTest::keyClick(&desk.dialog(), Qt::Key_Escape); + QTRY_VERIFY(desk.done); + QVERIFY(!desk.session.isProjectBusy()); + desk.controller.imageSize(desk.finished()); + desk.dialog().findChild<QPushButton *>(QStringLiteral("imageCancel"))->click(); + QTRY_VERIFY(desk.done); + QVERIFY(desk.session.document().value() == before); + QVERIFY(!desk.session.isProjectBusy()); + QTRY_VERIFY(!desk.window.findChild<QDialog *>()); + // Busy, or without a window, no sheet opens. + desk.session.setIsProjectBusy(true); + desk.controller.canvasSize(desk.finished()); + QTRY_VERIFY(desk.done); + QVERIFY(!desk.window.findChild<QDialog *>()); + desk.session.setIsProjectBusy(false); + desk.controller.window = nullptr; + desk.controller.imageSize(desk.finished()); + QTRY_VERIFY(desk.done); + desk.controller.canvasSize(desk.finished()); + QTRY_VERIFY(desk.done); + QVERIFY(!desk.session.isProjectBusy()); + QVERIFY(desk.session.document().value() == before); +} + +void ProjectResizeTests::theImageSizeSheetScalesThePixelsAsOneStep() +{ + Desk desk; + desk.controller.imageSize(desk.finished()); + QCOMPARE(desk.dialog().windowTitle(), QString("Image Size")); + QVERIFY(desk.dialog().findChild<ImageSizeSheet *>()); + desk.type("imageWidth", "50"); + QTRY_VERIFY(desk.done); + QCOMPARE(desk.session.document().value().width, 50); + QCOMPARE(desk.session.document().value().height, 25); + QCOMPARE(desk.session.activeLayer().value().transform.size, QSizeF(5, 5)); + QCOMPARE(desk.session.history.undoName(), QString("Image Size")); + QVERIFY(!desk.session.isProjectBusy()); +} + +void ProjectResizeTests::aResizeThatFailsIsExplained() +{ + Desk desk; + // Stretched past what twice the size may hold. + LayerTransform wide = desk.session.activeLayer().value().transform; + wide.size = QSizeF(20'000, 10); + desk.session.beginTransform(); + desk.session.previewTransform(wide); + desk.session.commitTransform(); + const CanvasDocument before = desk.session.document().value(); + DialogDesk alerts; + alerts.replies = {"OK"}; + alerts.note = [&] { return QStringLiteral("busy %1").arg(desk.session.isProjectBusy()); }; + desk.controller.imageSize(desk.finished()); + desk.type("imageWidth", "200"); + QTRY_VERIFY(desk.done); + QCOMPARE(alerts.seen, (QStringList{QStringLiteral("alert|2|Couldn’t resize the image|%1|OK|busy 1") + .arg(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()))})); + QVERIFY(desk.session.document().value() == before); + QVERIFY(!desk.session.isProjectBusy()); + // The canvas's own failure, alike, under its own title. + alerts.replies = {"OK"}; + wide.origin = QPointF(999'000, 0); + wide.size = QSizeF(10, 10); + desk.session.beginTransform(); + desk.session.previewTransform(wide); + desk.session.commitTransform(); + desk.controller.canvasSize(desk.finished()); + desk.type("canvasWidth", "5000"); + QTRY_VERIFY(desk.done); + QCOMPARE(alerts.seen.size(), 2); + QVERIFY(alerts.seen.last().startsWith("alert|2|Couldn’t change canvas size|")); +} + +QTEST_MAIN(ProjectResizeTests) +#include "ProjectResizeTests.moc" diff --git a/tests/ProjectSessionTests.cpp b/tests/ProjectSessionTests.cpp new file mode 100644 index 0000000..8e18210 --- /dev/null +++ b/tests/ProjectSessionTests.cpp @@ -0,0 +1,304 @@ +#include "Document/EditorSession.h" +#include <QSignalSpy> +#include <QtTest> + +// `EditorSession+Projects`: snapshots out, projects in. +namespace { +ImportedImage pixels(int width, int height, const QString &name) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + return ImportedImage(image, image, name); +} + +ImportedImage gray(int width, int height, int level) +{ + QImage image(width, height, QImage::Format_Grayscale8); + image.fill(level); + return LayerMask::assetFrom(image); +} + +// Two layers in a folder, no field at its default. +ProjectSnapshot fixture() +{ + const QUuid folder = QUuid::createUuid(), lower = QUuid::createUuid(), upper = QUuid::createUuid(); + const QString lowerFile = uuidString(lower), upperFile = uuidString(upper); + ProjectSnapshot snapshot{.manifest = {.resolution = 300, .documentID = QUuid::createUuid(), .width = 320, .height = 200, + .activeLayerID = upper, .layers = {}}, + .images = {}}; + snapshot.manifest.layers = { + {.id = folder, .name = "Artwork", .isVisible = false, .transform = {.origin = {0, 0}, .size = {320, 200}}, .imageFile = std::nullopt, + .isGroup = true, .opacity = 1, .blendMode = LayerBlendMode::normal}, + {.id = lower, .name = "Lower", .isVisible = true, .transform = {.origin = {10, 20}, .size = {40, 30}, .rotation = 15, .flipX = true, + .sampling = LayerSampling::nearest}, + .imageFile = lowerFile + ".png", .parentID = folder, .isGroup = false, .opacity = 0.25, .blendMode = LayerBlendMode::multiply, + .maskFile = lowerFile + ".mask.png", .maskEnabled = false, + .maskPlacement = LayerTransform{.origin = {1, 2}, .size = {8, 6}}, .maskLinked = false}, + {.id = upper, .name = "Upper", .isVisible = false, .transform = {.origin = {-5, 7}, .size = {16, 8}, .flipY = true, + .sampling = LayerSampling::smooth}, + .imageFile = upperFile + ".png", .parentID = folder, .isGroup = false, .opacity = 0.75, .blendMode = LayerBlendMode::screen, + .maskFile = upperFile + ".mask.png", .maskEnabled = true, .maskSourceID = lower, .maskLinked = true}, + }; + // Files keep the names they came with; layers get renamed. + snapshot.images.insert({lower, pixels(40, 30, "IMG_0001")}); + snapshot.images.insert({upper, pixels(16, 8, "IMG_0002")}); + snapshot.masks.insert({lower, gray(8, 6, 64)}); + snapshot.masks.insert({upper, gray(16, 8, 200)}); + return snapshot; +} +} + +class ProjectSessionTests : public QObject { + Q_OBJECT +private slots: + void installingRebuildsEveryField(); + void aSnapshotGivesBackWhatWasInstalled(); + void installingStartsAFreshSession(); + void clearingLeavesNothingBehind(); + void aNewProjectIsOneEmptyLayerAndNoHistory(); + void applyingASizeIsOneNamedStepOnTheSameCanvas(); + void installingAndClearingFreeFileRequests(); +}; + +void ProjectSessionTests::installingRebuildsEveryField() +{ + const ProjectSnapshot snapshot = fixture(); + EditorSession session; + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^installed a project of 3 layers from \\S*art\\.comp$")); + session.installProject(snapshot, "/projects/art.comp"); + const CanvasDocument document = session.document().value(); + QCOMPARE(document.id, snapshot.manifest.documentID); + QCOMPARE(document.size(), QSizeF(320, 200)); + QCOMPARE(document.resolution, 300.0); + QCOMPARE(session.projectPath(), std::optional(QString("/projects/art.comp"))); + QCOMPARE(session.activeLayerID(), snapshot.manifest.activeLayerID); + QCOMPARE(int(document.layers.size()), 3); + for (size_t index = 0; index < 3; ++index) { + const ProjectLayerRecord &record = snapshot.manifest.layers[index]; + const ImageLayer &layer = document.layers[index]; + QCOMPARE(layer.id, record.id); + QCOMPARE(layer.name, record.name); + QCOMPARE(layer.isVisible, record.isVisible); + QCOMPARE(layer.transform, record.transform); + QCOMPARE(layer.parentID, record.parentID); + QCOMPARE(layer.isGroup, record.isGroup.value()); + QCOMPARE(layer.opacity, record.opacity.value()); + QCOMPARE(layer.blendMode, record.blendMode.value()); + QCOMPARE(layer.maskSourceID, record.maskSourceID); + QCOMPARE(layer.asset.has_value(), record.imageFile.has_value()); + QCOMPARE(layer.mask.has_value(), record.maskFile.has_value()); + } + // Pixels and masks are shared, not copied. + const ImageLayer &lower = document.layers[1], &upper = document.layers[2]; + QVERIFY(lower.asset.value().identity() == snapshot.images.at(lower.id).identity()); + QVERIFY(upper.asset.value().identity() == snapshot.images.at(upper.id).identity()); + QCOMPARE(lower.mask.value(), LayerMask(snapshot.masks.at(lower.id), false, LayerTransform{.origin = {1, 2}, .size = {8, 6}}, false)); + QCOMPARE(upper.mask.value(), LayerMask(snapshot.masks.at(upper.id), true, std::nullopt, true)); + // What a record leaves out takes Swift's defaults. + ProjectSnapshot bare = snapshot; + bare.manifest.resolution = std::nullopt; + for (ProjectLayerRecord &record : bare.manifest.layers) { + record.isGroup = std::nullopt; + record.opacity = std::nullopt; + record.blendMode = std::nullopt; + } + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^installed a project of 3 layers .*")); + session.installProject(bare, "bare.comp"); + QCOMPARE(session.projectPath(), std::optional(QString("bare.comp"))); + QCOMPARE(session.document().value().resolution, 72.0); + for (const ImageLayer &layer : session.document().value().layers) { + QVERIFY(!layer.isGroup); + QCOMPARE(layer.opacity, 1.0); + QCOMPARE(layer.blendMode, LayerBlendMode::normal); + } +} + +void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled() +{ + QVERIFY(!EditorSession().projectSnapshot().has_value()); + const ProjectSnapshot snapshot = fixture(); + EditorSession session; + session.installProject(snapshot, "art.comp"); + const ProjectSnapshot again = session.projectSnapshot().value(); + // The same manifest to the byte; pixels by identity. + QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded()); + QCOMPARE(again.manifest.version, qint64(7)); + QCOMPARE(int(again.images.size()), 2); + QCOMPARE(int(again.masks.size()), 2); + for (const auto &[id, image] : snapshot.images) + QVERIFY(again.images.at(id).identity() == image.identity()); + for (const auto &[id, mask] : snapshot.masks) + QVERIFY(again.masks.at(id).identity() == mask.identity()); + // The active layer of the moment goes into the manifest. + session.selectLayer(snapshot.manifest.layers[1].id); + QCOMPARE(session.projectSnapshot().value().manifest.activeLayerID, std::optional(snapshot.manifest.layers[1].id)); + session.selectLayer(std::nullopt); + QCOMPARE(session.projectSnapshot().value().manifest.activeLayerID, std::nullopt); + // A canvas without layers still has a snapshot. + EditorSession bare; + bare.createDocument(7, 5); + const ProjectSnapshot blank = bare.projectSnapshot().value(); + QCOMPARE(blank.manifest.documentID, bare.document().value().id); + QCOMPARE(blank.manifest.width, qint64(7)); + QCOMPARE(blank.manifest.height, qint64(5)); + QCOMPARE(blank.manifest.resolution, std::optional(72.0)); + QVERIFY(blank.manifest.layers.empty() && blank.images.empty() && blank.masks.empty()); + QCOMPARE(blank.manifest.activeLayerID, std::nullopt); +} + +void ProjectSessionTests::installingStartsAFreshSession() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + session.createDocument(64, 64); + session.addGroup(); + const QUuid old = session.activeLayerID().value(); + session.insert(pixels(8, 8, "Old")); + session.selectLayer(old); + session.toggleGroupExpansion(old); + session.selectLayer(session.document().value().layers.back().id); + session.beginTransform(); + session.setRenamingLayerID(old); + session.zoom(3); + QVERIFY(session.transformEdit().has_value() && !session.collapsedGroupIDs().isEmpty() && session.history.undoCount() > 0); + const ProjectSnapshot snapshot = fixture(); + session.installProject(snapshot, "art.comp"); + QVERIFY(!session.transformEdit().has_value()); + QVERIFY(session.collapsedGroupIDs().isEmpty()); + QVERIFY(!session.renamingLayerID().has_value()); + QCOMPARE(session.history.undoCount(), 0); + QVERIFY(!session.canUndo() && !session.canRedo() && !session.isModified()); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{snapshot.manifest.activeLayerID.value()}); + // The view fits the canvas that came in. + QVERIFY(session.viewport.followsFit()); + EditorSession fitted; + fitted.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + fitted.createDocument(320, 200); + QCOMPARE(session.viewport.zoom(), fitted.viewport.zoom()); + // The first edit after installing is the first step. + session.addBlankLayer(); + QCOMPARE(session.history.undoCount(), 1); + QVERIFY(session.isModified()); + // A project saved with nothing selected opens that way. + ProjectSnapshot unselected = fixture(); + unselected.manifest.activeLayerID = std::nullopt; + QVERIFY(session.activeLayerID().has_value()); + session.installProject(unselected, "unselected.comp"); + QVERIFY(!session.activeLayerID().has_value() && session.selectedLayerIDs().isEmpty()); + QCOMPARE(int(session.document().value().layers.size()), 3); + // An empty canvas is a project too. + const ProjectSnapshot empty{.manifest = {.documentID = QUuid::createUuid(), .width = 7, .height = 5, .activeLayerID = std::nullopt, .layers = {}}, + .images = {}}; + session.installProject(empty, "empty.comp"); + QCOMPARE(session.document().value().id, empty.manifest.documentID); + QCOMPARE(session.document().value().size(), QSizeF(7, 5)); + QVERIFY(session.document().value().layers.empty()); +} + +void ProjectSessionTests::clearingLeavesNothingBehind() +{ + EditorSession session; + session.installProject(fixture(), "art.comp"); + session.toggleGroupExpansion(session.document().value().layers.front().id); + session.selectLayer(session.document().value().layers[1].id); + session.toggleLayerVisibility(session.document().value().layers.front().id); + session.beginTransform(); + session.setRenamingLayerID(QUuid::createUuid()); + session.clearProject(); + QVERIFY(!session.document().has_value() && !session.activeLayerID().has_value() && session.selectedLayerIDs().isEmpty()); + QVERIFY(!session.projectPath().has_value() && !session.renamingLayerID().has_value()); + QVERIFY(!session.transformEdit().has_value() && session.collapsedGroupIDs().isEmpty()); + QCOMPARE(session.history.undoCount(), 0); + QVERIFY(!session.isModified() && !session.projectSnapshot().has_value()); +} + +void ProjectSessionTests::aNewProjectIsOneEmptyLayerAndNoHistory() +{ + EditorSession session; + session.installProject(fixture(), "art.comp"); + session.createNewProject(640, 480); + QCOMPARE(session.document().value().size(), QSizeF(640, 480)); + QCOMPARE(int(session.document().value().layers.size()), 1); + QVERIFY(!session.projectPath().has_value()); + // The canvas itself is the one step, as in Swift. + QCOMPARE(session.history.undoCount(), 1); + const std::optional<CanvasDocument> kept = session.document(); + // Refused sizes and busy sessions keep what they have. + for (const auto &[width, height] : {std::pair(0, 10), std::pair(10, 0), std::pair(30'001, 10), std::pair(10, 30'001)}) { + session.createNewProject(width, height); + QCOMPARE(session.document(), kept); + } + session.setIsImporting(true); + session.createNewProject(10, 10); + session.setIsImporting(false); + session.setIsProjectBusy(true); + session.createNewProject(10, 10); + QCOMPARE(session.document(), kept); + session.setIsProjectBusy(false); + // Both ends of both sides are taken. + session.createNewProject(30'000, 1); + QCOMPARE(session.document().value().size(), QSizeF(30'000, 1)); + session.createNewProject(1, 30'000); + QCOMPARE(session.document().value().size(), QSizeF(1, 30'000)); +} + +void ProjectSessionTests::applyingASizeIsOneNamedStepOnTheSameCanvas() +{ + EditorSession session; + session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); + const ProjectSnapshot snapshot = fixture(); + session.applyImageSize(snapshot); + QVERIFY(!session.document().has_value()); + session.installProject(snapshot, "art.comp"); + const std::optional<CanvasDocument> before = session.document(); + // Another canvas's pixels are refused in silence. + ProjectSnapshot other = fixture(); + QSignalSpy changes(&session, &EditorSession::changed); + session.applyDocumentSize(other, "Canvas Size"); + QCOMPARE(session.document(), before); + QCOMPARE(int(changes.count()), 0); + QCOMPARE(session.history.undoCount(), 0); + // The same canvas, half as large, with one layer gone. + ProjectSnapshot smaller = snapshot; + smaller.manifest.width = 160; + smaller.manifest.height = 100; + smaller.manifest.resolution = 150; + smaller.manifest.layers.pop_back(); + // What the snapshot calls active is not the session's affair. + smaller.manifest.activeLayerID = smaller.manifest.layers.front().id; + session.zoom(3); + session.applyDocumentSize(smaller, "Canvas Size"); + QCOMPARE(session.activeLayerID(), snapshot.manifest.activeLayerID); + QCOMPARE(session.projectPath(), std::optional(QString("art.comp"))); + QCOMPARE(session.document().value().size(), QSizeF(160, 100)); + QCOMPARE(session.document().value().resolution, 150.0); + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.history.undoCount(), 1); + QCOMPARE(session.history.undoName(), QString("Canvas Size")); + QVERIFY(session.viewport.followsFit()); + session.undo(); + QCOMPARE(session.document(), before); + session.redo(); + QCOMPARE(session.document().value().size(), QSizeF(160, 100)); + session.undo(); + session.applyImageSize(smaller); + QCOMPARE(session.history.undoName(), QString("Image Size")); +} + +void ProjectSessionTests::installingAndClearingFreeFileRequests() +{ + for (const bool clears : {false, true}) { + EditorSession session; + session.setRenamingLayerID(QUuid::createUuid()); + bool ran = false; + session.waitForFileRequest([&] { ran = true; }); + if (clears) + session.clearProject(); + else + session.installProject(fixture(), "art.comp"); + QVERIFY(QTest::qWaitFor([&] { return ran; }, 5'000)); + } +} + +QTEST_GUILESS_MAIN(ProjectSessionTests) +#include "ProjectSessionTests.moc" diff --git a/tests/ProjectTests.cpp b/tests/ProjectTests.cpp new file mode 100644 index 0000000..38d9bd7 --- /dev/null +++ b/tests/ProjectTests.cpp @@ -0,0 +1,417 @@ +#include "Document/LayerMask.h" +#include "ProjectFixtures.h" +#include <QImageWriter> +#include <QTemporaryDir> +#include <QtTest> +#include <limits> + +class ProjectTests : public QObject { + Q_OBJECT +private slots: + void projectRoundTripSurvivesPackageMove(); + void masksAndLargeImagesRoundTrip(); + void overwriteReplacesPackageAndDropsRemovedAssets(); + void failedSavePreservesPreviouslySavedPackage(); + void unsupportedCorruptAndUnsafeMetadataAreRejected(); + void missingAndForeignImagesAreRejected(); + void filesMustBePlainFilesInsideTheProject(); + void sizeLimitsHoldOnSaveAndLoad(); + void numbersThatAreNoNumbersAreRefusedOnSave(); + void savesAndLoadsAreLogged(); +}; + +void ProjectTests::projectRoundTripSurvivesPackageMove() +{ + QTemporaryDir root; + const ProjectSnapshot before = twoLayers(); + const QString original = root.filePath("Original.comp"), moved = root.filePath("Moved.comp"); + ProjectStore::save(before, original); + QVERIFY(QDir().rename(original, moved)); + const ProjectSnapshot loaded = ProjectStore::load(moved); + QCOMPARE(loaded.manifest.encoded(), before.manifest.encoded()); + QCOMPARE(loaded.manifest.documentID, before.manifest.documentID); + QCOMPARE(loaded.manifest.resolution, std::optional<double>(300)); + QCOMPARE(loaded.manifest.activeLayerID, before.manifest.activeLayerID); + QCOMPARE(loaded.manifest.layers[0].name, QStringLiteral("Paint & sky \U0001F324")); + QCOMPARE(loaded.manifest.layers[0].isVisible, false); + QCOMPARE(loaded.manifest.layers[0].transform, before.manifest.layers[0].transform); + // The blank layer has no pixels; the image layer does. + QCOMPARE(int(loaded.images.size()), 1); + QVERIFY(loaded.masks.empty()); + const ImportedImage &pixels = loaded.images.at(before.manifest.layers[0].id); + QCOMPARE(pixels.image(), halfRed()); + QCOMPARE(pixels.image().format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(pixels.thumbnail, halfRed()); + // Up to 96 pixels the thumbnail is the image itself. + QCOMPARE(pixels.thumbnail.cacheKey(), pixels.image().cacheKey()); + QCOMPARE(pixels.name, QStringLiteral("Paint & sky \U0001F324")); + // The package holds the manifest and the images, nothing else. + QCOMPARE(QDir(moved).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), (QStringList{"images", "manifest.json"})); + QCOMPARE(QDir(moved + "/images").entryList(QDir::Files), QStringList{uuidString(before.manifest.layers[0].id) + ".png"}); +} + +void ProjectTests::masksAndLargeImagesRoundTrip() +{ + QTemporaryDir root; + ProjectSnapshot masked = twoLayers(); + const QUuid id = masked.manifest.layers[0].id; + QImage soft = gray(40, 20, 0); + for (int y = 0; y < 20; ++y) { + for (int x = 0; x < 40; ++x) + soft.scanLine(y)[x] = uchar(x * 6 + y); + } + masked.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + masked.manifest.layers[0].maskEnabled = false; + masked.manifest.layers[0].maskLinked = false; + masked.manifest.layers[0].maskPlacement = LayerTransform{.origin = {3, 4}, .size = {50, 60}, .rotation = 10}; + masked.masks.insert({id, asset(soft, "mask")}); + // A long, thin image: its thumbnail keeps one pixel across. + const QUuid wideID = QUuid::createUuid(); + masked.manifest.layers.push_back({.id = wideID, .name = "Wide", .isVisible = true, .transform = {.origin = {0, 0}, .size = {300, 2}}, + .imageFile = uuidString(wideID) + ".png"}); + masked.images.insert({wideID, asset(noise(300, 2, 5), "wide")}); + // At 96 pixels the thumbnail is still the image. + const QUuid edgeID = QUuid::createUuid(); + masked.manifest.layers.push_back({.id = edgeID, .name = "Edge", .isVisible = true, .transform = {.origin = {0, 0}, .size = {96, 10}}, + .imageFile = uuidString(edgeID) + ".png"}); + masked.images.insert({edgeID, asset(noise(96, 10, 6), "edge")}); + ProjectStore::save(masked, root.filePath("Masked.comp")); + const ProjectSnapshot loaded = ProjectStore::load(root.filePath("Masked.comp")); + QCOMPARE(loaded.manifest.encoded(), masked.manifest.encoded()); + QCOMPARE(loaded.masks.at(id).image(), soft); + QCOMPARE(loaded.masks.at(id).image().format(), QImage::Format_Grayscale8); + const LayerMask mask = loaded.mask(loaded.manifest.layers[0]).value(); + QCOMPARE(mask.isEnabled, false); + QCOMPARE(mask.isLinked, false); + QCOMPARE(mask.placement.value(), (LayerTransform{.origin = {3, 4}, .size = {50, 60}, .rotation = 10})); + QCOMPARE(loaded.images.at(wideID).image(), noise(300, 2, 5)); + QCOMPARE(loaded.images.at(wideID).thumbnail.size(), QSize(96, 1)); + QCOMPARE(loaded.images.at(wideID).thumbnail.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(loaded.images.at(edgeID).thumbnail.cacheKey(), loaded.images.at(edgeID).image().cacheKey()); + QVERIFY(loaded.images.at(wideID).thumbnail.cacheKey() != loaded.images.at(wideID).image().cacheKey()); +} + +void ProjectTests::overwriteReplacesPackageAndDropsRemovedAssets() +{ + QTemporaryDir root; + const QString destination = root.filePath("Overwrite.comp"); + ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, destination); + QCOMPARE(QDir(destination + "/images").entryList(QDir::Files).size(), 1); + snapshot.manifest.layers.erase(snapshot.manifest.layers.begin()); + snapshot.images.clear(); + ProjectStore::save(snapshot, destination); + const ProjectSnapshot loaded = ProjectStore::load(destination); + QVERIFY(loaded.images.empty()); + QCOMPARE(int(loaded.manifest.layers.size()), 1); + QVERIFY(QDir(destination + "/images").entryList(QDir::Files).isEmpty()); + // Nothing is left beside the project: no staging, no copy. + QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), QStringList{"Overwrite.comp"}); + // A plain file there is replaced too; trailing slashes pass. + const QString file = root.filePath("WasAFile.comp"); + overwrite(file, "not a project"); + ProjectStore::save(snapshot, file + "/"); + QCOMPARE(int(ProjectStore::load(file).manifest.layers.size()), 1); + QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), (QStringList{"Overwrite.comp", "WasAFile.comp"})); +} + +void ProjectTests::failedSavePreservesPreviouslySavedPackage() +{ + QTemporaryDir root; + const ProjectSnapshot snapshot = twoLayers(); + const QString path = root.filePath("Safe.comp"); + ProjectStore::save(snapshot, path); + const QByteArray original = contents(path + "/manifest.json"); + const QStringList beside{"Safe.comp"}; + ProjectSnapshot future = snapshot; + future.manifest.version = 99; + try { + ProjectStore::save(future, path); + QFAIL("an unsupported version was saved"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::version); + QCOMPARE(error.version, std::optional(qint64(99))); + } + // Pixels named but missing; a mask that is no mask. + ProjectSnapshot hollow = snapshot; + hollow.images.clear(); + QCOMPARE(projectError([&] { ProjectStore::save(hollow, path); }), std::optional(ProjectError::Kind::missingImage)); + ProjectSnapshot coloured = snapshot; + coloured.manifest.layers[1].maskFile = uuidString(coloured.manifest.layers[1].id) + ".mask.png"; + QCOMPARE(projectError([&] { ProjectStore::save(coloured, path); }), std::optional(ProjectError::Kind::missingImage)); + coloured.masks.insert({coloured.manifest.layers[1].id, asset(halfRed(), "not gray")}); + QCOMPARE(projectError([&] { ProjectStore::save(coloured, path); }), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(contents(path + "/manifest.json"), original); + QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), beside); + // No folder to write in: a file, a missing one. + overwrite(root.filePath("not-a-directory"), "x"); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not create .*")); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("not-a-directory/CannotSave.comp")), std::runtime_error); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not create .*")); + QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("missing/CannotSave.comp")), std::runtime_error); + QVERIFY(!QFileInfo::exists(root.filePath("missing"))); + QCOMPARE(contents(path + "/manifest.json"), original); + QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2); +} + +void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected() +{ + QTemporaryDir root; + const ProjectSnapshot snapshot = twoLayers(); + const QString path = root.filePath("Invalid.comp"), metadata = path + "/manifest.json"; + ProjectStore::save(snapshot, path); + const QByteArray good = contents(metadata); + const auto loading = [&](const QByteArray &manifest) { + overwrite(metadata, manifest); + return projectError([&] { ProjectStore::load(path); }); + }; + // A version out of range is named whatever else fails. + const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})"; + for (const qint64 strange : {qint64(42), qint64(8), qint64(0)}) { + try { + overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange))); + ProjectStore::load(path); + QFAIL("a strange version opened"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::version); + QCOMPARE(error.version, std::optional(strange)); + } + } + // Versions in range go on to decode the rest. + QCOMPARE(loading(QByteArray(future).replace("42", "7")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid)); + // Another format is invalid whatever version it claims. + QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 0")), std::optional(ProjectError::Kind::version)); + // Swift's Int holds 64 bits, each one: no double between. + const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min(); + for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) { + try { + overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": " + QByteArray::number(wild))); + ProjectStore::load(path); + QFAIL("a wild version opened"); + } catch (const ProjectError &error) { + QCOMPARE(error.kind, ProjectError::Kind::version); + QCOMPARE(error.version, std::optional(wild)); + QVERIFY(QString(error.what()).contains(QString::number(wild))); + } + } + QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge)); + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid)); + // Declared: Qt rounds a token just past Int64's minimum. + try { + overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": -9223372036854775809")); + ProjectStore::load(path); + QFAIL("a version below every integer opened"); + } catch (const ProjectError &error) { + QCOMPARE(error.version, std::optional(least)); + } + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid)); + // A file name that climbs out of the project. + const QByteArray name = (uuidString(snapshot.manifest.layers[0].id) + ".png").toUtf8(); + QCOMPARE(loading(QByteArray(good).replace(name, "../../outside.png")), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(loading(good), std::nullopt); + // Not a folder, no manifest inside, no such place. + QCOMPARE(projectError([&] { ProjectStore::load(metadata); }), std::optional(ProjectError::Kind::invalid)); + QVERIFY(QFile::remove(metadata)); + QCOMPARE(projectError([&] { ProjectStore::load(path); }), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Nowhere.comp")); }), std::optional(ProjectError::Kind::invalid)); +} + +void ProjectTests::missingAndForeignImagesAreRejected() +{ + QTemporaryDir root; + ProjectSnapshot snapshot = twoLayers(); + const QUuid id = snapshot.manifest.layers[0].id; + snapshot.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + snapshot.masks.insert({id, asset(gray(8, 8, 200), "mask")}); + const QString path = root.filePath("Images.comp"); + ProjectStore::save(snapshot, path); + const QString image = path + "/images/" + uuidString(id) + ".png", mask = path + "/images/" + uuidString(id) + ".mask.png"; + const QByteArray goodImage = contents(image), goodMask = contents(mask); + const auto written = [](const QImage &pixels, const char *format) { + QByteArray data; + QBuffer buffer(&data); + QImageWriter writer(&buffer, format); + writer.write(pixels); + return data; + }; + const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); }; + QVERIFY(QFile::remove(image)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + // A JPEG named PNG; sixteen-bit channels; bytes of no image. + overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGB888), "jpeg")); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGBA64), "png")); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + overwrite(image, "\x89PNG broken"); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + // A PNG cut short: the header reads, the pixels fail. + overwrite(image, goodImage.left(goodImage.size() * 6 / 10)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + // Any eight-bit PNG serves as pixels; masks must be gray. + overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGB888), "png")); + QCOMPARE(loading(), std::nullopt); + QCOMPARE(ProjectStore::load(path).images.at(id).image().format(), QImage::Format_RGBA8888_Premultiplied); + overwrite(image, goodImage); + overwrite(mask, written(halfRed(), "png")); + QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid)); + QVERIFY(QFile::remove(mask)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage)); + overwrite(mask, goodMask); + QCOMPARE(loading(), std::nullopt); +} + +void ProjectTests::filesMustBePlainFilesInsideTheProject() +{ + QTemporaryDir root; + const ProjectSnapshot snapshot = twoLayers(); + const QString path = root.filePath("Links.comp"); + ProjectStore::save(snapshot, path); + const QString image = path + "/images/" + uuidString(snapshot.manifest.layers[0].id) + ".png", metadata = path + "/manifest.json"; + const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); }; + // Links leading out, within, and nowhere; then a folder. + QVERIFY(QFile::rename(image, root.filePath("outside.png"))); + QVERIFY(QFile::link(root.filePath("outside.png"), image)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid)); + QVERIFY(QFile::remove(image)); + QVERIFY(QFile::copy(root.filePath("outside.png"), path + "/images/real.png")); + QVERIFY(QFile::link(path + "/images/real.png", image)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + QVERIFY(QFile::remove(image)); + QVERIFY(QFile::link(root.filePath("nothing.png"), image)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid)); + QVERIFY(QFile::remove(image)); + QVERIFY(QDir().mkdir(image)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + QVERIFY(QDir().rmdir(image)); + QVERIFY(QFile::copy(root.filePath("outside.png"), image)); + QCOMPARE(loading(), std::nullopt); + // The manifest is held to the same rules. + QVERIFY(QFile::rename(metadata, root.filePath("manifest.json"))); + QVERIFY(QFile::link(root.filePath("manifest.json"), metadata)); + QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid)); + // A manifest that may not be read. + QVERIFY(QFile::remove(metadata)); + QVERIFY(QFile::rename(root.filePath("manifest.json"), metadata)); + QVERIFY(QFile::setPermissions(metadata, QFileDevice::Permissions())); + QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid)); + QVERIFY(QFile::setPermissions(metadata, QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + QVERIFY(QFile::rename(metadata, root.filePath("manifest.json"))); + QVERIFY(QFile::link(root.filePath("manifest.json"), metadata)); + // A project reached through a link is still itself. + QVERIFY(QFile::remove(metadata)); + QVERIFY(QFile::rename(root.filePath("manifest.json"), metadata)); + QVERIFY(QFile::link(path, root.filePath("Shortcut.comp"))); + QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Shortcut.comp")); }), std::nullopt); +} + +void ProjectTests::sizeLimitsHoldOnSaveAndLoad() +{ + QTemporaryDir root; + const ProjectSnapshot snapshot = twoLayers(); + const QString path = root.filePath("Limits.comp"); + ProjectStore::save(snapshot, path); + const QUuid id = snapshot.manifest.layers[0].id; + const QString image = path + "/images/" + uuidString(id) + ".png", metadata = path + "/manifest.json"; + const QByteArray goodImage = contents(image), goodManifest = contents(metadata); + const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); }; + // A manifest over 4 MiB, on disk and in memory. + overwrite(metadata, goodManifest + QByteArray(4 * 1024 * 1024, ' ')); + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + overwrite(metadata, goodManifest + QByteArray(4 * 1024 * 1024 - goodManifest.size(), ' ')); + QCOMPARE(loading(), std::nullopt); + overwrite(metadata, goodManifest); + ProjectSnapshot wordy = snapshot; + for (int index = 0; index < 270; ++index) + wordy.manifest.layers.push_back({.id = QUuid::createUuid(), .name = QString(16'000, 'n'), .isVisible = true, + .transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt}); + QVERIFY(wordy.manifest.encoded().size() > 4 * 1024 * 1024); + QCOMPARE(projectError([&] { ProjectStore::save(wordy, root.filePath("Wordy.comp")); }), std::optional(ProjectError::Kind::tooLarge)); + // An encoded image over 512 MiB, as a sparse file. + { + QFile sparse(image); + QVERIFY(sparse.open(QIODevice::ReadWrite)); + QVERIFY(sparse.resize(512ll * 1024 * 1024 + 1)); + } + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + // At 512 MiB the padded PNG still opens. + { + QFile sparse(image); + QVERIFY(sparse.open(QIODevice::ReadWrite)); + QVERIFY(sparse.resize(512ll * 1024 * 1024)); + } + QCOMPARE(loading(), std::nullopt); + overwrite(image, goodImage); + // A side over 30,000 pixels, read from the header alone. + const auto png = [](const QImage &pixels) { + QByteArray data; + QBuffer buffer(&data); + QImageWriter(&buffer, "png").write(pixels); + return data; + }; + overwrite(image, png(gray(30'001, 1, 9))); + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + overwrite(image, png(gray(1, 30'001, 9))); + QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge)); + overwrite(image, png(gray(30'000, 1, 9))); + QCOMPARE(loading(), std::nullopt); + overwrite(image, png(gray(1, 30'000, 9))); + QCOMPARE(loading(), std::nullopt); + overwrite(image, goodImage); + ProjectSnapshot long1 = snapshot; + long1.images.insert_or_assign(id, asset(gray(30'001, 1, 9).convertToFormat(QImage::Format_RGBA8888_Premultiplied), "long")); + QCOMPARE(projectError([&] { ProjectStore::save(long1, root.filePath("Long.comp")); }), std::optional(ProjectError::Kind::tooLarge)); + // Masks share 100 million pixels, apart from the images'. + ProjectSnapshot heavy = snapshot; + const QUuid second = heavy.manifest.layers[1].id; + heavy.manifest.layers[0].maskFile = uuidString(id) + ".mask.png"; + heavy.manifest.layers[1].maskFile = uuidString(second) + ".mask.png"; + heavy.masks.insert({id, asset(gray(10'000, 10'000, 255), "whole budget")}); + heavy.masks.insert({second, asset(gray(1, 1, 255), "one too many")}); + QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge)); + // 99,990,000 and 10,000 pixels fill the budget exactly. + heavy.masks.insert_or_assign(id, asset(gray(10'000, 9'999, 255), "nearly all")); + heavy.masks.insert_or_assign(second, asset(gray(100, 100, 255), "the rest")); + QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::nullopt); + QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::nullopt); + // On load the same sum comes from the files' headers. + const QString firstMask = root.filePath("Heavy.comp/images/") + uuidString(id) + ".mask.png"; + overwrite(firstMask, png(gray(10'000, 10'000, 255))); + QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge)); +} + +void ProjectTests::numbersThatAreNoNumbersAreRefusedOnSave() +{ + QTemporaryDir root; + const auto saving = [&](const ProjectSnapshot &snapshot) { return projectError([&] { ProjectStore::save(snapshot, root.filePath("Numbers.comp")); }); }; + for (const double value : {std::nan(""), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) { + ProjectSnapshot resolution = twoLayers(), opacity = twoLayers(), origin = twoLayers(); + resolution.manifest.resolution = value; + opacity.manifest.layers[1].opacity = value; + origin.manifest.layers[1].transform.origin.setX(value); + QCOMPARE(saving(resolution), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(saving(opacity), std::optional(ProjectError::Kind::invalid)); + QCOMPARE(saving(origin), std::optional(ProjectError::Kind::invalid)); + } + QVERIFY(!QFileInfo::exists(root.filePath("Numbers.comp"))); +} + +void ProjectTests::savesAndLoadsAreLogged() +{ + QTemporaryDir root; + const QString path = root.filePath("Logged.comp"); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("saved \".*Logged.comp\" with 2 layers and 1 images")); + ProjectStore::save(twoLayers(), path); + QTest::ignoreMessage(QtInfoMsg, QRegularExpression("loaded \".*Logged.comp\" with 2 layers and 1 images")); + ProjectStore::load(path); +} + +QTEST_MAIN(ProjectTests) +#include "ProjectTests.moc" diff --git a/tests/ProjectWorkspaceFlowTests.cpp b/tests/ProjectWorkspaceFlowTests.cpp new file mode 100644 index 0000000..01f7d0d --- /dev/null +++ b/tests/ProjectWorkspaceFlowTests.cpp @@ -0,0 +1,460 @@ +#include "DialogDesk.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectStore.h" +#include <QSemaphore> +#include <QTemporaryDir> +#include <QThreadPool> +#include <QtTest> + +// The workspace's flows that open dialogs or take time. +namespace { +const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save"); + +// A project of `layers` blank layers saved at `path`. +void store(const QString &path, int layers) +{ + EditorSession session; + session.createDocument(8, 6); + for (int index = 0; index < layers; ++index) + session.addBlankLayer(); + ProjectStore::save(session.projectSnapshot().value(), path); +} + +// Unsaved work in that tab. +void paint(ProjectTab &tab) +{ + tab.session.createDocument(8, 6); + tab.session.addBlankLayer(); +} + +QUrl picture(const QTemporaryDir &folder, const QString &name) +{ + QImage image(4, 2, QImage::Format_RGBA8888); + image.fill(Qt::green); + if (!image.save(folder.filePath(name + ".png"))) + throw std::runtime_error("the fixture could not be written"); + return QUrl::fromLocalFile(folder.filePath(name + ".png")); +} + +QStringList titles(const ProjectWorkspace &workspace) +{ + QStringList result; + for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs()) + result << tab->title() + (tab->id == workspace.selectedID() ? "*" : ""); + return result; +} + +std::optional<bool> answered(const std::function<void(std::function<void(bool)>)> &call) +{ + std::optional<bool> result; + call([&](bool value) { result = value; }); + // An answer that never comes stays empty and fails. + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} +} + +class ProjectWorkspaceFlowTests : public QObject { + Q_OBJECT +private slots: + void aProjectOpensInATabOfItsOwn(); + void aProjectThatIsOpenIsBroughtToTheFront(); + void aProjectThatFailsToOpenLeavesNoTab(); + void closingATabAsksAboutItsWork(); + void quittingAsksTabByTabAndStopsAtCancel(); + void closingTheWindowLeavesOneFreshTab(); + void dropsWaitUntilTheWorkspaceIsFree(); + void aControllerInATabHandsOverToTheWorkspace(); + void aWorkspaceThatEndsMidwayCallsNobody(); + void aTabRemovedMidImportStillEndsTheDrop(); + void aProjectThatMovedAwayIsNoMatchForAnotherMissingOne(); + void everyCallIsWholeWithoutACompletion(); +}; + +void ProjectWorkspaceFlowTests::aProjectOpensInATabOfItsOwn() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 2); + store(folder.filePath("Second.comp"), 1); + ProjectWorkspace workspace; + QWidget window; + workspace.window = &window; + DialogDesk desk; + bool managedMeanwhile = false; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { managedMeanwhile = managedMeanwhile || workspace.isManaging(); }); + // The single empty tab makes way for the project. + const QUuid empty = workspace.current().id; + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Trip*"})); + QVERIFY(workspace.current().id != empty && managedMeanwhile && !workspace.isManaging()); + QCOMPARE(int(workspace.current().session.document().value().layers.size()), 2); + QVERIFY(workspace.current().controller.workspace == &workspace && workspace.current().controller.window == &window); + // Chosen in a panel, a second project gets its tab. + desk.note = [&] { return QString(workspace.isManaging() ? "managing" : "idle") + (workspace.canSwitch() ? ", free" : ", held"); }; + desk.replies = {"<cancel>", folder.filePath("Second.comp")}; + QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(false)); + QCOMPARE(titles(workspace), (QStringList{"Trip*"})); + QVERIFY(!workspace.isManaging()); + QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(true)); + QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||managing, held", "panel|Open Project|open|folder|||managing, held"})); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Second*"})); + // An empty tab in front of others is no placeholder. + workspace.newCanvas(); + store(folder.filePath("Third.comp"), 1); + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Third.comp"), done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"})); + // While it manages, nothing else begins. + workspace.current().session.setIsProjectBusy(true); + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(false)); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"})); +} + +void ProjectWorkspaceFlowTests::aProjectThatIsOpenIsBroughtToTheFront() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 2); + QVERIFY(QFile::link(folder.filePath("Trip.comp"), folder.filePath("Link.comp"))); + ProjectWorkspace workspace; + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(true)); + workspace.newCanvas(); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Untitled 2*"})); + // By a link, with a slash: still the open project. + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Link.comp") + "/", done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Trip*", "Untitled 2"})); + QVERIFY(!workspace.isManaging()); +} + +void ProjectWorkspaceFlowTests::aProjectThatFailsToOpenLeavesNoTab() +{ + QTemporaryDir folder; + ProjectWorkspace workspace; + const QUuid empty = workspace.current().id; + DialogDesk desk; + desk.replies = {"OK"}; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), 1); + QCOMPARE(titles(workspace), (QStringList{"Untitled*"})); + QVERIFY(workspace.current().id == empty && !workspace.isManaging()); +} + +void ProjectWorkspaceFlowTests::closingATabAsksAboutItsWork() +{ + ProjectWorkspace workspace; + QWidget window; + workspace.window = &window; + paint(workspace.current()); + const QUuid painted = workspace.current().id; + const QUuid blank = workspace.addTab().id; + DialogDesk desk; + desk.note = [&] { return workspace.isManaging() ? QStringLiteral("managing") : QStringLiteral("idle"); }; + desk.replies = {"Cancel", "Don’t Save"}; + bool done = false; + // A tab behind the front can be closed too. + workspace.close(painted, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen, (QStringList{question.arg("Untitled") + "|managing"})); + QCOMPARE(titles(workspace), (QStringList{"Untitled", "Untitled 2*"})); + QVERIFY(!workspace.isManaging() && workspace.tabs()[0]->controller.window == &window); + done = false; + workspace.close(painted, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(titles(workspace), (QStringList{"Untitled 2*"})); + // Nothing unsaved: no question. A stranger: nothing at all. + done = false; + workspace.close(QUuid::createUuid(), [&] { done = true; }); + QTRY_VERIFY(done); + // A busy project in front holds every tab. + const QUuid extra = workspace.addTab(false).id; + workspace.current().session.setIsProjectBusy(true); + done = false; + workspace.close(blank, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 2); + workspace.current().session.setIsProjectBusy(false); + workspace.close(extra); + QTRY_COMPARE(int(workspace.tabs().size()), 1); + workspace.close(blank); + QTRY_COMPARE(titles(workspace), (QStringList{"Untitled 4*"})); + QCOMPARE(desk.seen.size(), 2); + QVERIFY(!workspace.isManaging()); +} + +void ProjectWorkspaceFlowTests::quittingAsksTabByTabAndStopsAtCancel() +{ + ProjectWorkspace workspace; + paint(workspace.current()); + const QUuid first = workspace.current().id; + paint(workspace.addTab()); + const QUuid second = workspace.selectedID(); + paint(workspace.addTab()); + workspace.select(second); + // The window arrives late: each asked controller still gets it. + QWidget window; + workspace.window = &window; + QUuid announced; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { announced = workspace.selectedID(); }); + DialogDesk desk; + // Each tab asked about comes forward and says so. + desk.note = [&] { + return workspace.current().title() + (workspace.isManaging() ? " managing" : " idle") + + (announced == workspace.selectedID() && workspace.current().controller.window == &window ? "" : " stale"); + }; + desk.replies = {"Don’t Save", "Cancel"}; + QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(false)); + QCOMPARE(desk.seen, (QStringList{question.arg("Untitled") + "|Untitled 2 managing", question.arg("Untitled") + "|Untitled managing"})); + QCOMPARE(workspace.selectedID(), first); + QVERIFY(!workspace.isManaging()); + desk.replies = {"Don’t Save", "Don’t Save", "Don’t Save"}; + QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(true)); + QCOMPARE(desk.seen.size(), 5); + QCOMPARE(desk.seen.mid(2).join(";").count("managing"), 3); + QVERIFY(!workspace.isManaging()); + // A busy project in front refuses the quit outright. + workspace.current().session.setIsProjectBusy(true); + QCOMPARE(answered([&](auto done) { workspace.confirmQuit(done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), 5); +} + +void ProjectWorkspaceFlowTests::closingTheWindowLeavesOneFreshTab() +{ + ProjectWorkspace workspace; + paint(workspace.current()); + workspace.addTab(); + QWidget window; + window.show(); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + workspace.closeWindow(&window); + QTRY_COMPARE(desk.seen.size(), 1); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(window.isVisible()); + QCOMPARE(int(workspace.tabs().size()), 2); + workspace.closeWindow(&window); + QTRY_VERIFY(!window.isVisible()); + QCOMPARE(titles(workspace), (QStringList{"Untitled 3*"})); + QVERIFY(!workspace.current().session.document().has_value()); +} + +void ProjectWorkspaceFlowTests::dropsWaitUntilTheWorkspaceIsFree() +{ + QTemporaryDir folder; + store(folder.filePath("Dropped.comp"), 1); + ProjectWorkspace workspace; + workspace.current().session.setIsProjectBusy(true); + bool done = false; + // Picture, project, picture: the project gets its own tab. + workspace.receive({picture(folder, "One"), QUrl::fromLocalFile(folder.filePath("Dropped.comp")), picture(folder, "Two")}, + std::nullopt, QPointF(3, 1), [&] { done = true; }); + QTest::qWait(120); + QVERIFY(!done && !workspace.isManaging()); + QCOMPARE(titles(workspace), (QStringList{"Untitled*"})); + workspace.current().session.setIsProjectBusy(false); + QTRY_VERIFY(done); + QCOMPARE(titles(workspace), (QStringList{"Untitled", "Dropped", "Untitled 2*"})); + QVERIFY(!workspace.isManaging()); + const ImageLayer one = workspace.tabs()[0]->session.document().value().layers.front(); + QCOMPARE(one.name, QString("One")); + // The first picture made its canvas: the point is void. + QCOMPARE(one.transform.center(), QPointF(2, 1)); + QCOMPARE(workspace.tabs()[2]->session.document().value().layers.front().name, QString("Two")); + // A tab that is gone takes nothing. + done = false; + workspace.receive({picture(folder, "Lost")}, QUuid::createUuid(), std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 3); + QVERIFY(!workspace.isManaging()); + // Into a named tab, at the point; it comes forward. + done = false; + const std::shared_ptr<ProjectTab> first = workspace.tabs()[0]; + QUuid announced; + bool staleAtImport = false; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { announced = workspace.selectedID(); }); + connect(&first->session, &EditorSession::changed, this, [&] { staleAtImport = staleAtImport || (first->session.isImporting() && announced != first->id); }); + workspace.receive({picture(folder, "Placed")}, first->id, QPointF(3, 1), [&] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!staleAtImport); + QCOMPARE(workspace.selectedID(), first->id); + QCOMPARE(first->session.document().value().layers.back().transform.center(), QPointF(3, 1)); +} + +void ProjectWorkspaceFlowTests::aControllerInATabHandsOverToTheWorkspace() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 2); + ProjectWorkspace workspace; + paint(workspace.current()); + ProjectController &controller = workspace.current().controller; + // Opening never replaces this tab's work: a new tab, unasked. + DialogDesk desk; + QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Trip.comp"), done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip*"})); + QVERIFY(desk.seen.isEmpty()); + controller.newCanvas(); + QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip", "Untitled 2*"})); + // Its drops land in its own tab, wherever that is. + bool done = false; + controller.receive({picture(folder, "Mine")}, QPointF(3, 1), [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(titles(workspace), (QStringList{"Untitled*", "Trip", "Untitled 2"})); + QCOMPARE(workspace.tabs()[0]->session.document().value().layers.back().name, QString("Mine")); + QCOMPARE(workspace.tabs()[0]->session.document().value().layers.back().transform.center(), QPointF(3, 1)); + // So do those of a later tab. + done = false; + workspace.tabs()[2]->controller.receive({picture(folder, "Theirs")}, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(titles(workspace), (QStringList{"Untitled", "Trip", "Untitled 2*"})); + QCOMPARE(workspace.tabs()[2]->session.document().value().layers.back().name, QString("Theirs")); + workspace.select(workspace.tabs()[0]->id); + // Closing its window closes the tab, not the window. + QWidget window; + window.show(); + desk.replies = {"Don’t Save"}; + controller.close(&window); + QTRY_COMPARE(titles(workspace), (QStringList{"Trip*", "Untitled 2"})); + QVERIFY(window.isVisible()); + // While the workspace manages, no tab's controller may start. + ProjectController &other = workspace.current().controller; + QVERIFY(other.canStart()); + desk.replies = {"<cancel>"}; + bool managedAndRefused = false; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { managedAndRefused = managedAndRefused || (workspace.isManaging() && !other.canStart()); }); + QCOMPARE(answered([&](auto done) { workspace.open(std::nullopt, done); }), std::optional(false)); + QVERIFY(managedAndRefused && other.canStart()); +} + +void ProjectWorkspaceFlowTests::aWorkspaceThatEndsMidwayCallsNobody() +{ + QTemporaryDir folder; + store(folder.filePath("Slow.comp"), 1); + const QUrl dropped = picture(folder, "Late"); + // Every worker is held: loads and decodes have to wait. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + int calls = 0; + auto loading = std::make_unique<ProjectWorkspace>(); + loading->open(folder.filePath("Slow.comp"), [&](bool) { calls += 1; }); + auto importing = std::make_unique<ProjectWorkspace>(); + const std::shared_ptr<ProjectTab> kept = importing->tabs()[0]; + importing->receive({dropped}, std::nullopt, std::nullopt, [&] { calls += 1; }); + QTRY_VERIFY(kept->session.isImporting()); + loading.reset(); + importing.reset(); + gate.release(workers); + // The tab someone still holds finishes its import alone. + QTRY_VERIFY(kept->session.document().has_value() && !kept->session.isImporting()); + QVERIFY(pool->waitForDone(10'000)); + QTest::qWait(100); + QCOMPARE(calls, 0); +} + +void ProjectWorkspaceFlowTests::aTabRemovedMidImportStillEndsTheDrop() +{ + QTemporaryDir folder; + const QUrl dropped = picture(folder, "Late"); + ProjectWorkspace workspace; + const std::weak_ptr<ProjectTab> first = workspace.tabs()[0]; + // Every worker is held: the decode has to wait. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + bool done = false; + workspace.receive({dropped}, std::nullopt, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(first.lock()->session.isImporting()); + // Off the strip, the tab lives until its import ends. + workspace.removeTab(first.lock()->id); + QVERIFY(!first.expired() && workspace.isManaging()); + gate.release(workers); + QTRY_VERIFY(done); + QVERIFY(!workspace.isManaging() && workspace.canSwitch()); + QTRY_VERIFY(first.expired()); +} + +void ProjectWorkspaceFlowTests::aProjectThatMovedAwayIsNoMatchForAnotherMissingOne() +{ + QTemporaryDir folder; + store(folder.filePath("Here.comp"), 1); + ProjectWorkspace workspace; + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Here.comp"), done); }), std::optional(true)); + workspace.newCanvas(); + // Its folder moved away: neither place resolves any more. + QVERIFY(QDir().rename(folder.filePath("Here.comp"), folder.filePath("Moved.comp"))); + DialogDesk desk; + desk.replies = {"OK"}; + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Elsewhere.comp"), done); }), std::optional(false)); + QCOMPARE(desk.seen.size(), 1); + QCOMPARE(titles(workspace), (QStringList{"Here", "Untitled 2*"})); + // The same missing place is still the same place, + QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("sub/../Here.comp"), done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"})); + // also when named from the folder the process stands in. + workspace.select(workspace.tabs()[1]->id); + QDir::setCurrent(folder.path()); + QCOMPARE(answered([&](auto done) { workspace.open(QString("Here.comp"), done); }), std::optional(true)); + QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"})); +} + +void ProjectWorkspaceFlowTests::everyCallIsWholeWithoutACompletion() +{ + QTemporaryDir folder; + store(folder.filePath("Quiet.comp"), 1); + ProjectWorkspace workspace; + // Refused, with nobody to tell: each posted answer is nothing. + workspace.current().session.setIsProjectBusy(true); + workspace.open(folder.filePath("Quiet.comp")); + workspace.confirmQuit({}); + workspace.close(workspace.selectedID()); + workspace.copyLayer(QUuid::createUuid(), std::nullopt); + QTest::qWait(30); + QCOMPARE(titles(workspace), (QStringList{"Untitled*"})); + workspace.current().session.setIsProjectBusy(false); + // Carried out, with nobody to tell. + workspace.open(folder.filePath("Quiet.comp")); + QTRY_COMPARE(titles(workspace), (QStringList{"Quiet*"})); + QTRY_VERIFY(!workspace.isManaging()); + workspace.receive({picture(folder, "Silent")}, workspace.selectedID()); + QTRY_COMPARE(int(workspace.current().session.document().value().layers.size()), 2); + QTRY_VERIFY(!workspace.isManaging()); + const QUuid id = workspace.current().session.activeLayerID().value(); + workspace.copyLayer(id, std::nullopt); + QTRY_COMPARE(int(workspace.tabs().size()), 2); + QTRY_VERIFY(!workspace.isManaging()); + QMimeData row; + row.setData(ProjectWorkspace::layerType, uuidString(id).toUtf8()); + workspace.select(workspace.tabs()[0]->id); + workspace.receiveProviders(row); + QTRY_COMPARE(int(workspace.tabs().size()), 3); + QTRY_VERIFY(!workspace.isManaging()); + workspace.confirmQuit({}); + DialogDesk desk; + desk.replies = {"Don’t Save", "Don’t Save", "Don’t Save"}; + QTRY_COMPARE(desk.seen.size(), 3); + QTRY_VERIFY(!workspace.isManaging()); + QTest::qWait(30); +} + +QTEST_MAIN(ProjectWorkspaceFlowTests) +#include "ProjectWorkspaceFlowTests.moc" diff --git a/tests/ProjectWorkspaceSignalTests.cpp b/tests/ProjectWorkspaceSignalTests.cpp new file mode 100644 index 0000000..0f9f3d7 --- /dev/null +++ b/tests/ProjectWorkspaceSignalTests.cpp @@ -0,0 +1,133 @@ +#include "DialogDesk.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectStore.h" +#include <QSemaphore> +#include <QTemporaryDir> +#include <QThreadPool> +#include <QtTest> + +// What observers of the workspace see, and what outlives it. +namespace { +// Everything an observer can read from the workspace. +QStringList described(const ProjectWorkspace &workspace) +{ + QStringList result{workspace.isManaging() ? "managing" : "idle", workspace.canSwitch() ? "free" : "held"}; + for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs()) + result << tab->title() + (tab->id == workspace.selectedID() ? "*" : "") + (tab->session.document() ? " drawn" : ""); + return result; +} + +void store(const QString &path) +{ + EditorSession session; + session.createDocument(8, 6); + session.addBlankLayer(); + ProjectStore::save(session.projectSnapshot().value(), path); +} + +QUrl picture(const QTemporaryDir &folder, const QString &name) +{ + QImage image(4, 2, QImage::Format_RGBA8888); + image.fill(Qt::green); + if (!image.save(folder.filePath(name + ".png"))) + throw std::runtime_error("the fixture could not be written"); + return QUrl::fromLocalFile(folder.filePath(name + ".png")); +} +} + +class ProjectWorkspaceSignalTests : public QObject { + Q_OBJECT +private slots: + void theLastSignalOfEveryFlowSeesWhatItLeaves(); + void anEmptyDropManagesNothingForLong(); + void aWorkspaceThatEndsUnderAQuestionLeavesItHarmless(); +}; + +void ProjectWorkspaceSignalTests::theLastSignalOfEveryFlowSeesWhatItLeaves() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp")); + ProjectWorkspace workspace; + QStringList seen; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { seen = described(workspace); }); + // Runs a flow to its end; names what went stale. + const auto stale = [&](const std::function<void(std::function<void()>)> &flow) { + bool done = false; + seen.clear(); + flow([&] { done = true; }); + if (!QTest::qWaitFor([&] { return done; }, 10'000)) + return QStringLiteral("never finished"); + return seen == described(workspace) ? QString() : seen.join("; ") + " != " + described(workspace).join("; "); + }; + DialogDesk desk; + QCOMPARE(stale([&](auto done) { workspace.open(folder.filePath("Trip.comp"), [done](bool) { done(); }); }), QString()); + QCOMPARE(stale([&](auto done) { workspace.receive({picture(folder, "One")}, std::nullopt, std::nullopt, done); }), QString()); + const QUuid trip = workspace.tabs()[0]->id; + const QUuid layer = workspace.tabs()[0]->session.document().value().layers.front().id; + QCOMPARE(stale([&](auto done) { workspace.copyLayer(layer, workspace.selectedID(), std::nullopt, done); }), QString()); + desk.replies = {"Don’t Save"}; + QCOMPARE(stale([&](auto done) { workspace.close(workspace.selectedID(), done); }), QString()); + desk.replies = {"Cancel"}; + workspace.tabs()[0]->session.addBlankLayer(); + QCOMPARE(stale([&](auto done) { workspace.confirmQuit([done](bool) { done(); }); }), QString()); + QCOMPARE(described(workspace), (QStringList{"idle", "free", "Trip* drawn"})); + QCOMPARE(workspace.selectedID(), trip); +} + +void ProjectWorkspaceSignalTests::anEmptyDropManagesNothingForLong() +{ + ProjectWorkspace workspace; + const QStringList before = described(workspace); + bool done = false; + workspace.receive({}, std::nullopt, std::nullopt, [&] { done = true; }); + QVERIFY(!done); + QTRY_VERIFY(done); + QCOMPARE(described(workspace), before); +} + +void ProjectWorkspaceSignalTests::aWorkspaceThatEndsUnderAQuestionLeavesItHarmless() +{ + auto workspace = std::make_unique<ProjectWorkspace>(); + workspace->current().session.createDocument(8, 6); + const std::weak_ptr<ProjectTab> tab = workspace->tabs()[0]; + int calls = 0; + workspace->close(workspace->selectedID(), [&] { calls += 1; }); + // The question is up; the workspace goes; then the answer. + QTRY_VERIFY(qobject_cast<QMessageBox *>(QApplication::activeModalWidget()) || !QApplication::topLevelWidgets().isEmpty()); + workspace.reset(); + QVERIFY(!tab.expired()); + DialogDesk desk; + desk.replies = {"Don’t Save"}; + QTRY_COMPARE(desk.seen.size(), 1); + // The answer frees the tab it held; nobody is called. + QTRY_VERIFY(tab.expired()); + QCOMPARE(calls, 0); + // A copy in flight ends with its workspace as well. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore held, gate; + const auto letGo = qScopeGuard([&] { + gate.release(workers); + pool->waitForDone(); + }); + for (int index = 0; index < workers; ++index) { + pool->start([&] { + held.release(); + gate.acquire(); + }); + } + QVERIFY(held.tryAcquire(workers, 10'000)); + auto copying = std::make_unique<ProjectWorkspace>(); + copying->current().session.createDocument(8, 6); + copying->current().session.addBlankLayer(); + copying->copyLayer(copying->current().session.activeLayerID().value(), std::nullopt, std::nullopt, [&] { calls += 1; }); + QVERIFY(copying->isManaging()); + copying.reset(); + gate.release(workers); + QVERIFY(pool->waitForDone(10'000)); + QTest::qWait(50); + QCOMPARE(calls, 0); +} + +QTEST_MAIN(ProjectWorkspaceSignalTests) +#include "ProjectWorkspaceSignalTests.moc" diff --git a/tests/ProjectWorkspaceTests.cpp b/tests/ProjectWorkspaceTests.cpp new file mode 100644 index 0000000..393529e --- /dev/null +++ b/tests/ProjectWorkspaceTests.cpp @@ -0,0 +1,362 @@ +#include "Document/ProjectWorkspace.h" +#include "SelectionFixtures.h" +#include "SessionFixtures.h" +#include <QTemporaryDir> +#include <QtTest> + +// Swift's ProjectWorkspaceTests, then the tabs' own rules. +namespace { +ImportedImage white() +{ + QImage image(1, 1, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + return ImportedImage(image, QImage(), "White"); +} + +QList<QUuid> ids(const std::vector<std::shared_ptr<ProjectTab>> &tabs) +{ + QList<QUuid> result; + for (const std::shared_ptr<ProjectTab> &tab : tabs) + result << tab->id; + return result; +} + +int layerCount(const ProjectTab &tab) +{ + return int(tab.session.document().value().layers.size()); +} +} + +class ProjectWorkspaceTests : public QObject { + Q_OBJECT +private slots: + void newCanvasOpensAnEmptyTabWithoutAModal(); + void layerDropProviderCopiesIntoANewProject(); + void tabsKeepIndependentDocumentsAndUndo(); + void crossProjectCopyRemapsIdentityAndHasIndependentUndo(); + void dockCreatesTabsAndTargetedImportUsesExistingTab(); + void quitAsksAboutTheActiveTabFirst(); + void tabsAreNamedAndOnlyTheFirstSkipsTheClipboard(); + void selectingEndsATransformAndRefusesWhatItMust(); + void removingATabSelectsItsNeighbour(); + void everyChangeIsAnnouncedAndRefusalsAreSilent(); + void aDropThatIsNoLayerIdIsIgnored(); + void anOpenFilterHoldsTheSwitch(); +}; + +void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal() +{ + ProjectWorkspace workspace; + ProjectTab &original = workspace.current(); + original.session.createDocument(4000, 3000); + workspace.newCanvas(); + QCOMPARE(int(workspace.tabs().size()), 2); + QVERIFY(&workspace.current() != &original); + QVERIFY(!workspace.current().session.document().has_value()); + QVERIFY(!workspace.current().session.showsNewDocument()); + QVERIFY(workspace.canSwitch()); + QCOMPARE(original.session.document().value().width, 4000); + workspace.newCanvas(); + QCOMPARE(int(workspace.tabs().size()), 3); + QVERIFY(!workspace.current().session.showsNewDocument()); + // Beside a single empty tab too, a new tab. + ProjectWorkspace fresh; + fresh.newCanvas(); + QCOMPARE(int(fresh.tabs().size()), 2); + // The tab that is left ends its transform. + ProjectTab &drawn = fresh.current(); + drawn.session.createDocument(8, 8); + drawn.session.insert(white()); + drawn.session.beginTransform(); + LayerTransform moved = drawn.session.transformEdit().value().draft; + moved.origin += QPointF(0, 2); + drawn.session.previewTransform(moved); + fresh.newCanvas(); + QCOMPARE(int(fresh.tabs().size()), 3); + QVERIFY(!drawn.session.transformEdit().has_value()); + QCOMPARE(drawn.session.document().value().layers.front().transform.origin, moved.origin); +} + +void ProjectWorkspaceTests::layerDropProviderCopiesIntoANewProject() +{ + ProjectWorkspace workspace; + ProjectTab &source = workspace.current(); + source.session.createDocument(100, 100); + source.session.insert(white()); + const QUuid id = source.session.activeLayerID().value(); + QMimeData provider; + provider.setData(ProjectWorkspace::layerType, uuidString(id).toUtf8()); + bool done = false; + workspace.receiveProviders(provider, std::nullopt, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 2); + QVERIFY(workspace.current().id != source.id); + QCOMPARE(layerCount(workspace.current()), 1); + QVERIFY(workspace.current().session.activeLayerID().value() != id); + QCOMPARE(source.session.document().value().layers.front().id, id); +} + +void ProjectWorkspaceTests::tabsKeepIndependentDocumentsAndUndo() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + first.session.createDocument(4000, 4000); + first.session.addBlankLayer(); + ProjectTab &second = workspace.addTab(); + second.session.createDocument(640, 480); + second.session.addBlankLayer(); + second.session.undo(); + QCOMPARE(layerCount(first), 1); + QCOMPARE(layerCount(second), 0); + workspace.select(first.id); + QCOMPARE(&workspace.current(), &first); + QCOMPARE(first.session.document().value().width, 4000); + const QUuid firstID = first.id; + workspace.removeTab(second.id); + QCOMPARE(&workspace.current(), &first); + workspace.removeTab(firstID); + QVERIFY(workspace.tabs().size() == 1 && !workspace.current().session.document().has_value()); +} + +void ProjectWorkspaceTests::crossProjectCopyRemapsIdentityAndHasIndependentUndo() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + first.session.createDocument(100, 100); + first.session.insert(white()); + const QUuid original = first.session.activeLayerID().value(); + ProjectTab &second = workspace.addTab(); + second.session.createDocument(200, 200); + bool done = false; + workspace.copyLayer(original, second.id, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + const ImageLayer copied = second.session.document().value().layers.front(); + QVERIFY(copied.id != original); + QCOMPARE(copied.transform.center(), QPointF(100, 100)); + QCOMPARE(layerCount(first), 1); + second.session.undo(); + QCOMPARE(layerCount(second), 0); + QCOMPARE(layerCount(first), 1); + second.session.redo(); + QCOMPARE(second.session.document().value().layers.front().id, copied.id); +} + +void ProjectWorkspaceTests::dockCreatesTabsAndTargetedImportUsesExistingTab() +{ + QTemporaryDir folder; + QImage picture(4, 2, QImage::Format_RGBA8888); + picture.fill(Qt::green); + QVERIFY(picture.save(folder.filePath("Picture.png"))); + const QUrl url = QUrl::fromLocalFile(folder.filePath("Picture.png")); + ProjectWorkspace workspace; + bool done = false; + workspace.receive({url, url}, std::nullopt, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 2); + const std::shared_ptr<ProjectTab> first = workspace.tabs()[0]; + done = false; + workspace.receive({url}, first->id, std::nullopt, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(int(workspace.tabs().size()), 2); + QCOMPARE(&workspace.current(), first.get()); + QCOMPARE(layerCount(*first), 2); + QCOMPARE(layerCount(*workspace.tabs()[1]), 1); +} + +void ProjectWorkspaceTests::quitAsksAboutTheActiveTabFirst() +{ + ProjectWorkspace workspace; + const QUuid first = workspace.current().id; + const QUuid second = workspace.addTab(false).id, third = workspace.addTab(false).id; + workspace.select(second); + QCOMPARE(ids(workspace.quitOrder()), (QList<QUuid>{second, first, third})); + workspace.select(third); + QCOMPARE(ids(workspace.quitOrder()), (QList<QUuid>{third, first, second})); + workspace.select(first); + QCOMPARE(ids(workspace.quitOrder()), (QList<QUuid>{first, second, third})); +} + +void ProjectWorkspaceTests::tabsAreNamedAndOnlyTheFirstSkipsTheClipboard() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + QCOMPARE(first.title(), QString("Untitled")); + QVERIFY(first.session.skipsInitialClipboardCanvasSize); + QVERIFY(first.controller.workspace == &workspace); + // A single empty tab is reused unless told otherwise. + QCOMPARE(&workspace.addTab(), &first); + QWidget window; + workspace.window = &window; + ProjectTab &second = workspace.addTab(false); + ProjectTab &third = workspace.addTab(); + QCOMPARE(second.title(), QString("Untitled 2")); + QCOMPARE(third.title(), QString("Untitled 3")); + QVERIFY(!second.session.skipsInitialClipboardCanvasSize && !third.session.skipsInitialClipboardCanvasSize); + QVERIFY(second.controller.workspace == &workspace && second.controller.window == &window); + QCOMPARE(workspace.selectedID(), third.id); + // A saved project goes by its file's name without suffix. + third.session.setProjectPath(QString("/somewhere/My.Trip.comp")); + QCOMPARE(third.title(), QString("My.Trip")); + // Like Swift: a first or last dot cuts nothing. + const auto titled = [&](const QString &path) { + third.session.setProjectPath(path); + return third.title(); + }; + QCOMPARE(titled("/somewhere/.comp"), QString(".comp")); + QCOMPARE(titled("/somewhere/.hidden.comp"), QString(".hidden")); + QCOMPARE(titled("/somewhere/Plain"), QString("Plain")); + QCOMPARE(titled("/somewhere/Ends."), QString("Ends.")); + QCOMPARE(ProjectTab::nameWithoutSuffix("/somewhere/.comp"), QString(".comp")); + // One tab with a canvas is not reused. + ProjectWorkspace drawn; + drawn.current().session.createDocument(8, 8); + QVERIFY(&drawn.addTab() != drawn.tabs()[0].get()); + QCOMPARE(int(drawn.tabs().size()), 2); +} + +void ProjectWorkspaceTests::selectingEndsATransformAndRefusesWhatItMust() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + first.session.createDocument(8, 8); + first.session.insert(white()); + ProjectTab &second = workspace.addTab(); + QWidget window; + workspace.window = &window; + QSignalSpy changes(&workspace, &ProjectWorkspace::changed); + // The tab in front, a stranger: nothing to do. + workspace.select(second.id); + workspace.select(QUuid::createUuid()); + QCOMPARE(changes.count(), 0); + // The tab that is left ends its transform. + second.session.createDocument(8, 8); + second.session.insert(white()); + second.session.beginTransform(); + LayerTransform moved = second.session.transformEdit().value().draft; + moved.origin += QPointF(3, 0); + second.session.previewTransform(moved); + workspace.select(first.id); + QCOMPARE(changes.count(), 1); + QCOMPARE(workspace.selectedID(), first.id); + QVERIFY(!second.session.transformEdit().has_value()); + QCOMPARE(second.session.document().value().layers.front().transform.origin, moved.origin); + // The window goes to the tab that comes forward. + QVERIFY(first.controller.window == &window && second.controller.window == nullptr); + // A busy project in front holds the switch. + first.session.setIsProjectBusy(true); + QVERIFY(!workspace.canSwitch()); + workspace.select(second.id); + workspace.newCanvas(); + QCOMPARE(workspace.selectedID(), first.id); + QCOMPARE(int(workspace.tabs().size()), 2); + QCOMPARE(changes.count(), 1); +} + +void ProjectWorkspaceTests::removingATabSelectsItsNeighbour() +{ + ProjectWorkspace workspace; + const QUuid a = workspace.current().id; + const QUuid b = workspace.addTab(false).id, c = workspace.addTab(false).id, d = workspace.addTab(false).id; + // Not in front: the selection stays. + workspace.removeTab(b); + QCOMPARE(ids(workspace.tabs()), (QList<QUuid>{a, c, d})); + QCOMPARE(workspace.selectedID(), d); + // The last one in front: the new last takes over. + workspace.removeTab(d); + QCOMPARE(workspace.selectedID(), c); + // The first in front: what slides into its place. + workspace.select(a); + workspace.removeTab(a); + QCOMPARE(workspace.selectedID(), c); + workspace.removeTab(QUuid::createUuid()); + QCOMPARE(ids(workspace.tabs()), (QList<QUuid>{c})); + // The very last tab leaves a fresh one behind. + workspace.removeTab(c); + QCOMPARE(int(workspace.tabs().size()), 1); + QVERIFY(workspace.current().id != c); + QCOMPARE(workspace.current().title(), QString("Untitled 5")); +} + +void ProjectWorkspaceTests::everyChangeIsAnnouncedAndRefusalsAreSilent() +{ + ProjectWorkspace workspace; + QSignalSpy changes(&workspace, &ProjectWorkspace::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + QVERIFY(!announced([&] { workspace.addTab(); })); + QVERIFY(announced([&] { workspace.addTab(false); })); + QVERIFY(announced([&] { workspace.newCanvas(); })); + const QUuid first = workspace.tabs()[0]->id; + QVERIFY(announced([&] { workspace.select(first); })); + QVERIFY(!announced([&] { workspace.select(first); })); + QVERIFY(!announced([&] { workspace.removeTab(QUuid::createUuid()); })); + QVERIFY(announced([&] { workspace.removeTab(first); })); + // What the last signal sees is what the call leaves. + QList<QUuid> seen; + connect(&workspace, &ProjectWorkspace::changed, this, [&] { seen = ids(workspace.tabs()) << workspace.selectedID(); }); + workspace.addTab(false); + QCOMPARE(seen, ids(workspace.tabs()) << workspace.selectedID()); + workspace.removeTab(workspace.selectedID()); + QCOMPARE(seen, ids(workspace.tabs()) << workspace.selectedID()); +} + +void ProjectWorkspaceTests::aDropThatIsNoLayerIdIsIgnored() +{ + ProjectWorkspace workspace; + workspace.current().session.createDocument(8, 8); + workspace.current().session.insert(white()); + QSignalSpy changes(&workspace, &ProjectWorkspace::changed); + const auto dropped = [&](const QString &type, const QByteArray &payload) { + QMimeData provider; + provider.setData(type, payload); + bool done = false; + workspace.receiveProviders(provider, std::nullopt, std::nullopt, [&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 10'000); + }; + const QByteArray id = uuidString(workspace.current().session.activeLayerID().value()).toUtf8(); + QVERIFY(dropped(ProjectWorkspace::layerType, "not an id")); + QVERIFY(dropped(ProjectWorkspace::layerType, id.left(20))); + QVERIFY(dropped(ProjectWorkspace::layerType, uuidString(QUuid::createUuid()).toUtf8())); + QCOMPARE(int(workspace.tabs().size()), 1); + QCOMPARE(changes.count(), 0); + // Under another type it is no row: nothing there reads. + QVERIFY(dropped("text/plain", id)); + QVERIFY(workspace.current().session.importError().value().startsWith("Some dropped items couldn’t be read.")); + workspace.current().session.setImportError(std::nullopt); + QCOMPARE(int(workspace.tabs().size()), 1); + // Neither white space around the id nor lower case matters. + QVERIFY(dropped(ProjectWorkspace::layerType, " " + id.toLower() + "\n")); + QCOMPARE(int(workspace.tabs().size()), 2); +} + +void ProjectWorkspaceTests::anOpenFilterHoldsTheSwitch() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(8, 8, true); + QImage image(8, 8, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::white); + session.insert(ImportedImage(image, image, "White")); + session.applySelection(rectPath(QRectF(2, 2, 2, 2)), SelectionMode::replace, "Select"); + QVERIFY(workspace.canSwitch()); + session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(!workspace.canSwitch()); + session.cancelFilter(); + QVERIFY(workspace.canSwitch()); + // So does a pixel move under way. + QVERIFY(session.beginPixelMove()); + QVERIFY(!workspace.canSwitch()); + session.cancelPixelMove(); + QVERIFY(workspace.canSwitch()); + // And an open colour picker. + session.openColorPicker(false); + QVERIFY(!workspace.canSwitch()); + session.closeColorPicker(false); + QVERIFY(workspace.canSwitch()); +} + +QTEST_MAIN(ProjectWorkspaceTests) +#include "ProjectWorkspaceTests.moc" diff --git a/tests/ProjectWorkspaceViewTests.cpp b/tests/ProjectWorkspaceViewTests.cpp new file mode 100644 index 0000000..8852da0 --- /dev/null +++ b/tests/ProjectWorkspaceViewTests.cpp @@ -0,0 +1,391 @@ +#include "DialogDesk.h" +#include "IO/ProjectStore.h" +#include "UI/ProjectWorkspaceView.h" +#include <QLineEdit> +#include <QProcess> +#include <QScrollBar> +#include <QPushButton> +#include <QTemporaryDir> +#include <QToolButton> +#include <QtTest> + +// The window: tabs, the toolbar, closing and quitting. +namespace { +template <typename Widget> Widget &find(QWidget &root, const char *name) +{ + Widget *found = root.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; +} + +QAction &action(QWidget &root, const char *name) +{ + QAction *found = root.findChild<QAction *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no action named ") + name); + return *found; +} + +QStringList titles(const ProjectTabStrip &strip) +{ + QStringList result; + for (const ProjectTabButton *button : strip.buttons()) + result << button->findChild<QToolButton *>("selectTab")->text(); + return result; +} + +void store(const QString &path, int layers) +{ + EditorSession session; + session.createDocument(8, 6); + for (int index = 0; index < layers; ++index) + session.addBlankLayer(); + ProjectStore::save(session.projectSnapshot().value(), path); +} +} + +class ProjectWorkspaceViewTests : public QObject { + Q_OBJECT +private slots: + void testCreateCanvasAndNavigation(); + void theFrontTabHasTheEditorAndTheWindow(); + void tabsShowTheirTitlesDotsAndState(); + void tabButtonsSelectAndClose(); + void theToolbarFollowsTheFrontSession(); + void closingTheWindowAsksThroughTheWorkspace(); + void aReplacedTabOutlivesItsEditor(); + void filesAtLaunchGoToTheWorkspace(); +}; + +void ProjectWorkspaceViewTests::testCreateCanvasAndNavigation() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + auto &width = find<QLineEdit>(window, "widthInput"); + width.setFocus(); + width.selectAll(); + QTest::keyClicks(&width, "0"); + QVERIFY(!find<QPushButton>(window, "createCanvas").isEnabled()); + width.selectAll(); + QTest::keyClicks(&width, "1200"); + auto &height = find<QLineEdit>(window, "heightInput"); + height.setFocus(); + height.selectAll(); + QTest::keyClicks(&height, "800"); + QTest::mouseClick(&find<QPushButton>(window, "createCanvas"), Qt::LeftButton); + QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("1,200 × 800 px")); + workspace.current().session.zoom(0.5); + action(window, "actualPixels").trigger(); + QCOMPARE(find<QLabel>(window, "zoomStatus").text(), QString("100%")); + action(window, "zoomIn").trigger(); + QCOMPARE(find<QLabel>(window, "zoomStatus").text(), QString("125%")); + action(window, "fitCanvas").trigger(); + QVERIFY(workspace.current().session.viewport.followsFit()); + QCoreApplication::processEvents(); + QVERIFY(window.grab().save(QStringLiteral("EditorFoundation.png"))); + // New canvas opens a tab unasked; the canvas stays behind. + action(window, "newCanvasToolbar").trigger(); + QCOMPARE(int(workspace.tabs().size()), 2); + QVERIFY(find<QLineEdit>(window, "widthInput").isVisible()); + workspace.select(workspace.tabs()[0]->id); + QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("1,200 × 800 px")); +} + +void ProjectWorkspaceViewTests::theFrontTabHasTheEditorAndTheWindow() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + ProjectTab &first = workspace.current(); + QVERIFY(workspace.window == &window && first.controller.window == &window); + QVERIFY(window.content() && window.centralWidget() == window.content()); + window.content()->setProperty("first", true); + // A second tab in front: a fresh editor for it. + first.session.createDocument(8, 8); + workspace.newCanvas(); + ProjectTab &second = workspace.current(); + QVERIFY(!window.content()->property("first").toBool() && window.centralWidget() == window.content()); + QVERIFY(second.controller.window == &window); + QVERIFY(find<QLineEdit>(window, "widthInput").isVisible()); + // Back to the first: its canvas, not the welcome. + workspace.select(first.id); + QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("8 × 8 px")); + QVERIFY(window.findChildren<QLineEdit *>("widthInput").isEmpty()); + // Managing dims the editor; the tabs stay live. + QVERIFY(window.content()->isEnabled()); + DialogDesk desk; + desk.note = [&] { return window.content()->isEnabled() ? QStringLiteral("enabled") : QStringLiteral("dimmed"); }; + desk.replies = {"<cancel>"}; + bool done = false; + workspace.open(std::nullopt, [&](bool) { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||dimmed"})); + QVERIFY(window.content()->isEnabled()); +} + +void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 1); + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + ProjectTabStrip &strip = *window.tabs(); + QCOMPARE(strip.height(), 34); + QCOMPARE(strip.accessibleName(), QString("Project tabs")); + // The strip paints no stripe over the toolbar. + QVERIFY(QTest::qWaitForWindowExposed(&window)); + const QImage bar = window.grab().toImage(); + const QPoint inStrip = strip.mapTo(&window, QPoint(strip.width() - 4, 3)), beside = strip.mapTo(&window, QPoint(strip.width() + 8, 3)); + QCOMPARE(bar.pixelColor(inStrip), bar.pixelColor(beside)); + QCOMPARE(titles(strip), (QStringList{"Untitled"})); + workspace.current().session.createDocument(8, 8); + // Unsaved work shows as a dot before the title. + QCOMPARE(titles(strip), (QStringList{"● Untitled"})); + bool done = false; + workspace.open(folder.filePath("Trip.comp"), [&](bool) { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"})); + const QList<ProjectTabButton *> buttons = strip.buttons(); + QCOMPARE(buttons.size(), 2); + QVERIFY(!buttons[0]->isActive() && buttons[1]->isActive()); + // Swift's capsule: 28 high, title 35 to 155, cross 16. + 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. + QToolButton &title = *buttons[0]->findChild<QToolButton *>("selectTab"); + workspace.tabs()[0]->session.setProjectPath(QString("/x/") + QString(80, QLatin1Char('W')) + ".comp"); + QTRY_COMPARE(title.width(), 155); + QCOMPARE(buttons[0]->height(), 28); + workspace.tabs()[0]->session.setProjectPath(QString("/x/I.comp")); + QTRY_COMPARE(title.width(), 35); + 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); + QCOMPARE(buttons[1]->findChild<QToolButton *>("closeTab")->toolTip(), QString("Close Trip")); + QCOMPARE(buttons[1]->findChild<QToolButton *>("closeTab")->accessibleName(), QString("Close Trip")); + // The front tab is painted brighter, at once. + const QImage front = buttons[1]->grab().toImage(), behind = buttons[0]->grab().toImage(); + QVERIFY(front.pixelColor(3, 14) != behind.pixelColor(3, 14)); + workspace.select(workspace.tabs()[0]->id); + QCOMPARE(buttons[0]->grab().toImage().pixelColor(3, 14), front.pixelColor(3, 14)); + QCOMPARE(buttons[1]->grab().toImage().pixelColor(3, 14), behind.pixelColor(3, 14)); + workspace.select(workspace.tabs()[1]->id); + // A dot in a tab behind shows there at once. + workspace.tabs()[0]->session.history.markSaved(); + workspace.tabs()[0]->session.notify(); + QCOMPARE(titles(strip), (QStringList{"Untitled", "Trip"})); + workspace.tabs()[0]->session.addBlankLayer(); + QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"})); + // A busy front holds every button but the front's own. + workspace.current().session.setIsProjectBusy(true); + QVERIFY(!buttons[0]->findChild<QToolButton *>("selectTab")->isEnabled()); + QVERIFY(buttons[1]->findChild<QToolButton *>("selectTab")->isEnabled()); + QVERIFY(!buttons[0]->findChild<QToolButton *>("closeTab")->isEnabled() && !buttons[1]->findChild<QToolButton *>("closeTab")->isEnabled()); + workspace.current().session.setIsProjectBusy(false); + QVERIFY(buttons[0]->findChild<QToolButton *>("selectTab")->isEnabled() && buttons[1]->findChild<QToolButton *>("closeTab")->isEnabled()); + // Buttons stay: the same widget for the same tab. + buttons[0]->setProperty("kept", true); + workspace.addTab(false); + QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip", "Untitled 2"})); + QVERIFY(strip.buttons()[0]->property("kept").toBool()); + workspace.removeTab(workspace.tabs()[1]->id); + QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2"})); + QVERIFY(strip.buttons()[0]->property("kept").toBool()); + QCOMPARE(strip.buttons().size(), 2); + // A tab made behind shows its title without a nudge. + ProjectTab &late = workspace.addTab(false); + late.session.setProjectPath(QString("/x/Late.comp")); + workspace.select(workspace.tabs()[0]->id); + QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"})); + // More tabs than fit: the front one scrolls into view. + QVERIFY(QTest::qWaitForWindowExposed(&window)); + for (int index = 0; index < 8; ++index) + workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index)); + const ProjectTabButton *last = strip.buttons().last(); + const auto wholeInView = [&](const ProjectTabButton *button) { + return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size())); + }; + // In view at once, and still after the layout settles. + QVERIFY(last->tab->id == workspace.selectedID() && 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. + workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp"); + QCoreApplication::processEvents(); + QCoreApplication::processEvents(); + QVERIFY(last->width() > 180 && wholeInView(last)); + workspace.select(workspace.tabs()[0]->id); + const ProjectTabButton *head = strip.buttons().first(); + QVERIFY(wholeInView(head)); + QCoreApplication::processEvents(); + QVERIFY(wholeInView(head)); +} + +void ProjectWorkspaceViewTests::tabButtonsSelectAndClose() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + const QUuid first = workspace.current().id; + workspace.current().session.createDocument(8, 8); + workspace.newCanvas(); + const QUuid second = workspace.current().id; + const QList<ProjectTabButton *> buttons = window.tabs()->buttons(); + QTest::mouseClick(buttons[0]->findChild<QToolButton *>("selectTab"), Qt::LeftButton); + QCOMPARE(workspace.selectedID(), first); + QTest::mouseClick(buttons[1]->findChild<QToolButton *>("selectTab"), Qt::LeftButton); + QCOMPARE(workspace.selectedID(), second); + // Closing the first asks; Don't Save removes it. + DialogDesk desk; + desk.replies = {"Don’t Save"}; + QTest::mouseClick(buttons[0]->findChild<QToolButton *>("closeTab"), Qt::LeftButton); + QTRY_COMPARE(int(workspace.tabs().size()), 1); + QCOMPARE(workspace.selectedID(), second); + QCOMPARE(titles(*window.tabs()), (QStringList{"Untitled 2"})); +} + +void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QAction &fit = action(window, "fitCanvas"), &actual = action(window, "actualPixels"); + QAction &in = action(window, "zoomIn"), &out = action(window, "zoomOut"), &fresh = action(window, "newCanvasToolbar"); + QCOMPARE(fit.toolTip(), QString("Fit canvas in window (Ctrl+0)")); + QCOMPARE(actual.toolTip(), QString("Actual pixels (Ctrl+1)")); + QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl++)")); + QCOMPARE(out.toolTip(), QString("Zoom out (Ctrl+−)")); + QCOMPARE(fresh.toolTip(), QString("New canvas (Ctrl+N) · Drop images here for new tabs")); + // No canvas: the zooms wait; New canvas is ready. + QVERIFY(!fit.isEnabled() && !actual.isEnabled() && !in.isEnabled() && !out.isEnabled() && fresh.isEnabled()); + EditorSession &session = workspace.current().session; + session.createDocument(400, 300); + QVERIFY(fit.isEnabled() && actual.isEnabled() && in.isEnabled() && out.isEnabled()); + actual.trigger(); + out.trigger(); + QCOMPARE(session.viewport.zoom(), 0.8); + in.trigger(); + in.trigger(); + QCOMPARE(session.viewport.zoom(), 1.25); + // Importing or a busy spell holds New canvas. + session.setIsImporting(true); + QVERIFY(!fresh.isEnabled()); + session.setIsImporting(false); + session.setIsProjectBusy(true); + QVERIFY(fresh.isEnabled()); + QTRY_VERIFY(!fresh.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(fresh.isEnabled()); + // The window carries the project's state for the frame. + QVERIFY(window.isWindowModified()); + QCOMPARE(window.windowTitle(), QString("Untitled[*]")); + session.setProjectPath(QString("/somewhere/Trip.comp")); + QCOMPARE(window.windowFilePath(), QString("/somewhere/Trip.comp")); + QCOMPARE(window.windowTitle(), QString("Trip[*]")); + session.history.markSaved(); + session.notify(); + QVERIFY(!window.isWindowModified()); + // Another tab in front: the toolbar follows that session. + workspace.newCanvas(); + QVERIFY(!fit.isEnabled() && !window.isWindowModified()); + QCOMPARE(window.windowFilePath(), QString()); + QCOMPARE(window.windowTitle(), QString("Untitled 2[*]")); + // At its smallest the window still shows the whole editor. + window.resize(800, 520); + QTRY_COMPARE(window.size(), QSize(800, 520)); + QWidget &status = find<QWidget>(window, "statusBar"); + QTRY_VERIFY(window.rect().contains(status.mapTo(&window, status.rect().bottomRight()))); +} + +void ProjectWorkspaceViewTests::closingTheWindowAsksThroughTheWorkspace() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + workspace.current().session.createDocument(8, 8); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + // Cancel keeps the window and its tab. + window.close(); + QTRY_COMPARE(desk.seen.size(), 1); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(window.isVisible()); + QVERIFY(workspace.current().session.document().has_value()); + // Don't Save closes it and leaves one fresh tab behind. + window.close(); + QTRY_VERIFY(!window.isVisible()); + QCOMPARE(int(workspace.tabs().size()), 1); + QVERIFY(!workspace.current().session.document().has_value()); + // Nothing unsaved: no question at all. + window.show(); + window.close(); + QTRY_VERIFY(!window.isVisible()); + QCOMPARE(desk.seen.size(), 2); + // Shown again with new work, it asks again. + window.show(); + workspace.current().session.createDocument(8, 8); + desk.replies = {"Cancel"}; + window.close(); + QTRY_COMPARE(desk.seen.size(), 3); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(window.isVisible()); +} + +void ProjectWorkspaceViewTests::aReplacedTabOutlivesItsEditor() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 1); + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.show(); + // The project replaces the empty tab; its editor goes first. + const std::weak_ptr<ProjectTab> untitled = workspace.tab(workspace.current().id); + std::optional<bool> aliveAtTheEnd; + connect(window.content(), &QObject::destroyed, this, [&] { aliveAtTheEnd = !untitled.expired(); }); + bool opened = false; + workspace.open(folder.filePath("Trip.comp"), [&](bool done) { opened = done; }); + QTRY_VERIFY(opened); + QVERIFY(aliveAtTheEnd.value()); + QVERIFY(untitled.expired()); + QCOMPARE(int(workspace.tabs().size()), 1); + QCOMPARE(find<QLabel>(window, "canvasDimensions").text(), QString("8 × 6 px")); +} + +void ProjectWorkspaceViewTests::filesAtLaunchGoToTheWorkspace() +{ + QTemporaryDir folder; + store(folder.filePath("Trip.comp"), 2); + QImage picture(4, 2, QImage::Format_RGBA8888); + picture.fill(Qt::green); + QVERIFY(picture.save(folder.filePath("Picture.png"))); + // The real app opens its arguments; the log says so. + QProcess app; + app.setProcessChannelMode(QProcess::MergedChannels); + // A Qt built for journald logs there unless told otherwise. + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert("QT_FORCE_STDERR_LOGGING", "1"); + app.setProcessEnvironment(environment); + app.start(QCoreApplication::applicationDirPath() + "/omaphoto", {folder.filePath("Trip.comp"), folder.filePath("Picture.png")}); + QVERIFY(app.waitForStarted(10'000)); + QByteArray log; + QTRY_VERIFY2_WITH_TIMEOUT((log += app.readAll()).contains("imported a request of 1 files"), log.constData(), 30'000); + app.kill(); + app.waitForFinished(10'000); + const qsizetype opened = log.indexOf("installed a project of 2 layers from " + folder.filePath("Trip.comp").toUtf8()); + const qsizetype imported = log.indexOf("imported " + folder.filePath("Picture.png").toUtf8() + " 4 x 2"); + // Both arrived, the project first, as they were named. + QVERIFY2(opened >= 0 && imported > opened, log.constData()); + QVERIFY2(log.contains("OmaPhoto 1.0.0 on Qt"), log.constData()); +} + +QTEST_MAIN(ProjectWorkspaceViewTests) +#include "ProjectWorkspaceViewTests.moc" diff --git a/tests/RasterSnapshotSessionTests.cpp b/tests/RasterSnapshotSessionTests.cpp new file mode 100644 index 0000000..0a791e4 --- /dev/null +++ b/tests/RasterSnapshotSessionTests.cpp @@ -0,0 +1,161 @@ +#include "BrushFixtures.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include "SessionFixtures.h" +#include <QTemporaryDir> + +// Swift's RasterSnapshotTests through the session: no mouse-up flattens. +namespace { +std::unique_ptr<EditorSession> session() +{ + auto made = std::make_unique<EditorSession>(); + made->createDocument(4000, 4000, true); + made->addBlankLayer(); + made->selectTool(NavigationTool::brush); + made->setBrushSettings(brush(800, 0, 1, 1, 1)); + return made; +} + +QImage drawn(const ImportedImage &asset, const LayerTransform &transform, int width, int height, double opacity = 1) +{ + QImage context = BrushRaster::context(width, height, false); + QPainter painter(&context); + LayerRenderer::draw(asset.image(), transform, transform.center(), painter, {.opacity = opacity}); + return context; +} + +int spread(const QImage &image, int y, int from, int to) +{ + int low = 255, high = 0; + for (int x = from; x <= to; ++x) { + low = std::min(low, alpha(image, x, y)); + high = std::max(high, alpha(image, x, y)); + } + return high - low; +} +} + +class RasterSnapshotSessionTests : public QObject { + Q_OBJECT +private slots: + void mouseUpAndNextStrokeNeverFlattenTheDocument(); + void snapshotsStayImmutableAndDisplayMatchesExportAcrossSuccessiveStrokes(); + void maskMouseUpIsImmediateAndPreservesTheImage(); + void theSoftStrokeStaysContinuousInASnapshot(); + void eightHundredPixelSoftStrokeHasNoPeriodicRidges(); +}; + +void RasterSnapshotSessionTests::mouseUpAndNextStrokeNeverFlattenTheDocument() +{ + const auto made = session(); + made->beginBrush(QPointF(800, 3200)); + made->continueBrush(QPointF(800, 800)); + made->continueBrush(QPointF(3200, 800)); + QVERIFY(made->finishBrushImmediately()); + const ImportedImage first = made->activeLayer().value().asset.value(); + QVERIFY(first.raster && !first.raster->hasMaterializedPixels()); + QVERIFY(made->canPaint() && made->canEditLayers() && !made->isProjectBusy()); + const int count = made->history.undoCount(); + made->beginBrush(QPointF(1600, 1600)); + made->continueBrush(QPointF(2200, 2200)); + QVERIFY(made->finishBrushImmediately()); + const ImportedImage second = made->activeLayer().value().asset.value(); + QVERIFY(!first.raster->hasMaterializedPixels()); + QVERIFY(second.raster && !second.raster->hasMaterializedPixels()); + QCOMPARE(made->history.undoCount(), count + 1); + made->undo(); + QCOMPARE(made->activeLayer().value().asset.value().identity(), first.identity()); + made->redo(); + QCOMPARE(made->activeLayer().value().asset.value().identity(), second.identity()); + made->selectTool(NavigationTool::move); + QVERIFY(made->tool() == NavigationTool::move && made->canTransform()); +} + +void RasterSnapshotSessionTests::snapshotsStayImmutableAndDisplayMatchesExportAcrossSuccessiveStrokes() +{ + const auto made = session(); + BrushSettings settings = made->brushSettings(); + settings.opacity = 0.5; + const std::vector<QPointF> points{QPointF(1400, 1600), QPointF(1200, 1600), QPointF(1800, 1700)}; + for (size_t index = 0; index < points.size(); ++index) { + settings.red = index % 2; + made->setBrushSettings(settings); + made->beginBrush(points[index]); + made->continueBrush(points[index] + QPointF(800, 200)); + QVERIFY(made->finishBrushImmediately()); + } + const ImageLayer layer = made->activeLayer().value(); + const ImportedImage asset = layer.asset.value(); + QVERIFY(asset.raster); + // Tile clips apply the opacity once past shifted crops. + QImage display = BrushRaster::context(4000, 4000, false); + { + QPainter painter(&display); + LayerRenderer::drawBrushPreview(QImage(), layer.transform, layer.transform.center(), painter, {.opacity = 0.5}, + {.patches = {}, .pixelWidth = asset.raster->width, .pixelHeight = asset.raster->height, .paintingMask = false, .raster = asset.raster}); + } + QVERIFY(!asset.raster->hasMaterializedPixels()); + const QImage reference = drawn(asset, layer.transform, 4000, 4000, 0.5); + QVERIFY(asset.raster->hasMaterializedPixels()); + QCOMPARE(display, reference); + made->beginBrush(QPointF(1800, 1700)); + QVERIFY(made->finishBrushImmediately()); + QCOMPARE(drawn(asset, layer.transform, 4000, 4000, 0.5), reference); + // Saving and reopening consume the lazy image the usual way. + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("SparseBrush.comp")); + ProjectStore::save(made->projectSnapshot().value(), path); + QCOMPARE(ImageExporter::render(ProjectStore::load(path)).image.size(), QSize(4000, 4000)); +} + +void RasterSnapshotSessionTests::maskMouseUpIsImmediateAndPreservesTheImage() +{ + const auto made = session(); + made->beginBrush(QPointF(2000, 2000)); + QVERIFY(made->finishBrushImmediately()); + const ImageIdentity image = made->activeLayer().value().asset.value().identity(); + made->addLayerMask(); + BrushSettings settings = made->brushSettings(); + settings.diameter = 300; + made->setBrushSettings(settings); + made->beginBrush(QPointF(2000, 2000)); + made->continueBrush(QPointF(2200, 2000)); + QVERIFY(made->finishBrushImmediately()); + QVERIFY(made->canPaint() && !made->isProjectBusy()); + QCOMPARE(made->activeLayer().value().asset.value().identity(), image); + const ImportedImage mask = made->activeLayer().value().mask.value().asset; + QVERIFY(mask.raster && !mask.raster->hasMaterializedPixels()); + made->beginBrush(QPointF(2000, 2100)); + QVERIFY(made->finishBrushImmediately()); + QVERIFY(!mask.raster->hasMaterializedPixels()); + QVERIFY(LayerMask::isValid(made->activeLayer().value().mask.value().asset.image())); +} + +void RasterSnapshotSessionTests::theSoftStrokeStaysContinuousInASnapshot() +{ + const auto made = session(); + BrushStroke stroke(made->activeLayer().value(), false, brush(120, 0, 1, 0, 0), QSizeF(4000, 4000)); + stroke.append(QPointF(500, 1000)); + stroke.append(QPointF(1300, 1000)); + stroke.flush(); + const PaintSnapshot result = stroke.paintSnapshot(); + const QImage image = drawn(result.asset, result.transform, 1400, 1100); + for (int y : {1000, 1030, 1050}) + QVERIFY2(spread(image, y, 700, 1100) <= 2, qPrintable(QStringLiteral("row %1 spread %2").arg(y).arg(spread(image, y, 700, 1100)))); +} + +void RasterSnapshotSessionTests::eightHundredPixelSoftStrokeHasNoPeriodicRidges() +{ + const auto made = session(); + made->beginBrush(QPointF(600, 1600)); + made->continueBrush(QPointF(3400, 1600)); + QVERIFY(made->finishBrushImmediately()); + const ImageLayer layer = made->activeLayer().value(); + const QImage image = drawn(layer.asset.value(), layer.transform, 4000, 4000); + for (int y : {1600, 1700, 1800, 1900, 1980}) + QVERIFY2(spread(image, y, 1000, 3000) <= 1, qPrintable(QStringLiteral("row %1 spread %2").arg(y).arg(spread(image, y, 1000, 3000)))); +} + +QTEST_GUILESS_MAIN(RasterSnapshotSessionTests) +#include "RasterSnapshotSessionTests.moc" diff --git a/tests/RasterSnapshotTests.cpp b/tests/RasterSnapshotTests.cpp new file mode 100644 index 0000000..d4f61f5 --- /dev/null +++ b/tests/RasterSnapshotTests.cpp @@ -0,0 +1,454 @@ +#include "Document/DocumentHistory.h" +#include "IO/ImageExporter.h" +#include "Rendering/RasterSnapshot.h" +#include <QtTest> +#include <climits> +#include <latch> +#include <thread> + +namespace { +QImage solid(int width, int height, QColor color) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(color); + return image; +} + +// Red holds x, green holds y. +QImage coordinates(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.setPixelColor(x, y, QColor(x % 256, y % 256, 0)); + } + return image; +} + +ImportedImage painted(std::shared_ptr<const RasterSnapshot> raster) +{ + return ImportedImage(raster, raster->thumbnail(), "Painted"); +} +} + +class RasterSnapshotTests : public QObject { + Q_OBJECT +private slots: + void contextsStartClearInTheLayerFormats(); + void contextAllocationFailureThrowsARenderError(); + void drawReplacesPixelsInsteadOfBlending(); + void drawCopiesOnlyItsSourcePart(); + void visibleRectFollowsDeviceTransformAndClip(); + void drawFillsAWindowMappedDevice(); + void replacingAnImportKeepsItAsTheBase(); + void growingTheCanvasShiftsBaseAndPatches(); + void newerPatchesSplitOlderOnes(); + void untouchedPatchesAreSharedNotCopied(); + void aNeighbourInTheSameCellStaysWhole(); + void drawKeepsHardEdgesUnderAnAntialiasingPainter(); + void concurrentFlatteningHappensOnce(); + void patchesOutsideTheCropAreClippedAway(); + void shrinkingCropsTheBaseOnDraw(); + void maskRastersRevealTheGrownArea(); + void drawScalesIntoTheRequestedRect(); + void flatteningIsLazyAndCached(); + void thumbnailFitsNinetySixPixels(); + void paintedAssetsReportSizeIdentityAndBytesWithoutFlattening(); + void historyBudgetNeverFlattensPaintedTiles(); +}; + +void RasterSnapshotTests::contextsStartClearInTheLayerFormats() +{ + const QImage color = BrushRaster::context(3, 2, false); + QCOMPARE(color.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(color.size(), QSize(3, 2)); + QCOMPARE(color.pixel(2, 1), qRgba(0, 0, 0, 0)); + const QImage mask = BrushRaster::context(3, 2, true); + QCOMPARE(mask.format(), QImage::Format_Grayscale8); + QCOMPARE(mask.constScanLine(1)[2], uchar(0)); +} + +void RasterSnapshotTests::contextAllocationFailureThrowsARenderError() +{ + try { + BrushRaster::context(INT_MAX, INT_MAX, false); + QFAIL("expected ExportError"); + } catch (const ExportError &error) { + QCOMPARE(error.kind, ExportError::Kind::render); + QCOMPARE(QString(error.what()), QString("The canvas could not be rendered. Try a smaller canvas.")); + } + QCOMPARE(QString(ExportError(ExportError::Kind::tooLarge).what()), + QString("Image export supports canvases up to 100 megapixels and 30,000 pixels per side.")); + QCOMPARE(QString(ExportError(ExportError::Kind::encode).what()), QString("The image could not be encoded.")); +} + +void RasterSnapshotTests::drawReplacesPixelsInsteadOfBlending() +{ + QImage surface = solid(4, 4, Qt::red); + QPainter painter(&surface); + BrushRaster::draw(BrushRaster::context(2, 2, false), QRectF(1, 1, 2, 2), painter); + QCOMPARE(painter.compositionMode(), QPainter::CompositionMode_SourceOver); + painter.end(); + QCOMPARE(surface.pixel(1, 1), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(2, 2), qRgba(0, 0, 0, 0)); + QCOMPARE(surface.pixel(0, 0), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(3, 3), qRgba(255, 0, 0, 255)); +} + +void RasterSnapshotTests::drawCopiesOnlyItsSourcePart() +{ + QImage surface = solid(6, 4, Qt::red); + QPainter painter(&surface); + BrushRaster::draw(coordinates(8, 8), QRectF(1, 1, 4, 2), painter, QRectF(2, 3, 4, 2)); + painter.end(); + QCOMPARE(surface.pixel(1, 1), qRgba(2, 3, 0, 255)); + QCOMPARE(surface.pixel(4, 2), qRgba(5, 4, 0, 255)); + QCOMPARE(surface.pixel(0, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(5, 2), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(1, 3), qRgba(255, 0, 0, 255)); +} + +void RasterSnapshotTests::visibleRectFollowsDeviceTransformAndClip() +{ + QImage surface = BrushRaster::context(100, 50, false); + QPainter painter(&surface); + QCOMPARE(BrushRaster::visibleRect(painter), QRectF(0, 0, 100, 50)); + painter.translate(10, 0); + QCOMPARE(BrushRaster::visibleRect(painter), QRectF(-10, 0, 100, 50)); + painter.scale(2, 2); + QCOMPARE(BrushRaster::visibleRect(painter), QRectF(-5, 0, 50, 25)); + painter.setClipRect(QRectF(5, 5, 10, 10)); + QCOMPARE(BrushRaster::visibleRect(painter), QRectF(5, 5, 10, 10)); + painter.setClipRect(QRectF(40, 20, 100, 100)); + QCOMPARE(BrushRaster::visibleRect(painter), QRectF(40, 20, 5, 5)); + painter.end(); + + QPainter windowed(&surface); + windowed.setWindow(0, 0, 200, 100); + QCOMPARE(BrushRaster::visibleRect(windowed), QRectF(0, 0, 200, 100)); + windowed.end(); + + QImage dense = BrushRaster::context(100, 50, false); + dense.setDevicePixelRatio(2); + QPainter scaled(&dense); + QCOMPARE(BrushRaster::visibleRect(scaled), QRectF(0, 0, 50, 25)); +} + +void RasterSnapshotTests::drawFillsAWindowMappedDevice() +{ + const RasterSnapshot raster(200, 100, solid(200, 100, Qt::red), QRectF(0, 0, 200, 100), + {BrushPatch{QRectF(100, 50, 100, 50), solid(100, 50, Qt::blue)}}); + QImage surface = solid(100, 50, Qt::green); + QPainter painter(&surface); + painter.setWindow(0, 0, 200, 100); + raster.draw(QRectF(0, 0, 200, 100), painter); + painter.end(); + QCOMPARE(surface.pixel(0, 0), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(99, 0), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(0, 49), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(50, 25), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(99, 49), qRgba(0, 0, 255, 255)); +} + +void RasterSnapshotTests::replacingAnImportKeepsItAsTheBase() +{ + const QImage pixels = solid(8, 8, Qt::red); + const ImportedImage source(pixels, QImage(), "Photo"); + const auto raster = RasterSnapshot::replacing(source, QRectF(0, 0, 8, 8), + {BrushPatch{QRectF(2, 2, 4, 4), solid(4, 4, Qt::blue)}}, QRectF(0, 0, 8, 8)); + QCOMPARE(raster->width, 8); + QCOMPARE(raster->height, 8); + QCOMPARE(raster->base.cacheKey(), pixels.cacheKey()); + QCOMPARE(raster->baseRect, QRectF(0, 0, 8, 8)); + QCOMPARE(raster->alignment, QPointF(0, 0)); + QVERIFY(!raster->isMask); + QCOMPARE(int(raster->patches.size()), 1); + QCOMPARE(raster->patches[0].rect, QRectF(2, 2, 4, 4)); + const QImage flat = raster->makeImage(); + QCOMPARE(flat.pixel(1, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(flat.pixel(2, 2), qRgba(0, 0, 255, 255)); + QCOMPARE(flat.pixel(5, 5), qRgba(0, 0, 255, 255)); + QCOMPARE(flat.pixel(6, 6), qRgba(255, 0, 0, 255)); +} + +void RasterSnapshotTests::growingTheCanvasShiftsBaseAndPatches() +{ + const ImportedImage source(solid(8, 8, Qt::red), QImage(), "Photo"); + const auto first = RasterSnapshot::replacing(source, QRectF(10, 20, 8, 8), + {BrushPatch{QRectF(6, 18, 4, 4), solid(4, 4, Qt::blue)}}, QRectF(6, 18, 12, 10)); + QCOMPARE(first->width, 12); + QCOMPARE(first->height, 10); + QCOMPARE(first->baseRect, QRectF(4, 2, 8, 8)); + QCOMPARE(first->alignment, QPointF(4, 2)); + QCOMPARE(first->patches[0].rect, QRectF(0, 0, 4, 4)); + const QImage flat = first->makeImage(); + QCOMPARE(flat.pixel(0, 0), qRgba(0, 0, 255, 255)); + QCOMPARE(flat.pixel(3, 3), qRgba(0, 0, 255, 255)); + QCOMPARE(flat.pixel(4, 4), qRgba(255, 0, 0, 255)); + QCOMPARE(flat.pixel(11, 9), qRgba(255, 0, 0, 255)); + QCOMPARE(flat.pixel(11, 0), qRgba(0, 0, 0, 0)); + QCOMPARE(flat.pixel(0, 9), qRgba(0, 0, 0, 0)); + + const auto second = RasterSnapshot::replacing(painted(first), QRectF(6, 18, 12, 10), {}, QRectF(4, 15, 14, 13)); + QCOMPARE(second->baseRect, QRectF(6, 5, 8, 8)); + QCOMPARE(second->alignment, QPointF(6, 5)); + QCOMPARE(second->patches[0].rect, QRectF(2, 3, 4, 4)); + QCOMPARE(second->base.cacheKey(), first->base.cacheKey()); +} + +void RasterSnapshotTests::newerPatchesSplitOlderOnes() +{ + const QImage tile = coordinates(256, 256); + const auto old = std::make_shared<const RasterSnapshot>(256, 256, QImage(), QRectF(), + std::vector<BrushPatch>{BrushPatch{QRectF(0, 0, 256, 256), tile}}); + const auto raster = RasterSnapshot::replacing(painted(old), QRectF(0, 0, 256, 256), + {BrushPatch{QRectF(100, 90, 56, 40), solid(56, 40, Qt::blue)}}, QRectF(0, 0, 256, 256)); + QCOMPARE(int(raster->patches.size()), 5); + const QRectF expected[] = {QRectF(0, 0, 256, 90), QRectF(0, 130, 256, 126), QRectF(0, 90, 100, 40), + QRectF(156, 90, 100, 40), QRectF(100, 90, 56, 40)}; + for (int index = 0; index < 5; ++index) { + QCOMPARE(raster->patches[index].rect, expected[index]); + QCOMPARE(QSizeF(raster->patches[index].image.size()), expected[index].size()); + } + QVERIFY(raster->base.isNull()); + const QImage flat = raster->makeImage(); + for (const QPoint point : {QPoint(0, 0), QPoint(255, 89), QPoint(99, 90), QPoint(156, 129), QPoint(0, 130), QPoint(255, 255)}) + QCOMPARE(flat.pixel(point), qRgba(point.x(), point.y(), 0, 255)); + for (const QPoint point : {QPoint(100, 90), QPoint(155, 129)}) + QCOMPARE(flat.pixel(point), qRgba(0, 0, 255, 255)); +} + +void RasterSnapshotTests::untouchedPatchesAreSharedNotCopied() +{ + const QImage near = solid(256, 256, Qt::green), far = solid(256, 256, Qt::yellow); + const auto old = std::make_shared<const RasterSnapshot>(1024, 256, QImage(), QRectF(), + std::vector<BrushPatch>{BrushPatch{QRectF(0, 0, 256, 256), near}, BrushPatch{QRectF(768, 0, 256, 256), far}}); + const auto raster = RasterSnapshot::replacing(painted(old), QRectF(0, 0, 1024, 256), + {BrushPatch{QRectF(0, 0, 256, 256), solid(256, 256, Qt::blue)}}, QRectF(0, 0, 1024, 256)); + QCOMPARE(int(raster->patches.size()), 2); + QCOMPARE(raster->patches[0].rect, QRectF(768, 0, 256, 256)); + QCOMPARE(raster->patches[0].image.cacheKey(), far.cacheKey()); + QCOMPARE(raster->patches[1].rect, QRectF(0, 0, 256, 256)); + QCOMPARE(raster->makeImage().pixel(10, 10), qRgba(0, 0, 255, 255)); +} + +void RasterSnapshotTests::aNeighbourInTheSameCellStaysWhole() +{ + const QImage neighbour = solid(100, 100, Qt::green); + const auto old = std::make_shared<const RasterSnapshot>(256, 256, QImage(), QRectF(), + std::vector<BrushPatch>{BrushPatch{QRectF(0, 0, 100, 100), neighbour}}); + const auto raster = RasterSnapshot::replacing(painted(old), QRectF(0, 0, 256, 256), + {BrushPatch{QRectF(150, 150, 50, 50), solid(50, 50, Qt::blue)}}, QRectF(0, 0, 256, 256)); + QCOMPARE(int(raster->patches.size()), 2); + QCOMPARE(raster->patches[0].rect, QRectF(0, 0, 100, 100)); + QCOMPARE(raster->patches[0].image.cacheKey(), neighbour.cacheKey()); + QCOMPARE(raster->patches[1].rect, QRectF(150, 150, 50, 50)); +} + +void RasterSnapshotTests::drawKeepsHardEdgesUnderAnAntialiasingPainter() +{ + const RasterSnapshot raster(4, 1, solid(4, 1, Qt::red), QRectF(0, 0, 4, 1), {}); + QImage surface = solid(6, 1, Qt::green); + QPainter painter(&surface); + painter.setRenderHint(QPainter::Antialiasing, true); + raster.draw(QRectF(0.5, 0, 4, 1), painter); + QVERIFY(painter.testRenderHint(QPainter::Antialiasing)); + painter.end(); + int red = 0; + for (int x = 0; x < 6; ++x) { + const QRgb pixel = surface.pixel(x, 0); + QVERIFY(pixel == qRgba(255, 0, 0, 255) || pixel == qRgba(0, 255, 0, 255)); + red += pixel == qRgba(255, 0, 0, 255); + } + QCOMPARE(red, 4); + + const RasterSnapshot mask(4, 1, QImage(), QRectF(), {}, true); + QImage coverage = BrushRaster::context(6, 1, true); + QPainter masking(&coverage); + masking.setRenderHint(QPainter::Antialiasing, true); + mask.draw(QRectF(0.5, 0, 4, 1), masking); + masking.end(); + int white = 0; + for (int x = 0; x < 6; ++x) { + const uchar value = coverage.constScanLine(0)[x]; + QVERIFY(value == 0 || value == 255); + white += value == 255; + } + QCOMPARE(white, 4); +} + +void RasterSnapshotTests::concurrentFlatteningHappensOnce() +{ + std::vector<BrushPatch> patches; + for (int index = 0; index < 16; ++index) + patches.push_back({QRectF(index % 4 * 256, index / 4 * 256, 256, 256), coordinates(256, 256)}); + const RasterSnapshot raster(1024, 1024, QImage(), QRectF(), patches); + constexpr int workers = 16; + std::latch start(1); + std::vector<qint64> keys(workers); + std::vector<std::thread> threads; + for (int index = 0; index < workers; ++index) { + threads.emplace_back([&, index] { + start.wait(); + keys[index] = raster.makeImage().cacheKey(); + }); + } + start.count_down(); + for (std::thread &thread : threads) + thread.join(); + for (qint64 key : keys) + QCOMPARE(key, keys[0]); + QCOMPARE(raster.makeImage().pixel(300, 700), qRgba(300 % 256, 700 % 256, 0, 255)); +} + +void RasterSnapshotTests::patchesOutsideTheCropAreClippedAway() +{ + const auto raster = RasterSnapshot::replacing(std::nullopt, QRectF(0, 0, 40, 40), + {BrushPatch{QRectF(-10, -10, 20, 20), coordinates(20, 20)}, BrushPatch{QRectF(60, 0, 10, 10), solid(10, 10, Qt::blue)}}, + QRectF(0, 0, 40, 40)); + QCOMPARE(int(raster->patches.size()), 1); + QCOMPARE(raster->patches[0].rect, QRectF(0, 0, 10, 10)); + QCOMPARE(raster->patches[0].image.size(), QSize(10, 10)); + QCOMPARE(raster->patches[0].image.pixel(0, 0), qRgba(10, 10, 0, 255)); + QCOMPARE(raster->patches[0].image.pixel(9, 9), qRgba(19, 19, 0, 255)); + QVERIFY(raster->base.isNull()); + QCOMPARE(raster->baseRect, QRectF(0, 0, 40, 40)); +} + +void RasterSnapshotTests::shrinkingCropsTheBaseOnDraw() +{ + const ImportedImage source(coordinates(40, 30), QImage(), "Photo"); + const auto raster = RasterSnapshot::replacing(source, QRectF(0, 0, 40, 30), {}, QRectF(10, 5, 20, 20)); + QCOMPARE(raster->baseRect, QRectF(-10, -5, 40, 30)); + const QImage flat = raster->makeImage(); + QCOMPARE(flat.size(), QSize(20, 20)); + QCOMPARE(flat.pixel(0, 0), qRgba(10, 5, 0, 255)); + QCOMPARE(flat.pixel(19, 19), qRgba(29, 24, 0, 255)); + + QImage surface = solid(30, 30, Qt::green); + QPainter painter(&surface); + raster->draw(QRectF(5, 5, 20, 20), painter); + painter.end(); + QCOMPARE(surface.pixel(5, 5), qRgba(10, 5, 0, 255)); + QCOMPARE(surface.pixel(24, 24), qRgba(29, 24, 0, 255)); + for (const QPoint outside : {QPoint(4, 5), QPoint(5, 4), QPoint(25, 24), QPoint(24, 25)}) + QCOMPARE(surface.pixel(outside), qRgba(0, 255, 0, 255)); +} + +void RasterSnapshotTests::maskRastersRevealTheGrownArea() +{ + QImage gray = BrushRaster::context(8, 8, true); + gray.fill(100); + const ImportedImage source(gray, QImage(), "Mask"); + const auto raster = RasterSnapshot::replacing(source, QRectF(4, 2, 8, 8), {}, QRectF(0, 0, 16, 12), true); + QVERIFY(raster->isMask); + const QImage flat = raster->makeImage(); + QCOMPARE(flat.format(), QImage::Format_Grayscale8); + QCOMPARE(flat.constScanLine(0)[0], uchar(255)); + QCOMPARE(flat.constScanLine(11)[15], uchar(255)); + QCOMPARE(flat.constScanLine(2)[4], uchar(100)); + QCOMPARE(flat.constScanLine(9)[11], uchar(100)); + QCOMPARE(flat.constScanLine(10)[11], uchar(255)); +} + +void RasterSnapshotTests::drawScalesIntoTheRequestedRect() +{ + const ImportedImage source(solid(8, 8, Qt::red), QImage(), "Photo"); + const auto raster = RasterSnapshot::replacing(source, QRectF(0, 0, 8, 8), + {BrushPatch{QRectF(4, 0, 4, 8), solid(4, 8, Qt::blue)}}, QRectF(0, 0, 8, 8)); + QImage surface = solid(12, 6, Qt::green); + QPainter painter(&surface); + raster->draw(QRectF(2, 1, 4, 4), painter); + QVERIFY(!painter.hasClipping()); + painter.end(); + QCOMPARE(surface.pixel(2, 1), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(3, 4), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(4, 1), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(5, 4), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(1, 1), qRgba(0, 255, 0, 255)); + QCOMPARE(surface.pixel(6, 4), qRgba(0, 255, 0, 255)); + QCOMPARE(surface.pixel(2, 5), qRgba(0, 255, 0, 255)); + QVERIFY(!raster->hasMaterializedPixels()); + + QImage pair = BrushRaster::context(2, 1, false); + pair.setPixelColor(0, 0, Qt::red); + pair.setPixelColor(1, 0, Qt::blue); + QImage enlarged = BrushRaster::context(8, 1, false); + QPainter enlarging(&enlarged); + enlarging.setRenderHint(QPainter::SmoothPixmapTransform, true); + RasterSnapshot(2, 1, pair, QRectF(0, 0, 2, 1), {}).draw(QRectF(0, 0, 8, 1), enlarging); + enlarging.end(); + for (int x = 0; x < 8; ++x) + QCOMPARE(enlarged.pixel(x, 0), x < 4 ? qRgba(255, 0, 0, 255) : qRgba(0, 0, 255, 255)); +} + +void RasterSnapshotTests::flatteningIsLazyAndCached() +{ + const ImportedImage source(solid(8, 8, Qt::red), QImage(), "Photo"); + const auto raster = RasterSnapshot::replacing(source, QRectF(0, 0, 8, 8), {}, QRectF(0, 0, 8, 8)); + QVERIFY(!raster->hasMaterializedPixels()); + raster->thumbnail(); + QVERIFY(!raster->hasMaterializedPixels()); + const QImage first = raster->makeImage(); + QVERIFY(raster->hasMaterializedPixels()); + QCOMPARE(raster->makeImage().cacheKey(), first.cacheKey()); +} + +void RasterSnapshotTests::thumbnailFitsNinetySixPixels() +{ + const RasterSnapshot wide(4000, 2000, QImage(), QRectF(), {BrushPatch{QRectF(0, 0, 2000, 2000), solid(2, 2, Qt::blue)}}); + const QImage thumbnail = wide.thumbnail(); + QCOMPARE(thumbnail.size(), QSize(96, 48)); + QCOMPARE(thumbnail.pixel(47, 47), qRgba(0, 0, 255, 255)); + QCOMPARE(thumbnail.pixel(48, 47), qRgba(0, 0, 0, 0)); + QCOMPARE(RasterSnapshot(8, 4, QImage(), QRectF(), {}).thumbnail().size(), QSize(8, 4)); + QCOMPARE(RasterSnapshot(4000, 10, QImage(), QRectF(), {}).thumbnail().size(), QSize(96, 1)); + QCOMPARE(RasterSnapshot(8, 4, QImage(), QRectF(3, 2, 1, 1), {}).alignment, QPointF(3, 2)); + QCOMPARE(RasterSnapshot(8, 4, QImage(), QRectF(3, 2, 1, 1), {}, false, QPointF(7, 9)).alignment, QPointF(7, 9)); +} + +void RasterSnapshotTests::paintedAssetsReportSizeIdentityAndBytesWithoutFlattening() +{ + const auto raster = std::make_shared<const RasterSnapshot>(40, 30, QImage(), QRectF(), std::vector<BrushPatch>()); + const auto mask = std::make_shared<const RasterSnapshot>(40, 30, QImage(), QRectF(), std::vector<BrushPatch>(), true); + const ImportedImage asset = painted(raster); + QCOMPARE(asset.size(), QSize(40, 30)); + QCOMPARE(asset.byteCount(), qint64(40 * 30 * 4)); + QCOMPARE(painted(mask).byteCount(), qint64(40 * 30)); + QVERIFY(asset.identity() == painted(raster).identity()); + QVERIFY(!(asset.identity() == painted(mask).identity())); + QVERIFY(!raster->hasMaterializedPixels()); + QCOMPARE(asset.image().size(), QSize(40, 30)); + QVERIFY(raster->hasMaterializedPixels()); + + const QImage pixels = solid(5, 4, Qt::red); + const ImportedImage plain(pixels, QImage(), "Photo"); + QCOMPARE(plain.size(), QSize(5, 4)); + QCOMPARE(plain.byteCount(), qint64(5 * 4 * 4)); + QCOMPARE(plain.image().cacheKey(), pixels.cacheKey()); + QVERIFY(plain.identity() == ImportedImage(pixels, QImage(), "Copy").identity()); + QVERIFY(!(plain.identity() == ImportedImage(pixels.copy(), QImage(), "Photo").identity())); + QVERIFY(!(plain.identity() == asset.identity())); +} + +void RasterSnapshotTests::historyBudgetNeverFlattensPaintedTiles() +{ + const auto raster = std::make_shared<const RasterSnapshot>(40, 30, QImage(), QRectF(), std::vector<BrushPatch>()); + DocumentHistory history; + std::optional<CanvasDocument> document = CanvasDocument(40, 30); + document->layers.push_back(ImageLayer(painted(raster), QPointF(0, 0))); + QVERIFY(document->layers[0] == ImageLayer(document->layers[0])); + history.begin("Select Layer", document, std::nullopt); + history.end(document, document->layers[0].id); + QCOMPARE(history.undoCount(), 0); + QVERIFY(!raster->hasMaterializedPixels()); + history.begin("Delete Layer", document, std::nullopt); + const qint64 thumbnailBytes = document->layers[0].asset->thumbnail.sizeInBytes(); + document->layers.clear(); + history.end(document, std::nullopt); + QCOMPARE(history.retainedBytes(document), qint64(40 * 30 * 4) + thumbnailBytes); + QVERIFY(!raster->hasMaterializedPixels()); +} + +QTEST_APPLESS_MAIN(RasterSnapshotTests) +#include "RasterSnapshotTests.moc" diff --git a/tests/RemoveBackgroundTests.cpp b/tests/RemoveBackgroundTests.cpp new file mode 100644 index 0000000..e1eb57b --- /dev/null +++ b/tests/RemoveBackgroundTests.cpp @@ -0,0 +1,181 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "SelectionFixtures.h" +#include "SessionFixtures.h" +#include <QPainter> +#include <QtTest> + +// Remove Background in the session: a preview, then a mask. +namespace { +// A red disc on white, a subject the model finds. +std::unique_ptr<EditorSession> withDisc() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 300); + QImage image = BrushRaster::context(400, 300, false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(217, 26, 26)); + painter.drawEllipse(QPointF(200, 150), 80, 80); + painter.end(); + session->insert(ImportedImage(image, image, QStringLiteral("Disc"))); + return session; +} + +bool settled(const EditorSession &session) +{ + return QTest::qWaitFor([&] { return session.filterEdit() && !session.filterEdit().value().preparing; }, 20000); +} + +bool committed(EditorSession &session) +{ + bool done = false; + session.commitFilter([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} + +int at(const QImage &mask, int x, int y) +{ + return mask.constScanLine(y)[x]; +} + +QImage maskOf(const EditorSession &session, QUuid id) +{ + return layerWith(session, id).mask.value().asset.image(); +} + +QImage gray(int value) +{ + QImage image(400, 300, QImage::Format_Grayscale8); + image.fill(value); + return image; +} +} + +class RemoveBackgroundTests : public QObject { + Q_OBJECT +private slots: + void thePreviewClearsTheBackgroundAndTheCommitMasksIt(); + void anExistingMaskAndASelectionAreKept(); + void aPlacedMaskGivesWayToOneInTheLayersGrid(); + void aMaskOfAnotherGridIsReplaced(); + void noSubjectCommitsNothing(); +}; + +void RemoveBackgroundTests::thePreviewClearsTheBackgroundAndTheCommitMasksIt() +{ + const auto session = withDisc(); + const QUuid id = session->activeLayerID().value(); + const ImageLayer before = layerWith(*session, id); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session)); + // Previewed whole: the white round the disc goes clear. + const FilterEdit &edit = session->filterEdit().value(); + QVERIFY(edit.previewSource.size() == QSize(400, 300) && !edit.previewError); + const QImage preview = edit.previewImage(id).value(); + QVERIFY(preview.pixelColor(0, 0).alpha() == 0 && preview.pixelColor(200, 150) == before.asset->image().pixelColor(200, 150)); + const int steps = session->history.undoCount(); + QVERIFY(committed(*session)); + // The pixels stay; a mask hides the background instead. + const ImageLayer after = layerWith(*session, id); + QVERIFY(after.asset->identity() == before.asset->identity() && after.transform == before.transform); + const LayerMask &mask = after.mask.value(); + QVERIFY(mask.isEnabled && !mask.placement && mask.isLinked); + QVERIFY(mask.asset.image().format() == QImage::Format_Grayscale8 && mask.asset.size() == QSize(400, 300)); + QVERIFY(at(mask.asset.image(), 200, 150) == 255 && at(mask.asset.image(), 0, 0) == 0); + // One step; the mask chosen, panel closed, project free. + QVERIFY(session->history.undoCount() == steps + 1 && session->history.undoName() == "Remove Background"); + QVERIFY(session->isMaskSelected() && !session->filterEdit() && !session->isProjectBusy()); + session->undo(); + QVERIFY(!layerWith(*session, id).mask); +} + +void RemoveBackgroundTests::anExistingMaskAndASelectionAreKept() +{ + const auto session = withDisc(); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(gray(128))); + record(snapshot, id).maskEnabled = false; + }); + session->selectLayer(id); + // The left half selected: only it changes. + session->applySelection(rectPath(QRectF(0, 0, 200, 300)), SelectionMode::replace, QStringLiteral("Select")); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session) && committed(*session)); + const QImage mask = maskOf(*session, id); + // Inside, both masks hide; outside, the old mask stays. + QVERIFY(at(mask, 190, 150) == 128 && at(mask, 10, 10) == 0); + QVERIFY(at(mask, 210, 150) == 128 && at(mask, 390, 10) == 128); + // A disabled mask is multiplied in, then shown, as Swift's. + QVERIFY(layerWith(*session, id).mask.value().isEnabled); + // Without a selection, a white mask stands in for none. + session->undo(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskFile = std::nullopt; }); + session->selectLayer(id); + session->applySelection(rectPath(QRectF(0, 0, 200, 300)), SelectionMode::replace, QStringLiteral("Select")); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session) && committed(*session)); + const QImage fresh = maskOf(*session, id); + QVERIFY(at(fresh, 190, 150) == 255 && at(fresh, 10, 10) == 0 && at(fresh, 390, 10) == 255 && at(fresh, 210, 150) == 255); +} + +void RemoveBackgroundTests::aPlacedMaskGivesWayToOneInTheLayersGrid() +{ + const auto session = withDisc(); + const QUuid id = session->activeLayerID().value(); + LayerTransform placed = layerWith(*session, id).transform; + placed.origin += QPointF(50, 40); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(gray(64))); + record(snapshot, id).maskPlacement = placed; + record(snapshot, id).maskLinked = false; + }); + session->selectLayer(id); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session) && committed(*session)); + // The placed mask's pixels are not kept, nor its placement. + const LayerMask mask = layerWith(*session, id).mask.value(); + QVERIFY(!mask.placement && !mask.isLinked && mask.isEnabled); + QVERIFY(at(mask.asset.image(), 200, 150) == 255 && at(mask.asset.image(), 0, 0) == 0); +} + +void RemoveBackgroundTests::aMaskOfAnotherGridIsReplaced() +{ + const auto session = withDisc(); + const QUuid id = session->activeLayerID().value(); + // A solid mask one pixel square covers the layer. + QImage solid(1, 1, QImage::Format_Grayscale8); + solid.fill(0); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(solid)); }); + session->selectLayer(id); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session) && committed(*session)); + const QImage mask = maskOf(*session, id); + QVERIFY(mask.size() == QSize(400, 300) && at(mask, 200, 150) == 255 && at(mask, 0, 0) == 0); +} + +void RemoveBackgroundTests::noSubjectCommitsNothing() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(64, 48); + QImage flat = BrushRaster::context(64, 48, false); + flat.fill(QColor(90, 140, 200)); + session->insert(ImportedImage(flat, flat, QStringLiteral("Flat"))); + const QUuid id = session->activeLayerID().value(); + session->beginFilter(FilterKind::removeBackground); + QVERIFY(settled(*session)); + QCOMPARE(session->filterEdit().value().previewError.value(), + QString("No foreground subject was detected in this layer. Try an image with a more distinct subject.")); + const int steps = session->history.undoCount(); + // OK does nothing; the panel stays for Cancel. + QVERIFY(committed(*session)); + QVERIFY(!layerWith(*session, id).mask && session->history.undoCount() == steps && session->filterEdit()); + session->cancelFilter(); + QVERIFY(!session->filterEdit()); +} + +QTEST_GUILESS_MAIN(RemoveBackgroundTests) +#include "RemoveBackgroundTests.moc" diff --git a/tests/RenderFixtures.h b/tests/RenderFixtures.h new file mode 100644 index 0000000..1722337 --- /dev/null +++ b/tests/RenderFixtures.h @@ -0,0 +1,131 @@ +#pragma once +#include "Document/BrushStroke.h" +#include "Rendering/RasterSnapshot.h" +#include <QImage> +#include <functional> +#include <memory> +#include <vector> + +// An RGBA image filled with one premultiplied pixel value. +inline QImage solid(int width, int height, QRgb premultiplied) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.setPixel(x, y, premultiplied); + } + return image; +} + +// Red holds x, green holds y, opaque. +inline QImage coordinates(int width, int height, int step = 10) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.setPixel(x, y, qRgba(x * step, y * step, 0, 255)); + } + return image; +} + +inline QImage gray(int width, int height, int value) +{ + QImage image = BrushRaster::context(width, height, true); + image.fill(value); + return image; +} + +inline LayerTransform placedAt(QPointF origin, QSizeF size) +{ + return {.origin = origin, .size = size, .sampling = LayerSampling::nearest}; +} + +// Detailed, deterministic pixels. +inline QImage noise(int width, int height, quint32 seed, int alpha = 255) +{ + QImage image = BrushRaster::context(width, height, false); + quint32 state = seed; + for (int y = 0; y < height; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x, row += 4) { + state = state * 1'664'525u + 1'013'904'223u; + row[0] = uchar(int(uchar(state >> 24)) * alpha / 255); + row[1] = uchar(int(uchar(state >> 16)) * alpha / 255); + row[2] = uchar(int(uchar(state >> 8)) * alpha / 255); + row[3] = uchar(alpha); + } + } + return image; +} + +// A painted layer: unchanged pixels and replacement tiles. +inline std::shared_ptr<const RasterSnapshot> painted(const QImage &base, const std::vector<BrushPatch> &patches) +{ + return std::make_shared<const RasterSnapshot>(base.width(), base.height(), base, QRectF(base.rect()), patches); +} + +// Smooth pixels: a slipped sample changes little. +inline QImage ramp(int width, int height, int seed) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + image.setPixel(x, y, qRgba((x + seed) / 4 % 256, y / 4 % 256, seed % 256, 255)); + } + return image; +} + +inline QImage composite(const QImage &base, const std::vector<BrushPatch> &patches) +{ + QImage result = BrushRaster::context(base.width(), base.height(), false); + QPainter painter(&result); + BrushRaster::draw(base, QRectF(base.rect()), painter); + for (const BrushPatch &patch : patches) + BrushRaster::draw(patch.image, patch.rect, painter); + return result; +} + +inline QImage render(int side, const std::function<void(QPainter &)> &draw) +{ + QImage surface = BrushRaster::context(side, side, false); + QPainter painter(&surface); + draw(painter); + return surface; +} + +// Largest channel difference over pixels fully inside `outline`'s layer. +inline int largestDifference(const QImage &expected, const QImage &actual, const QImage &outline) +{ + int largest = 0; + const int side = expected.width(); + for (int y = 1; y < side - 1; ++y) { + for (int x = 1; x < side - 1; ++x) { + bool inside = true; + for (int dy = -1; dy <= 1 && inside; ++dy) { + for (int dx = -1; dx <= 1 && inside; ++dx) + inside = outline.constScanLine(y + dy)[(x + dx) * 4 + 3] == 255; + } + if (!inside) + continue; + for (int channel = 0; channel < 4; ++channel) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x * 4 + channel] - actual.constScanLine(y)[x * 4 + channel])); + } + } + return largest; +} + +inline int largestDifference(const QImage &expected, const QImage &actual) +{ + return largestDifference(expected, actual, expected); +} + +// Largest channel difference over every pixel. +inline int largestAnywhere(const QImage &expected, const QImage &actual) +{ + int largest = 0; + for (int y = 0; y < expected.height(); ++y) { + for (int x = 0; x < expected.width() * 4; ++x) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - actual.constScanLine(y)[x])); + } + return largest; +} diff --git a/tests/SelectMenuTests.cpp b/tests/SelectMenuTests.cpp new file mode 100644 index 0000000..4e2dd0b --- /dev/null +++ b/tests/SelectMenuTests.cpp @@ -0,0 +1,372 @@ +#include "MenuFixtures.h" +#include <QApplication> +#include <QLineEdit> +#include <QRegularExpression> + +// The Select and Image menus, the Edit menu's clipboard. +class SelectMenuTests : public QObject { + Q_OBJECT +private slots: + void selectEntriesFollowTheSelectionAndActOnIt(); + void selectAllInAFieldIsTheFields(); + void invertFollowsTheActiveLayerAndItsMask(); + void levelsOpensOnPixelsAndRestsNewAndImport(); + void hueSaturationOpensOnPixelsAndRestsLevels(); + void filtersOpenFromTheImageAndFilterMenus(); + void copyPasteAndLayerViaCopyFollowTheSelection(); + void copyAndPasteInAFieldAreTheFields(); + void contentAwareFillFollowsTheSelection(); + void cutTheFillsAndClearFollowTheSelectionAndTheField(); + void transformTheSelectionOrTheLayer(); +}; + +void SelectMenuTests::selectEntriesFollowTheSelectionAndActOnIt() +{ + Bar bar; + for (const char *name : {"selectAll", "deselect", "inverse", "layerPixels", "maskBlackAreas", "expandSelection", "contractSelection"}) + QVERIFY2(!bar.action(name).isEnabled(), name); + bar.session().createDocument(50, 50, true); + QVERIFY(bar.action("selectAll").isEnabled()); + // A blank layer has no pixels and no mask. + QVERIFY(!bar.action("layerPixels").isEnabled() && !bar.action("maskBlackAreas").isEnabled()); + QVERIFY(!bar.action("deselect").isEnabled() && !bar.action("inverse").isEnabled()); + bar.action("selectAll").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Select All")); + QVERIFY(bar.action("deselect").isEnabled() && bar.action("inverse").isEnabled()); + QVERIFY(bar.action("expandSelection").isEnabled() && bar.action("contractSelection").isEnabled()); + QCOMPARE(bar.action("expandSelection").text(), QString("Expand by 1 px")); + QCOMPARE(bar.action("contractSelection").text(), QString("Contract by 1 px")); + bar.session().setSelectionExpandAmount(3); + bar.session().setSelectionContractAmount(7); + QCOMPARE(bar.action("expandSelection").text(), QString("Expand by 3 px")); + QCOMPARE(bar.action("contractSelection").text(), QString("Contract by 7 px")); + bar.action("contractSelection").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Contract Selection")); + QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(7, 7, 36, 36)); + bar.action("expandSelection").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Expand Selection")); + QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(4, 4, 42, 42)); + bar.action("inverse").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Inverse")); + bar.action("deselect").trigger(); + QVERIFY(!bar.session().selection().has_value()); + // Pixels and a mask: their entries load them. + bar.session().insert(white()); + QVERIFY(bar.action("layerPixels").isEnabled() && !bar.action("maskBlackAreas").isEnabled()); + bar.action("layerPixels").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Load Layer Selection")); + bar.session().addLayerMask(false); + QVERIFY(bar.action("maskBlackAreas").isEnabled()); + // The same outline again records nothing; deselected, it loads. + bar.action("maskBlackAreas").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Add Hide-All Mask")); + bar.session().deselect(); + bar.action("maskBlackAreas").trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Load Mask Selection")); + // Busy, every entry waits; a draft holds Expand and Contract. + bar.session().setIsProjectBusy(true); + for (const char *name : {"deselect", "inverse", "layerPixels", "maskBlackAreas", "expandSelection", "contractSelection"}) + QVERIFY2(!bar.action(name).isEnabled(), name); + QVERIFY(bar.action("selectAll").isEnabled()); + bar.session().setIsProjectBusy(false); + bar.session().selectTool(NavigationTool::lasso); + bar.session().beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!bar.action("expandSelection").isEnabled() && bar.action("deselect").isEnabled()); +} + +void SelectMenuTests::selectAllInAFieldIsTheFields() +{ + Bar bar; + bar.window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); + // Without a canvas the entry rests; the welcome keeps Ctrl+A. + QLineEdit &width = *bar.window.findChild<QLineEdit *>("widthInput"); + QTRY_VERIFY(width.hasFocus()); + QVERIFY(!bar.action("selectAll").isEnabled()); + bar.session().createDocument(50, 50, true); + QTRY_VERIFY(bar.action("selectAll").isEnabled()); + // A rename in progress: the editor's text, never the canvas. + bar.session().setRenamingLayerID(bar.session().activeLayerID()); + QLineEdit *editor = nullptr; + QTRY_VERIFY((editor = bar.window.findChild<QLineEdit *>("layerNameEditor")) && editor->hasFocus()); + editor->deselect(); + QVERIFY(editor->selectedText().isEmpty()); + bar.action("selectAll").trigger(); + QCOMPARE(editor->selectedText(), QString("Layer 1")); + QVERIFY(!bar.session().selection().has_value()); + QTest::keyClick(editor, Qt::Key_Escape); + QTRY_VERIFY(!bar.session().renamingLayerID().has_value()); + bar.action("selectAll").trigger(); + QVERIFY(bar.session().selection().has_value()); +} + +void SelectMenuTests::invertFollowsTheActiveLayerAndItsMask() +{ + Bar bar; + QAction &invert = bar.action("invert"); + QVERIFY(!invert.isEnabled()); + QCOMPARE(invert.text(), QString("Invert")); + bar.session().createDocument(8, 8, true); + QVERIFY(!invert.isEnabled()); + bar.session().insert(white()); + QVERIFY(invert.isEnabled()); + bar.session().addLayerMask(); + QCOMPARE(invert.text(), QString("Invert Mask")); + invert.trigger(); + QTRY_VERIFY(!bar.session().isProjectBusy()); + QCOMPARE(bar.session().history.undoName(), QString("Invert Mask")); + QCOMPARE(int(bar.session().activeLayer().value().mask.value().asset.image().constScanLine(0)[0]), 0); + bar.session().selectLayerTarget(bar.session().activeLayerID().value(), false); + QCOMPARE(invert.text(), QString("Invert")); + invert.trigger(); + QTRY_VERIFY(!bar.session().isProjectBusy()); + QCOMPARE(bar.session().history.undoName(), QString("Invert")); + QCOMPARE(bar.session().activeLayer().value().asset.value().image().pixelColor(0, 0), QColor(Qt::black)); + bar.session().setIsImporting(true); + QVERIFY(!invert.isEnabled()); +} + +void SelectMenuTests::levelsOpensOnPixelsAndRestsNewAndImport() +{ + Bar bar; + QAction &levels = bar.action("levels"), &import = bar.action("importImages"), &undo = bar.action("undo"); + QAction &fresh = *bar.window.findChild<QAction *>("newCanvasToolbar"); + QCOMPARE(levels.text(), QString("Levels…")); + QVERIFY(!levels.isEnabled()); + // A blank layer holds no pixels to adjust. + bar.session().createDocument(8, 8, true); + QVERIFY(!levels.isEnabled()); + bar.session().insert(white()); + QVERIFY(levels.isEnabled() && import.isEnabled() && fresh.isEnabled()); + levels.trigger(); + QVERIFY(bar.session().levels()); + // Open, it rests itself, New and Import; Undo is bare. + QVERIFY(!levels.isEnabled() && !import.isEnabled() && !fresh.isEnabled()); + QCOMPARE(undo.text(), QString("Undo")); + QVERIFY(!undo.isEnabled()); + bar.session().cancelLevels(); + QVERIFY(levels.isEnabled() && import.isEnabled() && fresh.isEnabled()); + QCOMPARE(undo.text(), QString("Undo Import Image")); + bar.session().addLayerMask(); + QVERIFY(!levels.isEnabled()); +} + +void SelectMenuTests::hueSaturationOpensOnPixelsAndRestsLevels() +{ + Bar bar; + QAction &hue = bar.action("hueSaturation"), &levels = bar.action("levels"), &import = bar.action("importImages"); + QCOMPARE(hue.text(), QString("Hue/Saturation…")); + QVERIFY(!hue.isEnabled()); + bar.session().createDocument(8, 8, true); + QVERIFY(!hue.isEnabled()); + bar.session().insert(white()); + QVERIFY(hue.isEnabled() && levels.isEnabled()); + hue.trigger(); + const QUuid opened = bar.session().hueSaturation().value().id; + // Open, it rests Levels; its own entry stays, as Swift's. + QVERIFY(!levels.isEnabled() && hue.isEnabled() && import.isEnabled()); + hue.trigger(); + QCOMPARE(bar.session().hueSaturation().value().id, opened); + bar.session().cancelHueSaturation(); + QVERIFY(levels.isEnabled()); + // Open Levels rests it in turn. + levels.trigger(); + QVERIFY(!hue.isEnabled()); + bar.session().cancelLevels(); + QVERIFY(hue.isEnabled()); +} + +void SelectMenuTests::filtersOpenFromTheImageAndFilterMenus() +{ + Bar bar; + const std::pair<const char *, FilterKind> entries[] = { + {"curves", FilterKind::curves}, {"exposure", FilterKind::exposure}, {"gradientMap", FilterKind::gradientMap}, + {"grain", FilterKind::grain}, {"gaussianBlur", FilterKind::gaussianBlur}, {"motionBlur", FilterKind::motionBlur}, + {"addNoise", FilterKind::addNoise}, {"lensCorrection", FilterKind::lensCorrection}, {"removeBackground", FilterKind::removeBackground}}; + for (const auto &[name, kind] : entries) { + QCOMPARE(bar.action(name).text(), rawValue(kind) + QStringLiteral("…")); + QVERIFY2(!bar.action(name).isEnabled(), name); + } + bar.session().createDocument(8, 8, true); + bar.session().insert(white()); + // Each opens its filter; open, a filter rests every entry. + for (const auto &[name, kind] : entries) { + QVERIFY2(bar.action(name).isEnabled(), name); + bar.action(name).trigger(); + QCOMPARE(bar.session().filterEdit().value().kind, kind); + for (const auto &[other, unused] : entries) + QVERIFY2(!bar.action(other).isEnabled(), other); + bar.session().cancelFilter(); + } + // Hue/Saturation open rests them too, as Levels. + bar.session().beginHueSaturation(); + for (const auto &[name, kind] : entries) + QVERIFY2(!bar.action(name).isEnabled(), name); + bar.session().cancelHueSaturation(); + QVERIFY(bar.action("curves").isEnabled()); +} + +void SelectMenuTests::copyPasteAndLayerViaCopyFollowTheSelection() +{ + Bar bar; + QAction © = bar.action("copy"), &merged = bar.action("copyMerged"), &paste = bar.action("paste"), &via = bar.action("layerViaCopy"); + // Copy and Paste are always live and check when chosen. + QVERIFY(copy.isEnabled() && paste.isEnabled() && !merged.isEnabled() && !via.isEnabled()); + copy.trigger(); + paste.trigger(); + QVERIFY(!bar.session().document().has_value()); + bar.session().createDocument(8, 8, true); + QCOMPARE(via.text(), QString("Duplicate Layer")); + QVERIFY(via.isEnabled() && !merged.isEnabled()); + // A blank layer's selection has no pixels for a layer. + bar.session().selectAll(); + QVERIFY(!via.isEnabled()); + bar.session().deselect(); + QVERIFY(via.isEnabled()); + bar.session().insert(white()); + QVERIFY(merged.isEnabled()); + bar.session().selectAll(); + QCOMPARE(via.text(), QString("Layer via Copy")); + QVERIFY(via.isEnabled()); + copy.trigger(); + QVERIFY(bar.session().pixelClipboard().has_value()); + paste.trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Paste")); + QCOMPARE(int(bar.session().document().value().layers.size()), 3); + bar.session().selectAll(); + via.trigger(); + QCOMPARE(bar.session().history.undoName(), QString("Layer via Copy")); + merged.trigger(); + QVERIFY(bar.session().pixelClipboard().has_value()); + bar.session().setIsProjectBusy(true); + QVERIFY(!merged.isEnabled() && !via.isEnabled() && copy.isEnabled() && paste.isEnabled()); +} + +void SelectMenuTests::copyAndPasteInAFieldAreTheFields() +{ + Bar bar; + bar.window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); + QLineEdit &width = *bar.window.findChild<QLineEdit *>("widthInput"); + QTRY_VERIFY(width.hasFocus()); + width.setText("77"); + width.selectAll(); + bar.action("copy").trigger(); + width.setText(""); + bar.action("paste").trigger(); + QCOMPARE(width.text(), QString("77")); + QVERIFY(!bar.session().pixelClipboard().has_value()); +} + +void SelectMenuTests::contentAwareFillFollowsTheSelection() +{ + Bar bar; + QAction &fill = bar.action("contentAwareFill"); + QCOMPARE(fill.text(), QString("Content-Aware Fill…")); + QVERIFY(!fill.isEnabled()); + bar.session().createDocument(8, 8, true); + bar.session().insert(white()); + QVERIFY(!fill.isEnabled()); + bar.session().selectAll(); + QVERIFY(fill.isEnabled()); + fill.trigger(); + QVERIFY(bar.session().filterEdit().has_value() && !fill.isEnabled()); + bar.session().cancelFilter(); + QVERIFY(fill.isEnabled()); + bar.session().setIsProjectBusy(true); + QVERIFY(!fill.isEnabled()); +} + +void SelectMenuTests::cutTheFillsAndClearFollowTheSelectionAndTheField() +{ + Bar bar; + QAction &cut = bar.action("cut"), &foreground = bar.action("fillForeground"), &background = bar.action("fillBackground"), &clear = bar.action("clearSelectionPixels"); + QCOMPARE(foreground.text(), QString("Fill with Foreground Color")); + QCOMPARE(background.text(), QString("Fill with Background Color")); + QCOMPARE(clear.text(), QString("Clear Selection Pixels")); + // Cut checks when chosen; the others follow the pixels. + QVERIFY(cut.isEnabled() && !foreground.isEnabled() && !background.isEnabled() && !clear.isEnabled()); + cut.trigger(); + QVERIFY(!bar.session().document().has_value()); + bar.session().createDocument(8, 8, true); + bar.session().insert(white()); + QVERIFY(foreground.isEnabled() && background.isEnabled() && !clear.isEnabled()); + // A rename closes the fills, as Swift's `canEditPixels` does. + bar.session().setRenamingLayerID(bar.session().activeLayerID()); + QVERIFY(!foreground.isEnabled() && !background.isEnabled()); + bar.session().setRenamingLayerID(std::nullopt); + // In a text field the entries are its own deletions. + bar.window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar.window)); + QLineEdit &percent = *bar.window.findChild<QLineEdit *>("opacityPercent"); + percent.setFocus(); + QTRY_VERIFY(percent.hasFocus()); + percent.setText(QStringLiteral("45")); + percent.end(false); + foreground.trigger(); + QCOMPARE(percent.text(), QString("")); + percent.setText(QStringLiteral("45")); + percent.end(false); + background.trigger(); + QCOMPARE(percent.text(), QString("")); + // At the start nothing lies behind the caret. + percent.setText(QStringLiteral("45")); + percent.home(false); + foreground.trigger(); + QCOMPARE(percent.text(), QString("45")); + background.trigger(); + QCOMPARE(percent.text(), QString("45")); + // Mid-text they delete backwards; a selection goes alone. + percent.setCursorPosition(1); + background.trigger(); + QCOMPARE(percent.text(), QString("5")); + percent.setText(QStringLiteral("1245")); + percent.setSelection(1, 2); + foreground.trigger(); + QCOMPARE(percent.text(), QString("15")); + percent.setText(QStringLiteral("1245")); + percent.setSelection(3, -2); + background.trigger(); + QCOMPARE(percent.text(), QString("15")); + QCOMPARE(bar.session().history.undoName(), QString("Import Image")); + percent.clearFocus(); + QTRY_VERIFY(!percent.hasFocus()); + // Without a selection Cut has nothing, whole layer or not. + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("nothing to cut"))); + cut.trigger(); + bar.session().selectAll(); + QVERIFY(clear.isEnabled()); + cut.trigger(); + QTRY_COMPARE(bar.session().history.undoName(), QString("Clear")); + QVERIFY(bar.session().pixelClipboard().has_value()); + bar.session().undo(); + clear.trigger(); + QTRY_COMPARE(bar.session().history.undoName(), QString("Clear")); + background.trigger(); + QTRY_COMPARE(bar.session().history.undoName(), QString("Fill")); +} + +void SelectMenuTests::transformTheSelectionOrTheLayer() +{ + Bar bar; + QAction &transform = bar.action("transformLayer"); + QVERIFY(!transform.isEnabled()); + bar.session().createDocument(8, 8, true); + bar.session().insert(white()); + QCOMPARE(transform.text(), QString("Transform Layer")); + QVERIFY(transform.isEnabled()); + bar.session().selectAll(); + QCOMPARE(transform.text(), QString("Transform Selection")); + transform.trigger(); + QTRY_VERIFY(bar.session().transformEdit().has_value() && bar.session().transformEdit().value().floating); + // Floating, neither transform may start again. + QVERIFY(!transform.isEnabled()); + QCOMPARE(transform.text(), QString("Transform Layer")); + bar.session().cancelTransform(); + bar.session().deselect(); + transform.trigger(); + QVERIFY(bar.session().transformEdit().has_value() && !bar.session().transformEdit().value().floating); +} + +QTEST_MAIN(SelectMenuTests) +#include "SelectMenuTests.moc" diff --git a/tests/SelectionCanvasFixtures.h b/tests/SelectionCanvasFixtures.h new file mode 100644 index 0000000..fe26d4e --- /dev/null +++ b/tests/SelectionCanvasFixtures.h @@ -0,0 +1,64 @@ +#pragma once +#include "CanvasFixtures.h" +#include "SelectionFixtures.h" +#include <QPaintEvent> + +// Shared by the selection canvas tests: canvas, events, spy. +inline QImage image(const QCursor &cursor) +{ + return cursor.pixmap().toImage(); +} + +// The document fills the canvas: view points are pixels. +struct Canvas : Shown { + Canvas() : Shown(QSize(400, 300), QSize(400, 300)) + { + settle(); + session.zoom(1); + session.selectTool(NavigationTool::marquee); + canvas->synchronizeDisplay(); + } + void press(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mousePress(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void move(QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QMouseEvent event(QEvent::MouseMove, to, to, canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, modifiers); + QApplication::sendEvent(canvas, &event); + } + void hover(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QMouseEvent event(QEvent::MouseMove, at, at, canvas->mapToGlobal(at.toPoint()), Qt::NoButton, Qt::NoButton, modifiers); + QApplication::sendEvent(canvas, &event); + } + void release(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mouseRelease(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void click(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(at, modifiers); + release(at, modifiers); + } + void drag(QPointF from, QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(from, modifiers); + move(to, modifiers); + release(to, modifiers); + } + bool shows(const QCursor &cursor) const { return image(canvas->cursor()) == image(cursor); } + int coverage(int x, int y) const { return ::coverage(session, x, y); } +}; + +// Counts repaints and unites what they covered. +class PaintSpy : public QObject { +public: + explicit PaintSpy(QWidget &widget) { widget.installEventFilter(this); } + QRect painted; + int count = 0; + +protected: + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::Paint) { + painted |= static_cast<QPaintEvent *>(event)->region().boundingRect(); + ++count; + } + return false; + } +}; diff --git a/tests/SelectionCanvasTests.cpp b/tests/SelectionCanvasTests.cpp new file mode 100644 index 0000000..1da93ee --- /dev/null +++ b/tests/SelectionCanvasTests.cpp @@ -0,0 +1,323 @@ +#include "SelectionCanvasFixtures.h" + +// Marquee and Lasso gestures on the canvas: drags, keys, focus. +class SelectionCanvasTests : public QObject { + Q_OBJECT +private slots: + void marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares(); + void theFreehandLassoDragsAnOutlineAndClicksMoveOrDeselect(); + void thePolygonalLassoTakesCornersAndClosesByClickReturnOrDoubleClick(); + void arrowsNudgeTheOutlineAndTheKeysMAndLChooseTools(); + void focusLossEndsAMarqueeDraftAndAnOutlineMove(); + void shiftDeleteBeginsAContentAwareFill(); + void aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem(); +}; + +void SelectionCanvasTests::marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares() +{ + Canvas shown; + EditorSession &session = shown.session; + session.applySelection(rectPath(QRectF(5, 5, 10, 10)), SelectionMode::replace, "Select"); + // Shift held throughout: an add, 40–70 × 40–50, not squared. + shown.drag(QPointF(40, 40), QPointF(70, 50), Qt::ShiftModifier); + QCOMPARE(session.history.undoName(), QString("Rectangular Marquee")); + QCOMPARE(shown.coverage(10, 10), 255); + QCOMPARE(shown.coverage(65, 45), 255); + QCOMPARE(shown.coverage(65, 60), 0); + // Shift pressed afresh mid-drag: squared to 20–40 × 60–80. + shown.press(QPointF(20, 60), Qt::ShiftModifier); + shown.move(QPointF(30, 65), Qt::ShiftModifier); + shown.move(QPointF(35, 68)); + shown.move(QPointF(40, 70), Qt::ShiftModifier); + shown.release(QPointF(40, 70), Qt::ShiftModifier); + QCOMPARE(shown.coverage(30, 75), 255); + QCOMPARE(shown.coverage(65, 45), 255); + QCOMPARE(shown.coverage(10, 10), 255); + // Alt subtracts and never draws from the centre. + session.selectAll(); + shown.drag(QPointF(40, 40), QPointF(60, 60), Qt::AltModifier); + QCOMPARE(shown.coverage(50, 50), 0); + QCOMPARE(shown.coverage(30, 30), 255); + // A key change reshapes the box before the pointer moves. + session.deselect(); + shown.press(QPointF(100, 100)); + shown.move(QPointF(130, 110)); + QCOMPARE(session.lassoDraft().value().points[2], QPointF(130, 110)); + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Shift, Qt::ShiftModifier); + QCOMPARE(session.lassoDraft().value().points[2], QPointF(130, 130)); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Shift, Qt::NoModifier); + QCOMPARE(session.lassoDraft().value().points[2], QPointF(130, 110)); + shown.release(QPointF(130, 110)); + QCOMPARE(bounds(session), QRectF(100, 100, 30, 10)); + // A click outside deselects; busy, a press draws nothing. + shown.click(QPointF(200, 200)); + QVERIFY(!session.selection().has_value()); + session.setIsProjectBusy(true); + shown.press(QPointF(10, 10)); + QVERIFY(!session.lassoDraft().has_value()); + session.setIsProjectBusy(false); + shown.release(QPointF(10, 10)); +} + +void SelectionCanvasTests::theFreehandLassoDragsAnOutlineAndClicksMoveOrDeselect() +{ + Canvas shown; + EditorSession &session = shown.session; + session.selectTool(NavigationTool::lasso); + shown.press(QPointF(10, 10)); + for (const QPointF point : {QPointF(90, 10), QPointF(90, 90), QPointF(10, 90)}) + shown.move(point); + QCOMPARE(session.lassoDraft().value().points.size(), size_t(4)); + shown.release(QPointF(10, 90)); + QCOMPARE(session.history.undoName(), QString("Lasso")); + QCOMPARE(shown.coverage(50, 50), 255); + QCOMPARE(shown.coverage(95, 95), 0); + // Inside, a drag moves the outline; Shift keeps one axis. + shown.drag(QPointF(50, 50), QPointF(60, 55)); + QCOMPARE(session.history.undoName(), QString("Move Selection")); + QCOMPARE(bounds(session), QRectF(20, 15, 80, 80)); + shown.press(QPointF(50, 50)); + shown.move(QPointF(70, 58), Qt::ShiftModifier); + shown.release(QPointF(70, 58), Qt::ShiftModifier); + QCOMPARE(bounds(session), QRectF(40, 15, 80, 80)); + // Shift at the press adds an outline instead of moving. + shown.press(QPointF(50, 50), Qt::ShiftModifier); + shown.move(QPointF(200, 200), Qt::ShiftModifier); + shown.move(QPointF(200, 50), Qt::ShiftModifier); + shown.release(QPointF(200, 50), Qt::ShiftModifier); + QCOMPARE(session.history.undoName(), QString("Lasso")); + QCOMPARE(bounds(session), QRectF(40, 15, 160, 185)); + // A click inside without a drag deselects, as outside. + shown.click(QPointF(50, 50)); + QVERIFY(!session.selection().has_value()); + QCOMPARE(session.history.undoName(), QString("Deselect")); + shown.click(QPointF(200, 200)); + QVERIFY(!session.selection().has_value()); +} + +void SelectionCanvasTests::thePolygonalLassoTakesCornersAndClosesByClickReturnOrDoubleClick() +{ + Canvas shown; + EditorSession &session = shown.session; + session.selectTool(NavigationTool::lasso); + session.setLassoKind(LassoKind::polygonal); + shown.click(QPointF(10, 10)); + shown.click(QPointF(90, 10)); + shown.click(QPointF(90, 90)); + QCOMPARE(session.lassoDraft().value().points.size(), size_t(3)); + // The rubber band follows the pointer; Backspace takes a corner. + shown.hover(QPointF(50, 50)); + QCOMPARE(session.lassoDraft().value().cursor, std::optional(QPointF(50, 50))); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Backspace); + QCOMPARE(session.lassoDraft().value().points.size(), size_t(2)); + shown.click(QPointF(90, 90)); + // Near the first corner, with three, a click closes. + shown.click(QPointF(13, 12)); + QVERIFY(!session.lassoDraft().has_value()); + QCOMPARE(session.history.undoName(), QString("Polygonal Lasso")); + QCOMPARE(shown.coverage(80, 40), 255); + QCOMPARE(shown.coverage(20, 80), 0); + // Return closes; Escape cancels; a double click closes. + shown.click(QPointF(100, 100)); + QVERIFY(session.lassoDraft().has_value()); + shown.click(QPointF(200, 100)); + shown.click(QPointF(150, 200)); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QCOMPARE(shown.coverage(150, 130), 255); + QCOMPARE(shown.coverage(50, 50), 0); + shown.click(QPointF(10, 200)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!session.lassoDraft().has_value()); + QCOMPARE(shown.coverage(150, 130), 255); + shown.click(QPointF(10, 250)); + shown.click(QPointF(100, 250)); + // A double click's first press adds the closing corner. + shown.click(QPointF(100, 290)); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(100, 290)); + QVERIFY(!session.lassoDraft().has_value()); + QCOMPARE(shown.coverage(70, 265), 255); + // With two corners a click by the first adds one. + shown.click(QPointF(300, 10)); + shown.click(QPointF(350, 10)); + shown.click(QPointF(302, 12)); + QCOMPARE(session.lassoDraft().value().points.size(), size_t(3)); + // A double click while Space pans closes nothing. + QTest::keyPress(shown.canvas, Qt::Key_Space); + QTest::mouseDClick(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(320, 40)); + QTest::mouseRelease(shown.canvas, Qt::LeftButton, Qt::NoModifier, QPoint(320, 40)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); + QCOMPARE(session.lassoDraft().value().points.size(), size_t(3)); + session.cancelLasso(); +} + +void SelectionCanvasTests::arrowsNudgeTheOutlineAndTheKeysMAndLChooseTools() +{ + Canvas shown; + EditorSession &session = shown.session; + session.applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select"); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Right); + QTest::keyClick(shown.canvas, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(bounds(session), QRectF(11, 20, 20, 20)); + QCOMPARE(session.history.undoName(), QString("Move Selection")); + // Alt-arrows are no nudge; a draft takes the arrows away. + QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::AltModifier); + QCOMPARE(bounds(session), QRectF(11, 20, 20, 20)); + session.beginLasso(QPointF(1, 1), SelectionMode::replace); + QTest::keyClick(shown.canvas, Qt::Key_Left); + QCOMPARE(bounds(session), QRectF(11, 20, 20, 20)); + session.cancelLasso(); + // M and L choose their tools; repeats and Shift too. + session.selectTool(NavigationTool::brush); + QTest::keyClick(shown.canvas, Qt::Key_M); + QVERIFY(session.tool() == NavigationTool::marquee && session.marqueeKind() == LassoKind::rectangle); + QTest::keyClick(shown.canvas, Qt::Key_M, Qt::ShiftModifier); + QVERIFY(session.tool() == NavigationTool::marquee && session.marqueeKind() == LassoKind::rectangle); + session.setMarqueeKind(LassoKind::ellipse); + QTest::keyClick(shown.canvas, Qt::Key_L); + QVERIFY(session.tool() == NavigationTool::lasso && session.lassoKind() == LassoKind::freehand); + QTest::keyClick(shown.canvas, Qt::Key_M); + QVERIFY(session.tool() == NavigationTool::marquee && session.marqueeKind() == LassoKind::ellipse); + // A held key's repeats choose nothing and end no draft. + session.selectTool(NavigationTool::lasso); + session.beginLasso(QPointF(1, 1), SelectionMode::replace); + QKeyEvent repeat(QEvent::KeyPress, Qt::Key_M, Qt::NoModifier, QStringLiteral("m"), true); + QApplication::sendEvent(shown.canvas, &repeat); + QVERIFY(session.tool() == NavigationTool::lasso && session.lassoDraft().has_value()); + session.cancelLasso(); + // In the Move tool the arrows nudge the layer. + session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red")); + session.selectTool(NavigationTool::move); + QTest::keyClick(shown.canvas, Qt::Key_Right); + QCOMPARE(bounds(session), QRectF(11, 20, 20, 20)); + QCOMPARE(session.history.undoName(), QString("Transform Layer")); +} + +void SelectionCanvasTests::focusLossEndsAMarqueeDraftAndAnOutlineMove() +{ + Canvas shown; + EditorSession &session = shown.session; + shown.press(QPointF(50, 50)); + shown.move(QPointF(80, 80)); + shown.canvas->clearFocus(); + QVERIFY(!session.lassoDraft().has_value()); + shown.release(QPointF(80, 80)); + QVERIFY(!session.selection().has_value()); + // A polygonal draft survives; a move ends as a step. + session.selectTool(NavigationTool::lasso); + session.setLassoKind(LassoKind::polygonal); + shown.click(QPointF(10, 10)); + shown.canvas->clearFocus(); + QVERIFY(session.lassoDraft().has_value()); + session.cancelLasso(); + session.selectAll(); + shown.press(QPointF(200, 150)); + shown.move(QPointF(210, 150)); + shown.canvas->clearFocus(); + QCOMPARE(session.history.undoName(), QString("Move Selection")); + QVERIFY(session.canUndo()); + shown.release(QPointF(210, 150)); + QCOMPARE(bounds(session), QRectF(10, 0, 400, 300)); +} + +void SelectionCanvasTests::shiftDeleteBeginsAContentAwareFill() +{ + Canvas shown; + EditorSession &session = shown.session; + QImage field = BrushRaster::context(400, 300, false); + field.fill(QColor(51, 153, 204)); + QPainter red(&field); + red.fillRect(QRect(30, 30, 40, 40), Qt::red); + red.end(); + session.insert(ImportedImage(field, field, "Field")); + QTRY_VERIFY(shown.canvas->hasFocus()); + // Without a selection the keys do nothing. + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::ShiftModifier); + QVERIFY(!session.filterEdit().has_value()); + shown.drag(QPointF(20, 20), QPointF(80, 80)); + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::ShiftModifier); + QVERIFY(session.filterEdit().has_value()); + QCOMPARE(session.filterEdit().value().kind, FilterKind::contentAwareFill); + session.cancelFilter(); + QTest::keyClick(shown.canvas, Qt::Key_Delete, Qt::ShiftModifier); + QVERIFY(session.filterEdit().has_value()); + // The canvas repaints once the preview lands. + QTRY_VERIFY(!session.filterEdit().value().preparing); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(!shown.canvas->synchronizeDisplay()); + // The preview stands in: the red block is gone. + const QColor shown_pixel = shown.canvas->grab().toImage().pixelColor(50, 50); + QVERIFY(std::abs(shown_pixel.red() - 51) <= 2 && std::abs(shown_pixel.blue() - 204) <= 2); + session.cancelFilter(); + QCOMPARE(shown.canvas->grab().toImage().pixelColor(50, 50), QColor(Qt::red)); + // Ctrl with it is no fill. + QTest::keyClick(shown.canvas, Qt::Key_Backspace, Qt::ShiftModifier | Qt::ControlModifier); + QVERIFY(!session.filterEdit().has_value()); +} + +void SelectionCanvasTests::aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem() +{ + Canvas shown; + EditorSession &session = shown.session; + QImage halves = BrushRaster::context(400, 300, false); + QPainter painter(&halves); + painter.fillRect(QRect(0, 0, 200, 300), Qt::red); + painter.fillRect(QRect(200, 0, 200, 300), Qt::blue); + painter.end(); + session.insert(ImportedImage(halves, halves, "Halves")); + session.selectTool(NavigationTool::marquee); + session.applySelection(rectPath(QRectF(20, 20, 40, 40)), SelectionMode::replace, "Select"); + shown.canvas->synchronizeDisplay(); + const int count = session.history.undoCount(); + // Ctrl inside: the pixels lift and follow the pointer. + shown.press(QPointF(40, 40), Qt::ControlModifier); + QVERIFY(session.pixelMove()); + shown.move(QPointF(260, 40), Qt::ControlModifier); + const QImage moving = shown.canvas->grab().toImage(); + QCOMPARE(moving.pixelColor(260, 40), QColor(Qt::red)); + QVERIFY(moving.pixelColor(40, 40) != QColor(Qt::red)); + QCOMPARE(session.displayedSelection().value().path.boundingRect(), QRectF(240, 20, 40, 40)); + shown.release(QPointF(260, 40), Qt::ControlModifier); + QTRY_VERIFY(!session.pixelMove()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Move Pixels")); + // Ctrl-arrow in any tool, ten with Shift. + session.selectTool(NavigationTool::brush); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ControlModifier | Qt::ShiftModifier); + QTRY_COMPARE(session.history.undoCount(), count + 2); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(250, 20, 40, 40)); + // With Alt the arrow is not the nudge's. + QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ControlModifier | Qt::AltModifier); + QCOMPARE(session.history.undoCount(), count + 2); + // Ctrl-Alt copies; losing the keys mid-drag cancels. + session.selectTool(NavigationTool::marquee); + shown.press(QPointF(270, 40), Qt::ControlModifier | Qt::AltModifier); + QVERIFY(session.pixelMove() && session.pixelMove()->duplicate); + shown.move(QPointF(300, 40), Qt::ControlModifier | Qt::AltModifier); + QFocusEvent out(QEvent::FocusOut, Qt::ActiveWindowFocusReason); + QApplication::sendEvent(shown.canvas, &out); + QVERIFY(!session.pixelMove()); + shown.release(QPointF(300, 40)); + QCOMPARE(session.history.undoCount(), count + 2); + // Ctrl outside the selection lifts nothing. + shown.press(QPointF(380, 280), Qt::ControlModifier); + QVERIFY(!session.pixelMove()); + shown.release(QPointF(380, 280), Qt::ControlModifier); + // An open outline keeps the pixels still under Ctrl-arrows. + session.applySelection(rectPath(QRectF(250, 20, 40, 40)), SelectionMode::replace, "Select"); + QVERIFY(session.selection().has_value()); + session.selectTool(NavigationTool::lasso); + session.setLassoKind(LassoKind::polygonal); + shown.click(QPointF(300, 200)); + QVERIFY(session.lassoDraft().has_value()); + const int drafting = session.history.undoCount(); + QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ControlModifier); + QTest::qWait(50); + QCOMPARE(session.history.undoCount(), drafting); + QVERIFY(session.lassoDraft().has_value() && !session.pixelMove()); +} + +QTEST_MAIN(SelectionCanvasTests) +#include "SelectionCanvasTests.moc" diff --git a/tests/SelectionClipboardTests.cpp b/tests/SelectionClipboardTests.cpp new file mode 100644 index 0000000..33496b0 --- /dev/null +++ b/tests/SelectionClipboardTests.cpp @@ -0,0 +1,471 @@ +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "SelectionFixtures.h" +#include "AddressSpaceLimit.h" +#include <QClipboard> +#include <QColorSpace> +#include <QGuiApplication> +#include <QMimeData> +#include <QRegularExpression> +#include <malloc.h> + +// Copy, Copy Merged, Paste, Layer via Copy; the clipboard. +namespace { +// A 100×40 layer: red on the left, blue right. +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(100, 40, true); + QImage image = BrushRaster::context(100, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 50, 40), Qt::red); + painter.fillRect(QRect(50, 0, 50, 40), Qt::blue); + painter.end(); + session->insert(ImportedImage(image, image, "Halves")); + session->deleteLayer(session->document().value().layers[0].id); + return session; +} + +void select(EditorSession &session, const QRectF &rect) +{ + session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); +} + +// Within two of each channel, for rounding on the way. +bool near(const std::vector<int> &pixel, const std::vector<int> &expected) +{ + for (size_t i = 0; i < 4; ++i) { + if (std::abs(pixel[i] - expected[i]) > 2) + return false; + } + return true; +} + +std::vector<int> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +// Renders one layer alone, to read its own pixels. +std::vector<int> layerPixel(const EditorSession &session, QUuid id, int x, int y) +{ + ProjectSnapshot solo = session.projectSnapshot().value(); + std::erase_if(solo.manifest.layers, [&](const ProjectLayerRecord &layer) { return layer.id != id; }); + return pixel(ImageExporter::render(solo).image, x, y); +} +} + +class SelectionClipboardTests : public QObject { + Q_OBJECT +private slots: + void copyAndPastePutsPixelsOnANewLayerInPlace(); + void layerViaCopyCopiesTheSelectionOrDuplicatesTheLayer(); + void lassoShapedSelectionCopiesAndPastes(); + void copyMergedTakesEveryVisibleLayerNotJustTheActiveOne(); + void aCopyTakesTheLayersOwnPixelsAtTheirTransformedPlaces(); + void aMaskCopiesAsOpaqueGray(); + void aSoftSelectionEdgeClipsBothMaskPasses(); + void theCopyRegionRoundsOffFloatNoiseAndStopsAtTheCanvas(); + void anotherAppsImagePastesCentredAndUnstalesOurs(); + void copyingAndPastingAreRefusedWhereSwiftRefusesThem(); + void anImageTooLargeToCopyWarnsWithoutAnAlert(); + void cutLeavesAHoleAndPasteRestoresThePixels(); +}; + +void SelectionClipboardTests::copyAndPastePutsPixelsOnANewLayerInPlace() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + // Straddles red and blue. + select(*session, QRectF(40, 10, 20, 20)); + session->copySelection(); + const int count = session->history.undoCount(); + QVERIFY(session->canPaste()); + QVERIFY(QGuiApplication::clipboard()->mimeData()->hasFormat("image/png")); + session->paste(); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Paste")); + const ImageLayer pasted = session->activeLayer().value(); + QVERIFY(pasted.id != source && pasted.name == "Layer 1" && !session->selection().has_value()); + QCOMPARE(indexOf(session->document().value().layers, pasted.id), indexOf(session->document().value().layers, source) + 1); + QCOMPARE(pasted.transform.origin, QPointF(40, 10)); + QCOMPARE(pasted.size(), QSizeF(20, 20)); + QCOMPARE(layerPixel(*session, pasted.id, 45, 15), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(layerPixel(*session, pasted.id, 55, 15), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(layerPixel(*session, pasted.id, 30, 15)[3], 0); + // The source is untouched. + QCOMPARE(layerPixel(*session, source, 45, 15), (std::vector<int>{255, 0, 0, 255})); + session->undo(); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + // Pasted again: the next free names; a folder's child. + session->paste(); + session->paste(); + QCOMPARE(session->activeLayer().value().name, QString("Layer 2")); + session->addGroup(); + const QUuid folder = session->activeLayerID().value(); + session->paste(); + QCOMPARE(session->activeLayer().value().parentID, std::optional(folder)); + QCOMPARE(session->activeLayer().value().name, QString("Layer 3")); +} + +void SelectionClipboardTests::layerViaCopyCopiesTheSelectionOrDuplicatesTheLayer() +{ + const auto session = makeSession(); + const ImageLayer source = session->activeLayer().value(); + select(*session, QRectF(60, 0, 10, 40)); + session->layerViaCopy(); + QCOMPARE(session->history.undoName(), QString("Layer via Copy")); + QCOMPARE(session->document().value().layers.size(), size_t(2)); + QCOMPARE(session->activeLayer().value().size(), QSizeF(10, 40)); + QVERIFY(!session->selection().has_value()); + QCOMPARE(layerPixel(*session, session->activeLayerID().value(), 65, 20), (std::vector<int>{0, 0, 255, 255})); + session->selectLayer(source.id); + session->layerViaCopy(); + QCOMPARE(session->history.undoName(), QString("Duplicate Layer")); + QCOMPARE(session->activeLayer().value().name, source.name + " copy"); + QVERIFY(session->activeLayer().value().asset.value().identity() == source.asset.value().identity()); + // An empty selection and a folder make no layer. + const int count = session->history.undoCount(); + session->addGroup(); + session->layerViaCopy(); + session->selectLayer(source.id); + select(*session, QRectF(0, 0, 10, 10)); + session->applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, "Subtract"); + session->layerViaCopy(); + QCOMPARE(session->history.undoCount(), count + 3); +} + +void SelectionClipboardTests::lassoShapedSelectionCopiesAndPastes() +{ + const auto session = makeSession(); + session->selectTool(NavigationTool::lasso); + // A triangle over the red half. + session->beginLasso(QPointF(10, 5), SelectionMode::replace); + session->extendLasso(QPointF(40, 5)); + session->extendLasso(QPointF(10, 35)); + session->finishLasso(); + QVERIFY(session->history.undoName() == "Lasso" && session->canCopyPixels()); + session->copySelection(); + session->paste(); + const ImageLayer pasted = session->activeLayer().value(); + QCOMPARE(pasted.transform.origin, QPointF(10, 5)); + QCOMPARE(pasted.size(), QSizeF(30, 30)); + QCOMPARE(layerPixel(*session, pasted.id, 15, 10), (std::vector<int>{255, 0, 0, 255})); + // Past the diagonal, and a soft edge along it. + QCOMPARE(layerPixel(*session, pasted.id, 38, 33)[3], 0); + int soft = 0; + for (int x = 10; x < 40; ++x) { + const int alpha = layerPixel(*session, pasted.id, x, 40 - x + 4)[3]; + soft += alpha > 0 && alpha < 255; + } + QVERIFY(soft > 0); +} + +void SelectionClipboardTests::copyMergedTakesEveryVisibleLayerNotJustTheActiveOne() +{ + const auto session = makeSession(); + // A small green layer over the red and blue one. + QImage square = BrushRaster::context(20, 20, false); + square.fill(Qt::green); + session->insert(ImportedImage(square, square, "Green"), QPointF(70, 20)); + const QUuid green = session->activeLayerID().value(); + QCOMPARE(layerWith(*session, green).transform.origin, QPointF(60, 10)); + select(*session, QRectF(55, 5, 30, 30)); + // The active layer alone: just the green square. + session->copySelection(); + session->paste(); + QUuid pasted = session->activeLayerID().value(); + QCOMPARE(layerPixel(*session, pasted, 65, 15), (std::vector<int>{0, 255, 0, 255})); + QCOMPARE(layerPixel(*session, pasted, 58, 8)[3], 0); + session->undo(); + session->selectLayer(green); + select(*session, QRectF(55, 5, 30, 30)); + QVERIFY(session->canCopyMerged()); + session->copyMergedSelection(); + session->paste(); + pasted = session->activeLayerID().value(); + QCOMPARE(layerPixel(*session, pasted, 65, 15), (std::vector<int>{0, 255, 0, 255})); + QCOMPARE(layerPixel(*session, pasted, 58, 8), (std::vector<int>{0, 0, 255, 255})); + session->undo(); + // A triangle: the far corner clear, its hypotenuse soft. + session->selectLayer(green); + session->selectTool(NavigationTool::lasso); + lasso(*session, {QPointF(55, 5), QPointF(85, 5), QPointF(55, 35)}); + QCOMPARE(session->history.undoName(), QString("Lasso")); + session->copyMergedSelection(); + session->paste(); + pasted = session->activeLayerID().value(); + QCOMPARE(layerPixel(*session, pasted, 62, 12), (std::vector<int>{0, 255, 0, 255})); + QCOMPARE(layerPixel(*session, pasted, 83, 33)[3], 0); + const int edge = layerPixel(*session, pasted, 69, 20)[3]; + QVERIFY(edge > 0 && edge < 255); + session->undo(); + // Hidden layers are left out. + session->selectLayer(green); + session->toggleLayerVisibility(green); + select(*session, QRectF(55, 5, 30, 30)); + session->copyMergedSelection(); + session->paste(); + pasted = session->activeLayerID().value(); + QCOMPARE(layerPixel(*session, pasted, 65, 15), (std::vector<int>{0, 0, 255, 255})); + // With nothing drawn there is nothing to merge. + const auto blank = std::make_unique<EditorSession>(); + blank->createDocument(8, 8, true); + QVERIFY(!blank->canCopyMerged()); +} + +void SelectionClipboardTests::aCopyTakesTheLayersOwnPixelsAtTheirTransformedPlaces() +{ + const auto session = makeSession(); + // A 20 by 10 yellow bar centred at (50, 20). + QImage bar = BrushRaster::context(20, 10, false); + bar.fill(Qt::yellow); + session->insert(ImportedImage(bar, bar, "Bar"), QPointF(50, 20)); + const QUuid id = session->activeLayerID().value(); + QCOMPARE(layerWith(*session, id).transform.origin, QPointF(40, 15)); + // Hidden, faint, masked out and turned upright: the pixels stay. + QImage black = BrushRaster::context(20, 10, true); + black.fill(Qt::black); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(black)); + record(snapshot, id).isVisible = false; + record(snapshot, id).opacity = 0.25; + record(snapshot, id).transform.rotation = 90; + }); + session->selectLayerTarget(id, false); + select(*session, QRectF(30, 5, 40, 30)); + session->copySelection(); + session->paste(); + const ImageLayer pasted = session->activeLayer().value(); + QCOMPARE(pasted.transform.origin, QPointF(30, 5)); + QCOMPARE(layerPixel(*session, pasted.id, 50, 12), (std::vector<int>{255, 255, 0, 255})); + QCOMPARE(layerPixel(*session, pasted.id, 50, 28), (std::vector<int>{255, 255, 0, 255})); + QCOMPARE(layerPixel(*session, pasted.id, 42, 20)[3], 0); + QCOMPARE(layerPixel(*session, pasted.id, 58, 20)[3], 0); +} + +void SelectionClipboardTests::aMaskCopiesAsOpaqueGray() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + QImage mask = BrushRaster::context(100, 40, true); + mask.fill(Qt::white); + for (int y = 0; y < 40; ++y) + std::fill_n(mask.scanLine(y), 30, uchar(0)); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); }); + session->selectLayerTarget(id, true); + select(*session, QRectF(20, 10, 20, 20)); + QVERIFY(session->canCopyPixels()); + session->copySelection(); + session->paste(); + const QUuid pasted = session->activeLayerID().value(); + QCOMPARE(layerPixel(*session, pasted, 25, 15), (std::vector<int>{0, 0, 0, 255})); + QCOMPARE(layerPixel(*session, pasted, 35, 15), (std::vector<int>{255, 255, 255, 255})); + QCOMPARE(layerPixel(*session, pasted, 45, 15)[3], 0); + session->undo(); + // A mask placed apart: outside it, its edges' mean gray. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).maskPlacement = LayerTransform{.origin = {60, 0}, .size = {100, 40}}; + record(snapshot, id).maskLinked = false; + }); + session->selectLayerTarget(id, true); + select(*session, QRectF(20, 10, 20, 20)); + session->copySelection(); + session->paste(); + const std::vector<int> ground = layerPixel(*session, session->activeLayerID().value(), 25, 15); + const int tone = qRound(LayerMask::background(layerWith(*session, id).mask.value().asset.thumbnail) * 255); + QVERIFY(tone > 0); + QCOMPARE(ground, (std::vector<int>{tone, tone, tone, 255})); + session->undo(); + // Placed away, this mask's black edges cover the region. + QImage inverse = BrushRaster::context(100, 40, true); + inverse.fill(Qt::black); + for (int y = 5; y < 35; ++y) + std::fill_n(inverse.scanLine(y) + 15, 30, uchar(255)); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(inverse)); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {60, 0}, .size = {100, 40}}; + record(snapshot, id).maskLinked = false; + }); + session->selectLayerTarget(id, true); + select(*session, QRectF(20, 10, 20, 20)); + session->copySelection(); + session->paste(); + QCOMPARE(layerPixel(*session, session->activeLayerID().value(), 25, 15), (std::vector<int>{0, 0, 0, 255})); + session->undo(); + // A folder copies its mask alone; without one, nothing. + session->selectLayer(id); + session->addGroup(); + QVERIFY(!session->canCopyPixels()); + session->addLayerMask(false); + QVERIFY(session->canCopyPixels()); +} + +void SelectionClipboardTests::aSoftSelectionEdgeClipsBothMaskPasses() +{ + const auto session = makeSession(); + const QUuid id = session->activeLayerID().value(); + session->addMask(true); + session->selectLayerTarget(id, true); + // Half a pixel in: half coverage at the left column. + select(*session, QRectF(20.5, 10, 20, 20)); + session->copySelection(); + session->paste(); + const QUuid pasted = session->activeLayerID().value(); + // Ground at half, then white at half over it: 192. + QVERIFY(near(layerPixel(*session, pasted, 20, 15), {128, 128, 128, 192})); + QCOMPARE(layerPixel(*session, pasted, 25, 15), (std::vector<int>{255, 255, 255, 255})); + QCOMPARE(layerPixel(*session, pasted, 19, 15), (std::vector<int>{0, 0, 0, 0})); + // The layer's pixels take the coverage once. + session->selectLayerTarget(id, false); + select(*session, QRectF(20.5, 10, 20, 20)); + session->copySelection(); + session->paste(); + QVERIFY(near(layerPixel(*session, session->activeLayerID().value(), 20, 15), {128, 0, 0, 128})); +} + +void SelectionClipboardTests::theCopyRegionRoundsOffFloatNoiseAndStopsAtTheCanvas() +{ + const auto session = makeSession(); + QCOMPARE(session->selectionCopyRegion(), std::optional(QRectF(0, 0, 100, 40))); + session->applySelection(rectPath(QRectF(9.9995, 10.0004, 30.0009, 19.9993)), SelectionMode::replace, "Select"); + QCOMPARE(session->selectionCopyRegion(), std::optional(QRectF(10, 10, 30, 20))); + session->applySelection(rectPath(QRectF(10.3, 10.3, 30, 20)), SelectionMode::replace, "Select"); + QCOMPARE(session->selectionCopyRegion(), std::optional(QRectF(10, 10, 31, 21))); + // Beyond the canvas the region stops; nudged off it, nothing. + session->selectAll(); + session->nudgeSelection(90, 0); + QCOMPARE(session->selectionCopyRegion(), std::optional(QRectF(90, 0, 10, 40))); + session->nudgeSelection(10, 0); + QVERIFY(!session->selectionCopyRegion().has_value()); + QVERIFY(session->canCopyPixels()); + const int count = session->history.undoCount(); + session->copySelection(); + session->layerViaCopy(); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(!session->pixelClipboard().has_value()); +} + +void SelectionClipboardTests::anotherAppsImagePastesCentredAndUnstalesOurs() +{ + const auto session = makeSession(); + select(*session, QRectF(0, 0, 10, 10)); + session->copySelection(); + QVERIFY(session->canPaste()); + // Another app copies a picture: Paste centres that instead. + QImage picture(20, 10, QImage::Format_ARGB32); + picture.fill(QColor(0, 255, 0, 255)); + QGuiApplication::clipboard()->setImage(picture); + QVERIFY(session->canPaste()); + session->paste(); + const ImageLayer pasted = session->activeLayer().value(); + QCOMPARE(pasted.transform.origin, QPointF(40, 15)); + QCOMPARE(pasted.size(), QSizeF(20, 10)); + QCOMPARE(pasted.asset.value().image().format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(layerPixel(*session, pasted.id, 50, 20), (std::vector<int>{0, 255, 0, 255})); + // Linear gray 128 is sRGB 188, as CoreGraphics converts it. + QImage linear(4, 4, QImage::Format_ARGB32); + linear.fill(QColor(128, 128, 128, 255)); + linear.setColorSpace(QColorSpace::SRgbLinear); + QGuiApplication::clipboard()->setImage(linear); + session->paste(); + const ImageLayer converted = session->activeLayer().value(); + QVERIFY(near(layerPixel(*session, converted.id, 50, 20), {188, 188, 188, 255})); + QCOMPARE(converted.asset.value().image().colorSpace(), QColorSpace(QColorSpace::SRgb)); + // Text on the clipboard pastes nothing. + QGuiApplication::clipboard()->setText(QStringLiteral("words")); + QVERIFY(!session->canPaste()); + const int count = session->history.undoCount(); + session->paste(); + QCOMPARE(session->history.undoCount(), count); + // Our own copy again: back in place. + session->copySelection(); + session->paste(); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(0, 0)); +} + +void SelectionClipboardTests::copyingAndPastingAreRefusedWhereSwiftRefusesThem() +{ + EditorSession session; + QVERIFY(!session.canCopyPixels() && !session.canCopyMerged() && !session.canPaste()); + session.createDocument(20, 10, true); + // A blank layer has no pixels to copy. + QVERIFY(!session.canCopyPixels()); + QImage image = BrushRaster::context(20, 10, false); + image.fill(Qt::red); + session.insert(ImportedImage(image, image, "Red")); + QVERIFY(session.canCopyPixels()); + session.copySelection(); + QVERIFY(session.canPaste()); + // An empty selection copies nothing; busy, nothing at all. + select(session, QRectF(0, 0, 5, 5)); + session.applySelection(rectPath(QRectF(0, 0, 20, 10)), SelectionMode::subtract, "Subtract"); + QVERIFY(!session.canCopyPixels() && !session.canCopyMerged()); + session.deselect(); + session.setIsProjectBusy(true); + QVERIFY(!session.canCopyPixels() && !session.canPaste()); + session.setIsProjectBusy(false); + QVERIFY(session.canPaste()); + // A paste's signal, a copy's silence. + QSignalSpy changes(&session, &EditorSession::changed); + session.copySelection(); + QCOMPARE(changes.count(), 0); + session.paste(); + QVERIFY(changes.count() > 0); + // A cleared clipboard holds no data object at all. + QGuiApplication::clipboard()->clear(); + QVERIFY(!session.canPaste()); + const int count = session.history.undoCount(); + session.paste(); + QCOMPARE(session.history.undoCount(), count); +} + +void SelectionClipboardTests::anImageTooLargeToCopyWarnsWithoutAnAlert() +{ + const auto session = makeSession(); + // Half a megabyte of bits; its working copy needs sixteen. + const QImage large(2000, 2000, QImage::Format_Mono); + QVERIFY(!large.isNull()); + QGuiApplication::clipboard()->setImage(large); + QVERIFY(session->canPaste()); + const int count = session->history.undoCount(); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("the clipboard's image could not be copied"))); + { + malloc_trim(0); + const AddressSpaceLimit limit(4 * 1024 * 1024); + session->paste(); + } + QCOMPARE(session->history.undoCount(), count); + QVERIFY(!session->brushError().has_value()); +} + +void SelectionClipboardTests::cutLeavesAHoleAndPasteRestoresThePixels() +{ + const auto session = makeSession(); + const QUuid source = session->activeLayerID().value(); + select(*session, QRectF(0, 0, 10, 10)); + const int count = session->history.undoCount(); + bool done = false; + session->cutSelection([&] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(session->pixelClipboard().has_value()); + QCOMPARE(session->history.undoName(), QString("Clear")); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(layerPixel(*session, source, 5, 5)[3], 0); + QCOMPARE(layerPixel(*session, source, 15, 5), (std::vector<int>{255, 0, 0, 255})); + // The pixels come back where they were. + session->paste(); + QCOMPARE(layerPixel(*session, session->activeLayerID().value(), 5, 5), (std::vector<int>{255, 0, 0, 255})); + // Without a selection, nothing is cut. + session->deselect(); + done = false; + session->cutSelection([&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoName(), QString("Paste")); +} + +QTEST_MAIN(SelectionClipboardTests) +#include "SelectionClipboardTests.moc" diff --git a/tests/SelectionEditTests.cpp b/tests/SelectionEditTests.cpp new file mode 100644 index 0000000..169eb46 --- /dev/null +++ b/tests/SelectionEditTests.cpp @@ -0,0 +1,427 @@ +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "AddressSpaceLimit.h" +#include "RenderFixtures.h" +#include "SelectionFixtures.h" +#include <QElapsedTimer> + +// Masks from a selection, the delete target, and Invert. +namespace { +std::unique_ptr<EditorSession> editSession(int width = 100, int height = 40) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height, true); + return session; +} + +void select(EditorSession &session, const QRectF &rect, bool antialiased = true) +{ + session.setSelectionAntialiased(antialiased); + session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); +} + +// Premultiplied bytes of the rendered document at a pixel. +std::vector<int> pixel(const QImage &image, int x, int y) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + const uchar *at = bytes.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +QImage render(const EditorSession &session) +{ + return ImageExporter::render(session.projectSnapshot().value()).image; +} + +// A 100×40 layer: red left half, blue right half. +void twoColorLayer(EditorSession &session) +{ + QImage image = BrushRaster::context(100, 40, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 50, 40), Qt::red); + painter.fillRect(QRect(50, 0, 50, 40), Qt::blue); + painter.end(); + session.insert(ImportedImage(image, image, "Two")); +} + +// Runs the invert and waits for its step. +void invert(EditorSession &session) +{ + bool done = false; + session.invertPixels([&] { done = true; }); + QTRY_VERIFY(done); +} +} + +class SelectionEditTests : public QObject { + Q_OBJECT +private slots: + void maskButtonAddsWhiteMaskOrHidesTheSelection(); + void layerMenuMasksUseTheSelection(); + void maskFromSelectionLinesUpOnScaledLayers(); + void deletingWithTheMaskTargetedRemovesOnlyTheMask(); + void invertKeepsTransparencyStaysInSelectionAndWorksOnMasks(); + void invertIsFastOnLargeImagesAndHandlesUniformMasksWithASelection(); + void invertWorksInEveryTool(); + void invertIsRefusedWhereSwiftRefusesIt(); + void anInvertComputedFromOtherPixelsIsDropped(); + void aPaintedAssetThatCannotFlattenReportsAndFreesTheProject(); +}; + +void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() +{ + const auto session = editSession(); + session->insert(filledRed()); + session->addMask(); + QVERIFY(session->activeLayer().value().mask.has_value()); + QCOMPARE(session->history.undoName(), QString("Add Reveal-All Mask")); + QCOMPARE(pixel(render(*session), 30, 20)[3], 255); + session->undo(); + QVERIFY(!session->activeLayer().value().mask.has_value()); + select(*session, QRectF(20, 10, 30, 20)); + session->addMask(); + QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QVERIFY(!session->selection().has_value() && session->isMaskSelected()); + const QImage result = render(*session); + // The selected area is black, hidden; the rest white. + QCOMPARE(pixel(result, 30, 20)[3], 0); + QCOMPARE(pixel(result, 5, 5)[3], 255); + session->undo(); + QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); + // A layer with a mask takes no second one. + session->redo(); + const int count = session->history.undoCount(); + select(*session, QRectF(0, 0, 10, 10)); + session->addMask(); + QCOMPARE(session->history.undoCount(), count + 1); +} + +void SelectionEditTests::layerMenuMasksUseTheSelection() +{ + const auto session = editSession(); + session->insert(filledRed()); + select(*session, QRectF(20, 10, 30, 20)); + session->addMask(false); + QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QVERIFY(!session->selection().has_value() && session->isMaskSelected()); + const QImage result = render(*session); + // The selected area is white, visible; the rest black. + QCOMPARE(pixel(result, 30, 20)[3], 255); + QCOMPARE(pixel(result, 5, 5)[3], 0); + session->undo(); + QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); + session->deselect(); + // No selection: a plain black mask. + session->addMask(false); + QCOMPARE(session->history.undoName(), QString("Add Hide-All Mask")); + QCOMPARE(pixel(render(*session), 30, 20)[3], 0); +} + +void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() +{ + const auto session = editSession(100, 100); + QImage image = BrushRaster::context(50, 50, false); + image.fill(Qt::blue); + session->insert(ImportedImage(image, image, "Blue")); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform.origin = QPointF(0, 0); + record(snapshot, id).transform.size = QSizeF(100, 100); + }); + select(*session, QRectF(0, 0, 50, 50)); + session->addMask(); + // The mask uses the layer's pixel grid. + QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(50, 50)); + const QImage result = render(*session); + QCOMPARE(pixel(result, 25, 25)[3], 0); + QCOMPARE(pixel(result, 75, 75)[3], 255); + QCOMPARE(pixel(result, 75, 25)[3], 255); + // A blank layer's grid is its rounded size. + session->undo(); + session->addBlankLayer(); + select(*session, QRectF(10, 10, 20, 20)); + session->addMask(); + QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(100, 100)); + QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(15)[15]), 0); + QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); + // A soft outline softens the mask's edge; hard, none. + QPainterPath triangle; + triangle.moveTo(0, 0); + triangle.lineTo(100, 0); + triangle.lineTo(0, 100); + triangle.closeSubpath(); + for (const bool soft : {true, false}) { + session->undo(); + session->setSelectionAntialiased(soft); + session->applySelection(triangle, SelectionMode::replace, "Select"); + session->addMask(); + const QImage edge = session->activeLayer().value().mask.value().asset.image(); + int partial = 0; + for (int x = 0; x < 100; ++x) { + const int value = edge.constScanLine(99 - x)[x]; + partial += value > 0 && value < 255; + } + QCOMPARE(partial > 0, soft); + } +} + +void SelectionEditTests::deletingWithTheMaskTargetedRemovesOnlyTheMask() +{ + const auto session = editSession(); + const QUuid id = session->activeLayerID().value(); + session->addLayerMask(false); + QVERIFY(session->isMaskSelected()); + session->deleteLayerOrMask(); + QCOMPARE(session->activeLayer().value().id, id); + QVERIFY(!session->activeLayer().value().mask.has_value()); + QCOMPARE(session->history.undoName(), QString("Delete Layer Mask")); + QVERIFY(!session->isMaskSelected()); + session->undo(); + QVERIFY(session->activeLayer().value().mask.has_value()); + // The trash button follows the same target. + session->selectLayerTarget(id, true); + session->deleteLayerOrMask(); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + QVERIFY(!session->activeLayer().value().mask.has_value()); + // With the layer's pixels targeted, the whole layer goes. + session->addLayerMask(); + session->selectLayerTarget(id, false); + session->deleteLayerOrMask(); + QVERIFY(session->document().value().layers.empty()); +} + +void SelectionEditTests::invertKeepsTransparencyStaysInSelectionAndWorksOnMasks() +{ + const auto session = editSession(); + twoColorLayer(*session); + // The top half only. + select(*session, QRectF(0, 0, 100, 20)); + const int revision = session->brushRevision(); + invert(*session); + QCOMPARE(session->history.undoName(), QString("Invert")); + QVERIFY(!session->isProjectBusy()); + // The canvas's counter moves as Swift's does. + QCOMPARE(session->brushRevision(), revision + 1); + QImage result = render(*session); + QCOMPARE(pixel(result, 10, 5), (std::vector<int>{0, 255, 255, 255})); + QCOMPARE(pixel(result, 90, 5), (std::vector<int>{255, 255, 0, 255})); + QCOMPARE(pixel(result, 10, 30), (std::vector<int>{255, 0, 0, 255})); + session->deselect(); + // A half-transparent pixel keeps its alpha. + const auto blank = editSession(); + QImage half = BrushRaster::context(100, 40, false); + half.fill(QColor(255, 255, 255, 128)); + blank->insert(ImportedImage(half, half, "Half")); + invert(*blank); + const std::vector<int> faded = pixel(render(*blank), 50, 20); + QVERIFY(std::abs(faded[3] - 128) <= 1 && faded[0] == 0); + // On a mask black and white swap: white hides all. + session->addLayerMask(); + session->toggleMaskLink(session->activeLayerID().value()); + invert(*session); + QCOMPARE(session->history.undoName(), QString("Invert Mask")); + QVERIFY(!session->activeLayer().value().mask.value().isLinked); + result = render(*session); + QCOMPARE(pixel(result, 50, 30)[3], 0); + session->undo(); + QCOMPARE(pixel(render(*session), 50, 30)[3], 255); + // A mask placed apart takes the selection where it shows. + const QUuid id = session->activeLayerID().value(); + QImage white = BrushRaster::context(100, 40, true); + white.fill(Qt::white); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(white)); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {50, 0}, .size = {100, 40}}; + record(snapshot, id).maskLinked = false; + }); + session->selectLayerTarget(id, true); + select(*session, QRectF(50, 0, 50, 40)); + invert(*session); + const QImage placed = session->activeLayer().value().mask.value().asset.image(); + QCOMPARE(int(placed.constScanLine(20)[25]), 0); + QCOMPARE(int(placed.constScanLine(20)[75]), 255); +} + +void SelectionEditTests::invertIsFastOnLargeImagesAndHandlesUniformMasksWithASelection() +{ + const auto session = editSession(4000, 3000); + QImage image = BrushRaster::context(4000, 3000, false); + image.fill(Qt::red); + session->insert(ImportedImage(image, image, "Big")); + QElapsedTimer clock; + clock.start(); + invert(*session); + const qint64 whole = clock.restart(); + select(*session, QRectF(0, 0, 2000, 3000)); + invert(*session); + const qint64 selected = clock.elapsed(); + QVERIFY2(whole < 1500 && selected < 1500, qPrintable(QStringLiteral("whole %1 ms, selected %2 ms").arg(whole).arg(selected))); + const QImage result = render(*session); + // Inverted twice, then once. + QCOMPARE(pixel(result, 100, 100), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(pixel(result, 3000, 100), (std::vector<int>{0, 255, 255, 255})); + // A 1×1 mask with a selection inverts the selected part. + session->addLayerMask(); + invert(*session); + QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(4000, 3000)); + const QImage masked = render(*session); + QCOMPARE(pixel(masked, 100, 100)[3], 0); + QCOMPARE(pixel(masked, 3000, 100)[3], 255); +} + +void SelectionEditTests::invertWorksInEveryTool() +{ + const auto session = editSession(); + twoColorLayer(*session); + for (const NavigationTool tool : {NavigationTool::brush, NavigationTool::move, NavigationTool::crop, NavigationTool::hand, NavigationTool::zoom, NavigationTool::lasso}) { + session->selectTool(tool); + QVERIFY2(session->canInvert(), qPrintable(label(tool))); + } + session->selectTool(NavigationTool::crop); + invert(*session); + QCOMPARE(session->history.undoName(), QString("Invert")); + QCOMPARE(pixel(render(*session), 10, 5), (std::vector<int>{0, 255, 255, 255})); + // A pending transform is applied first, then inverted. + session->selectTool(NavigationTool::move); + session->beginTransform(); + session->previewTransform(LayerTransform{.origin = {10, 0}, .size = {100, 40}}); + QVERIFY(session->transformEdit().has_value() && session->canInvert()); + invert(*session); + QVERIFY(!session->transformEdit().has_value()); + QCOMPARE(session->history.undoName(), QString("Invert")); + session->undo(); + QCOMPARE(session->history.undoName(), QString("Transform Layer")); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(10, 0)); +} + +void SelectionEditTests::invertIsRefusedWhereSwiftRefusesIt() +{ + EditorSession session; + QVERIFY(!session.canInvert()); + bool done = false; + session.invertPixels([&] { done = true; }); + QTRY_VERIFY(done); + session.createDocument(100, 40, true); + // A blank layer has no pixels; its mask inverts. + QVERIFY(!session.canInvert()); + session.addLayerMask(); + QVERIFY(session.canInvert()); + session.toggleLayerMask(); + QVERIFY(!session.canInvert()); + session.toggleLayerMask(); + const QUuid blank = session.activeLayerID().value(); + twoColorLayer(session); + const QUuid two = session.activeLayerID().value(); + QVERIFY(session.canInvert()); + session.setIsProjectBusy(true); + QVERIFY(!session.canInvert()); + session.setIsProjectBusy(false); + session.setIsImporting(true); + QVERIFY(!session.canInvert()); + session.setIsImporting(false); + session.setRenamingLayerID(session.activeLayerID()); + QVERIFY(!session.canInvert()); + session.setRenamingLayerID(std::nullopt); + session.setShowsNewDocument(true); + QVERIFY(!session.canInvert()); + session.setShowsNewDocument(false); + session.setShowsImporter(true); + QVERIFY(!session.canInvert()); + session.setShowsImporter(false); + // Hidden, two selected, an empty selection, a folder. + session.toggleLayerVisibility(session.activeLayerID().value()); + QVERIFY(!session.canInvert()); + session.toggleLayerVisibility(session.activeLayerID().value()); + session.selectLayers({two, blank}, two); + QCOMPARE(session.activeLayerID(), std::optional(two)); + QVERIFY(!session.canInvert()); + session.selectLayers({two}, two); + QVERIFY(session.canInvert()); + select(session, QRectF(10, 10, 10, 10)); + session.applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, "Subtract"); + QVERIFY(session.selection().value().isEmpty() && !session.canInvert()); + const std::optional<CanvasDocument> before = session.document(); + const int count = session.history.undoCount(); + done = false; + session.invertPixels([&] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(session.document() == before && session.history.undoCount() == count); + session.deselect(); + session.addGroup(); + QVERIFY(!session.canInvert()); + session.addLayerMask(); + QVERIFY(session.canInvert()); +} + +void SelectionEditTests::anInvertComputedFromOtherPixelsIsDropped() +{ + const auto session = editSession(); + twoColorLayer(*session); + const QUuid id = session->activeLayerID().value(); + const int revision = session->brushRevision(); + bool done = false; + session->invertPixels([&] { done = true; }); + QVERIFY(session->isProjectBusy()); + // Meanwhile the layer takes other pixels, as a load would. + QImage green = BrushRaster::context(100, 40, false); + green.fill(Qt::green); + rewrite(*session, [&](ProjectSnapshot &snapshot) { snapshot.images.insert_or_assign(id, ImportedImage(green, green, "Green")); }); + QTRY_VERIFY(done); + QVERIFY(!session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), 0); + QCOMPARE(session->brushRevision(), revision); + QCOMPARE(pixel(render(*session), 10, 5), (std::vector<int>{0, 255, 0, 255})); + // Gone altogether, it is dropped as well. + done = false; + session->invertPixels([&] { done = true; }); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + snapshot.manifest.layers.clear(); + snapshot.images.clear(); + snapshot.manifest.activeLayerID = std::nullopt; + }); + QTRY_VERIFY(done); + QVERIFY(session->document().value().layers.empty() && !session->isProjectBusy()); + // The document itself gone: the callback still comes. + twoColorLayer(*session); + done = false; + session->invertPixels([&] { done = true; }); + session->clearProject(); + QTRY_VERIFY(done); + QVERIFY(!session->document().has_value() && !session->isProjectBusy()); + // Only the mask replaced: the mask's invert is dropped too. + session->createDocument(100, 40, true); + twoColorLayer(*session); + const QUuid two = session->activeLayerID().value(); + session->addLayerMask(); + done = false; + session->invertPixels([&] { done = true; }); + QImage gray = BrushRaster::context(100, 40, true); + gray.fill(QColor(100, 100, 100)); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, two, LayerMask::assetFrom(gray)); }); + QTRY_VERIFY(done); + QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(0)[0]), 100); + QCOMPARE(session->history.undoCount(), 0); + QVERIFY(!session->isProjectBusy()); +} + +void SelectionEditTests::aPaintedAssetThatCannotFlattenReportsAndFreesTheProject() +{ + // A painted base of 48 MB, flattened on first use. + const auto raster = std::make_shared<const RasterSnapshot>(4000, 3000, solid(2, 2, qRgba(255, 0, 0, 255)), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}); + const auto session = editSession(4000, 3000); + session->insert(ImportedImage(raster, QImage(), "Painted")); + QVERIFY(session->canInvert()); + const int count = session->history.undoCount(); + bool done = false; + { + const AddressSpaceLimit limit(16 * 1024 * 1024); + session->invertPixels([&] { done = true; }); + } + QVERIFY(session->brushError().has_value() && !session->isProjectBusy()); + QTRY_VERIFY(done); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(!raster->hasMaterializedPixels()); +} + +QTEST_GUILESS_MAIN(SelectionEditTests) +#include "SelectionEditTests.moc" diff --git a/tests/SelectionFixtures.h b/tests/SelectionFixtures.h new file mode 100644 index 0000000..50e0cda --- /dev/null +++ b/tests/SelectionFixtures.h @@ -0,0 +1,54 @@ +#pragma once +#include "SessionFixtures.h" +#include <QtTest> +#include <memory> + +// Shared by the selection tests: a session, outlines, coverage. +inline std::unique_ptr<EditorSession> selectionSession(int width = 100, int height = 100) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(width, height, true); + session->selectTool(NavigationTool::lasso); + return session; +} + +inline void lasso(EditorSession &session, const std::vector<QPointF> &points, SelectionMode mode = SelectionMode::replace) +{ + session.beginLasso(points[0], mode); + for (std::size_t index = 1; index < points.size(); ++index) + session.extendLasso(points[index]); + session.finishLasso(); +} + +inline std::vector<QPointF> square(double x, double y, double size) +{ + return {QPointF(x, y), QPointF(x + size, y), QPointF(x + size, y + size), QPointF(x, y + size)}; +} + +// Coverage 0–255 at a document pixel. +inline int coverage(const EditorSession &session, int x, int y) +{ + const CanvasDocument &document = session.document().value(); + const QImage image = session.selection().value().coverage(document.width, document.height); + return image.constScanLine(y)[x]; +} + +inline QRectF bounds(const EditorSession &session) +{ + return session.selection().value().path.boundingRect(); +} + +// A 100×40 red image, the edit tests' whole layer. +inline ImportedImage filledRed() +{ + QImage image(100, 40, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + return ImportedImage(image, image, "Red"); +} + +inline QPainterPath rectPath(const QRectF &rect) +{ + QPainterPath path; + path.addRect(rect); + return path; +} diff --git a/tests/SelectionOverlayTests.cpp b/tests/SelectionOverlayTests.cpp new file mode 100644 index 0000000..7f1673e --- /dev/null +++ b/tests/SelectionOverlayTests.cpp @@ -0,0 +1,343 @@ +#include "SelectionCanvasFixtures.h" + +// What the selection tools show: cursors, ants, autoscroll, drawing. +class SelectionOverlayTests : public QObject { + Q_OBJECT +private slots: + void cursorsFollowTheToolTheKindTheModeAndTheOutline(); + void theAntsMarchWhileASelectionShowsAndStopWithout(); + void autoscrollPansTheViewWhileADragHoldsAtTheEdge(); + void theOutlineAndTheDraftAreDrawnWhereTheyLie(); + void outlinesAndDraftsAreAntialiased(); + void theAntsDashesKeepFourOnFourOffAtTwoTimes(); + void openDraftsAndCursorBadgesEndWhereTheyEnd(); +}; + +void SelectionOverlayTests::cursorsFollowTheToolTheKindTheModeAndTheOutline() +{ + Canvas shown; + EditorSession &session = shown.session; + CanvasView &canvas = *shown.canvas; + const double ratio = canvas.devicePixelRatio(); + shown.hover(QPointF(200, 150)); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, ratio))); + shown.hover(QPointF(200, 150), Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::add, ratio))); + QVERIFY(session.displayedSelectionMode() == SelectionMode::add); + shown.hover(QPointF(200, 150), Qt::AltModifier | Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::subtract, ratio))); + // A key from anywhere in the window changes the badge. + QTest::keyPress(shown.window.windowHandle(), Qt::Key_Shift, Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::add, ratio))); + QVERIFY(session.displayedSelectionMode() == SelectionMode::add); + QTest::keyRelease(shown.window.windowHandle(), Qt::Key_Shift, Qt::NoModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, ratio))); + session.setMarqueeKind(LassoKind::ellipse); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::replace, ratio))); + session.selectTool(NavigationTool::lasso); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, ratio))); + session.setLassoKind(LassoKind::polygonal); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::polygonalLasso, SelectionMode::replace, ratio))); + session.selectTool(NavigationTool::wand); + canvas.synchronizeDisplay(); + QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::replace, ratio))); + shown.hover(QPointF(200, 150), Qt::AltModifier); + QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::subtract, ratio))); + // Icons and badges differ; the hot spot is the cross. + QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::rectangleMarquee, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::replace, 1))); + QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::polygonalLasso, SelectionMode::replace, 1))); + QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::add, 1)) != image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::subtract, 1))); + QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)) != image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::subtract, 1))); + QCOMPARE(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1).hotSpot(), QPoint(8, 8)); + QVERIFY(image(CanvasView::selectionCursor(SelectionIcon::freehandLasso, SelectionMode::replace, 1)).pixelColor(8, 8).lightness() < 128); + QCOMPARE(CanvasView::wandCursor(SelectionMode::replace, 1).hotSpot(), QPoint(7, 7)); + QVERIFY(image(CanvasView::wandCursor(SelectionMode::replace, 1)).pixelColor(20, 20).lightness() < 60); + QCOMPARE(image(CanvasView::wandCursor(SelectionMode::replace, 1)).pixelColor(20, 20).alpha(), 255); + // Over the selection in New mode, the move pointer. + session.selectTool(NavigationTool::marquee); + session.selectAll(); + canvas.synchronizeDisplay(); + shown.hover(QPointF(200, 150)); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); + shown.hover(QPointF(200, 150), Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::ellipseMarquee, SelectionMode::add, ratio))); + shown.press(QPointF(200, 150)); + shown.move(QPointF(210, 150), Qt::ShiftModifier); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); + // Space mid-drag leaves the outline its pointer. + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyPress(&canvas, Qt::Key_Space); + shown.move(QPointF(220, 150)); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); + QTest::keyRelease(&canvas, Qt::Key_Space); + shown.release(QPointF(220, 150), Qt::ShiftModifier); + QCOMPARE(bounds(session).x(), 20.0); + QVERIFY(image(CanvasView::moveSelectionCursor(1)).pixelColor(17, 16).alpha() > 0); + // Space shows the hand; busy the arrow. + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyPress(&canvas, Qt::Key_Space); + QCOMPARE(canvas.cursor().shape(), Qt::OpenHandCursor); + QTest::keyRelease(&canvas, Qt::Key_Space); + QVERIFY(shown.shows(CanvasView::moveSelectionCursor(ratio))); +} + +void SelectionOverlayTests::theAntsMarchWhileASelectionShowsAndStopWithout() +{ + Canvas shown; + EditorSession &session = shown.session; + CanvasView &canvas = *shown.canvas; + session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select"); + canvas.synchronizeDisplay(); + // Along the top edge the white dashes move. + const auto edge = [&] { + const QImage shot = canvas.grab().toImage(); + QString row; + for (int x = 50; x < 150; ++x) + row += shot.pixelColor(x, 50).lightness() > 120 ? 'w' : '.'; + return row; + }; + const QString first = edge(); + QVERIFY(first.contains('w') && first.contains('.')); + QTRY_VERIFY(edge() != first); + // Each tick repaints the outline's rect, nothing more. + QApplication::processEvents(); + PaintSpy spy(canvas); + QTRY_VERIFY(spy.count >= 2); + QVERIFY(canvas.findChild<QTimer *>("antsTimer")->isActive()); + QVERIFY(spy.painted.contains(QRect(50, 50, 100, 100))); + QVERIFY(QRect(46, 46, 108, 108).contains(spy.painted)); + // Without a selection the ants rest; the outline is gone. + session.deselect(); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + const QString cleared = edge(); + QVERIFY(!cleared.contains('w')); + const int painted = spy.count; + QTest::qWait(300); + QCOMPARE(spy.count, painted); + QCOMPARE(edge(), cleared); + QVERIFY(!canvas.findChild<QTimer *>("antsTimer")->isActive()); + QCOMPARE(canvas.findChild<QTimer *>("antsTimer")->interval(), 120); + // An empty selection shows no ants either. + session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select"); + session.applySelection(rectPath(QRectF(0, 0, 400, 300)), SelectionMode::subtract, "Subtract"); + canvas.synchronizeDisplay(); + QCOMPARE(edge(), cleared); + // Beside the Move box a tick repaints the ants alone. + session.insert(filled(400, 300, qRgba(0, 0, 255, 255), "Blue")); + session.selectTool(NavigationTool::move); + session.applySelection(rectPath(QRectF(50, 50, 20, 20)), SelectionMode::replace, "Select"); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + spy.painted = QRect(); + const int before = spy.count; + QTRY_VERIFY(spy.count >= before + 2); + QVERIFY(QRect(46, 46, 28, 28).contains(spy.painted)); +} + +void SelectionOverlayTests::autoscrollPansTheViewWhileADragHoldsAtTheEdge() +{ + Canvas shown; + EditorSession &session = shown.session; + const QSizeF size = shown.documentSize(); + const QPointF before = session.viewport.viewPoint(QPointF(0, 0), size); + // Held at the right edge, the view slides. + shown.press(QPointF(100, 100)); + shown.move(QPointF(395, 150)); + QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() < before.x() - 4); + // Seven points into the margin: 4.8 a tick. + const double slid = before.x() - session.viewport.viewPoint(QPointF(0, 0), size).x(); + QVERIFY2(std::abs(std::fmod(slid, 4.8)) < 1e-6 && slid < 4.8 * 6, qPrintable(QString::number(slid))); + QVERIFY(!session.selection().has_value()); + const double grown = session.lassoDraft().value().points[2].x(); + QVERIFY(grown > 395); + // Far past the edge, forty a tick at most. + shown.move(QPointF(200, 150)); + const QPointF held = session.viewport.viewPoint(QPointF(0, 0), size); + shown.move(QPointF(500, 150)); + QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() < held.x() - 39); + const double capped = held.x() - session.viewport.viewPoint(QPointF(0, 0), size).x(); + QVERIFY2(std::abs(std::fmod(capped, 40)) < 1e-6, qPrintable(QString::number(capped))); + // Back inside, the pan stops where it is. + shown.move(QPointF(200, 150)); + QVERIFY(!shown.canvas->findChild<QTimer *>("marqueeAutoscroll")->isActive()); + QCOMPARE(shown.canvas->findChild<QTimer *>("marqueeAutoscroll")->interval(), 16); + const QPointF rest = session.viewport.viewPoint(QPointF(0, 0), size); + QTest::qWait(100); + QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), size), rest); + shown.release(QPointF(200, 150)); + QVERIFY(session.selection().has_value()); + // A dragged outline follows the pan too. + session.selectAll(); + shown.press(QPointF(200, 150)); + shown.move(QPointF(5, 150)); + QTRY_VERIFY(session.viewport.viewPoint(QPointF(0, 0), size).x() > rest.x() + 20); + // The outline followed the pointer, then the pan. + QVERIFY(bounds(session).x() < -195); + shown.release(QPointF(5, 150)); + QCOMPARE(session.history.undoName(), QString("Move Selection")); +} + +void SelectionOverlayTests::theOutlineAndTheDraftAreDrawnWhereTheyLie() +{ + Canvas shown; + EditorSession &session = shown.session; + CanvasView &canvas = *shown.canvas; + // The first paint is still pending: let it pass. + QTest::qWait(50); + PaintSpy spy(canvas); + shown.press(QPointF(50, 50)); + shown.move(QPointF(150, 100)); + QApplication::processEvents(); + // The draft's box, black under white, repainted where it lies. + QVERIFY(spy.painted.contains(QRect(50, 50, 100, 50))); + QVERIFY(!spy.painted.contains(QPoint(300, 250))); + QImage shot = canvas.grab().toImage(); + // Soft-edged lines mix with the checkerboard's 77 and 89. + const QColor top = shot.pixelColor(100, 50), beside = shot.pixelColor(100, 53); + QVERIFY(top.lightness() > 120 || top.lightness() < 55); + QVERIFY(beside.lightness() > 55 && beside.lightness() < 120); + shown.release(QPointF(150, 100)); + QApplication::processEvents(); + shot = canvas.grab().toImage(); + // Four on, four off: about half the edge is white. + int light = 0; + for (int x = 50; x < 150; ++x) + light += shot.pixelColor(x, 50).lightness() > 120; + QVERIFY2(light >= 40 && light <= 60, qPrintable(QString::number(light))); + // A new outline repaints where it lies, before any tick. + session.deselect(); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + spy.painted = QRect(); + session.applySelection(rectPath(QRectF(200, 200, 50, 50)), SelectionMode::replace, "Select"); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + QVERIFY(spy.painted.contains(QRect(200, 200, 50, 50))); + QVERIFY(!spy.painted.contains(QPoint(20, 20))); + // Panned, the outline moves; deselecting repaints where it is. + session.viewport.translate(QSizeF(-100, 0)); + session.notify(); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + spy.painted = QRect(); + session.deselect(); + canvas.synchronizeDisplay(); + QApplication::processEvents(); + QVERIFY(spy.painted.contains(QRect(100, 200, 50, 50))); + // A polygonal draft draws its first corner's handle. + session.selectTool(NavigationTool::lasso); + session.setLassoKind(LassoKind::polygonal); + shown.click(QPointF(200, 200)); + shown.hover(QPointF(250, 220)); + QApplication::processEvents(); + shot = canvas.grab().toImage(); + QVERIFY(shot.pixelColor(200, 200).lightness() > 200); + QVERIFY(shot.pixelColor(196, 200).lightness() < 160); + QVERIFY(shot.pixelColor(193, 200).lightness() < 120); + session.cancelLasso(); +} + +void SelectionOverlayTests::outlinesAndDraftsAreAntialiased() +{ + Canvas shown; + EditorSession &session = shown.session; + // Over mid gray a soft edge leaves other values. + const auto softness = [&] { + QImage sheet(400, 300, QImage::Format_RGB32); + sheet.fill(QColor(128, 128, 128)); + QPainter painter(&sheet); + TransformOverlay(session).draw(painter, shown.canvas->palette()); + painter.end(); + // A white line's partial pixels lie between gray and white. + int soft = 0; + for (int y = 0; y < 300; ++y) { + for (int x = 0; x < 400; ++x) { + const int value = qRed(sheet.pixel(x, y)); + soft += value > 128 && value < 255; + } + } + return soft; + }; + QPainterPath oval; + oval.addEllipse(QRectF(50, 50, 200, 100)); + session.applySelection(oval, SelectionMode::replace, "Select"); + QVERIFY(softness() > 100); + session.deselect(); + QCOMPARE(softness(), 0); + session.setMarqueeKind(LassoKind::ellipse); + session.beginLasso(QPointF(50, 50), SelectionMode::replace); + session.dragMarquee(QPointF(250, 150), false, false); + QVERIFY(softness() > 100); + session.cancelLasso(); +} + +void SelectionOverlayTests::theAntsDashesKeepFourOnFourOffAtTwoTimes() +{ + Canvas shown; + EditorSession &session = shown.session; + session.applySelection(rectPath(QRectF(50, 50, 100, 100)), SelectionMode::replace, "Select"); + // Drawn at 2×: a one-point line fills two device rows. + QImage sheet(800, 600, QImage::Format_RGB32); + sheet.fill(QColor(128, 128, 128)); + QPainter painter(&sheet); + painter.scale(2, 2); + TransformOverlay(session).draw(painter, shown.canvas->palette()); + painter.end(); + QString row; + for (int x = 100; x <= 300; ++x) { + const int value = qRed(sheet.pixel(x, 100)); + row += value < 64 ? 'b' : value > 192 ? 'w' : '.'; + } + QVERIFY(!row.contains('.')); + // Butt caps: every whole dash and gap is eight pixels. + int runs = 0; + for (int start = 0; start < row.size();) { + int end = start; + while (end < row.size() && row[end] == row[start]) + ++end; + if (start > 0 && end < row.size()) { + QCOMPARE(end - start, 8); + ++runs; + } + start = end; + } + QVERIFY(runs >= 20); +} + +void SelectionOverlayTests::openDraftsAndCursorBadgesEndWhereTheyEnd() +{ + Canvas shown; + EditorSession &session = shown.session; + session.selectTool(NavigationTool::lasso); + session.beginLasso(QPointF(50, 100), SelectionMode::replace); + session.extendLasso(QPointF(150, 100)); + // At 2× over gray the stroke stops at its end. + QImage sheet(800, 600, QImage::Format_RGB32); + sheet.fill(QColor(128, 128, 128)); + QPainter painter(&sheet); + painter.scale(2, 2); + TransformOverlay(session).draw(painter, shown.canvas->palette()); + painter.end(); + QVERIFY(qRed(sheet.pixel(298, 200)) < 64 || qRed(sheet.pixel(298, 200)) > 192); + QCOMPARE(sheet.pixel(300, 200), QColor(128, 128, 128).rgb()); + QCOMPARE(sheet.pixel(301, 200), QColor(128, 128, 128).rgb()); + QCOMPARE(sheet.pixel(99, 200), QColor(128, 128, 128).rgb()); + session.cancelLasso(); + // The badges' dashes at 2×: four dark, three light, butt-capped. + const auto whites = [](const QCursor &cursor, int row, int from, int to) { + const QImage badge = image(cursor); + int count = 0; + for (int x = from; x <= to; ++x) + count += badge.pixelColor(x, row).lightness() > 192 && badge.pixelColor(x, row).alpha() > 0; + return count; + }; + QVERIFY2(whites(CanvasView::moveSelectionCursor(2), 33, 28, 42) >= 5, "the move badge"); + QVERIFY2(whites(CanvasView::loadSelectionCursor(2), 37, 36, 50) >= 5, "the load badge"); +} + +QTEST_MAIN(SelectionOverlayTests) +#include "SelectionOverlayTests.moc" diff --git a/tests/SelectionSignalTests.cpp b/tests/SelectionSignalTests.cpp new file mode 100644 index 0000000..e91d3fc --- /dev/null +++ b/tests/SelectionSignalTests.cpp @@ -0,0 +1,161 @@ +#include "SelectionFixtures.h" +#include "SessionRecord.h" + +// The signal contract of every selection call. +class SelectionSignalTests : public QObject { + Q_OBJECT +private slots: + void selectionCallsAnnounceAndRefuseInSilence(); + void theLastSignalOfEverySelectionChangeSeesWhatItLeaves(); +}; + +void SelectionSignalTests::selectionCallsAnnounceAndRefuseInSilence() +{ + const auto session = selectionSession(); + QSignalSpy changes(session.get(), &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + QVERIFY(!announced([&] { session->deselect(); })); + QVERIFY(!announced([&] { session->invertSelection(); })); + QVERIFY(!announced([&] { session->expandSelection(1); })); + QVERIFY(!announced([&] { session->cancelLasso(); })); + QVERIFY(!announced([&] { session->extendLasso(QPointF(1, 1)); })); + QVERIFY(!announced([&] { session->moveLassoCursor(QPointF(1, 1)); })); + QVERIFY(!announced([&] { session->removeLastLassoPoint(); })); + QVERIFY(!announced([&] { session->finishLasso(); })); + QVERIFY(!announced([&] { session->dragMarquee(QPointF(1, 1), false, false); })); + QVERIFY(!announced([&] { QVERIFY(!session->beginSelectionMove()); })); + QVERIFY(!announced([&] { session->moveSelection(QSizeF(1, 1)); })); + QVERIFY(!announced([&] { session->endSelectionMove(); })); + QVERIFY(!announced([&] { session->nudgeSelection(1, 1); })); + QVERIFY(!announced([&] { session->applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::subtract, "Subtract"); })); + QVERIFY(!announced([&] { session->updateHeldSelectionKeys(false, false); })); + QVERIFY(!announced([&] { session->loadMaskSelection(session->activeLayerID().value()); })); + QVERIFY(!announced([&] { session->loadLayerSelection(session->activeLayerID().value()); })); + QVERIFY(announced([&] { session->updateHeldSelectionKeys(true, false); })); + QVERIFY(announced([&] { session->setSelectionModeChoice(SelectionMode::add); })); + QVERIFY(announced([&] { session->setSelectionAntialiased(false); })); + QVERIFY(announced([&] { session->setSelectionExpandAmount(3); })); + QVERIFY(announced([&] { session->setSelectionContractAmount(2); })); + QVERIFY(announced([&] { session->setWandSettings(WandSettings{.tolerance = 5}); })); + QVERIFY(!announced([&] { session->magicWand(QPointF(-1, -1), SelectionMode::replace); })); + QVERIFY(announced([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); })); + QVERIFY(announced([&] { session->extendLasso(QPointF(50, 1)); })); + QVERIFY(!announced([&] { session->extendLasso(QPointF(50.1, 1)); })); + QVERIFY(announced([&] { session->moveLassoCursor(QPointF(50, 50)); })); + QVERIFY(announced([&] { session->removeLastLassoPoint(); })); + QVERIFY(announced([&] { session->extendLasso(QPointF(50, 1)); })); + QVERIFY(announced([&] { session->extendLasso(QPointF(50, 50)); })); + QVERIFY(announced([&] { session->finishLasso(); })); + QVERIFY(session->selection().has_value()); + QVERIFY(announced([&] { session->selectAll(); })); + QVERIFY(!announced([&] { session->selectAll(); })); + QVERIFY(announced([&] { session->invertSelection(); })); + QVERIFY(announced([&] { session->deselect(); })); + QVERIFY(announced([&] { session->toggleMarqueeKind(); })); + QVERIFY(announced([&] { session->toggleLassoKind(); })); + QVERIFY(announced([&] { session->selectTool(NavigationTool::marquee); })); + QVERIFY(announced([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); })); + QVERIFY(announced([&] { session->dragMarquee(QPointF(20, 20), false, false); })); + QVERIFY(announced([&] { session->cancelLasso(); })); + QVERIFY(announced([&] { session->applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select"); })); + QVERIFY(announced([&] { QVERIFY(session->beginSelectionMove()); })); + QVERIFY(announced([&] { session->moveSelection(QSizeF(1, 1)); })); + QVERIFY(announced([&] { session->endSelectionMove(); })); + QVERIFY(announced([&] { session->nudgeSelection(1, 1); })); + QVERIFY(announced([&] { session->expandSelection(1); })); + QVERIFY(announced([&] { session->contractSelection(1); })); + QVERIFY(announced([&] { session->addMask(); })); + QVERIFY(announced([&] { session->deleteLayerOrMask(); })); + QVERIFY(announced([&] { session->deleteLayerOrMask(); })); + QVERIFY(session->document().value().layers.empty()); + QVERIFY(!announced([&] { session->deleteLayerOrMask(); })); +} + +void SelectionSignalTests::theLastSignalOfEverySelectionChangeSeesWhatItLeaves() +{ + const auto session = selectionSession(); + QStringList seen; + connect(session.get(), &EditorSession::changed, this, [&] { seen = described(*session); }); + const auto stale = [&](const std::function<void()> &change) { + seen.clear(); + change(); + const QStringList left = described(*session); + return seen == left ? QString() : seen.join("; ") + " != " + left.join("; "); + }; + QCOMPARE(stale([&] { session->updateHeldSelectionKeys(false, true); }), QString()); + // A click with nothing selected still ends the draft. + QCOMPARE(stale([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); }), QString()); + QCOMPARE(stale([&] { session->finishLasso(); }), QString()); + QVERIFY(seen.filter("lasso ").isEmpty()); + QCOMPARE(stale([&] { session->setSelectionModeChoice(SelectionMode::add); }), QString()); + QCOMPARE(stale([&] { session->setSelectionAntialiased(false); }), QString()); + QCOMPARE(stale([&] { session->setSelectionExpandAmount(4); }), QString()); + QCOMPARE(stale([&] { session->setSelectionContractAmount(5); }), QString()); + QCOMPARE(stale([&] { session->setWandSettings(WandSettings{.tolerance = 5, .sampleAllLayers = true}); }), QString()); + QCOMPARE(stale([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); }), QString()); + QCOMPARE(stale([&] { session->extendLasso(QPointF(60, 1)); }), QString()); + QCOMPARE(stale([&] { session->moveLassoCursor(QPointF(60, 60)); }), QString()); + QCOMPARE(stale([&] { session->removeLastLassoPoint(); }), QString()); + QCOMPARE(stale([&] { session->extendLasso(QPointF(60, 1)); }), QString()); + QCOMPARE(stale([&] { session->extendLasso(QPointF(60, 60)); }), QString()); + QCOMPARE(stale([&] { session->finishLasso(); }), QString()); + QVERIFY(seen.contains("names Lasso/") && seen.filter("outline ").size() == 1); + QCOMPARE(stale([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); }), QString()); + QCOMPARE(stale([&] { session->cancelLasso(); }), QString()); + QCOMPARE(stale([&] { session->beginLasso(QPointF(1, 1), SelectionMode::replace); }), QString()); + QCOMPARE(stale([&] { session->selectTool(NavigationTool::marquee); }), QString()); + QVERIFY(seen.filter("lasso ").isEmpty()); + QCOMPARE(stale([&] { session->toggleMarqueeKind(); }), QString()); + QCOMPARE(stale([&] { session->beginLasso(QPointF(1, 1), SelectionMode::add); }), QString()); + QCOMPARE(stale([&] { session->dragMarquee(QPointF(30, 30), true, false); }), QString()); + QCOMPARE(stale([&] { session->finishLasso(); }), QString()); + QCOMPARE(stale([&] { session->toggleLassoKind(); }), QString()); + QCOMPARE(stale([&] { QVERIFY(session->beginSelectionMove()); }), QString()); + QVERIFY(seen.contains("history 00")); + QCOMPARE(stale([&] { session->moveSelection(QSizeF(3, 4)); }), QString()); + QCOMPARE(stale([&] { session->endSelectionMove(); }), QString()); + QVERIFY(seen.contains("history 10") && seen.contains("names Move Selection/")); + QCOMPARE(stale([&] { session->nudgeSelection(1, 0); }), QString()); + QCOMPARE(stale([&] { session->expandSelection(2); }), QString()); + QCOMPARE(stale([&] { session->contractSelection(1); }), QString()); + QCOMPARE(stale([&] { session->invertSelection(); }), QString()); + QCOMPARE(stale([&] { session->selectAll(); }), QString()); + QCOMPARE(stale([&] { session->applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::subtract, "Subtract"); }), QString()); + QCOMPARE(stale([&] { session->deselect(); }), QString()); + QCOMPARE(stale([&] { session->undo(); }), QString()); + QVERIFY(seen.filter("outline ").size() == 1); + QCOMPARE(stale([&] { session->setSelection(std::nullopt, "Clear"); }), QString()); + // The panel's Ctrl-clicks and the mask from a selection. + QCOMPARE(stale([&] { session->applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select"); }), QString()); + QCOMPARE(stale([&] { session->addMask(); }), QString()); + QVERIFY(seen.filter("outline ").isEmpty() && seen.contains("mask target 1")); + QCOMPARE(stale([&] { session->loadMaskSelection(session->activeLayerID().value()); }), QString()); + QVERIFY(seen.contains("names Load Mask Selection/")); + QCOMPARE(stale([&] { session->deleteLayerOrMask(); }), QString()); + QVERIFY(seen.contains("mask target 0")); + QCOMPARE(stale([&] { session->insert(filledRed()); }), QString()); + QCOMPARE(stale([&] { session->loadLayerSelection(session->activeLayerID().value(), SelectionMode::add); }), QString()); + QVERIFY(seen.contains("names Load Layer Selection/")); + // An invert: busy while running, a step when it lands. + bool done = false; + QCOMPARE(stale([&] { session->invertPixels([&] { done = true; }); }), QString()); + QVERIFY(seen.contains("flags 1000")); + QTRY_VERIFY(done); + QVERIFY(seen.contains("flags 0000") && seen.contains("names Invert/")); + QCOMPARE(seen, described(*session)); + QCOMPARE(stale([&] { session->deleteLayerOrMask(); }), QString()); + // A wand click: busy while running, a step when landing. + done = false; + QCOMPARE(stale([&] { session->magicWand(QPointF(1, 1), SelectionMode::replace, [&] { done = true; }); }), QString()); + QVERIFY(seen.contains("flags 1000")); + QTRY_VERIFY(done); + QVERIFY(seen.contains("flags 0000") && seen.contains("names Magic Wand/")); + QCOMPARE(seen, described(*session)); +} + +QTEST_GUILESS_MAIN(SelectionSignalTests) +#include "SelectionSignalTests.moc" diff --git a/tests/SelectionSourceTests.cpp b/tests/SelectionSourceTests.cpp new file mode 100644 index 0000000..7565939 --- /dev/null +++ b/tests/SelectionSourceTests.cpp @@ -0,0 +1,241 @@ +#include "AddressSpaceLimit.h" +#include "RenderFixtures.h" +#include "SelectionFixtures.h" + +// The Marquee's boxes and thumbnails loaded as selections. +namespace { +void marquee(EditorSession &session, QPointF start, QPointF end, SelectionMode mode = SelectionMode::replace, bool square = false, bool fromCenter = false) +{ + session.beginLasso(start, mode); + session.dragMarquee(end, square, fromCenter); + session.finishLasso(); +} + +// A mask: a black square with a white hole. +QUuid maskedLayer(EditorSession &session) +{ + const QUuid id = session.activeLayerID().value(); + QImage mask = BrushRaster::context(100, 100, true); + mask.fill(Qt::white); + QPainter painter(&mask); + painter.fillRect(QRect(20, 30, 40, 40), Qt::black); + painter.fillRect(QRect(30, 40, 10, 10), Qt::white); + painter.end(); + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); }); + return id; +} +} + +class SelectionSourceTests : public QObject { + Q_OBJECT +private slots: + void marqueeDrawsWholePixelRectanglesInAnyDirection(); + void marqueeShiftMakesSquaresAndCenteredDragsGrowFromTheAnchor(); + void marqueeEllipseSelectsAnOvalInItsBoxAndShiftMakesACircle(); + void ctrlClickingAMaskSelectsItsBlackAreas(); + void maskSelectionFollowsTheLayerTransformAndIgnoresAllWhiteMasks(); + void ctrlClickingALayerSelectsItsOpaquePixels(); + void paintedPixelsThatCannotFlattenLoadNothing(); +}; + +void SelectionSourceTests::marqueeDrawsWholePixelRectanglesInAnyDirection() +{ + const auto session = selectionSession(); + session->selectTool(NavigationTool::marquee); + marquee(*session, QPointF(60.4, 70.6), QPointF(20.2, 30.3)); + QCOMPARE(session->history.undoName(), QString("Rectangular Marquee")); + QCOMPARE(bounds(*session), QRectF(20, 30, 40, 41)); + QCOMPARE(coverage(*session, 20, 30), 255); + QCOMPARE(coverage(*session, 19, 30), 0); + marquee(*session, QPointF(80, 80), QPointF(90, 90), SelectionMode::add); + QCOMPARE(coverage(*session, 85, 85), 255); + QCOMPARE(coverage(*session, 40, 50), 255); + marquee(*session, QPointF(30, 40), QPointF(50, 60), SelectionMode::subtract); + QCOMPARE(coverage(*session, 40, 50), 0); + QCOMPARE(coverage(*session, 25, 35), 255); + // A click deselects; a wild drag point is ignored. + session->beginLasso(QPointF(5, 5), SelectionMode::replace); + session->dragMarquee(QPointF(std::nan(""), 5), false, false); + QCOMPARE(session->lassoDraft().value().points.size(), size_t(1)); + session->finishLasso(); + QVERIFY(!session->selection().has_value()); + // A lasso draft takes no marquee drag. + session->selectTool(NavigationTool::lasso); + session->beginLasso(QPointF(5, 5), SelectionMode::replace); + session->dragMarquee(QPointF(50, 50), false, false); + QCOMPARE(session->lassoDraft().value().points.size(), size_t(1)); + QVERIFY(!session->lassoDraft().value().anchor.has_value()); + session->cancelLasso(); +} + +void SelectionSourceTests::marqueeShiftMakesSquaresAndCenteredDragsGrowFromTheAnchor() +{ + const auto session = selectionSession(); + session->selectTool(NavigationTool::marquee); + marquee(*session, QPointF(10, 10), QPointF(40, 20), SelectionMode::replace, true); + QCOMPARE(bounds(*session), QRectF(10, 10, 30, 30)); + marquee(*session, QPointF(50, 50), QPointF(60, 55), SelectionMode::replace, false, true); + QCOMPARE(bounds(*session), QRectF(40, 45, 20, 10)); + marquee(*session, QPointF(50, 50), QPointF(45, 58), SelectionMode::replace, true, true); + QCOMPARE(bounds(*session), QRectF(42, 42, 16, 16)); + QVERIFY(isSelectionTool(NavigationTool::marquee) && !isSelectionTool(NavigationTool::brush)); + // The anchor rounds; the box follows the drag's sign. + QCOMPARE(DragBox::rect(QPointF(10, 10), QPointF(3.4, 15.6), false, false), QRectF(3, 10, 7, 6)); + QCOMPARE(DragBox::rect(QPointF(10, 10), QPointF(3, 16), true, false), QRectF(3, 10, 7, 7)); +} + +void SelectionSourceTests::marqueeEllipseSelectsAnOvalInItsBoxAndShiftMakesACircle() +{ + const auto session = selectionSession(); + session->selectTool(NavigationTool::marquee); + session->toggleMarqueeKind(); + QVERIFY(session->marqueeKind() == LassoKind::ellipse); + session->beginLasso(QPointF(10, 20), SelectionMode::replace); + session->dragMarquee(QPointF(70, 60), false, false); + session->finishLasso(); + QCOMPARE(session->history.undoName(), QString("Elliptical Marquee")); + const QRectF box = bounds(*session); + QVERIFY(std::abs(box.left() - 10) < 0.5 && std::abs(box.right() - 70) < 0.5 && std::abs(box.top() - 20) < 0.5 && std::abs(box.bottom() - 60) < 0.5); + QCOMPARE(coverage(*session, 40, 40), 255); + // The box's corner lies outside the oval. + QCOMPARE(coverage(*session, 11, 21), 0); + session->beginLasso(QPointF(5, 5), SelectionMode::replace); + session->dragMarquee(QPointF(45, 25), true, false); + session->finishLasso(); + const QRectF circle = bounds(*session); + QVERIFY(std::abs(circle.width() - circle.height()) < 0.5); + // A click with the ellipse deselects too. + marquee(*session, QPointF(5, 5), QPointF(5, 5)); + QVERIFY(!session->selection().has_value()); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + session->toggleMarqueeKind(); + QVERIFY(session->marqueeKind() == LassoKind::rectangle && !session->lassoDraft().has_value()); +} + +void SelectionSourceTests::ctrlClickingAMaskSelectsItsBlackAreas() +{ + const auto session = selectionSession(); + const QUuid id = maskedLayer(*session); + session->loadMaskSelection(id); + QCOMPARE(session->history.undoName(), QString("Load Mask Selection")); + // Black is selected; the white hole and surroundings are not. + QCOMPARE(coverage(*session, 25, 35), 255); + QCOMPARE(coverage(*session, 35, 45), 0); + QCOMPARE(coverage(*session, 80, 80), 0); + // Exact pixel edges. + QCOMPARE(coverage(*session, 59, 69), 255); + QCOMPARE(coverage(*session, 60, 70), 0); + // Shift adds and Alt subtracts. + lasso(*session, square(80, 80, 10)); + session->loadMaskSelection(id, SelectionMode::add); + QCOMPARE(coverage(*session, 85, 85), 255); + QCOMPARE(coverage(*session, 25, 35), 255); + session->loadMaskSelection(id, SelectionMode::subtract); + QCOMPARE(coverage(*session, 85, 85), 255); + QCOMPARE(coverage(*session, 25, 35), 0); + // An unknown layer, or one without a mask, loads nothing. + const int count = session->history.undoCount(); + EditorSession empty; + empty.loadMaskSelection(id); + empty.loadLayerSelection(id); + QVERIFY(!empty.document().has_value()); + session->loadMaskSelection(QUuid::createUuid()); + session->addBlankLayer(); + session->loadMaskSelection(session->activeLayerID().value()); + QCOMPARE(session->history.undoCount(), count + 1); +} + +void SelectionSourceTests::maskSelectionFollowsTheLayerTransformAndIgnoresAllWhiteMasks() +{ + const auto session = selectionSession(200, 200); + const QUuid id = maskedLayer(*session); + // The layer and its mask stretched 2× from the origin. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform.origin = QPointF(0, 0); + record(snapshot, id).transform.size = QSizeF(200, 200); + }); + session->loadMaskSelection(id); + QCOMPARE(bounds(*session), QRectF(40, 60, 80, 80)); + // A mask placed apart loads where it shows. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).maskPlacement = LayerTransform{.origin = {10, 0}, .size = {100, 100}}; + record(snapshot, id).maskLinked = false; + }); + session->loadMaskSelection(id); + QCOMPARE(bounds(*session), QRectF(30, 30, 40, 40)); + session->deselect(); + session->addBlankLayer(); + session->addLayerMask(true); + // No black anywhere: nothing to select. + session->loadMaskSelection(session->activeLayerID().value()); + QVERIFY(!session->selection().has_value()); +} + +void SelectionSourceTests::ctrlClickingALayerSelectsItsOpaquePixels() +{ + const auto session = selectionSession(200, 200); + // An opaque ring, a clear centre, a faint corner pixel. + QImage image = BrushRaster::context(50, 50, false); + QPainter painter(&image); + painter.fillRect(QRect(10, 10, 30, 30), Qt::red); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(QRect(20, 20, 10, 10), Qt::transparent); + painter.fillRect(QRect(0, 0, 1, 1), QColor(255, 0, 0, 64)); + painter.end(); + session->insert(ImportedImage(image, image, "Ring")); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform.origin = QPointF(50, 50); + record(snapshot, id).transform.size = QSizeF(100, 100); + }); + session->loadLayerSelection(id); + QCOMPARE(session->history.undoName(), QString("Load Layer Selection")); + // Twice the size: the ring's box doubles. + QCOMPARE(bounds(*session), QRectF(70, 70, 60, 60)); + QCOMPARE(coverage(*session, 75, 75), 255); + QCOMPARE(coverage(*session, 100, 100), 0); + // The 25% pixel is under the threshold. + QCOMPARE(coverage(*session, 50, 50), 0); + lasso(*session, square(0, 0, 20)); + session->loadLayerSelection(id, SelectionMode::add); + QCOMPARE(coverage(*session, 10, 10), 255); + QCOMPARE(coverage(*session, 75, 75), 255); + // An empty layer selects nothing; nor does a folder. + session->addBlankLayer(); + const std::optional<DocumentSelection> before = session->selection(); + session->loadLayerSelection(session->activeLayerID().value()); + QVERIFY(session->selection() == before); + session->addGroup(); + session->loadLayerSelection(session->activeLayerID().value()); + QVERIFY(session->selection() == before); + session->loadLayerSelection(QUuid::createUuid()); + QVERIFY(session->selection() == before); +} + +void SelectionSourceTests::paintedPixelsThatCannotFlattenLoadNothing() +{ + // Two painted bases flatten on first use. + const auto session = selectionSession(4000, 3000); + const auto pixels = std::make_shared<const RasterSnapshot>(4000, 3000, solid(2, 2, qRgba(255, 0, 0, 255)), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}); + session->insert(ImportedImage(pixels, QImage(), "Painted")); + const QUuid id = session->activeLayerID().value(); + QImage gray(2, 2, QImage::Format_Grayscale8); + gray.fill(Qt::black); + const auto mask = std::make_shared<const RasterSnapshot>(4000, 3000, gray, QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}, true); + rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, ImportedImage(mask, gray, "Mask")); }); + const int count = session->history.undoCount(); + { + const AddressSpaceLimit limit(8 * 1024 * 1024); + session->loadLayerSelection(id); + session->loadMaskSelection(id); + } + QVERIFY(!session->selection().has_value()); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(!pixels->hasMaterializedPixels() && !mask->hasMaterializedPixels()); + // With room, the mask loads: its black base, white around. + session->loadMaskSelection(id); + QCOMPARE(bounds(*session), QRectF(0, 0, 2, 2)); +} + +QTEST_GUILESS_MAIN(SelectionSourceTests) +#include "SelectionSourceTests.moc" diff --git a/tests/SelectionTests.cpp b/tests/SelectionTests.cpp new file mode 100644 index 0000000..5fd2b51 --- /dev/null +++ b/tests/SelectionTests.cpp @@ -0,0 +1,398 @@ +#include "SelectionFixtures.h" + +// Outlines, modes, moves and resizes of the selection. +class SelectionTests : public QObject { + Q_OBJECT +private slots: + void replaceAddAndSubtractCombineOutlines(); + void modifiersPickModeAndSelectionIsClippedToCanvas(); + void emptySelectionIsDistinctFromNoSelection(); + void clickDeselectsAndSelectionStepsUndo(); + void polygonalCornersCanBeRemovedAndClosed(); + void antialiasingControlsEdgeCoverage(); + void selectAllInverseAndToolSwitchCancelsDraft(); + void cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode(); + void draggingMovesTheOutlineInWholePixelsAsOneUndo(); + void movingOffCanvasAndBackKeepsTheWholeShape(); + void arrowNudgesAndMoveIsOnlyForNewModeOnARealSelection(); + void expandAndContractGrowAndShrinkTheOutline(); + void expandStaysOnCanvasAndContractCanEmptyTheSelection(); + void theClipCoversTheOutlinesRegionAndAnEmptyOneNothing(); + void draftsRefuseWildPointsAndOtherToolsAndKeepTheirCursor(); +}; + +void SelectionTests::replaceAddAndSubtractCombineOutlines() +{ + const auto session = selectionSession(); + lasso(*session, square(10, 10, 40)); + QCOMPARE(coverage(*session, 30, 30), 255); + QCOMPARE(coverage(*session, 70, 70), 0); + lasso(*session, square(50, 50, 40), SelectionMode::add); + QCOMPARE(coverage(*session, 30, 30), 255); + QCOMPARE(coverage(*session, 70, 70), 255); + lasso(*session, square(20, 20, 20), SelectionMode::subtract); + QCOMPARE(coverage(*session, 30, 30), 0); + QCOMPARE(coverage(*session, 15, 15), 255); + lasso(*session, square(60, 10, 20)); + QCOMPARE(coverage(*session, 70, 20), 255); + QCOMPARE(coverage(*session, 70, 70), 0); + QCOMPARE(coverage(*session, 15, 15), 0); +} + +void SelectionTests::modifiersPickModeAndSelectionIsClippedToCanvas() +{ + const auto session = selectionSession(); + QVERIFY(session->selectionMode(false, false) == SelectionMode::replace); + QVERIFY(session->selectionMode(true, false) == SelectionMode::add); + QVERIFY(session->selectionMode(true, true) == SelectionMode::subtract); + QVERIFY(session->selectionMode(false, true) == SelectionMode::subtract); + lasso(*session, square(-50, -50, 100)); + const QRectF box = bounds(*session); + QVERIFY(box.left() >= 0 && box.top() >= 0 && box.right() <= 50.001 && box.bottom() <= 50.001); + QCOMPARE(coverage(*session, 49, 49), 255); + QCOMPARE(coverage(*session, 50, 50), 0); +} + +void SelectionTests::emptySelectionIsDistinctFromNoSelection() +{ + const auto session = selectionSession(); + // Nothing to subtract from. + lasso(*session, square(0, 0, 50), SelectionMode::subtract); + QVERIFY(!session->selection().has_value()); + lasso(*session, square(10, 10, 20)); + lasso(*session, square(0, 0, 60), SelectionMode::subtract); + QVERIFY(session->selection().value().isEmpty()); + QCOMPARE(coverage(*session, 20, 20), 0); + session->deselect(); + QVERIFY(!session->selection().has_value()); + // Deselecting nothing is refused and records nothing. + const int count = session->history.undoCount(); + session->deselect(); + QCOMPARE(session->history.undoCount(), count); +} + +void SelectionTests::clickDeselectsAndSelectionStepsUndo() +{ + const auto session = selectionSession(); + const int count = session->history.undoCount(); + lasso(*session, square(10, 10, 40)); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Lasso")); + lasso(*session, {QPointF(5, 5)}); + QVERIFY(!session->selection().has_value()); + QCOMPARE(session->history.undoName(), QString("Deselect")); + session->undo(); + QVERIFY(!session->selection().value().isEmpty()); + session->undo(); + QVERIFY(!session->selection().has_value()); + session->redo(); + QCOMPARE(coverage(*session, 30, 30), 255); + // The same square traced twice fills by winding, not odd-even. + std::vector<QPointF> twice = square(20, 20, 30); + twice.insert(twice.end(), {QPointF(20, 20), QPointF(50, 20), QPointF(50, 50), QPointF(20, 50)}); + lasso(*session, twice); + QCOMPARE(coverage(*session, 35, 35), 255); + // A click in Add mode keeps what there is. + lasso(*session, {QPointF(5, 5)}, SelectionMode::add); + QCOMPARE(coverage(*session, 30, 30), 255); +} + +void SelectionTests::polygonalCornersCanBeRemovedAndClosed() +{ + const auto session = selectionSession(); + session->toggleLassoKind(); + QVERIFY(session->lassoKind() == LassoKind::polygonal); + session->beginLasso(QPointF(10, 10), SelectionMode::replace); + session->extendLasso(QPointF(90, 10)); + // A misplaced corner, taken back. + session->extendLasso(QPointF(50, 50)); + session->removeLastLassoPoint(); + session->extendLasso(QPointF(90, 90)); + session->extendLasso(QPointF(10, 90)); + QCOMPARE(session->lassoDraft().value().points.size(), size_t(4)); + session->finishLasso(); + QCOMPARE(session->history.undoName(), QString("Polygonal Lasso")); + QCOMPARE(coverage(*session, 80, 80), 255); + QCOMPARE(coverage(*session, 5, 50), 0); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + session->cancelLasso(); + QVERIFY(!session->lassoDraft().has_value()); + QVERIFY(!session->selection().value().isEmpty()); + // Taking back the only point ends the draft. + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + session->removeLastLassoPoint(); + QVERIFY(!session->lassoDraft().has_value()); + // A toggle ends a draft in progress. + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + session->toggleLassoKind(); + QVERIFY(session->lassoKind() == LassoKind::freehand && !session->lassoDraft().has_value()); +} + +void SelectionTests::antialiasingControlsEdgeCoverage() +{ + const auto session = selectionSession(); + const std::vector<QPointF> triangle{QPointF(0, 0), QPointF(100, 0), QPointF(0, 100)}; + lasso(*session, triangle); + // Pixels on x + y = 99 straddle the diagonal. + int soft = 0, partial = 0; + for (int x = 0; x < 100; ++x) { + const int value = coverage(*session, x, 99 - x); + soft += value > 0 && value < 255; + } + QVERIFY(soft > 0); + QVERIFY(session->selectionAntialiased()); + session->setSelectionAntialiased(false); + lasso(*session, triangle); + QVERIFY(!session->selection().value().antialiased); + for (int x = 0; x < 100; ++x) { + const int value = coverage(*session, x, 99 - x); + partial += value > 0 && value < 255; + } + QCOMPARE(partial, 0); + // The hard edge survives Inverse, Expand and Contract. + session->invertSelection(); + QVERIFY(!session->selection().value().antialiased); + session->expandSelection(1); + QVERIFY(!session->selection().value().antialiased); + session->contractSelection(1); + QVERIFY(!session->selection().value().antialiased); +} + +void SelectionTests::selectAllInverseAndToolSwitchCancelsDraft() +{ + const auto session = selectionSession(); + session->selectAll(); + QCOMPARE(session->history.undoName(), QString("Select All")); + QCOMPARE(coverage(*session, 0, 0), 255); + QCOMPARE(coverage(*session, 99, 99), 255); + lasso(*session, square(0, 0, 50)); + session->invertSelection(); + QCOMPARE(session->history.undoName(), QString("Inverse")); + QCOMPARE(coverage(*session, 25, 25), 0); + QCOMPARE(coverage(*session, 75, 75), 255); + session->beginLasso(QPointF(5, 5), SelectionMode::replace); + session->selectTool(NavigationTool::brush); + QVERIFY(!session->lassoDraft().has_value()); + // Selecting all twice records one step. + const int count = session->history.undoCount(); + session->selectAll(); + session->selectAll(); + QCOMPARE(session->history.undoCount(), count + 1); +} + +void SelectionTests::cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode() +{ + const auto session = selectionSession(); + QVERIFY(session->lassoCursorMode(false, false) == SelectionMode::replace); + QVERIFY(session->lassoCursorMode(true, false) == SelectionMode::add); + QVERIFY(session->lassoCursorMode(false, true) == SelectionMode::subtract); + session->setSelectionModeChoice(SelectionMode::add); + QVERIFY(session->lassoCursorMode(false, false) == SelectionMode::add); + session->beginLasso(QPointF(5, 5), SelectionMode::subtract); + QVERIFY(session->lassoCursorMode(false, false) == SelectionMode::subtract); + QVERIFY(session->lassoCursorMode(true, false) == SelectionMode::subtract); + QVERIFY(session->displayedSelectionMode() == SelectionMode::subtract); + session->cancelLasso(); + session->setSelectionModeChoice(SelectionMode::replace); + session->updateHeldSelectionKeys(true, false); + QVERIFY(session->displayedSelectionMode() == SelectionMode::add); + QVERIFY(session->selectionModeChoice() == SelectionMode::replace); + QVERIFY(session->heldSelectionMode() == std::optional(SelectionMode::add)); + session->updateHeldSelectionKeys(true, true); + QVERIFY(session->displayedSelectionMode() == SelectionMode::subtract); + session->updateHeldSelectionKeys(false, false); + QVERIFY(session->displayedSelectionMode() == SelectionMode::replace); + QVERIFY(!session->heldSelectionMode().has_value()); +} + +void SelectionTests::draggingMovesTheOutlineInWholePixelsAsOneUndo() +{ + const auto session = selectionSession(); + lasso(*session, square(10, 10, 20)); + QVERIFY(session->canMoveSelection(QPointF(20, 20))); + QVERIFY(!session->canMoveSelection(QPointF(60, 60))); + const int count = session->history.undoCount(); + QVERIFY(session->beginSelectionMove()); + // A second begin waits for the first to end. + QVERIFY(!session->beginSelectionMove()); + session->moveSelection(QSizeF(10.4, 29.6)); + session->moveSelection(QSizeF(40.2, 40.4)); + session->endSelectionMove(); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Move Selection")); + QCOMPARE(bounds(*session), QRectF(50, 50, 20, 20)); + QCOMPARE(coverage(*session, 55, 55), 255); + QCOMPARE(coverage(*session, 15, 15), 0); + session->undo(); + QCOMPARE(bounds(*session), QRectF(10, 10, 20, 20)); + // Ended without a begin: nothing happens. + session->endSelectionMove(); + session->moveSelection(QSizeF(5, 5)); + QCOMPARE(bounds(*session), QRectF(10, 10, 20, 20)); +} + +void SelectionTests::movingOffCanvasAndBackKeepsTheWholeShape() +{ + const auto session = selectionSession(); + lasso(*session, square(10, 10, 20)); + QVERIFY(session->beginSelectionMove()); + session->moveSelection(QSizeF(-25, 0)); + session->endSelectionMove(); + QCOMPARE(coverage(*session, 0, 20), 255); + QVERIFY(session->beginSelectionMove()); + session->moveSelection(QSizeF(25, 0)); + session->endSelectionMove(); + QCOMPARE(bounds(*session), QRectF(10, 10, 20, 20)); +} + +void SelectionTests::arrowNudgesAndMoveIsOnlyForNewModeOnARealSelection() +{ + const auto session = selectionSession(); + // Nothing selected. + QVERIFY(!session->beginSelectionMove()); + lasso(*session, square(10, 10, 20)); + const int count = session->history.undoCount(); + session->nudgeSelection(1, 0); + session->nudgeSelection(0, -10); + QCOMPARE(bounds(*session), QRectF(11, 0, 20, 20)); + QCOMPARE(session->history.undoCount(), count + 2); + // Shift draws an added outline instead of moving. + QVERIFY(session->selectionMode(true, false) != SelectionMode::replace); + session->beginLasso(QPointF(5, 5), SelectionMode::add); + QVERIFY(!session->canMoveSelection(QPointF(20, 10))); + session->cancelLasso(); + lasso(*session, square(0, 0, 60), SelectionMode::subtract); + QVERIFY(session->selection().value().isEmpty()); + QVERIFY(!session->beginSelectionMove()); + session->nudgeSelection(1, 0); + QCOMPARE(session->history.undoCount(), count + 3); +} + +void SelectionTests::expandAndContractGrowAndShrinkTheOutline() +{ + const auto session = selectionSession(); + QVERIFY(!session->canModifySelection()); + lasso(*session, square(40, 40, 20)); + QVERIFY(session->canModifySelection()); + session->expandSelection(5); + QCOMPARE(session->history.undoName(), QString("Expand Selection")); + const QRectF grown = bounds(*session); + QVERIFY(std::abs(grown.left() - 35) < 0.01 && std::abs(grown.width() - 30) < 0.01); + QCOMPARE(coverage(*session, 37, 50), 255); + QCOMPARE(coverage(*session, 33, 50), 0); + // Rounded corners: the grown box's corner pixel is outside. + QCOMPARE(coverage(*session, 35, 35), 0); + QVERIFY(coverage(*session, 36, 36) > 100); + session->contractSelection(8); + QCOMPARE(session->history.undoName(), QString("Contract Selection")); + const QRectF shrunk = bounds(*session); + QVERIFY(std::abs(shrunk.left() - 43) < 0.01 && std::abs(shrunk.width() - 14) < 0.01); + session->undo(); + QVERIFY(std::abs(bounds(*session).width() - 30) < 0.01); + // Nothing, a wild amount, or a draft in progress: refused. + const int count = session->history.undoCount(); + session->expandSelection(0); + session->expandSelection(501); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!session->canModifySelection()); + session->contractSelection(1); + session->cancelLasso(); + QCOMPARE(session->history.undoCount(), count); +} + +void SelectionTests::expandStaysOnCanvasAndContractCanEmptyTheSelection() +{ + const auto session = selectionSession(); + session->selectAll(); + session->expandSelection(10); + QCOMPARE(bounds(*session), QRectF(0, 0, 100, 100)); + // Pulls in from the canvas edges too. + session->contractSelection(10); + QCOMPARE(coverage(*session, 5, 50), 0); + QCOMPARE(coverage(*session, 50, 50), 255); + session->contractSelection(45); + QVERIFY(session->selection().value().isEmpty()); + QVERIFY(!session->canModifySelection()); +} + +void SelectionTests::theClipCoversTheOutlinesRegionAndAnEmptyOneNothing() +{ + const auto session = selectionSession(); + lasso(*session, square(10, 10, 20)); + const SelectionClip clip = session->selection().value().clip(QSizeF(100, 100)); + // One pixel around the outline, whole pixels. + QCOMPARE(clip.rect, QRectF(9, 9, 22, 22)); + QCOMPARE(clip.coverage.value().size(), QSize(22, 22)); + QCOMPARE(int(clip.coverage.value().constScanLine(0)[0]), 0); + QCOMPARE(int(clip.coverage.value().constScanLine(1)[1]), 255); + QCOMPARE(int(clip.coverage.value().constScanLine(20)[20]), 255); + QCOMPARE(int(clip.coverage.value().constScanLine(21)[21]), 0); + // At the canvas edge the region stops with the canvas. + lasso(*session, square(-5, -5, 20)); + const SelectionClip edge = session->selection().value().clip(QSizeF(100, 100)); + QCOMPARE(edge.rect, QRectF(0, 0, 16, 16)); + QCOMPARE(int(edge.coverage.value().constScanLine(14)[14]), 255); + QCOMPARE(int(edge.coverage.value().constScanLine(15)[15]), 0); + lasso(*session, square(0, 0, 60), SelectionMode::subtract); + const SelectionClip empty = session->selection().value().clip(QSizeF(100, 100)); + QVERIFY(empty.rect.isNull() && !empty.coverage.has_value()); + // An outline without area is empty too. + session->applySelection(rectPath(QRectF(10, 10, 0, 30)), SelectionMode::replace, "Select"); + QVERIFY(session->selection().value().isEmpty()); + QVERIFY(!session->selection().value().clip(QSizeF(100, 100)).coverage.has_value()); + QVERIFY(DocumentSelection{rectPath(QRectF(10, 10, 0, 30))}.isEmpty()); + QVERIFY(!DocumentSelection{rectPath(QRectF(10, 10, 1, 30))}.isEmpty()); + // A hard-edged clip is never soft. + session->setSelectionAntialiased(false); + lasso(*session, {QPointF(0, 0), QPointF(30, 0), QPointF(0, 30)}); + const SelectionClip hard = session->selection().value().clip(QSizeF(100, 100)); + // Pixel centres on the diagonal: soft would give 128. + for (int x = 0; x < 30; ++x) { + const int value = hard.coverage.value().constScanLine(29 - x)[x]; + QVERIFY2(value == 0 || value == 255, qPrintable(QString::number(value))); + } +} + +void SelectionTests::draftsRefuseWildPointsAndOtherToolsAndKeepTheirCursor() +{ + const auto session = selectionSession(); + // The Move tool and the Wand draw no outline. + session->selectTool(NavigationTool::move); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!session->lassoDraft().has_value()); + session->selectTool(NavigationTool::wand); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!session->lassoDraft().has_value()); + session->selectTool(NavigationTool::lasso); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + session->extendLasso(QPointF(std::nan(""), 5)); + session->extendLasso(QPointF(1.1, 1.1)); + QCOMPARE(session->lassoDraft().value().points.size(), size_t(1)); + session->moveLassoCursor(QPointF(40, 40)); + QCOMPARE(session->lassoDraft().value().cursor, std::optional(QPointF(40, 40))); + session->moveLassoCursor(std::nullopt); + QVERIFY(!session->lassoDraft().value().cursor.has_value()); + // Busy, an outline can neither begin nor apply. + session->cancelLasso(); + session->setIsProjectBusy(true); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!session->lassoDraft().has_value()); + session->applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select"); + QVERIFY(!session->selection().has_value()); + session->setIsProjectBusy(false); + // While the outline moves, no draft begins. + lasso(*session, square(10, 10, 20)); + QVERIFY(session->beginSelectionMove()); + session->beginLasso(QPointF(1, 1), SelectionMode::replace); + QVERIFY(!session->lassoDraft().has_value()); + session->endSelectionMove(); + // The project closed mid-drag: the move goes nowhere. + QVERIFY(session->beginSelectionMove()); + session->clearProject(); + session->moveSelection(QSizeF(1, 1)); + session->endSelectionMove(); + QVERIFY(!session->document().has_value()); +} + +QTEST_GUILESS_MAIN(SelectionTests) +#include "SelectionTests.moc" diff --git a/tests/SessionFixtures.h b/tests/SessionFixtures.h new file mode 100644 index 0000000..d6e5580 --- /dev/null +++ b/tests/SessionFixtures.h @@ -0,0 +1,60 @@ +#pragma once +#include "Document/EditorSession.h" +#include <functional> +#include <memory> + +// Swift's tests write into `session.document`; here a snapshot goes in. +inline void rewrite(EditorSession &session, const std::function<void(ProjectSnapshot &)> &change) +{ + ProjectSnapshot snapshot = session.projectSnapshot().value(); + change(snapshot); + session.installProject(snapshot, QStringLiteral("fixture.comp")); +} + +inline ProjectLayerRecord &record(ProjectSnapshot &snapshot, QUuid id) +{ + for (ProjectLayerRecord &layer : snapshot.manifest.layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such record"); +} + +// Gives the layer this mask and its record a file. +inline void setMask(ProjectSnapshot &snapshot, QUuid id, const ImportedImage &mask) +{ + record(snapshot, id).maskFile = uuidString(id) + ".mask.png"; + snapshot.masks.insert_or_assign(id, mask); +} + +inline ImageLayer layerWith(const EditorSession &session, QUuid id) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such layer"); +} + +// Two by two, red, nearest: masks map pixel for pixel. +inline std::unique_ptr<EditorSession> redSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(2, 2); + QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session->insert(ImportedImage(image, image, "Red")); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.sampling = LayerSampling::nearest; }); + return session; +} + +// Coverage 255, 0 over 128, 255. +inline ImportedImage coverage() +{ + QImage image(2, 2, QImage::Format_Grayscale8); + const uchar values[] = {255, 0, 128, 255}; + for (int index = 0; index < 4; ++index) + image.scanLine(index / 2)[index % 2] = values[index]; + return LayerMask::assetFrom(image); +} diff --git a/tests/SessionGateTests.cpp b/tests/SessionGateTests.cpp new file mode 100644 index 0000000..c82c2a5 --- /dev/null +++ b/tests/SessionGateTests.cpp @@ -0,0 +1,177 @@ +#include "Document/EditorSession.h" +#include <QSignalSpy> +#include <QtTest> + +// Who may start a project operation, and when waiters run. +class SessionGateTests : public QObject { + Q_OBJECT +private slots: + void fileRequestsWaitForEveryGate_data(); + void fileRequestsWaitForEveryGate(); + void aWaiterLooksAgainWhenItsTurnComes(); + void aNewCanvasFreesWaitingFileRequests(); + void waitersEndWithTheirSession(); + void theBusyIndicatorWaitsAQuarterSecond(); +}; + +void SessionGateTests::fileRequestsWaitForEveryGate_data() +{ + QTest::addColumn<int>("gate"); + QTest::newRow("a busy project") << 0; + QTest::newRow("an import under way") << 1; + QTest::newRow("the new canvas sheet") << 2; + QTest::newRow("the importer sheet") << 3; + QTest::newRow("a layer being renamed") << 4; + QTest::newRow("an import error on show") << 5; + QTest::newRow("an adjustment being edited") << 6; +} + +void SessionGateTests::fileRequestsWaitForEveryGate() +{ + QFETCH(int, gate); + EditorSession session; + const auto set = [&](bool closed) { + switch (gate) { + case 0: session.setIsProjectBusy(closed); break; + case 1: session.setIsImporting(closed); break; + case 2: session.setShowsNewDocument(closed); break; + case 3: session.setShowsImporter(closed); break; + case 4: session.setRenamingLayerID(closed ? std::optional(QUuid::createUuid()) : std::nullopt); break; + case 5: session.setImportError(closed ? std::optional(QString("failed")) : std::nullopt); break; + default: session.setAdjustmentEditingID(closed ? std::optional(QUuid::createUuid()) : std::nullopt); break; + } + }; + int ran = 0; + session.waitForFileRequest([&] { ++ran; }); + QCOMPARE(ran, 1); + QVERIFY(session.canStartProjectOperation()); + set(true); + QVERIFY(!session.canStartProjectOperation()); + session.waitForFileRequest([&] { ++ran; }); + QTest::qWait(20); + QCOMPARE(ran, 1); + // Another gate opening and closing frees nobody. + session.setShowsImporter(gate != 3); + session.setShowsImporter(gate == 3); + QTest::qWait(20); + QCOMPARE(ran, 1); + set(false); + QVERIFY(QTest::qWaitFor([&] { return ran == 2; }, 5'000)); +} + +void SessionGateTests::aWaiterLooksAgainWhenItsTurnComes() +{ + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + session.setIsProjectBusy(true); + QStringList ran; + qsizetype heard = 0; + // The first to run takes the project for itself. + session.waitForProjectAccess([&] { + ran << "first"; + heard = changes.count(); + session.setIsProjectBusy(true); + }); + session.waitForProjectAccess([&] { ran << "second"; }); + session.waitForFileRequest([&] { ran << "file"; }); + const qsizetype before = changes.count(); + session.setIsProjectBusy(false); + // Waiters run once the change is made and announced. + QVERIFY(ran.isEmpty()); + QTest::qWait(50); + QCOMPARE(ran, QStringList{"first"}); + QCOMPARE(heard, before + 1); + session.setIsProjectBusy(false); + QVERIFY(QTest::qWaitFor([&] { return ran.size() == 3; }, 5'000)); + QCOMPARE(ran, (QStringList{"first", "second", "file"})); + // Busy said again frees nobody and keeps the line. + ran.clear(); + session.setIsProjectBusy(true); + session.waitForProjectAccess([&] { ran << "a"; }); + session.setIsProjectBusy(true); + session.waitForProjectAccess([&] { ran << "b"; }); + QTest::qWait(50); + QVERIFY(ran.isEmpty()); + session.setIsProjectBusy(false); + QVERIFY(QTest::qWaitFor([&] { return ran.size() == 2; }, 5'000)); + QCOMPARE(ran, (QStringList{"a", "b"})); +} + +void SessionGateTests::aNewCanvasFreesWaitingFileRequests() +{ + // The sheet closes, or a rename ends, inside createDocument. + for (const bool renaming : {false, true}) { + EditorSession session; + if (renaming) + session.setRenamingLayerID(QUuid::createUuid()); + else + session.setShowsNewDocument(true); + bool ran = false; + session.waitForFileRequest([&] { ran = true; }); + QTest::qWait(20); + QVERIFY(!ran); + session.createDocument(16, 16); + QVERIFY(QTest::qWaitFor([&] { return ran; }, 5'000)); + } +} + +void SessionGateTests::waitersEndWithTheirSession() +{ + QStringList ran; + { + EditorSession session; + session.setIsProjectBusy(true); + session.waitForProjectAccess([&] { ran << "project"; }); + session.waitForFileRequest([&] { ran << "file"; }); + // Both are posted, and the session ends before they run. + session.setIsProjectBusy(false); + } + QTest::qWait(50); + QCOMPARE(ran, QStringList()); +} + +void SessionGateTests::theBusyIndicatorWaitsAQuarterSecond() +{ + QCOMPARE(EditorSession::busyIndicatorDelay, 250); + EditorSession session; + QSignalSpy changes(&session, &EditorSession::changed); + QElapsedTimer clock; + clock.start(); + session.setIsProjectBusy(true); + QVERIFY(!session.showsBusy()); + // A quick operation never dims the controls. + QTest::qWait(60); + QVERIFY(!session.showsBusy() || clock.elapsed() >= 250); + session.setIsProjectBusy(false); + QTest::qWait(400); + QVERIFY(!session.showsBusy()); + // A long one does, by itself and never early. + qint64 shownAfter = -1; + connect(&session, &EditorSession::changed, this, [&] { + if (session.showsBusy() && shownAfter < 0) + shownAfter = clock.nsecsElapsed(); + }); + clock.restart(); + session.setIsProjectBusy(true); + const qsizetype before = changes.count(); + // Busy said again on the way starts no new wait. + QTest::qSleep(100); + const QTimer *timer = session.findChild<QTimer *>(); + QVERIFY(timer != nullptr && timer->isActive()); + QCOMPARE(timer->timerType(), Qt::PreciseTimer); + session.setIsProjectBusy(true); + QVERIFY2(timer->remainingTime() <= 150, qPrintable(QString::number(timer->remainingTime()))); + QVERIFY(QTest::qWaitFor([&] { return session.showsBusy(); }, 10'000)); + QVERIFY2(shownAfter >= 250'000'000, qPrintable(QString::number(shownAfter))); + QCOMPARE(changes.count(), before + 2); + // Busy again keeps it on and starts no second wait. + session.setIsProjectBusy(true); + QVERIFY(session.showsBusy()); + QTest::qWait(400); + QCOMPARE(changes.count(), before + 3); + session.setIsProjectBusy(false); + QVERIFY(!session.showsBusy()); +} + +QTEST_GUILESS_MAIN(SessionGateTests) +#include "SessionGateTests.moc" diff --git a/tests/SessionRecord.h b/tests/SessionRecord.h new file mode 100644 index 0000000..ec97dfc --- /dev/null +++ b/tests/SessionRecord.h @@ -0,0 +1,112 @@ +#pragma once +#include "Document/EditorSession.h" +#include "IO/ProjectStore+Json.h" +#include <QJsonDocument> +#include <QStringList> + +// All an observer can read, as text. +inline QStringList described(const EditorSession &session) +{ + const auto text = [](const std::optional<QUuid> &id) { return id.has_value() ? id.value().toString() : QStringLiteral("none"); }; + const auto sorted = [](const QSet<QUuid> &ids) { + QStringList all; + for (const QUuid &id : ids) + all << id.toString(); + all.sort(); + return all.join(' '); + }; + QStringList result{ + "active " + text(session.activeLayerID()), + "selected " + sorted(session.selectedLayerIDs()), + "collapsed " + sorted(session.collapsedGroupIDs()), + "tool " + QString::number(int(session.tool())), + QStringLiteral("flags %1%2%3%4").arg(int(session.isProjectBusy())).arg(int(session.showsNewDocument())) + .arg(int(session.showsImporter())).arg(int(session.isImporting())), + QStringLiteral("dimmed %1 startable %2").arg(int(session.showsBusy())).arg(int(session.canStartProjectOperation())), + "error " + session.importError().value_or(QStringLiteral("none")), + "tool error " + session.brushError().value_or(QStringLiteral("none")), + "renaming " + text(session.renamingLayerID()), + "adjusting " + text(session.adjustmentEditingID()) + (session.adjustmentOriginal().has_value() ? " open" : ""), + "effects " + (session.effectsEditing() ? text(session.effectsEditing()->layerID) + " " + rawValue(session.effectsEditing()->kind) : QStringLiteral("closed")) + + (session.effectsEditingOriginal() ? " " + QJsonDocument(ManifestJson::encoded(*session.effectsEditingOriginal())).toJson(QJsonDocument::Compact) : QString()) + + " chosen " + (session.effectSelection() ? text(session.effectSelection()->layerID) + " " + rawValue(session.effectSelection()->kind) : QStringLiteral("none")), + "picker " + (session.colorPicker() ? session.colorPicker()->target.title() + " " + session.colorPicker()->color().hex() : QStringLiteral("closed")), + QStringLiteral("mask target %1").arg(int(session.isMaskSelected())), + QStringLiteral("history %1%2").arg(int(session.canUndo())).arg(int(session.canRedo())), + QStringLiteral("names %1/%2").arg(session.history.undoName(), session.history.redoName()), + QStringLiteral("undos %1 modified %2").arg(session.history.undoCount()).arg(int(session.isModified())), + QStringLiteral("view %1 %2").arg(session.viewport.zoom()).arg(int(session.viewport.followsFit())), + QStringLiteral("grid %1 focus %2").arg(int(session.showsPixelGrid())).arg(session.canvasFocusRequest()), + }; + const auto placed = [](const LayerTransform &transform) { + return QStringLiteral("%1,%2 %3x%4").arg(transform.origin.x()).arg(transform.origin.y()).arg(transform.size.width()).arg(transform.size.height()); + }; + if (session.blendPreview().has_value()) + result << "preview " + session.blendPreview().value().layerID.toString() + " " + rawValue(session.blendPreview().value().mode); + if (session.transformEdit().has_value()) { + const TransformEdit &edit = session.transformEdit().value(); + result << "edit " + edit.layerID.toString() + " " + placed(edit.draft) + (edit.group.has_value() ? " group" : "") + (edit.mask ? " mask" : ""); + } + const BrushSettings &tip = session.brushSettings(); + result << QStringLiteral("brush %1 %2 %3 rgb %4 %5 %6 erase %7 mode %8 white %9 stroke %10 revision %11").arg(tip.diameter).arg(tip.hardness).arg(tip.opacity) + .arg(tip.red).arg(tip.green).arg(tip.blue).arg(int(tip.erasing)).arg(rawValue(session.brushMode())).arg(int(session.maskPaintWhite())) + .arg(session.brushStroke() ? int(session.brushStroke()->patches().size()) : -1).arg(session.brushRevision()); + if (session.filterEdit().has_value()) { + const FilterEdit &edit = session.filterEdit().value(); + result << QStringLiteral("filter %1 %2 preview %3 preparing %4 committing %5 error %6 grown %7").arg(rawValue(edit.kind), edit.layerID.toString()) + .arg(int(edit.preview)).arg(int(edit.preparing)).arg(int(edit.committing)).arg(edit.previewError.value_or(QStringLiteral("none"))) + .arg(edit.grownTransform.has_value() ? placed(edit.grownTransform.value()) : QStringLiteral("none")); + } + if (session.levels().has_value()) + result << QStringLiteral("levels %1 preview %2 ready %3").arg(session.levels().value().id.toString()).arg(int(session.levels().value().preview)) + .arg(int(session.levels().value().histogramReady)); + if (session.hueSaturation().has_value()) + result << QStringLiteral("hue %1 preview %2").arg(session.hueSaturation().value().id.toString()).arg(int(session.hueSaturation().value().preview)); + const auto styled = [](const LayerTextStyle &style) { + return QStringLiteral("\"%1\" %2 %3 rgb %4 %5 %6 %7 %8 %9").arg(style.content, style.fontName).arg(style.fontSize).arg(style.red) + .arg(style.green).arg(style.blue).arg(rawValue(style.alignment)).arg(style.tracking).arg(style.leading) + + (style.boxSize.has_value() ? QStringLiteral(" box %1x%2").arg(style.boxSize.value().width()).arg(style.boxSize.value().height()) : QString()); + }; + result << "text defaults " + styled(session.textDefaults()); + if (session.textDraft().has_value()) { + const TextDraft &draft = session.textDraft().value(); + result << QStringLiteral("text draft %1 for %2 at %3,%4 ").arg(draft.id.toString(), text(draft.layerID)).arg(draft.origin.x()).arg(draft.origin.y()) + + styled(draft.style) + (draft.transform.has_value() ? " placed " + placed(draft.transform.value()) : QString()); + } + result << "path " + session.projectPath().value_or(QStringLiteral("none")); + result << QStringLiteral("selection tools %1 %2 mode %3 held %4 soft %5 grow %6 %7").arg(int(session.lassoKind())).arg(int(session.marqueeKind())) + .arg(int(session.selectionModeChoice())).arg(session.heldSelectionMode().has_value() ? int(session.heldSelectionMode().value()) : -1) + .arg(int(session.selectionAntialiased())).arg(session.selectionExpandAmount()).arg(session.selectionContractAmount()); + result << QStringLiteral("wand %1 %2 %3 %4").arg(session.wandSettings().tolerance).arg(int(session.wandSettings().sampleSize)) + .arg(int(session.wandSettings().contiguous)).arg(int(session.wandSettings().sampleAllLayers)); + if (session.lassoDraft().has_value()) { + const LassoDraft &draft = session.lassoDraft().value(); + result << QStringLiteral("lasso %1 %2 points %3 cursor %4").arg(int(draft.kind)).arg(int(draft.mode)).arg(draft.points.size()) + .arg(draft.cursor.has_value() ? QStringLiteral("%1,%2").arg(draft.cursor.value().x()).arg(draft.cursor.value().y()) : QStringLiteral("none")); + } + if (session.document().has_value()) { + const CanvasDocument &canvas = session.document().value(); + result << QStringLiteral("canvas %1 %2x%3 at %4").arg(canvas.id.toString()).arg(canvas.width).arg(canvas.height).arg(canvas.resolution); + if (canvas.selection.has_value()) { + const QRectF bounds = canvas.selection.value().path.boundingRect(); + result << QStringLiteral("outline %1,%2 %3x%4 soft %5").arg(bounds.x()).arg(bounds.y()).arg(bounds.width()).arg(bounds.height()) + .arg(int(canvas.selection.value().antialiased)); + } + for (const ImageLayer &layer : session.document().value().layers) { + result << QStringLiteral("%1 %2 %3 %4 ").arg(layer.id.toString(), layer.name, text(layer.parentID)).arg(int(layer.isVisible)) + + placed(layer.transform) + QStringLiteral(" %1 ").arg(layer.opacity) + rawValue(layer.blendMode) + + " clipped to " + text(layer.maskSourceID) + + QStringLiteral(" pixels %1").arg(layer.asset.has_value() ? layer.asset.value().identity().cacheKey : 0) + + (layer.text.has_value() ? " text " + styled(layer.text.value().style) + (layer.liveText().has_value() ? " live" : "") : QString()) + + (layer.adjustment.has_value() ? " adjusts " + QJsonDocument(ManifestJson::encoded(layer.adjustment.value())).toJson(QJsonDocument::Compact) + : QString()) + + (layer.effects.has_value() ? " effects " + QJsonDocument(ManifestJson::encoded(layer.effects.value())).toJson(QJsonDocument::Compact) : QString()); + if (layer.mask.has_value()) { + const LayerMask &mask = layer.mask.value(); + result << QStringLiteral(" mask %1 on %2 linked %3 ").arg(mask.asset.identity().cacheKey).arg(int(mask.isEnabled)).arg(int(mask.isLinked)) + + (mask.placement.has_value() ? placed(mask.placement.value()) : QStringLiteral("over its layer")); + } + } + } + return result; +} diff --git a/tests/ShapeCanvasTests.cpp b/tests/ShapeCanvasTests.cpp new file mode 100644 index 0000000..bc00025 --- /dev/null +++ b/tests/ShapeCanvasTests.cpp @@ -0,0 +1,485 @@ +#include "BrushCanvasFixtures.h" +#include "UI/LassoControls.h" +#include "UI/ShapeControls.h" +#include <QSlider> +#include <QToolButton> + +// The Shape tool on the canvas: draft, keys, drawing, bar. +namespace { +const QColor redShown(255, 0, 0); + +// A blank active layer, the Shape tool, red paint. +QUuid shapes(Canvas &shown) +{ + shown.session.addBlankLayer(); + shown.session.selectTool(NavigationTool::shape); + shown.session.setForegroundColor(PaletteColor{1, 0, 0}); + shown.canvas->synchronizeDisplay(); + return shown.session.activeLayerID().value(); +} + +QColor shownAt(Canvas &shown, int x, int y) +{ + return shown.canvas->grab().toImage().pixelColor(x, y); +} + +template <typename Widget> Widget &find(QWidget &parent, const char *name) +{ + auto *found = parent.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} +} + +class ShapeCanvasTests : public QObject { + Q_OBJECT +private slots: + void aDragShowsTheDraftThenMakesALayer(); + void theDraftSitsJustAboveTheActiveLayer(); + void shiftSquaresAndAltGrowsFromTheCentre(); + void escapeAndLostFocusCancel(); + void shiftUStepsTheKinds(); + void aLineDraftHasItsWidthAndRoundCaps(); + void aDraftRepaintsTheView(); + void aDraftOutlivingItsProjectMakesNothing(); + void aPixelWideDraftIsItsOutline(); + void aDraftFollowsItsLayerInTheStack(); + void aDraftFollowsANewTarget(); + void aRoundedDraftScalesItsCorners(); + void aFolderMaskClipsTheDraft(); + void theMoveToolShowsRoundCornersRedrawn(); + void theBarSetsTheShape(); +}; + +void ShapeCanvasTests::aDragShowsTheDraftThenMakesALayer() +{ + Canvas shown; + shapes(shown); + shown.press(QPointF(50, 50)); + shown.move(QPointF(150, 120)); + QCOMPARE(shown.session.shapeDraft().value().rect, QRectF(50, 50, 100, 70)); + QCOMPARE(shownAt(shown, 100, 80), redShown); + QVERIFY(shownAt(shown, 40, 40) != redShown && shownAt(shown, 151, 80) != redShown); + QCOMPARE(shown.session.document().value().layers.size(), size_t(1)); + shown.release(QPointF(150, 120)); + QVERIFY(!shown.session.shapeDraft()); + const ImageLayer made = shown.session.activeLayer().value(); + QCOMPARE(made.name, QString("Rectangle 1")); + QCOMPARE(made.transform.origin, QPointF(50, 50)); + QCOMPARE(made.transform.size, QSizeF(100, 70)); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shownAt(shown, 100, 80), redShown); + // Space pans instead of drawing. + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyPress(shown.canvas, Qt::Key_Space); + shown.press(QPointF(200, 200)); + QVERIFY(!shown.session.shapeDraft()); + shown.release(QPointF(200, 200)); + QTest::keyRelease(shown.canvas, Qt::Key_Space); +} + +void ShapeCanvasTests::theDraftSitsJustAboveTheActiveLayer() +{ + Canvas shown; + // Green below and active; blue over the right half. + shown.session.insert(filled(400, 300, qRgba(0, 255, 0, 255), QStringLiteral("Green")), QPointF(200, 150)); + const QUuid below = shown.session.activeLayerID().value(); + shown.session.insert(filled(200, 300, qRgba(0, 0, 255, 255), QStringLiteral("Blue")), QPointF(300, 150)); + shown.session.selectLayer(below); + shown.session.selectTool(NavigationTool::shape); + shown.session.setForegroundColor(PaletteColor{1, 0, 0}); + shown.press(QPointF(100, 100)); + shown.move(QPointF(300, 200)); + QCOMPARE(shownAt(shown, 150, 150), redShown); + QCOMPARE(shownAt(shown, 250, 150), QColor(0, 0, 255)); + // A hidden active layer shows no draft. + shown.session.toggleLayerVisibility(below); + shown.move(QPointF(301, 200)); + QVERIFY(shownAt(shown, 150, 150) != redShown && shownAt(shown, 150, 150) != QColor(0, 255, 0)); + shown.release(QPointF(301, 200)); +} + +void ShapeCanvasTests::shiftSquaresAndAltGrowsFromTheCentre() +{ + Canvas shown; + shapes(shown); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 120), Qt::ShiftModifier); + QCOMPARE(shown.session.shapeDraft().value().rect, QRectF(100, 100, 50, 50)); + shown.move(QPointF(150, 120), Qt::AltModifier); + QCOMPARE(shown.session.shapeDraft().value().rect, QRectF(50, 80, 100, 40)); + shown.move(QPointF(150, 120), Qt::ShiftModifier | Qt::AltModifier); + QCOMPARE(shown.session.shapeDraft().value().rect, QRectF(50, 50, 100, 100)); + shown.release(QPointF(150, 120), Qt::ShiftModifier | Qt::AltModifier); + QCOMPARE(shown.session.activeLayer().value().transform.size, QSizeF(100, 100)); + QCOMPARE(shown.session.activeLayer().value().transform.origin, QPointF(50, 50)); +} + +void ShapeCanvasTests::escapeAndLostFocusCancel() +{ + Canvas shown; + shapes(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.shapeDraft()); + QVERIFY(shownAt(shown, 120, 120) != redShown); + shown.move(QPointF(160, 160)); + shown.release(QPointF(160, 160)); + QCOMPARE(shown.session.document().value().layers.size(), size_t(1)); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 150)); + QWidget other(&shown.window); + other.setFocusPolicy(Qt::StrongFocus); + other.show(); + other.setFocus(); + QTRY_VERIFY(!shown.canvas->hasFocus()); + QVERIFY(!shown.session.shapeDraft()); + shown.release(QPointF(150, 150)); + QCOMPARE(shown.session.document().value().layers.size(), size_t(1)); +} + +void ShapeCanvasTests::shiftUStepsTheKinds() +{ + Canvas shown; + shapes(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + QTest::keyClick(shown.canvas, Qt::Key_U, Qt::ShiftModifier); + QCOMPARE(shown.session.shapeKind(), ShapeKind::ellipse); + QTest::keyClick(shown.canvas, Qt::Key_U, Qt::ShiftModifier); + QCOMPARE(shown.session.shapeKind(), ShapeKind::line); + QTest::keyClick(shown.canvas, Qt::Key_U); + QCOMPARE(shown.session.shapeKind(), ShapeKind::line); + // From another tool, Shift-U only chooses the Shape. + shown.session.selectTool(NavigationTool::brush); + QTest::keyClick(shown.canvas, Qt::Key_U, Qt::ShiftModifier); + QCOMPARE(shown.session.tool(), NavigationTool::shape); + QCOMPARE(shown.session.shapeKind(), ShapeKind::line); + shown.session.selectTool(NavigationTool::brush); + QTest::keyClick(shown.canvas, Qt::Key_U); + QCOMPARE(shown.session.tool(), NavigationTool::shape); +} + +void ShapeCanvasTests::aLineDraftHasItsWidthAndRoundCaps() +{ + Canvas shown; + shapes(shown); + shown.session.setShapeKind(ShapeKind::line); + shown.session.setShapeLineWidth(10); + shown.press(QPointF(100, 150)); + shown.move(QPointF(300, 150)); + QCOMPARE(shownAt(shown, 200, 154), redShown); + QVERIFY(shownAt(shown, 200, 157) != redShown); + // The round cap reaches past the start. + QCOMPARE(shownAt(shown, 96, 150), redShown); + QVERIFY(shownAt(shown, 95, 145) != redShown); + // Twice the zoom, twice the width on screen. + shown.session.zoom(2, QPointF(100, 150)); + shown.canvas->synchronizeDisplay(); + const QPointF middle = shown.session.viewport.viewPoint(QPointF(150, 150), shown.documentSize()); + QCOMPARE(shownAt(shown, int(middle.x()), int(middle.y()) + 8), redShown); + QVERIFY(shownAt(shown, int(middle.x()), int(middle.y()) + 12) != redShown); + // Half the zoom, half the width on screen. + shown.session.zoom(0.5); + shown.canvas->synchronizeDisplay(); + const QPointF half = shown.session.viewport.viewPoint(QPointF(150, 150), shown.documentSize()); + QCOMPARE(shownAt(shown, int(half.x()), int(half.y()) + 1), redShown); + QVERIFY(shownAt(shown, int(half.x()), int(half.y()) + 4) != redShown); + // A quarter the zoom: still a whole point wide. + shown.session.setShapeLineWidth(1); + shown.session.zoom(0.25); + shown.canvas->synchronizeDisplay(); + const QPointF centre = shown.session.viewport.viewPoint(QPointF(200, 150), shown.documentSize()); + QCOMPARE(centre.y(), 150.0); + QVERIFY2(shownAt(shown, int(centre.x()), 150).red() > 140, qPrintable(shownAt(shown, int(centre.x()), 150).name())); + shown.release(QPointF(300, 150)); +} + +void ShapeCanvasTests::aRoundedDraftScalesItsCorners() +{ + Canvas shown; + shapes(shown); + shown.session.setShapeCornerRadius(20); + shown.session.zoom(0.5); + const auto view = [&](double x, double y) { return shown.session.viewport.viewPoint(QPointF(x, y), shown.documentSize()); }; + shown.press(view(60, 60)); + shown.move(view(150, 120)); + // 20 pixels are 10 points: four points in fills. + const QPoint corner = view(60, 60).toPoint(); + QCOMPARE(shownAt(shown, corner.x() + 4, corner.y() + 4), redShown); + QVERIFY(shownAt(shown, corner.x(), corner.y()) != redShown); + // The box keeps its height at this zoom. + QVERIFY(shownAt(shown, view(100, 125).toPoint().x(), view(100, 125).toPoint().y()) != redShown); + shown.release(view(150, 120)); +} + +void ShapeCanvasTests::aDraftRepaintsTheView() +{ + Canvas shown; + shapes(shown); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 150)); + QApplication::processEvents(); + PaintSpy spy(*shown.canvas); + shown.move(QPointF(160, 170)); + QTRY_VERIFY(spy.count > 0); + // As Swift's observation redraws it: the whole view. + QCOMPARE(spy.painted, shown.canvas->rect()); + // Nothing new, nothing repainted. + QApplication::processEvents(); + spy.count = 0; + shown.canvas->synchronizeDisplay(); + QApplication::processEvents(); + QCOMPARE(spy.count, 0); + // A new width or colour repaints the draft too. + shown.session.setShapeLineWidth(30); + shown.canvas->synchronizeDisplay(); + QTRY_VERIFY(spy.count > 0); + QApplication::processEvents(); + spy.count = 0; + shown.session.setForegroundColor(PaletteColor{0, 0, 1}); + shown.canvas->synchronizeDisplay(); + QTRY_VERIFY(spy.count > 0); + QCOMPARE(shownAt(shown, 130, 130), QColor(0, 0, 255)); + shown.release(QPointF(160, 170)); + // An ellipse's rim is smooth: part paint, part ground. + shown.session.setShapeKind(ShapeKind::ellipse); + shown.press(QPointF(250, 50)); + shown.move(QPointF(290, 90)); + const QImage view = shown.canvas->grab().toImage(); + int partial = 0; + for (int x = 245; x < 260; ++x) + partial += view.pixelColor(x, 60).green() > 5 && view.pixelColor(x, 60).green() < 70; + QVERIFY(partial > 0); + shown.release(QPointF(290, 90)); +} + +void ShapeCanvasTests::aDraftOutlivingItsProjectMakesNothing() +{ + Canvas shown; + shapes(shown); + shown.press(QPointF(100, 100)); + shown.move(QPointF(150, 150)); + shown.session.clearProject(); + shown.move(QPointF(160, 160)); + QCOMPARE(shown.session.shapeDraft().value().rect, QRectF(100, 100, 50, 50)); + shown.release(QPointF(160, 160)); + QVERIFY(!shown.session.shapeDraft() && !shown.session.document()); +} + +void ShapeCanvasTests::aPixelWideDraftIsItsOutline() +{ + Canvas shown; + shapes(shown); + shown.session.setShapeKind(ShapeKind::line); + shown.session.setShapeLineWidth(1); + shown.press(QPointF(100, 100)); + shown.move(QPointF(300, 200)); + const QImage one = shown.canvas->grab().toImage(); + shown.session.setShapeLineWidth(1.001); + shown.canvas->synchronizeDisplay(); + const QImage wider = shown.canvas->grab().toImage(); + // Qt thins a one-pixel pen; an outline does not. + int apart = 0; + for (int y = 95; y < 205; ++y) { + for (int x = 95; x < 305; ++x) + apart = std::max(apart, std::abs(one.pixelColor(x, y).red() - wider.pixelColor(x, y).red())); + } + QVERIFY2(apart <= 3, qPrintable(QString::number(apart))); + shown.release(QPointF(300, 200)); +} + +void ShapeCanvasTests::aDraftFollowsItsLayerInTheStack() +{ + Canvas shown; + const QUuid blank = shapes(shown); + shown.session.addBlankLayer(); + const QUuid other = shown.session.activeLayerID().value(); + shown.session.insert(filled(400, 300, qRgba(0, 0, 255, 255), QStringLiteral("Blue")), QPointF(200, 150)); + shown.session.selectLayer(blank); + shown.session.selectTool(NavigationTool::shape); + shown.canvas->synchronizeDisplay(); + shown.press(QPointF(100, 100)); + shown.move(QPointF(300, 200)); + QCOMPARE(shownAt(shown, 200, 150), QColor(0, 0, 255)); + // Another blank layer draws nothing: hiding it changes nothing. + shown.session.toggleLayerVisibility(other); + QVERIFY(!shown.canvas->synchronizeDisplay()); + // Past the hidden blank layer, nothing shows otherwise. + shown.session.moveActiveLayer(1); + QVERIFY(!shown.canvas->synchronizeDisplay()); + // Raised over the blue mid-drag, as Ctrl+] does: redrawn. + shown.session.moveActiveLayer(1); + QVERIFY(shown.canvas->synchronizeDisplay()); + QCOMPARE(shownAt(shown, 200, 150), redShown); + shown.session.toggleLayerVisibility(blank); + QVERIFY(shown.canvas->synchronizeDisplay()); + shown.release(QPointF(300, 200)); + // Without a draft, a blank layer moves nothing on screen. + shown.session.toggleLayerVisibility(blank); + shown.canvas->synchronizeDisplay(); + shown.session.selectLayer(blank); + shown.session.moveActiveLayer(-1); + QVERIFY(!shown.canvas->synchronizeDisplay()); +} + +void ShapeCanvasTests::aDraftFollowsANewTarget() +{ + Canvas shown; + shown.session.insert(filled(400, 300, qRgba(0, 255, 0, 255), QStringLiteral("Green")), QPointF(200, 150)); + const QUuid below = shown.session.activeLayerID().value(); + shown.session.insert(filled(400, 300, qRgba(0, 0, 255, 255), QStringLiteral("Blue")), QPointF(200, 150)); + const QUuid above = shown.session.activeLayerID().value(); + shown.session.selectLayer(below); + shown.session.renameLayer(below, QStringLiteral("Below")); + shown.session.selectLayer(above); + shown.session.selectTool(NavigationTool::shape); + shown.session.setForegroundColor(PaletteColor{1, 0, 0}); + shown.canvas->synchronizeDisplay(); + shown.press(QPointF(100, 100)); + shown.move(QPointF(300, 200)); + QCOMPARE(shownAt(shown, 200, 150), redShown); + QApplication::processEvents(); + PaintSpy spy(*shown.canvas); + // Undo mid-drag makes the lower layer active: the draft sinks. + shown.session.undo(); + QCOMPARE(shown.session.activeLayerID(), std::optional(below)); + shown.canvas->synchronizeDisplay(); + QTRY_VERIFY(spy.count > 0); + QCOMPARE(shownAt(shown, 200, 150), QColor(0, 0, 255)); + shown.release(QPointF(300, 200)); +} + +void ShapeCanvasTests::aFolderMaskClipsTheDraft() +{ + Canvas shown; + const QUuid child = shapes(shown); + shown.session.groupSelectedLayers(); + const QUuid folder = shown.session.activeLayerID().value(); + QImage mask(400, 300, QImage::Format_Grayscale8); + mask.fill(255); + for (int y = 0; y < 300; ++y) + std::fill_n(mask.scanLine(y), 200, uchar(0)); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(mask)); }); + shown.session.zoom(1); + shown.session.selectLayer(child); + shown.session.selectTool(NavigationTool::shape); + shown.canvas->synchronizeDisplay(); + shown.press(QPointF(100, 100)); + shown.move(QPointF(300, 200)); + QVERIFY(shownAt(shown, 150, 150) != redShown); + QCOMPARE(shownAt(shown, 250, 150), redShown); + shown.release(QPointF(300, 200)); +} + +void ShapeCanvasTests::theMoveToolShowsRoundCornersRedrawn() +{ + Canvas shown; + shapes(shown); + shown.session.setShapeCornerRadius(20); + shown.press(QPointF(100, 100)); + shown.move(QPointF(200, 160)); + shown.release(QPointF(200, 160)); + shown.session.selectTool(NavigationTool::move); + shown.session.beginTransform(); + LayerTransform draft = shown.session.transformEdit().value().draft; + draft.size = QSizeF(200, 120); + shown.session.previewTransform(draft); + shown.canvas->synchronizeDisplay(); + // Radius 20 covers (106, 106); a stretched 40 would not. + QCOMPARE(shownAt(shown, 106, 106), redShown); + QVERIFY(shownAt(shown, 101, 101) != redShown); + QCOMPARE(shownAt(shown, 250, 200), redShown); + shown.session.cancelTransform(); +} + +void ShapeCanvasTests::theBarSetsTheShape() +{ + EditorSession session; + session.createDocument(40, 20, true); + session.selectTool(NavigationTool::shape); + ShapeControls bar(session); + bar.show(); + QCOMPARE(bar.title->text(), QString("Shape")); + auto &rectangle = find<QToolButton>(bar, "shapeRectangle"), &ellipse = find<QToolButton>(bar, "shapeEllipse"), + &line = find<QToolButton>(bar, "shapeLine"); + QCOMPARE(line.text(), QString("Line")); + QCOMPARE(ellipse.toolTip(), QString("Shift-U (or Tab) steps through Rectangle, Ellipse and Line")); + QVERIFY(rectangle.isChecked()); + QWidget &width = find<QWidget>(bar, "shapeWidthGroup"), &radius = find<QWidget>(bar, "shapeRadiusGroup"); + QVERIFY(!width.isVisible() && radius.isVisible()); + QCOMPARE(radius.toolTip(), QString("Round the rectangle's corners by this many pixels; 0 keeps them square")); + // Choosing a kind drops the draft first. + session.beginShape(QPointF(1, 1)); + line.click(); + QVERIFY(!session.shapeDraft()); + QCOMPARE(session.shapeKind(), ShapeKind::line); + QVERIFY(line.isChecked() && !rectangle.isChecked()); + QVERIFY(width.isVisible() && !radius.isVisible()); + ellipse.click(); + QCOMPARE(session.shapeKind(), ShapeKind::ellipse); + QVERIFY(!width.isVisible() && !radius.isVisible()); + rectangle.click(); + QCOMPARE(session.shapeKind(), ShapeKind::rectangle); + // A line's width: slider to 100, field to 5000. + session.setShapeKind(ShapeKind::line); + auto &widthSlider = find<QSlider>(bar, "shapeWidthSlider"); + auto &widthField = find<SelectionAmountField>(bar, "shapeWidth"); + QVERIFY(widthSlider.minimum() == 1 && widthSlider.maximum() == 100 && widthSlider.width() == 100 && widthField.width() == 48); + widthSlider.setValue(37); + QCOMPARE(session.shapeLineWidth(), 37.0); + QCOMPARE(widthField.text(), QString("37")); + widthField.setFocus(); + widthField.selectAll(); + QTest::keyClicks(&widthField, "0"); + QCOMPARE(session.shapeLineWidth(), 1.0); + widthField.selectAll(); + QTest::keyClicks(&widthField, "9999"); + QTest::keyClick(&widthField, Qt::Key_Return); + QCOMPARE(session.shapeLineWidth(), 5000.0); + QCOMPARE(widthSlider.value(), 100); + QCOMPARE(widthField.text(), QString("5000")); + session.setShapeLineWidth(4.6); + QVERIFY(widthSlider.value() == 5 && widthField.text() == QString("5")); + QCOMPARE(session.shapeLineWidth(), 4.6); + // A rectangle's radius: slider to 200, field to 5000. + session.setShapeKind(ShapeKind::rectangle); + auto &radiusSlider = find<QSlider>(bar, "shapeRadiusSlider"); + auto &radiusField = find<SelectionAmountField>(bar, "shapeRadius"); + QVERIFY(radiusSlider.minimum() == 0 && radiusSlider.maximum() == 200 && radiusSlider.width() == 100 && radiusField.width() == 48); + radiusSlider.setValue(150); + QCOMPARE(session.shapeCornerRadius(), 150.0); + radiusField.setFocus(); + radiusField.selectAll(); + QTest::keyClicks(&radiusField, "9999"); + QTest::keyClick(&radiusField, Qt::Key_Return); + QCOMPARE(session.shapeCornerRadius(), 5000.0); + QCOMPARE(session.shapeLineWidth(), 4.6); + session.setShapeCornerRadius(700); + QVERIFY(radiusSlider.value() == 200 && radiusField.text() == QString("700")); + QCOMPARE(session.shapeCornerRadius(), 700.0); + QStringList labels; + for (const QLabel *label : bar.findChildren<QLabel *>()) + labels << label->text(); + labels.sort(); + QCOMPARE(labels, (QStringList{"Fill", "Radius", "Shape", "Width", "px", "px"})); + // The row reads left to right, spaced as Swift's. + QCOMPARE(radius.layout()->spacing(), 6); + bar.resize(bar.sizeHint().width() + 300, bar.height()); + QTRY_VERIFY(radius.geometry().right() < bar.width() / 2 + 100 && rectangle.x() < line.x()); + session.setIsProjectBusy(true); + QTRY_VERIFY(!bar.isEnabled()); + session.setIsProjectBusy(false); + QVERIFY(bar.isEnabled()); + EditorSession empty; + const ShapeControls idle(empty); + QVERIFY(!idle.isEnabled()); +} + +QTEST_MAIN(ShapeCanvasTests) +#include "ShapeCanvasTests.moc" diff --git a/tests/ShapeLayerTests.cpp b/tests/ShapeLayerTests.cpp new file mode 100644 index 0000000..74da474 --- /dev/null +++ b/tests/ShapeLayerTests.cpp @@ -0,0 +1,460 @@ +#include "BrushFixtures.h" +#include "SelectionFixtures.h" +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageExporter.h" +#include "IO/ImageResizer.h" +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> + +// A shape layer's life: resized, previewed, repainted, saved. +namespace { +// Red, 30 by 20 at (10, 10), radius 6. +std::unique_ptr<EditorSession> shapeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(100, 80, true); + session->selectTool(NavigationTool::shape); + session->setForegroundColor(PaletteColor{1, 0, 0}); + session->setShapeCornerRadius(6); + session->beginShape(QPointF(10, 10)); + session->dragShape(QPointF(40, 30), false, false); + session->finishShape(); + return session; +} + +int alphaAt(const EditorSession &session, int x, int y) +{ + return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y)[3]; +} + +// An independent stroke, to hold a redrawn line against. +QImage stroked(QSize size, QPointF from, QPointF to, double width) +{ + QImage image = BrushRaster::context(size.width(), size.height(), false); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(QColor(0, 0, 255), width, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(from, to); + painter.end(); + return image; +} + +// Swift's await: true once the call's callback ran. +bool landed(const std::function<void(std::function<void()>)> &start) +{ + bool done = false; + start([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 20000); +} + +void resize(EditorSession &session, QSizeF size) +{ + session.selectTool(NavigationTool::move); + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.size = size; + session.previewTransform(draft); +} + +QJsonObject firstShape(const ProjectSnapshot &snapshot, qsizetype index) +{ + return QJsonDocument::fromJson(snapshot.manifest.encoded()).object().value("layers").toArray().at(index).toObject().value("shape").toObject(); +} + +// The manifest with this layer's shape object replaced. +QByteArray withShape(const ProjectSnapshot &snapshot, const QJsonValue &shape) +{ + QJsonObject object = QJsonDocument::fromJson(snapshot.manifest.encoded()).object(); + QJsonArray layers = object.value("layers").toArray(); + QJsonObject layer = layers.at(1).toObject(); + layer.insert("shape", shape); + layers.replace(1, layer); + object.insert("layers", layers); + return QJsonDocument(object).toJson(); +} +} + +class ShapeLayerTests : public QObject { + Q_OBJECT +private slots: + void resizingRedrawsTheShapeAtItsRadius(); + void aMaskOnTheOldGridStaysWhereItWas(); + void everyShapeInAGroupIsRedrawn(); + void whatIsNotRedrawn(); + void theMovePreviewKeepsTheCorners(); + void paintingLeavesPlainPixels(); + void fillsInvertsFiltersAndMergesDropTheShape(); + void aMaskStrokeOrADuplicateKeepsTheShape(); + void aProjectKeepsLiveShapesOnly(); + void theManifestRefusesAWrongShape(); + void resizersKeepOrDropTheShape(); + void aShapeNeedsItsImage(); + void anOlderLineRunsCornerToCorner(); + void aLineKeepsItsEndsWhenRedrawn(); + void aDistortedShapeStretches(); +}; + +void ShapeLayerTests::resizingRedrawsTheShapeAtItsRadius() +{ + const auto session = shapeSession(); + const ImageLayer before = session->activeLayer().value(); + resize(*session, QSizeF(60, 40)); + session->commitTransform(); + QCOMPARE(session->history.undoName(), QString("Transform Layer")); + const ImageLayer after = session->activeLayer().value(); + QCOMPARE(after.asset.value().size(), QSize(60, 40)); + QCOMPARE(after.asset.value().name, QString("Rectangle 1")); + QCOMPARE(after.liveShape().value().style, before.shape.value().style); + // Radius 6 fills (13, 13); a stretched 12 would not. + QCOMPARE(alphaAt(*session, 13, 13), 255); + QCOMPARE(alphaAt(*session, 10, 10), 0); + QCOMPARE(alphaAt(*session, 69, 49), 0); + QCOMPARE(alphaAt(*session, 40, 30), 255); + session->undo(); + QCOMPARE(session->activeLayer().value().asset.value().identity(), before.asset.value().identity()); + QVERIFY(session->activeLayer().value().liveShape()); + // Whole pixels, rounded; one side alone redraws too. + resize(*session, QSizeF(60.6, 40)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().size(), QSize(61, 40)); + resize(*session, QSizeF(61, 30)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().size(), QSize(61, 30)); +} + +void ShapeLayerTests::aMaskOnTheOldGridStaysWhereItWas() +{ + const auto session = shapeSession(); + session->addLayerMask(true); + const ImageIdentity mask = session->activeLayer().value().mask.value().asset.identity(); + resize(*session, QSizeF(60, 40)); + session->commitTransform(); + const ImageLayer after = session->activeLayer().value(); + QCOMPARE(after.mask.value().placement, std::optional(after.transform)); + QVERIFY(after.mask.value().asset.identity() == mask); + // Undone, the mask follows the grid again. + session->undo(); + QVERIFY(!session->activeLayer().value().mask.value().placement); +} + +void ShapeLayerTests::everyShapeInAGroupIsRedrawn() +{ + const auto session = shapeSession(); + const QUuid rectangle = session->activeLayerID().value(); + session->setShapeKind(ShapeKind::ellipse); + session->beginShape(QPointF(50, 10)); + session->dragShape(QPointF(70, 30), false, false); + session->finishShape(); + const QUuid ellipse = session->activeLayerID().value(); + session->selectLayers({rectangle, ellipse}, ellipse); + session->selectTool(NavigationTool::move); + session->beginTransform(); + QVERIFY(session->transformEdit().value().group); + LayerTransform box = session->transformEdit().value().draft; + box.size = QSizeF(box.size.width() * 2, box.size.height() * 2); + session->previewTransform(box); + session->commitTransform(); + QCOMPARE(session->history.undoName(), QString("Transform Layers")); + for (const QUuid id : {rectangle, ellipse}) { + const ImageLayer layer = layerWith(*session, id); + QVERIFY(layer.liveShape()); + QCOMPARE(layer.asset.value().size(), QSize(int(std::round(layer.transform.size.width())), int(std::round(layer.transform.size.height())))); + } + QCOMPARE(layerWith(*session, ellipse).asset.value().size(), QSize(40, 40)); +} + +void ShapeLayerTests::whatIsNotRedrawn() +{ + const auto session = shapeSession(); + const ImportedImage asset = session->activeLayer().value().asset.value(); + // Sizes that round to the same pixels keep the pixels. + resize(*session, QSizeF(30.4, 19.6)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().transform.size, QSizeF(30.4, 19.6)); + QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity()); + QVERIFY(session->activeLayer().value().liveShape()); + // Past 100 megapixels it stretches as it is. + resize(*session, QSizeF(20'001, 5'000)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().identity(), asset.identity()); + session->undo(); + // Painted over, it stretches like any pixels. + session->selectTool(NavigationTool::brush); + session->beginBrush(QPointF(20, 20)); + session->finishBrush(); + const ImportedImage painted = session->activeLayer().value().asset.value(); + resize(*session, QSizeF(60, 40)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().identity(), painted.identity()); + QVERIFY(!session->activeLayer().value().shape); +} + +void ShapeLayerTests::theMovePreviewKeepsTheCorners() +{ + const auto session = shapeSession(); + const ImageLayer layer = session->activeLayer().value(); + LayerTransform transform = layer.transform; + transform.size = QSizeF(60, 40); + QVERIFY(session->shapeTransformPreview(layer, transform).isNull()); + resize(*session, QSizeF(60, 40)); + const QImage preview = session->shapeTransformPreview(layer, transform); + QCOMPARE(preview.size(), QSize(60, 40)); + QVERIFY(alpha(preview, 3, 3) == 255 && alpha(preview, 0, 0) == 0); + QCOMPARE(session->shapeTransformPreview(layer, transform).cacheKey(), preview.cacheKey()); + transform.size = QSizeF(61, 40); + QVERIFY(session->shapeTransformPreview(layer, transform).cacheKey() != preview.cacheKey()); + // At most 2048 across; the radius shrinks alike. + transform.size = QSizeF(4096, 40); + const QImage wide = session->shapeTransformPreview(layer, transform); + QCOMPARE(wide.size(), QSize(2048, 20)); + QVERIFY(alpha(wide, 1, 0) > 30 && alpha(wide, 0, 0) < 64); + // Within half a pixel, or under one, there is none. + transform.size = QSizeF(30.4, 20); + QVERIFY(session->shapeTransformPreview(layer, transform).isNull()); + transform.size = QSizeF(30.5, 20); + QCOMPARE(session->shapeTransformPreview(layer, transform).size(), QSize(31, 20)); + transform.size = QSizeF(0.9, 40); + QVERIFY(session->shapeTransformPreview(layer, transform).isNull()); + // An ended edit empties the cache; the next draws afresh. + transform.size = QSizeF(60, 40); + const qint64 kept = session->shapeTransformPreview(layer, transform).cacheKey(); + session->cancelTransform(); + QVERIFY(session->shapeTransformPreview(layer, transform).isNull()); + resize(*session, QSizeF(60, 40)); + QVERIFY(session->shapeTransformPreview(layer, transform).cacheKey() != kept); + session->cancelTransform(); + // Square corners and ellipses stretch with no preview. + ImageLayer square = layer; + square.shape.value().style.cornerRadius = 0; + ImageLayer round = layer; + round.shape.value().style.kind = ShapeKind::ellipse; + resize(*session, QSizeF(60, 40)); + QVERIFY(session->shapeTransformPreview(square, transform).isNull() && session->shapeTransformPreview(round, transform).isNull()); +} + +void ShapeLayerTests::paintingLeavesPlainPixels() +{ + const auto session = shapeSession(); + session->selectTool(NavigationTool::brush); + session->beginBrush(QPointF(20, 20)); + session->finishBrush(); + QCOMPARE(session->history.undoName(), QString("Brush Stroke")); + QVERIFY(!session->activeLayer().value().shape); + QVERIFY(!session->projectSnapshot().value().manifest.layers.back().shape); + session->undo(); + QVERIFY(session->activeLayer().value().liveShape()); +} + +void ShapeLayerTests::fillsInvertsFiltersAndMergesDropTheShape() +{ + const std::vector<std::function<bool(EditorSession &)>> edits = { + [](EditorSession &session) { + return landed([&](std::function<void()> done) { session.fillSelection(EditorSession::FillSource::foreground, done); }); + }, + [](EditorSession &session) { return landed([&](std::function<void()> done) { session.invertPixels(done); }); }, + [](EditorSession &session) { + session.beginFilter(FilterKind::contentAwareFill); + return landed([&](std::function<void()> done) { session.commitFilter(done); }); + }, + [](EditorSession &session) { + if (!landed([&](std::function<void()> done) { session.beginSelectionTransform(done); })) + return false; + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(3, 2); + session.previewTransform(draft); + session.commitTransform(); + return true; + }, + }; + const std::vector<QString> names{"Fill", "Invert", "Content-Aware Fill", "Transform Selection"}; + // A selection past the shape's pixels floats nothing out first. + { + const auto session = shapeSession(); + const QUuid id = session->activeLayerID().value(); + session->applySelection(rectPath(QRectF(60, 50, 10, 10)), SelectionMode::replace, "Select"); + QVERIFY(edits[3](*session)); + QVERIFY(!layerWith(*session, id).shape); + } + for (size_t index = 0; index < edits.size(); ++index) { + const auto session = shapeSession(); + const QUuid id = session->activeLayerID().value(); + session->applySelection(rectPath(QRectF(20, 14, 8, 6)), SelectionMode::replace, "Select"); + QVERIFY(edits[index](*session)); + QCOMPARE(session->history.undoName(), names[index]); + QVERIFY2(!layerWith(*session, id).shape, qPrintable(names[index])); + } +} + +void ShapeLayerTests::aMaskStrokeOrADuplicateKeepsTheShape() +{ + const auto session = shapeSession(); + const QUuid id = session->activeLayerID().value(); + session->addLayerMask(true); + session->selectLayerTarget(id, true); + session->selectTool(NavigationTool::brush); + session->beginBrush(QPointF(20, 20)); + session->finishBrush(); + QCOMPARE(session->history.undoName(), QString("Paint Mask")); + QVERIFY(session->activeLayer().value().liveShape()); + session->selectLayerTarget(id, false); + session->duplicateActiveLayer(); + QCOMPARE(session->activeLayer().value().name, QString("Rectangle 1 copy")); + QCOMPARE(session->activeLayer().value().liveShape(), layerWith(*session, id).liveShape()); +} + +void ShapeLayerTests::aProjectKeepsLiveShapesOnly() +{ + const auto session = shapeSession(); + session->setShapeKind(ShapeKind::line); + session->setShapeLineWidth(3); + session->setForegroundColor(PaletteColor{0.2, 0.4, 0.6}); + session->beginShape(QPointF(60, 60)); + session->dragShape(QPointF(80, 70), false, false); + session->finishShape(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + QCOMPARE(snapshot.manifest.layers[1].shape.value().cornerRadius, 6.0); + const LayerShapeStyle line = snapshot.manifest.layers[2].shape.value(); + QCOMPARE(line.lineWidth.value(), 3.0); + // Swift's keys; a rectangle writes no line fields. + QCOMPARE(firstShape(snapshot, 1).keys(), (QStringList{"blue", "cornerRadius", "green", "kind", "red"})); + QCOMPARE(firstShape(snapshot, 1).value("kind").toString(), QString("Rectangle")); + QCOMPARE(firstShape(snapshot, 2).value("start").toArray(), (QJsonArray{line.start->x(), line.start->y()})); + QCOMPARE(firstShape(snapshot, 2).value("lineWidth").toDouble(), 3.0); + const ProjectManifest decoded = ProjectManifest::decoded(snapshot.manifest.encoded()); + QCOMPARE(decoded.layers[1].shape, snapshot.manifest.layers[1].shape); + QCOMPARE(decoded.layers[2].shape, snapshot.manifest.layers[2].shape); + QVERIFY(!decoded.layers[0].shape); + // Installed, the shapes are live and redraw. + EditorSession opened; + opened.installProject(snapshot, QStringLiteral("shapes.comp")); + QCOMPARE(opened.document().value().layers[2].liveShape().value().style, line); + opened.selectLayer(opened.document().value().layers[1].id); + resize(opened, QSizeF(60, 40)); + opened.commitTransform(); + QCOMPARE(opened.activeLayer().value().asset.value().size(), QSize(60, 40)); + // A shape whose image is gone saves as pixels alone. + ImageLayer stale = session->document().value().layers[2]; + stale.shape.value().image = ImageIdentity{}; + QVERIFY(!stale.liveShape() && !stale.hierarchyRecord().shape); +} + +void ShapeLayerTests::theManifestRefusesAWrongShape() +{ + const auto session = shapeSession(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + const QJsonObject good = firstShape(snapshot, 1); + QVERIFY(!ProjectManifest::decoded(withShape(snapshot, QJsonValue::Null)).layers[1].shape); + QJsonObject nulled = good; + nulled.insert("lineWidth", QJsonValue::Null); + QVERIFY(!ProjectManifest::decoded(withShape(snapshot, nulled)).layers[1].shape.value().lineWidth); + const auto refused = [&](const QJsonValue &shape) { + try { + ProjectManifest::decoded(withShape(snapshot, shape)); + } catch (const ProjectError &error) { + return error.kind == ProjectError::Kind::invalid; + } + return false; + }; + for (const auto &[key, value] : std::vector<std::pair<QString, QJsonValue>>{ + {"kind", "Star"}, {"kind", 1}, {"red", "1"}, {"cornerRadius", true}, {"lineWidth", "4"}, {"start", QJsonArray{1}}, {"end", 2}}) { + QJsonObject wrong = good; + wrong.insert(key, value); + QVERIFY2(refused(wrong), qPrintable(key)); + } + for (const char *key : {"kind", "red", "green", "blue", "cornerRadius"}) { + QJsonObject missing = good; + missing.remove(key); + QVERIFY2(refused(missing), key); + } + QVERIFY(refused(QJsonValue(5))); +} + +void ShapeLayerTests::resizersKeepOrDropTheShape() +{ + const auto session = shapeSession(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + const ProjectSnapshot canvas = CanvasResizer::resize(snapshot, {.width = 120, .height = 90}); + QCOMPARE(canvas.manifest.layers[1].shape, snapshot.manifest.layers[1].shape); + const ProjectSnapshot image = ImageResizer::resize(snapshot, {.width = 200, .height = 160, .resolution = 72}); + QVERIFY(!image.manifest.layers[1].shape); + // Swift's rebuild after a resize leaves every shape out. + session->applyDocumentSize(canvas, QStringLiteral("Canvas Size")); + QCOMPARE(session->history.undoName(), QString("Canvas Size")); + QVERIFY(!session->document().value().layers[1].shape); +} + +void ShapeLayerTests::aShapeNeedsItsImage() +{ + const auto session = shapeSession(); + const ImageLayer shape = session->activeLayer().value(); + ImageLayer bare = shape; + bare.asset = std::nullopt; + QVERIFY(!bare.liveShape()); + // Layers differ by their shapes too, as Swift compares them. + ImageLayer plain = shape; + plain.shape = std::nullopt; + QVERIFY(!(plain == shape) && shape == session->activeLayer().value()); + // A style on a pixel-less layer loads as no shape. + const QUuid blank = session->document().value().layers.front().id; + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, blank).shape = shape.shape.value().style; }); + QVERIFY(!layerWith(*session, blank).shape); + QVERIFY(layerWith(*session, shape.id).liveShape()); + QVERIFY(!LayerShape::loaded(std::nullopt, shape.asset)); +} + +void ShapeLayerTests::anOlderLineRunsCornerToCorner() +{ + const auto session = shapeSession(); + const QUuid id = session->activeLayerID().value(); + // Older lines kept no ends: redrawn inset corner to corner. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).shape = LayerShapeStyle{.kind = ShapeKind::line, .red = 0, .green = 0, .blue = 1, .cornerRadius = 0, .lineWidth = 4}; + }); + resize(*session, QSizeF(40, 40)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().image(), stroked(QSize(40, 40), QPointF(2, 2), QPointF(38, 38), 4)); + // Thicker than its box: the inset stops at the middle. + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).shape = LayerShapeStyle{.kind = ShapeKind::line, .red = 0, .green = 0, .blue = 1, .cornerRadius = 0, .lineWidth = 6}; + }); + resize(*session, QSizeF(4, 40)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().image(), stroked(QSize(4, 40), QPointF(2, 3), QPointF(2, 37), 6)); +} + +void ShapeLayerTests::aLineKeepsItsEndsWhenRedrawn() +{ + const auto session = shapeSession(); + session->setShapeKind(ShapeKind::line); + session->setForegroundColor(PaletteColor{0, 0, 1}); + session->beginShape(QPointF(10, 30)); + session->dragShape(QPointF(50, 10), false, false); + session->finishShape(); + // Up-right, doubled: the ends scale, the width holds. + resize(*session, QSizeF(88, 48)); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().asset.value().image(), + stroked(QSize(88, 48), QPointF(2.0 / 44 * 88, 22.0 / 24 * 48), QPointF(42.0 / 44 * 88, 2.0 / 24 * 48), 4)); +} + +void ShapeLayerTests::aDistortedShapeStretches() +{ + const auto session = shapeSession(); + session->selectTool(NavigationTool::move); + session->beginTransform(false); + session->beginDistort(); + session->previewCorners({QPointF(10, 10), QPointF(40, 10), QPointF(45, 30), QPointF(5, 30)}); + session->commitTransform(); + // Warped in place as Swift's: the shape stays, not live. + const ImageLayer warped = session->activeLayer().value(); + QVERIFY(warped.shape && !warped.liveShape()); + resize(*session, warped.transform.size * 2); + session->commitTransform(); + QCOMPARE(session->activeLayer().value().transform.size, warped.transform.size * 2); + QCOMPARE(session->activeLayer().value().asset.value().identity(), warped.asset.value().identity()); +} + +QTEST_GUILESS_MAIN(ShapeLayerTests) +#include "ShapeLayerTests.moc" diff --git a/tests/ShapeToolTests.cpp b/tests/ShapeToolTests.cpp new file mode 100644 index 0000000..527337c --- /dev/null +++ b/tests/ShapeToolTests.cpp @@ -0,0 +1,401 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QSignalSpy> +#include <cmath> +#include <numbers> + +// Swift's ShapeToolTests, and the draft's own rules. +namespace { +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(100, 80, true); + session->selectTool(NavigationTool::shape); + session->setForegroundColor(PaletteColor{1, 0, 0}); + return session; +} + +void drag(EditorSession &session, QPointF start, QPointF end, bool square = false, bool fromCenter = false) +{ + session.beginShape(start); + session.dragShape(end, square, fromCenter); + session.finishShape(); +} + +// One exported pixel's red and alpha, as Swift's reader. +std::pair<int, int> redAlpha(const EditorSession &session, int x, int y) +{ + const std::vector<int> at = pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y); + return {at[0], at[3]}; +} + +QStringList names(const EditorSession &session) +{ + QStringList list; + for (const ImageLayer &layer : session.document().value().layers) + list << layer.name; + return list; +} + +const std::pair<int, int> solid{255, 255}; +} + +class ShapeToolTests : public QObject { + Q_OBJECT +private slots: + void rectangleFillsANewLayerWithTheForegroundColorAsOneUndoStep(); + void ellipseLeavesItsCornersClearWithShiftCircleAndOptionFromCenter(); + void aClickEscapeOrToolSwitchMakesNoLayer(); + void roundedRectanglesFollowTheRadiusAndClampToAPill(); + void aLineKeepsItsEndsThicknessAndRoundCaps(); + void shiftSnapsALineToEighthsOfATurn(); + void aShapeTooLargeIsRefused(); + void namesSkipThoseInUse(); + void theDraftWaitsForEditableLayers(); + void tabAndTheKindsCancelTheDraft(); + void everyChangeIsAnnounced(); + void pathsFollowSwift(); + void flatDragsMakeNothing(); + void edgesAreSmoothAndLinesAPixelWide(); +}; + +void ShapeToolTests::rectangleFillsANewLayerWithTheForegroundColorAsOneUndoStep() +{ + const auto session = makeSession(); + session->selectAll(); + const int count = session->history.undoCount(); + drag(*session, QPointF(10, 10), QPointF(40, 30)); + QCOMPARE(names(*session), (QStringList{"Layer 1", "Rectangle 1"})); + QCOMPARE(session->activeLayer().value().name, QString("Rectangle 1")); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Rectangle")); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(10, 10)); + QCOMPARE(session->activeLayer().value().transform.size, QSizeF(30, 20)); + // Unlike Paste, drawing a shape keeps the selection. + QVERIFY(session->selection()); + QCOMPARE(redAlpha(*session, 25, 20), solid); + QCOMPARE(redAlpha(*session, 10, 10), solid); + QCOMPARE(redAlpha(*session, 39, 29), solid); + QCOMPARE(redAlpha(*session, 9, 20).second, 0); + QCOMPARE(redAlpha(*session, 40, 20).second, 0); + QCOMPARE(redAlpha(*session, 25, 30).second, 0); + drag(*session, QPointF(60, 10), QPointF(70, 20)); + QCOMPARE(session->activeLayer().value().name, QString("Rectangle 2")); + session->undo(); + session->undo(); + QCOMPARE(names(*session), (QStringList{"Layer 1"})); +} + +void ShapeToolTests::ellipseLeavesItsCornersClearWithShiftCircleAndOptionFromCenter() +{ + const auto session = makeSession(); + session->toggleShapeKind(); + QCOMPARE(session->shapeKind(), ShapeKind::ellipse); + drag(*session, QPointF(50, 40), QPointF(60, 45), true, true); + QCOMPARE(session->activeLayer().value().name, QString("Ellipse 1")); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(40, 30)); + QCOMPARE(session->activeLayer().value().transform.size, QSizeF(20, 20)); + QCOMPARE(redAlpha(*session, 50, 40), solid); + QVERIFY(redAlpha(*session, 41, 40).second > 0 && redAlpha(*session, 50, 31).second > 0); + // Outside the circle, inside its box. + QCOMPARE(redAlpha(*session, 40, 30).second, 0); + QCOMPARE(redAlpha(*session, 59, 49).second, 0); +} + +void ShapeToolTests::aClickEscapeOrToolSwitchMakesNoLayer() +{ + const auto session = makeSession(); + const int count = session->history.undoCount(); + session->beginShape(QPointF(20, 20)); + session->finishShape(); + session->beginShape(QPointF(20, 20)); + session->dragShape(QPointF(50, 50), false, false); + QCOMPARE(session->shapeDraft().value().rect, QRectF(20, 20, 30, 30)); + session->cancelShape(); + session->beginShape(QPointF(20, 20)); + session->dragShape(QPointF(50, 50), false, false); + session->selectTool(NavigationTool::brush); + QVERIFY(!session->shapeDraft()); + QCOMPARE(session->history.undoCount(), count); + QCOMPARE(session->document().value().layers.size(), size_t(1)); +} + +void ShapeToolTests::roundedRectanglesFollowTheRadiusAndClampToAPill() +{ + const auto session = makeSession(); + session->setShapeCornerRadius(8); + drag(*session, QPointF(10, 10), QPointF(50, 40)); + session->setShapeCornerRadius(500); + // 40 × 20: a radius of 10. + drag(*session, QPointF(55, 50), QPointF(95, 70)); + QCOMPARE(redAlpha(*session, 10, 10).second, 0); + QCOMPARE(redAlpha(*session, 11, 11).second, 0); + QCOMPARE(redAlpha(*session, 13, 13).second, 255); + QCOMPARE(redAlpha(*session, 30, 10).second, 255); + QCOMPARE(redAlpha(*session, 30, 25), solid); + QCOMPARE(redAlpha(*session, 55, 50).second, 0); + QCOMPARE(redAlpha(*session, 75, 60), solid); + QCOMPARE(session->document().value().layers.size(), size_t(3)); + QCOMPARE(session->activeLayer().value().shape.value().style.cornerRadius, 500.0); + session->toggleShapeKind(); + session->beginShape(QPointF(5, 5)); + QCOMPARE(session->shapeDraft().value().cornerRadius, 0.0); + session->cancelShape(); +} + +void ShapeToolTests::aLineKeepsItsEndsThicknessAndRoundCaps() +{ + const auto session = makeSession(); + session->setForegroundColor(PaletteColor{1, 0.5, 0.25}); + session->setShapeKind(ShapeKind::line); + session->beginShape(QPointF(10, 10)); + // Undragged, a line has no end yet. + QVERIFY(!session->shapeLineEnds()); + session->dragShape(QPointF(50, 30), false, false); + QCOMPARE(session->shapeLineEnds().value(), std::pair(QPointF(10, 10), QPointF(50, 30))); + session->finishShape(); + // The ends' box, grown by half the default 4. + const ImageLayer line = session->activeLayer().value(); + QCOMPARE(line.name, QString("Line 1")); + QCOMPARE(line.transform.origin, QPointF(8, 8)); + QCOMPARE(line.transform.size, QSizeF(44, 24)); + const LayerShapeStyle style = line.liveShape().value().style; + QCOMPARE(style.kind, ShapeKind::line); + QCOMPARE(style.color(), (PaletteColor{1, 0.5, 0.25})); + QCOMPARE(style.lineWidth.value(), 4.0); + QCOMPARE(style.start.value(), QPointF(2.0 / 44, 2.0 / 24)); + QCOMPARE(style.end.value(), QPointF(42.0 / 44, 22.0 / 24)); + QCOMPARE(redAlpha(*session, 30, 20), solid); + QCOMPARE(redAlpha(*session, 20, 25).second, 0); + // Past its start, inside the round cap alone. + QVERIFY(redAlpha(*session, 8, 9).second > 100); + QCOMPARE(redAlpha(*session, 50, 30), solid); + // Dragged back the other way: the same box, ends swapped. + session->setShapeLineWidth(6); + session->beginShape(QPointF(50, 30)); + session->dragShape(QPointF(10, 10), false, false); + QCOMPARE(session->shapeLineEnds().value().first, QPointF(50, 30)); + session->finishShape(); + const ImageLayer back = session->activeLayer().value(); + QCOMPARE(back.transform.origin, QPointF(7, 7)); + QCOMPARE(back.transform.size, QSizeF(46, 26)); + QCOMPARE(back.shape.value().style.start.value(), QPointF(43.0 / 46, 23.0 / 26)); + QCOMPARE(back.shape.value().style.lineWidth.value(), 6.0); + // A click leaves a dot: the box grows by thickness. + session->beginShape(QPointF(70, 60)); + session->finishShape(); + const ImageLayer dot = session->activeLayer().value(); + QCOMPARE(dot.name, QString("Line 3")); + QCOMPARE(dot.transform.origin, QPointF(67, 57)); + QCOMPARE(dot.transform.size, QSizeF(6, 6)); + QCOMPARE(dot.shape.value().style.start.value(), QPointF(0.5, 0.5)); + QCOMPARE(redAlpha(*session, 70, 60), solid); + // A disc: its box's corners barely touched, its edges full. + QVERIFY(redAlpha(*session, 67, 57).second < 20 && redAlpha(*session, 67, 60).second > 150); + // A box that ends between pixels keeps its whole pixels. + session->beginShape(QPointF(10, 40)); + session->dragShape(QPointF(50.5, 60), false, false); + session->finishShape(); + QCOMPARE(session->activeLayer().value().asset.value().size(), QSize(46, 26)); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(7, 37)); +} + +void ShapeToolTests::shiftSnapsALineToEighthsOfATurn() +{ + const auto session = makeSession(); + session->setShapeKind(ShapeKind::line); + session->beginShape(QPointF(10.6, 9.4)); + QCOMPARE(session->shapeDraft().value().anchor, QPointF(11, 9)); + session->beginShape(QPointF(10.4, 9.6)); + QCOMPARE(session->shapeDraft().value().anchor, QPointF(10, 10)); + session->dragShape(QPointF(50, 33), true, false); + const double length = std::hypot(40.0, 23.0), leg = length * std::cos(std::numbers::pi / 4); + const QPointF end = session->shapeLineEnds().value().second; + QVERIFY(std::abs(end.x() - (10 + leg)) < 1e-9 && std::abs(end.y() - (10 + leg)) < 1e-9); + QCOMPARE(session->shapeDraft().value().rect, DragBox::rect(QPointF(10, 10), end, false, false)); + // Flat stays flat; Alt grows the box both ways. + session->dragShape(QPointF(50, 14), true, true); + QVERIFY(std::abs(session->shapeLineEnds().value().second.y() - 10) < 1e-9); + QCOMPARE(session->shapeDraft().value().rect, DragBox::rect(QPointF(10, 10), session->shapeLineEnds().value().second, false, true)); + // Without Shift a line ends where the pointer is. + session->dragShape(QPointF(33.3, 21.7), false, false); + QCOMPARE(session->shapeLineEnds().value().second, QPointF(33.3, 21.7)); + QCOMPARE(session->shapeDraft().value().rect, QRectF(10, 10, 23, 12)); +} + +void ShapeToolTests::aShapeTooLargeIsRefused() +{ + EditorSession session; + session.createDocument(20'000, 12'000, true); + session.selectTool(NavigationTool::shape); + // Each side truncated first: 10,001 × 10,000 is over. + session.beginShape(QPointF(0, 0)); + session.dragShape(QPointF(10'001, 10'000), false, false); + session.finishShape(); + QCOMPARE(session.brushError().value(), QString("That shape is too large. A shape can cover up to 100 megapixels.")); + QCOMPARE(session.document().value().layers.size(), size_t(1)); + QVERIFY(!session.shapeDraft()); +} + +void ShapeToolTests::namesSkipThoseInUse() +{ + EditorSession empty; + QCOMPARE(empty.nextShapeName(ShapeKind::ellipse), QString("Ellipse 1")); + const auto session = makeSession(); + session->renameLayer(session->activeLayerID().value(), QStringLiteral("Rectangle 1")); + QCOMPARE(session->nextShapeName(ShapeKind::rectangle), QString("Rectangle 2")); + QCOMPARE(session->nextShapeName(ShapeKind::line), QString("Line 1")); + drag(*session, QPointF(1, 1), QPointF(5, 5)); + QCOMPARE(session->activeLayer().value().name, QString("Rectangle 2")); +} + +void ShapeToolTests::theDraftWaitsForEditableLayers() +{ + const auto session = makeSession(); + // Only the Shape tool, a finite point, editable layers begin. + session->selectTool(NavigationTool::brush); + session->beginShape(QPointF(5, 5)); + QVERIFY(!session->shapeDraft()); + session->selectTool(NavigationTool::shape); + session->beginShape(QPointF(NAN, 5)); + session->beginShape(QPointF(5, INFINITY)); + QVERIFY(!session->shapeDraft()); + session->setIsProjectBusy(true); + session->beginShape(QPointF(5, 5)); + QVERIFY(!session->shapeDraft()); + session->setIsProjectBusy(false); + session->beginShape(QPointF(5, 5)); + session->dragShape(QPointF(NAN, 20), false, false); + session->dragShape(QPointF(20, -INFINITY), false, false); + QCOMPARE(session->shapeDraft().value().rect, QRectF(5, 5, 0, 0)); + // A draft finished while busy makes nothing. + session->dragShape(QPointF(20, 20), false, false); + session->setIsProjectBusy(true); + session->finishShape(); + QVERIFY(!session->shapeDraft()); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + session->setIsProjectBusy(false); + // Without a draft, a drag or finish changes nothing. + session->dragShape(QPointF(30, 30), false, false); + session->finishShape(); + QVERIFY(!session->shapeDraft()); + QCOMPARE(session->document().value().layers.size(), size_t(1)); +} + +void ShapeToolTests::tabAndTheKindsCancelTheDraft() +{ + const auto session = makeSession(); + session->beginShape(QPointF(5, 5)); + session->toggleShapeKind(); + QVERIFY(!session->shapeDraft()); + QCOMPARE(session->shapeKind(), ShapeKind::ellipse); + session->toggleShapeKind(); + QCOMPARE(session->shapeKind(), ShapeKind::line); + session->toggleShapeKind(); + QCOMPARE(session->shapeKind(), ShapeKind::rectangle); + session->beginShape(QPointF(5, 5)); + session->cycleToolMode(); + QVERIFY(!session->shapeDraft()); + QCOMPARE(session->shapeKind(), ShapeKind::ellipse); + // Choosing the tool it has keeps the draft. + session->beginShape(QPointF(5, 5)); + session->selectTool(NavigationTool::shape); + QVERIFY(session->shapeDraft()); + QCOMPARE(session->shapeDraft().value().kind, ShapeKind::ellipse); +} + +void ShapeToolTests::everyChangeIsAnnounced() +{ + const auto session = makeSession(); + QSignalSpy spy(session.get(), &EditorSession::changed); + session->setShapeKind(ShapeKind::line); + session->setShapeLineWidth(9); + session->setShapeCornerRadius(3); + QCOMPARE(spy.count(), 3); + QCOMPARE(session->shapeLineWidth(), 9.0); + QCOMPARE(session->shapeCornerRadius(), 3.0); + session->beginShape(QPointF(1, 1)); + session->dragShape(QPointF(9, 9), false, false); + session->cancelShape(); + QCOMPARE(spy.count(), 6); + session->cancelShape(); + QCOMPARE(spy.count(), 6); + // A rectangle's click clears the draft, one signal. + session->setShapeKind(ShapeKind::rectangle); + session->beginShape(QPointF(1, 1)); + spy.clear(); + session->finishShape(); + QCOMPARE(spy.count(), 1); + QVERIFY(!session->shapeDraft()); +} + +void ShapeToolTests::pathsFollowSwift() +{ + const QRectF tall(0, 0, 20, 40); + QPainterPath expected; + expected.addRoundedRect(tall, 10, 10); + // The radius stops at half the shorter side, either way. + QCOMPARE(path(ShapeKind::rectangle, tall, 500), expected); + QCOMPARE(path(ShapeKind::rectangle, tall.transposed(), 500), [] { + QPainterPath wide; + wide.addRoundedRect(QRectF(0, 0, 40, 20), 10, 10); + return wide; + }()); + QPainterPath square; + square.addRect(tall); + QCOMPARE(path(ShapeKind::rectangle, tall, -5), square); + QCOMPARE(path(ShapeKind::rectangle, tall), square); + QPainterPath ellipse; + ellipse.addEllipse(tall); + QCOMPARE(path(ShapeKind::ellipse, tall, 8), ellipse); + QCOMPARE(rawValue(ShapeKind::rectangle) + rawValue(ShapeKind::ellipse) + rawValue(ShapeKind::line), QString("RectangleEllipseLine")); + QCOMPARE(shapeKind(QStringLiteral("Line")), std::optional(ShapeKind::line)); + QCOMPARE(shapeKind(QStringLiteral("Ellipse")), std::optional(ShapeKind::ellipse)); + QVERIFY(!shapeKind(QStringLiteral("line"))); +} + +void ShapeToolTests::flatDragsMakeNothing() +{ + const auto session = makeSession(); + drag(*session, QPointF(20, 20), QPointF(50, 20)); + drag(*session, QPointF(20, 20), QPointF(20, 50)); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + QVERIFY(!session->brushError()); +} + +void ShapeToolTests::edgesAreSmoothAndLinesAPixelWide() +{ + const auto session = makeSession(); + session->setShapeKind(ShapeKind::ellipse); + drag(*session, QPointF(40, 30), QPointF(60, 50)); + // The circle's rim crosses this pixel: part covered. + const int rim = redAlpha(*session, 40, 38).second; + QVERIFY2(rim > 40 && rim < 250, qPrintable(QString::number(rim))); + // Thinner than a pixel, a line still draws one wide. + session->setShapeKind(ShapeKind::line); + session->setShapeLineWidth(0.4); + drag(*session, QPointF(10, 10), QPointF(50, 30)); + const QImage line = session->activeLayer().value().asset.value().image(); + int strongest = 0; + for (int y = 0; y < line.height(); ++y) + strongest = std::max(strongest, alpha(line, 20, y)); + QVERIFY2(strongest > 150, qPrintable(QString::number(strongest))); + // Pixel-wide is still an outline: 1 and 1.001 agree. + session->setShapeLineWidth(1); + drag(*session, QPointF(10, 40), QPointF(50, 60)); + const QImage one = session->activeLayer().value().asset.value().image(); + session->setShapeLineWidth(1.001); + drag(*session, QPointF(10, 40), QPointF(50, 60)); + const QImage wider = session->activeLayer().value().asset.value().image(); + QCOMPARE(one.size(), wider.size()); + int apart = 0; + for (int y = 0; y < one.height(); ++y) { + for (int x = 0; x < one.width(); ++x) + apart = std::max(apart, std::abs(alpha(one, x, y) - alpha(wider, x, y))); + } + QVERIFY2(apart <= 3, qPrintable(QString::number(apart))); +} + +QTEST_GUILESS_MAIN(ShapeToolTests) +#include "ShapeToolTests.moc" diff --git a/tests/SliderSnapTests.cpp b/tests/SliderSnapTests.cpp new file mode 100644 index 0000000..18eaf38 --- /dev/null +++ b/tests/SliderSnapTests.cpp @@ -0,0 +1,135 @@ +#include "UI/SliderSnap.h" +#include <QProxyStyle> +#include <QSlider> +#include <QStyle> +#include <QStyleFactory> +#include <QtTest> + +// Clicking the track must snap the knob to the click. +class SliderSnapTests : public QObject { + Q_OBJECT +private slots: + void clickingTheTrackSnapsTheKnobAndStillEditsTheValue(); +}; + +namespace { +// Qt's own absolute set: the knob centred under the press. +class Absolute : public QProxyStyle { +public: + using QProxyStyle::QProxyStyle; + int styleHint(StyleHint hint, const QStyleOption *option, const QWidget *widget, QStyleHintReturn *returned) const override + { + return hint == SH_Slider_AbsoluteSetButtons ? int(Qt::LeftButton) : QProxyStyle::styleHint(hint, option, widget, returned); + } +}; + +struct Probe { + QSlider slider; + QStringList log; + explicit Probe(Qt::Orientation orientation = Qt::Horizontal) + : slider(orientation) + { + slider.setRange(0, 100); + slider.setValue(10); + slider.resize(orientation == Qt::Horizontal ? QSize(200, 24) : QSize(24, 200)); + slider.show(); + QObject::connect(&slider, &QSlider::sliderPressed, [this] { log << "began"; }); + QObject::connect(&slider, &QSlider::valueChanged, [this] { log << "set"; }); + QObject::connect(&slider, &QSlider::sliderReleased, [this] { log << "ended"; }); + } + void click(Qt::MouseButton button, QPoint at) + { + QTest::mousePress(&slider, button, Qt::NoModifier, at); + QTest::mouseRelease(&slider, button, Qt::NoModifier, at); + } +}; +} + +void SliderSnapTests::clickingTheTrackSnapsTheKnobAndStillEditsTheValue() +{ + QApplication::setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); + { + // Qt alone steps a page towards the press. + Probe plain; + plain.click(Qt::LeftButton, QPoint(180, 12)); + QCOMPARE(plain.slider.value(), 20); + } + // Qt's own centring is the oracle for the landing. + std::vector<int> centred; + for (const int x : {30, 180, 30}) { + Probe oracle; + oracle.slider.setInvertedAppearance(centred.size() == 2); + oracle.slider.setStyle(new Absolute(QStyleFactory::create(QStringLiteral("Fusion")))); + oracle.click(Qt::LeftButton, QPoint(x, 12)); + centred.push_back(oracle.slider.value()); + } + SliderSnap::install(); + Probe probe; + QTest::mousePress(&probe.slider, Qt::LeftButton, Qt::NoModifier, QPoint(180, 12)); + // Editing begins, then the value lands under the press. + QCOMPARE(probe.slider.value(), centred.at(1)); + QVERIFY(centred.at(1) > 85 && centred.at(1) < 100); + QVERIFY(probe.slider.isSliderDown()); + QCOMPARE(probe.log, (QStringList{"began", "set"})); + QTest::mouseMove(&probe.slider, QPoint(100, 12)); + QVERIFY(probe.slider.value() > 40 && probe.slider.value() < 60); + QTest::mouseRelease(&probe.slider, Qt::LeftButton, Qt::NoModifier, QPoint(100, 12)); + QCOMPARE(probe.log.last(), QString("ended")); + // The knob drags from where it is; others snap nothing. + const int kept = probe.slider.value(); + probe.click(Qt::LeftButton, QPoint(103, 12)); + QCOMPARE(probe.slider.value(), kept); + probe.click(Qt::RightButton, QPoint(10, 12)); + QCOMPARE(probe.slider.value(), kept); + Probe low; + low.slider.setValue(90); + low.click(Qt::LeftButton, QPoint(30, 12)); + QCOMPARE(low.slider.value(), centred.at(0)); + Probe inverted; + inverted.slider.setInvertedAppearance(true); + inverted.click(Qt::LeftButton, QPoint(30, 12)); + QCOMPARE(inverted.slider.value(), centred.at(2)); + QVERIFY(centred.at(2) > 85); + // An empty range takes no snap; nothing stays held. + Probe empty; + empty.slider.setRange(5, 5); + empty.log.clear(); + empty.click(Qt::LeftButton, QPoint(180, 12)); + QVERIFY(!empty.slider.isSliderDown() && !empty.log.contains("began")); + // Narrower than its knob, a slider has nowhere to snap. + Probe narrow; + narrow.slider.resize(10, 40); + narrow.slider.setValue(50); + narrow.log.clear(); + narrow.click(Qt::LeftButton, QPoint(5, 1)); + QVERIFY(!narrow.slider.isSliderDown() && !narrow.log.contains("began")); + // An owner that rounds may move the knob; drags hold. + Probe rounded; + QObject::connect(&rounded.slider, &QSlider::valueChanged, [&rounded](int value) { + const QSignalBlocker quiet(&rounded.slider); + rounded.slider.setValue(value < 25 ? 0 : value < 75 ? 50 : 100); + }); + QTest::mousePress(&rounded.slider, Qt::LeftButton, Qt::NoModifier, QPoint(130, 12)); + QVERIFY(rounded.slider.value() == 50 && rounded.slider.isSliderDown()); + QTest::mouseMove(&rounded.slider, QPoint(195, 12)); + QCOMPARE(rounded.slider.value(), 100); + QTest::mouseRelease(&rounded.slider, Qt::LeftButton, Qt::NoModifier, QPoint(195, 12)); + QVERIFY(!rounded.slider.isSliderDown() && rounded.log.last() == QString("ended")); + // A press with another button held takes no snap. + Probe held; + QTest::mousePress(&held.slider, Qt::RightButton, Qt::NoModifier, QPoint(10, 12)); + QTest::mousePress(&held.slider, Qt::LeftButton, Qt::NoModifier, QPoint(180, 12)); + QCOMPARE(held.slider.value(), 10); + QTest::mouseRelease(&held.slider, Qt::LeftButton, Qt::NoModifier, QPoint(180, 12)); + QTest::mouseRelease(&held.slider, Qt::RightButton, Qt::NoModifier, QPoint(10, 12)); + QVERIFY(!held.slider.isSliderDown()); + probe.slider.setEnabled(false); + probe.click(Qt::LeftButton, QPoint(10, 12)); + QCOMPARE(probe.slider.value(), kept); + Probe upright(Qt::Vertical); + upright.click(Qt::LeftButton, QPoint(12, 20)); + QCOMPARE(upright.slider.value(), 20); +} + +QTEST_MAIN(SliderSnapTests) +#include "SliderSnapTests.moc" diff --git a/tests/SmartEditTests.cpp b/tests/SmartEditTests.cpp new file mode 100644 index 0000000..6684499 --- /dev/null +++ b/tests/SmartEditTests.cpp @@ -0,0 +1,497 @@ +#include "Document/BrushStroke.h" +#include "Document/ContentFill.h" +#include "SelectionFixtures.h" +#include "SessionRecord.h" +#include <QSemaphore> +#include <QThreadPool> +#include <QtConcurrent> + +// Content-Aware Fill through the session; Subject Removal is 9.7's. +namespace { +const std::array<uchar, 4> red{255, 0, 0, 255}, blue{51, 153, 204, 255}; + +QImage picture(int width, int height, const std::function<std::array<uchar, 4>(int, int)> &colour) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x) + std::copy_n(colour(x, y).data(), 4, row + x * 4); + } + return image; +} + +std::vector<int> pixel(const QImage &image, int x, int y) +{ + const uchar *at = image.constScanLine(y) + x * 4; + return {at[0], at[1], at[2], at[3]}; +} + +bool within(const std::vector<int> &pixel, const std::array<uchar, 4> &expected, int tolerance) +{ + for (size_t i = 0; i < 4; ++i) { + if (std::abs(pixel[i] - expected[i]) > tolerance) + return false; + } + return true; +} + +// Swift's fixture: a blue field with a red object, selected. +std::unique_ptr<EditorSession> fixture() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(64, 48, true); + const QImage image = picture(64, 48, [](int x, int y) { return x >= 20 && x < 32 && y >= 16 && y < 26 ? red : blue; }); + session->insert(ImportedImage(image, image, "Object")); + session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); + return session; +} + +bool committed(EditorSession &session) +{ + bool done = false; + session.commitFilter([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 20000); +} + +bool prepared(EditorSession &session) +{ + return QTest::qWaitFor([&] { return session.filterEdit().has_value() && !session.filterEdit().value().preparing; }, 20000); +} + +QImage active(const EditorSession &session) +{ + return session.activeLayer().value().asset.value().image(); +} +} + +class SmartEditTests : public QObject { + Q_OBJECT +private slots: + void fillContinuesRepeatingTexture(); + void fillReconstructsBackgroundInsideSelectionAndUndoes(); + void cancelAndNoSourceLeaveOriginalUntouched(); + void theFillGrowsTheLayerOverASelectionPastItsEdge(); + void aSolidPlacedOrDisabledMaskFollowsSwift(); + void growthKeepsTheLayersTurn(); + void commitsThatWaitForThePreviewEachResume(); + void aSoftEdgeBlendsTheFillInByItsCoverage(); + void theFillRefusesWhatSwiftRefuses(); + void aLayerChangedMeanwhileTakesNoResult(); + void filterCallsAnnounceAndRefuseInSilence(); + void theCanvasCounterMovesWhereSwiftsDoes(); +}; + +void SmartEditTests::fillContinuesRepeatingTexture() +{ + EditorSession session; + session.createDocument(80, 64, true); + // Stripes four wide, a red block over them. + const QImage image = picture(80, 64, [](int x, int y) { + if (x >= 32 && x < 44 && y >= 26 && y < 36) + return red; + const uchar value = (x / 4) % 2 == 0 ? 51 : 204; + return std::array<uchar, 4>{value, value, value, 255}; + }); + session.insert(ImportedImage(image, image, "Stripes")); + session.applySelection(rectPath(QRectF(32, 26, 12, 10)), SelectionMode::replace, "Select"); + session.beginFilter(FilterKind::contentAwareFill); + QVERIFY(committed(session)); + const QImage result = active(session); + int matching = 0; + for (int y = 26; y < 36; ++y) { + for (int x = 32; x < 44; ++x) + matching += std::abs(pixel(result, x, y)[0] - ((x / 4) % 2 == 0 ? 51 : 204)) <= 1; + } + QVERIFY2(matching >= 114, qPrintable(QStringLiteral("Matched %1 of 120 texture pixels").arg(matching))); +} + +void SmartEditTests::fillReconstructsBackgroundInsideSelectionAndUndoes() +{ + const auto session = fixture(); + const ImageIdentity original = session->activeLayer().value().asset.value().identity(); + const int count = session->history.undoCount(); + QVERIFY(session->canContentAwareFill()); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(session->filterEdit().has_value() && session->filterEdit().value().preparing); + QVERIFY(!session->filterEdit().value().grownTransform.has_value()); + QVERIFY(committed(*session)); + QVERIFY(!session->filterEdit().has_value() && !session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), count + 1); + QCOMPARE(session->history.undoName(), QString("Content-Aware Fill")); + const QImage image = active(*session); + QCOMPARE(image.size(), QSize(64, 48)); + for (int y = 0; y < 48; ++y) { + for (int x = 0; x < 64; ++x) + QVERIFY2(within(pixel(image, x, y), blue, 1), qPrintable(QStringLiteral("pixel %1,%2").arg(x).arg(y))); + } + QCOMPARE(session->activeLayer().value().name, QString("Object")); + session->undo(); + QCOMPARE(session->activeLayer().value().asset.value().identity(), original); +} + +void SmartEditTests::cancelAndNoSourceLeaveOriginalUntouched() +{ + const auto session = fixture(); + const ImageIdentity original = session->activeLayer().value().asset.value().identity(); + session->beginFilter(FilterKind::contentAwareFill); + session->cancelFilter(); + QVERIFY(!session->filterEdit().has_value()); + QCOMPARE(session->activeLayer().value().asset.value().identity(), original); + // Everything selected: no pixel to copy from. + session->selectAll(); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session)); + QCOMPARE(session->filterEdit().value().previewError, + std::optional(QString("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image."))); + QVERIFY(!session->filterEdit().value().previewImage(session->activeLayerID().value()).has_value()); + QVERIFY(committed(*session)); + QVERIFY(session->filterEdit().has_value()); + QCOMPARE(session->activeLayer().value().asset.value().identity(), original); + session->cancelFilter(); + session->deselect(); + QVERIFY(!session->canContentAwareFill()); +} + +void SmartEditTests::theFillGrowsTheLayerOverASelectionPastItsEdge() +{ + EditorSession session; + session.createDocument(60, 40, true); + const QImage image = picture(20, 20, [](int, int) { return blue; }); + session.insert(ImportedImage(image, image, "Blue"), QPointF(20, 20)); + const QUuid id = session.activeLayerID().value(); + QCOMPARE(session.activeLayer().value().transform.origin, QPointF(10, 10)); + // White edges, a black block inside: the pixels must carry. + QImage drawn = BrushRaster::context(20, 20, true); + drawn.fill(Qt::white); + for (int y = 5; y < 15; ++y) + std::fill_n(drawn.scanLine(y) + 5, 10, uchar(0)); + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(drawn)); }); + session.selectLayer(id); + // Past the top left corner: 30 by 25 at (0,5). + session.applySelection(rectPath(QRectF(-10, 5, 30, 10)), SelectionMode::replace, "Select"); + session.beginFilter(FilterKind::contentAwareFill); + const FilterEdit &edit = session.filterEdit().value(); + QCOMPARE(edit.grownTransform, std::optional(LayerTransform{.origin = {0, 5}, .size = {30, 25}})); + QCOMPARE(edit.grownImage.value().size(), QSize(30, 25)); + QCOMPARE(pixel(edit.grownImage.value(), 5, 3)[3], 0); + QVERIFY(within(pixel(edit.grownImage.value(), 15, 10), blue, 0)); + // The preview sits on the grown grid, the mask stays. + const ImageLayer layer = session.activeLayer().value(); + QCOMPARE(session.displayedTransform(layer), layer.transform); + QVERIFY(prepared(session)); + QCOMPARE(session.displayedTransform(layer), edit.grownTransform.value()); + QCOMPARE(session.displayedMaskPlacement(layer), std::optional(layer.transform)); + QVERIFY(within(pixel(edit.previewImage(id).value(), 5, 3), blue, 1)); + QVERIFY(!edit.previewImage(QUuid::createUuid()).has_value()); + session.updateFilter(FilterSettings(), false); + QCOMPARE(session.displayedTransform(layer), layer.transform); + QVERIFY(!session.displayedMaskPlacement(layer).has_value()); + session.updateFilter(FilterSettings(), true); + QVERIFY(committed(session)); + const ImageLayer grown = session.activeLayer().value(); + QCOMPARE(grown.transform, (LayerTransform{.origin = {0, 5}, .size = {30, 25}})); + QCOMPARE(grown.asset.value().size(), QSize(30, 25)); + QVERIFY(within(pixel(grown.asset.value().image(), 5, 3), blue, 1)); + QCOMPARE(pixel(grown.asset.value().image(), 5, 22)[3], 0); + // The mask covers the new grid, white past the edge. + const QImage mask = grown.mask.value().asset.image(); + QCOMPARE(mask.size(), QSize(30, 25)); + QCOMPARE(int(mask.constScanLine(3)[5]), 255); + QCOMPARE(int(mask.constScanLine(13)[18]), 0); + QCOMPARE(int(mask.constScanLine(7)[12]), 255); + QVERIFY(!grown.mask.value().placement.has_value()); + session.undo(); + QCOMPARE(session.activeLayer().value().transform.size, QSizeF(20, 20)); +} + +void SmartEditTests::aSolidPlacedOrDisabledMaskFollowsSwift() +{ + EditorSession session; + session.createDocument(60, 40, true); + const QImage image = picture(20, 20, [](int, int) { return blue; }); + session.insert(ImportedImage(image, image, "Blue"), QPointF(20, 20)); + const QUuid id = session.activeLayerID().value(); + const auto fill = [&] { + session.selectLayerTarget(id, false); + session.applySelection(rectPath(QRectF(25, 15, 20, 10)), SelectionMode::replace, "Select"); + session.beginFilter(FilterKind::contentAwareFill); + return committed(session); + }; + // A solid mask is left as it is. + session.addMask(true); + QVERIFY(fill()); + QCOMPARE(session.activeLayer().value().asset.value().size(), QSize(35, 20)); + QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(1, 1)); + session.undo(); + // Empty selection: nothing to fill. + QImage white = BrushRaster::context(20, 20, true); + white.fill(Qt::white); + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(white)); + record(snapshot, id).maskPlacement = LayerTransform{.origin = {40, 0}, .size = {20, 20}}; + record(snapshot, id).maskLinked = false; + }); + QVERIFY(fill()); + QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(20, 20)); + QCOMPARE(session.activeLayer().value().mask.value().placement, std::optional(LayerTransform{.origin = {40, 0}, .size = {20, 20}})); + session.undo(); + rewrite(session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, LayerMask::assetFrom(white)); + record(snapshot, id).maskPlacement = std::nullopt; + record(snapshot, id).maskLinked = std::nullopt; + record(snapshot, id).maskEnabled = false; + }); + QVERIFY(fill()); + QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(35, 20)); + QVERIFY(!session.activeLayer().value().mask.value().isEnabled); +} + +void SmartEditTests::growthKeepsTheLayersTurn() +{ + EditorSession session; + session.createDocument(60, 40, true); + // A red block at pixel (2,2) on a turned layer. + const QImage image = picture(20, 20, [](int x, int y) { return x >= 2 && x < 5 && y >= 2 && y < 5 ? red : blue; }); + session.insert(ImportedImage(image, image, "Turned"), QPointF(20, 20)); + const QUuid id = session.activeLayerID().value(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 90; }); + session.selectLayer(id); + const LayerTransform before = session.activeLayer().value().transform; + const QPointF redAt = BrushRaster::pixelToDocument(before, 20, 20).map(QPointF(3.5, 3.5)); + session.applySelection(rectPath(QRectF(25, 15, 60, 10)), SelectionMode::replace, "Select"); + session.beginFilter(FilterKind::contentAwareFill); + const FilterEdit &edit = session.filterEdit().value(); + QCOMPARE(edit.grownTransform.value().rotation, 90.0); + QVERIFY(committed(session)); + const ImageLayer grown = session.activeLayer().value(); + QCOMPARE(grown.transform.rotation, 90.0); + QVERIFY(grown.transform.size.width() > 20 || grown.transform.size.height() > 20); + // The block sits where it sat on the document. + const QSize size = grown.asset.value().size(); + const QPointF pixel = BrushRaster::pixelToDocument(grown.transform, size.width(), size.height()).inverted().map(redAt); + QVERIFY(within(::pixel(grown.asset.value().image(), int(std::floor(pixel.x())), int(std::floor(pixel.y()))), red, 0)); +} + +void SmartEditTests::commitsThatWaitForThePreviewEachResume() +{ + const auto session = fixture(); + const int count = session->history.undoCount(); + // Two wait; the first commits, the second finds it committing. + session->beginFilter(FilterKind::contentAwareFill); + bool first = false, second = false; + session->commitFilter([&] { first = true; }); + session->commitFilter([&] { second = true; }); + QTRY_VERIFY(first && second); + QCOMPARE(session->history.undoCount(), count + 1); + QVERIFY(!session->filterEdit().has_value()); + session->undo(); + // A waiter whose edit was cancelled returns without applying. + session->beginFilter(FilterKind::contentAwareFill); + bool third = false; + session->commitFilter([&] { third = true; }); + session->cancelFilter(); + session->beginFilter(FilterKind::contentAwareFill); + QTRY_VERIFY(third); + QVERIFY(session->filterEdit().has_value() && !session->filterEdit().value().committing); + QCOMPARE(session->history.undoCount(), count); + QVERIFY(prepared(*session)); + QVERIFY(session->filterEdit().has_value()); + session->cancelFilter(); + // Done but undelivered: it holds the next job, and commits. + session->beginFilter(FilterKind::contentAwareFill); + QThreadPool::globalInstance()->waitForDone(); + QVERIFY(session->filterEdit().value().preparing); + session->updateFilter(FilterSettings(), true); + QVERIFY(session->filterEdit().value().pending.has_value()); + bool fourth = false; + session->commitFilter([&] { fourth = true; }); + QTRY_VERIFY(fourth); + QCOMPARE(session->history.undoCount(), count + 1); + QVERIFY(!session->filterEdit().has_value()); + session->undo(); + // Preview off while the job waits: it still runs. + QThreadPool &pool = *QThreadPool::globalInstance(); + const int threads = pool.maxThreadCount(); + pool.setMaxThreadCount(1); + QSemaphore gate; + const QFuture<void> held = QtConcurrent::run([&] { gate.acquire(); }); + session->beginFilter(FilterKind::contentAwareFill); + session->cancelFilter(); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(session->filterEdit().value().pending.has_value()); + session->updateFilter(FilterSettings(), false); + QVERIFY(session->filterEdit().value().pending.has_value() && session->filterEdit().value().preparing); + gate.release(); + pool.setMaxThreadCount(threads); + QVERIFY(prepared(*session)); + QVERIFY(!session->filterEdit().value().previewError.has_value()); + QVERIFY(!session->filterEdit().value().previewImage(session->activeLayerID().value()).has_value()); + bool fifth = false; + session->commitFilter([&] { fifth = true; }); + QTRY_VERIFY(fifth); + QCOMPARE(session->history.undoCount(), count + 1); +} + +void SmartEditTests::aSoftEdgeBlendsTheFillInByItsCoverage() +{ + const auto session = fixture(); + // Half a pixel in: the left column is half filled. + session->applySelection(rectPath(QRectF(20.5, 16, 11.5, 10)), SelectionMode::replace, "Select"); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(committed(*session)); + const QImage image = active(*session); + QVERIFY(within(pixel(image, 25, 20), blue, 1)); + QVERIFY(within(pixel(image, 20, 20), {153, 77, 102, 255}, 2)); + QVERIFY(within(pixel(image, 19, 20), blue, 1)); +} + +void SmartEditTests::theFillRefusesWhatSwiftRefuses() +{ + const auto session = fixture(); + const QImage image = active(*session); + QVERIFY_THROWS_EXCEPTION(ContentFillError, ContentFill::run(FilterJob{FilterKind::contentAwareFill, image, FilterSettings(), 1, std::nullopt, QTransform()})); + QCOMPARE(QString::fromUtf8(ContentFillError(ContentFillError::Kind::noSource).what()), + QString("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image.")); + QCOMPARE(rawValue(FilterKind::contentAwareFill), QString("Content-Aware Fill")); + QVERIFY(isAutomatic(FilterKind::contentAwareFill)); + // A mask target, a hidden layer, a busy project: no. + const QUuid id = session->activeLayerID().value(); + session->addMask(true); + session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); + session->selectLayerTarget(id, true); + QVERIFY(!session->canContentAwareFill()); + session->selectLayerTarget(id, false); + QVERIFY(session->canContentAwareFill()); + // An empty selection has nothing to fill. + session->applySelection(rectPath(QRectF(0, 0, 64, 48)), SelectionMode::subtract, "Subtract"); + QVERIFY(session->selection().value().isEmpty() && !session->canContentAwareFill()); + session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); + QVERIFY(session->canContentAwareFill()); + session->toggleLayerVisibility(id); + QVERIFY(!session->canContentAwareFill()); + session->toggleLayerVisibility(id); + session->setIsProjectBusy(true); + QVERIFY(!session->canContentAwareFill()); + session->setIsProjectBusy(false); + // A blank layer and a folder have no pixels. + session->addBlankLayer(); + QVERIFY(!session->canContentAwareFill()); + session->groupSelectedLayers(); + QVERIFY(session->activeLayer().value().isGroup && !session->canContentAwareFill()); + session->selectLayer(id); + QVERIFY(session->canContentAwareFill()); + // Beginning drops an open outline and commits a transform. + session->selectTool(NavigationTool::lasso); + session->setLassoKind(LassoKind::polygonal); + session->beginLasso(QPointF(1, 1), SelectionMode::add); + QVERIFY(session->lassoDraft().has_value()); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(session->filterEdit().has_value() && !session->lassoDraft().has_value()); + session->cancelFilter(); + session->selectTool(NavigationTool::move); + session->beginTransform(); + session->previewTransform(LayerTransform{.origin = {1, 0}, .size = {64, 48}}); + QVERIFY(session->transformEdit().has_value()); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(session->filterEdit().has_value() && !session->transformEdit().has_value()); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(1, 0)); + session->cancelFilter(); + // Open edit: the layers close, a second begin is refused. + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(session->filterEdit().has_value() && !session->canEditLayers() && !session->canContentAwareFill()); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session)); + session->cancelFilter(); + QVERIFY(session->canEditLayers()); +} + +void SmartEditTests::aLayerChangedMeanwhileTakesNoResult() +{ + const auto session = fixture(); + const QUuid id = session->activeLayerID().value(); + const ImageIdentity original = session->activeLayer().value().asset.value().identity(); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session)); + // Moved meanwhile: the result has no layer to land on. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = QPointF(1, 1); }); + QVERIFY(session->filterEdit().has_value()); + QVERIFY(committed(*session)); + QVERIFY(!session->filterEdit().has_value() && !session->isProjectBusy()); + QCOMPARE(session->history.undoCount(), 0); + QCOMPARE(session->activeLayer().value().asset.value().identity(), original); + QCOMPARE(session->activeLayer().value().transform.origin, QPointF(1, 1)); + // Other pixels meanwhile: the same. + session->applySelection(rectPath(QRectF(21, 17, 12, 10)), SelectionMode::replace, "Select"); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session)); + const QImage other = picture(64, 48, [](int, int) { return red; }); + rewrite(*session, [&](ProjectSnapshot &snapshot) { snapshot.images.insert_or_assign(id, ImportedImage(other, other, "Red")); }); + QVERIFY(committed(*session)); + QVERIFY(!session->filterEdit().has_value()); + QCOMPARE(session->history.undoCount(), 0); + QVERIFY(within(pixel(active(*session), 25, 20), red, 0)); +} + +void SmartEditTests::filterCallsAnnounceAndRefuseInSilence() +{ + const auto session = fixture(); + QSignalSpy changes(session.get(), &EditorSession::changed); + const auto announced = [&](const std::function<void()> &change) { + const qsizetype before = changes.count(); + change(); + return changes.count() > before; + }; + QVERIFY(!announced([&] { session->updateFilter(FilterSettings(), false); })); + QVERIFY(!announced([&] { session->cancelFilter(); })); + QVERIFY(!announced([&] { session->commitFilter(); })); + QVERIFY(announced([&] { session->beginFilter(FilterKind::contentAwareFill); })); + QVERIFY(!announced([&] { session->beginFilter(FilterKind::contentAwareFill); })); + QVERIFY(announced([&] { session->updateFilter(FilterSettings(), false); })); + QVERIFY(announced([&] { session->cancelFilter(); })); + QVERIFY(!announced([&] { session->cancelFilter(); })); + // The last signal of a change sees what it leaves. + QStringList seen; + connect(session.get(), &EditorSession::changed, session.get(), [&] { seen = described(*session); }); + session->beginFilter(FilterKind::contentAwareFill); + QCOMPARE(seen, described(*session)); + QVERIFY(prepared(*session)); + QCOMPARE(seen, described(*session)); + session->updateFilter(FilterSettings(), false); + QCOMPARE(seen, described(*session)); + QVERIFY(committed(*session)); + QCOMPARE(seen, described(*session)); + QVERIFY(!session->filterEdit().has_value()); +} + +void SmartEditTests::theCanvasCounterMovesWhereSwiftsDoes() +{ + const auto session = fixture(); + const int revision = session->brushRevision(); + session->beginFilter(FilterKind::contentAwareFill); + QCOMPARE(session->brushRevision(), revision); + // Preview off while the fill prepares; then the delivery. + session->updateFilter(FilterSettings(), false); + QCOMPARE(session->brushRevision(), revision + 1); + QVERIFY(prepared(*session)); + QCOMPARE(session->brushRevision(), revision + 2); + // A made fill shows again at once. + session->updateFilter(FilterSettings(), true); + QCOMPARE(session->brushRevision(), revision + 3); + session->cancelFilter(); + QCOMPARE(session->brushRevision(), revision + 4); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session)); + QVERIFY(committed(*session)); + QCOMPARE(session->brushRevision(), revision + 6); + // A refused commit leaves it. + session->selectAll(); + session->beginFilter(FilterKind::contentAwareFill); + QVERIFY(prepared(*session) && session->filterEdit().value().previewError.has_value()); + const int failed = session->brushRevision(); + QVERIFY(committed(*session)); + QCOMPARE(session->brushRevision(), failed); +} + +QTEST_GUILESS_MAIN(SmartEditTests) +#include "SmartEditTests.moc" diff --git a/tests/SmearCanvasTests.cpp b/tests/SmearCanvasTests.cpp new file mode 100644 index 0000000..e11eab3 --- /dev/null +++ b/tests/SmearCanvasTests.cpp @@ -0,0 +1,138 @@ +#include "BrushCanvasFixtures.h" + +// The Smear on the canvas: copy, holds, Escape. +namespace { +// Red left of x 200, blue right; Liquify chosen. +QUuid scene(Canvas &shown) +{ + QImage image = BrushRaster::context(400, 300, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, 200, 300), QColor(255, 0, 0)); + painter.fillRect(QRect(200, 0, 200, 300), QColor(0, 0, 255)); + painter.end(); + shown.session.insert(ImportedImage(image, image, QStringLiteral("Scene"))); + shown.session.selectTool(NavigationTool::blur); + shown.session.setBrushSettings(BrushSettings{.diameter = 30, .hardness = 0.5}); + shown.canvas->synchronizeDisplay(); + return shown.session.activeLayerID().value(); +} + +QColor shownAt(Canvas &shown, int x, int y) +{ + return shown.canvas->grab().toImage().pixelColor(x, y); +} +} + +class SmearCanvasTests : public QObject { + Q_OBJECT +private slots: + void aLiquifyDragShowsTheCopyThenCommits(); + void escapeCancelsAWarp(); + void aWarpHoldsTheViewAndTheTip(); + void theCopyShowsThroughTheLayersMask(); +}; + +void SmearCanvasTests::aLiquifyDragShowsTheCopyThenCommits() +{ + Canvas shown; + scene(shown); + shown.press(QPointF(180, 150)); + QVERIFY(shown.session.warpStroke()); + shown.move(QPointF(230, 150)); + shown.canvas->synchronizeDisplay(); + // The canvas shows red pushed right; nothing is committed yet. + QCOMPARE(shownAt(shown, 210, 150), QColor(255, 0, 0)); + QCOMPARE(pixel(shown.session, 210, 150), (std::vector<int>{0, 0, 255, 255})); + shown.release(QPointF(230, 150)); + QVERIFY(!shown.session.warpStroke()); + QCOMPARE(pixel(shown.session, 210, 150), (std::vector<int>{255, 0, 0, 255})); + QCOMPARE(shown.session.history.undoName(), QString("Liquify")); + QCOMPARE(shownAt(shown, 210, 150), QColor(255, 0, 0)); + QCOMPARE(shownAt(shown, 210, 40), QColor(0, 0, 255)); +} + +void SmearCanvasTests::escapeCancelsAWarp() +{ + Canvas shown; + scene(shown); + shown.canvas->setFocus(); + QTRY_VERIFY(shown.canvas->hasFocus()); + const int steps = shown.session.history.undoCount(); + shown.press(QPointF(180, 150)); + shown.move(QPointF(230, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!shown.session.warpStroke()); + shown.release(QPointF(230, 150)); + QCOMPARE(shown.session.history.undoCount(), steps); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shownAt(shown, 210, 150), QColor(0, 0, 255)); + // Other keys wait while a warp lasts. + shown.press(QPointF(180, 150)); + QTest::keyClick(shown.canvas, Qt::Key_B); + QCOMPARE(shown.session.tool(), NavigationTool::blur); + shown.release(QPointF(180, 150)); +} + +void SmearCanvasTests::aWarpHoldsTheViewAndTheTip() +{ + Canvas shown; + scene(shown); + shown.press(QPointF(180, 150)); + const CanvasViewport before = shown.session.viewport; + wheel(*shown.canvas, QPointF(180, 150), QPoint(0, 40), QPoint(0, 120), Qt::NoModifier); + QCOMPARE(shown.session.viewport.zoom(), before.zoom()); + QCOMPARE(shown.session.viewport.documentPoint(QPointF(0, 0), shown.documentSize()), before.documentPoint(QPointF(0, 0), shown.documentSize())); + QNativeGestureEvent pinch(Qt::ZoomNativeGesture, QPointingDevice::primaryPointingDevice(), 2, QPointF(180, 150), QPointF(180, 150), + shown.canvas->mapToGlobal(QPointF(180, 150)), 0.5, QPointF(), 0); + QApplication::sendEvent(shown.canvas, &pinch); + QCOMPARE(shown.session.viewport.zoom(), before.zoom()); + // A right press sizes no tip mid-warp. + QTest::mousePress(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(180, 150)); + QMouseEvent drag(QEvent::MouseMove, QPointF(260, 150), QPointF(260, 150), shown.canvas->mapToGlobal(QPoint(260, 150)), Qt::NoButton, + Qt::LeftButton | Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(shown.canvas, &drag); + QCOMPARE(shown.session.brushSettings().diameter, 30.0); + QTest::mouseRelease(shown.canvas, Qt::RightButton, Qt::NoModifier, QPoint(260, 150)); + shown.release(QPointF(260, 150)); + QVERIFY(!shown.session.warpStroke()); +} + +void SmearCanvasTests::theCopyShowsThroughTheLayersMask() +{ + Canvas shown; + const QUuid id = scene(shown); + // The mask hides the lower half, during the warp too. + QImage mask(400, 300, QImage::Format_Grayscale8); + mask.fill(255); + for (int y = 200; y < 300; ++y) + std::fill_n(mask.scanLine(y), 400, uchar(0)); + rewrite(shown.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); }); + // Installing fits the view; back to one pixel a point. + shown.session.zoom(1); + shown.session.selectTool(NavigationTool::blur); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shown.session.viewport.viewPoint(QPointF(300, 40), shown.documentSize()), QPointF(300, 40)); + const QColor ground = shownAt(shown, 210, 250); + shown.press(QPointF(180, 150)); + shown.move(QPointF(230, 150)); + shown.move(QPointF(230, 250)); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shownAt(shown, 210, 150), QColor(255, 0, 0)); + QCOMPARE(shownAt(shown, 210, 250), ground); + shown.release(QPointF(230, 250)); + // At half opacity the copy stays half, where nothing moved. + shown.session.selectTool(NavigationTool::blur); + shown.session.setLayerOpacity(0.5); + shown.canvas->synchronizeDisplay(); + const QColor faint = shownAt(shown, 300, 40); + QVERIFY(faint != QColor(0, 0, 255)); + shown.press(QPointF(180, 150)); + QVERIFY(shown.session.warpStroke()); + shown.move(QPointF(230, 150)); + shown.canvas->synchronizeDisplay(); + QCOMPARE(shownAt(shown, 300, 40), faint); + shown.release(QPointF(230, 150)); +} + +QTEST_MAIN(SmearCanvasTests) +#include "SmearCanvasTests.moc" diff --git a/tests/SmudgeLiquifyTests.cpp b/tests/SmudgeLiquifyTests.cpp new file mode 100644 index 0000000..2bae9c8 --- /dev/null +++ b/tests/SmudgeLiquifyTests.cpp @@ -0,0 +1,403 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include <cmath> + +// The Smear's warps against Swift's own arithmetic, replayed here. +namespace { +// Swift's WarpStroke, written again from SmudgeLiquify.swift. +struct Oracle { + std::vector<uchar> pixels; + int width, height; + double diameter, hardness, strength; + bool smudging; + std::vector<float> carried, scratch; + std::optional<QPointF> last; + + int radius() const { return int(std::ceil(diameter / 2)); } + float weight(float u) const + { + if (!(u < 1)) + return 0; + const float h = float(hardness); + if (!(u > h)) + return 1; + const float t = (1 - u) / (1 - h); + return t * t * (3 - 2 * t); + } + void pickUp(QPointF c) + { + const int r = radius(), side = 2 * r + 1; + carried.assign(size_t(side * side * 4), 0); + const int cx = int(std::round(c.x())), cy = int(std::round(c.y())); + for (int dy = -r; dy <= r; ++dy) + for (int dx = -r; dx <= r; ++dx) { + const int y = cy + dy, x = cx + dx; + if (y < 0 || y >= height || x < 0 || x >= width) + continue; + for (int k = 0; k < 4; ++k) + carried[size_t(((dy + r) * side + dx + r) * 4 + k)] = float(pixels[size_t((y * width + x) * 4 + k)]); + } + } + void smudge(QPointF c) + { + const int r = radius(), side = 2 * r + 1; + const int cx = int(std::round(c.x())), cy = int(std::round(c.y())); + const float keep = float(strength), invR = 1 / float(diameter / 2); + for (int dy = -r; dy <= r; ++dy) + for (int dx = -r; dx <= r; ++dx) { + const int y = cy + dy, x = cx + dx; + if (y < 0 || y >= height || x < 0 || x >= width) + continue; + const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR); + if (!(w > 0)) + continue; + for (int k = 0; k < 4; ++k) { + uchar &p = pixels[size_t((y * width + x) * 4 + k)]; + float &carry = carried[size_t(((dy + r) * side + dx + r) * 4 + k)]; + const float under = float(p), painted = under + (carry - under) * w; + p = uchar(std::max(0.0f, std::min(255.0f, std::round(painted)))); + carry = painted + (carry - painted) * keep; + } + } + } + void push(QPointF a, QPointF b) + { + const int r = radius(); + const float mx = float(b.x() - a.x()) * float(strength), my = float(b.y() - a.y()) * float(strength); + const int margin = int(std::ceil(std::max(std::abs(mx), std::abs(my)))) + 2; + const int cx = int(std::round(b.x())), cy = int(std::round(b.y())); + const int x0 = std::max(0, cx - r - margin), x1 = std::min(width - 1, cx + r + margin); + const int y0 = std::max(0, cy - r - margin), y1 = std::min(height - 1, cy + r + margin); + if (x0 > x1 || y0 > y1) + return; + const int cw = x1 - x0 + 1, ch = y1 - y0 + 1; + scratch.assign(size_t(cw * ch * 4), 0); + for (int y = 0; y < ch; ++y) + for (int x = 0; x < cw; ++x) + for (int k = 0; k < 4; ++k) + scratch[size_t((y * cw + x) * 4 + k)] = float(pixels[size_t(((y + y0) * width + x + x0) * 4 + k)]); + const float invR = 1 / float(diameter / 2); + for (int dy = -r; dy <= r; ++dy) + for (int dx = -r; dx <= r; ++dx) { + const int y = cy + dy, x = cx + dx; + if (y < y0 || y > y1 || x < x0 || x > x1) + continue; + const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR); + if (!(w > 0)) + continue; + const float sx = std::min(float(cw - 1), std::max(0.0f, float(x - x0) - mx * w)); + const float sy = std::min(float(ch - 1), std::max(0.0f, float(y - y0) - my * w)); + const int ix = std::min(cw - 2, int(sx)), iy = std::min(ch - 2, int(sy)); + if (ix < 0 || iy < 0) + continue; + const float fx = sx - float(ix), fy = sy - float(iy); + const auto at = [&](int ox, int oy, int k) { return scratch[size_t(((iy + oy) * cw + ix + ox) * 4 + k)]; }; + for (int k = 0; k < 4; ++k) { + const float top = at(0, 0, k) + (at(1, 0, k) - at(0, 0, k)) * fx; + const float bottom = at(0, 1, k) + (at(1, 1, k) - at(0, 1, k)) * fx; + pixels[size_t((y * width + x) * 4 + k)] = uchar(std::max(0.0f, std::min(255.0f, std::round(top + (bottom - top) * fy)))); + } + } + } + void append(QPointF point) + { + if (!last) { + last = point; + if (smudging) + pickUp(point); + return; + } + const QPointF from = *last; + const double distance = std::hypot(point.x() - from.x(), point.y() - from.y()); + const double spacing = std::max(1.0, diameter * (smudging ? 0.08 : 0.025)); + if (distance < spacing) + return; + const int steps = int(std::ceil(distance / spacing)); + QPointF previous = from; + for (int step = 1; step <= steps; ++step) { + const double t = double(step) / double(steps); + const QPointF next(from.x() + (point.x() - from.x()) * t, from.y() + (point.y() - from.y()) * t); + if (smudging) + smudge(next); + else + push(previous, next); + previous = next; + } + last = point; + } +}; + +// Red left, blue right, a green block below. +QImage scene(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + QPainter painter(&image); + painter.fillRect(QRect(0, 0, width / 2, height), QColor(255, 0, 0)); + painter.fillRect(QRect(width / 2, 0, width - width / 2, height), QColor(0, 0, 255)); + painter.fillRect(QRect(width / 4, height * 3 / 4, width / 2, height / 4), QColor(0, 160, 0, 200)); + return image; +} + +Oracle oracle(const QImage &image, const BrushSettings &settings, bool smudging) +{ + std::vector<uchar> bytes(size_t(image.width() * image.height() * 4)); + for (int y = 0; y < image.height(); ++y) + std::memcpy(bytes.data() + size_t(y * image.width() * 4), image.constScanLine(y), size_t(image.width() * 4)); + return Oracle{bytes, image.width(), image.height(), std::max(2.0, settings.diameter), std::min(0.98, std::max(0.0, settings.hardness)), + std::min(1.0, std::max(0.01, settings.opacity)), smudging, {}, {}, std::nullopt}; +} + +// Every byte the stroke made, against the oracle's. +int differences(const WarpStroke &stroke, const Oracle &expected) +{ + int found = 0; + for (int y = 0; y < stroke.height; ++y) + found += std::memcmp(stroke.image().constScanLine(y), expected.pixels.data() + size_t(y * stroke.width * 4), size_t(stroke.width * 4)) != 0; + return found; +} + +std::vector<int> exported(const EditorSession &session, int x, int y) +{ + return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y); +} + +// The scene as one layer, the Smear chosen in `mode`. +void smearing(EditorSession &session, BlurToolMode mode, const BrushSettings &settings) +{ + session.createDocument(80, 40); + const QImage image = scene(80, 40); + session.insert(ImportedImage(image, image, QStringLiteral("Scene"))); + session.selectTool(NavigationTool::blur); + session.setBlurMode(mode); + session.setBrushSettings(settings); +} +} + +class SmudgeLiquifyTests : public QObject { + Q_OBJECT +private slots: + void liquifyAndSmudgeFollowSwiftsArithmetic_data(); + void liquifyAndSmudgeFollowSwiftsArithmetic(); + void theSettingsAreClampedAsSwiftClampsThem(); + void liquifyCommitsAlongThePathAsOneStep(); + void theCommitLandsAllTheWarpMoved(); + void smudgeCarriesColourAndNamesItsStep(); + void aWarpReplacesSoClearPixelsClear(); + void aMaskIsNeverWarped(); + void aWarpHoldsTheSessionUntilItEnds(); + void aLayerChangedUnderTheWarpTakesNothing(); + void tabStepsTheSmearsModes(); +}; + +void SmudgeLiquifyTests::liquifyAndSmudgeFollowSwiftsArithmetic_data() +{ + QTest::addColumn<bool>("smudging"); + QTest::addColumn<double>("diameter"); + QTest::addColumn<double>("hardness"); + QTest::addColumn<double>("strength"); + QTest::newRow("liquify") << false << 20.0 << 0.5 << 1.0; + QTest::newRow("liquify weak soft") << false << 13.0 << 0.0 << 0.35; + QTest::newRow("liquify wide") << false << 60.0 << 0.3 << 0.8; + QTest::newRow("smudge") << true << 20.0 << 0.5 << 1.0; + QTest::newRow("smudge weak hard") << true << 9.0 << 0.9 << 0.2; +} + +void SmudgeLiquifyTests::liquifyAndSmudgeFollowSwiftsArithmetic() +{ + QFETCH(bool, smudging); + QFETCH(double, diameter); + QFETCH(double, hardness); + QFETCH(double, strength); + const QImage image = scene(80, 40); + const BrushSettings settings = brush(diameter, hardness, 0, 0, 0, strength); + WarpStroke stroke(ImageLayer(ImportedImage(image, image, QStringLiteral("Scene")), QPointF(0, 0)), image, + ImageLayer(ImportedImage(image, image, QStringLiteral("Scene")), QPointF(0, 0)).transform, QSizeF(80, 40), + smudging ? BlurToolMode::smudge : BlurToolMode::liquify, settings); + Oracle expected = oracle(image, settings, smudging); + // Along, back across, off the canvas and in again. + for (const QPointF point : {QPointF(30.6, 20.2), QPointF(47.7, 23.1), QPointF(47.9, 23.3), QPointF(48.4, 23.3), QPointF(20, 31.5), QPointF(-6, 37), + QPointF(12, 9.5)}) { + stroke.append(point); + expected.append(point); + QCOMPARE(differences(stroke, expected), 0); + } + QVERIFY(stroke.image() != image); + QVERIFY(stroke.points().size() > 10); +} + +void SmudgeLiquifyTests::theSettingsAreClampedAsSwiftClampsThem() +{ + const QImage image = scene(20, 20); + const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Scene")), QPointF(0, 0)); + const WarpStroke low(layer, image, layer.transform, QSizeF(20, 20), BlurToolMode::liquify, brush(1, -1, 0, 0, 0, 0)); + QCOMPARE(low.diameter, 2.0); + QCOMPARE(low.hardness, 0.0); + QCOMPARE(low.strength, 0.01); + const WarpStroke high(layer, image, layer.transform, QSizeF(20, 20), BlurToolMode::smudge, brush(300, 1, 0, 0, 0, 2)); + QCOMPARE(high.hardness, 0.98); + QCOMPARE(high.strength, 1.0); + QCOMPARE(high.width, 20); + QCOMPARE(high.image(), image); +} + +void SmudgeLiquifyTests::liquifyCommitsAlongThePathAsOneStep() +{ + EditorSession session; + smearing(session, BlurToolMode::liquify, BrushSettings{.diameter = 16, .hardness = 0.5}); + const int steps = session.history.undoCount(); + session.beginBrush(QPointF(30, 12)); + QVERIFY(session.warpStroke() && !session.brushStroke()); + session.continueBrush(QPointF(52, 12)); + // The copy shows the push before anything is committed. + const QImage live = session.warpStroke()->image(); + QCOMPARE(exported(session, 44, 12), (std::vector<int>{0, 0, 255, 255})); + QVERIFY(pixel(live, 44, 12)[0] > 200); + session.finishBrush(); + QVERIFY(!session.warpStroke()); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Liquify")); + QCOMPARE(exported(session, 44, 12), pixel(live, 44, 12)); + // Away from the path, nothing moved. + QCOMPARE(exported(session, 70, 30), (std::vector<int>{0, 0, 255, 255})); + QCOMPARE(exported(session, 5, 2), (std::vector<int>{255, 0, 0, 255})); + session.undo(); + QCOMPARE(exported(session, 44, 12), (std::vector<int>{0, 0, 255, 255})); +} + +void SmudgeLiquifyTests::theCommitLandsAllTheWarpMoved() +{ + // Dabs round past an off-grid path; short strokes show rims. + for (const auto &[from, to] : {std::pair(QPointF(24, 11.6), QPointF(56, 11.6)), std::pair(QPointF(37, 11.6), QPointF(41, 11.6))}) { + EditorSession session; + smearing(session, BlurToolMode::liquify, BrushSettings{.diameter = 16, .hardness = 0}); + session.beginBrush(from); + session.continueBrush(to); + const QImage live = session.warpStroke()->image().copy(); + session.finishBrush(); + const QImage landed = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + int apart = 0; + for (int y = 0; y < 40; ++y) + apart += std::memcmp(landed.constScanLine(y), live.constScanLine(y), 80 * 4) != 0; + QCOMPARE(apart, 0); + QVERIFY(pixel(live, 41, 19) != pixel(scene(80, 40), 41, 19)); + // Shift's line starts where the warp ended, on the pixels. + QCOMPARE(session.shiftLineStart().value(), to); + } +} + +void SmudgeLiquifyTests::smudgeCarriesColourAndNamesItsStep() +{ + EditorSession session; + smearing(session, BlurToolMode::smudge, BrushSettings{.diameter = 12, .hardness = 0, .opacity = 0.5}); + session.beginBrush(QPointF(30, 12)); + session.continueBrush(QPointF(50, 12)); + session.finishBrush(); + QCOMPARE(session.history.undoName(), QString("Smudge")); + // The core carries red all the way; its flanks fade. + QCOMPARE(exported(session, 49, 12), (std::vector<int>{255, 0, 0, 255})); + const std::vector<int> near = exported(session, 42, 15), far = exported(session, 49, 15); + QVERIFY2(near[0] > far[0] + 20 && far[0] > 0 && far[2] > 0, qPrintable(QString("%1 %2 %3").arg(near[0]).arg(far[0]).arg(far[2]))); + QCOMPARE(exported(session, 75, 35), (std::vector<int>{0, 0, 255, 255})); +} + +void SmudgeLiquifyTests::aWarpReplacesSoClearPixelsClear() +{ + EditorSession session; + session.createDocument(60, 20); + QImage half = BrushRaster::context(60, 20, false); + QPainter(&half).fillRect(QRect(30, 0, 30, 20), QColor(255, 0, 0)); + session.insert(ImportedImage(half, half, QStringLiteral("Half"))); + session.selectTool(NavigationTool::blur); + session.setBrushSettings(BrushSettings{.diameter = 16, .hardness = 0.5}); + // Clear pushed over red leaves the red clear there. + session.beginBrush(QPointF(22, 10)); + session.continueBrush(QPointF(42, 10)); + session.finishBrush(); + QVERIFY2(exported(session, 36, 10)[3] < 60, qPrintable(QString::number(exported(session, 36, 10)[3]))); + QCOMPARE(exported(session, 50, 2)[3], 255); +} + +void SmudgeLiquifyTests::aMaskIsNeverWarped() +{ + EditorSession session; + smearing(session, BlurToolMode::liquify, BrushSettings{.diameter = 16, .hardness = 0.5}); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + session.beginBrush(QPointF(30, 12)); + QVERIFY(!session.warpStroke() && !session.brushStroke()); + QCOMPARE(session.brushError().value(), QString("Smudge and Liquify work on a layer's pixels, not its mask.")); + // Blur still paints the mask, softened. + session.setBrushError(std::nullopt); + session.setBlurMode(BlurToolMode::blur); + session.beginBrush(QPointF(30, 12)); + QVERIFY(session.brushStroke() && session.brushStroke()->isMask && session.brushStroke()->isBlur); + session.cancelBrush(); +} + +void SmudgeLiquifyTests::aWarpHoldsTheSessionUntilItEnds() +{ + EditorSession session; + smearing(session, BlurToolMode::liquify, BrushSettings{.diameter = 16, .hardness = 0.5}); + session.addBlankLayer(); + const QUuid blank = session.activeLayerID().value(); + session.selectLayer(session.document().value().layers.front().id); + session.selectTool(NavigationTool::blur); + session.beginBrush(QPointF(30, 12)); + QVERIFY(session.warpStroke()); + QVERIFY(!session.canEditLayers() && !session.canUseHistory() && !session.canStartProjectOperation()); + session.selectTool(NavigationTool::brush); + QCOMPARE(session.tool(), NavigationTool::blur); + session.selectLayer(blank); + QVERIFY(session.activeLayerID() != blank); + session.cycleToolMode(); + QCOMPARE(session.blurMode(), BlurToolMode::liquify); + // Busy holds the release; Escape's cancel drops it all. + session.continueBrush(QPointF(50, 12)); + session.setIsProjectBusy(true); + QVERIFY(!session.finishBrushImmediately()); + QVERIFY(session.warpStroke()); + session.setIsProjectBusy(false); + const int steps = session.history.undoCount(); + session.cancelBrush(); + QVERIFY(!session.warpStroke()); + QCOMPARE(session.history.undoCount(), steps); + QVERIFY(session.canEditLayers()); +} + +void SmudgeLiquifyTests::aLayerChangedUnderTheWarpTakesNothing() +{ + EditorSession session; + smearing(session, BlurToolMode::liquify, BrushSettings{.diameter = 16, .hardness = 0.5}); + const QUuid id = session.activeLayerID().value(); + session.beginBrush(QPointF(30, 12)); + session.continueBrush(QPointF(52, 12)); + // Swift's guard: the same pixels and placement, or nothing lands. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = QPointF(1, 0); }); + const int steps = session.history.undoCount(); + session.finishBrush(); + QVERIFY(!session.warpStroke()); + QCOMPARE(session.history.undoCount(), steps); +} + +void SmudgeLiquifyTests::tabStepsTheSmearsModes() +{ + EditorSession session; + session.createDocument(8, 8, true); + session.selectTool(NavigationTool::blur); + QCOMPARE(session.blurMode(), BlurToolMode::liquify); + session.cycleToolMode(); + QCOMPARE(session.blurMode(), BlurToolMode::blur); + session.cycleToolMode(); + QCOMPARE(session.blurMode(), BlurToolMode::smudge); + session.cycleToolMode(); + QCOMPARE(session.blurMode(), BlurToolMode::liquify); + QCOMPARE(rawValue(BlurToolMode::liquify), QString("Liquify")); + QCOMPARE(rawValue(BlurToolMode::blur), QString("Blur")); + QCOMPARE(rawValue(BlurToolMode::smudge), QString("Smudge")); +} + +QTEST_GUILESS_MAIN(SmudgeLiquifyTests) +#include "SmudgeLiquifyTests.moc" diff --git a/tests/SpotHealingTests.cpp b/tests/SpotHealingTests.cpp new file mode 100644 index 0000000..f68403b --- /dev/null +++ b/tests/SpotHealingTests.cpp @@ -0,0 +1,300 @@ +#include "AddressSpaceLimit.h" +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectStore.h" +#include "Rendering/RasterSnapshot.h" +#include <QSignalSpy> +#include <malloc.h> + +// Swift's SpotHealingTests: a blemish healed from its surroundings. +namespace { +// Gray stripes two pixels wide, a red blemish mid-image. +QImage blemished(int width = 120, int left = 55) +{ + QImage image(width, 80, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 80; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x) { + const bool red = x >= left && x < left + 10 && y >= 35 && y < 45; + const uchar gray = x % 4 < 2 ? 100 : 112; + row[x * 4] = red ? 230 : gray; + row[x * 4 + 1] = red ? 20 : gray; + row[x * 4 + 2] = red ? 20 : gray; + row[x * 4 + 3] = 255; + } + } + return image; +} + +// Gray within the stripes' range, the red gone. +bool healedGray(const std::vector<int> &pixel) +{ + return pixel[0] - pixel[1] < 30 && pixel[1] >= 80 && pixel[1] <= 130 && pixel[3] == 255; +} + +// One healing dab on the surface, healed: its tile. +QImage healedTile(const QImage &surface, SpotHealingMode mode, double opacity = 1) +{ + BrushSettings settings = brush(24, 1, 0, 0, 0, opacity); + settings.healing = true; + settings.healingMode = mode; + BrushStroke paint(ImageLayer(ImportedImage(surface, surface, QStringLiteral("Surface")), QPointF(0, 0)), false, settings, QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + paint.heal(); + return paint.patches().at(0).image; +} + +// The blemished surface in a session, Spot Healing chosen. +void surface(EditorSession &session) +{ + session.createDocument(120, 80); + const QImage original = blemished(); + session.insert(ImportedImage(original, original, QStringLiteral("Surface"))); + session.selectTool(NavigationTool::spotHealing); + session.setBrushSettings(BrushSettings{.diameter = 24, .hardness = 1}); +} +} + +class SpotHealingTests : public QObject { + Q_OBJECT +private slots: + // Large asks map memory, so the address limit counts them. + void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); } + void healsTheBlemishUnderTheBrushAndNothingElse_data(); + void healsTheBlemishUnderTheBrushAndNothingElse(); + void aMaskIsNeverHealed(); + void tabCyclesTheTypesAndDigitsSetTheOpacity(); + void aHealingStrokeShowsTheWashThenTheSurface(); + void aMaskStrokeNeverHeals(); + void theSelectionBoundsTheHeal(); + void aPaintedSourceHealsFromItsTiles(); + void aHealThatCannotAllocateIsTooLarge(); + void aHealWithoutRoomForItsCoverageCannotRender(); + void theTypesDifferAndTextureGrainIsRandom(); + void opacityHealsPartWayAndPlainStrokesNever(); + void aSpotAcrossTilesHealsWhole(); +}; + +void SpotHealingTests::healsTheBlemishUnderTheBrushAndNothingElse_data() +{ + QTest::addColumn<int>("type"); + for (const SpotHealingMode mode : allSpotHealingModes) + QTest::newRow(qPrintable(rawValue(mode))) << int(mode); +} + +void SpotHealingTests::healsTheBlemishUnderTheBrushAndNothingElse() +{ + QFETCH(int, type); + EditorSession session; + surface(session); + session.setSpotHealingMode(allSpotHealingModes[size_t(type)]); + const int count = session.history.undoCount(); + session.beginBrush(QPointF(60, 40)); + session.continueBrush(QPointF(60.5, 40)); + session.finishBrush(); + QVERIFY(!session.brushError().has_value()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Spot Healing")); + const QImage after = ImageExporter::render(session.projectSnapshot().value()).image; + for (const auto &[x, y] : {std::pair{60, 40}, std::pair{56, 36}, std::pair{64, 44}}) { + const std::vector<int> healed = pixel(after, x, y); + QVERIFY2(healed[0] - healed[1] < 30 && healed[1] >= 80 && healed[1] <= 130 && healed[3] == 255, + qPrintable(QString("(%1, %2) is %3 %4 %5 %6").arg(x).arg(y).arg(healed[0]).arg(healed[1]).arg(healed[2]).arg(healed[3]))); + } + // Away from the brush, nothing moves. + const QImage original = blemished(); + for (const auto &[x, y] : {std::pair{10, 10}, std::pair{90, 40}, std::pair{30, 40}, std::pair{60, 10}, std::pair{60, 70}}) + QCOMPARE(pixel(after, x, y), pixel(original, x, y)); +} + +void SpotHealingTests::aMaskIsNeverHealed() +{ + EditorSession session; + surface(session); + session.addLayerMask(true); + QVERIFY(session.isMaskSelected()); + session.beginBrush(QPointF(60, 40)); + QVERIFY(!session.brushStroke()); + // The Brush still paints the mask. + session.selectTool(NavigationTool::brush); + session.beginBrush(QPointF(60, 40)); + QVERIFY(session.brushStroke() && session.brushStroke()->isMask && !session.brushStroke()->settings.healing); + session.cancelBrush(); +} + +void SpotHealingTests::tabCyclesTheTypesAndDigitsSetTheOpacity() +{ + EditorSession session; + surface(session); + QSignalSpy changes(&session, &EditorSession::changed); + session.setSpotHealingMode(SpotHealingMode::proximityMatch); + QCOMPARE(changes.count(), 1); + // Tab walks Swift's order, round to the start. + session.cycleToolMode(); + QCOMPARE(session.spotHealingMode(), SpotHealingMode::contentAware); + session.cycleToolMode(); + QCOMPARE(session.spotHealingMode(), SpotHealingMode::createTexture); + session.cycleToolMode(); + QCOMPARE(session.spotHealingMode(), SpotHealingMode::proximityMatch); + QCOMPARE(session.brushMode(), BrushToolMode::paint); + QVERIFY(session.usesOpacityKeys()); + session.typeOpacityDigit(3, 10); + QCOMPARE(session.brushSettings().opacity, 0.3); + // The stroke takes the type chosen, and never erases. + session.setBrushMode(BrushToolMode::erase); + session.beginBrush(QPointF(60, 40)); + QCOMPARE(session.brushStroke()->settings.healingMode, SpotHealingMode::proximityMatch); + QVERIFY(session.brushStroke()->settings.healing && !session.brushStroke()->settings.erasing); + session.cancelBrush(); +} + +void SpotHealingTests::aHealingStrokeShowsTheWashThenTheSurface() +{ + const QImage original = blemished(); + BrushSettings settings = brush(24, 1, 0, 0, 0); + settings.healing = true; + settings.erasing = true; + BrushStroke paint(ImageLayer(ImportedImage(original, original, QStringLiteral("Surface")), QPointF(0, 0)), false, settings, QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + // While painting, Swift's dark wash covers the pixels at 45%. + QCOMPARE(pixel(preview(paint, QSizeF(120, 80)), 60, 40), (std::vector<int>{140, 25, 25, 255})); + paint.heal(); + const QImage healed = preview(paint, QSizeF(120, 80)); + const std::vector<int> centre = pixel(healed, 60, 40); + QVERIFY(centre[0] - centre[1] < 30 && centre[1] >= 80 && centre[1] <= 130); + QCOMPARE(pixel(healed, 90, 40), pixel(original, 90, 40)); +} + +void SpotHealingTests::aMaskStrokeNeverHeals() +{ + const QImage original = blemished(); + ImageLayer layer(ImportedImage(original, original, QStringLiteral("Surface")), QPointF(0, 0)); + layer.mask = LayerMask(LayerMask::assetFrom(BrushRaster::context(120, 80, true))); + BrushSettings settings = brush(24, 1, 1, 1, 1); + settings.healing = true; + BrushStroke paint(layer, true, settings, QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + // The mask takes plain white, and heal() leaves it be. + const QImage painted = paint.patches().at(0).image; + QCOMPARE(int(painted.constScanLine(40)[60]), 255); + paint.heal(); + QCOMPARE(paint.patches().at(0).image, painted); +} + +void SpotHealingTests::theSelectionBoundsTheHeal() +{ + const QImage original = blemished(); + BrushSettings settings = brush(24, 1, 0, 0, 0); + settings.healing = true; + BrushStroke paint(ImageLayer(ImportedImage(original, original, QStringLiteral("Surface")), QPointF(0, 0)), false, settings, QSizeF(120, 80)); + DocumentSelection selection; + selection.path.addRect(QRectF(0, 0, 60, 80)); + paint.selectionClip = selection.clip(QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + paint.heal(); + // Left of the edge the blemish heals; right, it stays. + const QImage result = preview(paint, QSizeF(120, 80)); + const std::vector<int> inside = pixel(result, 57, 40); + QVERIFY(inside[0] - inside[1] < 30 && inside[1] >= 80 && inside[1] <= 130); + QCOMPARE(pixel(result, 62, 40), pixel(original, 62, 40)); +} + +void SpotHealingTests::aPaintedSourceHealsFromItsTiles() +{ + // Blue beneath, the blemished stripes painted over it all. + QImage base = BrushRaster::context(120, 80, false); + base.fill(QColor(20, 20, 200)); + const QImage original = blemished(); + const auto raster = std::make_shared<const RasterSnapshot>(120, 80, base, QRectF(0, 0, 120, 80), + std::vector<BrushPatch>{{QRectF(0, 0, 120, 80), original}}); + BrushSettings settings = brush(24, 1, 0, 0, 0); + settings.healing = true; + BrushStroke paint(ImageLayer(ImportedImage(raster, PixelAdjust::thumbnail(original), QStringLiteral("Painted")), QPointF(0, 0)), false, settings, + QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + paint.heal(); + QVERIFY(!raster->hasMaterializedPixels()); + const QImage result = preview(paint, QSizeF(120, 80)); + for (const QPoint healed : {QPoint(60, 40), QPoint(56, 36), QPoint(64, 44)}) + QVERIFY2(healedGray(pixel(result, healed.x(), healed.y())), qPrintable(QString("(%1, %2)").arg(healed.x()).arg(healed.y()))); + // Painted pixels away from the brush stay the stripes. + for (const QPoint away : {QPoint(10, 10), QPoint(90, 40), QPoint(30, 40), QPoint(60, 10), QPoint(62, 70)}) + QCOMPARE(pixel(result, away.x(), away.y()), pixel(original, away.x(), away.y())); +} + +void SpotHealingTests::aHealThatCannotAllocateIsTooLarge() +{ + if (!placesStarvation()) + QSKIP("the failure point is placed for Qt 6.4's allocations"); + // Room for the region's 7.2 MB, not the kernel's 6.4. + QImage flat = BrushRaster::context(1200, 1200, false); + flat.fill(QColor(90, 90, 90)); + BrushSettings settings = brush(600, 1, 0, 0, 0); + settings.healing = true; + BrushStroke paint(ImageLayer(ImportedImage(flat, flat, QStringLiteral("Flat")), QPointF(0, 0)), false, settings, QSizeF(1200, 1200)); + paint.append(QPointF(600, 600)); + malloc_trim(0); + const AddressSpaceLimit limit(qint64(1200) * 1200 * 5 + 2 * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ProjectError, paint.heal()); +} + +void SpotHealingTests::aHealWithoutRoomForItsCoverageCannotRender() +{ + if (!placesStarvation()) + QSKIP("the failure point is placed for Qt 6.4's allocations"); + // Room for the region's pixels, 5.8 MB, not its coverage. + QImage flat = BrushRaster::context(1200, 1200, false); + flat.fill(QColor(90, 90, 90)); + BrushSettings settings = brush(600, 1, 0, 0, 0); + settings.healing = true; + BrushStroke paint(ImageLayer(ImportedImage(flat, flat, QStringLiteral("Flat")), QPointF(0, 0)), false, settings, QSizeF(1200, 1200)); + paint.append(QPointF(600, 600)); + malloc_trim(0); + const AddressSpaceLimit limit(qint64(1200) * 1200 * 4 + 1200 * 1200 / 2); + QVERIFY_THROWS_EXCEPTION(ExportError, paint.heal()); +} + +void SpotHealingTests::theTypesDifferAndTextureGrainIsRandom() +{ + // Content-Aware copies a patch; Create Texture adds random grain. + const QImage surface = blemished(); + const QImage copied = healedTile(surface, SpotHealingMode::contentAware); + QVERIFY(healedGray(pixel(copied, 60, 40))); + QCOMPARE(healedTile(surface, SpotHealingMode::contentAware), copied); + const QImage textured = healedTile(surface, SpotHealingMode::createTexture); + QVERIFY(textured != copied); + QVERIFY(healedTile(surface, SpotHealingMode::createTexture) != textured); +} + +void SpotHealingTests::opacityHealsPartWayAndPlainStrokesNever() +{ + // At half opacity the red is half gone. + const std::vector<int> half = pixel(healedTile(blemished(), SpotHealingMode::contentAware, 0.5), 60, 40); + QVERIFY2(half[0] - half[1] > 60 && half[0] < 200, qPrintable(QString("%1 %2").arg(half[0]).arg(half[1]))); + // heal() leaves a stroke that is no healing one. + const QImage original = blemished(); + BrushStroke paint(ImageLayer(ImportedImage(original, original, QStringLiteral("Surface")), QPointF(0, 0)), false, brush(24, 1, 0, 0, 1), QSizeF(120, 80)); + paint.append(QPointF(60, 40)); + const QImage painted = paint.patches().at(0).image; + paint.heal(); + QCOMPARE(paint.patches().at(0).image, painted); +} + +void SpotHealingTests::aSpotAcrossTilesHealsWhole() +{ + // Odd width, the blemish astride the first tile's edge. + const QImage original = blemished(301, 251); + BrushSettings settings = brush(24, 1, 0, 0, 0); + settings.healing = true; + BrushStroke paint(ImageLayer(ImportedImage(original, original, QStringLiteral("Surface")), QPointF(0, 0)), false, settings, QSizeF(301, 80)); + paint.append(QPointF(256, 40)); + paint.heal(); + const QImage result = preview(paint, QSizeF(301, 80)); + QVERIFY(healedGray(pixel(result, 253, 40)) && healedGray(pixel(result, 259, 40))); + QCOMPARE(pixel(result, 5, 40), pixel(original, 5, 40)); +} + +QTEST_GUILESS_MAIN(SpotHealingTests) +#include "SpotHealingTests.moc" diff --git a/tests/SubjectRemovalFailureTests.cpp b/tests/SubjectRemovalFailureTests.cpp new file mode 100644 index 0000000..1d13859 --- /dev/null +++ b/tests/SubjectRemovalFailureTests.cpp @@ -0,0 +1,55 @@ +#include "AddressSpaceLimit.h" +#include "Document/BrushStroke.h" +#include "Document/SubjectRemoval.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <QtTest> +#include <malloc.h> + +// Remove Background's preview when memory runs out. +class SubjectRemovalFailureTests : public QObject { + Q_OBJECT +private slots: + // Large requests always map memory the limit counts. + void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); } + void aPreviewThatFindsNoMemoryIsARenderError(); + void aModelRunThatFindsNoMemoryIsARenderError(); +}; + +namespace { +QImage disc(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(217, 26, 26)); + painter.drawEllipse(QPointF(width / 2.0, height / 2.0), width / 5.0, height / 5.0); + return image; +} +} + +void SubjectRemovalFailureTests::aPreviewThatFindsNoMemoryIsARenderError() +{ + const QImage large = disc(6000, 4000); + // The model's mask is kept; the preview copies 96 MB. + QCOMPARE(SubjectRemoval::subjectMask(large, std::nullopt, FilterSettings()).size(), large.size()); + malloc_trim(0); + const AddressSpaceLimit limit(8ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, SubjectRemoval::run(large, FilterSettings())); +} + +void SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError() +{ + if (!placesStarvation()) + QSKIP("the failure point is placed for Qt 6.4's allocations"); + QCOMPARE(SubjectRemoval::subjectMask(disc(200, 150), std::nullopt, FilterSettings()).size(), QSize(200, 150)); + // A new image, whose model input finds no room. + const QImage fresh = disc(320, 320); + malloc_trim(0); + const AddressSpaceLimit limit(512ll * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, SubjectRemoval::run(fresh, FilterSettings())); +} + +QTEST_GUILESS_MAIN(SubjectRemovalFailureTests) +#include "SubjectRemovalFailureTests.moc" diff --git a/tests/SubjectRemovalModelTests.cpp b/tests/SubjectRemovalModelTests.cpp new file mode 100644 index 0000000..f8849fb --- /dev/null +++ b/tests/SubjectRemovalModelTests.cpp @@ -0,0 +1,60 @@ +#include "Document/BrushStroke.h" +#include "Document/SubjectRemoval.h" +#include <QPainter> +#include <QTemporaryDir> +#include <QtTest> + +// The model comes from data folders; failures say why. +class SubjectRemovalModelTests : public QObject { + Q_OBJECT +private slots: + void aModelThatFailsIsNamedAndTheNextUseLoadsAgain(); +}; + +namespace { +QString failure(const QImage &image) +{ + try { + SubjectRemoval::run(image, FilterSettings()); + } catch (const SubjectRemovalError &error) { + return error.kind == SubjectRemovalError::Kind::model ? QString::fromUtf8(error.what()) : QStringLiteral("no subject"); + } + return QString(); +} +} + +void SubjectRemovalModelTests::aModelThatFailsIsNamedAndTheNextUseLoadsAgain() +{ + QImage image = BrushRaster::context(200, 150, false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setBrush(QColor(217, 26, 26)); + painter.setPen(Qt::NoPen); + painter.drawEllipse(QPointF(100, 75), 40, 40); + painter.end(); + const QByteArray folders = qgetenv("XDG_DATA_DIRS"); + // No model in the file: ONNX Runtime's words pass on. + const QTemporaryDir broken; + QVERIFY(QDir(broken.path()).mkpath(QStringLiteral("omaphoto"))); + QFile garbage(broken.filePath(QStringLiteral("omaphoto/u2net.onnx"))); + QVERIFY(garbage.open(QIODevice::WriteOnly) && garbage.write("not a model") == 11); + garbage.close(); + qputenv("XDG_DATA_DIRS", broken.path().toUtf8()); + QString said = failure(image); + QVERIFY2(said.startsWith(QStringLiteral("Remove Background could not use its model: ")) && said.size() > 60, qPrintable(said)); + // No file at all: every folder looked in is named. + const QTemporaryDir empty; + qputenv("XDG_DATA_DIRS", empty.path().toUtf8()); + said = failure(image); + QVERIFY2(said.startsWith(QStringLiteral("Remove Background could not use its model: omaphoto/u2net.onnx is in no data folder (")), qPrintable(said)); + QVERIFY2(said.contains(empty.path()), qPrintable(said)); + // Back in its folder, the next use loads it. + if (folders.isEmpty()) + qunsetenv("XDG_DATA_DIRS"); + else + qputenv("XDG_DATA_DIRS", folders); + QVERIFY(failure(image).isEmpty()); +} + +QTEST_GUILESS_MAIN(SubjectRemovalModelTests) +#include "SubjectRemovalModelTests.moc" diff --git a/tests/SubjectRemovalTests.cpp b/tests/SubjectRemovalTests.cpp new file mode 100644 index 0000000..abf759b --- /dev/null +++ b/tests/SubjectRemovalTests.cpp @@ -0,0 +1,312 @@ +#include "Document/BrushStroke.h" +#include "Document/SubjectRemoval.h" +#include <QPainter> +#include <QtTest> + +// Remove Background's mask: U²-Net's subject, refined as asked. +namespace { +// A red disc on white: a subject the model finds. +QImage disc(int width, int height, QPointF centre, double radius) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(Qt::white); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(217, 26, 26)); + painter.drawEllipse(centre, radius, radius); + return image; +} + +FilterSettings advanced(double refine, double contrast, double shift) +{ + FilterSettings settings; + settings.backgroundQuality = BackgroundQuality::advanced; + settings.refineEdges = refine; + settings.matteContrast = contrast; + settings.shiftEdge = shift; + return settings; +} + +// How many pixels of a gray mask pass half. +int kept(const QImage &mask) +{ + int count = 0; + for (int y = 0; y < mask.height(); ++y) { + for (int x = 0; x < mask.width(); ++x) + count += mask.constScanLine(y)[x] >= 128; + } + return count; +} + +int at(const QImage &mask, int x, int y) +{ + return mask.constScanLine(y)[x]; +} +} + +class SubjectRemovalTests : public QObject { + Q_OBJECT +private slots: + void theModelsInputIsThePapersAndRembgs(); + void theModelsAnswerIsStretchedAndJudged(); + void visionRequestRunsOnAnImage(); + void theMaskKeepsTheSubjectAndHidesTheBackground(); + void aFlatImageHasNoSubject(); + void advancedRefinesShiftsAndClearsHaze(); + void anExistingMaskMultipliesIn(); + void thePreviewClearsTheBackgroundOnASmallerRefine(); + void eachImageHasItsOwnMask(); +}; + +void SubjectRemovalTests::theModelsInputIsThePapersAndRembgs() +{ + constexpr size_t plane = size_t(SubjectRemoval::side) * SubjectRemoval::side; + // Opaque on the left, clear on the right: black, premultiplied. + QImage image(64, 32, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::transparent); + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 32; ++x) + image.setPixelColor(x, y, QColor(100, 50, 200)); + } + const std::vector<float> input = SubjectRemoval::modelInput(image); + QCOMPARE(input.size(), 3 * plane); + // By the brightest byte, 200, then ImageNet's mean and spread. + const float mean[3] = {0.485f, 0.456f, 0.406f}, spread[3] = {0.229f, 0.224f, 0.225f}, colour[3] = {100, 50, 200}; + for (size_t channel = 0; channel < 3; ++channel) { + QCOMPARE(input[channel * plane + 10 * 320 + 20], (colour[channel] / 200 - mean[channel]) / spread[channel]); + QCOMPARE(input[channel * plane + 10 * 320 + 300], (0 - mean[channel]) / spread[channel]); + } + // Detail shrinks through Qt's area filter, byte for byte. + QImage detail(500, 410, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 410; ++y) { + for (int x = 0; x < 500; ++x) + detail.setPixelColor(x, y, QColor((x * 7 + y * 3) % 256, (x * x + y) % 200, (y * 5) % 256)); + } + const QImage filtered = detail.scaled(320, 320, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_RGBA8888_Premultiplied); + float brightest = 0; + for (int y = 0; y < 320; ++y) { + for (int x = 0; x < 320 * 4; ++x) + brightest = std::max(brightest, x % 4 == 3 ? 0.0f : float(filtered.constScanLine(y)[x])); + } + const std::vector<float> fine = SubjectRemoval::modelInput(detail); + for (int y = 0; y < 320; y += 7) { + for (int x = 0; x < 320; ++x) + QCOMPARE(fine[size_t(y) * 320 + size_t(x)], (float(filtered.constScanLine(y)[x * 4]) / brightest - mean[0]) / spread[0]); + } + // All black divides by no nothing. + QImage black(8, 8, QImage::Format_RGBA8888_Premultiplied); + black.fill(Qt::black); + for (const float value : SubjectRemoval::modelInput(black)) + QVERIFY(std::isfinite(value)); +} + +void SubjectRemovalTests::theModelsAnswerIsStretchedAndJudged() +{ + constexpr int side = SubjectRemoval::side; + std::vector<float> answer(size_t(side) * side, 0.25f); + answer[0] = 0.75f; + answer[1] = 0.5f; + // Stretched from its lowest to its highest, truncated. + const QImage mask = SubjectRemoval::modelMask(answer.data(), QSize(side, side)); + QVERIFY(mask.format() == QImage::Format_Grayscale8 && mask.size() == QSize(side, side)); + QVERIFY(at(mask, 0, 0) == 255 && at(mask, 1, 0) == 127 && at(mask, 5, 5) == 0); + // Scaled, filtered, to the size asked. + const QImage wide = SubjectRemoval::modelMask(answer.data(), QSize(640, 160)); + QVERIFY(wide.format() == QImage::Format_Grayscale8 && wide.size() == QSize(640, 160) && at(wide, 639, 159) == 0); + // Shrunk, alternate columns average rather than alias. + std::vector<float> stripes(size_t(side) * side); + for (size_t index = 0; index < stripes.size(); ++index) + stripes[index] = index % 2 ? 0.75f : 0.25f; + const QImage shrunk = SubjectRemoval::modelMask(stripes.data(), QSize(100, 100)); + for (int x = 0; x < 100; ++x) + QVERIFY2(at(shrunk, x, 50) > 80 && at(shrunk, x, 50) < 175, qPrintable(QString::number(at(shrunk, x, 50)))); + // Enlarged through premultiplied RGBA, byte for byte. + std::vector<float> noise(size_t(side) * side); + QImage stretched(side, side, QImage::Format_Grayscale8); + for (size_t index = 0; index < noise.size(); ++index) { + noise[index] = float((index * 37) % 101) / 100; + stretched.scanLine(int(index) / side)[index % size_t(side)] = uchar(noise[index] * 255); + } + QCOMPARE(SubjectRemoval::modelMask(noise.data(), QSize(640, 480)), + stretched.convertToFormat(QImage::Format_RGBA8888_Premultiplied) + .scaled(640, 480, Qt::IgnoreAspectRatio, Qt::SmoothTransformation) + .convertToFormat(QImage::Format_Grayscale8)); + // A checkerboard halves through premultiplied RGBA, averaging. + std::vector<float> checks(size_t(side) * side); + for (int y = 0; y < side; ++y) { + for (int x = 0; x < side; ++x) + checks[size_t(y) * side + size_t(x)] = (x + y) % 2 ? 0.75f : 0.25f; + } + const QImage halved = SubjectRemoval::modelMask(checks.data(), QSize(side / 2, side / 2)); + for (int y = 0; y < side / 2; y += 9) { + for (int x = 0; x < side / 2; ++x) + QVERIFY2(std::abs(at(halved, x, y) - 127) <= 1, qPrintable(QString::number(at(halved, x, y)))); + } + // At half the model takes a subject; below, none. + answer[0] = 0.5f; + QCOMPARE(at(SubjectRemoval::modelMask(answer.data(), QSize(side, side)), 0, 0), 255); + answer[0] = 0.4999f; + answer[1] = 0.3f; + QVERIFY_THROWS_EXCEPTION(SubjectRemovalError, SubjectRemoval::modelMask(answer.data(), QSize(side, side))); +} + +// Swift's test: a result the image's size, or none. +void SubjectRemovalTests::visionRequestRunsOnAnImage() +{ + const QImage image = disc(400, 300, QPointF(200, 150), 80); + const QImage result = SubjectRemoval::run(image, FilterSettings()); + QVERIFY(result.size() == image.size() && result.format() == QImage::Format_RGBA8888_Premultiplied); +} + +void SubjectRemovalTests::theMaskKeepsTheSubjectAndHidesTheBackground() +{ + const QImage image = disc(400, 300, QPointF(200, 150), 80); + const QImage mask = SubjectRemoval::subjectMask(image, std::nullopt, FilterSettings()); + QVERIFY(mask.format() == QImage::Format_Grayscale8 && mask.size() == image.size()); + // White over the disc, black round it. + QCOMPARE(at(mask, 200, 150), 255); + for (const QPoint corner : {QPoint(0, 0), QPoint(399, 0), QPoint(0, 299), QPoint(399, 299), QPoint(200, 20), QPoint(40, 150)}) + QVERIFY2(at(mask, corner.x(), corner.y()) == 0, qPrintable(QStringLiteral("%1,%2").arg(corner.x()).arg(corner.y()))); + // About the disc's area passes half. + const double area = 3.14159265 * 80 * 80; + QVERIFY2(std::abs(kept(mask) - area) < area * 0.1, qPrintable(QString::number(kept(mask)))); +} + +void SubjectRemovalTests::aFlatImageHasNoSubject() +{ + QImage flat = BrushRaster::context(64, 48, false); + flat.fill(QColor(90, 140, 200)); + for (const bool preview : {false, true}) { + try { + if (preview) + SubjectRemoval::run(flat, FilterSettings()); + else + SubjectRemoval::subjectMask(flat, std::nullopt, FilterSettings()); + QFAIL("a flat image has a subject"); + } catch (const SubjectRemovalError &error) { + QVERIFY(error.kind == SubjectRemovalError::Kind::noSubject); + QCOMPARE(QString::fromUtf8(error.what()), + QString("No foreground subject was detected in this layer. Try an image with a more distinct subject.")); + } + } +} + +void SubjectRemovalTests::advancedRefinesShiftsAndClearsHaze() +{ + const QImage image = disc(400, 300, QPointF(200, 150), 80); + const QImage basic = SubjectRemoval::subjectMask(image, std::nullopt, FilterSettings()); + // Basic is the model's mask, whatever the other settings say. + FilterSettings plain = advanced(40, 100, -10); + plain.backgroundQuality = BackgroundQuality::basic; + QCOMPARE(SubjectRemoval::subjectMask(image, std::nullopt, plain), basic); + // Nothing asked, Advanced leaves the mask as it is. + QCOMPARE(SubjectRemoval::subjectMask(image, std::nullopt, advanced(0, 0, 0)), basic); + // Refine pulls the mask onto the layer's own edges. + QVERIFY(SubjectRemoval::subjectMask(image, std::nullopt, advanced(12, 0, 0)) != basic); + // Shift Edge shrinks the mask inward, or grows it. + const int kept0 = kept(basic); + // Measured: the disc's area, near enough to the pixel. + QVERIFY2(std::abs(kept0 - 20138) <= 40, qPrintable(QString::number(kept0))); + const QImage shrunk = SubjectRemoval::subjectMask(image, std::nullopt, advanced(0, 0, -10)); + const QImage grown = SubjectRemoval::subjectMask(image, std::nullopt, advanced(0, 0, 10)); + QVERIFY2(std::abs(kept(shrunk) - 18328) <= 40 && std::abs(kept(grown) - 21772) <= 40, qPrintable(QStringLiteral("%1 %2").arg(kept(shrunk)).arg(kept(grown)))); + // A hard cut leaves one step of gray at most. + for (const QImage &cut : {shrunk, grown}) { + for (int y = 0; y < cut.height(); ++y) { + for (int x = 0; x < cut.width(); ++x) + QVERIFY(at(cut, x, y) == 0 || at(cut, x, y) >= 250); + } + } + // Full contrast leaves black and white alone. + const QImage hard = SubjectRemoval::subjectMask(image, std::nullopt, advanced(0, 100, 0)); + int grays = 0; + for (int y = 0; y < hard.height(); ++y) { + for (int x = 0; x < hard.width(); ++x) + grays += at(hard, x, y) > 5 && at(hard, x, y) < 250; + } + QVERIFY2(grays < 60, qPrintable(QString::number(grays))); + // A cut at the middle keeps exactly what passed half. + QCOMPARE(kept(hard), kept0); + // Contrast is a line through the middle, rounded once. + const QImage hazy = SubjectRemoval::subjectMask(image, std::nullopt, advanced(0, 25, 0)); + const float slope = 1 / std::max(0.02f, 1 - float(25.0 / 100) * 0.98f); + for (int y = 0; y < 300; y += 2) { + for (int x = 0; x < 400; ++x) { + const float value = slope * (float(at(basic, x, y)) / 255) + (1 - slope) / 2; + QCOMPARE(at(hazy, x, y), int(std::lround(std::clamp(value, 0.0f, 1.0f) * 255))); + } + } + // A shrink keeps a subject's side on the image's edge. + QImage edge = BrushRaster::context(400, 300, false); + edge.fill(Qt::white); + QPainter painter(&edge); + painter.fillRect(QRect(0, 60, 160, 180), QColor(217, 26, 26)); + painter.end(); + QCOMPARE(at(SubjectRemoval::subjectMask(edge, std::nullopt, FilterSettings()), 0, 150), 254); + const QImage cut = SubjectRemoval::subjectMask(edge, std::nullopt, advanced(0, 0, -10)); + QVERIFY(at(cut, 0, 150) == 255 && at(cut, 0, 62) == 0 && at(cut, 170, 150) == 0); +} + +void SubjectRemovalTests::anExistingMaskMultipliesIn() +{ + const QImage image = disc(400, 300, QPointF(200, 150), 80); + QImage existing(image.size(), QImage::Format_Grayscale8); + existing.fill(128); + const QImage combined = SubjectRemoval::subjectMask(image, existing, FilterSettings()); + const QImage subject = SubjectRemoval::subjectMask(image, std::nullopt, FilterSettings()); + // What either one hides stays hidden: the product, rounded. + QVERIFY(combined.format() == QImage::Format_Grayscale8 && at(combined, 200, 150) == 128 && at(combined, 0, 0) == 0); + for (int y = 0; y < 300; ++y) { + for (int x = 0; x < 400; ++x) + QCOMPARE(at(combined, x, y), (at(subject, x, y) * 128 + 127) / 255); + } + // A mask of another grid is refused, never read past. + QVERIFY_THROWS_EXCEPTION(std::logic_error, SubjectRemoval::subjectMask(image, QImage(2, 2, QImage::Format_Grayscale8), FilterSettings())); + // Any image reads as gray, as Swift draws it. + QImage colour(image.size(), QImage::Format_RGBA8888_Premultiplied); + colour.fill(QColor(128, 128, 128)); + QCOMPARE(SubjectRemoval::subjectMask(image, colour, FilterSettings()), combined); +} + +void SubjectRemovalTests::thePreviewClearsTheBackgroundOnASmallerRefine() +{ + const QImage image = disc(400, 300, QPointF(200, 150), 80); + const QImage preview = SubjectRemoval::run(image, FilterSettings()); + // The disc stays; the white round it goes clear. + QCOMPARE(preview.pixelColor(200, 150), image.pixelColor(200, 150)); + QCOMPARE(preview.pixelColor(0, 0), QColor(0, 0, 0, 0)); + // Each byte times the mask, rounded, over every channel. + const QImage mask = SubjectRemoval::subjectMask(image, std::nullopt, FilterSettings()); + for (int x = 100; x < 300; x += 7) { + const int coverage = at(mask, x, 150); + const QRgb pixel = image.pixel(x, 150); + QCOMPARE(qAlpha(preview.pixel(x, 150)), (255 * coverage + 127) / 255); + QCOMPARE(qRed(preview.pixel(x, 150)), (qRed(pixel) * coverage + 127) / 255); + } + // Any format comes back premultiplied, the same pixels. + const QImage straight = SubjectRemoval::run(image.convertToFormat(QImage::Format_ARGB32), FilterSettings()); + QVERIFY(straight.format() == QImage::Format_RGBA8888_Premultiplied && straight.pixelColor(0, 0) == QColor(0, 0, 0, 0)); + QCOMPARE(straight.pixelColor(200, 150), preview.pixelColor(200, 150)); + // Past 1400 a side, the preview refines a smaller copy. + const QImage large = disc(2800, 2100, QPointF(1400, 1050), 560); + const QImage refined = SubjectRemoval::run(large, advanced(12, 0, 0)); + const QImage full = SubjectRemoval::subjectMask(large, std::nullopt, advanced(12, 0, 0)); + int differ = 0; + for (int y = 0; y < 2100; y += 3) { + for (int x = 0; x < 2800; x += 3) + differ += qAlpha(refined.pixel(x, y)) != at(full, x, y); + } + QVERIFY2(differ > 100, qPrintable(QString::number(differ))); +} + +void SubjectRemovalTests::eachImageHasItsOwnMask() +{ + const QImage left = SubjectRemoval::subjectMask(disc(400, 300, QPointF(120, 150), 70), std::nullopt, FilterSettings()); + const QImage right = SubjectRemoval::subjectMask(disc(400, 300, QPointF(280, 150), 70), std::nullopt, FilterSettings()); + QVERIFY(at(left, 120, 150) == 255 && at(left, 280, 150) == 0); + QVERIFY(at(right, 280, 150) == 255 && at(right, 120, 150) == 0); +} + +QTEST_GUILESS_MAIN(SubjectRemovalTests) +#include "SubjectRemovalTests.moc" diff --git a/tests/TextLayoutTests.cpp b/tests/TextLayoutTests.cpp new file mode 100644 index 0000000..8a65abc --- /dev/null +++ b/tests/TextLayoutTests.cpp @@ -0,0 +1,394 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "Rendering/TextLayout.h" + +// Text laid out as TextKit lays it: fonts, boxes, lines. +namespace { +LayerTextStyle styled(const QString &content, double size = 72) +{ + LayerTextStyle style; + style.content = content; + style.fontSize = size; + return style; +} + +// The rows and columns where a text image has ink. +QRect inked(const QImage &image) +{ + QRect found; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (alpha(image, x, y) > 64) + found |= QRect(x, y, 1, 1); + } + } + return found; +} +} + +class TextLayoutTests : public QObject { + Q_OBJECT +private slots: + void facesAreFoundByPostScriptName(); + void pointTextMeasuresItsLines(); + void aBoxWrapsAndDropsWhatDoesNotFit(); + void linesAlignInTheirBox(); + void baselinesSitAtTheBottomOfFixedLines(); + void imagesKeepSwiftsLimits(); + void aBoxUnderItsPaddingHoldsAPixel(); + void glyphsTakeTheStyleColour(); + void paragraphsBreakWhereCocoaBreaksThem(); + void tabsStopEvery28Points(); + void linesStopWhereTheBoxDoes(); + void aSelectionFollowsItsRunsToTheLinesEdge(); +}; + +void TextLayoutTests::facesAreFoundByPostScriptName() +{ + LayerTextStyle style = styled(QStringLiteral("Text"), 36.5); + style.fontName = QStringLiteral("DejaVuSans-Bold"); + style.tracking = 4; + const QFont bold = TextLayout::font(style); + QCOMPARE(bold.family(), QString("DejaVu Sans")); + QCOMPARE(bold.styleName(), QString("Bold")); + QCOMPARE(QFontInfo(bold).pixelSize(), 37); + // A whole size is that many pixels, as Swift's points. + style.fontSize = 40; + QFont pixels = TextLayout::font(style); + const double advance = QFontMetricsF(pixels).horizontalAdvance(QStringLiteral("Hamburgefons")); + pixels.setPixelSize(40); + QVERIFY(std::abs(QFontMetricsF(pixels).horizontalAdvance(QStringLiteral("Hamburgefons")) - advance) < 0.01); + // Qt draws faces in whole pixels: a fraction rounds. + style.fontSize = 36.4; + QCOMPARE(QFontInfo(TextLayout::font(style)).pixelSize(), 36); + style.fontSize = 36.6; + QCOMPARE(QFontInfo(TextLayout::font(style)).pixelSize(), 37); + // Swift always sets a kern: no pairs, the tracking added. + QVERIFY(!bold.kerning()); + QCOMPARE(bold.letterSpacingType(), QFont::AbsoluteSpacing); + QCOMPARE(bold.letterSpacing(), 4.0); + QCOMPARE(bold.hintingPreference(), QFont::PreferNoHinting); + // Not installed: Qt's match, where macOS takes its own. + style.fontName = QStringLiteral("Helvetica"); + QCOMPARE(TextLayout::font(style).family(), QString("Helvetica")); + QCOMPARE(TextLayout::font(style).styleName(), QString()); + // Distributions split DejaVu differently: its faces, once each. + const QStringList names = TextLayout::availableFonts(); + QVERIFY(names.contains(QStringLiteral("DejaVuSans")) && names.contains(QStringLiteral("DejaVuSerif-Bold"))); + QCOMPARE(QSet<QString>(names.begin(), names.end()).size(), names.size()); + QStringList sorted = names; + sorted.sort(); + QCOMPARE(names, sorted); +} + +void TextLayoutTests::pointTextMeasuresItsLines() +{ + // One line of 72: 86.4 high, rounded up, padded. + const QSizeF one = EditorSession::textBoxSize(styled(QStringLiteral("Text"))); + QCOMPARE(one.height(), 111.0); + const TextLines lines(styled(QStringLiteral("Text")), QSizeF(100'000, 100'000)); + QCOMPARE(one.width(), std::ceil(lines.usedSize().width() + 24 + 7.2)); + QVERIFY(lines.usedSize().width() > 100 && !lines.overflows()); + QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("A\nB"))).height(), 197.0); + // The widest line sets the width, wherever it sits. + QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("WWWW\nI"))).width(), EditorSession::textBoxSize(styled(QStringLiteral("WWWW"))).width()); + // A final newline adds no line; two newlines make two. + QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("Text\n"))), one); + QCOMPARE(EditorSession::textBoxSize(styled(QStringLiteral("\n\n"))).height(), 197.0); + // Empty text keeps a line and a caret's room. + QCOMPARE(EditorSession::textBoxSize(styled(QString())), QSizeF(32, 111)); + QCOMPARE(EditorSession::textBoxSize(styled(QString(), 2)), QSizeF(25, 27)); + // Tracking widens by its amount a letter. + LayerTextStyle spaced = styled(QStringLiteral("Text")); + spaced.tracking = 10; + QVERIFY(std::abs(EditorSession::textBoxSize(spaced).width() - one.width() - 40) <= 1); + LayerTextStyle led = styled(QStringLiteral("Text")); + led.leading = 100; + QCOMPARE(EditorSession::textBoxSize(led).height(), 124.0); + QCOMPARE(EditorSession::textImage(styled(QStringLiteral("Text"))).size(), one.toSize()); + // Point text never wraps short of 100,000 pixels. + const QSizeF wide = EditorSession::textBoxSize(styled(QStringLiteral("Hamburgefonstiv ").repeated(8))); + QVERIFY(wide.width() > 3'000 && wide.height() == 111); +} + +void TextLayoutTests::aBoxWrapsAndDropsWhatDoesNotFit() +{ + LayerTextStyle style = styled(QStringLiteral("Words that wrap inside the box and keep on going"), 24); + style.boxSize = QSizeF(160, 120); + QCOMPARE(EditorSession::textBoxSize(style), QSizeF(160, 120)); + const QImage image = EditorSession::textImage(style); + QCOMPARE(image.size(), QSize(160, 120)); + // 96 pixels hold three 28.8 lines; the rest drops. + const TextLines lines(style, QSizeF(136, 96)); + QCOMPARE(lines.usedSize().height(), 3 * 28.8); + QVERIFY(lines.overflows() && lines.usedSize().width() <= 136); + // Half a pixel short of three lines holds two. + QCOMPARE(TextLines(style, QSizeF(136, 3 * 28.8 - 0.5)).usedSize().height(), 2 * 28.8); + const QRect ink = inked(image); + QVERIFY2(ink.left() >= 12 && ink.right() < 148 && ink.bottom() < 12 + 3 * 28.8 + 1, qPrintable(QString("%1 %2 %3").arg(ink.left()).arg(ink.right()).arg(ink.bottom()))); + // Room for all: nothing falls past the bottom. + style.boxSize = QSizeF(160, 400); + QVERIFY(!TextLines(style, QSizeF(136, 376)).overflows()); + // A word longer than the line breaks anywhere. + style.content = QStringLiteral("Unbreakablewordthatislong"); + const TextLines broken(style, QSizeF(136, 376)); + QVERIFY(broken.lineCount() > 1); + // With no space, its line ends where the next begins. + QCOMPARE(broken.lineEnd(0), broken.lineStart(1)); +} + +void TextLayoutTests::linesAlignInTheirBox() +{ + LayerTextStyle style = styled(QStringLiteral("WWWWW\nI"), 40); + style.boxSize = QSizeF(400, 200); + const auto lineInk = [&](int top, int bottom) { + const QImage image = EditorSession::textImage(style); + return inked(image.copy(0, top, image.width(), bottom - top)); + }; + // The short second line: left, middle, then right. + const QRect left = lineInk(12 + 48, 12 + 96); + QVERIFY(left.left() < 30); + style.alignment = TextAlignment::center; + const QRect centre = lineInk(12 + 48, 12 + 96); + QVERIFY2(std::abs(centre.center().x() - 200) <= 3, qPrintable(QString::number(centre.center().x()))); + style.alignment = TextAlignment::right; + const QRect right = lineInk(12 + 48, 12 + 96); + QVERIFY(right.right() > 400 - 12 - 12 && right.right() < 400 - 12); +} + +void TextLayoutTests::baselinesSitAtTheBottomOfFixedLines() +{ + // A tall line: letters sit low, the space above. + LayerTextStyle style = styled(QStringLiteral("H"), 20); + style.leading = 300; + const QImage image = EditorSession::textImage(style); + QCOMPARE(image.height(), 324); + const QRect ink = inked(image); + const QFontMetricsF metrics(TextLayout::font(style)); + const double baseline = 12 + 300 - metrics.descent(); + QVERIFY2(std::abs(ink.bottom() + 1 - baseline) <= 1.5 && ink.top() > 260, + qPrintable(QString("%1 %2 %3").arg(ink.top()).arg(ink.bottom()).arg(baseline))); + // A short line: letters reach into the one before. + style.content = QStringLiteral("H\nH"); + style.leading = 10; + const QImage tight = EditorSession::textImage(style); + QCOMPARE(tight.height(), 44); + QVERIFY(inked(tight).top() < 12); +} + +void TextLayoutTests::imagesKeepSwiftsLimits() +{ + const auto refused = [](const LayerTextStyle &style) -> std::optional<ProjectError::Kind> { + try { + EditorSession::textImage(style); + } catch (const ProjectError &error) { + return error.kind; + } + return std::nullopt; + }; + QCOMPARE(refused(styled(QStringLiteral("Text"), 0)), std::optional(ProjectError::Kind::invalid)); + // A side past 30,000, then the area past 100 megapixels. + const LayerTextStyle wide = styled(QStringLiteral("M").repeated(20), 2000); + const QSizeF wideSize = EditorSession::textBoxSize(wide); + QVERIFY(wideSize.width() > 30'000 && wideSize.width() < 40'000 && wideSize.width() * wideSize.height() < 100'000'000); + QCOMPARE(refused(wide), std::optional(ProjectError::Kind::tooLarge)); + const LayerTextStyle tall = styled(QStringLiteral("a\n").repeated(13), 2000); + const QSizeF tallSize = EditorSession::textBoxSize(tall); + QVERIFY(tallSize.height() > 30'000 && tallSize.height() < 40'000 && tallSize.width() * tallSize.height() < 100'000'000); + QCOMPARE(refused(tall), std::optional(ProjectError::Kind::tooLarge)); + const LayerTextStyle large = styled(QStringLiteral("M").repeated(14) + QStringLiteral("\nM"), 2000); + const QSizeF largeSize = EditorSession::textBoxSize(large); + QVERIFY(largeSize.width() < 30'000 && largeSize.width() * largeSize.height() > 100'000'000); + QCOMPARE(refused(large), std::optional(ProjectError::Kind::tooLarge)); + QVERIFY(!refused(styled(QStringLiteral("M").repeated(10), 2000))); + // A fractional box rounds up to whole pixels. + LayerTextStyle fractional = styled(QStringLiteral("Box"), 12); + fractional.boxSize = QSizeF(100.2, 50.5); + QCOMPARE(EditorSession::textImage(fractional).size(), QSize(101, 51)); +} + +void TextLayoutTests::aBoxUnderItsPaddingHoldsAPixel() +{ + // Both boxes leave a one-pixel container, as Swift's max(1, …). + LayerTextStyle style = styled(QStringLiteral("I"), 10); + style.leading = 1; + style.alignment = TextAlignment::right; + style.boxSize = QSizeF(16, 16); + const QImage small = EditorSession::textImage(style); + style.boxSize = QSizeF(25, 25); + const QImage exact = EditorSession::textImage(style).copy(0, 0, 16, 16); + QVERIFY(inked(small).isValid()); + QCOMPARE(small, exact); +} + +void TextLayoutTests::glyphsTakeTheStyleColour() +{ + LayerTextStyle style = styled(QStringLiteral("H"), 72); + style.red = 0.2; + style.green = 0.4; + style.blue = 0.8; + const QImage image = EditorSession::textImage(style); + // A solid pixel of the stem shows the colour itself. + int solid = 0; + QCOMPARE(image.format(), QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const uchar *at = image.constScanLine(y) + x * 4; + if (at[3] == 255) { + ++solid; + QCOMPARE((std::vector<int>{at[0], at[1], at[2]}), (std::vector<int>{51, 102, 204})); + } + } + } + QVERIFY(solid > 100); +} + +void TextLayoutTests::paragraphsBreakWhereCocoaBreaksThem() +{ + const auto height = [](const QString &text) { return EditorSession::textBoxSize(styled(text)).height(); }; + const double one = height(QStringLiteral("A")), two = height(QStringLiteral("A\nB")); + QCOMPARE(two, 197.0); + // CR, CRLF, U+2029, and Qt's own U+2028, each break. + for (const QString &text : {QStringLiteral("A\rB"), QStringLiteral("A\r\nB"), QStringLiteral("A") + QChar(0x2029) + QStringLiteral("B"), + QStringLiteral("A") + QChar(0x2028) + QStringLiteral("B")}) + QCOMPARE(height(text), two); + // CRLF is one break; a final one adds no line. + QCOMPARE(height(QStringLiteral("A\r\n")), one); + QCOMPARE(height(QStringLiteral("A") + QChar(0x2029)), one); + QCOMPARE(height(QStringLiteral("A\r\n\r\nB")), 284.0); +} + +void TextLayoutTests::tabsStopEvery28Points() +{ + const QSizeF room(100'000, 100'000); + const double letter = TextLines(styled(QStringLiteral("B"), 24), room).usedSize().width(); + const auto width = [&](const QString &text) { return TextLines(styled(text, 24), room).usedSize().width(); }; + // NSParagraphStyle's twelve stops: every 28 pixels to 336. + QVERIFY(std::abs(width(QStringLiteral("\tB")) - (28 + letter)) < 0.5); + QVERIFY(std::abs(width(QStringLiteral("\t\tB")) - (56 + letter)) < 0.5); + QVERIFY(std::abs(width(QStringLiteral("WWW\tB")) - (84 + letter)) < 0.5); + QVERIFY(std::abs(width(QString(12, QLatin1Char('\t')) + QStringLiteral("B")) - (336 + letter)) < 0.5); + // Past 336 they go on, where Cocoa wraps (known difference). + const TextLines thirteen(styled(QString(13, QLatin1Char('\t')) + QStringLiteral("B"), 24), room); + QVERIFY(std::abs(thirteen.usedSize().width() - (364 + letter)) < 0.5); + QCOMPARE(thirteen.usedSize().height(), 28.8); +} + +void TextLayoutTests::linesStopWhereTheBoxDoes() +{ + // One line of room: "beta" and "more" fall past it. + const TextLines lines(styled(QStringLiteral("alpha beta\nmore"), 24), QSizeF(90, 40)); + QCOMPARE(lines.lineCount(), 1); + QVERIFY(lines.overflows()); + QCOMPARE(lines.lineOf(3), std::optional(0)); + QCOMPARE(lines.lineOf(5), std::optional(0)); + // Past the laid text: no line, no caret. + for (const int position : {6, 8, 10, 11, 15}) + QVERIFY2(!lines.lineOf(position) && !lines.caret(position), qPrintable(QString::number(position))); + // The laid line still ends before its breaking space. + QCOMPARE(lines.lineEnd(0), 5); + // TextKit's line after a final newline holds the caret alone. + const TextLines ending(styled(QStringLiteral("A\n"), 24), QSizeF(90, 40)); + QCOMPARE(ending.lineCount(), 2); + QVERIFY(!ending.overflows()); + QCOMPARE(ending.usedSize().height(), 28.8); + QCOMPARE(ending.lineOf(2), std::optional(1)); + QCOMPARE(ending.caret(2).value().y(), 28.8); + // So does a final U+2028; one inside makes a line. + const QString separator(QChar::LineSeparator); + const TextLines broken(styled(QStringLiteral("A") + separator, 24), QSizeF(90, 40)); + QCOMPARE(broken.lineCount(), 2); + QCOMPARE(broken.usedSize().height(), 28.8); + QCOMPARE(broken.lineOf(2), std::optional(1)); + QCOMPARE(broken.lineEnd(0), 1); + QCOMPARE(TextLines(styled(QStringLiteral("A") + separator + QStringLiteral("\nB"), 24), QSizeF(90, 400)).usedSize().height(), 3 * 28.8); + for (const QString &last : {QStringLiteral("\r"), QString(QChar::ParagraphSeparator)}) { + const TextLines closed(styled(QStringLiteral("A") + last, 24), QSizeF(90, 40)); + QCOMPARE(closed.lineCount(), 2); + QCOMPARE(closed.usedSize().height(), 28.8); + } + // Only the text's last break has a caret's line. + QVERIFY(TextLines(styled(QStringLiteral("A") + separator + QStringLiteral("\n"), 24), QSizeF(90, 40)).overflows()); + const TextLines cut(styled(QStringLiteral("alpha beta") + separator, 24), QSizeF(90, 40)); + QVERIFY(cut.overflows()); + QCOMPARE(cut.lineCount(), 1); + // Cut short, the text never reaches it. + QCOMPARE(TextLines(styled(QStringLiteral("alpha beta\n"), 24), QSizeF(90, 40)).usedSize().height(), 28.8); +} + +void TextLayoutTests::aSelectionFollowsItsRunsToTheLinesEdge() +{ + const TextLines lines(styled(QStringLiteral("abc אבג xyz\nnext"), 24), QSizeF(400, 100)); + const auto painted = [&](TextRange selection) { + QImage image(400, 100, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::white); + QPainter painter(&image); + painter.setPen(Qt::NoPen); + lines.draw(painter, QPointF(0, 0), selection, {}, Qt::red); + return image; + }; + // The first five letters: "abc " and aleph, not bet. + const QImage five = painted({0, 5}); + const int boundary = int(lines.caret(5).value().x()), middle = 14; + QCOMPARE(five.pixelColor(boundary + 3, middle), QColor(Qt::red)); + QCOMPARE(five.pixelColor(boundary - 3, middle), QColor(Qt::white)); + QCOMPARE(five.pixelColor(5, middle), QColor(Qt::red)); + // A tall line highlights top to bottom, past its letters. + LayerTextStyle tall = styled(QStringLiteral("abc"), 24); + tall.leading = 100; + QImage high(400, 100, QImage::Format_ARGB32_Premultiplied); + high.fill(Qt::white); + QPainter onHigh(&high); + onHigh.setPen(Qt::NoPen); + TextLines(tall, QSizeF(400, 100)).draw(onHigh, QPointF(0, 0), {0, 3}, {}, Qt::red); + onHigh.end(); + QCOMPARE(high.pixelColor(5, 2), QColor(Qt::red)); + QCOMPARE(high.pixelColor(5, 97), QColor(Qt::red)); + // Past a line's end it runs to the edge. + const QImage across = painted({1, 14}); + QCOMPARE(across.pixelColor(395, middle), QColor(Qt::red)); + QCOMPARE(across.pixelColor(1, 28 + middle), QColor(Qt::red)); + QCOMPARE(across.pixelColor(int(lines.caret(14).value().x()) + 4, 28 + middle), QColor(Qt::white)); + QCOMPARE(across.pixelColor(1, middle), QColor(Qt::white)); + // A lone newline does too; a wrap's start does not. + QCOMPARE(painted({11, 12}).pixelColor(395, middle), QColor(Qt::red)); + LayerTextStyle wrapped = styled(QStringLiteral("alpha beta"), 24); + const TextLines two(wrapped, QSizeF(90, 100)); + QImage second(90, 100, QImage::Format_ARGB32_Premultiplied); + second.fill(Qt::white); + QPainter onSecond(&second); + onSecond.setPen(Qt::NoPen); + two.draw(onSecond, QPointF(0, 0), {6, 10}, {}, Qt::red); + onSecond.end(); + QCOMPARE(second.pixelColor(85, middle), QColor(Qt::white)); + QCOMPARE(second.pixelColor(2, 28 + middle), QColor(Qt::red)); + QCOMPARE(across.pixelColor(395, 28 + middle), QColor(Qt::white)); + // Margins fill where a line reads from, or to. + const auto aligned = [](const QString &content, TextAlignment alignment) { + LayerTextStyle style = styled(content, 24); + style.alignment = alignment; + return style; + }; + const auto margins = [&](const LayerTextStyle &style, TextRange selection) { + QImage image(400, 100, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::white); + QPainter painter(&image); + painter.setPen(Qt::NoPen); + TextLines(style, QSizeF(400, 100)).draw(painter, QPointF(0, 0), selection, {}, Qt::red); + return image; + }; + const LayerTextStyle centred = aligned(QStringLiteral("ab\ncd"), TextAlignment::center); + const TextLines centredLines(centred, QSizeF(400, 100)); + QCOMPARE(margins(centred, {1, 5}).pixelColor(5, 28 + middle), QColor(Qt::red)); + QCOMPARE(margins(centred, {1, 5}).pixelColor(5, middle), QColor(Qt::white)); + QCOMPARE(margins(centred, {0, 3}).pixelColor(5, 28 + middle), QColor(Qt::white)); + const int d = int((centredLines.caret(4).value().x() + centredLines.caret(5).value().x()) / 2); + QCOMPARE(margins(centred, {1, 4}).pixelColor(d, 28 + middle), QColor(Qt::white)); + QCOMPARE(margins(aligned(QStringLiteral("אב\nגד"), TextAlignment::left), {1, 5}).pixelColor(395, 28 + middle), QColor(Qt::red)); + const QImage right = margins(aligned(QStringLiteral("אב\nגד"), TextAlignment::right), {1, 5}); + QCOMPARE(right.pixelColor(5, middle), QColor(Qt::red)); + QCOMPARE(right.pixelColor(395, middle), QColor(Qt::white)); +} + +QTEST_MAIN(TextLayoutTests) +#include "TextLayoutTests.moc" diff --git a/tests/TiledLayerTests.cpp b/tests/TiledLayerTests.cpp new file mode 100644 index 0000000..3bed189 --- /dev/null +++ b/tests/TiledLayerTests.cpp @@ -0,0 +1,358 @@ +#include "Rendering/TiledLayerRenderer.h" +#include "RenderFixtures.h" +#include <QtTest> + +class TiledLayerTests : public QObject { + Q_OBJECT +private slots: + void init(); + void tiledLayersDrawLikeOneImage_data(); + void tiledLayersDrawLikeOneImage(); + void tilesAtTheLayersEdgeKeepItsOutline_data(); + void tilesAtTheLayersEdgeKeepItsOutline(); + void theOutlineOpensByOneDevicePixel_data(); + void theOutlineOpensByOneDevicePixel(); + void piecesOpenOnlyWhereTheirImageEnds_data(); + void piecesOpenOnlyWhereTheirImageEnds(); + void piecesMeetingOnAPixelCentreLeaveNoGap_data(); + void piecesMeetingOnAPixelCentreLeaveNoGap(); + void oddSizedLayersKeepTheirOverhang(); + void clippedPaintersDrawTheTilesTheyShow(); + void emptyLayersDrawNothing(); + void transparentTilesReplaceTheBase(); + void opacityBlendModeAndMaskApplyOnce(); +}; + +void TiledLayerTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void TiledLayerTests::tiledLayersDrawLikeOneImage_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<double>("rotation"); + QTest::addColumn<bool>("flipped"); + QTest::newRow("0.2x") << 0.2 << 0.0 << false; + QTest::newRow("0.7x") << 0.7 << 0.0 << false; + QTest::newRow("0.3x turned") << 0.3 << 25.0 << false; + QTest::newRow("1x") << 1.0 << 0.0 << false; + QTest::newRow("0.4x flipped") << 0.4 << 0.0 << true; + QTest::newRow("a right angle") << 0.6 << 90.0 << false; + QTest::newRow("a right angle, flipped") << 0.6 << 270.0 << true; +} + +void TiledLayerTests::tiledLayersDrawLikeOneImage() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + QFETCH(bool, flipped); + const QImage base = noise(1600, 1000, 7); + // The second tile crosses a piece boundary at x=1024. + const std::vector<BrushPatch> committed{{QRectF(1100, 400, 256, 256), noise(256, 256, 99)}, + {QRectF(900, 100, 256, 256), noise(256, 256, 42)}}; + const auto raster = painted(base, committed); + const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1000}, .rotation = rotation, + .flipX = flipped, .flipY = flipped, .sampling = LayerSampling::high}; + // Fixed-point texture stepping: 3 measured upright on noise. + const int tolerance = rotation == 0 ? 4 : 12; + const int side = int(std::ceil(1700 * scale)); + const QPointF center(side / 2.0, side / 2.0); + const QImage finished = composite(base, committed); + const QImage expected = render(side, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); }); + const QImage drawn = render(side, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = scale}); }); + const int difference = largestDifference(expected, drawn); + QVERIFY2(difference <= tolerance, qPrintable(QString("a committed painted layer differs by %1").arg(difference))); + QVERIFY(largestDifference(expected, render(side, [](QPainter &) {})) > 100); + QVERIFY(!raster->hasMaterializedPixels()); +} + +void TiledLayerTests::tilesAtTheLayersEdgeKeepItsOutline_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<double>("rotation"); + QTest::addColumn<LayerSampling>("sampling"); + QTest::addColumn<bool>("transparentBase"); + QTest::addColumn<int>("tolerance"); + QTest::newRow("0.3x turned") << 0.3 << 25.0 << LayerSampling::high << false << 12; + QTest::newRow("1x turned") << 1.0 << 25.0 << LayerSampling::smooth << false << 12; + QTest::newRow("0.7x") << 0.7 << 0.0 << LayerSampling::high << false << 4; + QTest::newRow("nearest turned") << 0.5 << 25.0 << LayerSampling::nearest << false << 12; + QTest::newRow("turned past a right angle") << 0.6 << 205.0 << LayerSampling::high << false << 12; + // Below a device pixel per step, outlines still need room. + QTest::newRow("0.002x turned, tiles alone") << 0.002 << 25.0 << LayerSampling::high << true << 12; + QTest::newRow("0.01x turned, tiles alone") << 0.01 << 25.0 << LayerSampling::high << true << 12; + // Off the halving grid, pieces keep edge spill: 10 measured. + QTest::newRow("0.05x tiles alone") << 0.05 << 0.0 << LayerSampling::high << true << 12; + QTest::newRow("0.3x turned, tiles alone") << 0.3 << 25.0 << LayerSampling::high << true << 12; +} + +void TiledLayerTests::tilesAtTheLayersEdgeKeepItsOutline() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + QFETCH(LayerSampling, sampling); + QFETCH(bool, transparentBase); + QFETCH(int, tolerance); + // Nearest picks whole texels: a ramp keeps one-texel slips small. + const auto pixels = [&](int width, int height, int seed) { + return sampling == LayerSampling::nearest ? ramp(width, height, seed) : noise(width, height, quint32(seed)); + }; + const QImage base = transparentBase ? BrushRaster::context(800, 600, false) : pixels(800, 600, 7); + const std::vector<BrushPatch> corners{{QRectF(0, 0, 256, 256), pixels(256, 256, 99)}, + {QRectF(544, 344, 256, 256), pixels(256, 256, 42)}}; + const auto raster = painted(base, corners); + const LayerTransform transform{.origin = {0, 0}, .size = {800, 600}, .rotation = rotation, .sampling = sampling}; + const int side = int(std::ceil(1100 * scale)) + 4; + const QPointF center(side / 2.0, side / 2.0); + const QImage finished = composite(base, corners); + const QImage expected = render(side, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); }); + const QImage drawn = render(side, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = scale}); }); + // Every pixel counts here, the antialiased outline included. + int largest = 0, slipped = 0; + for (int y = 0; y < side; ++y) { + for (int x = 0; x < side * 4; ++x) { + const int difference = std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x]); + largest = std::max(largest, difference); + slipped += difference > tolerance; + } + } + if (sampling == LayerSampling::nearest) { + // Nearest slips single pixels along tile and layer edges. + QVERIFY2(slipped < 150, qPrintable(QString("%1 samples slipped").arg(slipped))); + return; + } + QVERIFY2(largest <= tolerance, qPrintable(QString("the outline differs by %1").arg(largest))); +} + +void TiledLayerTests::theOutlineOpensByOneDevicePixel_data() +{ + QTest::addColumn<QSizeF>("size"); + QTest::addColumn<double>("rotation"); + QTest::addColumn<QPointF>("painterScale"); + QTest::addColumn<QPointF>("center"); + QTest::addColumn<QList<QPoint>>("outline"); + QTest::addColumn<int>("alpha"); + // Outline pixels left, above, right and below the layer. + QTest::newRow("squashed") << QSizeF(801.2, 153.2) << 0.0 << QPointF(0.25, 0.25) << QPointF(420, 420) + << QList<QPoint>{{4, 90}, {30, 85}, {205, 115}, {170, 124}} << 35; + QTest::newRow("narrowed") << QSizeF(201.2, 601.2) << 0.0 << QPointF(0.25, 0.25) << QPointF(420, 420) + << QList<QPoint>{{79, 60}, {88, 29}, {130, 150}, {122, 180}} << 34; + QTest::newRow("unequal painter scales") << QSizeF(201.2, 630) << 0.0 << QPointF(0.25, 0.01) << QPointF(420, 500) + << QList<QPoint>{{79, 3}, {88, 1}, {130, 6}, {122, 8}} << 34; + QTest::newRow("a flat painter, turned") << QSizeF(201.2, 630) << 25.0 << QPointF(0.25, 0.01) << QPointF(420, 2000) + << QList<QPoint>{} << 0; + QTest::newRow("a thin painter, turned") << QSizeF(630, 201.2) << 25.0 << QPointF(0.01, 0.25) << QPointF(2000, 420) + << QList<QPoint>{} << 0; +} + +void TiledLayerTests::theOutlineOpensByOneDevicePixel() +{ + QFETCH(QSizeF, size); + QFETCH(double, rotation); + QFETCH(QPointF, painterScale); + QFETCH(QPointF, center); + QFETCH(QList<QPoint>, outline); + QFETCH(int, alpha); + // Both sides are whole halving steps: no edge spill. + const QImage base = BrushRaster::context(832, 640, false); + const std::vector<BrushPatch> corners{{QRectF(0, 0, 256, 256), ramp(256, 256, 99)}, + {QRectF(576, 384, 256, 256), ramp(256, 256, 42)}}; + const auto raster = painted(base, corners); + // Upright, every edge lies 0.35 pixels past a pixel centre. + const LayerTransform transform{.origin = {0, 0}, .size = size, .rotation = rotation, .sampling = LayerSampling::high}; + const auto scaled = [&](const std::function<void(QPainter &)> &draw) { + return render(210, [&](QPainter &painter) { + painter.scale(painterScale.x(), painterScale.y()); + draw(painter); + }); + }; + const QImage finished = composite(base, corners); + const QImage expected = scaled([&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter); }); + const QImage drawn = scaled([&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); }); + for (const QPoint &point : outline) { + QCOMPARE(qAlpha(expected.pixel(point)), alpha); + QCOMPARE(qAlpha(drawn.pixel(point)), alpha); + } + int largest = 0; + for (int y = 0; y < 210; ++y) { + for (int x = 0; x < 210 * 4; ++x) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x])); + } + QVERIFY2(largest <= 4, qPrintable(QString("the outline differs by %1").arg(largest))); +} + +void TiledLayerTests::piecesOpenOnlyWhereTheirImageEnds_data() +{ + QTest::addColumn<bool>("across"); + QTest::addColumn<bool>("patchAtTheEnd"); + QTest::newRow("an inner left edge") << true << true; + QTest::newRow("an inner right edge") << true << false; + QTest::newRow("an inner top edge") << false << true; + QTest::newRow("an inner bottom edge") << false << false; +} + +void TiledLayerTests::piecesOpenOnlyWhereTheirImageEnds() +{ + QFETCH(bool, across); + QFETCH(bool, patchAtTheEnd); + // One device pixel spans 1440 grid pixels, past a margin. + const double length = 2304, shown = 1.6; + const auto oriented = [&](double along, double aside) { return across ? QPointF(along, aside) : QPointF(aside, along); }; + const QPointF extent = oriented(length, 640); + const QImage base = noise(int(extent.x()), int(extent.y()), 5); + const std::vector<BrushPatch> patches{{QRectF(oriented(patchAtTheEnd ? length - 64 : 0, 288), QSizeF(64, 64)), noise(64, 64, 9)}}; + const auto raster = painted(base, patches); + // The base owns a pixel centred 96 grid pixels inside. + const double watched = patchAtTheEnd ? 5 : 7, inside = patchAtTheEnd ? 96 : length - 96; + const double start = watched + 0.5 - inside * shown / length; + const QPointF size = oriented(shown, 8), center = oriented(start + shown / 2, 6); + const LayerTransform transform{.origin = {0, 0}, .size = {size.x(), size.y()}, .sampling = LayerSampling::high}; + const QImage finished = composite(base, patches); + const QImage expected = render(12, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter); }); + const QImage drawn = render(12, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); }); + QCOMPARE(qAlpha(expected.pixel(oriented(watched, 6).toPoint())), 143); + int largest = 0; + for (int y = 0; y < 12; ++y) { + for (int x = 0; x < 12 * 4; ++x) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x])); + } + QVERIFY2(largest <= 4, qPrintable(QString("the far end differs by %1").arg(largest))); +} + +void TiledLayerTests::piecesMeetingOnAPixelCentreLeaveNoGap_data() +{ + QTest::addColumn<bool>("flipX"); + QTest::addColumn<bool>("flipY"); + QTest::newRow("upright") << false << false; + QTest::newRow("flipped across") << true << false; + QTest::newRow("flipped down") << false << true; +} + +void TiledLayerTests::piecesMeetingOnAPixelCentreLeaveNoGap() +{ + QFETCH(bool, flipX); + QFETCH(bool, flipY); + // Cells start at (1,1): halved, shared edges hit pixel centres. + const QImage base = solid(1600, 1600, qRgba(255, 0, 0, 255)); + const auto raster = std::make_shared<const RasterSnapshot>(1600, 1600, base, QRectF(base.rect()), + std::vector<BrushPatch>{BrushPatch{QRectF(900, 900, 256, 256), solid(256, 256, qRgba(0, 0, 255, 255))}}, false, QPointF(1, 1)); + const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1600}, .flipX = flipX, .flipY = flipY, .sampling = LayerSampling::smooth}; + QImage surface = BrushRaster::context(800, 800, false); + QPainter painter(&surface); + TiledLayerRenderer::drawRaster(raster, transform, QPointF(400, 400), painter, {.scale = 0.5}); + painter.end(); + const int left = flipX ? 800 - 578 : 450, top = flipY ? 800 - 578 : 450; + for (int y = top + 2; y < top + 126; ++y) { + for (int x = left + 2; x < left + 126; ++x) + QCOMPARE(surface.pixel(x, y), qRgba(0, 0, 255, 255)); + } +} + +void TiledLayerTests::oddSizedLayersKeepTheirOverhang() +{ + // 605x405 at 0.4x: the last halved texel passes the grid. + const QImage base = noise(605, 405, 71); + const std::vector<BrushPatch> corner{{QRectF(349, 149, 256, 256), noise(256, 256, 72)}}; + const auto raster = painted(base, corner); + const LayerTransform transform{.origin = {0, 0}, .size = {605, 405}, .sampling = LayerSampling::high}; + // The grid's far edges land on whole device pixels. + const QPointF center(10 + 121, 10 + 81); + const QImage expected = render(270, [&](QPainter &painter) { LayerRenderer::draw(composite(base, corner), transform, center, painter, {.scale = 0.4}); }); + const QImage drawn = render(270, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = 0.4}); }); + // Right of and below the grid: half a texel. + for (const QPoint &past : {QPoint(252, 100), QPoint(200, 172)}) { + QCOMPARE(qAlpha(expected.pixel(past)), 51); + QCOMPARE(qAlpha(drawn.pixel(past)), 51); + } + QVERIFY(largestAnywhere(expected, drawn) <= 4); +} + +void TiledLayerTests::clippedPaintersDrawTheTilesTheyShow() +{ + // A stretched grid: columns and rows scale apart. + const QImage base = noise(800, 600, 73); + const std::vector<BrushPatch> committed{{QRectF(500, 300, 256, 256), noise(256, 256, 74)}}; + const auto raster = painted(base, committed); + const LayerTransform transform{.origin = {0, 0}, .size = {400, 1200}, .sampling = LayerSampling::high}; + const QPointF center(210, 610); + const QRect shown(270, 700, 100, 300); + const auto clipped = [&](const std::function<void(QPainter &)> &draw) { + QImage surface = BrushRaster::context(420, 1220, false); + QPainter painter(&surface); + painter.setClipRect(shown); + draw(painter); + return surface; + }; + const QImage expected = clipped([&](QPainter &painter) { LayerRenderer::draw(composite(base, committed), transform, center, painter); }); + const QImage drawn = clipped([&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); }); + QVERIFY(largestAnywhere(expected, drawn) <= 4); + QCOMPARE(drawn.pixel(269, 800), qRgba(0, 0, 0, 0)); + QVERIFY(qAlpha(drawn.pixel(300, 800)) == 255); +} + +void TiledLayerTests::emptyLayersDrawNothing() +{ + const auto raster = painted(noise(64, 64, 75), {BrushPatch{QRectF(0, 0, 32, 32), noise(32, 32, 76)}}); + const QImage blank = BrushRaster::context(80, 80, false); + for (const QSizeF &size : {QSizeF(0, 64), QSizeF(64, 0)}) { + const LayerTransform transform{.origin = {8, 8}, .size = size, .sampling = LayerSampling::high}; + QCOMPARE(render(80, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, QPointF(40, 40), painter); }), blank); + } + const LayerTransform transform{.origin = {8, 8}, .size = {64, 64}, .sampling = LayerSampling::high}; + for (const QSize &grid : {QSize(0, 64), QSize(64, 0)}) { + QCOMPARE(render(80, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(grid.width(), grid.height(), QRectF(0, 0, 64, 64), {}, noise(64, 64, 75), nullptr, transform, QPointF(40, 40), painter); + }), blank); + } +} + +void TiledLayerTests::transparentTilesReplaceTheBase() +{ + const QImage base = solid(600, 400, qRgba(255, 0, 0, 255)); + const auto raster = painted(base, {BrushPatch{QRectF(256, 0, 256, 256), BrushRaster::context(256, 256, false)}}); + const LayerTransform transform{.origin = {0, 0}, .size = {600, 400}, .sampling = LayerSampling::nearest}; + QImage surface = solid(600, 400, qRgba(0, 0, 255, 255)); + QPainter painter(&surface); + TiledLayerRenderer::drawRaster(raster, transform, transform.center(), painter); + painter.end(); + QCOMPARE(surface.pixel(100, 100), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(300, 100), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(511, 255), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(512, 100), qRgba(255, 0, 0, 255)); + QCOMPARE(surface.pixel(300, 256), qRgba(255, 0, 0, 255)); +} + +void TiledLayerTests::opacityBlendModeAndMaskApplyOnce() +{ + const QImage base = noise(700, 500, 3); + const BrushPatch patch{QRectF(256, 256, 256, 244), noise(256, 244, 11, 180)}; + const auto raster = painted(base, {patch}); + const QImage finished = composite(base, {patch}); + QImage mask = gray(700, 500, 255); + for (int y = 0; y < 500; ++y) { + for (int x = 0; x < 700; ++x) + mask.scanLine(y)[x] = uchar((x + y) % 256); + } + const LayerTransform transform{.origin = {20, 10}, .size = {700, 500}, .sampling = LayerSampling::high}; + const LayerRenderer::Options options[] = {{.opacity = 0.6}, {.blendMode = LayerBlendMode::multiply}, {.mask = mask}, + {.opacity = 0.5, .blendMode = LayerBlendMode::luminosity, .mask = mask}}; + for (const LayerRenderer::Options &option : options) { + QImage expected = noise(760, 540, 21), drawn = expected; + QPainter first(&expected), second(&drawn); + LayerRenderer::draw(finished, transform, transform.center(), first, option); + TiledLayerRenderer::drawRaster(raster, transform, transform.center(), second, option); + first.end(); + second.end(); + int largest = 0; + for (int y = 12; y < 508; ++y) { + for (int x = 22 * 4; x < 718 * 4; ++x) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x])); + } + QVERIFY2(largest <= 2, qPrintable(QString("differs by %1").arg(largest))); + QVERIFY(drawn != noise(760, 540, 21)); + } +} + +QTEST_MAIN(TiledLayerTests) +#include "TiledLayerTests.moc" diff --git a/tests/TiledPieceTests.cpp b/tests/TiledPieceTests.cpp new file mode 100644 index 0000000..54abbe8 --- /dev/null +++ b/tests/TiledPieceTests.cpp @@ -0,0 +1,185 @@ +#include "Rendering/TiledLayerRenderer.h" +#include "AddressSpaceLimit.h" +#include "RenderFixtures.h" +#include <QtTest> + +class TiledPieceTests : public QObject { + Q_OBJECT +private slots: + void alignedGrowsOutwardToTheStepGrid(); + void interiorsFollowChangesCellByCell(); + void interiorsSkipWhatCannotShow(); + void supportGrowsWithTheLevel(); + void aPieceHoldsItsInteriorAndAMargin(); + void aPieceStaysInsideItsBounds(); + void piecesThatCannotExistAreNil(); + void piecesThatCannotBeAllocatedAreLoggedAndNil(); + void committedPiecesAreBuiltOncePerLevel(); + void thePieceCacheDropsTheLeastRecentlyUsed(); +}; + +void TiledPieceTests::alignedGrowsOutwardToTheStepGrid() +{ + QCOMPARE(TiledLayerRenderer::aligned(QRectF(3, 5, 10, 10), 8, QPointF(0, 0)), QRectF(0, 0, 16, 16)); + QCOMPARE(TiledLayerRenderer::aligned(QRectF(8, 16, 8, 8), 8, QPointF(0, 0)), QRectF(8, 16, 8, 8)); + QCOMPARE(TiledLayerRenderer::aligned(QRectF(3, 5, 10, 10), 8, QPointF(2, 1)), QRectF(2, 1, 16, 16)); + QCOMPARE(TiledLayerRenderer::aligned(QRectF(-3, -9, 2, 2), 4, QPointF(0, 0)), QRectF(-4, -12, 4, 8)); + QCOMPARE(TiledLayerRenderer::aligned(QRectF(3.5, 5.5, 1, 1), 1, QPointF(0, 0)), QRectF(3, 5, 2, 2)); +} + +void TiledPieceTests::interiorsFollowChangesCellByCell() +{ + using TiledLayerRenderer::interiors; + QCOMPARE(interiors({QRectF(300, 300, 100, 100)}, 8, 1024, 1, QPointF(0, 0), std::nullopt), + std::vector<QRectF>({QRectF(292, 292, 116, 116)})); + QCOMPARE(interiors({QRectF(1000, 300, 100, 100)}, 8, 1024, 1, QPointF(0, 0), std::nullopt), + std::vector<QRectF>({QRectF(992, 292, 32, 116), QRectF(1024, 292, 84, 116)})); + QCOMPARE(interiors({QRectF(10, 10, 20, 20), QRectF(100, 50, 20, 20)}, 0, 256, 1, QPointF(0, 0), std::nullopt), + std::vector<QRectF>({QRectF(10, 10, 110, 60)})); + QCOMPARE(interiors({QRectF(10, 10, 20, 20)}, 3, 256, 8, QPointF(0, 0), std::nullopt), + std::vector<QRectF>({QRectF(0, 0, 40, 40)})); + QCOMPARE(interiors({QRectF(10, 10, 20, 20)}, 0, 256, 8, QPointF(5, 5), std::nullopt), + std::vector<QRectF>({QRectF(5, 5, 32, 32)})); + QCOMPARE(interiors({QRectF(250, 10, 12, 300)}, 0, 256, 1, QPointF(0, 0), std::nullopt), + std::vector<QRectF>({QRectF(250, 10, 6, 246), QRectF(256, 10, 6, 246), QRectF(250, 256, 6, 54), QRectF(256, 256, 6, 54)})); + QVERIFY(interiors({}, 8, 256, 1, QPointF(0, 0), std::nullopt).empty()); +} + +void TiledPieceTests::interiorsSkipWhatCannotShow() +{ + using TiledLayerRenderer::interiors; + const std::vector<QRectF> changes{QRectF(10, 10, 20, 20), QRectF(600, 10, 20, 20)}; + QCOMPARE(interiors(changes, 4, 256, 1, QPointF(0, 0), QRectF(0, 0, 100, 100)), std::vector<QRectF>({QRectF(6, 6, 28, 28)})); + QCOMPARE(interiors(changes, 4, 256, 1, QPointF(0, 0), QRectF(500, 0, 200, 100)), std::vector<QRectF>({QRectF(596, 6, 28, 28)})); + QVERIFY(interiors(changes, 4, 256, 1, QPointF(0, 0), QRectF(200, 200, 50, 50)).empty()); + QCOMPARE(interiors(changes, 4, 1024, 1, QPointF(0, 0), QRectF(0, 0, 100, 100)), std::vector<QRectF>({QRectF(6, 6, 28, 28)})); + QCOMPARE(interiors(changes, 4, 1024, 1, QPointF(0, 0), std::nullopt), std::vector<QRectF>({QRectF(6, 6, 618, 28)})); + QCOMPARE(interiors({QRectF(250, 10, 12, 20)}, 0, 256, 1, QPointF(0, 0), QRectF(0, 0, 255, 100)), + std::vector<QRectF>({QRectF(250, 10, 6, 20)})); +} + +void TiledPieceTests::supportGrowsWithTheLevel() +{ + QCOMPARE(TiledLayerRenderer::support(0), 8.0); + QCOMPARE(TiledLayerRenderer::support(1), 32.0); + QCOMPARE(TiledLayerRenderer::support(3), 128.0); + QCOMPARE(TiledLayerRenderer::committedCell, 1024.0); + QCOMPARE(TiledLayerRenderer::strokeCell, 256.0); +} + +void TiledPieceTests::aPieceHoldsItsInteriorAndAMargin() +{ + QRectF composed; + const auto piece = TiledLayerRenderer::piece(QRectF(100, 200, 50, 60), 0, QPointF(0, 0), std::nullopt, false, + [&](QPainter &painter, const QRectF ®ion) { + composed = region; + painter.fillRect(QRectF(100, 200, 50, 60), QColor(0, 255, 0)); + }).value(); + QCOMPARE(piece.interior, QRectF(100, 200, 50, 60)); + QCOMPARE(piece.region, QRectF(92, 192, 66, 76)); + QCOMPARE(composed, piece.region); + QCOMPARE(piece.image.size(), QSize(66, 76)); + QCOMPARE(piece.image.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(piece.image.pixel(8, 8), qRgba(0, 255, 0, 255)); + QCOMPARE(piece.image.pixel(7, 8), qRgba(0, 0, 0, 0)); + QCOMPARE(piece.image.pixel(57, 67), qRgba(0, 255, 0, 255)); + QCOMPARE(piece.image.pixel(58, 67), qRgba(0, 0, 0, 0)); + + const auto reduced = TiledLayerRenderer::piece(QRectF(128, 128, 64, 64), 2, QPointF(0, 0), std::nullopt, true, + [](QPainter &painter, const QRectF ®ion) { painter.fillRect(region, Qt::white); }).value(); + QCOMPARE(reduced.region, QRectF(64, 64, 192, 192)); + QCOMPARE(reduced.image.size(), QSize(48, 48)); + QCOMPARE(reduced.image.format(), QImage::Format_Grayscale8); + QCOMPARE(reduced.image.constScanLine(24)[24], uchar(255)); + QCOMPARE(TiledLayerRenderer::piece(QRectF(130, 130, 10, 10), 2, QPointF(2, 1), std::nullopt, true, + [](QPainter &, const QRectF &) {}).value().region, QRectF(66, 65, 140, 140)); +} + +void TiledPieceTests::aPieceStaysInsideItsBounds() +{ + const auto noop = [](QPainter &, const QRectF &) {}; + const auto piece = TiledLayerRenderer::piece(QRectF(0, 0, 40, 40), 0, QPointF(0, 0), QRectF(0, 0, 100, 30), false, noop).value(); + QCOMPARE(piece.region, QRectF(0, 0, 48, 30)); + QCOMPARE(piece.interior, QRectF(0, 0, 40, 30)); + QCOMPARE(piece.image.size(), QSize(48, 30)); + const auto halved = TiledLayerRenderer::piece(QRectF(0, 0, 40, 40), 1, QPointF(0, 0), QRectF(0, 0, 101, 31), false, noop).value(); + QCOMPARE(halved.region, QRectF(0, 0, 72, 32)); + QCOMPARE(halved.image.size(), QSize(36, 16)); +} + +void TiledPieceTests::piecesThatCannotExistAreNil() +{ + const auto noop = [](QPainter &, const QRectF &) {}; + QVERIFY(!TiledLayerRenderer::piece(QRectF(500, 500, 40, 40), 0, QPointF(0, 0), QRectF(0, 0, 100, 100), false, noop).has_value()); + QVERIFY(!TiledLayerRenderer::piece(QRectF(0, 0, 9000, 9000), 0, QPointF(0, 0), std::nullopt, false, noop).has_value()); + QVERIFY(TiledLayerRenderer::piece(QRectF(0, 0, 7980, 7980), 0, QPointF(0, 0), std::nullopt, true, noop).has_value()); +} + +void TiledPieceTests::piecesThatCannotBeAllocatedAreLoggedAndNil() +{ + const auto noop = [](QPainter &, const QRectF &) {}; + const QRectF interior(0, 0, 7900, 7900); + const qint64 pieceBytes = qint64(7908) * 7908; + std::optional<AddressSpaceLimit> limit(std::in_place, 16 * 1024 * 1024); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("a tile piece could not be allocated: .*")); + const bool allocated = TiledLayerRenderer::piece(interior, 0, QPointF(0, 0), std::nullopt, true, noop).has_value(); + limit.reset(); + QVERIFY(!allocated); + + // Room for the piece, not for its halving as well. + limit.emplace(pieceBytes + pieceBytes / 2); + QTest::ignoreMessage(QtWarningMsg, "a tile piece could not be halved"); + const bool halved = TiledLayerRenderer::piece(QRectF(0, 0, 7844, 7844), 1, QPointF(0, 0), std::nullopt, true, noop).has_value(); + limit.reset(); + QVERIFY(!halved); +} + +void TiledPieceTests::committedPiecesAreBuiltOncePerLevel() +{ + TiledPieceCache cache; + const auto raster = painted(noise(1200, 300, 1), {BrushPatch{QRectF(900, 0, 256, 256), noise(256, 256, 2)}}); + const auto first = cache.pieces(raster, 0); + QCOMPARE(int(first.size()), 2); + QCOMPARE(first[0].interior, QRectF(892, 0, 132, 264)); + QCOMPARE(first[1].interior, QRectF(1024, 0, 140, 264)); + QCOMPARE(first[0].image.pixel(16, 0), noise(256, 256, 2).pixel(0, 0)); + QCOMPARE(cache.pieces(raster, 0)[0].image.cacheKey(), first[0].image.cacheKey()); + const auto halved = cache.pieces(raster, 1); + QVERIFY(halved[0].image.cacheKey() != first[0].image.cacheKey()); + QCOMPARE(halved[0].interior, QRectF(868, 0, 156, 288)); + QCOMPARE(halved[0].region, QRectF(836, 0, 220, 300)); + QCOMPARE(halved[0].image.size(), QSize(110, 150)); + QVERIFY(!raster->hasMaterializedPixels()); + + // A snapshot's halving grid starts at its alignment. + const auto shifted = std::make_shared<const RasterSnapshot>(1200, 300, noise(1200, 300, 1), QRectF(0, 0, 1200, 300), + std::vector<BrushPatch>{BrushPatch{QRectF(900, 0, 256, 256), noise(256, 256, 2)}}, false, QPointF(3, 5)); + const auto offGrid = cache.pieces(shifted, 2); + QCOMPARE(int(offGrid.size()), 4); + QCOMPARE(offGrid[0].interior, QRectF(835, -3, 192, 8)); + QCOMPARE(offGrid[0].region, QRectF(771, -3, 320, 72)); + QCOMPARE(offGrid[1].interior, QRectF(1027, -3, 176, 8)); + QCOMPARE(offGrid[2].interior, QRectF(835, 5, 192, 296)); + QCOMPARE(offGrid[2].region, QRectF(771, -3, 320, 304)); + QCOMPARE(offGrid[2].image.size(), QSize(80, 76)); + QCOMPARE(offGrid[3].interior, QRectF(1027, 5, 176, 296)); +} + +void TiledPieceTests::thePieceCacheDropsTheLeastRecentlyUsed() +{ + TiledPieceCache cache(3 * 132 * 132); + const auto make = [&](quint32 seed) { return painted(noise(300, 300, seed), {BrushPatch{QRectF(100, 100, 100, 100), noise(100, 100, seed + 50)}}); }; + const auto a = make(1), b = make(2), c = make(3), d = make(4); + const qint64 firstA = cache.pieces(a, 0)[0].image.cacheKey(), firstB = cache.pieces(b, 0)[0].image.cacheKey(); + cache.pieces(c, 0); + QCOMPARE(cache.pieces(a, 0)[0].image.cacheKey(), firstA); + cache.pieces(d, 0); + QCOMPARE(cache.pieces(a, 0)[0].image.cacheKey(), firstA); + QVERIFY(cache.pieces(b, 0)[0].image.cacheKey() != firstB); + TiledPieceCache tiny(10); + const qint64 only = tiny.pieces(a, 0)[0].image.cacheKey(); + QCOMPARE(tiny.pieces(a, 0)[0].image.cacheKey(), only); +} + +QTEST_MAIN(TiledPieceTests) +#include "TiledPieceTests.moc" diff --git a/tests/TiledStrokeTests.cpp b/tests/TiledStrokeTests.cpp new file mode 100644 index 0000000..902ce71 --- /dev/null +++ b/tests/TiledStrokeTests.cpp @@ -0,0 +1,470 @@ +#include "Rendering/TiledLayerRenderer.h" +#include "Document/LayerMask.h" +#include "RenderFixtures.h" +#include <QtTest> + +namespace { +QImage grayNoise(int width, int height, quint32 seed) +{ + QImage image = BrushRaster::context(width, height, true); + quint32 state = seed; + for (int y = 0; y < height; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x) { + state = state * 1'664'525u + 1'013'904'223u; + row[x] = uchar(state >> 24); + } + } + return image; +} + +QImage maskComposite(const QImage &base, const QRectF &at, const std::vector<BrushPatch> &patches, QSize size) +{ + QImage result = gray(size.width(), size.height(), 255); + QPainter painter(&result); + BrushRaster::draw(base, at, painter); + for (const BrushPatch &patch : patches) + BrushRaster::draw(patch.image, patch.rect, painter); + return result; +} + +// A square surface behind a painter scaled like dense displays. +QImage scaled(int side, double device, const std::function<void(QPainter &)> &draw) +{ + return render(int(std::ceil(side * device)), [&](QPainter &painter) { + painter.scale(device, device); + draw(painter); + }); +} + +ImportedImage asset(const QImage &image) +{ + return ImportedImage(image, image, "Mask"); +} +} + +class TiledStrokeTests : public QObject { + Q_OBJECT +private slots: + void init(); + void strokesDrawLikeOneImage_data(); + void strokesDrawLikeOneImage(); + void maskStrokesDrawLikeOneMask_data(); + void maskStrokesDrawLikeOneMask(); + void paintingAtTheLayersEdgeDoesNotChangeIt_data(); + void paintingAtTheLayersEdgeDoesNotChangeIt(); + void translucentStrokesDrawLikeOneImage_data(); + void translucentStrokesDrawLikeOneImage(); + void paintPastTheOldBoundsIsRevealed_data(); + void paintPastTheOldBoundsIsRevealed(); + void aRasterAtAnOffsetKeepsItsPieces(); + void aLiveStrokeMatchesItsCommit_data(); + void aLiveStrokeMatchesItsCommit(); + void maskStrokesOverNoMaskAndOverAPaintedMask(); + void aMaskStrokeTakesNoSecondMask(); + void strokesApplyOpacityBlendModeAndMaskOnce(); +}; + +void TiledStrokeTests::init() +{ + QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore")); +} + +void TiledStrokeTests::strokesDrawLikeOneImage_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<double>("rotation"); + QTest::addColumn<LayerSampling>("sampling"); + QTest::addColumn<double>("device"); + QTest::newRow("0.2x") << 0.2 << 0.0 << LayerSampling::high << 1.0; + QTest::newRow("0.7x") << 0.7 << 0.0 << LayerSampling::high << 1.0; + QTest::newRow("0.3x turned") << 0.3 << 25.0 << LayerSampling::high << 1.0; + // Nearest never halves: tiles and image pick the same texels. + QTest::newRow("0.25x nearest") << 0.25 << 0.0 << LayerSampling::nearest << 1.0; + // A 2x painter shows 0.35x at 0.7: no halving. + QTest::newRow("0.35x on a 2x painter") << 0.35 << 0.0 << LayerSampling::high << 2.0; +} + +void TiledStrokeTests::strokesDrawLikeOneImage() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + QFETCH(LayerSampling, sampling); + QFETCH(double, device); + const QImage base = noise(1600, 1000, 7); + const std::vector<BrushPatch> committed{{QRectF(1100, 400, 256, 256), noise(256, 256, 99)}}; + const auto raster = painted(base, committed); + const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1000}, .rotation = rotation, .sampling = sampling}; + const int tolerance = rotation == 0 ? 4 : 12; + const int side = int(std::ceil(1700 * scale)); + const QPointF center(side / 2.0, side / 2.0); + const QRectF grid(0, 0, 1600, 1000); + // A live stroke over the committed layer, across a boundary. + const std::vector<BrushPatch> stroke{{QRectF(900, 500, 256, 256), noise(256, 256, 5)}}; + const QImage finished = composite(composite(base, committed), stroke); + const QImage expectedLive = scaled(side, device, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); }); + const QImage live = scaled(side, device, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(1600, 1000, grid, stroke, QImage(), raster, transform, center, painter, {.scale = scale}); + }); + QVERIFY2(largestDifference(expectedLive, live) <= tolerance, "a live stroke on a painted layer"); + QVERIFY(!raster->hasMaterializedPixels()); + + // A first stroke on a plain image. + const QImage first = composite(base, stroke); + const QImage expectedFirst = scaled(side, device, [&](QPainter &painter) { LayerRenderer::draw(first, transform, center, painter, {.scale = scale}); }); + const QImage drawnFirst = scaled(side, device, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(1600, 1000, grid, stroke, base, nullptr, transform, center, painter, {.scale = scale}); + }); + QVERIFY2(largestDifference(expectedFirst, drawnFirst) <= tolerance, "a first stroke on an image"); + // Far past the tolerance: a stale stroke cannot pass. + QVERIFY(largestDifference(expectedFirst, expectedLive) > 50); +} + +void TiledStrokeTests::maskStrokesDrawLikeOneMask_data() +{ + strokesDrawLikeOneImage_data(); +} + +void TiledStrokeTests::maskStrokesDrawLikeOneMask() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + QFETCH(LayerSampling, sampling); + QFETCH(double, device); + const QImage layer = noise(1600, 1000, 11); + const QImage oldMask = grayNoise(1600, 1000, 12); + const std::vector<BrushPatch> tile{{QRectF(900, 500, 256, 256), grayNoise(256, 256, 13)}}; + const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1000}, .rotation = rotation, .sampling = sampling}; + const int tolerance = rotation == 0 ? 4 : 12; + const int side = int(std::ceil(1700 * scale)); + const QPointF center(side / 2.0, side / 2.0); + const QRectF grid(0, 0, 1600, 1000); + const QImage unmasked = scaled(side, device, [&](QPainter &painter) { LayerRenderer::draw(layer, transform, center, painter, {.scale = scale}); }); + const QImage finishedMask = maskComposite(oldMask, grid, tile, QSize(1600, 1000)); + const QImage expected = scaled(side, device, [&](QPainter &painter) { + LayerRenderer::draw(layer, transform, center, painter, {.scale = scale, .mask = finishedMask}); + }); + const QImage live = scaled(side, device, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(1600, 1000, grid, tile, asset(oldMask), layer, nullptr, transform, center, painter, {.scale = scale}); + }); + QVERIFY2(largestDifference(expected, live, unmasked) <= tolerance, "a live mask stroke"); + const QImage stale = scaled(side, device, [&](QPainter &painter) { + LayerRenderer::draw(layer, transform, center, painter, {.scale = scale, .mask = oldMask}); + }); + QVERIFY(largestDifference(expected, stale, unmasked) > 50); +} + +void TiledStrokeTests::paintingAtTheLayersEdgeDoesNotChangeIt_data() +{ + QTest::addColumn<double>("zoom"); + QTest::newRow("10.749x") << 10.749; + QTest::newRow("1x") << 1.0; +} + +void TiledStrokeTests::paintingAtTheLayersEdgeDoesNotChangeIt() +{ + QFETCH(double, zoom); + // Each patch holds the pixels beneath it: nothing may change. + const QImage image = noise(3360, 1812, 11); + const LayerTransform transform{.origin = {0, 0}, .size = {336, 181}, .sampling = LayerSampling::high}; + // The layer's corner sits 50 points into a retina view. + const QPointF center(336 * zoom / 2 + 50, 181 * zoom / 2 + 50); + const QRectF grid(0, 0, 3360, 1812); + const auto retina = [](const std::function<void(QPainter &)> &draw) { + return render(1200, [&](QPainter &painter) { + painter.scale(2, 2); + draw(painter); + }); + }; + const LayerMask mask = LayerMask::solid(true); + const QImage plain = retina([&](QPainter &painter) { LayerRenderer::draw(image, transform, center, painter, {.scale = zoom}); }); + const QImage masked = retina([&](QPainter &painter) { + LayerRenderer::draw(image, transform, center, painter, {.scale = zoom, .mask = mask.asset.image()}); + }); + for (const QPoint &origin : {QPoint(0, 0), QPoint(256, 256)}) { + const QRect rect(origin, QSize(256, 256)); + const QImage painting = retina([&](QPainter &painter) { + TiledLayerRenderer::drawStroke(3360, 1812, grid, {BrushPatch{QRectF(rect), image.copy(rect)}}, image, nullptr, transform, center, + painter, {.scale = zoom}); + }); + QVERIFY2(largestAnywhere(plain, painting) <= 2, "painting at the edge"); + // A mask stroke shows the layer through its painted mask. + const QImage maskPainting = retina([&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(3360, 1812, grid, {BrushPatch{QRectF(rect), gray(256, 256, 255)}}, mask.asset, image, nullptr, + transform, center, painter, {.scale = zoom}); + }); + QVERIFY2(largestAnywhere(masked, maskPainting) <= 2, "painting a mask at the edge"); + } +} + +void TiledStrokeTests::translucentStrokesDrawLikeOneImage_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<double>("rotation"); + QTest::newRow("0.3x") << 0.3 << 0.0; + QTest::newRow("0.7x turned") << 0.7 << 25.0; +} + +void TiledStrokeTests::translucentStrokesDrawLikeOneImage() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + // Translucent pixels show any double draw as a line. + const QImage base = noise(1600, 1000, 21, 128); + const std::vector<BrushPatch> tile{{QRectF(900, 500, 256, 256), noise(256, 256, 22, 128)}}; + const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1000}, .rotation = rotation, .sampling = LayerSampling::high}; + const int side = int(std::ceil(1700 * scale)); + const QPointF center(side / 2.0, side / 2.0); + const QImage inside = render(side, [&](QPainter &painter) { LayerRenderer::draw(noise(1600, 1000, 1), transform, center, painter, {.scale = scale}); }); + const QImage finished = composite(base, tile); + const QImage expected = render(side, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); }); + const QImage live = render(side, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(1600, 1000, QRectF(0, 0, 1600, 1000), tile, base, nullptr, transform, center, painter, {.scale = scale}); + }); + QVERIFY2(largestDifference(expected, live, inside) <= (rotation == 0 ? 4 : 12), "a translucent live stroke"); +} + +void TiledStrokeTests::paintPastTheOldBoundsIsRevealed_data() +{ + QTest::addColumn<double>("scale"); + QTest::addColumn<double>("rotation"); + QTest::newRow("1x") << 1.0 << 0.0; + QTest::newRow("0.5x") << 0.5 << 0.0; + QTest::newRow("0.6x turned") << 0.6 << 25.0; +} + +void TiledStrokeTests::paintPastTheOldBoundsIsRevealed() +{ + QFETCH(double, scale); + QFETCH(double, rotation); + // The old 400x300 layer sits inside a grid paint grew. + const QImage old = noise(400, 300, 31); + const QImage oldMask = grayNoise(400, 300, 32); + const QRectF sourceRect(128, 96, 400, 300); + // One tile straddles the old edge, one lies past it. + const std::vector<BrushPatch> patches{{QRectF(450, 200, 128, 128), noise(128, 128, 33)}, {QRectF(16, 16, 64, 64), noise(64, 64, 34)}}; + QImage finished = BrushRaster::context(640, 480, false); + { + QPainter painter(&finished); + BrushRaster::draw(old, sourceRect, painter); + for (const BrushPatch &patch : patches) + BrushRaster::draw(patch.image, patch.rect, painter); + } + const QImage finishedMask = maskComposite(oldMask, sourceRect, {}, QSize(640, 480)); + const LayerTransform transform{.origin = {0, 0}, .size = {640, 480}, .rotation = rotation, .sampling = LayerSampling::high}; + const int side = int(std::ceil(820 * scale)); + const QPointF center(side / 2.0, side / 2.0); + const QImage expected = render(side, [&](QPainter &painter) { + LayerRenderer::draw(finished, transform, center, painter, {.scale = scale, .mask = finishedMask}); + }); + const QImage live = render(side, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(640, 480, sourceRect, patches, old, nullptr, transform, center, painter, {.scale = scale, .mask = oldMask}); + }); + // Turned, the old outline stays masked until commit, like macOS. + QTransform toGrid; + toGrid.translate(320, 240); + toGrid.scale(1 / scale, 1 / scale); + toGrid.rotate(-rotation); + toGrid.translate(-center.x(), -center.y()); + const QRectF outline = sourceRect.adjusted(-2, -2, 2, 2), within = sourceRect.adjusted(2, 2, -2, -2); + int largest = 0; + for (int y = 0; y < side; ++y) { + for (int x = 0; x < side; ++x) { + const QPointF grid = toGrid.map(QPointF(x + 0.5, y + 0.5)); + const bool pieces = QRectF(443, 193, 142, 142).contains(grid) || QRectF(9, 9, 78, 78).contains(grid); + if (rotation != 0 && !pieces && outline.contains(grid) && !within.contains(grid)) + continue; + for (int channel = 0; channel < 4; ++channel) + largest = std::max(largest, std::abs(expected.constScanLine(y)[x * 4 + channel] - live.constScanLine(y)[x * 4 + channel])); + } + } + QVERIFY2(largest <= (rotation == 0 ? 4 : 12), qPrintable(QString("paint past the old bounds differs by %1").arg(largest))); +} + +void TiledStrokeTests::aRasterAtAnOffsetKeepsItsPieces() +{ + // Paint grew the grid: the layer now sits at (128,96). + const QImage base = noise(600, 400, 41); + const std::vector<BrushPatch> committed{{QRectF(300, 100, 256, 256), noise(256, 256, 42)}}; + const auto raster = painted(base, committed); + const QRectF sourceRect(128, 96, 600, 400); + const std::vector<BrushPatch> stroke{{QRectF(40, 40, 64, 64), noise(64, 64, 43)}, {QRectF(500, 300, 128, 128), noise(128, 128, 44)}}; + QImage finished = BrushRaster::context(800, 608, false); + { + QPainter painter(&finished); + BrushRaster::draw(composite(base, committed), sourceRect, painter); + for (const BrushPatch &patch : stroke) + BrushRaster::draw(patch.image, patch.rect, painter); + } + const LayerTransform transform{.origin = {0, 0}, .size = {800, 608}, .sampling = LayerSampling::high}; + const QImage expected = render(440, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, QPointF(220, 220), painter, {.scale = 0.5}); }); + const QImage live = render(440, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(800, 608, sourceRect, stroke, QImage(), raster, transform, QPointF(220, 220), painter, {.scale = 0.5}); + }); + QVERIFY2(largestAnywhere(expected, live) <= 4, "a painted layer at an offset"); + QVERIFY(!raster->hasMaterializedPixels()); +} + +void TiledStrokeTests::aLiveStrokeMatchesItsCommit_data() +{ + QTest::addColumn<bool>("paintedBefore"); + QTest::newRow("over an image") << false; + QTest::newRow("over a painted layer") << true; +} + +void TiledStrokeTests::aLiveStrokeMatchesItsCommit() +{ + QFETCH(bool, paintedBefore); + // Odd origins: a halving grid started elsewhere shifts every pixel. + const QImage base = noise(600, 400, 61); + const std::vector<BrushPatch> committed{{QRectF(300, 100, 256, 256), noise(256, 256, 62)}}; + // An earlier edit grew the painted layer one pixel left. + const auto before = std::make_shared<const RasterSnapshot>(601, 400, base, QRectF(1, 0, 600, 400), committed, false, QPointF(1, 0)); + const ImportedImage source = paintedBefore ? ImportedImage(before, base, "Layer") : ImportedImage(base, base, "Layer"); + const QRectF sourceRect = paintedBefore ? QRectF(128, 97, 601, 400) : QRectF(129, 97, 600, 400); + // One tile over old pixels, one past the grid's right. + const std::vector<BrushPatch> stroke{{QRectF(500, 300, 128, 128), noise(128, 128, 63)}, {QRectF(800, 200, 96, 64), noise(96, 64, 64)}}; + const LayerTransform transform{.origin = {0, 0}, .size = {860, 600}, .sampling = LayerSampling::high}; + const QPointF center(190, 140); + const QImage live = render(400, [&](QPainter &painter) { + TiledLayerRenderer::drawStroke(860, 600, sourceRect, stroke, paintedBefore ? QImage() : base, paintedBefore ? before : nullptr, transform, + center, painter, {.scale = 0.4}); + }); + // The commit keeps the grid: paint past it grows it. + const auto commit = RasterSnapshot::replacing(source, sourceRect, stroke, QRectF(0, 0, 896, 600)); + QCOMPARE(commit->alignment, QPointF(129, 97)); + const LayerTransform grown{.origin = {0, 0}, .size = {896, 600}, .sampling = LayerSampling::high}; + const QImage kept = render(400, [&](QPainter &painter) { + TiledLayerRenderer::drawRaster(commit, grown, center + QPointF(18 * 0.4, 0), painter, {.scale = 0.4}); + }); + // A commit's larger pieces redraw the old outline with spill. + const QRectF outer = sourceRect.adjusted(-3, -3, 3, 3), inner = sourceRect.adjusted(3, 3, -3, -3); + int largest = 0, onTheOutline = 0; + for (int y = 0; y < 400; ++y) { + for (int x = 0; x < 400; ++x) { + const QPointF grid((x + 0.5 - 18) / 0.4, (y + 0.5 - 20) / 0.4); + int &worst = outer.contains(grid) && !inner.contains(grid) ? onTheOutline : largest; + for (int channel = 0; channel < 4; ++channel) + worst = std::max(worst, std::abs(kept.constScanLine(y)[x * 4 + channel] - live.constScanLine(y)[x * 4 + channel])); + } + } + QVERIFY2(largest <= 4, qPrintable(QString("the commit shifts pixels by %1").arg(largest))); + QVERIFY2(onTheOutline <= 12, qPrintable(QString("the old outline changes by %1").arg(onTheOutline))); + QVERIFY(largestAnywhere(kept, render(400, [](QPainter &) {})) > 100); +} + +void TiledStrokeTests::maskStrokesOverNoMaskAndOverAPaintedMask() +{ + const QImage layer = noise(800, 600, 51); + const std::vector<BrushPatch> tile{{QRectF(200, 200, 128, 128), grayNoise(128, 128, 52)}}; + const LayerTransform transform{.origin = {0, 0}, .size = {800, 600}, .sampling = LayerSampling::high}; + const QRectF grid(0, 0, 800, 600); + const QPointF center(300, 300); + const LayerRenderer::Options half{.scale = 0.7}; + const QImage unmasked = render(600, [&](QPainter &painter) { LayerRenderer::draw(layer, transform, center, painter, half); }); + const auto through = [&](const QImage &mask) { + return render(600, [&](QPainter &painter) { LayerRenderer::draw(layer, transform, center, painter, {.scale = 0.7, .mask = mask}); }); + }; + // No old mask: everything shows but what the stroke hides. + const QImage bare = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(800, 600, grid, tile, std::nullopt, layer, nullptr, transform, center, painter, half); + }); + QVERIFY(largestDifference(through(maskComposite(gray(1, 1, 255), grid, tile, QSize(800, 600))), bare, unmasked) <= 4); + QVERIFY(largestDifference(unmasked, bare, unmasked) > 50); + + // A solid old mask stretches over the layer and hides. + const QImage hidden = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(800, 600, grid, tile, LayerMask::solid(false).asset, layer, nullptr, transform, center, painter, half); + }); + QVERIFY(largestDifference(through(maskComposite(gray(1, 1, 0), grid, tile, QSize(800, 600))), hidden, unmasked) <= 4); + + // A painted layer shows through the stroke from its tiles. + const std::vector<BrushPatch> committed{{QRectF(150, 100, 256, 256), noise(256, 256, 55)}}; + const auto raster = painted(layer, committed); + const QImage tiled = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(800, 600, grid, tile, std::nullopt, QImage(), raster, transform, center, painter, half); + }); + const QImage shown = render(600, [&](QPainter &painter) { + LayerRenderer::draw(composite(layer, committed), transform, center, painter, + {.scale = 0.7, .mask = maskComposite(gray(1, 1, 255), grid, tile, QSize(800, 600))}); + }); + QVERIFY(largestDifference(shown, tiled, unmasked) <= 4); + QVERIFY(!raster->hasMaterializedPixels()); + + // Layer and mask at an odd offset: every pixel judged. + const QRectF placed(65, 33, 800, 600); + const std::vector<BrushPatch> edge{{QRectF(0, 200, 128, 128), grayNoise(128, 128, 57)}}; + const std::vector<BrushPatch> moved{{edge[0].rect.translated(65, 33), edge[0].image}}; + const LayerTransform wide{.origin = {0, 0}, .size = {928, 664}, .sampling = LayerSampling::high}; + for (const bool paintedLayer : {false, true}) { + const QImage offset = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(928, 664, placed, moved, asset(grayNoise(800, 600, 56)), paintedLayer ? QImage() : layer, + paintedLayer ? raster : nullptr, wide, center - QPointF(0.4, 0.4), painter, {.scale = 0.4}); + }); + const QImage expectedOffset = render(600, [&](QPainter &painter) { + LayerRenderer::draw(paintedLayer ? composite(layer, committed) : layer, transform, center, painter, + {.scale = 0.4, .mask = maskComposite(grayNoise(800, 600, 56), grid, edge, QSize(800, 600))}); + }); + QVERIFY(largestAnywhere(expectedOffset, offset) <= 4); + } + + // A mask matching one side only stretches over the layer. + for (const QSize &strip : {QSize(800, 1), QSize(1, 600)}) { + const QImage band = grayNoise(strip.width(), strip.height(), 58); + const QImage stretched = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(800, 600, grid, tile, asset(band), layer, nullptr, transform, center, painter, half); + }); + QVERIFY(largestDifference(through(maskComposite(band, grid, tile, QSize(800, 600))), stretched, unmasked) <= 4); + } + + // A painted old mask stays in tiles. + const QImage maskBase = grayNoise(800, 600, 53); + const std::vector<BrushPatch> maskTiles{{QRectF(100, 150, 256, 256), grayNoise(256, 256, 54)}}; + const auto maskRaster = std::make_shared<const RasterSnapshot>(800, 600, maskBase, grid, maskTiles, true); + const ImportedImage paintedMask(maskRaster, maskBase, "Mask"); + const QImage live = render(600, [&](QPainter &painter) { + TiledLayerRenderer::drawMaskStroke(800, 600, grid, tile, paintedMask, layer, nullptr, transform, center, painter, half); + }); + const QImage finishedMask = maskComposite(maskComposite(maskBase, grid, maskTiles, QSize(800, 600)), grid, tile, QSize(800, 600)); + QVERIFY(largestDifference(through(finishedMask), live, unmasked) <= 4); + QVERIFY(largestDifference(through(maskBase), live, unmasked) > 50); +} + +void TiledStrokeTests::aMaskStrokeTakesNoSecondMask() +{ + QImage surface = BrushRaster::context(64, 64, false); + QPainter painter(&surface); + const LayerTransform transform{.origin = {0, 0}, .size = {64, 64}, .sampling = LayerSampling::high}; + QVERIFY_EXCEPTION_THROWN(TiledLayerRenderer::drawMaskStroke(64, 64, QRectF(0, 0, 64, 64), {}, std::nullopt, noise(64, 64, 1), nullptr, transform, + QPointF(32, 32), painter, {.mask = gray(64, 64, 255)}), + std::logic_error); +} + +void TiledStrokeTests::strokesApplyOpacityBlendModeAndMaskOnce() +{ + const QImage base = noise(700, 500, 3); + // Translucent paint across four stroke cells. + const std::vector<BrushPatch> stroke{{QRectF(200, 200, 128, 128), noise(128, 128, 11, 180)}}; + const QImage finished = composite(base, stroke); + QImage mask = gray(700, 500, 255); + for (int y = 0; y < 500; ++y) { + for (int x = 0; x < 700; ++x) + mask.scanLine(y)[x] = uchar((x + y) % 256); + } + const LayerTransform transform{.origin = {20, 10}, .size = {700, 500}, .sampling = LayerSampling::high}; + const LayerRenderer::Options options[] = {{.opacity = 0.6}, {.blendMode = LayerBlendMode::multiply}, {.mask = mask}, + {.opacity = 0.5, .blendMode = LayerBlendMode::luminosity, .mask = mask}}; + for (const LayerRenderer::Options &option : options) { + QImage expected = noise(760, 540, 21), drawn = expected; + QPainter first(&expected), second(&drawn); + LayerRenderer::draw(finished, transform, transform.center(), first, option); + TiledLayerRenderer::drawStroke(700, 500, QRectF(0, 0, 700, 500), stroke, base, nullptr, transform, transform.center(), second, option); + first.end(); + second.end(); + QVERIFY2(largestAnywhere(expected, drawn) <= 2, "opacity, blend mode and mask apply once"); + QVERIFY(drawn != noise(760, 540, 21)); + } +} + +QTEST_MAIN(TiledStrokeTests) +#include "TiledStrokeTests.moc" diff --git a/tests/ToolIconTests.cpp b/tests/ToolIconTests.cpp new file mode 100644 index 0000000..66ef7d5 --- /dev/null +++ b/tests/ToolIconTests.cpp @@ -0,0 +1,348 @@ +#include "UI/ToolIcons.h" +#include <QtTest> + +// Every tool's icon: drawn, tinted, its own. +namespace { +const NavigationTool tools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand, + NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp, + NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type, + NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom}; + +QImage drawn(NavigationTool tool, const QColor &colour, int side = 36, QPointF origin = {0, 0}, ToolIconKind kind = ToolIconKind::plain) +{ + QImage image(side + 8, side + 8, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + ToolIcons::paint(painter, tool, origin, side, colour, kind); + return image; +} + +int inked(const QImage &image) +{ + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + count += qAlpha(image.pixel(x, y)) > 0; + } + return count; +} + +// The smallest rectangle around every pixel well inked. +QRect extent(const QImage &image) +{ + QRect result; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, y)) > 127) + result |= QRect(x, y, 1, 1); + } + } + return result; +} +} + +class ToolIconTests : public QObject { + Q_OBJECT +private slots: + void everyToolHasItsOwnIcon(); + void anIconTakesTheColourItIsGivenAndNoOther(); + void anIconStaysInsideItsBoxAtAnySizeAndPlace(); + void thePainterComesBackAsItWent(); + void swiftsTwoDrawnIconsKeepTheirGeometry(); + void noToolHasNoIcon(); + void anIconKeepsToTheCallersClip(); + void nothingOfTheCallersPainterShowsInAnIcon(); + void theFadeHasNoSeamsAtAFractionalScale(); + void theDrawingsMatchTheirReference(); +}; + +void ToolIconTests::everyToolHasItsOwnIcon() +{ + QList<QImage> icons; + for (const NavigationTool tool : tools) { + const QImage icon = drawn(tool, Qt::white); + // An icon is a drawing: no smudge, no slab. + QVERIFY2(inked(icon) > 60 && inked(icon) < 36 * 36 * 9 / 10, qPrintable(QString::number(inked(icon)))); + QVERIFY(!icons.contains(icon)); + icons << icon; + } + QCOMPARE(icons.size(), 15); + // The Marquee's, the Lasso's and the brush's kinds are icons. + for (const auto &[tool, kind] : {std::pair(NavigationTool::marquee, ToolIconKind::ellipse), std::pair(NavigationTool::lasso, ToolIconKind::polygonal), + std::pair(NavigationTool::brush, ToolIconKind::eraser)}) { + const QImage icon = drawn(tool, Qt::white, 36, {0, 0}, kind); + QVERIFY(inked(icon) > 60 && !icons.contains(icon)); + icons << icon; + } + // A kind another tool lacks leaves its icon alone. + QCOMPARE(drawn(NavigationTool::brush, Qt::white, 36, {0, 0}, ToolIconKind::ellipse), drawn(NavigationTool::brush, Qt::white)); + QCOMPARE(drawn(NavigationTool::move, Qt::white, 36, {0, 0}, ToolIconKind::eraser), drawn(NavigationTool::move, Qt::white)); +} + +void ToolIconTests::anIconTakesTheColourItIsGivenAndNoOther() +{ + const QColor colour(200, 100, 50); + for (const NavigationTool tool : tools) { + const QImage icon = drawn(tool, colour); + bool solid = false; + for (int y = 0; y < icon.height(); ++y) { + for (int x = 0; x < icon.width(); ++x) { + const QColor pixel = icon.pixelColor(x, y); + if (pixel.alpha() < 32) + continue; + // Antialiased edges keep the hue; only alpha falls. + QVERIFY2(std::abs(pixel.red() - 200) <= 6 && std::abs(pixel.green() - 100) <= 6 && std::abs(pixel.blue() - 50) <= 6, + qPrintable(QString("%1: %2").arg(int(tool)).arg(pixel.name(QColor::HexArgb)))); + solid = solid || pixel.alpha() == 255; + } + } + QVERIFY(solid); + } +} + +void ToolIconTests::anIconStaysInsideItsBoxAtAnySizeAndPlace() +{ + for (const NavigationTool tool : tools) { + for (const int side : {18, 36, 54}) { + const QImage icon = drawn(tool, Qt::white, side, QPointF(4, 4)); + for (int y = 0; y < icon.height(); ++y) { + for (int x = 0; x < icon.width(); ++x) { + const bool inside = x >= 3 && y >= 3 && x <= side + 4 && y <= side + 4; + QVERIFY2(inside || qAlpha(icon.pixel(x, y)) == 0, qPrintable(QString("%1 %2,%3 at %4").arg(int(tool)).arg(x).arg(y).arg(side))); + } + } + // The drawing grows with its box, from the box's corner. + const QRect small = extent(drawn(tool, Qt::white, 18, QPointF(4, 4))), grown = extent(icon); + const double scale = side / 18.0; + const auto near = [&](int actual, double expected) { return std::abs(actual - expected) <= scale + 1; }; + QVERIFY2(near(grown.left() - 4, (small.left() - 4) * scale) && near(grown.top() - 4, (small.top() - 4) * scale) + && near(grown.width(), small.width() * scale) && near(grown.height(), small.height() * scale), + qPrintable(QString("%1 at %2").arg(int(tool)).arg(side))); + } + } +} + +void ToolIconTests::thePainterComesBackAsItWent() +{ + QImage image(40, 40, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setPen(QPen(Qt::red, 3)); + painter.setBrush(Qt::blue); + painter.translate(2, 2); + const QTransform before = painter.transform(); + const QFont font = painter.font(); + // An icon that restores more than it saved warns here. + QTest::failOnWarning(QRegularExpression(".*")); + for (const NavigationTool tool : tools) { + ToolIcons::paint(painter, tool, QPointF(0, 0), 36, Qt::white); + QCOMPARE(painter.transform(), before); + QCOMPARE(painter.pen(), QPen(Qt::red, 3)); + QCOMPARE(painter.brush(), QBrush(Qt::blue)); + QCOMPARE(painter.font(), font); + QVERIFY(!painter.testRenderHint(QPainter::Antialiasing) && !painter.hasClipping()); + } + painter.end(); + // The caller's blue brush and red pen stayed out. + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + QVERIFY(pixel.alpha() < 32 || (pixel.red() > 200 && pixel.green() > 200 && pixel.blue() > 200)); + } + } +} + +void ToolIconTests::swiftsTwoDrawnIconsKeepTheirGeometry() +{ + // The stamp, a pixel a point: pad, body, neck, handle. + const QImage stamp = drawn(NavigationTool::cloneStamp, Qt::white, 18); + QCOMPARE(qAlpha(stamp.pixel(9, 15)), 255); + QCOMPARE(qAlpha(stamp.pixel(3, 15)), 255); + QCOMPARE(qAlpha(stamp.pixel(9, 12)), 255); + QCOMPARE(qAlpha(stamp.pixel(9, 7)), 255); + QCOMPARE(qAlpha(stamp.pixel(9, 3)), 255); + QCOMPARE(qAlpha(stamp.pixel(4, 7)), 0); + QCOMPARE(qAlpha(stamp.pixel(3, 3)), 0); + // The fade: empty at the left, solid at the right. + const QImage fade = drawn(NavigationTool::gradient, Qt::white, 18); + int left = 0, right = 0; + for (int y = 4; y < 14; ++y) { + for (int x = 3; x < 6; ++x) + left += qAlpha(fade.pixel(x, y)) > 127; + for (int x = 12; x < 15; ++x) + right += qAlpha(fade.pixel(x, y)) > 127; + } + QVERIFY2(left <= 6 && right >= 24, qPrintable(QString("%1 %2").arg(left).arg(right))); + // Floyd and Steinberg's dots, as Swift's weights place them. + const QStringList rows{"...#.#.#####", ".#..#.#.#.#.", "..#..#.#####", "...#.#.#.#.#"}; + for (int row = 0; row < 4; ++row) { + QString dots; + for (int x = 3; x < 15; ++x) + dots += qAlpha(fade.pixel(x, 5 + row)) > 127 ? '#' : '.'; + QCOMPARE(dots, rows[row]); + } + // Dots stop at the rounded frame, solid side included. + QVERIFY2(qAlpha(fade.pixel(16, 1)) < 120, qPrintable(QString::number(qAlpha(fade.pixel(16, 1))))); + // Swift's frame is 1.4 wide, clipped to its inner half. + QVERIFY2(std::abs(qAlpha(fade.pixel(1, 9)) - 179) <= 6, qPrintable(QString::number(qAlpha(fade.pixel(1, 9))))); + for (int along = 4; along < 14; ++along) { + QCOMPARE(qAlpha(fade.pixel(0, along)), 0); + QCOMPARE(qAlpha(fade.pixel(17, along)), 0); + QCOMPARE(qAlpha(fade.pixel(along, 0)), 0); + QCOMPARE(qAlpha(fade.pixel(along, 17)), 0); + } + // A rounded frame: a clear corner, a drawn edge. + QVERIFY2(qAlpha(fade.pixel(1, 1)) < 120 && qAlpha(fade.pixel(1, 9)) > 150 && qAlpha(fade.pixel(9, 1)) > 150, + qPrintable(QString("%1 %2 %3").arg(qAlpha(fade.pixel(1, 1))).arg(qAlpha(fade.pixel(1, 9))).arg(qAlpha(fade.pixel(9, 1))))); + // The stamp's parts overlap and still fill as one shape. + QVERIFY(qAlpha(stamp.pixel(9, 5)) > 200); + QCOMPARE(qAlpha(stamp.pixel(9, 9)), 255); + // Curves are smoothed: a circle's edge has soft pixels. + const QImage lens = drawn(NavigationTool::zoom, Qt::white, 18); + int soft = 0; + for (int y = 0; y < lens.height(); ++y) { + for (int x = 0; x < lens.width(); ++x) + soft += qAlpha(lens.pixel(x, y)) > 40 && qAlpha(lens.pixel(x, y)) < 215; + } + QVERIFY2(soft > 20, qPrintable(QString::number(soft))); +} + +void ToolIconTests::noToolHasNoIcon() +{ + QImage image(20, 20, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + // A painter left saved warns when it ends. + QTest::failOnWarning(QRegularExpression(".*")); + { + QPainter painter(&image); + painter.setPen(QPen(Qt::red, 3)); + painter.setBrush(Qt::blue); + painter.translate(2, 2); + const QTransform before = painter.transform(); + QVERIFY_EXCEPTION_THROWN(ToolIcons::paint(painter, NavigationTool::idle, QPointF(0, 0), 18, Qt::white), std::logic_error); + // The refusal left the painter as it was. + QCOMPARE(painter.transform(), before); + QCOMPARE(painter.pen(), QPen(Qt::red, 3)); + QCOMPARE(painter.brush(), QBrush(Qt::blue)); + QVERIFY(!painter.testRenderHint(QPainter::Antialiasing)); + } + QCOMPARE(inked(image), 0); +} + +void ToolIconTests::nothingOfTheCallersPainterShowsInAnIcon() +{ + QFont odd; + odd.setUnderline(true); + odd.setItalic(true); + odd.setStrikeOut(true); + odd.setLetterSpacing(QFont::AbsoluteSpacing, 6); + for (const NavigationTool tool : tools) { + QImage hostile(44, 44, QImage::Format_ARGB32_Premultiplied); + hostile.fill(Qt::transparent); + QPainter painter(&hostile); + painter.setPen(QPen(Qt::red, 5, Qt::DotLine)); + painter.setBrush(Qt::blue); + painter.setBackground(Qt::red); + painter.setBackgroundMode(Qt::OpaqueMode); + painter.setFont(odd); + painter.setBrushOrigin(3, 3); + ToolIcons::paint(painter, tool, QPointF(0, 0), 36, Qt::white); + // And the caller gets all of it back. + QCOMPARE(painter.backgroundMode(), Qt::OpaqueMode); + QCOMPARE(painter.background(), QBrush(Qt::red)); + QCOMPARE(painter.font(), odd); + painter.end(); + QVERIFY2(hostile == drawn(tool, Qt::white), qPrintable(QString::number(int(tool)))); + } +} + +void ToolIconTests::theFadeHasNoSeamsAtAFractionalScale() +{ + // Which dots are on, read at one pixel a dot. + const QImage plain = drawn(NavigationTool::gradient, Qt::white, 18); + const auto on = [&](double x, double y) { + const int column = int(std::floor(x)), row = int(std::floor(y)); + return column >= 3 && column <= 14 && row >= 3 && row <= 14 && qAlpha(plain.pixel(column, row)) > 127; + }; + // A pixel wholly inside dots that are on is solid. + const double scale = 22.5 / 18; + const QImage odd = [&] { + QImage image(36, 36, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + ToolIcons::paint(painter, NavigationTool::gradient, QPointF(4, 4), 22.5, Qt::white); + return image; + }(); + int solid = 0; + for (int y = 0; y < odd.height(); ++y) { + for (int x = 0; x < odd.width(); ++x) { + const double left = (x - 4) / scale, right = (x - 3) / scale, top = (y - 4) / scale, bottom = (y - 3) / scale; + const double inset = 0.001; + if (!on(left + inset, top + inset) || !on(right - inset, top + inset) || !on(left + inset, bottom - inset) || !on(right - inset, bottom - inset)) + continue; + solid += 1; + QVERIFY2(qAlpha(odd.pixel(x, y)) == 255, qPrintable(QString("%1,%2: %3").arg(x).arg(y).arg(qAlpha(odd.pixel(x, y))))); + } + } + // The reviewer's pixel among them: once 173, a seam. + QCOMPARE(qAlpha(odd.pixel(21, 16)), 255); + QVERIFY2(solid > 20, qPrintable(QString::number(solid))); +} + +void ToolIconTests::anIconKeepsToTheCallersClip() +{ + // A widget repaints a strip: nothing may land beside it. + for (const NavigationTool tool : tools) { + QImage image(18, 18, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setClipRect(QRect(0, 0, 8, 18)); + ToolIcons::paint(painter, tool, QPointF(0, 0), 18, Qt::white); + QVERIFY(painter.hasClipping()); + QCOMPARE(painter.clipBoundingRect(), QRectF(0, 0, 8, 18)); + painter.end(); + for (int y = 0; y < 18; ++y) { + for (int x = 8; x < 18; ++x) + QVERIFY2(qAlpha(image.pixel(x, y)) == 0, qPrintable(QString("%1 at %2,%3").arg(int(tool)).arg(x).arg(y))); + } + QVERIFY(inked(image) > 0); + } +} + +void ToolIconTests::theDrawingsMatchTheirReference() +{ + // Every stroke is pinned by the picture in the fixtures. + QImage sheet(18 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied); + sheet.fill(QColor(36, 36, 36)); + QPainter painter(&sheet); + for (int index = 0; index < 15; ++index) { + ToolIcons::paint(painter, tools[index], QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235)); + ToolIcons::paint(painter, tools[index], QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235)); + } + // Three more: the ellipse Marquee, polygonal Lasso, the eraser. + for (const auto &[index, tool, kind] : {std::tuple(15, NavigationTool::marquee, ToolIconKind::ellipse), std::tuple(16, NavigationTool::lasso, ToolIconKind::polygonal), + std::tuple(17, NavigationTool::brush, ToolIconKind::eraser)}) { + ToolIcons::paint(painter, tool, QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235), kind); + ToolIcons::paint(painter, tool, QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235), kind); + } + painter.end(); + // After a wanted change, look at this file; commit it. + QVERIFY(sheet.save(QStringLiteral("ToolIcons.png"))); + const QImage reference = QImage(QFINDTESTDATA("fixtures/ToolIcons.png")).convertToFormat(QImage::Format_ARGB32_Premultiplied); + QCOMPARE(reference.size(), sheet.size()); + // Type's letters take the system's sans; the sheet's is DejaVu. + const bool sheetFace = QFontInfo(QFont()).family() == QStringLiteral("DejaVu Sans"); + int differing = 0; + for (int y = 0; y < sheet.height(); ++y) { + for (int x = 0; x < sheet.width(); ++x) { + if (!sheetFace && x >= 4 + 11 * 44 && x < 48 + 11 * 44) + continue; + differing += std::abs(qRed(sheet.pixel(x, y)) - qRed(reference.pixel(x, y))) > 48; + } + } + // A font may differ by some pixels; no stroke may. + QVERIFY2(differing <= 12, qPrintable(QString::number(differing))); +} + +QTEST_MAIN(ToolIconTests) +#include "ToolIconTests.moc" diff --git a/tests/TransformInspectorTests.cpp b/tests/TransformInspectorTests.cpp new file mode 100644 index 0000000..0115a7f --- /dev/null +++ b/tests/TransformInspectorTests.cpp @@ -0,0 +1,310 @@ +#include "SessionFixtures.h" +#include "UI/LayerIcons.h" +#include "UI/TransformInspector.h" +#include <QtTest> + +// The Move tool's bar: fields, settings, Apply and Cancel. +namespace { +struct Bar { + EditorSession session; + QUuid layer; + std::unique_ptr<TransformInspector> header; + bool active = false; + + Bar() + { + session.createDocument(200, 100); + session.insert(ImportedImage(QImage(40, 20, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image")); + layer = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + header = std::make_unique<TransformInspector>(session); + header->show(); + header->activateWindow(); + active = QTest::qWaitForWindowActive(header.get()); + } + QLineEdit &field(const char *name) + { + QLineEdit *found = header->findChild<QLineEdit *>(QStringLiteral("transform") + QString::fromUtf8(name)); + if (!found) + throw std::runtime_error(std::string("no field named ") + name); + return *found; + } + template <typename Widget> Widget &control(const char *name) + { + Widget *found = header->findChild<Widget *>(QLatin1String(name)); + if (!found) + throw std::runtime_error(std::string("no control named ") + name); + return *found; + } + void type(const char *name, const QString &text) + { + QLineEdit &edit = field(name); + edit.setFocus(); + edit.selectAll(); + QTest::keyClicks(&edit, text); + } + LayerTransform draft() const { return session.transformEdit().value().draft; } +}; +} + +class TransformInspectorTests : public QObject { + Q_OBJECT +private slots: + void theFieldsShowTheLayerAndTypingPreviews(); + void returnEscapeAndArrowsInAField(); + void settingsButtonsAndTheTitleFollowTheSession(); + void anotherActiveLayerRemakesTheFields(); + void aMenuBorrowsTheFocusAndTheBarsKeysApplyOrCancel(); + void theLockIconFollowsThePalette(); + void theFieldsTakeTheSpareRoom(); +}; + +void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews() +{ + Bar bar; + QVERIFY(bar.active); + QCOMPARE(bar.header->title->text(), QString("Transform")); + QCOMPARE(bar.header->height(), 42); + QCOMPARE(bar.field("X").text(), QString("80")); + QCOMPARE(bar.field("Y").text(), QString("40")); + QCOMPARE(bar.field("W").text(), QString("40")); + QCOMPARE(bar.field("H").text(), QString("20")); + QCOMPARE(bar.field("Scale").text(), QString("100")); + QCOMPARE(bar.field("°").text(), QString("0")); + QCOMPARE(bar.control<QComboBox>("transformSampling").currentText(), QString("High quality")); + QCOMPARE(bar.field("X").accessibleName(), QString("X")); + // Typing begins a Ctrl+T edit and previews it. + bar.type("X", "90"); + QVERIFY(bar.session.transformEdit().value().persistent); + QCOMPARE(bar.draft().origin, QPointF(90, 40)); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin, QPointF(80, 40)); + bar.type("Y", "12.5"); + QCOMPARE(bar.draft().origin, QPointF(90, 12.5)); + // Width locked carries the height; unlocked, alone. + bar.type("W", "80"); + QCOMPARE(bar.draft().size, QSizeF(80, 40)); + QCOMPARE(bar.field("H").text(), QString("40")); + QCOMPARE(bar.field("Scale").text(), QString("200")); + bar.control<QToolButton>("locksTransformRatio").click(); + QVERIFY(!bar.session.locksTransformRatio()); + bar.type("W", "20"); + QCOMPARE(bar.draft().size, QSizeF(20, 40)); + bar.type("H", "10"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + // Under a pixel, or no number at all, changes nothing. + bar.type("H", "0"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + bar.type("H", "abc"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + bar.type("H", "inf"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + // Scale sets both sides about the middle. + const QPointF center = bar.draft().center(); + bar.type("Scale", "50"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + QCOMPARE(bar.draft().center(), center); + bar.type("Scale", "0"); + QCOMPARE(bar.draft().size, QSizeF(20, 10)); + bar.type("°", "405"); + QCOMPARE(bar.draft().rotation, 45.0); + bar.control<QComboBox>("transformSampling").setCurrentIndex(0); + emit bar.control<QComboBox>("transformSampling").activated(0); + QCOMPARE(bar.draft().sampling, LayerSampling::nearest); + bar.control<QPushButton>("flipHorizontal").click(); + QVERIFY(bar.draft().flipX); + bar.control<QPushButton>("flipVertical").click(); + QVERIFY(bar.draft().flipY); + // Apply commits the edit as one step; Cancel drops it. + const LayerTransform drafted = bar.draft(); + bar.control<QPushButton>("applyTransform").click(); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform, drafted); + QCOMPARE(bar.session.history.undoName(), QString("Transform Layer")); + bar.type("X", "5"); + QCOMPARE(bar.draft().origin.x(), 5.0); + bar.control<QPushButton>("cancelTransform").click(); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform, drafted); +} + +void TransformInspectorTests::returnEscapeAndArrowsInAField() +{ + Bar bar; + QVERIFY(bar.active); + QLineEdit &x = bar.field("X"); + x.setFocus(); + QVERIFY(x.hasFocus()); + // Up and Down step one, Shift ten, writing the number. + QTest::keyClick(&x, Qt::Key_Up); + QCOMPARE(bar.draft().origin.x(), 81.0); + QCOMPARE(x.text(), QString("81")); + QTest::keyClick(&x, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(bar.draft().origin.x(), 71.0); + QCOMPARE(x.text(), QString("71")); + // Return hands the canvas the keys; the edit waits there. + const int request = bar.session.canvasFocusRequest(); + QTest::keyClick(&x, Qt::Key_Return); + QVERIFY(!x.hasFocus()); + QCOMPARE(bar.session.canvasFocusRequest(), request + 1); + QVERIFY(bar.session.transformEdit().has_value()); + x.setFocus(); + QTest::keyClicks(&x, "2"); + QCOMPARE(x.text(), QString("712")); + QCOMPARE(bar.draft().origin.x(), 712.0); + QTest::keyClick(&x, Qt::Key_Escape); + QVERIFY(!x.hasFocus()); + QCOMPARE(bar.session.canvasFocusRequest(), request + 2); + QCOMPARE(bar.draft().origin.x(), 712.0); + // While a field is typed in, the numbers wait outside. + x.setFocus(); + LayerTransform moved = bar.draft(); + moved.origin.setX(30); + bar.session.previewTransform(moved); + QCOMPARE(x.text(), QString("712")); + QCOMPARE(bar.field("Y").text(), QString("40")); + x.clearFocus(); + QCOMPARE(x.text(), QString("30")); +} + +void TransformInspectorTests::settingsButtonsAndTheTitleFollowTheSession() +{ + Bar bar; + QCheckBox &autoSelect = bar.control<QCheckBox>("transformAutoSelect"); + QCheckBox &controls = bar.control<QCheckBox>("showTransformControls"); + QVERIFY(!autoSelect.isChecked() && controls.isChecked()); + QCOMPARE(autoSelect.toolTip(), QString("Select layers by clicking the canvas. When off, hold Ctrl to select a layer.")); + autoSelect.click(); + QVERIFY(bar.session.transformAutoSelect()); + controls.click(); + QVERIFY(!bar.session.showsTransformControls()); + bar.session.setShowsTransformControls(true); + QVERIFY(controls.isChecked()); + bar.session.setTransformAutoSelect(false); + QVERIFY(!autoSelect.isChecked()); + QVERIFY(bar.control<QToolButton>("locksTransformRatio").isChecked()); + bar.session.setLocksTransformRatio(false); + QVERIFY(!bar.control<QToolButton>("locksTransformRatio").isChecked()); + // Apply and Cancel need an edit; the numbers, a layer. + QPushButton &apply = bar.control<QPushButton>("applyTransform"); + QPushButton &cancel = bar.control<QPushButton>("cancelTransform"); + QWidget &numbers = bar.control<QWidget>("transformFields"); + QVERIFY(!apply.isEnabled() && !cancel.isEnabled() && numbers.isEnabled()); + bar.session.beginTransform(); + QVERIFY(apply.isEnabled() && cancel.isEnabled() && numbers.isEnabled()); + bar.session.cancelTransform(); + bar.session.addBlankLayer(); + QVERIFY(!numbers.isEnabled()); + QCOMPARE(bar.field("W").text(), QString("200")); + bar.session.setIsProjectBusy(true); + QVERIFY(!numbers.isEnabled()); + bar.session.setIsProjectBusy(false); + // An unlinked mask, selected, is what the bar transforms. + bar.session.selectLayer(bar.layer); + rewrite(bar.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, bar.layer, coverage()); + record(snapshot, bar.layer).maskLinked = false; + record(snapshot, bar.layer).maskPlacement = LayerTransform{.origin = {1, 2}, .size = {3, 4}}; + }); + bar.session.selectLayerTarget(bar.layer, true); + QVERIFY(bar.session.transformTargetsMask()); + QCOMPARE(bar.header->title->text(), QString("Transform Mask")); + QCOMPARE(bar.field("X").text(), QString("1")); + QCOMPARE(bar.field("W").text(), QString("3")); + bar.session.selectLayerTarget(bar.layer, false); + QCOMPARE(bar.header->title->text(), QString("Transform")); +} + +void TransformInspectorTests::anotherActiveLayerRemakesTheFields() +{ + Bar bar; + QVERIFY(bar.active); + bar.session.insert(ImportedImage(QImage(10, 10, QImage::Format_RGBA8888_Premultiplied), QImage(), "Other"), QPointF(5, 5)); + const QUuid other = bar.session.activeLayerID().value(); + bar.session.selectLayer(bar.layer); + QLineEdit &x = bar.field("X"); + x.setFocus(); + QTest::keyClicks(&x, "1"); + QCOMPARE(bar.draft().origin.x(), 801.0); + // Another layer: the typing ends, the numbers are its. + bar.session.selectLayer(other); + QVERIFY(!x.hasFocus()); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(x.text(), QString("0")); + QCOMPARE(bar.field("W").text(), QString("10")); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 801.0); +} + +void TransformInspectorTests::aMenuBorrowsTheFocusAndTheBarsKeysApplyOrCancel() +{ + Bar bar; + QVERIFY(bar.active); + QLineEdit &x = bar.field("X"); + bar.type("X", "12."); + QCOMPARE(bar.draft().origin.x(), 12.0); + // The menu bar borrows the focus: the typing stays. + QPushButton &apply = bar.control<QPushButton>("applyTransform"); + apply.setFocus(Qt::MenuBarFocusReason); + QVERIFY(!x.hasFocus()); + QCOMPARE(x.text(), QString("12.")); + LayerTransform moved = bar.draft(); + moved.origin.setX(30); + bar.session.previewTransform(moved); + QCOMPARE(x.text(), QString("12.")); + x.setFocus(); + QTest::keyClicks(&x, "5"); + QCOMPARE(x.text(), QString("12.5")); + QCOMPARE(bar.draft().origin.x(), 12.5); + // A popup borrows it too; leaving otherwise puts it back. + apply.setFocus(Qt::PopupFocusReason); + QCOMPARE(x.text(), QString("12.5")); + x.setFocus(); + apply.setFocus(Qt::TabFocusReason); + QCOMPARE(x.text(), QString("12.50")); + // Return on Apply applies; Escape on a toggle cancels. + apply.setFocus(); + QVERIFY(apply.hasFocus()); + QTest::keyClick(&apply, Qt::Key_Return); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 12.5); + bar.type("Y", "7"); + QCOMPARE(bar.draft().origin.y(), 7.0); + QCheckBox &controls = bar.control<QCheckBox>("showTransformControls"); + controls.setFocus(); + QTest::keyClick(&controls, Qt::Key_Escape); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.y(), 40.0); + // Without an edit the keys are the controls' own. + QTest::keyClick(&controls, Qt::Key_Return); + QTest::keyClick(&controls, Qt::Key_Escape); + QVERIFY(!bar.session.transformEdit().has_value()); + QVERIFY(bar.session.showsTransformControls()); +} + +void TransformInspectorTests::theLockIconFollowsThePalette() +{ + Bar bar; + QToolButton &lock = bar.control<QToolButton>("locksTransformRatio"); + const double ratio = bar.header->devicePixelRatio(); + QCOMPARE(lock.icon().pixmap(16).toImage(), LayerIcons::pixmap(LayerIcon::link, 16, bar.header->palette().color(QPalette::Text), ratio).toImage()); + QPalette changed = bar.header->palette(); + changed.setColor(QPalette::Text, QColor(200, 30, 30)); + bar.header->setPalette(changed); + QCoreApplication::processEvents(); + QCOMPARE(lock.icon().pixmap(16).toImage(), LayerIcons::pixmap(LayerIcon::link, 16, QColor(200, 30, 30), ratio).toImage()); +} + +void TransformInspectorTests::theFieldsTakeTheSpareRoom() +{ + Bar bar; + QVERIFY(bar.active); + auto &flip = bar.control<QPushButton>("flipVertical"); + const QRect natural = flip.geometry(); + // Swift's ScrollView takes the room; its content keeps its size. + bar.header->resize(1600, bar.header->height()); + QTRY_VERIFY(bar.control<QPushButton>("applyTransform").geometry().right() > 1600 - 40); + QCOMPARE(flip.geometry(), natural); + QCOMPARE(flip.width(), flip.sizeHint().width()); +} + +QTEST_MAIN(TransformInspectorTests) +#include "TransformInspectorTests.moc" diff --git a/tests/TransformOverlayTests.cpp b/tests/TransformOverlayTests.cpp new file mode 100644 index 0000000..65c67c5 --- /dev/null +++ b/tests/TransformOverlayTests.cpp @@ -0,0 +1,219 @@ +#include "CanvasFixtures.h" +#include "Rendering/TransformOverlay.h" + +// The Move tool's box: its handles, hits, cursors and drawing. +namespace { +bool near(QPointF a, QPointF b) +{ + return std::hypot(a.x() - b.x(), a.y() - b.y()) < 0.0001; +} +} + +class TransformOverlayTests : public QObject { + Q_OBJECT +private slots: + void hoverRegionsMatchRotatedEdgesCornersAndRotationHandle(); + void documentMappingAndRotatedHitTesting(); + void resizeCursorsTurnWithTheBox(); + void theBoxShowsForAMovableLayerAndHidesWithTheControls(); + void handlesAndGuidesArePaintedWhereTheyBelong(); + void anotherLayersHandlesRepaintOnlyTheirRects(); +}; + +namespace { +// Every paint's region, to see how much a change repaints. +struct PaintSpy : QObject { + std::vector<QRect> rects; + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::Paint) + rects.push_back(static_cast<QPaintEvent *>(event)->region().boundingRect()); + return false; + } +}; +} + +void TransformOverlayTests::hoverRegionsMatchRotatedEdgesCornersAndRotationHandle() +{ + CanvasViewport viewport; + const QSizeF size(1000, 800); + viewport.resize(QSizeF(1000, 800), 1, size); + const LayerTransform transform{.origin = {100, 100}, .size = {400, 300}, .rotation = 37}; + const TransformOverlayGeometry geometry(transform, viewport, size); + for (const auto &[start, end, expected] : {std::tuple(0, 2, 1), std::tuple(2, 4, 3), std::tuple(4, 6, 5), std::tuple(6, 0, 7)}) { + const QPointF a = geometry.handles[size_t(start)], b = geometry.handles[size_t(end)]; + const QPointF point(a.x() * 0.75 + b.x() * 0.25, a.y() * 0.75 + b.y() * 0.25); + const TransformDrag::Mode hit = geometry.hit(point).value(); + QVERIFY(hit.kind == TransformDrag::Kind::resize); + QCOMPARE(hit.index, expected); + } + for (const int index : {0, 2, 4, 6}) { + const TransformDrag::Mode hit = geometry.hit(geometry.handles[size_t(index)]).value(); + QVERIFY(hit.kind == TransformDrag::Kind::resize); + QCOMPARE(hit.index, index); + } + QVERIFY(geometry.hit(geometry.rotationHandle).value().kind == TransformDrag::Kind::rotate); + QVERIFY(!geometry.hit(viewport.viewPoint(transform.center(), size)).has_value()); + // The grip stands 28 points off the top edge, turned. + const double radians = transform.radians(); + QVERIFY(near(geometry.rotationHandle, geometry.handles[1] + QPointF(std::sin(radians) * 28, -std::cos(radians) * 28))); +} + +void TransformOverlayTests::documentMappingAndRotatedHitTesting() +{ + CanvasViewport viewport; + const QSizeF size(1000, 800); + viewport.resize(QSizeF(900, 600), 2, size); + viewport.setZoom(1.5, QPointF(300, 200), size); + viewport.translate(QSizeF(57, -30)); + const LayerTransform transform{.origin = {100, 200}, .size = {100, 50}, .rotation = 90}; + const TransformOverlayGeometry geometry(transform, viewport, size); + QVERIFY(near(viewport.documentPoint(geometry.handles[4], size), transform.point(QPointF(1, 1)))); + QVERIFY(geometry.hit(geometry.rotationHandle).value().kind == TransformDrag::Kind::rotate); + for (size_t index = 0; index < geometry.handles.size(); ++index) { + const TransformDrag::Mode hit = geometry.hit(geometry.handles[index]).value(); + QVERIFY(hit.kind == TransformDrag::Kind::resize); + QCOMPARE(hit.index, int(index)); + } + QVERIFY(!geometry.hit(viewport.viewPoint(transform.center(), size)).has_value()); +} + +void TransformOverlayTests::resizeCursorsTurnWithTheBox() +{ + CanvasViewport viewport; + const QSizeF size(400, 300); + viewport.resize(QSizeF(400, 300), 1, size); + const TransformOverlayGeometry upright({.origin = {100, 100}, .size = {100, 50}}, viewport, size); + QCOMPARE(upright.resizeCursor(3), Qt::SizeHorCursor); + QCOMPARE(upright.resizeCursor(7), Qt::SizeHorCursor); + QCOMPARE(upright.resizeCursor(1), Qt::SizeVerCursor); + QCOMPARE(upright.resizeCursor(5), Qt::SizeVerCursor); + QCOMPARE(upright.resizeCursor(0), Qt::SizeFDiagCursor); + QCOMPARE(upright.resizeCursor(4), Qt::SizeFDiagCursor); + QCOMPARE(upright.resizeCursor(2), Qt::SizeBDiagCursor); + QCOMPARE(upright.resizeCursor(6), Qt::SizeBDiagCursor); + // Ten points reach a handle; more do not. + QCOMPARE(upright.hit(upright.handles[0] + QPointF(-9.5, 0)).value().index, 0); + QVERIFY(!upright.hit(upright.handles[0] + QPointF(-10.5, 0)).has_value()); + QVERIFY(!upright.hit(upright.handles[0] + QPointF(-10.5, 0.5)).has_value()); + // A quarter turn swaps the edges' cursors. + const TransformOverlayGeometry turned({.origin = {100, 100}, .size = {100, 50}, .rotation = 90}, viewport, size); + QCOMPARE(turned.resizeCursor(3), Qt::SizeVerCursor); + QCOMPARE(turned.resizeCursor(1), Qt::SizeHorCursor); + QCOMPARE(turned.resizeCursor(0), Qt::SizeBDiagCursor); +} + +void TransformOverlayTests::theBoxShowsForAMovableLayerAndHidesWithTheControls() +{ + Shown shown; + EditorSession &session = shown.session; + const TransformOverlay overlay(session); + QVERIFY(!overlay.geometry().has_value()); + session.addBlankLayer(); + QVERIFY(!session.canTransform()); + QVERIFY(!overlay.geometry().has_value()); + session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red")); + const QUuid red = session.activeLayerID().value(); + QVERIFY(session.canTransform()); + QVERIFY(overlay.geometry().has_value()); + QCOMPARE(overlay.geometry().value(), TransformOverlayGeometry(layerWith(session, red).transform, session.viewport, shown.documentSize())); + session.selectTool(NavigationTool::hand); + QVERIFY(!overlay.geometry().has_value()); + session.selectTool(NavigationTool::move); + // Hidden controls: only a Ctrl+T transform shows its box. + session.setShowsTransformControls(false); + QVERIFY(!overlay.geometry().has_value()); + session.beginTransform(false); + QVERIFY(!overlay.geometry().has_value()); + session.cancelTransform(); + session.beginTransform(true); + QVERIFY(overlay.geometry().has_value()); + session.cancelTransform(); + session.setShowsTransformControls(true); + session.toggleLayerVisibility(red); + QVERIFY(!overlay.geometry().has_value()); + session.toggleLayerVisibility(red); + // A selection or a folder: the box around them all. + session.insert(filled(10, 10, qRgba(0, 0, 255, 255), "Blue"), QPointF(85, 85)); + const QUuid blue = session.activeLayerID().value(); + session.selectLayers({red, blue}, red); + QCOMPARE(overlay.geometry().value(), TransformOverlayGeometry(session.groupTransformBox().value(), session.viewport, shown.documentSize())); + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(3, 0); + session.previewTransform(draft); + QCOMPARE(overlay.geometry().value(), TransformOverlayGeometry(draft, session.viewport, shown.documentSize())); + session.cancelTransform(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + QVERIFY(session.placeLayer(blue, folder)); + session.selectLayer(folder); + QCOMPARE(overlay.geometry().value(), TransformOverlayGeometry(session.groupTransformBox().value(), session.viewport, shown.documentSize())); +} + +void TransformOverlayTests::handlesAndGuidesArePaintedWhereTheyBelong() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red")); + QVERIFY(canvas.synchronizeDisplay()); + QCOMPARE(session.viewport.viewPoint(QPointF(0, 0), shown.documentSize()), QPointF(150, 100)); + const TransformOverlay overlay(session); + QCOMPARE(overlay.drawnRect(canvas.rect()), QRect(185, 107, 30, 58)); + // White squares on the handles, a disc on the grip. + QImage shot = canvas.grab().toImage(); + QCOMPARE(shot.pixelColor(190, 140), QColor(Qt::white)); + QCOMPARE(shot.pixelColor(210, 160), QColor(Qt::white)); + QCOMPARE(shot.pixelColor(200, 112), QColor(Qt::white)); + // The accent outline runs between the handles, over the red. + QVERIFY(shot.pixelColor(190, 145) != QColor(Qt::red)); + QVERIFY(shot.pixelColor(190, 145).blue() > shot.pixelColor(190, 145).red()); + session.setShowsTransformControls(false); + QVERIFY(overlay.drawnRect(canvas.rect()).isEmpty()); + shot = canvas.grab().toImage(); + QVERIFY(shot.pixelColor(190, 140) != QColor(Qt::white)); + QVERIFY(shot.pixelColor(200, 112) != QColor(Qt::white)); + QCOMPARE(shot.pixelColor(190, 145), QColor(Qt::red)); + // A guide: an accent line across the document. + session.snapGuides = {{50}, {}}; + QVERIFY(!canvas.synchronizeDisplay()); + QCOMPARE(overlay.drawnRect(canvas.rect()), canvas.rect()); + shot = canvas.grab().toImage(); + QCOMPARE(shot.pixelColor(200, 190), canvas.palette().color(QPalette::Highlight)); + QCOMPARE(shot.pixelColor(200, 101), canvas.palette().color(QPalette::Highlight)); + QVERIFY(shot.pixelColor(230, 190) != canvas.palette().color(QPalette::Highlight)); + session.snapGuides = {{}, {30}}; + canvas.synchronizeDisplay(); + shot = canvas.grab().toImage(); + QCOMPARE(shot.pixelColor(170, 130), canvas.palette().color(QPalette::Highlight)); + QVERIFY(shot.pixelColor(200, 190) != canvas.palette().color(QPalette::Highlight)); +} + +void TransformOverlayTests::anotherLayersHandlesRepaintOnlyTheirRects() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"), QPointF(20, 20)); + const QUuid red = session.activeLayerID().value(); + session.insert(filled(10, 10, qRgba(0, 0, 255, 255), "Blue"), QPointF(80, 80)); + canvas.synchronizeDisplay(); + QCoreApplication::processEvents(); + PaintSpy spy; + canvas.installEventFilter(&spy); + // The old box and the new one, nothing more. + session.selectLayer(red); + QVERIFY(!canvas.synchronizeDisplay()); + QCoreApplication::processEvents(); + QCOMPARE(int(spy.rects.size()), 1); + QCOMPARE(spy.rects[0], QRect(155, 77, 85, 113)); + canvas.removeEventFilter(&spy); +} + +QTEST_MAIN(TransformOverlayTests) +#include "TransformOverlayTests.moc" diff --git a/tests/TransformPressTests.cpp b/tests/TransformPressTests.cpp new file mode 100644 index 0000000..9232391 --- /dev/null +++ b/tests/TransformPressTests.cpp @@ -0,0 +1,325 @@ +#include "CanvasFixtures.h" + +// The Move tool on the canvas: drags, handles, snapping. +namespace { +// The document fills the canvas: view points are pixels. +struct Canvas : Shown { + QUuid red; + Canvas() : Shown(QSize(400, 300), QSize(400, 300)) + { + settle(); + session.zoom(1); + session.insert(filled(100, 100, qRgba(255, 0, 0, 255), "Red")); + red = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + canvas->synchronizeDisplay(); + if (session.viewport.viewPoint(QPointF(0, 0), documentSize()) != QPointF(0, 0)) + throw std::runtime_error("the document does not fill the canvas"); + } + QPointF origin() const { return layerWith(session, red).transform.origin; } + void press(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mousePress(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void move(QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QMouseEvent event(QEvent::MouseMove, to, to, canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, modifiers); + QApplication::sendEvent(canvas, &event); + } + void release(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mouseRelease(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void drag(QPointF from, QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(from, modifiers); + move((from + to) / 2, modifiers); + move(to, modifiers); + release(to, modifiers); + } + void click(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(at, modifiers); + release(at, modifiers); + } +}; +} + +class TransformPressTests : public QObject { + Q_OBJECT +private slots: + void draggingOutsideTheLayerMovesIt(); + void altDraggingOutsideTheLayerDuplicatesIt(); + void handlesResizeAndTheGripRotates(); + void movesSnapToTheCanvasAndOtherLayersUnlessCtrl(); + void autoSelectCanBeDisabledAndCtrlClickOverridesIt(); + void escapeMidDragRestoresAndAPersistentEditWaitsForReturn(); + void losingTheKeysOrTheWheelMidDrag(); + void aSelectionDragsAsOneBox(); +}; + +void TransformPressTests::draggingOutsideTheLayerMovesIt() +{ + Canvas shown; + QCOMPARE(shown.origin(), QPointF(150, 100)); + // Thirteen down keeps the middle clear of the canvas's snap. + shown.drag(QPointF(20, 20), QPointF(40, 33)); + QVERIFY(!shown.session.transformEdit().has_value()); + QCOMPARE(shown.origin(), QPointF(170, 113)); + QCOMPARE(shown.session.history.undoName(), QString("Transform Layer")); + // A press that never moves changes nothing and records nothing. + const int steps = shown.session.history.undoCount(); + shown.click(QPointF(20, 20)); + QCOMPARE(shown.session.history.undoCount(), steps); + QCOMPARE(shown.origin(), QPointF(170, 113)); + // At 200% drags land on whole pixels; snapping halves. + shown.session.zoom(2); + shown.canvas->synchronizeDisplay(); + shown.drag(QPointF(20, 20), QPointF(66, 20)); + QCOMPARE(shown.origin(), QPointF(193, 113)); + shown.drag(QPointF(20, 20), QPointF(21, 20)); + QCOMPARE(shown.origin(), QPointF(194, 113)); + shown.session.zoom(1); + shown.canvas->synchronizeDisplay(); + // Nothing drags while the project is busy. + shown.session.setIsProjectBusy(true); + shown.drag(QPointF(20, 20), QPointF(60, 20)); + QCOMPARE(shown.origin(), QPointF(194, 113)); +} + +void TransformPressTests::altDraggingOutsideTheLayerDuplicatesIt() +{ + Canvas shown; + shown.drag(QPointF(20, 20), QPointF(40, 33), Qt::AltModifier); + const std::vector<ImageLayer> &layers = shown.session.document().value().layers; + QCOMPARE(int(layers.size()), 2); + QCOMPARE(layers[0].transform.origin, QPointF(150, 100)); + QCOMPARE(layers[1].transform.origin, QPointF(170, 113)); + QCOMPARE(layers[1].name, QString("Red copy")); + QVERIFY(!shown.session.transformEdit().has_value()); + QCOMPARE(shown.session.history.undoName(), QString("Duplicate Layer")); + shown.session.undo(); + QCOMPARE(int(shown.session.document().value().layers.size()), 1); + // Alt on a handle resizes about the middle, no copy. + shown.drag(QPointF(250, 200), QPointF(300, 250), Qt::AltModifier); + QCOMPARE(int(shown.session.document().value().layers.size()), 1); + QCOMPARE(layerWith(shown.session, shown.red).transform, (LayerTransform{.origin = {100, 50}, .size = {200, 200}})); +} + +void TransformPressTests::handlesResizeAndTheGripRotates() +{ + Canvas shown; + // Shift frees a locked corner: each side follows the pointer. + shown.drag(QPointF(250, 200), QPointF(300, 225), Qt::ShiftModifier); + QCOMPARE(layerWith(shown.session, shown.red).transform, (LayerTransform{.origin = {150, 100}, .size = {150, 125}})); + shown.session.undo(); + // The bottom right corner dragged out: both sides grow, locked. + shown.drag(QPointF(250, 200), QPointF(300, 250)); + LayerTransform transform = layerWith(shown.session, shown.red).transform; + QCOMPARE(transform.size, QSizeF(150, 150)); + QCOMPARE(transform.origin, QPointF(150, 100)); + shown.session.setLocksTransformRatio(false); + shown.drag(QPointF(300, 250), QPointF(300, 200)); + transform = layerWith(shown.session, shown.red).transform; + QCOMPARE(transform.size, QSizeF(150, 100)); + // An edge, away from its handle, resizes too. + shown.drag(QPointF(300, 170), QPointF(320, 170)); + QCOMPARE(layerWith(shown.session, shown.red).transform.size, QSizeF(170, 100)); + // Resizing never snaps: five past the middle stays five past. + shown.drag(QPointF(320, 170), QPointF(205, 170)); + QCOMPARE(layerWith(shown.session, shown.red).transform, (LayerTransform{.origin = {150, 100}, .size = {55, 100}})); + // The grip: a quarter turn about the middle. + const QPointF center = layerWith(shown.session, shown.red).transform.center(); + const QPointF grip = center + QPointF(0, -78); + shown.drag(grip, center + QPointF(78, 0)); + transform = layerWith(shown.session, shown.red).transform; + QCOMPARE(transform.rotation, 90.0); + QCOMPARE(transform.center(), center); + QCOMPARE(shown.session.history.undoName(), QString("Transform Layer")); +} + +void TransformPressTests::movesSnapToTheCanvasAndOtherLayersUnlessCtrl() +{ + Canvas shown; + EditorSession &session = shown.session; + // Three short of the middle, the left edge snaps. + shown.press(QPointF(20, 20)); + shown.move(QPointF(73, 20)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(200, 100)); + QCOMPARE(session.snapGuides, (SnapGuides{{200}, {150}})); + shown.release(QPointF(73, 20)); + QCOMPARE(shown.origin(), QPointF(200, 100)); + QCOMPARE(session.snapGuides, SnapGuides{}); + // Ctrl drags freely. + shown.press(QPointF(20, 20)); + shown.move(QPointF(23, 20), Qt::ControlModifier); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(203, 100)); + QCOMPARE(session.snapGuides, SnapGuides{}); + shown.release(QPointF(23, 20), Qt::ControlModifier); + QCOMPARE(shown.origin(), QPointF(203, 100)); + // Another layer's edge is the nearest target now. + session.insert(filled(50, 50, qRgba(0, 0, 255, 255), "Blue"), QPointF(330, 50)); + session.selectLayer(shown.red); + shown.press(QPointF(20, 20)); + shown.move(QPointF(21, 20)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(205, 100)); + QCOMPARE(session.snapGuides, (SnapGuides{{305}, {150}})); + shown.release(QPointF(21, 20)); + QCOMPARE(shown.origin(), QPointF(205, 100)); +} + +void TransformPressTests::autoSelectCanBeDisabledAndCtrlClickOverridesIt() +{ + Canvas shown; + EditorSession &session = shown.session; + session.insert(filled(100, 100, qRgba(0, 0, 255, 255), "Blue"), QPointF(300, 50)); + const QUuid blue = session.activeLayerID().value(); + session.selectLayer(shown.red); + QVERIFY(!session.transformAutoSelect()); + shown.click(QPointF(300, 50)); + QCOMPARE(session.activeLayerID(), std::optional(shown.red)); + session.setTransformAutoSelect(true); + shown.click(QPointF(300, 50)); + QCOMPARE(session.activeLayerID(), std::optional(blue)); + session.selectLayer(shown.red); + session.setTransformAutoSelect(false); + shown.click(QPointF(300, 50)); + QCOMPARE(session.activeLayerID(), std::optional(shown.red)); + shown.click(QPointF(300, 50), Qt::ControlModifier); + QCOMPARE(session.activeLayerID(), std::optional(blue)); + QVERIFY(!session.transformAutoSelect()); + // Ctrl+Shift adds the layer under the pointer to the selection. + shown.click(QPointF(200, 150), Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{shown.red, blue})); + // With auto-select, empty canvas still drags the active layer. + session.selectLayer(shown.red); + session.setTransformAutoSelect(true); + shown.drag(QPointF(20, 250), QPointF(33, 250)); + QCOMPARE(shown.origin(), QPointF(163, 100)); + // Inside the active layer's box, it drags, whatever lies above. + session.insert(filled(100, 100, qRgba(0, 255, 0, 255), "Green"), QPointF(220, 120)); + session.selectLayer(shown.red); + shown.click(QPointF(213, 150)); + QCOMPARE(session.activeLayerID(), std::optional(shown.red)); + // Ctrl picks what lies under the pointer, active included. + const QUuid green = session.activeLayerID() == shown.red ? session.document().value().layers.back().id : shown.red; + shown.click(QPointF(213, 150), Qt::ControlModifier); + QCOMPARE(session.activeLayerID(), std::optional(green)); + session.selectLayer(shown.red); + // A selection's box drags them all; outside it auto-select picks. + session.insert(filled(100, 100, qRgba(255, 255, 0, 255), "Yellow"), QPointF(60, 260)); + const QUuid yellow = session.activeLayerID().value(); + session.selectLayers({shown.red, blue}, shown.red); + shown.click(QPointF(300, 50)); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{shown.red, blue})); + shown.click(QPointF(60, 260)); + QCOMPARE(session.activeLayerID(), std::optional(yellow)); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{yellow}); +} + +void TransformPressTests::escapeMidDragRestoresAndAPersistentEditWaitsForReturn() +{ + Canvas shown; + EditorSession &session = shown.session; + QTRY_VERIFY(shown.canvas->hasFocus()); + shown.press(QPointF(20, 20)); + shown.move(QPointF(55, 40)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(185, 120)); + QVERIFY(!session.transformEdit().value().persistent); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!session.transformEdit().has_value()); + shown.release(QPointF(55, 40)); + QCOMPARE(shown.origin(), QPointF(150, 100)); + // Ctrl+T's edit outlives the drag until Return applies it. + session.beginTransform(); + QVERIFY(session.transformEdit().value().persistent); + shown.drag(QPointF(20, 20), QPointF(40, 33)); + QVERIFY(session.transformEdit().has_value()); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(170, 113)); + QCOMPARE(shown.origin(), QPointF(150, 100)); + QTest::keyClick(shown.canvas, Qt::Key_Return); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(shown.origin(), QPointF(170, 113)); + // A snapped drag under Ctrl+T: guides go with the release. + session.beginTransform(); + shown.press(QPointF(20, 20)); + shown.move(QPointF(53, 20)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(200, 113)); + QCOMPARE(session.snapGuides, (SnapGuides{{200}, {}})); + // The release repaints the guide away, though nothing commits. + struct PaintSpy : QObject { + std::vector<QRect> rects; + bool eventFilter(QObject *, QEvent *event) override + { + if (event->type() == QEvent::Paint) + rects.push_back(static_cast<QPaintEvent *>(event)->region().boundingRect()); + return false; + } + } spy; + QCoreApplication::processEvents(); + shown.canvas->installEventFilter(&spy); + shown.release(QPointF(53, 20)); + QVERIFY(session.transformEdit().has_value()); + QCOMPARE(session.snapGuides, SnapGuides{}); + QCoreApplication::processEvents(); + shown.canvas->removeEventFilter(&spy); + QVERIFY(std::any_of(spy.rects.begin(), spy.rects.end(), [&](const QRect &rect) { return rect == shown.canvas->rect(); })); + // Escape mid-resize puts the layer and the cursor back. + shown.press(QPointF(300, 213)); + QCOMPARE(shown.canvas->cursor().shape(), Qt::SizeFDiagCursor); + shown.move(QPointF(350, 263)); + QCOMPARE(session.transformEdit().value().draft.size, QSizeF(150, 150)); + QTest::keyClick(shown.canvas, Qt::Key_Escape); + QVERIFY(!session.transformEdit().has_value()); + QVERIFY(shown.canvas->cursor().shape() != Qt::SizeFDiagCursor); + shown.release(QPointF(350, 263)); + QCOMPARE(layerWith(session, shown.red).transform.size, QSizeF(100, 100)); +} + +void TransformPressTests::losingTheKeysOrTheWheelMidDrag() +{ + Canvas shown; + EditorSession &session = shown.session; + shown.press(QPointF(20, 20)); + shown.move(QPointF(60, 40)); + // The wheel neither pans nor zooms mid-drag. + const CanvasViewport before = session.viewport; + wheel(*shown.canvas, QPointF(100, 100), QPoint(0, 30), QPoint(0, 120), Qt::NoModifier); + wheel(*shown.canvas, QPointF(100, 100), QPoint(0, 30), QPoint(0, 120), Qt::ControlModifier); + QVERIFY(session.viewport == before); + // Nor does a pinch. + QNativeGestureEvent pinch(Qt::ZoomNativeGesture, QPointingDevice::primaryPointingDevice(), 2, QPointF(100, 100), QPointF(100, 100), + shown.canvas->mapToGlobal(QPoint(100, 100)), 0.5, QPointF()); + QApplication::sendEvent(shown.canvas, &pinch); + QVERIFY(session.viewport == before); + // The keys lost mid-drag: the layer goes back. + shown.canvas->clearFocus(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(shown.origin(), QPointF(150, 100)); + shown.release(QPointF(60, 40)); + QCOMPARE(shown.origin(), QPointF(150, 100)); + wheel(*shown.canvas, QPointF(100, 100), QPoint(0, 30), QPoint(0, 120), Qt::NoModifier); + QVERIFY(!(session.viewport == before)); + // Under Ctrl+T the edit stays, its draft put back. + session.viewport = before; + session.beginTransform(); + shown.press(QPointF(20, 20)); + shown.move(QPointF(55, 40)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(185, 120)); + shown.canvas->clearFocus(); + QVERIFY(session.transformEdit().value().persistent); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(150, 100)); + shown.release(QPointF(55, 40)); + QVERIFY(session.transformEdit().has_value()); +} + +void TransformPressTests::aSelectionDragsAsOneBox() +{ + Canvas shown; + EditorSession &session = shown.session; + session.insert(filled(50, 50, qRgba(0, 0, 255, 255), "Blue"), QPointF(325, 275)); + const QUuid blue = session.activeLayerID().value(); + session.selectLayers({shown.red, blue}, shown.red); + shown.drag(QPointF(20, 20), QPointF(30, 32)); + QCOMPARE(shown.origin(), QPointF(160, 112)); + QCOMPARE(layerWith(session, blue).transform.origin, QPointF(310, 262)); + QCOMPARE(session.history.undoName(), QString("Transform Layers")); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{shown.red, blue})); +} + +QTEST_MAIN(TransformPressTests) +#include "TransformPressTests.moc" diff --git a/tests/TransformSessionTests.cpp b/tests/TransformSessionTests.cpp new file mode 100644 index 0000000..ec223c2 --- /dev/null +++ b/tests/TransformSessionTests.cpp @@ -0,0 +1,409 @@ +#include "Document/EditorSession.h" +#include <QtTest> + +// Transform edits in the session beyond Swift's own cases. +namespace { +QUuid insertImage(EditorSession &session, int width, int height, QPointF center) +{ + session.insert(ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), center); + return session.activeLayerID().value(); +} + +ImageLayer layerWith(const EditorSession &session, QUuid id) +{ + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == id) + return layer; + } + throw std::runtime_error("no such layer"); +} + +QSet<QUuid> ids(const std::vector<ImageLayer> &layers) +{ + QSet<QUuid> result; + for (const ImageLayer &layer : layers) + result.insert(layer.id); + return result; +} +} + +class TransformSessionTests : public QObject { + Q_OBJECT +private slots: + void severalLayersMoveInOneBox(); + void aFolderCarriesItsVisiblePixels(); + void aFolderReachesSixtyThreeLevelsDown(); + void aGroupMoveKeepsEachTurnFlipAndSampling(); + void aBoxSqueezedTooFarLeavesThatLayer(); + void theBoxHoldsEveryCornerOfATurnedLayer(); + void aUnitLayerStillGetsAWholeBox(); + void nudgingIsOneStepOrPartOfAnOpenEdit(); + void anotherLayerToolOrCanvasCommits(); + void anOpenTransformClosesTheGates(); + void handlesFollowTheDraftOrTheBox(); +}; + +void TransformSessionTests::severalLayersMoveInOneBox() +{ + EditorSession session; + session.createDocument(400, 300); + // The lower right image comes first in the list. + const QUuid right = insertImage(session, 20, 60, {300, 200}); + const QUuid left = insertImage(session, 40, 20, {50, 50}); + const QUuid apart = insertImage(session, 10, 10, {200, 20}); + QVERIFY(!session.transformsAsGroup()); + QVERIFY(session.groupTransformMembers().empty()); + QVERIFY(!session.groupTransformBox().has_value()); + session.selectLayers({left, right}, left); + QVERIFY(session.transformsAsGroup() && session.canTransform()); + QCOMPARE(ids(session.groupTransformMembers()), (QSet<QUuid>{left, right})); + // From the left image's corner to the right one's. + const LayerTransform box{.origin = {30, 40}, .size = {280, 190}}; + QCOMPARE(session.groupTransformBox().value(), box); + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(280, 190))); + session.selectTool(NavigationTool::hand); + session.selectLayers({left, right}, left); + session.beginTransform(); + QCOMPARE(session.tool(), NavigationTool::move); + const TransformEdit edit = session.transformEdit().value(); + QCOMPARE(edit.layerID, left); + QCOMPARE(edit.draft, box); + QCOMPARE(edit.group.value().box, box); + QCOMPARE(edit.group.value().originals.value(left), layerWith(session, left).transform); + QCOMPARE(edit.group.value().originals.value(right), layerWith(session, right).transform); + QCOMPARE(int(edit.group.value().originals.size()), 2); + LayerTransform moved = box; + moved.origin += QPointF(10, -5); + const int count = session.history.undoCount(); + session.previewTransform(moved); + // The preview carries both; the document waits for the commit. + QCOMPARE(session.displayedTransform(layerWith(session, left)).origin, QPointF(40, 35)); + QCOMPARE(session.displayedTransform(layerWith(session, right)).origin, QPointF(300, 165)); + QCOMPARE(session.pendingTransform(layerWith(session, apart)), std::nullopt); + QCOMPARE(session.displayedTransform(layerWith(session, apart)), layerWith(session, apart).transform); + QCOMPARE(layerWith(session, left).transform.origin, QPointF(30, 40)); + // The size shown is the box as it began. + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(280, 190))); + session.commitTransform(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, left).transform.origin, QPointF(40, 35)); + QCOMPARE(layerWith(session, right).transform.origin, QPointF(300, 165)); + QCOMPARE(layerWith(session, apart).transform.origin, QPointF(195, 15)); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Transform Layers")); + session.undo(); + QCOMPARE(layerWith(session, left).transform.origin, QPointF(30, 40)); + QCOMPARE(layerWith(session, right).transform.origin, QPointF(290, 170)); +} + +void TransformSessionTests::aFolderCarriesItsVisiblePixels() +{ + EditorSession session; + session.createDocument(400, 300); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + const QUuid shown = insertImage(session, 40, 20, {50, 50}); + session.selectLayer(folder); + session.addGroup(); + const QUuid nested = insertImage(session, 20, 20, {100, 100}); + session.selectLayer(folder); + const QUuid hidden = insertImage(session, 30, 30, {300, 200}); + session.toggleLayerVisibility(hidden); + session.selectLayer(folder); + session.addBlankLayer(); + session.selectLayer(std::nullopt); + const QUuid outside = insertImage(session, 10, 10, {350, 250}); + session.selectLayer(folder); + // One selected folder: its shown pixels, however deep. + QVERIFY(session.transformsAsGroup() && session.canTransform()); + QCOMPARE(ids(session.groupTransformMembers()), (QSet<QUuid>{shown, nested})); + QCOMPARE(session.groupTransformBox().value(), (LayerTransform{.origin = {30, 40}, .size = {80, 70}})); + // The handles sit on the box until an edit begins. + QCOMPARE(session.editedTransform(layerWith(session, folder)), session.groupTransformBox().value()); + QCOMPARE(session.editedTransform(layerWith(session, outside)), layerWith(session, outside).transform); + // Hidden, the folder shows nothing to move. + session.toggleLayerVisibility(folder); + QVERIFY(session.groupTransformMembers().empty()); + QVERIFY(!session.canTransform()); + QCOMPARE(session.editedTransform(layerWith(session, folder)), layerWith(session, folder).transform); + session.beginTransform(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.transformPixelSize(), std::nullopt); + // Inside a hidden folder a layer cannot transform either. + session.selectLayer(shown); + QVERIFY(!session.transformsAsGroup() && !session.canTransform()); + session.toggleLayerVisibility(folder); + QVERIFY(session.canTransform()); +} + +void TransformSessionTests::aFolderReachesSixtyThreeLevelsDown() +{ + EditorSession session; + session.createDocument(64, 64); + // Each new folder goes inside the last: 64 deep. + std::vector<QUuid> folders; + for (int depth = 0; depth < 64; ++depth) { + session.addGroup(); + folders.push_back(session.activeLayerID().value()); + } + const QUuid image = insertImage(session, 8, 8, {32, 32}); + QCOMPARE(layerWith(session, image).parentID, std::optional(folders.back())); + QVERIFY(session.document().value().effectiveVisibleIDs().contains(image)); + // The walk takes 64 steps, the layer itself among them. + session.selectLayer(folders[1]); + QCOMPARE(ids(session.groupTransformMembers()), QSet<QUuid>{image}); + QVERIFY(session.canTransform()); + session.selectLayer(folders[0]); + QVERIFY(session.groupTransformMembers().empty()); + QVERIFY(!session.canTransform()); +} + +void TransformSessionTests::aGroupMoveKeepsEachTurnFlipAndSampling() +{ + EditorSession session; + session.createDocument(400, 300); + // Opposite flips, different turns and samplings. + const auto placed = [&](QPointF center, double rotation, bool flipX, LayerSampling sampling) { + const QUuid id = insertImage(session, 40, 20, center); + session.beginTransform(); + LayerTransform value = session.transformEdit().value().draft; + value.rotation = rotation; + value.flipX = flipX; + value.flipY = !flipX; + value.sampling = sampling; + session.previewTransform(value); + session.commitTransform(); + return id; + }; + const QUuid first = placed({100, 100}, 30, true, LayerSampling::nearest); + const QUuid second = placed({250, 180}, -75, false, LayerSampling::smooth); + const LayerTransform firstBefore = layerWith(session, first).transform, secondBefore = layerWith(session, second).transform; + QCOMPARE(firstBefore.sampling, LayerSampling::nearest); + QVERIFY(firstBefore.flipX && !firstBefore.flipY && !secondBefore.flipX && secondBefore.flipY); + session.selectLayers({first, second}, second); + session.beginTransform(); + QCOMPARE(session.transformEdit().value().group.value().originals.value(first), firstBefore); + QCOMPARE(session.transformEdit().value().group.value().originals.value(second), secondBefore); + session.nudgeLayer(5, 3); + // The preview and the commit move origins and nothing else. + LayerTransform firstAfter = firstBefore, secondAfter = secondBefore; + firstAfter.origin += QPointF(5, 3); + secondAfter.origin += QPointF(5, 3); + QCOMPARE(session.displayedTransform(layerWith(session, first)), firstAfter); + QCOMPARE(session.displayedTransform(layerWith(session, second)), secondAfter); + session.commitTransform(); + QCOMPARE(layerWith(session, first).transform, firstAfter); + QCOMPARE(layerWith(session, second).transform, secondAfter); + // One layer's edit starts from all of its transform too. + session.selectLayer(first); + const int count = session.history.undoCount(); + session.beginTransform(); + QCOMPARE(session.transformEdit().value().draft, firstAfter); + QVERIFY(!session.transformEdit().value().group.has_value()); + session.commitTransform(); + QCOMPARE(session.history.undoCount(), count); +} + +void TransformSessionTests::aBoxSqueezedTooFarLeavesThatLayer() +{ + EditorSession session; + session.createDocument(400, 300); + const QUuid wide = insertImage(session, 200, 100, {100, 50}); + const QUuid thin = insertImage(session, 2, 100, {300, 50}); + session.selectLayers({wide, thin}, wide); + session.beginTransform(); + LayerTransform squeezed = session.transformEdit().value().draft; + QCOMPARE(squeezed, (LayerTransform{.origin = {0, 0}, .size = {301, 100}})); + // Quartered, the thin layer would be half a pixel. + squeezed.size.setWidth(squeezed.size.width() / 4); + session.previewTransform(squeezed); + // The pixels shown stay the box's as it began. + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(301, 100))); + session.commitTransform(); + QCOMPARE(layerWith(session, wide).transform.size, QSizeF(50, 100)); + QCOMPARE(layerWith(session, thin).transform, (LayerTransform{.origin = {299, 0}, .size = {2, 100}})); +} + +void TransformSessionTests::theBoxHoldsEveryCornerOfATurnedLayer() +{ + EditorSession session; + session.createDocument(200, 200); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + insertImage(session, 40, 20, {100, 100}); + session.beginTransform(); + LayerTransform turned = session.transformEdit().value().draft; + turned.rotation = 90; + session.previewTransform(turned); + session.commitTransform(); + session.selectLayer(folder); + // A quarter turn: 20 by 40 about (100, 100). + const LayerTransform box = session.groupTransformBox().value(); + QVERIFY2(std::abs(box.origin.x() - 90) < 1e-9 && std::abs(box.origin.y() - 80) < 1e-9, qPrintable(QString::number(box.origin.x()))); + QVERIFY2(std::abs(box.size.width() - 20) < 1e-9 && std::abs(box.size.height() - 40) < 1e-9, qPrintable(QString::number(box.size.width()))); + QCOMPARE(box.rotation, 0.0); +} + +void TransformSessionTests::aUnitLayerStillGetsAWholeBox() +{ + EditorSession session; + session.createDocument(64, 64); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + const QUuid dot = insertImage(session, 1, 1, {10, 10}); + session.beginTransform(); + LayerTransform off = session.transformEdit().value().draft; + off.origin = {1.3, 1.3}; + session.previewTransform(off); + session.commitTransform(); + // In doubles these corners lie under one pixel apart. + QVERIFY((1.3 + 0.5 + 0.5) - (1.3 + 0.5 - 0.5) < 1.0); + session.selectLayer(folder); + QCOMPARE(session.groupTransformBox().value().size, QSizeF(1, 1)); + session.beginTransform(); + QVERIFY(session.transformEdit().value().draft.isValid()); + QCOMPARE(layerWith(session, dot).transform.origin, QPointF(1.3, 1.3)); +} + +void TransformSessionTests::nudgingIsOneStepOrPartOfAnOpenEdit() +{ + EditorSession session; + session.createDocument(100, 100); + session.nudgeLayer(1, 0); + QVERIFY(!session.transformEdit().has_value()); + const QUuid image = insertImage(session, 20, 10, {50, 50}); + const int count = session.history.undoCount(); + session.nudgeLayer(3, -2); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43)); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Transform Layer")); + // Inside an open edit a nudge moves the draft alone. + session.beginTransform(); + session.nudgeLayer(1, 0); + session.nudgeLayer(0, 4); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(44, 47)); + QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43)); + QCOMPARE(session.history.undoCount(), count + 1); + session.cancelTransform(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43)); + QCOMPARE(session.history.undoCount(), count + 1); + // A nudge past the valid origins moves nothing. + session.nudgeLayer(2'000'000, 0); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43)); + QCOMPARE(session.history.undoCount(), count + 1); +} + +void TransformSessionTests::anotherLayerToolOrCanvasCommits() +{ + EditorSession session; + session.createDocument(100, 100); + const QUuid first = insertImage(session, 20, 10, {50, 50}); + const QUuid second = insertImage(session, 20, 10, {50, 50}); + const auto nudged = [&](QUuid id) { + session.selectLayer(id); + session.selectTool(NavigationTool::move); + session.beginTransform(); + session.nudgeLayer(1, 0); + return layerWith(session, id).transform.origin.x(); + }; + // The same layer and tool leave the edit open. + double x = nudged(second); + session.selectLayer(second); + session.selectTool(NavigationTool::move); + QVERIFY(session.transformEdit().has_value()); + QCOMPARE(layerWith(session, second).transform.origin.x(), x); + session.selectLayer(first); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, second).transform.origin.x(), x + 1); + x = nudged(first); + session.selectLayers({first, second}, first); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, first).transform.origin.x(), x + 1); + // The same selection again commits nothing. + session.beginTransform(); + session.nudgeLayer(5, 0); + session.selectLayers({first, second}, first); + QVERIFY(session.transformEdit().has_value()); + session.cancelTransform(); + x = nudged(second); + const int count = session.history.undoCount(); + session.createDocument(50, 50); + QVERIFY(!session.transformEdit().has_value()); + // The move, then the new canvas: two steps. + QCOMPARE(session.history.undoCount(), count + 2); + session.undo(); + QCOMPARE(layerWith(session, second).transform.origin.x(), x + 1); +} + +void TransformSessionTests::anOpenTransformClosesTheGates() +{ + EditorSession session; + session.createDocument(100, 100); + const QUuid first = insertImage(session, 20, 10, {50, 50}); + const QUuid second = insertImage(session, 20, 10, {50, 50}); + QVERIFY(session.canEditLayers() && session.canUndo()); + session.beginTransform(); + QVERIFY(!session.canEditLayers() && !session.canUndo() && !session.canTransform()); + session.addBlankLayer(); + session.undo(); + QCOMPARE(int(session.document().value().layers.size()), 2); + // A second begin keeps the draft of the first. + session.nudgeLayer(7, 0); + session.beginTransform(); + QCOMPARE(session.transformEdit().value().draft.origin.x(), 47.0); + // Cmd-Shift-click still extends: the edit commits first. + session.extendSelection(first); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, second).transform.origin.x(), 47.0); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second})); + QVERIFY(session.canEditLayers() && session.canUndo()); +} + +void TransformSessionTests::handlesFollowTheDraftOrTheBox() +{ + EditorSession session; + QCOMPARE(session.transformPixelSize(), std::nullopt); + session.createDocument(100, 100); + session.addBlankLayer(); + QCOMPARE(session.transformPixelSize(), std::nullopt); + const QUuid image = insertImage(session, 20, 10, {50, 50}); + const QUuid other = insertImage(session, 8, 6, {20, 20}); + session.selectLayer(image); + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10))); + QCOMPARE(session.editedTransform(layerWith(session, image)), layerWith(session, image).transform); + QCOMPARE(session.pendingTransform(layerWith(session, image)), std::nullopt); + session.beginTransform(); + LayerTransform draft = session.transformEdit().value().draft; + draft.size = {60, 30}; + draft.rotation = 45; + session.previewTransform(draft); + QCOMPARE(session.editedTransform(layerWith(session, image)), draft); + QCOMPARE(session.pendingTransform(layerWith(session, image)), std::optional(draft)); + QCOMPARE(session.displayedTransform(layerWith(session, image)), draft); + QCOMPARE(session.editedTransform(layerWith(session, other)), layerWith(session, other).transform); + QCOMPARE(session.pendingTransform(layerWith(session, other)), std::nullopt); + // The pixels stay the image's, whatever the draft or placement. + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10))); + session.commitTransform(); + QCOMPARE(layerWith(session, image).transform.size, QSizeF(60, 30)); + QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10))); + // Made active behind an open edit, a folder shows none. + session.selectLayer(std::nullopt); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + insertImage(session, 4, 4, {70, 70}); + session.selectLayer(image); + session.beginTransform(); + session.setActiveLayerID(folder); + QVERIFY(session.transformsAsGroup() && session.transformEdit().has_value()); + QCOMPARE(session.editedTransform(layerWith(session, folder)), layerWith(session, folder).transform); + QCOMPARE(session.transformPixelSize(), std::nullopt); + session.cancelTransform(); + QCOMPARE(session.editedTransform(layerWith(session, folder)), session.groupTransformBox().value()); +} + +QTEST_GUILESS_MAIN(TransformSessionTests) +#include "TransformSessionTests.moc" diff --git a/tests/TransformSnapTests.cpp b/tests/TransformSnapTests.cpp new file mode 100644 index 0000000..ad6aee9 --- /dev/null +++ b/tests/TransformSnapTests.cpp @@ -0,0 +1,109 @@ +#include "SessionFixtures.h" +#include <QtTest> + +// What a move snaps to; the edits drags leave behind. +namespace { +QUuid insertImage(EditorSession &session, int width, int height, QPointF center) +{ + session.insert(ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), center); + return session.activeLayerID().value(); +} +} + +class TransformSnapTests : public QObject { + Q_OBJECT +private slots: + void targetsAreTheCanvasAndTheOtherVisibleLayersEdgesAndMiddles(); + void snappedMoveNudgesWithinToleranceAndKeepsTheGuides(); + void dragsEditsAreNotPersistentAndEndsClearTheGuides(); +}; + +void TransformSnapTests::targetsAreTheCanvasAndTheOtherVisibleLayersEdgesAndMiddles() +{ + EditorSession session; + QCOMPARE(session.transformSnapTargets({}), SnapGuides{}); + session.createDocument(100, 80); + QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 50, 100}, {0, 40, 80}})); + const QUuid a = insertImage(session, 20, 10, {20, 15}); + const QUuid b = insertImage(session, 10, 10, {70, 60}); + QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 50, 100, 10, 20, 30, 65, 70, 75}, {0, 40, 80, 10, 15, 20, 55, 60, 65}})); + // The layers being moved are no targets; hidden ones neither. + QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 65, 70, 75}, {0, 40, 80, 55, 60, 65}})); + session.toggleLayerVisibility(b); + QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100}, {0, 40, 80}})); + session.toggleLayerVisibility(b); + // A turned layer offers the box around its corners, rounded. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, b).transform = {.origin = {60, 50}, .size = {20, 10}, .rotation = 90}; }); + QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 65, 70, 75}, {0, 40, 80, 45, 55, 65}})); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, b).transform = {.origin = {60, 50}, .size = {20, 10}, .rotation = 30}; }); + QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 59, 70, 81}, {0, 40, 80, 46, 55, 64}})); + // A pending edit's placement counts, as the canvas shows it. + session.selectLayer(b); + session.beginTransform(); + session.previewTransform({.origin = {50, 50}, .size = {10, 10}}); + QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 50, 55, 60}, {0, 40, 80, 50, 55, 60}})); + session.cancelTransform(); + // A blank layer has no pixels to line up with. + session.addBlankLayer(); + QCOMPARE(session.transformSnapTargets({a, b}), (SnapGuides{{0, 50, 100}, {0, 40, 80}})); +} + +void TransformSnapTests::snappedMoveNudgesWithinToleranceAndKeepsTheGuides() +{ + EditorSession session; + session.createDocument(100, 80); + const QUuid a = insertImage(session, 20, 10, {20, 15}); + // Middle three off, top two off: both snap. + const LayerTransform draft{.origin = {43, 38}, .size = {20, 10}}; + QCOMPARE(session.snappedMove(draft, {a}, 5), (LayerTransform{.origin = {40, 40}, .size = {20, 10}})); + QCOMPARE(session.snapGuides, (SnapGuides{{50}, {40}})); + // Past the tolerance the draft comes back untouched. + QCOMPARE(session.snappedMove(draft, {a}, 1), draft); + QCOMPARE(session.snapGuides, SnapGuides{}); + // The nearest of left, middle and right wins, one axis. + const LayerTransform edge{.origin = {98, 60}, .size = {20, 10}}; + QCOMPARE(session.snappedMove(edge, {a}, 5), (LayerTransform{.origin = {100, 60}, .size = {20, 10}})); + QCOMPARE(session.snapGuides, (SnapGuides{{100}, {}})); + // A turned draft snaps by the box around its corners. + const LayerTransform turned{.origin = {42, 40}, .size = {20, 10}, .rotation = 90}; + QCOMPARE(session.snappedMove(turned, {a}, 3), (LayerTransform{.origin = {40, 40}, .size = {20, 10}, .rotation = 90})); + QCOMPARE(session.snapGuides, (SnapGuides{{50}, {}})); + // Another layer's edge is a target unless it moves too. + const QUuid b = insertImage(session, 10, 10, {70, 60}); + const LayerTransform near{.origin = {77, 20}, .size = {20, 10}}; + QCOMPARE(session.snappedMove(near, {a}, 2).origin, QPointF(75, 20)); + QCOMPARE(session.snappedMove(near, {a, b}, 2).origin, QPointF(77, 20)); +} + +void TransformSnapTests::dragsEditsAreNotPersistentAndEndsClearTheGuides() +{ + EditorSession session; + session.createDocument(100, 80); + insertImage(session, 20, 10, {20, 15}); + session.beginTransform(); + QVERIFY(session.transformEdit().value().persistent); + session.snapGuides = {{50}, {}}; + session.commitTransform(); + QCOMPARE(session.snapGuides, SnapGuides{}); + session.beginTransform(false); + QVERIFY(!session.transformEdit().value().persistent); + session.snapGuides = {{}, {40}}; + session.cancelTransform(); + QCOMPARE(session.snapGuides, SnapGuides{}); + // Nothing pending: the ends still clear what a drag left. + session.snapGuides = {{50}, {}}; + session.cancelTransform(); + QCOMPARE(session.snapGuides, SnapGuides{}); + session.snapGuides = {{50}, {}}; + session.commitTransform(); + QCOMPARE(session.snapGuides, SnapGuides{}); + // The Move settings start as Swift's and change on request. + QVERIFY(!session.transformAutoSelect() && session.showsTransformControls() && session.locksTransformRatio()); + session.setTransformAutoSelect(true); + session.setShowsTransformControls(false); + session.setLocksTransformRatio(false); + QVERIFY(session.transformAutoSelect() && !session.showsTransformControls() && !session.locksTransformRatio()); +} + +QTEST_GUILESS_MAIN(TransformSnapTests) +#include "TransformSnapTests.moc" diff --git a/tests/TransformTests.cpp b/tests/TransformTests.cpp new file mode 100644 index 0000000..18d7a99 --- /dev/null +++ b/tests/TransformTests.cpp @@ -0,0 +1,423 @@ +#include "Document/EditorSession.h" +#include <QtTest> + +namespace { +// A red image as large as the canvas. +void insertPaintedLayer(EditorSession &session) +{ + QImage image(session.document().value().size().toSize(), QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::red); + session.insert(ImportedImage(image, image, "Painted layer")); +} + +bool near(QPointF lhs, QPointF rhs, double tolerance = 0.0001) +{ + return std::abs(lhs.x() - rhs.x()) < tolerance && std::abs(lhs.y() - rhs.y()) < tolerance; +} +} + +class TransformTests : public QObject { + Q_OBJECT +private slots: + void blankLayersHaveNoTransformHandlesOrEditing(); + void rotatedResizeKeepsOppositeAnchorAtEveryHandle(); + void moveRotateAndShiftConstraints(); + void previewCommitCancelAndUndoPreserveSources(); + void duplicateTransformPreservesOriginalAndSupportsUndoAndCancel(); + void duplicateTransformCopiesEveryPixelLayerButNoFolder(); + void scalePercentSetsBothSidesAboutTheCenter(); + void noOpInvalidValuesAndSwitchingTools(); + void rotatedHitTesting(); + void unitToDocumentAgreesWithPoint(); + void unitToDocumentFlipsAboutTheCenter(); + void placingRecoversATransformFromItsMap(); + void followingCarriesAPlainMoveExactly(); + void followingCarriesAMaskThroughScaleAndRotation(); + void roundedLeavesWholePixelsAndDegrees(); + void samePlacementIgnoresSampling(); + void transformsCompareExactly(); + void validityLimits(); + void resizePastTheAnchorFlipsTheLayer(); + void distortDragMovesCornersEdgesAndBody(); + void snapPullsTheNearestGuideWithinTolerance(); +}; + +void TransformTests::rotatedResizeKeepsOppositeAnchorAtEveryHandle() +{ + const LayerTransform original{.origin = {31, -19}, .size = {200, 100}, .rotation = 37}; + for (int index = 0; index < 8; ++index) { + const QPointF handle = LayerTransform::handles[index]; + const QPointF opposite(1 - handle.x(), 1 - handle.y()); + const QPointF start = original.point(handle); + const TransformDrag drag{original, start, {TransformDrag::Kind::resize, index}, std::nullopt}; + for (bool locked : {true, false}) { + const LayerTransform changed = drag.updated(start + QPointF(34, 17), locked, false); + QVERIFY(near(original.point(opposite), changed.point(opposite))); + if (locked) + QVERIFY(std::abs(changed.size.width() / changed.size.height() - 2) < 0.0001); + QVERIFY(changed.isValid()); + QVERIFY(!(changed == original)); + } + } +} + +void TransformTests::moveRotateAndShiftConstraints() +{ + const LayerTransform original{.origin = {0, 0}, .size = {100, 50}}; + const TransformDrag move{original, {40, 20}, {TransformDrag::Kind::move}, std::nullopt}; + const LayerTransform moved = move.updated({60, 25}, true, true); + QCOMPARE(moved.origin, QPointF(20, 0)); + const TransformDrag rotate{original, {100, 25}, {TransformDrag::Kind::rotate}, std::nullopt}; + const LayerTransform rotated = rotate.updated({50, 75}, true, false); + QVERIFY(std::abs(rotated.rotation - 90) < 0.0001); + QCOMPARE(rotated.center(), original.center()); + const LayerTransform snapped = rotate.updated({99, 45}, true, true); + QCOMPARE(snapped.rotation, 15.0); + const TransformDrag resize{original, {100, 50}, {TransformDrag::Kind::resize, 4}, std::nullopt}; + const LayerTransform free = resize.updated({150, 50}, true, true); + QCOMPARE(free.size, QSizeF(150, 50)); +} + +void TransformTests::blankLayersHaveNoTransformHandlesOrEditing() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + QVERIFY(!session.canTransform()); + session.beginTransform(); + QVERIFY(!session.transformEdit().has_value()); + insertPaintedLayer(session); + QVERIFY(session.canTransform()); +} + +void TransformTests::previewCommitCancelAndUndoPreserveSources() +{ + EditorSession session; + session.createDocument(200, 100); + insertPaintedLayer(session); + const ImageLayer original = session.activeLayer().value(); + const int count = session.history.undoCount(); + session.beginTransform(); + LayerTransform value = original.transform; + value.origin = {-45, 34}; + value.size = {80, 140}; + value.rotation = 23; + value.flipX = true; + for (int index = 0; index < 30; ++index) + session.previewTransform(value); + QCOMPARE(session.activeLayer().value().transform, original.transform); + QCOMPARE(session.history.undoCount(), count); + session.cancelTransform(); + QCOMPARE(session.activeLayer().value(), original); + session.beginTransform(); + session.previewTransform(value); + session.commitTransform(); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.activeLayer().value().transform, value); + session.undo(); + QCOMPARE(session.activeLayer().value(), original); + session.redo(); + QCOMPARE(session.activeLayer().value().transform, value); + QCOMPARE(session.activeLayerID(), std::optional(original.id)); +} + +void TransformTests::scalePercentSetsBothSidesAboutTheCenter() +{ + EditorSession session; + session.createDocument(400, 200); + insertPaintedLayer(session); + const QSizeF pixels = session.transformPixelSize().value(); + QCOMPARE(pixels, QSizeF(400, 200)); + LayerTransform stretched = session.activeLayer().value().transform; + stretched.size = {pixels.width() * 2, pixels.height() * 3}; + stretched.rotation = 30; + QCOMPARE(stretched.scalePercent(pixels), 200.0); + const LayerTransform scaled = stretched.scaled(50, pixels); + QCOMPARE(scaled.size, QSizeF(pixels.width() / 2, pixels.height() / 2)); + QVERIFY(near(scaled.center(), stretched.center(), 0.001)); + QCOMPARE(scaled.rotation, 30.0); + QCOMPARE(scaled.scalePercent(pixels), 50.0); +} + +void TransformTests::noOpInvalidValuesAndSwitchingTools() +{ + EditorSession session; + session.createDocument(200, 100); + insertPaintedLayer(session); + const int count = session.history.undoCount(); + session.beginTransform(); + session.commitTransform(); + QCOMPARE(session.history.undoCount(), count); + session.beginTransform(); + LayerTransform invalid = session.transformEdit().value().draft; + invalid.size.setWidth(0); + session.previewTransform(invalid); + QCOMPARE(session.transformEdit().value().draft.size.width(), 200.0); + invalid.size.setWidth(std::numeric_limits<double>::infinity()); + session.previewTransform(invalid); + QCOMPARE(session.transformEdit().value().draft.size.width(), 200.0); + LayerTransform moved = session.transformEdit().value().draft; + moved.origin.setX(10); + session.previewTransform(moved); + session.selectTool(NavigationTool::hand); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.activeLayer().value().transform.origin.x(), 10.0); + QCOMPARE(session.history.undoCount(), count + 1); +} + +void TransformTests::rotatedHitTesting() +{ + const LayerTransform transform{.origin = {100, 200}, .size = {100, 50}, .rotation = 90}; + QVERIFY(transform.contains(transform.center())); + QVERIFY(transform.contains(QPointF(150, 265))); + QVERIFY(!transform.contains(QPointF(190, 225))); +} + +void TransformTests::unitToDocumentAgreesWithPoint() +{ + const LayerTransform transform{.origin = {31, -19}, .size = {200, 100}, .rotation = 37}; + const QTransform map = transform.unitToDocument(); + for (const QPointF unit : {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0.25, 0.8)}) + QVERIFY(near(map.map(unit), transform.point(unit))); + const LayerTransform quarterTurn{.origin = {0, 0}, .size = {100, 50}, .rotation = 90}; + QVERIFY(near(quarterTurn.unitToDocument().map(QPointF(1, 0.5)), QPointF(50, 75))); +} + +void TransformTests::unitToDocumentFlipsAboutTheCenter() +{ + const LayerTransform flipped{.origin = {10, 20}, .size = {100, 50}, .flipX = true}; + QVERIFY(near(flipped.unitToDocument().map(QPointF(0, 0)), QPointF(110, 20))); + const LayerTransform turnedOver{.origin = {10, 20}, .size = {100, 50}, .flipY = true}; + QVERIFY(near(turnedOver.unitToDocument().map(QPointF(0, 0)), QPointF(10, 70))); +} + +void TransformTests::placingRecoversATransformFromItsMap() +{ + for (bool flipX : {false, true}) { + for (bool flipY : {false, true}) { + const LayerTransform source{.origin = {31, -19}, .size = {200, 100}, .rotation = 397, + .flipX = flipX, .flipY = flipY, .sampling = LayerSampling::smooth}; + const LayerTransform placed = source.placing(source.unitToDocument()); + QVERIFY(near(placed.origin, source.origin)); + QVERIFY(std::abs(placed.size.width() - 200) < 0.0001); + QVERIFY(std::abs(placed.size.height() - 100) < 0.0001); + QVERIFY(std::abs(placed.rotation - 397) < 0.0001); + QCOMPARE(placed.flipX, flipX); + QCOMPARE(placed.flipY, flipY); + QCOMPARE(placed.sampling, LayerSampling::smooth); + } + } +} + +void TransformTests::followingCarriesAPlainMoveExactly() +{ + const LayerTransform mask{.origin = {5.5, 7.25}, .size = {40, 30}, .rotation = 12}; + const LayerTransform old{.origin = {0, 0}, .size = {100, 50}, .rotation = 12}; + LayerTransform moved = old; + moved.origin = {13, -4}; + const LayerTransform carried = mask.following(old, moved); + QCOMPARE(carried.origin, QPointF(18.5, 3.25)); + QCOMPARE(carried.size, mask.size); + QCOMPARE(carried.rotation, 12.0); + QCOMPARE(mask.following(old, old), mask); +} + +void TransformTests::followingCarriesAMaskThroughScaleAndRotation() +{ + const LayerTransform old{.origin = {0, 0}, .size = {100, 50}}; + const LayerTransform inner{.origin = {25, 12.5}, .size = {50, 25}}; + const LayerTransform updated{.origin = {100, 100}, .size = {200, 100}, .rotation = 90}; + const LayerTransform carried = inner.following(old, updated); + QVERIFY(near(carried.center(), updated.center())); + QVERIFY(std::abs(carried.size.width() - 100) < 0.0001); + QVERIFY(std::abs(carried.size.height() - 50) < 0.0001); + QVERIFY(std::abs(carried.rotation - 90) < 0.0001); + for (const QPointF unit : {QPointF(0, 0), QPointF(1, 1)}) + QVERIFY(near(carried.point(unit), updated.point(old.unitToDocument().inverted().map(inner.point(unit))))); +} + +void TransformTests::roundedLeavesWholePixelsAndDegrees() +{ + const LayerTransform rough{.origin = {10.5, -3.5}, .size = {0.2, 99.5}, .rotation = 44.5}; + const LayerTransform whole = rough.rounded(); + QCOMPARE(whole.origin, QPointF(11, -4)); + QCOMPARE(whole.size, QSizeF(1, 100)); + QCOMPARE(whole.rotation, 45.0); +} + +void TransformTests::samePlacementIgnoresSampling() +{ + const LayerTransform high{.origin = {1, 2}, .size = {30, 40}, .rotation = 5}; + LayerTransform nearest = high; + nearest.sampling = LayerSampling::nearest; + QVERIFY(!(high == nearest)); + QVERIFY(high.samePlacement(nearest)); + nearest.origin = {2, 2}; + QVERIFY(!high.samePlacement(nearest)); +} + +void TransformTests::transformsCompareExactly() +{ + const LayerTransform transform{.origin = {1, 2}, .size = {30, 40}, .rotation = 5}; + QVERIFY(transform == LayerTransform(transform)); + const auto differs = [&](auto change) { + LayerTransform other = transform; + change(other); + return !(transform == other); + }; + QVERIFY(differs([](LayerTransform &other) { other.origin.rx() += 1e-13; })); + QVERIFY(differs([](LayerTransform &other) { other.origin.ry() += 1e-13; })); + QVERIFY(differs([](LayerTransform &other) { other.size.rwidth() += 1e-12; })); + QVERIFY(differs([](LayerTransform &other) { other.size.rheight() += 1e-12; })); + QVERIFY(differs([](LayerTransform &other) { other.rotation += 1e-13; })); + QVERIFY(differs([](LayerTransform &other) { other.flipX = true; })); + QVERIFY(differs([](LayerTransform &other) { other.flipY = true; })); + QVERIFY(differs([](LayerTransform &other) { other.sampling = LayerSampling::smooth; })); +} + +void TransformTests::validityLimits() +{ + const LayerTransform valid{.origin = {-1'000'000, 1'000'000}, .size = {1, 300'000}}; + QVERIFY(valid.isValid()); + LayerTransform tooSmall = valid; + tooSmall.size = {0.5, 10}; + QVERIFY(!tooSmall.isValid()); + LayerTransform tooLarge = valid; + tooLarge.size = {300'001, 10}; + QVERIFY(!tooLarge.isValid()); + LayerTransform tooFar = valid; + tooFar.origin = {1'000'001, 0}; + QVERIFY(!tooFar.isValid()); + LayerTransform notFinite = valid; + notFinite.rotation = std::nan(""); + QVERIFY(!notFinite.isValid()); +} + +void TransformTests::resizePastTheAnchorFlipsTheLayer() +{ + const LayerTransform original{.origin = {0, 0}, .size = {100, 50}}; + const TransformDrag resize{original, {100, 25}, {TransformDrag::Kind::resize, 3}, std::nullopt}; + const LayerTransform turned = resize.updated({-60, 25}, false, false); + QVERIFY(turned.flipX); + QVERIFY(!turned.flipY); + QCOMPARE(turned.size, QSizeF(60, 50)); + QCOMPARE(turned.origin, QPointF(-60, 0)); +} + +void TransformTests::distortDragMovesCornersEdgesAndBody() +{ + const LayerTransform original{.origin = {0, 0}, .size = {100, 50}}; + const Corners corners{QPointF(0, 0), QPointF(100, 0), QPointF(100, 50), QPointF(0, 50)}; + const TransformDrag corner{original, {100, 0}, {TransformDrag::Kind::distort, 2}, corners}; + QCOMPARE(corner.corners({110, 5}).value(), (Corners{QPointF(0, 0), QPointF(110, 5), QPointF(100, 50), QPointF(0, 50)})); + QCOMPARE(corner.corners({110, 5}, true).value(), (Corners{QPointF(0, 0), QPointF(110, 0), QPointF(100, 50), QPointF(0, 50)})); + const TransformDrag edge{original, {100, 25}, {TransformDrag::Kind::distort, 3}, corners}; + QCOMPARE(edge.corners({120, 25}).value(), (Corners{QPointF(0, 0), QPointF(120, 0), QPointF(120, 50), QPointF(0, 50)})); + const TransformDrag body{original, {50, 25}, {TransformDrag::Kind::move}, corners}; + QCOMPARE(body.corners({53, 29}).value(), (Corners{QPointF(3, 4), QPointF(103, 4), QPointF(103, 54), QPointF(3, 54)})); + const TransformDrag plain{original, {50, 25}, {TransformDrag::Kind::move}, std::nullopt}; + QVERIFY(!plain.corners({53, 29}).has_value()); + const TransformDrag rotating{original, {50, 25}, {TransformDrag::Kind::rotate}, corners}; + QVERIFY(!rotating.corners({53, 29}).has_value()); +} + +void TransformTests::snapPullsTheNearestGuideWithinTolerance() +{ + const QRectF box(12, 40, 100, 60); + const TransformSnap::Offset snapped = TransformSnap::offset(box, {0, 60, 500}, {300}, 5); + QCOMPARE(snapped.offset, QSizeF(-2, 0)); + QCOMPARE(snapped.x, std::optional<double>(60)); + QCOMPARE(snapped.y, std::nullopt); + const TransformSnap::Offset edge = TransformSnap::offset(box, {9, 116}, {97}, 5); + QCOMPARE(edge.offset, QSizeF(-3, -3)); + QCOMPARE(edge.x, std::optional<double>(9)); + QCOMPARE(edge.y, std::optional<double>(97)); +} + +void TransformTests::duplicateTransformPreservesOriginalAndSupportsUndoAndCancel() +{ + EditorSession session; + session.createDocument(400, 200); + insertPaintedLayer(session); + const ImageLayer original = session.activeLayer().value(); + const int count = session.history.undoCount(); + session.beginDuplicateTransform(); + QVERIFY(session.transformEdit().has_value()); + QVERIFY(!session.transformEdit().value().persistent); + LayerTransform moved = original.transform; + moved.origin.setX(moved.origin.x() + 50); + session.previewTransform(moved); + session.commitTransform(); + QCOMPARE(int(session.document().value().layers.size()), 2); + QCOMPARE(session.document().value().layers[0].transform, original.transform); + QCOMPARE(session.activeLayer().value().transform, moved); + QCOMPARE(session.activeLayer().value().name, QString("Painted layer copy")); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Duplicate Layer")); + session.undo(); + QCOMPARE(int(session.document().value().layers.size()), 1); + QCOMPARE(session.activeLayerID(), std::optional(original.id)); + session.beginDuplicateTransform(); + session.previewTransform(moved); + session.cancelTransform(); + QCOMPARE(int(session.document().value().layers.size()), 1); + QCOMPARE(session.activeLayerID(), std::optional(original.id)); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{original.id}); + QCOMPARE(session.history.undoCount(), count); + QVERIFY(session.canUndo()); + // A second begin while one is open changes nothing. + session.beginDuplicateTransform(); + session.beginDuplicateTransform(); + QCOMPARE(int(session.document().value().layers.size()), 2); + session.cancelTransform(); + QCOMPARE(int(session.document().value().layers.size()), 1); +} + +void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder() +{ + EditorSession session; + session.createDocument(400, 200); + insertPaintedLayer(session); + const QUuid first = session.activeLayerID().value(); + insertPaintedLayer(session); + const QUuid second = session.activeLayerID().value(); + session.addGroup(); + const QUuid folder = session.activeLayerID().value(); + // A folder has no pixels to copy; layers do. + session.selectLayers({first, second, folder}, first); + session.beginDuplicateTransform(); + QCOMPARE(int(session.document().value().layers.size()), 5); + QCOMPARE(session.selectedLayerIDs().size(), 2); + QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(second)); + // The topmost copy leads; copies sit above their sources. + QCOMPARE(session.document().value().layers[1].name, QString("Painted layer copy")); + QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[3].id)); + QVERIFY(session.transformsAsGroup()); + session.cancelTransform(); + QCOMPARE(int(session.document().value().layers.size()), 3); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second, folder})); + QCOMPARE(session.activeLayerID(), std::optional(first)); + session.selectLayers({first, second}, second); + session.beginDuplicateTransform(); + session.commitTransform(); + QCOMPARE(session.history.undoName(), QString("Duplicate Layers")); + QCOMPARE(int(session.document().value().layers.size()), 5); + // A folder alone has no pixels: nothing begins. + QVERIFY(session.placeLayer(second, folder)); + session.selectLayer(folder); + QVERIFY(session.canTransform()); + session.beginDuplicateTransform(); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(int(session.document().value().layers.size()), 5); + QVERIFY(session.canUndo()); + session.setIsProjectBusy(true); + session.selectLayer(first); + session.beginDuplicateTransform(); + QVERIFY(!session.transformEdit().has_value()); + session.setIsProjectBusy(false); + QVERIFY(session.canUndo()); + QCOMPARE(int(session.document().value().layers.size()), 5); +} + +QTEST_GUILESS_MAIN(TransformTests) +#include "TransformTests.moc" diff --git a/tests/TypeControlsFixtures.h b/tests/TypeControlsFixtures.h new file mode 100644 index 0000000..236a390 --- /dev/null +++ b/tests/TypeControlsFixtures.h @@ -0,0 +1,56 @@ +#pragma once +#include "SessionFixtures.h" +#include "UI/TypeControls.h" +#include <QComboBox> +#include <QPushButton> +#include <QToolButton> +#include <QtTest> + +// Shared by the Type bar's tests: the bar, a document. +namespace { +template <typename Widget> Widget &find(QWidget &parent, const char *name) +{ + auto *found = parent.findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; +} + +// A new text layer saying `content`, left active. +QUuid addText(EditorSession &session, const QString &content) +{ + session.selectTool(NavigationTool::type); + session.beginText(QPointF(20, 30), true); + TextDraft draft = session.textDraft().value(); + draft.style.content = content; + if (!session.applyText(draft)) + throw std::runtime_error("the text was refused"); + return session.activeLayerID().value(); +} + +// The bar shown in an active window, over a document. +struct Bar { + EditorSession session; + TypeControls bar; + Bar() : bar((session.createDocument(400, 300, true), session.selectTool(NavigationTool::type), session)) + { + bar.show(); + if (!QTest::qWaitForWindowActive(&bar)) + throw std::runtime_error("the bar never became active"); + } + TextStyleField &field(const char *name) { return find<TextStyleField>(bar, name); } + QToolButton &align(const char *name) { return find<QToolButton>(bar, name); } + QPushButton &button(const char *name) { return find<QPushButton>(bar, name); } + // Replaces a field's text key by key, as typed. + void type(TextStyleField &field, const QString &text) + { + field.setFocus(); + if (!QTest::qWaitFor([&] { return field.hasFocus(); })) + throw std::runtime_error("the field never took the focus"); + field.selectAll(); + QTest::keyClicks(&field, text); + } + void restyle(const std::function<void(LayerTextStyle &)> &change) { session.changeTextStyle(change); } +}; + +} diff --git a/tests/TypeControlsTests.cpp b/tests/TypeControlsTests.cpp new file mode 100644 index 0000000..31fd5fa --- /dev/null +++ b/tests/TypeControlsTests.cpp @@ -0,0 +1,465 @@ +#include "TypeControlsFixtures.h" +#include "Rendering/TextLayout.h" +#include "UI/LayerIcons.h" +#include "UI/NativeLayerList.h" +#include "UI/ToolIcons.h" +#include "UI/TypeControls.h" +#include <QApplication> +#include <QComboBox> +#include <QLabel> +#include <QLineEdit> +#include <QPainter> +#include <QPushButton> +#include <QScrollArea> +#include <QToolButton> +#include <QtTest> + +// The Type bar and the panel's text rows. +namespace { +// The first and last inked columns of an icon's row. +std::pair<int, int> inkedColumns(const QToolButton &button, int row) +{ + const QImage image = button.icon().pixmap(QSize(18, 18), 1).toImage(); + int first = -1, last = -1; + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, row)) > 64) { + first = first < 0 ? x : first; + last = x; + } + } + return {first, last}; +} +} + +class TypeControlsTests : public QObject { + Q_OBJECT +private slots: + void theBarShowsTheStyleItEdits(); + void sizeAndTrackingTakeWhatTheyCanRead(); + void sizeAndTrackingFollowTheLocale(); + void leadingIsAutoWhenEmptyAndClamps(); + void fieldsKeepTypingAndTheFocus(); + void alignmentAndFontChangeTheDraft(); + void theButtonsFollowTheDraft(); + void theBarRestsWithoutADocumentOrWhileBusy(); + void aTextRowShowsAndOpensItsText(); +}; + +void TypeControlsTests::theBarShowsTheStyleItEdits() +{ + Bar shown; + QCOMPARE(shown.bar.title->text(), QString("Type")); + auto &font = find<QComboBox>(shown.bar, "typeFont"); + QCOMPARE(font.width(), 185); + QCOMPARE(font.toolTip(), QString("Font face, including bold and italic variants")); + // Every face, and Helvetica though missing, sorted. + QStringList names = TextLayout::availableFonts(); + names << QStringLiteral("Helvetica"); + names.sort(); + QStringList listed; + for (int index = 0; index < font.count(); ++index) + listed << font.itemText(index); + QCOMPARE(listed, names); + QCOMPARE(font.currentText(), QString("Helvetica")); + TextStyleField &size = shown.field("typeSize"), &tracking = shown.field("typeTracking"), &leading = shown.field("typeLeading"); + QVERIFY(size.width() == 52 && tracking.width() == 45 && leading.width() == 52); + QCOMPARE(size.text(), QString("72")); + QCOMPARE(tracking.text(), QString("0")); + QCOMPARE(leading.text(), QString()); + QCOMPARE(leading.placeholderText(), QString("Auto")); + QCOMPARE(leading.toolTip(), QString("Line height, baseline to baseline. Empty or 0 is Auto: 120% of the font size.")); + QVERIFY(size.alignment().testFlag(Qt::AlignRight)); + QStringList labels; + for (const QLabel *label : shown.bar.findChildren<QLabel *>()) + labels << label->text(); + labels.sort(); + QCOMPARE(labels, (QStringList{"Leading", "Tracking", "Type", "px"})); + QToolButton &left = shown.align("typeAlignLeft"), ¢er = shown.align("typeAlignCenter"), &right = shown.align("typeAlignRight"); + QCOMPARE(left.toolTip(), QString("Align left")); + QCOMPARE(center.accessibleName(), QString("Align center")); + QCOMPARE(right.toolTip(), QString("Align right")); + QVERIFY(left.size() == QSize(30, 26) && left.autoRaise() && left.parentWidget()->layout()->spacing() == 2); + QVERIFY(left.isChecked() && !center.isChecked() && !right.isChecked()); + // Four lines each; the short ones show the side. + const auto [leftStart, leftEnd] = inkedColumns(left, 7); + const auto [centreStart, centreEnd] = inkedColumns(center, 7); + const auto [rightStart, rightEnd] = inkedColumns(right, 7); + QVERIFY(leftStart == 1 && leftEnd == 11 && centreStart == 4 && centreEnd == 13 && rightStart == 6 && rightEnd == 16); + QCOMPARE(inkedColumns(center, 4), (std::pair{1, 16})); + QCOMPARE(inkedColumns(left, 14), (std::pair{1, 11})); + QCOMPARE(inkedColumns(left, 12), (std::pair{-1, -1})); + const QColor glyph = left.icon().pixmap(QSize(18, 18), 1).toImage().pixelColor(9, 4); + const QColor ink = shown.bar.palette().color(QPalette::WindowText); + QVERIFY(std::abs(glyph.red() - ink.red()) <= 2 && std::abs(glyph.green() - ink.green()) <= 2 && std::abs(glyph.blue() - ink.blue()) <= 2); + // A new theme redraws them in its own ink. + const QPalette before = QApplication::palette(); + QPalette themed = before; + themed.setColor(QPalette::WindowText, QColor(200, 10, 30)); + QApplication::setPalette(themed); + const auto inked = [&] { + const QColor now = center.icon().pixmap(QSize(18, 18), 1).toImage().pixelColor(9, 4); + return std::abs(now.red() - 200) <= 2 && std::abs(now.green() - 10) <= 2 && std::abs(now.blue() - 30) <= 2; + }; + QTRY_VERIFY(inked()); + QApplication::setPalette(before); + // Swift's order and spacing, scrolling without bars. + auto &scroll = find<QScrollArea>(shown.bar, "typeScroll"); + QWidget &fields = find<QWidget>(shown.bar, "typeFields"); + QVERIFY(scroll.widget() == &fields && scroll.widgetResizable() && scroll.frameShape() == QFrame::NoFrame); + QVERIFY(scroll.horizontalScrollBarPolicy() == Qt::ScrollBarAlwaysOff && scroll.verticalScrollBarPolicy() == Qt::ScrollBarAlwaysOff); + QCOMPARE(scroll.height(), fields.sizeHint().height()); + QCOMPARE(fields.layout()->spacing(), 10); + QVERIFY(font.x() < size.x() && size.x() < left.parentWidget()->x() && left.parentWidget()->x() < tracking.x() && tracking.x() < leading.x()); + // The defaults, then a text layer's own style. + const QUuid blank = shown.session.activeLayerID().value(); + shown.restyle([](LayerTextStyle &style) { + style.fontSize = 30; + style.alignment = TextAlignment::right; + }); + QCOMPARE(size.text(), QString("30")); + QVERIFY(right.isChecked() && !left.isChecked()); + const QUuid text = addText(shown.session, QStringLiteral("Styled")); + shown.session.selectLayer(blank); + shown.restyle([](LayerTextStyle &style) { + style.fontSize = 50; + style.tracking = 2.5; + style.leading = 90.4; + style.alignment = TextAlignment::center; + }); + QVERIFY(size.text() == QString("50") && tracking.text() == QString("2.5") && leading.text() == QString("90")); + QVERIFY(center.isChecked()); + shown.session.selectLayer(text); + QVERIFY(size.text() == QString("30") && tracking.text() == QString("0") && leading.text() == QString()); + QVERIFY(right.isChecked() && !center.isChecked()); + // A draft shows its own style. + shown.session.editActiveText(); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 44.25; }); + QCOMPARE(size.text(), QString("44.25")); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 1234.5678; }); + QCOMPARE(size.text(), QString("1234.568")); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 40.0004; }); + QCOMPARE(size.text(), QString("40")); + QCOMPARE(shown.session.activeLayer().value().liveText().value().style.fontSize, 30.0); +} + +void TypeControlsTests::sizeAndTrackingTakeWhatTheyCanRead() +{ + Bar shown; + TextStyleField &size = shown.field("typeSize"), &tracking = shown.field("typeTracking"); + shown.type(size, "48"); + QCOMPARE(shown.session.currentTextStyle().fontSize, 48.0); + // A size out of range, or no number, changes nothing. + shown.type(size, "0"); + QCOMPARE(size.text(), QString("0")); + QCOMPARE(shown.session.currentTextStyle().fontSize, 48.0); + shown.type(size, "2500"); + QCOMPARE(shown.session.currentTextStyle().fontSize, 250.0); + shown.type(size, "x"); + QCOMPARE(shown.session.currentTextStyle().fontSize, 250.0); + shown.type(size, "12.5"); + QCOMPARE(shown.session.currentTextStyle().fontSize, 12.5); + // Arrows step one, Shift ten, within 1 to 2000. + QTest::keyClick(&size, Qt::Key_Up); + QCOMPARE(shown.session.currentTextStyle().fontSize, 13.5); + QCOMPARE(size.text(), QString("13.5")); + QTest::keyClick(&size, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().fontSize, 3.5); + QTest::keyClick(&size, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().fontSize, 1.0); + QCOMPARE(size.text(), QString("1")); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 1995; }); + QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().fontSize, 2000.0); + QCOMPARE(size.text(), QString("2000")); + // Tracking takes any readable number the style allows. + shown.type(tracking, "-3"); + QCOMPARE(shown.session.currentTextStyle().tracking, -3.0); + QTest::keyClick(&tracking, Qt::Key_Up); + QCOMPARE(shown.session.currentTextStyle().tracking, -2.0); + QTest::keyClick(&tracking, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().tracking, -12.0); + QCOMPARE(tracking.text(), QString("-12")); + shown.type(tracking, "-150"); + QCOMPARE(shown.session.currentTextStyle().tracking, -15.0); + shown.type(tracking, "abc"); + QCOMPARE(shown.session.currentTextStyle().tracking, -15.0); + shown.restyle([](LayerTextStyle &style) { style.tracking = 995; }); + QTest::keyClick(&tracking, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().tracking, 995.0); + QCOMPARE(tracking.text(), QString("995")); +} + +void TypeControlsTests::sizeAndTrackingFollowTheLocale() +{ + // Swift's `.number` fields read and write the user's locale. + Bar shown; + shown.bar.setLocale(QLocale(QLocale::German, QLocale::Germany)); + shown.restyle([](LayerTextStyle &style) { + style.fontSize = 1212.5; + style.tracking = -0.25; + }); + TextStyleField &size = shown.field("typeSize"), &tracking = shown.field("typeTracking"), &leading = shown.field("typeLeading"); + size.sync(); + tracking.sync(); + QCOMPARE(size.text(), QString("1212,5")); + QCOMPARE(tracking.text(), QString("-0,25")); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 12; }); + size.sync(); + QCOMPARE(size.text(), QString("12")); + shown.type(size, "14,25"); + QCOMPARE(shown.session.currentTextStyle().fontSize, 14.25); + shown.type(tracking, "1,5"); + QCOMPARE(shown.session.currentTextStyle().tracking, 1.5); + // Leading reads as Swift's Double(text), whatever the locale. + shown.type(leading, "20,5"); + QCOMPARE(shown.session.currentTextStyle().leading, 0.0); + shown.type(leading, "20.5"); + QCOMPARE(shown.session.currentTextStyle().leading, 20.5); +} + +void TypeControlsTests::leadingIsAutoWhenEmptyAndClamps() +{ + Bar shown; + TextStyleField &leading = shown.field("typeLeading"); + shown.type(leading, "90"); + QCOMPARE(shown.session.currentTextStyle().leading, 90.0); + // Past the bounds it clamps; no number is Auto. + shown.type(leading, "9000"); + QCOMPARE(shown.session.currentTextStyle().leading, 5000.0); + // One edit makes "-7": below zero clamps to Auto. + shown.type(leading, "7"); + QTest::keyClick(&leading, Qt::Key_Home); + QTest::keyClicks(&leading, "-"); + QCOMPARE(shown.session.currentTextStyle().leading, 0.0); + shown.type(leading, " 64 "); + QCOMPARE(shown.session.currentTextStyle().leading, 64.0); + shown.type(leading, "x"); + QCOMPARE(shown.session.currentTextStyle().leading, 0.0); + // Steps count from the height Auto works out to. + QTest::keyClick(&leading, Qt::Key_Up); + QCOMPARE(shown.session.currentTextStyle().leading, 72 * 1.2 + 1); + QCOMPARE(leading.text(), QString("87")); + QTest::keyClick(&leading, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().leading, 72 * 1.2 - 9); + QCOMPARE(leading.text(), QString("77")); + shown.restyle([](LayerTextStyle &style) { style.leading = 5; }); + QTest::keyClick(&leading, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.session.currentTextStyle().leading, 0.0); + QCOMPARE(leading.text(), QString()); + // Unfocused, it shows the leading rounded. + leading.clearFocus(); + shown.restyle([](LayerTextStyle &style) { style.leading = 90.5; }); + QCOMPARE(leading.text(), QString("91")); +} + +void TypeControlsTests::fieldsKeepTypingAndTheFocus() +{ + Bar shown; + TextStyleField &size = shown.field("typeSize"), &tracking = shown.field("typeTracking"); + // Focused, a field keeps its typing through changes. + shown.type(size, "4"); + QTest::keyClicks(&size, "."); + shown.restyle([](LayerTextStyle &style) { style.tracking = 7; }); + QCOMPARE(size.text(), QString("4.")); + QCOMPARE(tracking.text(), QString("7")); + // Leaving shows the session's number. + tracking.setFocus(Qt::OtherFocusReason); + QCOMPARE(size.text(), QString("4")); + // A menu or popup borrows the focus: the typing stays. + for (const Qt::FocusReason reason : {Qt::MenuBarFocusReason, Qt::PopupFocusReason}) { + shown.type(size, "3."); + tracking.setFocus(reason); + QVERIFY(!size.hasFocus()); + shown.restyle([](LayerTextStyle &style) { style.fontSize = 40; }); + QCOMPARE(size.text(), QString("3.")); + size.setFocus(); + QTRY_VERIFY(size.hasFocus()); + tracking.setFocus(Qt::OtherFocusReason); + QCOMPARE(size.text(), QString("40")); + } + // Return shows the number, selected; Swift's bar keeps the focus. + for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter}) { + shown.type(size, "2."); + const int request = shown.session.canvasFocusRequest(); + QTest::keyClick(&size, key); + QVERIFY(size.hasFocus() && size.selectedText() == QString("2")); + QCOMPARE(shown.session.canvasFocusRequest(), request); + } + // Escape is the field's own: the typing stays. + shown.type(size, "3."); + QTest::keyClick(&size, Qt::Key_Escape); + QVERIFY(size.hasFocus() && size.text() == QString("3.")); + // Other keys stay the field's own. + shown.type(size, "15"); + QTest::keyClick(&size, Qt::Key_Backspace); + QCOMPARE(size.text(), QString("1")); + QCOMPARE(shown.session.currentTextStyle().fontSize, 1.0); +} + +void TypeControlsTests::alignmentAndFontChangeTheDraft() +{ + Bar shown; + auto &font = find<QComboBox>(shown.bar, "typeFont"); + QToolButton &left = shown.align("typeAlignLeft"), ¢er = shown.align("typeAlignCenter"); + // Without a draft the defaults change. + center.click(); + QCOMPARE(shown.session.currentTextStyle().alignment, TextAlignment::center); + QVERIFY(!shown.session.textDraft()); + QVERIFY(center.isChecked() && !left.isChecked()); + shown.session.beginText(QPointF(10, 10), true); + left.click(); + QCOMPARE(shown.session.textDraft().value().style.alignment, TextAlignment::left); + QVERIFY(left.isChecked() && !center.isChecked()); + // A face writes its name; Helvetica then leaves the list. + const int bold = font.findText(QStringLiteral("DejaVuSans-Bold")); + QVERIFY(bold >= 0); + emit font.activated(bold); + QCOMPARE(shown.session.textDraft().value().style.fontName, QString("DejaVuSans-Bold")); + QCOMPARE(font.currentText(), QString("DejaVuSans-Bold")); + QCOMPARE(font.findText(QStringLiteral("Helvetica")), -1); + QCOMPARE(font.count(), TextLayout::availableFonts().size()); + // A missing face named elsewhere joins the list. + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Zapfino"); }); + QCOMPARE(font.currentText(), QString("Zapfino")); + QCOMPARE(font.count(), TextLayout::availableFonts().size() + 1); + QCOMPARE(font.itemText(font.count() - 1), QString("Zapfino")); + shown.restyle([](LayerTextStyle &style) { style.fontName = QStringLiteral("Aachen"); }); + QVERIFY(font.currentText() == QString("Aachen") && font.itemText(0) == QString("Aachen") && font.findText("Zapfino") < 0); + // Editing an existing text layer opens its draft. + shown.session.cancelText(); + const QUuid text = addText(shown.session, QStringLiteral("Aligned")); + QVERIFY(!shown.session.textDraft()); + center.click(); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text)); + QCOMPARE(shown.session.textDraft().value().style.alignment, TextAlignment::center); +} + +void TypeControlsTests::theButtonsFollowTheDraft() +{ + Bar shown; + QPushButton &cancel = shown.button("typeCancel"), &done = shown.button("typeDone"), &edit = shown.button("typeEdit"); + QVERIFY(!cancel.isVisible() && !done.isVisible() && edit.isVisible()); + QCOMPARE(edit.text(), QString("Edit Text")); + QVERIFY(!edit.isEnabled()); + // The fields take the room; the buttons keep the end. + QLabel &unit = *shown.bar.findChild<QWidget *>("typeFields")->findChild<QLabel *>(); + const int natural = unit.sizeHint().width(); + shown.bar.resize(1400, shown.bar.height()); + QTRY_VERIFY(edit.geometry().right() > 1400 - 40); + QCOMPARE(unit.width(), natural); + // Done on blank new text just closes it. + shown.session.beginText(QPointF(20, 30), true); + done.click(); + QVERIFY(!shown.session.textDraft() && !cancel.isVisible() && edit.isVisible()); + QCOMPARE(shown.session.document().value().layers.size(), size_t(1)); + shown.session.beginText(QPointF(20, 30), true); + QVERIFY(cancel.isVisible() && done.isVisible() && !edit.isVisible()); + QVERIFY(cancel.text() == QString("Cancel") && done.text() == QString("Done")); + // Done applies the draft as a new layer. + shown.restyle([](LayerTextStyle &style) { style.content = QStringLiteral("Done"); }); + done.click(); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.history.undoName(), QString("New Text Layer")); + const QUuid text = shown.session.activeLayerID().value(); + QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Done")); + QVERIFY(edit.isVisible() && edit.isEnabled()); + // Edit Text opens the active layer from any tool. + shown.session.selectTool(NavigationTool::move); + edit.click(); + QCOMPARE(shown.session.tool(), NavigationTool::type); + QCOMPARE(shown.session.textDraft().value().layerID, std::optional(text)); + // Cancel drops the draft; the layer stays as it was. + shown.restyle([](LayerTextStyle &style) { style.content = QStringLiteral("Changed"); }); + cancel.click(); + QVERIFY(!shown.session.textDraft()); + QCOMPARE(shown.session.activeLayer().value().liveText().value().style.content, QString("Done")); + QCOMPARE(shown.session.history.undoName(), QString("New Text Layer")); + // Pixels drop the text: Edit Text rests. + shown.session.selectTool(NavigationTool::brush); + shown.session.beginBrush(QPointF(40, 60)); + shown.session.finishBrush(); + QVERIFY(!shown.session.activeLayer().value().liveText()); + QVERIFY(edit.isVisible() && !edit.isEnabled()); +} + +void TypeControlsTests::theBarRestsWithoutADocumentOrWhileBusy() +{ + EditorSession empty; + const TypeControls idle(empty); + QVERIFY(!idle.isEnabled()); + empty.createDocument(40, 20, true); + QVERIFY(idle.isEnabled()); + Bar shown; + // Busy dims the bar only once the busy indicator shows. + shown.session.setIsProjectBusy(true); + QVERIFY(shown.bar.isEnabled()); + QTRY_VERIFY(!shown.bar.isEnabled()); + shown.session.setIsProjectBusy(false); + QVERIFY(shown.bar.isEnabled()); +} + +void TypeControlsTests::aTextRowShowsAndOpensItsText() +{ + EditorSession session; + session.createDocument(400, 300, true); + const QUuid blank = session.activeLayerID().value(); + const QUuid text = addText(session, QStringLiteral("Row")); + NativeLayerList list(session); + list.resize(252, 400); + list.show(); + QVERIFY(QTest::qWaitForWindowActive(&list)); + LayerCell &textRow = *list.cells().at(0), &blankRow = *list.cells().at(1); + const auto dimensions = [](LayerCell &row) { return row.findChild<QLabel *>("layerDimensions")->text(); }; + QCOMPARE(dimensions(textRow), QString("Text · Double-click to edit")); + QCOMPARE(dimensions(blankRow), QString("400 × 300 px")); + QCOMPARE(textRow.thumbnail().toolTip(), QString("Editable text layer")); + QCOMPARE(textRow.thumbnail().accessibleName(), QString("Select text: Row")); + QCOMPARE(blankRow.thumbnail().toolTip(), QString("Select image pixels")); + QCOMPARE(blankRow.thumbnail().accessibleName(), QString("Select image: Layer 1")); + // A square text icon, where pixels show the canvas. + const QColor ink = textRow.palette().color(QPalette::WindowText); + QCOMPARE(textRow.thumbnail().size(), QSize(36, 36)); + QCOMPARE(textRow.thumbnail().icon().availableSizes(), QList<QSize>{QSize(36, 36)}); + const QImage expected = LayerIcons::pixmap(LayerIcon::text, 36, ink, 1).toImage(); + QCOMPARE(textRow.thumbnail().icon().pixmap(QSize(36, 36), 1).toImage().convertToFormat(expected.format()), expected); + // The rail's Type glyph, 1.2 times its size, centred. + QImage glyph(36, 36, expected.format()); + glyph.fill(Qt::transparent); + QPainter painter(&glyph); + ToolIcons::paint(painter, NavigationTool::type, QPointF(7.2, 7.2), 18 * 1.2, ink); + painter.end(); + QCOMPARE(expected, glyph); + QCOMPARE(blankRow.thumbnail().size(), QSize(36, 27)); + // A double click on the thumbnail opens the text. + session.selectLayer(blank); + QTest::mouseDClick(&textRow.thumbnail(), Qt::LeftButton); + QCOMPARE(session.activeLayerID(), std::optional(text)); + QCOMPARE(session.textDraft().value().layerID, std::optional(text)); + QVERIFY(!session.renamingLayerID()); + // With a draft open, rows refuse the gesture. + QTest::mouseDClick(&blankRow.thumbnail(), Qt::LeftButton); + QCOMPARE(session.activeLayerID(), std::optional(text)); + session.cancelText(); + // On the name a text layer renames, as every layer. + QTest::mouseDClick(&textRow, Qt::LeftButton, Qt::NoModifier, QPoint(textRow.width() - 20, 20)); + QCOMPARE(session.renamingLayerID(), std::optional(text)); + QVERIFY(!session.textDraft()); + session.setRenamingLayerID(std::nullopt); + // Text that loads as pixels shows them, same image. + const ImageIdentity before = layerWith(session, text).asset.value().identity(); + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, text).text.value().fontSize = 5000; }); + QCOMPARE(layerWith(session, text).asset.value().identity(), before); + QCOMPARE(list.cells().at(0), &textRow); + QVERIFY(dimensions(textRow).endsWith(" px")); + QCOMPARE(textRow.thumbnail().toolTip(), QString("Select image pixels")); + QCOMPARE(textRow.thumbnail().size(), QSize(36, 27)); + QCOMPARE(textRow.thumbnail().icon().availableSizes(), QList<QSize>{QSize(72, 54)}); + QTest::mouseDClick(&textRow.thumbnail(), Qt::LeftButton); + QVERIFY(!session.textDraft()); + QCOMPARE(session.renamingLayerID(), std::optional(text)); + QTest::keyClick(textRow.findChild<QLineEdit *>("layerNameEditor"), Qt::Key_Escape); + QVERIFY(!session.renamingLayerID()); +} + +QTEST_MAIN(TypeControlsTests) +#include "TypeControlsTests.moc" diff --git a/tests/TypeSessionTests.cpp b/tests/TypeSessionTests.cpp new file mode 100644 index 0000000..e3b33c0 --- /dev/null +++ b/tests/TypeSessionTests.cpp @@ -0,0 +1,498 @@ +#include "BrushFixtures.h" +#include "SelectionFixtures.h" +#include "SessionRecord.h" +#include "Document/EditorSession.h" +#include <QSignalSpy> + +// The Type tool in the session: gates, hooks, colours, names. +namespace { +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600, true); + session->selectTool(NavigationTool::type); + return session; +} + +// New text saying `content` at `at`, applied. +QUuid textLayer(EditorSession &session, QPointF at, const QString &content) +{ + session.selectTool(NavigationTool::type); + session.beginText(at, true); + TextDraft draft = session.textDraft().value(); + draft.style.content = content; + if (!session.applyText(draft)) + throw std::runtime_error("text was refused"); + return session.activeLayerID().value(); +} + +// Swift's `await`: true once the callback ran. +bool landed(const std::function<void(std::function<void()>)> &start) +{ + bool done = false; + start([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 20000); +} +} + +class TypeSessionTests : public QObject { + Q_OBJECT +private slots: + void aDraftHoldsTheGatesAndFileRequests(); + void switchingFinishesTheDraft(); + void theSwatchColoursOpenTextOnly(); + void fillRecoloursLiveText(); + void styleChangesEditTextOrSetDefaults(); + void namesAreFirstWordsOnOneLine(); + void aClickFindsTheTextUnderIt(); + void applyingKeepsTheCornerAndPinsTheMask(); + void aDraggedBoxIsRoundedAndBounded(); + void aStyleIsValidWithinSwiftsBounds(); + void everyChangeIsAnnouncedAndRefusalsAreSilent(); + void applyingTakesWhatSwiftTakes(); + void recolouringNeedsANewColour(); +}; + +void TypeSessionTests::aDraftHoldsTheGatesAndFileRequests() +{ + const auto session = makeSession(); + QVERIFY(session->canEditLayers() && session->canUseHistory() && session->canStartProjectOperation()); + session->beginText(QPointF(10, 10)); + QVERIFY(!session->canEditLayers() && !session->canUseHistory() && !session->canStartProjectOperation()); + bool ran = false; + session->waitForFileRequest([&] { ran = true; }); + QCoreApplication::processEvents(); + QVERIFY(!ran); + const int focus = session->canvasFocusRequest(); + session->cancelText(); + QCOMPARE(session->canvasFocusRequest(), focus + 1); + QTRY_VERIFY(ran); + QVERIFY(session->canEditLayers()); + // Landing the draft frees a waiting request too. + session->beginText(QPointF(10, 10)); + ran = false; + session->waitForFileRequest([&] { ran = true; }); + QCoreApplication::processEvents(); + QVERIFY(!ran); + session->changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Landed"); }); + // The draft's own change looked, found the draft, and waits. + QCoreApplication::processEvents(); + QVERIFY(!ran); + QVERIFY(session->finishText()); + QTRY_VERIFY(ran); +} + +void TypeSessionTests::switchingFinishesTheDraft() +{ + const auto session = makeSession(); + const QUuid blank = session->activeLayerID().value(); + session->beginText(QPointF(10, 10)); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Tool"); + session->setTextDraft(draft); + // Busy: the draft cannot land, so the tool stays. + session->setIsProjectBusy(true); + session->selectTool(NavigationTool::brush); + QVERIFY(session->tool() == NavigationTool::type && session->textDraft()); + session->setIsProjectBusy(false); + // What is chosen already leaves the draft open. + session->selectTool(NavigationTool::type); + session->selectLayer(blank); + session->selectLayers({blank}, blank); + QCOMPARE(session->textDraft().value().style.content, QString("Tool")); + session->selectTool(NavigationTool::brush); + QVERIFY(session->tool() == NavigationTool::brush && !session->textDraft()); + QCOMPARE(session->activeLayer().value().name, QString("Tool")); + const QUuid layer = textLayer(*session, QPointF(10, 200), QStringLiteral("Layer")); + session->beginText(QPointF(300, 300), true); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Selected"); + session->setTextDraft(draft); + session->selectLayer(blank); + QVERIFY(!session->textDraft() && session->activeLayerID() == blank); + QCOMPARE(session->document().value().layers.back().name, QString("Selected")); + session->beginText(QPointF(300, 400), true); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Several"); + session->setTextDraft(draft); + session->selectLayers({blank, layer}, blank); + QVERIFY(!session->textDraft()); + // Above the active layer, as every new layer goes. + QCOMPARE(session->document().value().layers[1].name, QString("Several")); +} + +void TypeSessionTests::theSwatchColoursOpenTextOnly() +{ + const auto session = makeSession(); + const QUuid id = textLayer(*session, QPointF(10, 10), QStringLiteral("Hello")); + // Merely selected, the text keeps its colour. + session->setPaletteColor(PaletteColor{1, 0, 0}, false); + QCOMPARE(session->foregroundColor(), (PaletteColor{1, 0, 0})); + QCOMPARE(layerWith(*session, id).liveText().value().style.red, 0.0); + session->editActiveText(); + session->setPaletteColor(PaletteColor{0.25, 0.5, 1}, false); + const LayerTextStyle style = session->textDraft().value().style; + QVERIFY(style.red == 0.25 && style.green == 0.5 && style.blue == 1); + // The background swatch leaves the open text alone. + session->setPaletteColor(PaletteColor{1, 1, 0}, true); + QCOMPARE(session->textDraft().value().style.blue, 1.0); + // Swapping and resetting move the foreground, and the text. + session->swapPaletteColors(); + QVERIFY(session->textDraft().value().style.red == 1 && session->textDraft().value().style.blue == 0); + session->resetPaletteColors(); + QVERIFY(session->textDraft().value().style.red == 0 && session->textDraft().value().style.green == 0); +} + +void TypeSessionTests::fillRecoloursLiveText() +{ + const auto session = makeSession(); + const QUuid id = textLayer(*session, QPointF(10, 10), QStringLiteral("Fill")); + session->setPaletteColor(PaletteColor{1, 0, 0}, false); + const int steps = session->history.undoCount(); + QVERIFY(landed([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, done); })); + QCOMPARE(session->history.undoName(), QString("Fill Text")); + QCOMPARE(layerWith(*session, id).liveText().value().style.red, 1.0); + QVERIFY(landed([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, done); })); + QCOMPARE(session->history.undoCount(), steps + 1); + QVERIFY(!session->recolorText(session->document().value().layers.front().id, PaletteColor{0, 1, 0})); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("text could not be repainted: This is not a valid")); + QVERIFY(!session->recolorText(id, PaletteColor{2, 0, 0})); + // A text layer's mask fills as any mask does. + session->addLayerMask(true); + QVERIFY(landed([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, done); })); + QCOMPARE(session->history.undoName(), QString("Fill Mask")); + QVERIFY(layerWith(*session, id).liveText()); + session->selectLayerTarget(id, false); + // With a selection the pixels fill: no longer text. + session->applySelection(rectPath(QRectF(10, 10, 30, 30)), SelectionMode::replace, "Select"); + QVERIFY(landed([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, done); })); + QCOMPARE(session->history.undoName(), QString("Fill")); + QVERIFY(!layerWith(*session, id).text); +} + +void TypeSessionTests::styleChangesEditTextOrSetDefaults() +{ + const auto session = makeSession(); + session->changeTextStyle([](LayerTextStyle &style) { style.fontSize = 30; }); + QCOMPARE(session->textDefaults().fontSize, 30.0); + QCOMPARE(session->currentTextStyle().fontSize, 30.0); + session->changeTextStyle([](LayerTextStyle &style) { style.fontSize = 0; }); + QCOMPARE(session->textDefaults().fontSize, 30.0); + const QUuid id = textLayer(*session, QPointF(10, 10), QStringLiteral("Styled")); + QCOMPARE(session->currentTextStyle().content, QString("Styled")); + // A selected text layer opens for editing and changes. + session->changeTextStyle([](LayerTextStyle &style) { style.alignment = TextAlignment::center; }); + QCOMPARE(session->textDraft().value().layerID, std::optional(id)); + QCOMPARE(session->currentTextStyle().alignment, TextAlignment::center); + session->changeTextStyle([](LayerTextStyle &style) { style.tracking = 5000; }); + QCOMPARE(session->textDraft().value().style.tracking, 0.0); + QCOMPARE(session->textDefaults().content, QString("Styled")); +} + +void TypeSessionTests::namesAreFirstWordsOnOneLine() +{ + QCOMPARE(EditorSession::layerName(QStringLiteral(" Hello \n\t World ")), QString("Hello World")); + QCOMPARE(EditorSession::layerName(QString()), QString("Text")); + QCOMPARE(EditorSession::layerName(QStringLiteral(" \n ")), QString("Text")); + QCOMPARE(EditorSession::layerName(QString(45, QLatin1Char('a'))), QString(40, QLatin1Char('a'))); + // Forty characters as a reader counts them, accents joined. + const QString accented = QString(39, QLatin1Char('a')) + QStringLiteral("ébb"); + QCOMPARE(EditorSession::layerName(accented), QString(39, QLatin1Char('a')) + QStringLiteral("é")); +} + +void TypeSessionTests::aClickFindsTheTextUnderIt() +{ + const auto session = makeSession(); + const QUuid lower = textLayer(*session, QPointF(10, 10), QStringLiteral("Lower")); + const QUuid upper = textLayer(*session, QPointF(20, 20), QStringLiteral("Upper")); + session->selectLayer(lower); + session->beginText(QPointF(40, 40)); + QCOMPARE(session->textDraft().value().layerID, std::optional(upper)); + QCOMPARE(session->activeLayerID(), std::optional(upper)); + QCOMPARE(session->textDraft().value().origin, QPointF(20, 20)); + QCOMPARE(session->textDraft().value().transform, std::optional(layerWith(*session, upper).transform)); + QCOMPARE(session->textDraft().value().style.content, QString("Upper")); + session->cancelText(); + session->toggleLayerVisibility(upper); + session->beginText(QPointF(40, 40)); + QCOMPARE(session->textDraft().value().layerID, std::optional(lower)); + session->cancelText(); + session->beginText(QPointF(40, 40), true); + QVERIFY(!session->textDraft().value().layerID); + session->cancelText(); + // New text: empty, in the foreground colour, no box. + session->beginText(QRectF(600, 400, 100, 50)); + TextDraft boxed = session->textDraft().value(); + boxed.style.content = QStringLiteral("Boxed"); + QVERIFY(session->applyText(boxed)); + QVERIFY(session->textDefaults().boxSize); + session->setPaletteColor(PaletteColor{0.25, 1, 0.5}, false); + session->selectTool(NavigationTool::move); + session->beginText(QPointF(700, 300)); + QCOMPARE(session->tool(), NavigationTool::type); + const TextDraft fresh = session->textDraft().value(); + QVERIFY(!fresh.layerID && fresh.style.content.isEmpty() && !fresh.style.boxSize); + QVERIFY(fresh.style.red == 0.25 && fresh.style.green == 1 && fresh.style.blue == 0.5); + QCOMPARE(fresh.origin, QPointF(700, 300)); + session->cancelText(); + session->beginText(QPointF(NAN, 5)); + QVERIFY(!session->textDraft()); + session->beginText(QPointF(5, INFINITY)); + QVERIFY(!session->textDraft()); + // On a mask, new text keeps the defaults' colour. + session->selectLayer(lower); + session->addLayerMask(true); + session->beginText(QPointF(700, 300)); + QCOMPARE(session->textDraft().value().style.green, 0.0); + QCOMPARE(session->tool(), NavigationTool::type); +} + +void TypeSessionTests::applyingKeepsTheCornerAndPinsTheMask() +{ + const auto session = makeSession(); + const QUuid id = textLayer(*session, QPointF(50, 60), QStringLiteral("Hi")); + const QSize first = layerWith(*session, id).asset.value().size(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.size *= 2; }); + session->addLayerMask(true); + session->editActiveText(); + TextDraft draft = session->textDraft().value(); + // The draft starts where the layer is, as it is. + QCOMPARE(draft.origin, QPointF(50, 60)); + QCOMPARE(draft.transform, std::optional(layerWith(*session, id).transform)); + const int focus = session->canvasFocusRequest(); + // Unchanged: nothing recorded, the draft simply closes. + const int steps = session->history.undoCount(); + QVERIFY(session->applyText(draft)); + QVERIFY(session->history.undoCount() == steps && session->canvasFocusRequest() == focus && !session->textDraft()); + session->editActiveText(); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Hi there"); + QVERIFY(session->applyText(draft)); + QCOMPARE(session->history.undoName(), QString("Edit Text")); + QCOMPARE(session->canvasFocusRequest(), focus + 1); + const ImageLayer edited = layerWith(*session, id); + // Point text grows at the layer's scale, corner fixed. + QCOMPARE(edited.transform.size, QSizeF(edited.asset.value().size()) * 2); + QCOMPARE(edited.transform.point(QPointF(0, 0)), QPointF(50, 60)); + QVERIFY(edited.asset.value().size().width() > first.width()); + QCOMPARE(edited.mask.value().placement, std::optional(LayerTransform{.origin = {50, 60}, .size = QSizeF(first) * 2})); + QCOMPARE(session->textDefaults().content, QString("Hi there")); + // A box keeps the transform it was given. + session->editActiveText(); + draft = session->textDraft().value(); + draft.style.boxSize = QSizeF(300, 200); + draft.transform = LayerTransform{.origin = {40, 40}, .size = {600, 400}}; + QVERIFY(session->applyText(draft)); + QCOMPARE(layerWith(*session, id).transform, (LayerTransform{.origin = {40, 40}, .size = {600, 400}})); + QCOMPARE(layerWith(*session, id).asset.value().size(), QSize(300, 200)); +} + +void TypeSessionTests::aDraggedBoxIsRoundedAndBounded() +{ + const auto session = makeSession(); + session->beginText(QRectF(10.4, 20.6, 100.4, 8.2)); + QCOMPARE(session->textDraft().value().style.boxSize, std::optional(QSizeF(100, 16))); + QCOMPARE(session->textDraft().value().origin, QPointF(10.4, 20.6)); + // An open draft keeps its own box. + session->beginText(QRectF(0, 0, 400, 300)); + QCOMPARE(session->textDraft().value().style.boxSize, std::optional(QSizeF(100, 16))); + session->cancelText(); + session->beginText(QRectF(0, 0, 30'001, 50)); + QVERIFY(!session->textDraft()); + QCOMPARE(session->brushError().value(), QString("That text box exceeds the 30,000-pixel or 100-megapixel limit.")); + // No number at all is refused without a word. + session->setBrushError(std::nullopt); + session->beginText(QRectF(0, 0, INFINITY, 50)); + session->beginText(QRectF(0, 0, 50, NAN)); + session->beginText(QRectF(0, 0, NAN, 50)); + QVERIFY(!session->textDraft() && !session->brushError()); +} + +void TypeSessionTests::aStyleIsValidWithinSwiftsBounds() +{ + const auto valid = [](const std::function<void(LayerTextStyle &)> &change) { + LayerTextStyle style; + change(style); + return style.isValid(); + }; + QVERIFY(LayerTextStyle().isValid() && LayerTextStyle().boxIsValid()); + // Content counts UTF-16 units, as Swift's `utf16.count`. + QVERIFY(valid([](LayerTextStyle &style) { style.content = QString(100'000, QLatin1Char('a')); })); + QVERIFY(!valid([](LayerTextStyle &style) { style.content = QString(100'001, QLatin1Char('a')); })); + QVERIFY(!valid([](LayerTextStyle &style) { style.content = QStringLiteral("😀").repeated(50'001); })); + // Every number holds its bounds, and no further. + const std::vector<std::tuple<double LayerTextStyle::*, double, double>> numbers = { + {&LayerTextStyle::fontSize, 1, 2000}, {&LayerTextStyle::red, 0, 1}, {&LayerTextStyle::green, 0, 1}, + {&LayerTextStyle::blue, 0, 1}, {&LayerTextStyle::tracking, -100, 1000}, {&LayerTextStyle::leading, 0, 5000}}; + for (const auto &[member, low, high] : numbers) { + for (const double value : {low, high}) + QVERIFY(valid([&](LayerTextStyle &style) { style.*member = value; })); + for (const double value : {low - 0.01, high + 0.01, double(NAN), double(INFINITY), -double(INFINITY)}) + QVERIFY(!valid([&](LayerTextStyle &style) { style.*member = value; })); + } + // A box: 16 to 30,000 a side, 100 megapixels. + const auto box = [&](double width, double height) { return valid([&](LayerTextStyle &style) { style.boxSize = QSizeF(width, height); }); }; + QVERIFY(box(16, 16) && box(30'000, 3'000) && box(10'000, 10'000) && box(3'000, 30'000)); + QVERIFY(!box(15.99, 100) && !box(100, 15.99) && !box(30'000.5, 100) && !box(100, 30'000.5)); + QVERIFY(!box(10'000, 10'000.01) && !box(NAN, 100) && !box(100, INFINITY)); +} + +void TypeSessionTests::everyChangeIsAnnouncedAndRefusalsAreSilent() +{ + const auto session = makeSession(); + QStringList heard; + connect(session.get(), &EditorSession::changed, session.get(), [&] { heard = described(*session); }); + QSignalSpy spy(session.get(), &EditorSession::changed); + // A change's last signal sees what the call leaves. + const auto announced = [&](const std::function<void()> &call) { + spy.clear(); + call(); + return !spy.isEmpty() && heard == described(*session); + }; + const auto silent = [&](const std::function<void()> &call) { + const QStringList before = described(*session); + spy.clear(); + call(); + return spy.isEmpty() && described(*session) == before; + }; + const auto restyle = [&](double size) { session->changeTextStyle([size](LayerTextStyle &style) { style.fontSize = size; }); }; + QVERIFY(announced([&] { restyle(40); })); + QVERIFY(silent([&] { restyle(0); })); + // Blank new text closes the draft and says so. + QVERIFY(announced([&] { session->beginText(QPointF(10, 10)); })); + QVERIFY(announced([&] { session->finishText(); })); + QVERIFY(announced([&] { session->beginText(QPointF(10, 10)); })); + QVERIFY(silent([&] { session->beginText(QPointF(20, 20)); })); + QVERIFY(silent([&] { session->beginText(QRectF(0, 0, 50, 50)); })); + QVERIFY(announced([&] { session->changeTextStyle([](LayerTextStyle &style) { style.content = QStringLiteral("Heard"); }); })); + QVERIFY(silent([&] { restyle(3000); })); + // Busy, the draft stays open and nothing is said. + session->setIsProjectBusy(true); + QVERIFY(silent([&] { session->finishText(); })); + session->setIsProjectBusy(false); + QVERIFY(announced([&] { session->finishText(); })); + const QUuid id = session->activeLayerID().value(); + QVERIFY(silent([&] { session->finishText(); })); + QVERIFY(announced([&] { session->editActiveText(); })); + QVERIFY(silent([&] { session->editActiveText(); })); + TextDraft stale = session->textDraft().value(); + stale.documentID = QUuid::createUuid(); + QVERIFY(silent([&] { session->applyText(stale); })); + // An invalid style is refused before anything is tried. + TextDraft invalid = session->textDraft().value(); + invalid.style.fontSize = 0; + QVERIFY(silent([&] { session->applyText(invalid); })); + QVERIFY(announced([&] { session->finishText(); })); + QVERIFY(announced([&] { restyle(30); })); + QVERIFY(announced([&] { session->finishText(); })); + // An error comes after the draft is back. + session->editActiveText(); + QVERIFY(announced([&] { session->changeTextStyle([](LayerTextStyle &style) { style.content = QString(80, QLatin1Char('W')); style.fontSize = 2000; }); })); + QVERIFY(announced([&] { session->finishText(); })); + QVERIFY(session->textDraft() && session->brushError()); + QVERIFY(announced([&] { session->cancelText(); })); + QVERIFY(announced([&] { session->recolorText(id, PaletteColor{0, 0, 1}); })); + QVERIFY(silent([&] { session->recolorText(id, PaletteColor{0, 0, 1}); })); + QVERIFY(announced([&] { session->beginText(QRectF(0, 0, 40'000, 50)); })); +} + +void TypeSessionTests::applyingTakesWhatSwiftTakes() +{ + const auto session = makeSession(); + const QUuid blank = session->activeLayerID().value(); + const size_t count = session->document().value().layers.size(); + // Blank new text goes; emptied old text stays text. + session->beginText(QPointF(10, 10)); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral(" \n\t "); + const int focus = session->canvasFocusRequest(); + QVERIFY(session->applyText(draft)); + QVERIFY(!session->textDraft() && session->canvasFocusRequest() == focus); + QCOMPARE(session->document().value().layers.size(), count); + const QUuid id = textLayer(*session, QPointF(100, 100), QStringLiteral("Kept")); + session->editActiveText(); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral(" "); + QVERIFY(session->applyText(draft)); + QCOMPARE(layerWith(*session, id).liveText().value().style.content, QString(" ")); + // A pixel layer never takes a text draft. + session->editActiveText(); + draft = session->textDraft().value(); + draft.layerID = blank; + QVERIFY(!session->applyText(draft)); + QCOMPARE(session->textDraft().value().layerID, std::optional(id)); + session->cancelText(); + session->selectLayer(blank); + session->editActiveText(); + QVERIFY(!session->textDraft()); + // Moved, same words: the corner follows the move. + session->selectLayer(id); + session->editActiveText(); + draft = session->textDraft().value(); + const QSizeF size = draft.transform.value().size; + draft.transform.value().origin = QPointF(300, 200); + QVERIFY(session->applyText(draft)); + QCOMPARE(layerWith(*session, id).transform, (LayerTransform{.origin = {300, 200}, .size = size})); + session->editActiveText(); + draft = session->textDraft().value(); + draft.transform.value().origin = QPointF(10, 20); + draft.style.content = QStringLiteral("Kept and grown"); + QVERIFY(session->applyText(draft)); + const ImageLayer grown = layerWith(*session, id); + QCOMPARE(grown.transform, (LayerTransform{.origin = {10, 20}, .size = QSizeF(grown.asset.value().size())})); + // A box without a transform scales as the layer did. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.size *= 2; }); + session->editActiveText(); + draft = session->textDraft().value(); + draft.transform = std::nullopt; + draft.style.boxSize = QSizeF(200, 100); + QVERIFY(session->applyText(draft)); + QCOMPARE(layerWith(*session, id).transform, (LayerTransform{.origin = {10, 20}, .size = {400, 200}})); + // New text leaves the selection where it is. + session->applySelection(rectPath(QRectF(5, 5, 20, 20)), SelectionMode::replace, "Select"); + textLayer(*session, QPointF(500, 400), QStringLiteral("Selected")); + QCOMPARE(bounds(*session), QRectF(5, 5, 20, 20)); + // Grown past the layer limits, the edit is refused. + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.size = QSizeF(299'000, 100); }); + session->selectLayer(id); + session->editActiveText(); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Kept and grown further"); + draft.style.boxSize = std::nullopt; + session->setTextDraft(draft); + QVERIFY(!session->finishText()); + QCOMPARE(session->brushError().value(), QString("This project exceeds the supported canvas, layer, file-size, or 100-megapixel image limit.")); + QCOMPARE(session->textDraft().value().style.content, QString("Kept and grown further")); + QCOMPARE(layerWith(*session, id).liveText().value().style.content, QString("Kept and grown")); +} + +void TypeSessionTests::recolouringNeedsANewColour() +{ + const auto session = makeSession(); + const QUuid id = textLayer(*session, QPointF(10, 10), QStringLiteral("Hue")); + const int steps = session->history.undoCount(); + // Each channel alone is a change. + for (const PaletteColor &colour : {PaletteColor{1, 0, 0}, PaletteColor{1, 0.5, 0}, PaletteColor{1, 0.5, 0.25}}) { + QVERIFY(session->recolorText(id, colour)); + const LayerTextStyle style = layerWith(*session, id).liveText().value().style; + QVERIFY(style.red == colour.red && style.green == colour.green && style.blue == colour.blue); + } + QCOMPARE(session->history.undoCount(), steps + 3); + QVERIFY(session->recolorText(id, PaletteColor{1, 0.5, 0.25})); + QCOMPARE(session->history.undoCount(), steps + 3); + // An opacity drag ends before the fill's own step. + session->beginOpacityEdit(); + session->setLayerOpacity(0.5); + QVERIFY(session->recolorText(id, PaletteColor{0, 0, 0})); + QVERIFY(session->canUndo() && session->history.undoName() == QString("Fill Text")); + QCOMPARE(session->history.undoCount(), steps + 5); + // An open draft holds the layers. + session->editActiveText(); + QVERIFY(!session->recolorText(id, PaletteColor{0, 1, 0})); + session->cancelText(); + EditorSession empty; + QVERIFY(!empty.recolorText(id, PaletteColor{0, 1, 0})); +} + +QTEST_MAIN(TypeSessionTests) +#include "TypeSessionTests.moc" diff --git a/tests/TypeStoreTests.cpp b/tests/TypeStoreTests.cpp new file mode 100644 index 0000000..a1cccd0 --- /dev/null +++ b/tests/TypeStoreTests.cpp @@ -0,0 +1,239 @@ +#include "BrushFixtures.h" +#include "SelectionFixtures.h" +#include "Document/EditorSession.h" +#include "IO/CanvasResizer.h" +#include "IO/ImageResizer.h" +#include "IO/ProjectStore.h" +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QTemporaryDir> + +// Text layers kept and dropped: the manifest, rebuilds, resizes. +namespace { +// A blank layer, then text saying "Stored" at (20, 30). +std::unique_ptr<EditorSession> textSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(400, 300, true); + session->selectTool(NavigationTool::type); + session->beginText(QPointF(20, 30), true); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Stored"); + session->applyText(draft); + return session; +} + +QJsonObject textOf(const ProjectSnapshot &snapshot, qsizetype index) +{ + return QJsonDocument::fromJson(snapshot.manifest.encoded()).object().value("layers").toArray().at(index).toObject().value("text").toObject(); +} + +QByteArray withText(const ProjectSnapshot &snapshot, const QJsonValue &text) +{ + QJsonObject object = QJsonDocument::fromJson(snapshot.manifest.encoded()).object(); + QJsonArray layers = object.value("layers").toArray(); + QJsonObject layer = layers.at(1).toObject(); + layer.insert("text", text); + layers.replace(1, layer); + object.insert("layers", layers); + return QJsonDocument(object).toJson(); +} + +bool landed(const std::function<void(std::function<void()>)> &start) +{ + bool done = false; + start([&] { done = true; }); + return QTest::qWaitFor([&] { return done; }, 20000); +} +} + +class TypeStoreTests : public QObject { + Q_OBJECT +private slots: + void theManifestKeepsSwiftsKeys(); + void theManifestRefusesAWrongStyle(); + void anInvalidStyleLoadsAsPixels(); + void staleTextIsNeitherLiveNorSaved(); + void theStoreRefusesTextItCannotShow(); + void rebuildsDropTheText(); + void resizersKeepOrDropTheText(); +}; + +void TypeStoreTests::theManifestKeepsSwiftsKeys() +{ + const auto session = textSession(); + const ProjectSnapshot point = session->projectSnapshot().value(); + QCOMPARE(textOf(point, 1).keys(), + (QStringList{"alignment", "blue", "content", "fontName", "fontSize", "green", "leading", "red", "tracking"})); + QCOMPARE(textOf(point, 1).value("alignment").toString(), QString("Left")); + QCOMPARE(textOf(point, 1).value("fontName").toString(), QString("Helvetica")); + session->changeTextStyle([](LayerTextStyle &style) { + style.alignment = TextAlignment::center; + style.boxSize = QSizeF(300, 150); + style.red = 0.25; + style.green = 0.5; + style.blue = 0.75; + style.tracking = -3; + style.leading = 90; + }); + QVERIFY(session->finishText()); + const ProjectSnapshot boxed = session->projectSnapshot().value(); + QCOMPARE(textOf(boxed, 1).value("boxSize").toArray(), (QJsonArray{300, 150})); + const ProjectManifest decoded = ProjectManifest::decoded(boxed.manifest.encoded()); + QCOMPARE(decoded.layers[1].text, boxed.manifest.layers[1].text); + QCOMPARE(decoded.layers[1].text.value().alignment, TextAlignment::center); + QVERIFY(!decoded.layers[0].text); +} + +void TypeStoreTests::theManifestRefusesAWrongStyle() +{ + const auto session = textSession(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + const QJsonObject good = textOf(snapshot, 1); + QVERIFY(!ProjectManifest::decoded(withText(snapshot, QJsonValue::Null)).layers[1].text); + const auto refused = [&](const QJsonValue &text) { + try { + ProjectManifest::decoded(withText(snapshot, text)); + } catch (const ProjectError &error) { + return error.kind == ProjectError::Kind::invalid; + } + return false; + }; + for (const char *key : {"content", "fontName", "fontSize", "red", "green", "blue", "alignment", "tracking", "leading"}) { + QJsonObject missing = good; + missing.remove(key); + QVERIFY2(refused(missing), key); + } + for (const auto &[key, value] : std::vector<std::pair<QString, QJsonValue>>{ + {"content", 1}, {"fontName", true}, {"fontSize", "72"}, {"red", "0"}, {"green", QJsonArray{}}, {"blue", "x"}, + {"alignment", "Justified"}, {"alignment", 0}, {"tracking", "0"}, {"leading", false}, {"boxSize", QJsonArray{300}}}) { + QJsonObject wrong = good; + wrong.insert(key, value); + QVERIFY2(refused(wrong), qPrintable(key)); + } + QVERIFY(refused(QJsonValue(3))); +} + +void TypeStoreTests::anInvalidStyleLoadsAsPixels() +{ + const auto session = textSession(); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).text.value().fontSize = 5000; }); + QVERIFY(!layerWith(*session, id).text); + QVERIFY(layerWith(*session, id).asset); +} + +void TypeStoreTests::theStoreRefusesTextItCannotShow() +{ + const auto session = textSession(); + const ProjectSnapshot good = session->projectSnapshot().value(); + const QUuid id = good.manifest.layers[1].id; + QTemporaryDir root; + const QString path = root.filePath("Text.comp"); + ProjectStore::save(good, path); + QCOMPARE(ProjectStore::load(path).manifest.layers[1].text, good.manifest.layers[1].text); + const auto refused = [&](const std::function<void()> &body) -> std::optional<ProjectError::Kind> { + try { + body(); + } catch (const ProjectError &error) { + return error.kind; + } + return std::nullopt; + }; + // A style past its bounds refuses the project, both ways. + ProjectSnapshot wild = good; + record(wild, id).text.value().fontSize = 5000; + QCOMPARE(refused([&] { ProjectStore::save(wild, path); }), std::optional(ProjectError::Kind::invalid)); + QFile manifest(path + "/manifest.json"); + QVERIFY(manifest.open(QIODevice::WriteOnly | QIODevice::Truncate)); + manifest.write(wild.manifest.encoded()); + manifest.close(); + QCOMPARE(refused([&] { ProjectStore::load(path); }), std::optional(ProjectError::Kind::invalid)); + // Text needs pixels: a blank layer cannot carry it. + ProjectSnapshot bare = good; + record(bare, id).imageFile = std::nullopt; + bare.images.erase(id); + QCOMPARE(refused([&] { ProjectStore::save(bare, path); }), std::optional(ProjectError::Kind::invalid)); + record(bare, id).text = std::nullopt; + QVERIFY(!refused([&] { ProjectStore::save(bare, path); })); +} + +void TypeStoreTests::staleTextIsNeitherLiveNorSaved() +{ + QImage pixels(20, 10, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::transparent); + ImageLayer layer(ImportedImage(pixels, pixels, QStringLiteral("Pixels")), QPointF()); + LayerTextStyle style; + style.content = QStringLiteral("Stale"); + layer.text = LayerText{style, layer.asset.value().identity()}; + QVERIFY(layer.liveText()); + QCOMPARE(layer.hierarchyRecord().text, std::optional(style)); + // Other pixels, as a bake leaves them: text no more. + layer.text.value().image = ImageIdentity{}; + QVERIFY(!layer.liveText() && layer.text); + QVERIFY(!layer.hierarchyRecord().text); +} + +void TypeStoreTests::rebuildsDropTheText() +{ + const std::vector<std::pair<QString, std::function<bool(EditorSession &)>>> edits = { + {"Brush Stroke", [](EditorSession &session) { + session.selectTool(NavigationTool::brush); + session.beginBrush(QPointF(40, 60)); + session.finishBrush(); + return true; + }}, + {"Fill", [](EditorSession &session) { + session.applySelection(rectPath(QRectF(30, 40, 20, 20)), SelectionMode::replace, "Select"); + return landed([&](std::function<void()> done) { session.fillSelection(EditorSession::FillSource::foreground, done); }); + }}, + {"Invert", [](EditorSession &session) { return landed([&](std::function<void()> done) { session.invertPixels(done); }); }}, + {"Content-Aware Fill", [](EditorSession &session) { + session.applySelection(rectPath(QRectF(30, 40, 20, 20)), SelectionMode::replace, "Select"); + session.beginFilter(FilterKind::contentAwareFill); + return landed([&](std::function<void()> done) { session.commitFilter(done); }); + }}, + {"Transform Selection", [](EditorSession &session) { + session.applySelection(rectPath(QRectF(300, 250, 10, 10)), SelectionMode::replace, "Select"); + if (!landed([&](std::function<void()> done) { session.beginSelectionTransform(done); })) + return false; + LayerTransform draft = session.transformEdit().value().draft; + draft.origin += QPointF(3, 2); + session.previewTransform(draft); + session.commitTransform(); + return true; + }}, + }; + for (const auto &[name, edit] : edits) { + const auto session = textSession(); + const QUuid id = session->activeLayerID().value(); + QVERIFY2(edit(*session), qPrintable(name)); + QCOMPARE(session->history.undoName(), name); + QVERIFY2(!layerWith(*session, id).text, qPrintable(name)); + } + // A mask stroke leaves the text as it was. + const auto session = textSession(); + const QUuid id = session->activeLayerID().value(); + session->addLayerMask(true); + session->selectLayerTarget(id, true); + session->selectTool(NavigationTool::brush); + session->beginBrush(QPointF(40, 60)); + session->finishBrush(); + QVERIFY(layerWith(*session, id).liveText()); +} + +void TypeStoreTests::resizersKeepOrDropTheText() +{ + const auto session = textSession(); + const ProjectSnapshot snapshot = session->projectSnapshot().value(); + const ProjectSnapshot canvas = CanvasResizer::resize(snapshot, {.width = 500, .height = 400}); + QCOMPARE(canvas.manifest.layers[1].text, snapshot.manifest.layers[1].text); + QVERIFY(!ImageResizer::resize(snapshot, {.width = 800, .height = 600, .resolution = 72}).manifest.layers[1].text); + // Swift's rebuild after a resize leaves the text out. + session->applyDocumentSize(canvas, QStringLiteral("Canvas Size")); + QVERIFY(!session->document().value().layers[1].text); +} + +QTEST_MAIN(TypeStoreTests) +#include "TypeStoreTests.moc" diff --git a/tests/TypeToolTests.cpp b/tests/TypeToolTests.cpp new file mode 100644 index 0000000..cb83672 --- /dev/null +++ b/tests/TypeToolTests.cpp @@ -0,0 +1,221 @@ +#include "BrushFixtures.h" +#include "SessionFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include <QTemporaryDir> + +// Swift's TypeToolTests: text layers made, edited, saved, clipped. +namespace { +std::unique_ptr<EditorSession> makeSession() +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(800, 600, true); + session->selectTool(NavigationTool::type); + return session; +} + +// Swift writes `session.textDraft?.style.content` in place. +void type(EditorSession &session, const QString &content) +{ + TextDraft draft = session.textDraft().value(); + draft.style.content = content; + session.setTextDraft(draft); +} + +// Opaque ink and clear pixels of an image, counted. +std::pair<int, int> inkAndClear(const QImage &image, const std::function<bool(const std::vector<int> &)> &inked) +{ + int ink = 0, clear = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const std::vector<int> at = pixel(image, x, y); + if (at[3] == 0) + ++clear; + else if (inked(at)) + ++ink; + } + } + return {ink, clear}; +} +} + +class TypeToolTests : public QObject { + Q_OBJECT +private slots: + void createEditCancelAndUndo(); + void transformsDuplicatesAndClippingKeepTextEditable(); + void saveReopenAndRasterize(); + void rasterHasTransparentBackgroundAndColoredGlyphs(); + void clippingToTextExportsColoredGlyphsOnTransparency(); + void paragraphBoxAndToolSwitchCommitEditableText(); + void emptyNewParagraphIsDiscarded(); + void invalidAndStaleDraftsDoNotChangeDocument(); +}; + +void TypeToolTests::createEditCancelAndUndo() +{ + const auto session = makeSession(); + const int before = session->history.undoCount(); + session->beginText(QPointF(30, 40)); + type(*session, QStringLiteral("Text")); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Hello\nCompositor"); + draft.style.fontSize = 48; + QVERIFY(session->applyText(draft)); + QCOMPARE(session->activeLayer().value().liveText().value().style, draft.style); + QCOMPARE(session->activeLayer().value().origin(), QPointF(30, 40)); + QCOMPARE(session->history.undoCount(), before + 1); + session->editActiveText(); + session->setTextDraft(std::nullopt); + QCOMPARE(session->history.undoCount(), before + 1); + session->editActiveText(); + draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Changed"); + QVERIFY(session->applyText(draft)); + session->undo(); + QCOMPARE(session->activeLayer().value().liveText().value().style.content, QString("Hello\nCompositor")); + session->undo(); + QCOMPARE(session->document().value().layers.size(), size_t(1)); + session->redo(); + QVERIFY(session->activeLayer().value().liveText()); +} + +void TypeToolTests::transformsDuplicatesAndClippingKeepTextEditable() +{ + const auto session = makeSession(); + session->beginText(QPointF(20, 20)); + type(*session, QStringLiteral("Text")); + QVERIFY(session->applyText(session->textDraft().value())); + const QUuid id = session->activeLayerID().value(); + rewrite(*session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform.rotation = 30; + record(snapshot, id).transform.size.rwidth() *= 2; + }); + const LayerTransform old = session->activeLayer().value().transform; + session->editActiveText(); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Longer text"); + QVERIFY(session->applyText(draft)); + const LayerTransform updated = session->activeLayer().value().transform; + QCOMPARE(updated.rotation, 30.0); + QVERIFY(std::abs(updated.point(QPointF(0, 0)).x() - old.point(QPointF(0, 0)).x()) < 0.001); + QVERIFY(std::abs(updated.point(QPointF(0, 0)).y() - old.point(QPointF(0, 0)).y()) < 0.001); + session->duplicateActiveLayer(); + QCOMPARE(session->activeLayer().value().liveText().value().style.content, QString("Longer text")); + const QUuid target = session->activeLayerID().value(); + QVERIFY(session->linkMask(id, target)); + QCOMPARE(session->activeLayer().value().maskSourceID, std::optional(id)); + QVERIFY(layerWith(*session, id).liveText()); +} + +void TypeToolTests::saveReopenAndRasterize() +{ + const auto session = makeSession(); + session->beginText(QPointF(0, 0)); + type(*session, QStringLiteral("Text")); + TextDraft draft = session->textDraft().value(); + draft.style.content = QStringLiteral("Café 日本語\nSecond line"); + draft.style.alignment = TextAlignment::right; + draft.style.tracking = 3; + QVERIFY(session->applyText(draft)); + QTemporaryDir folder; + const QString path = folder.filePath(QStringLiteral("text.comp")); + ProjectStore::save(session->projectSnapshot().value(), path); + const auto reopened = makeSession(); + reopened->installProject(ProjectStore::load(path), path); + QCOMPARE(reopened->activeLayer().value().liveText().value().style, draft.style); + // Other pixels in its place: no longer text. + ImageLayer painted = reopened->activeLayer().value(); + const QImage replacement = EditorSession::shapeImage(ShapeKind::rectangle, QSizeF(10, 10), PaletteColor{1, 0, 0}); + painted.asset = ImportedImage(replacement, replacement, QStringLiteral("Painted")); + QVERIFY(!painted.liveText()); + QVERIFY(!painted.hierarchyRecord().text); +} + +void TypeToolTests::rasterHasTransparentBackgroundAndColoredGlyphs() +{ + LayerTextStyle style; + style.content = QStringLiteral("TYPE"); + style.red = 1; + const QImage image = EditorSession::textImage(style); + bool pure = true; + const auto [ink, clear] = inkAndClear(image, [&](const std::vector<int> &at) { + pure = pure && at[0] > 0 && at[1] == 0 && at[2] == 0; + return true; + }); + QVERIFY(pure); + QVERIFY(ink > 100 && clear > 100); +} + +void TypeToolTests::clippingToTextExportsColoredGlyphsOnTransparency() +{ + const auto session = makeSession(); + session->beginText(QPointF(0, 0)); + type(*session, QStringLiteral("Text")); + QVERIFY(session->applyText(session->textDraft().value())); + const QUuid source = session->activeLayerID().value(); + const QSizeF size = session->activeLayer().value().size(); + const QImage fill = EditorSession::shapeImage(ShapeKind::rectangle, size, PaletteColor{1, 0, 0}); + session->addPixelLayer(fill, QPointF(0, 0), QStringLiteral("Clipped color"), QStringLiteral("Fill")); + QVERIFY(session->linkMask(source, session->activeLayerID().value())); + const QImage exported = ImageExporter::render(session->projectSnapshot().value()).image; + bool red = true; + const auto [ink, clear] = inkAndClear(exported, [&](const std::vector<int> &at) { + if (at[3] != 255) + return false; + red = red && at[0] == 255 && at[1] == 0; + return true; + }); + QVERIFY(red); + QVERIFY(ink > 100 && clear > 100); +} + +void TypeToolTests::paragraphBoxAndToolSwitchCommitEditableText() +{ + const auto session = makeSession(); + session->beginText(QRectF(40, 60, 200, 120)); + QCOMPARE(session->textDraft().value().style.content, QString()); + type(*session, QStringLiteral("Text that wraps inside its paragraph box")); + session->selectTool(NavigationTool::brush); + QVERIFY(!session->textDraft() && session->tool() == NavigationTool::brush); + QCOMPARE(session->activeLayer().value().size(), QSizeF(200, 120)); + QCOMPARE(session->activeLayer().value().liveText().value().style.boxSize, std::optional(QSizeF(200, 120))); + session->selectTool(NavigationTool::type); + session->editActiveText(); + type(*session, QStringLiteral("Edited on canvas")); + session->cancelText(); + QCOMPARE(session->activeLayer().value().liveText().value().style.content, QString("Text that wraps inside its paragraph box")); +} + +void TypeToolTests::emptyNewParagraphIsDiscarded() +{ + const auto session = makeSession(); + const size_t count = session->document().value().layers.size(); + session->beginText(QRectF(0, 0, 100, 100)); + session->selectTool(NavigationTool::brush); + QCOMPARE(session->document().value().layers.size(), count); + QVERIFY(!session->textDraft()); +} + +void TypeToolTests::invalidAndStaleDraftsDoNotChangeDocument() +{ + const auto session = makeSession(); + session->beginText(QPointF(0, 0)); + type(*session, QStringLiteral("Text")); + TextDraft draft = session->textDraft().value(); + draft.style.fontSize = NAN; + QVERIFY(!session->applyText(draft)); + draft.style.fontSize = 72; + draft.style.boxSize = QSizeF(0, 100); + QVERIFY(!session->applyText(draft)); + draft.style.boxSize = QSizeF(360, 160); + draft.style.content = QStringLiteral("Valid"); + session->setTextDraft(std::nullopt); + session->createDocument(100, 100, true); + QVERIFY(!session->applyText(draft)); + QCOMPARE(session->document().value().layers.size(), size_t(1)); +} + +QTEST_MAIN(TypeToolTests) +#include "TypeToolTests.moc" diff --git a/tests/fixtures/ToolIcons.png b/tests/fixtures/ToolIcons.png new file mode 100644 index 0000000000000000000000000000000000000000..8c04ccafafd17d69eff1f75eb16ea9e6c2f89251 GIT binary patch literal 20050 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,nz)Ff<!_JJrsx#Zd2a! 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 F{$G=?L(c#J literal 0 HcmV?d00001 diff --git a/tests/fixtures/p3-nclx.heic b/tests/fixtures/p3-nclx.heic new file mode 100644 index 0000000000000000000000000000000000000000..bedc156ae48b9a149835eaa4de3d5bcfa21b560e GIT binary patch literal 1013 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>Ufw3+(wImTF4g`uBDLF+DCIdr3W^xIP z4Ws!AGD~v797Z6il$nzcHqym`fe{FSfN>FoW?*0n26B}%GxLC!fFyue6v#<sg7PxT zk_|!3f0+eE1wi^<W<hd3kS+&GI5V<vfaJj7{|AOY|9|{oVBlv~02(9iz{tb+|363p zGs8R!5Xta=7n6e`P)^H<5v&s-F@Z%uVbk51Dc8+;co|qgiXnu85>PpZ3**58gM^6+ zKzl?oiwjaA7BV;hX%N;(&PfD04a5eyUja%3DF&wh|NlP#(LgMmoS##amz-0<z{9}H zz|a5`5-7;5$OO_1%p4pbuSjGT<R*gnKmZIX)<&jg7FM9{=-iY<U??;BYcQ^7-65Zo zy#N2-u;o^UQL9#MykGnMel6Gk|4;m`|NkFwZQdqt?Vo(k6P{S+Mu%P93M8&ftN$(c zg7H85Kko1Qw)Z}+xM$w~_{Tqc5Mlek=V*?%z@~Qn%UzkLDtEUq+dHja@P59(g-qdb z5UM-=28Oiq-}*R+RsWj%tM<M1#@yJgWmj*9=a!gWxb7Uw3#P(v|G5yrK5KD^DyyI! z5IAUnf!gy12A)}p{T$J!3->Imk+nX$tiASWwBOx-E%EjX7`RUTbS(cImuh-LY}qeO zw<I6ss=h#_Eewps!9qN|l8XXoSi5zU?Z0mHR=oc4hAkT!qY_$!`v3nw_kXTjT2auE zjXK^!r#HRXtp_5+r!MdR|L=nM{cDHWcqA+y9&YE8F|`ca@&Er#wKvr~l4Wc9nyffp zC@<)DwH5gie9<3Ftqsus|1tRL+nEbih#oUhZ}3+7(x`DQng9QP&XQ{`Y2}Bsmo<wy znXNP8;Hv-MR2FbgzQRrO?=E*qyH!(;%)0(hf#Gfg!>Uwfp^%HmUR&J%f9p@(nFRit zgnaS;|ID=mPH}1)i!80mWwKCN{<`}Azkr^y@0$ZBpGi(l`+R@H_4L;A{r?|k%+2_! zA2Pu<_T|(&&)L?5)&IXMmECB@QOz86=SA?&`F1lSHW};x|8dT>NL>B@Un?fX*9-X* zot)kO|LU0B#NJ{Mw~T$&i_&nn2UB{*>;Gq;^)ijTV)1!d;k;Ax-)4EJ{r~r6_LF&! SuTA%!ylhE=M>W?2YbF3H_j2U` literal 0 HcmV?d00001 diff --git a/tests/fixtures/premultiplied.heic b/tests/fixtures/premultiplied.heic new file mode 100644 index 0000000000000000000000000000000000000000..ba6f7edec526d59ce3653459a77f76eff6be9781 GIT binary patch literal 1169 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>UfoXegYDpqU90(LMQgVtQOa_L6%;XXn z8%FaLWR~QDIgCKkBr_);Y@~|=0}}&Cgn{Wcga$JA0>yxUc?MkMCy;BAnVAQ)76?GH zqCie6SQ$`vMp?2UNCF`Ulz|E|zQ`;nDqvt>JdjzCoDWn~4wP_aWZ?j500X%X41fOr z_`$%y&#VBnQQU!%hw=Y^kOF3gc@`j&L2eh5gCbB)%ZU-J6Cp8yML=QG-IyuY%^7*w zSU`#)gn|-KIfo15!D0ymjtM|}L^6vDQlU0F0BI1`NX|(FIt|PQ1%d()g98IdGyVVn z{{e^wV&UZcoT9wsoC=^UF9TZxP)ML4vmz5nGca>-fV@(O%~yYL_-a1Tvq-*DgZm05 zaiEDsEPC~%S<enMfK-Bs7LbqFKt4Lq3AB(B{{RyM#PN(Er>iHHRyYG`hSH)utK5Rr zbgPWivSceG14C0Qpj2jx6*zJgG7EAOLCSytm}XcTnVMNx8JJkx*gH78fMOb%MX8YV z!N9-=)RzNN1rAQWf}+%1FdG<{jQes^5<$sxrv~GCwjC2YFV$aqGXMX7#*AO`@8e$@ zEvoxfo>CV!`~N@9>&gGt82{A$Wxns;{V#{Kg3EIvKE3+E8gcyfw6Mva?VGQAPuTVK zE4N%3b3k!Mi_~o!*1eKy6El{C#Lk;+^Qs{Bf%*NEuQ_4I8$WftX6n5bcQAaanRR@J z+6(nKhZ(=BS-T}gKJ)uVTC|#Om&!|>BG9|$xZjFvt{XpWW!LOKD89|e@W<E0oLN4f z@BdqukgxrT_4&NbOGDp006PAw2ID&3g5B3N|7WSJc_07n!_DgpzTKZAq5kot))bcM zy=L>@O#OI3_w>$LIYQf=nI`Jj?Xhv*pZMe<zhvO#_x2}KH1g;6TlTE2nVfUY)a+Nw z<IS4`QtqXHvWi@FvzzhrmP>K<7eANF<tG+7<|&zN`7I-FKWAmqS>xw#()kx3vn%c? zcsA9uir-dM;Np*})ju8|56<{@K}9md#z)X2CpUtJu`NE_Y2`D)=xLmP{?{Gsy*~L8 zXR_hXiGSuzEPSZ8W{JPk^|RM*U4HrW+VTY)XOiyA@bB&0-%vMA+EUSV+UNMQHWrJT X!x!lnuF<>bVK93yL&Diy{o(`wafM#| literal 0 HcmV?d00001 diff --git a/tests/fixtures/red.heic b/tests/fixtures/red.heic new file mode 100644 index 0000000000000000000000000000000000000000..0dfc2a0d0e3f4bd0ed83c350d55c571e40ec1ef9 GIT binary patch literal 1150 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>UfoWxKYDpqU90(LMQgVtQOa_L6%;XXn z8%FaLWR~QDIgCKkBr_);Y@~|=0}}&Cgn{WSga$Hq0L6fSxfL$*6Uep5%*+E?3j`op zQ6MK3tPH3-qb%7FB!LhF%0LAfUt|^(6)-R`9>^?6&Ic+g2TC|IvT%SjfPvfxhCly* z{9s_<XI22(DDJ?>!}$L{NC7j$JPQ!XAh(OjK@ljY<-`cqiIAAUBA~G8Zp@VH=8U{- zEFi@YLO}_roWq6jV6lV&#{{4~BALYnsZbjofHVkeB<Caood#xu0zm<Y!GQs!ng0L( z{{Tb-v2b#JPElTRP6bewmw~MTC?rsjS&<2(8JIaZKwc@t=BqzAd^I2FStMVn!F>gj zIMBo*7QK4XtY-%rKq|pR3&=-oARis*1X@Ume}IVr;&?`o)72A8E1ZEeLupZ-Rc=9Q zx>ZJMS+bRpfuX4tP%1OU3LLo#nFYCtAZ0)ROf#&FOwBB;3{0$T>>Zq4KryMzqEtxw zU|`?_>dOJC0tF}I&fJtlP=ef|!ML7n$HdM{^_QN^|Noyc<CpyV_?JeD>VB1{)P>Fd z|4;LJ@^jsu>a8XmwVl2Hd+x7gW)~DFemq0zbE*Ey^M}(f^e((&cd=CTYNw{j=}$Ei zq|ZKR&7Zf)MJ0!cPxXACVRWO+a(~%nleYxy7vkb<J%7JwN_Sz)>0LYep7re#TKhgb zE4;>kmiWQkj02Z<^eb70`EI?%rSQWfX48V1q7@6g_lK@BV%I<V?+e$RXZ!A3U0re7 za|6)bUo{xl@fPg9rujchWzGBeZy#=6U-0ez90~Q0C$*-qOz$<D|7PmP1G=Yo&dL$m z?#wh%ziy9>^Zvvq5BVhnC%?BpnWB+Dx8Jg7ZO!DIYo=zuS{`rS9FTG^{gYMXs+--6 zpSN6!tH1cUTrNMc$T3gJY|C#MdHXpllg=7Hf0NF?_?TUBPr<XPo>ly|ssa~(RIUE; z_;_%}w+kwg88$wG9yz%YJdADe;Z7@`2}V!j{PVx=VDI(GmpGFRe@^@}Z(`v?wKYrp zovxp~cI)!Xr`MJ*;5d_XUxt5g-~NWWY0{R8uG2oppS7`A)EvG@zi^G-MGu47dl?eW J=IR$G001q;SOow8 literal 0 HcmV?d00001 diff --git a/tests/fixtures/turned-p3.heic b/tests/fixtures/turned-p3.heic new file mode 100644 index 0000000000000000000000000000000000000000..4fc5cf4cb66631bc1c81371d5376872100203f34 GIT binary patch literal 1403 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>Ufq8OnYDpqU90(LMQgVtQOa_L6%;XXn z8%FaLWR~QDIgCKkBr_);Y@~|=0}}&Cgn@ZGgl1r1Tm}>a0@hfV2!kAuYmu3m2ecLl zK(eAhPAXU#P<KXIvLQ$UCddd<;#!djQqFWKv!JK|NN>z6NX`celmk^eGqP}iG=PEJ z2ZlfYfBaxz;Ad6<+9>Y8$iw*mKS%*H!#oQR$so6j$w3h)r{%;5)`^gqz#^cq>2A!F z>*kESY%Czf5JEu-sGP%v@nErp0mlTOJtCRK1*uRQ9e^|lYb56+0-XkCg91STh{1sY zq?!Kz|Nj6)12Jc2QGN-~8H{(6^K*&{it>RTW4s8IU|@0pMoV5vQINZnLPTVg0s|6A zOfCi)jtc-S`gRRs-DTat5|HCb0-Qxa3xPU50I^6qn9T?>LnH~pmH@HKOA3HVfl&v< zPA(_{u^oVHo}!Q-XCT`L$W};4VkaT7Q&NkQfogJqYElt?Yrf0CV7m_(IC~IcUi%ps zHkvaqaP35hX;?Eb2y!zpoH&gT`|J<)FQeF@lGF-t67|z}WGF~1N(9;gj%Ws^G+@|% zTF$`08_B>Rd;w@?G{hZX72GbsSj<VRR0uEz^QlS*6l7Lpf=p!Q-~gpg$;^UWh{J%P zz`)4X$kfcj$_A8>$}CC+C2bJkOHKiX5-1M=<CF1BZb~93j2CGzu4mh^al=-X^gH|i z|KFeWm;WXUpLncNe?{W||7VO0y;W@)-o5|d^rCQ;+rjdA756*Xx4xH_op|@>Y=?%P zBb$QuxyWn(IBRe#Ud2~m-8FGlO@@<hq5H3gjAb$1cg-~t-o)>g;Y!r(<!vfo!=hK< z@NB-h#ipa{w7h1Wo8V}#m#4(H^Y7K`BFieIx>f&k72VMg%a{FCZgIJLqfE==tdvz; zPZ&}a)=v$~Vk<e`&Npp;*Tbr{^2{2~W_dhO{}oYQF!j>)FGUxRJ8zb<j9-7g>XP7X zciE=fZ>MQCl>Yl*+4xWYMee=u2i>`MUWK|`p1&e*v+wVirJ~=u7nVhRsB9@?5R^0# zzgaS|+;Ee?x!$m2jAxb9BDN^CE3iNDmE*S7c)aAe%<3$qpabk~R@V(5UUJ*`cj@t- z#Mxn6-=BVe(Coz;o95_`|CGwLzjvlHWCU``H&qv}U!7=vaP!Z5{ep}3WWAFQdU9sZ zyDjgdKfJVYIreY!s}HB1tu;OtGpXj_$8TIus|}LOCdfR0)4FV)f+_FfElN%HCu4-_ kU!O~FNPoOBGi(7U@;p7Y7(g-2$RNbX42nPoQ1pTT0P8VbW&i*H literal 0 HcmV?d00001