Acceptance run with screenshots and the MVP table; window teardown no longer reaches destroyed menus or toolbar (11.5)
No files matched your search
@@ -11,7 +11,8 @@ To make or change an image in a `.comp` project, the source is not needed: read
|
||||
- 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.
|
||||
- `.github/workflows/release.yml`: a `v*` tag builds the DEBs, the RPMs and the Arch package and publishes them.
|
||||
- `docs/acceptance.md`: the MVP acceptance table, its screenshots in `docs/acceptance/`.
|
||||
- `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`).
|
||||
@@ -79,6 +80,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- 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<true>`): 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.
|
||||
- The acceptance run (11.5): `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window as `main` builds it (`AcceptanceFixtures.h`'s `App`: an `OmarchyTheme` on a folder of the test's own, the workspace, the window at 1180 × 780), one scenario for each row of the README's feature list, through menu entries, canvas gestures, sheets and the real panels (`DialogDesk`), and check pixels, layers, sizes and files. With `ACCEPTANCE_SHOTS` naming a folder, each scenario saves the window with its shown floating panels drawn over it at their places (offscreen draws no title bars), after 300 ms of events, since posted geometry and previews land from the event loop; `docs/acceptance/` holds the committed shots and `docs/acceptance.md` the table. A fix the run found: `~CompositorMenus` disconnects from `QApplication::focusChanged` first, because its Keyboard Shortcuts panel, a member, hides as it goes and moves the focus into the half-destroyed menus (quitting with that panel active crashed); `KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys` holds it. So does `~ProjectWorkspaceView` with its session watch: a floating panel whose field holds typing (the colour picker, Levels, Hue/Saturation, a filter) commits as it hides, and the session's signal reached the window's toolbar and tab strip, already gone; `WindowTeardownTests` holds all four.
|
||||
- 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/omaphoto` (under the library folder) with the app's run path set there, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where the images keep them from the release; a configure that cannot find them fails). Every package bundles (11.4d, issue #5): ONNX Runtime stamps each release's symbols with its version (`VERS_1.20.1`), so a package linked against a system's runtime refuses to install, or to start, once that system updates it. The DEB, the RPM and the Arch package carry Microsoft's 1.21.0 build, pinned by checksum, which the dev, Fedora and Arch images install into `/usr/local` too, so the tests run against the runtime that ships; the flake builds from nixpkgs' own, since Nix rebuilds its closure with it. `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 but the bundled runtime left out of what it provides and requires (`__provides_exclude_from`, `__requires_exclude` in `CPACK_RPM_SPEC_MORE_DEFINE`), 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 and Microsoft's runtime by their checksums; its `prepare()` lays the runtime out as that build's CMake config expects (`lib64`, `include/onnxruntime` beside `lib/cmake`) and the package bundles it. `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|fedora44|nixos|resolute` builds `scripts/distro/<name>/Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; `fedora` is 43 since 11.4d, `fedora44` the same file on 44 since 12.16) 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 with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) 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 `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); 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. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder.
|
||||
- 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.
|
||||
@@ -171,7 +173,7 @@ Deliberate name breaks: `MetalBrushCoverage` → `BrushCoverage`, `MetalLayerEff
|
||||
|
||||
## 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.
|
||||
- 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); Swift keeps them in `GradientControls` and `BrushControls`, and the twin keeps them with the rail's other icons. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (the ellipse marquee, the polygonal lasso, the Magic tool's object mode, the eraser) take a `ToolIconKind`. `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-noble_amd64.deb`, `omaphoto-resolute_amd64.deb`, `omaphoto-fc43.x86_64.rpm`, `omaphoto-fc44.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`; 1.0.0 had one DEB, `omaphoto_amd64.deb`, and 1.0.0 to 1.2.3 one RPM, `omaphoto.x86_64.rpm`) with `SHA256SUMS`, so `releases/latest/download/<name>` always reaches the newest. `scripts/install/arch.sh` reads `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`), `fedora.sh` its `ID` or `ID_LIKE` and `VERSION_ID`, taking the RPM of release 43 or 44 and refusing any other, since each names its release's LibRaw (`libraw_r.so.23` on 43, `.25` on 44; found in 12.16, when 1.2.3's single RPM from Fedora 43 would not install on 44), and `ubuntu.sh` its `UBUNTU_CODENAME`, which Ubuntu's derivatives set to their base: each DEB names its release's Qt libraries (24.04's carry a `t64` suffix that 26.04's drop, and Debian 13 has neither), so the release carries one DEB per LTS, built on it (`Dockerfile.dev` takes the base as `BASE`), and the script takes the one whose codename matches, `noble` or `resolute`, refusing any other. Each refuses another system or another machine than x86_64, 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. 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 at the newest release's tag, which GitHub's `releases/latest` redirect names, into the user's profile (`nix profile install`, the name every Nix still takes), so no user builds unreleased work from `main`. 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 DEBs on the dev image over each base, the RPMs on Fedora 43 and 44 (`scripts/distro/fedora/Dockerfile` takes the release as `RELEASE`, 43 by default), 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, and the workflow's first job refuses a tag that names another.
|
||||
|
||||
@@ -21,6 +21,8 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
|
||||
- Keep working while a project saves
|
||||
- A project another app changes on disk reloads in place; unsaved work is never replaced without asking
|
||||
|
||||
The [acceptance run](docs/acceptance.md) checks each of these in the app's own window, with screenshots.
|
||||
|
||||
### Works with AI agents
|
||||
|
||||
AI agents and scripts can build and edit projects directly: a `.comp` is a folder of PNG layers and a manifest, and an open project updates live as it's written. See [Writing OmaPhoto projects](docs/writing-comp-files.md) and the [project format](docs/project-format.md).
|
||||
|
||||
@@ -401,7 +401,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- 12.16 release run: every job passed and published. The live installs (each script from GitHub in fresh containers) installed and started 1.3.3 on Ubuntu 24.04 and 26.04, Fedora 43 and Arch and refused Debian 13, but Fedora 44 refused the RPM: built on 43, it needs LibRaw's `libraw_r.so.23`, where 44 has `.25` (so 1.2.3's RPM had the same fault). Now one RPM per Fedora release, as one DEB per Ubuntu LTS: the Fedora Dockerfile takes `RELEASE`, the workflow builds `omaphoto-fc43` and `omaphoto-fc44`, `fedora.sh` takes the one for `VERSION_ID`, `distro-check.sh fedora44` passed 291 of 291. The published tag stays: the release gained `omaphoto-fc43.x86_64.rpm` (the CI build, renamed), `omaphoto-fc44.x86_64.rpm` (built from `git archive v1.3.3` with the workflow's command) and new sums, and keeps `omaphoto.x86_64.rpm` for the old script.
|
||||
- 12.16 round 3: FAIL, one finding: `fedora.sh`'s combined pattern refused a derivative whose `ID_LIKE` ends in `fedora`. Fixed (identity and version checked apart); the reviewer's own probe of eleven identities now passes each. Closed after the third round.
|
||||
- 12.16 live installs after the fix (`live-installs-1.3.3.log`, `live-nixos-1.3.3.log`): each script from GitHub in a fresh container verified its package, installed it and started 1.3.3 with the bundled runtime on Ubuntu 24.04 (Qt 6.4.2) and 26.04 (6.10.2), Fedora 43 (6.10.3) and 44 (6.11.2) and Arch (6.11.2); Debian 13 was refused; `nixos.sh` resolved `v1.3.3`, built its flake and started 1.3.3 on Qt 6.11.2.
|
||||
- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
|
||||
- [x] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
|
||||
- 11.5 work: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window through one scenario per README feature row (menus, canvas gestures, sheets, the real panels) and check pixels, layers, sizes and files; `docs/acceptance/` holds sixteen screenshots and `docs/acceptance.md` the MVP acceptance table, with the distro checks and live installs of 12.16. The README links the table (the README is the Linux app's own since 11.4b, so its Install and Build sections are the Linux section). The run found a crash: destroying the window while the Keyboard Shortcuts panel held the keys (so quitting with it active) reached the half-destroyed menus; `~CompositorMenus` now stops listening first. 295 of 295 in the dev image.
|
||||
- 11.5 round 1 (codex): FAIL. A sibling crash: a floating panel's typed field commits as the window goes, and the session's signal reached the window's toolbar and tab strip, already destroyed (colour picker, Levels, Hue/Saturation, a filter); `~ProjectWorkspaceView` now disconnects its session watch first, `WindowTeardownTests` holds the four cases. Also: the reload scenario did not check that the app asked (a bypass mutant passed), the documented command hid an early failure, unchecked lookups and `->` on optionals, the scrub row's screenshot taken after the reload, a stale Tool icons bullet and two table wordings. All fixed; each fix's mutant caught.
|
||||
- 11.5 round 2: PASS, no findings.
|
||||
|
||||
## Skills by phase
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# OmaPhoto 1.3.3 acceptance
|
||||
|
||||
The MVP acceptance table for OmaPhoto 1.3.3, the Linux port of Compositor 1.3.3. Each row is a feature from the README.
|
||||
|
||||
The acceptance run drives the app's own window, the way a person uses it. It chooses menu entries, drags on the canvas, types into sheets and answers the real file panels. Then it checks concrete results: pixels, layers, sizes and files on disk. The run is three test programs: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests`. They take the screenshots below when `ACCEPTANCE_SHOTS` names a folder.
|
||||
|
||||
```sh
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen -v "$PWD:$PWD" -w "$PWD" omaphoto-dev \
|
||||
sh -c 'cmake -S . -B build && cmake --build build --target AcceptanceTests AcceptanceColorTests AcceptanceProjectTests &&
|
||||
for t in AcceptanceTests AcceptanceColorTests AcceptanceProjectTests; do ACCEPTANCE_SHOTS=$PWD/docs/acceptance build/$t || exit 1; done'
|
||||
```
|
||||
|
||||
The screenshots come from Qt's offscreen platform, so floating panels appear without the window manager's title bars. The run uses the built-in dark theme, except for the last row.
|
||||
|
||||
## Table
|
||||
|
||||
| Feature | What the run does and checks | Screenshot | Suites that hold the details |
|
||||
| --- | --- | --- | --- |
|
||||
| Layers, folders, masks, clipping masks, blend modes, opacity | Imports a photo and paints a layer. Clips it to the photo and sets Multiply at 60%. Masks the photo and groups both in a folder. Undo takes a step back. | [01](acceptance/01-layers.png) | `GroupTests`, `LayerMaskTests`, `LiveMaskTests`, `LayerAppearanceTests`, `LayerRendererTests`, `HistoryTests` |
|
||||
| Selections | Drags a marquee to within 2 px. The wand takes the flat ground. Select › Subject finds the red disc with the U²-Net model. | [02](acceptance/02-selections.png) | `SelectionTests`, `MagicWandTests`, `SelectSubjectTests`, `SelectionEditTests`, `ObjectSelectionTests`, `SelectionFeatherTests` |
|
||||
| Painting tools | Drags a gradient that fades to clear, a rectangle shape and a soft white brush stroke. Types "Hello OmaPhoto" on the canvas and colours "Hello" red. That leaves one colour run, committed with Ctrl+Return. | [03](acceptance/03-painting.png) | `BrushTests`, `GradientTests`, `ShapeToolTests`, `TypeToolTests`, `TextColorRunsTests`, `SpotHealingTests`, `CloneStampTests`, `SmudgeLiquifyTests` |
|
||||
| Move, transform, distort; rulers, guides, grid | Drags the layer with the Move tool. Transform Layer halves it, and Return applies. Rulers, a guide and the grid are shown. | [04](acceptance/04-transform-guides-grid.png) | `TransformSessionTests`, `DistortTests`, `GuideTests`, `FloatingSelectionTests` |
|
||||
| Adjustments as edits and as layers | Hue/Saturation turns the red disc green. An Invert layer inverts the view and leaves the pixels alone. A Curves layer opens its editor. | [05](acceptance/05-adjustments.png) | `HueSaturationTests`, `LevelsTests`, `CurvesControlsTests`, `AdjustmentLayerTests`, `AdjustmentEditorTests`, `ImageAdjustmentTests` |
|
||||
| Camera Raw Filter, docked | Opens in the dock beside the canvas. One stop of exposure previews, commits and brightens the ground. | [06](acceptance/06-camera-raw.png) | `CameraRawTests`, `CameraRawSessionTests`, `CameraRawPanelTests`, `CameraRawCanvasTests` |
|
||||
| Layer effects | A 12 px red stroke rings a shape outside its edge, and a drop shadow's sheet shows. The shape keeps its fill. | [07](acceptance/07-effects.png) | `LayerEffectsTests`, `LayerEffectsKernelTests`, `EffectsPanelTests`, `EffectsCanvasTests`, `InnerGlowTests` |
|
||||
| Filters | Dither leaves only black and white dots. Remove Background masks the photo around its subject: the centre stays opaque and a corner turns clear. | [08](acceptance/08-filters-dither.png), [09](acceptance/09-remove-background.png) | `DitherTests`, `FilterTests`, `FinishingFilterTests`, `SubjectRemovalTests`, `RemoveBackgroundTests` |
|
||||
| Crop, trim, canvas size, image size; PNG and JPEG export | Crops to 1000 × 600. Canvas Size widens it to 1400. Trim finds the photo's own 1200 again. Image Size halves it. Exports a PNG through the save panel and a JPEG through its sheet and panel, then reads both back at 600 × 300. | [10](acceptance/10-canvas-size.png), [11](acceptance/11-export-jpeg.png) | `CropTests`, `ImageTrimTests`, `ProjectResizeTests`, `ProjectExportTests`, `JPEGExportTests` |
|
||||
| Imports | Takes JPEG, PNG, TIFF, HEIC, SVG, a layered Photoshop file (it arrives as a folder) and a camera RAW file, which asks how to develop it. That makes eight layers with no import error. | [12](acceptance/12-raw-develop.png), [13](acceptance/13-imports.png) | `ImageImportTests`, `PSDImportTests`, `RawImporterTests`, `RawDevelopTests`, `SVGImportTests` |
|
||||
| Open Recent; Keyboard Shortcuts window | Saves a project, and Open Recent lists it. The shortcuts window opens over the editor. | [15](acceptance/15-shortcuts.png) | `OpenRecentTests`, `RecentProjectsCoreTests`, `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests` |
|
||||
| Scrub a number by its label | Dragging the Opacity title 40 points left lowers the layer's opacity. | [14](acceptance/14-scrub.png) | `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableSheetsTests` |
|
||||
| Keep working while a project saves | Another layer is added while a save runs. The file holds the state the save began with. | — | `ProjectSaveWhileEditingTests` |
|
||||
| Reload a project changed on disk | With unsaved edits open, another program renames a layer on disk. The app's alert asks, Revert is chosen, and the saved state shows with the new name. | [15](acceptance/15-shortcuts.png) | `ExternalChangeTests` |
|
||||
| Follows the Omarchy theme | Starts in the built-in dark. A Catppuccin Latte `colors.toml` written while the app runs retints the window. | [16](acceptance/16-omarchy-light.png) | `OmarchyThemeTests` |
|
||||
| Builds and tests on each system | The full suite passed on Ubuntu 24.04 (Qt 6.4.2), Ubuntu 26.04, Arch (Qt 6.11), Fedora 43 and 44, and NixOS. Warnings count as errors. | — | `scripts/distro-check.sh` |
|
||||
| Packages and install scripts | Each install script ran from GitHub in a fresh container. On Ubuntu 24.04 and 26.04, Fedora 43 and 44 and Arch, it checked its package against `SHA256SUMS`, installed it and started 1.3.3. On NixOS, `nixos.sh` resolved `v1.3.3`, built its flake and started 1.3.3. Debian 13 was refused, as intended. | — | `DesktopIntegrationTests`, `.github/workflows/release.yml` |
|
||||
|
||||
## Known differences from Compositor
|
||||
|
||||
`AGENTS.md` lists each place where the port differs from the Swift app, and why. The main ones:
|
||||
|
||||
- Remove Background and Select Subject use U²-Net on ONNX Runtime, not Apple's Vision.
|
||||
- Rendering is CPU only.
|
||||
- There is no updater.
|
||||
- A `.comp` project is a folder.
|
||||
- A Wayland compositor places windows itself.
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -3,6 +3,7 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "UI/FloatingPanel.h"
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QLineEdit>
|
||||
#include <QMenuBar>
|
||||
#include <QPointer>
|
||||
@@ -12,6 +13,8 @@ class CompositorMenus : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWidget &window);
|
||||
// The panel's hide moves the focus: stop listening first.
|
||||
~CompositorMenus() override { disconnect(qApp, nullptr, this, nullptr); }
|
||||
|
||||
QAction *action(const QString &name) const;
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget
|
||||
synchronize();
|
||||
}
|
||||
|
||||
// Closing panels commit and notify: stop listening first.
|
||||
ProjectWorkspaceView::~ProjectWorkspaceView()
|
||||
{
|
||||
disconnect(m_sessionWatch);
|
||||
}
|
||||
|
||||
void ProjectWorkspaceView::watchFront()
|
||||
{
|
||||
// Every tab's buttons dim while the front tab is busy.
|
||||
|
||||
@@ -11,6 +11,7 @@ class ProjectWorkspaceView : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget *parent = nullptr);
|
||||
~ProjectWorkspaceView() override;
|
||||
|
||||
ContentView *content() const { return m_content; }
|
||||
ProjectTabStrip *tabs() const { return m_tabs; }
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#include "AcceptanceFixtures.h"
|
||||
#include "Document/HueSaturation.h"
|
||||
#include "Document/LayerEffects.h"
|
||||
|
||||
// The acceptance run, part two: adjustments, Camera Raw, effects, filters.
|
||||
class AcceptanceColorTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
|
||||
void adjustmentsAsEditsAndLayers();
|
||||
void cameraRawDocked();
|
||||
void layerEffects();
|
||||
void filters();
|
||||
};
|
||||
|
||||
namespace {
|
||||
// The photo, alone in a document its size.
|
||||
QUuid photo(App &app)
|
||||
{
|
||||
if (!scene(1200, 800).save(app.path("photo.png")))
|
||||
throw std::runtime_error("the photo could not be written");
|
||||
app.session().createDocument(1200, 800);
|
||||
app.importFile(app.path("photo.png"));
|
||||
return app.session().activeLayerID().value();
|
||||
}
|
||||
}
|
||||
|
||||
void AcceptanceColorTests::adjustmentsAsEditsAndLayers()
|
||||
{
|
||||
App app;
|
||||
const QUuid layer = photo(app);
|
||||
QCOMPARE(app.shown(600, 400), QColor(220, 30, 30));
|
||||
// Hue/Saturation as an edit: the red disc turns green.
|
||||
app.action("hueSaturation").trigger();
|
||||
QVERIFY(app.session().hueSaturation());
|
||||
app.session().updateHueSaturation(HueSaturationSettings(120), true);
|
||||
bool done = false;
|
||||
app.session().commitHueSaturation([&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
const QColor green = pixel(app.session(), layer, 600, 400);
|
||||
QVERIFY2(green.green() > 200 && green.red() < 60, qPrintable(green.name()));
|
||||
// Invert as a layer: the pixels stay, the view inverts.
|
||||
app.action("newInvertAdjustment").trigger();
|
||||
QVERIFY(app.session().activeLayer().value().adjustment);
|
||||
QCOMPARE(pixel(app.session(), layer, 600, 400), green);
|
||||
const QColor inverted = app.shown(600, 400);
|
||||
QVERIFY2(qAbs(inverted.red() - (255 - green.red())) <= 1 && qAbs(inverted.green() - (255 - green.green())) <= 1, qPrintable(inverted.name()));
|
||||
// Curves as a layer opens its editor in the panel.
|
||||
app.action("newCurvesAdjustment").trigger();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit(), 30000);
|
||||
QCOMPARE(app.session().filterEdit().value().kind, FilterKind::curves);
|
||||
app.shot(QStringLiteral("05-adjustments"));
|
||||
app.session().cancelFilter();
|
||||
QCOMPARE(int(app.session().document().value().layers.size()), 3);
|
||||
}
|
||||
|
||||
void AcceptanceColorTests::cameraRawDocked()
|
||||
{
|
||||
App app;
|
||||
const QUuid layer = photo(app);
|
||||
app.action("cameraRawFilter").trigger();
|
||||
QVERIFY(app.session().filterEdit());
|
||||
QWidget *const dock = app.window.findChild<QWidget *>(QStringLiteral("panelDock"));
|
||||
QVERIFY(dock && dock->isVisible());
|
||||
FilterSettings settings = app.session().filterSettings();
|
||||
settings.cameraRaw.exposure = 1;
|
||||
app.session().updateFilter(settings, true);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit().value().previewImage(layer), 30000);
|
||||
app.shot(QStringLiteral("06-camera-raw"));
|
||||
bool done = false;
|
||||
app.session().commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
// A stop brighter: the ground's green lifts.
|
||||
const QColor ground = pixel(app.session(), layer, 600, 700);
|
||||
QVERIFY2(ground.green() > 170, qPrintable(ground.name()));
|
||||
}
|
||||
|
||||
void AcceptanceColorTests::layerEffects()
|
||||
{
|
||||
App app;
|
||||
app.session().createDocument(1200, 800);
|
||||
app.session().selectTool(NavigationTool::shape);
|
||||
app.session().setForegroundColor({0.2, 0.5, 0.9});
|
||||
app.drag(QPointF(300, 200), QPointF(900, 600));
|
||||
const QUuid shape = app.session().activeLayerID().value();
|
||||
// A red stroke and a drop shadow.
|
||||
app.session().addEffect(LayerEffectKind::stroke);
|
||||
app.session().changeEffects([](LayerEffects &effects) {
|
||||
effects.stroke.value().size = 12;
|
||||
effects.stroke.value().red = 1;
|
||||
effects.stroke.value().green = 0;
|
||||
effects.stroke.value().blue = 0;
|
||||
});
|
||||
app.session().finishEffectsEditing(true);
|
||||
app.session().addEffect(LayerEffectKind::shadow);
|
||||
QVERIFY(app.session().effectsEditing());
|
||||
app.shot(QStringLiteral("07-effects"));
|
||||
app.session().finishEffectsEditing(true);
|
||||
const LayerEffects effects = app.session().activeLayer().value().effects.value();
|
||||
QVERIFY(effects.stroke && effects.shadow);
|
||||
QCOMPARE(app.session().activeLayerID(), std::optional<QUuid>(shape));
|
||||
// The stroke rings the shape outside its edge.
|
||||
QCOMPARE(app.shown(300 - 6, 400), QColor(255, 0, 0));
|
||||
QCOMPARE(app.shown(600, 400), QColor(0x33, 0x80, 0xe6));
|
||||
}
|
||||
|
||||
void AcceptanceColorTests::filters()
|
||||
{
|
||||
App app;
|
||||
const QUuid layer = photo(app);
|
||||
// Dither: black and white dots by default.
|
||||
app.action("dither").trigger();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit() && app.session().filterEdit().value().previewImage(layer), 30000);
|
||||
app.shot(QStringLiteral("08-filters-dither"));
|
||||
bool done = false;
|
||||
app.session().commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
for (const QPoint at : {QPoint(100, 100), QPoint(600, 400), QPoint(900, 700)}) {
|
||||
const QColor dot = pixel(app.session(), layer, at.x(), at.y());
|
||||
QVERIFY2(dot == QColor(Qt::black) || dot == QColor(Qt::white), qPrintable(dot.name()));
|
||||
}
|
||||
// Remove Background masks the photo round its subject.
|
||||
app.action("undo").trigger();
|
||||
app.action("removeBackground").trigger();
|
||||
QVERIFY(app.session().filterEdit());
|
||||
done = false;
|
||||
app.session().commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 60000);
|
||||
app.settle();
|
||||
QVERIFY(app.session().activeLayer().value().mask);
|
||||
QCOMPARE(app.shown(600, 400).alpha(), 255);
|
||||
QCOMPARE(app.shown(40, 40).alpha(), 0);
|
||||
app.shot(QStringLiteral("09-remove-background"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(AcceptanceColorTests)
|
||||
#include "AcceptanceColorTests.moc"
|
||||
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "UI/CompositorMenus.h"
|
||||
#include "UI/OmarchyTheme.h"
|
||||
#include "UI/ProjectWorkspaceView.h"
|
||||
#include <QDir>
|
||||
#include <QPainter>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// The acceptance run drives the app's own window.
|
||||
struct App {
|
||||
QTemporaryDir files;
|
||||
// As the app starts: the theme before the window.
|
||||
OmarchyTheme theme{files.filePath(QStringLiteral("omarchy"))};
|
||||
ProjectWorkspace workspace;
|
||||
ProjectWorkspaceView window{workspace};
|
||||
App()
|
||||
{
|
||||
window.resize(1180, 780);
|
||||
window.show();
|
||||
if (!QTest::qWaitForWindowActive(&window))
|
||||
throw std::runtime_error("the window never became active");
|
||||
}
|
||||
EditorSession &session() { return workspace.current().session; }
|
||||
CanvasView &canvas() { return *window.findChild<CanvasView *>(); }
|
||||
QAction &action(const char *name)
|
||||
{
|
||||
QAction *found = window.findChild<CompositorMenus *>()->action(QString::fromLatin1(name));
|
||||
if (!found)
|
||||
throw std::runtime_error(std::string("no entry named ") + name);
|
||||
return *found;
|
||||
}
|
||||
// A document point where the canvas shows it.
|
||||
QPoint at(QPointF point)
|
||||
{
|
||||
return session().viewport.viewPoint(point, session().document().value().size()).toPoint();
|
||||
}
|
||||
void press(QPointF point, Qt::KeyboardModifiers modifiers = Qt::NoModifier)
|
||||
{
|
||||
QTest::mousePress(&canvas(), Qt::LeftButton, modifiers, at(point));
|
||||
}
|
||||
void move(QPointF point)
|
||||
{
|
||||
const QPointF view = at(point);
|
||||
QMouseEvent event(QEvent::MouseMove, view, view, canvas().mapToGlobal(view.toPoint()), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&canvas(), &event);
|
||||
}
|
||||
void release(QPointF point) { QTest::mouseRelease(&canvas(), Qt::LeftButton, Qt::NoModifier, at(point)); }
|
||||
// A drag in eight steps, as a hand makes it.
|
||||
void drag(QPointF from, QPointF to)
|
||||
{
|
||||
press(from);
|
||||
for (int step = 1; step <= 8; ++step)
|
||||
move(from + (to - from) * (step / 8.0));
|
||||
release(to);
|
||||
}
|
||||
// Waits for the project to be free again.
|
||||
void settle() { QTRY_VERIFY_WITH_TIMEOUT(!session().isProjectBusy(), 30000); }
|
||||
QString path(const QString &name) const { return files.filePath(name); }
|
||||
// The window and its shown panels, saved when asked for.
|
||||
void shot(const QString &name)
|
||||
{
|
||||
const QByteArray folder = qgetenv("ACCEPTANCE_SHOTS");
|
||||
if (folder.isEmpty())
|
||||
return;
|
||||
// Posted geometry and previews land first.
|
||||
QTest::qWait(300);
|
||||
QImage image = window.grab().toImage();
|
||||
QPainter painter(&image);
|
||||
for (QWidget *panel : QApplication::topLevelWidgets())
|
||||
if (panel != &window && panel->isVisible() && panel->parentWidget() && panel->parentWidget()->window() == &window)
|
||||
painter.drawPixmap(panel->geometry().topLeft() - window.geometry().topLeft(), panel->grab());
|
||||
painter.end();
|
||||
QDir().mkpath(QString::fromLocal8Bit(folder));
|
||||
if (!image.save(QDir(QString::fromLocal8Bit(folder)).filePath(name + QStringLiteral(".png"))))
|
||||
throw std::runtime_error("the screenshot could not be written");
|
||||
}
|
||||
// The document as an export draws it.
|
||||
QColor shown(int x, int y) { return ImageExporter::render(session().projectSnapshot().value()).image.pixelColor(x, y); }
|
||||
void importFile(const QString &file)
|
||||
{
|
||||
bool done = false;
|
||||
session().importImages({QUrl::fromLocalFile(file)}, std::nullopt, [&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
// A photograph's stand-in: sky, ground and a red disc.
|
||||
inline QImage scene(int width, int height)
|
||||
{
|
||||
QImage image(width, height, QImage::Format_RGB32);
|
||||
QPainter painter(&image);
|
||||
QLinearGradient sky(0, 0, 0, height);
|
||||
sky.setColorAt(0, QColor(40, 90, 200));
|
||||
sky.setColorAt(1, QColor(170, 210, 250));
|
||||
painter.fillRect(image.rect(), sky);
|
||||
painter.fillRect(QRect(0, height * 3 / 4, width, height / 4), QColor(60, 140, 70));
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(QColor(220, 30, 30));
|
||||
painter.drawEllipse(QPointF(width / 2.0, height / 2.0), height / 4.0, height / 4.0);
|
||||
return image;
|
||||
}
|
||||
|
||||
// A layer's pixel at a document point; clear outside it.
|
||||
inline QColor pixel(const EditorSession &session, QUuid id, int x, int y)
|
||||
{
|
||||
for (const ImageLayer &layer : session.document().value().layers) {
|
||||
if (layer.id != id)
|
||||
continue;
|
||||
const QImage image = layer.asset.value().image();
|
||||
const QPointF unit = (QPointF(x + 0.5, y + 0.5) - layer.transform.origin);
|
||||
const QPoint at(int(std::floor(unit.x() * image.width() / layer.transform.size.width())),
|
||||
int(std::floor(unit.y() * image.height() / layer.transform.size.height())));
|
||||
return image.rect().contains(at) ? image.pixelColor(at) : QColor(0, 0, 0, 0);
|
||||
}
|
||||
throw std::runtime_error("no such layer");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#include "AcceptanceFixtures.h"
|
||||
#include "DNGFixture.h"
|
||||
#include "DialogDesk.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "PSDFixture.h"
|
||||
#include "UI/ColorPickerSheet.h"
|
||||
#include <QMenu>
|
||||
|
||||
// The acceptance run, part three: sizes, exports, imports, projects, theme.
|
||||
class AcceptanceProjectTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
|
||||
void cropSizesAndExports();
|
||||
void imports();
|
||||
void projectsRecentScrubAndReload();
|
||||
void omarchyTheme();
|
||||
};
|
||||
|
||||
namespace {
|
||||
QUuid photo(App &app)
|
||||
{
|
||||
if (!scene(1200, 800).save(app.path("photo.png")))
|
||||
throw std::runtime_error("the photo could not be written");
|
||||
app.session().createDocument(1200, 800);
|
||||
app.importFile(app.path("photo.png"));
|
||||
return app.session().activeLayerID().value();
|
||||
}
|
||||
|
||||
// Types into a sheet's field, as the resize tests do.
|
||||
void type(App &app, const char *name, const QString &text)
|
||||
{
|
||||
auto *field = app.window.findChild<QDialog *>()->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);
|
||||
}
|
||||
|
||||
QSize document(App &app)
|
||||
{
|
||||
return QSize(int(app.session().document().value().width), int(app.session().document().value().height));
|
||||
}
|
||||
}
|
||||
|
||||
void AcceptanceProjectTests::cropSizesAndExports()
|
||||
{
|
||||
App app;
|
||||
photo(app);
|
||||
ProjectController &controller = app.workspace.current().controller;
|
||||
// Crop to the middle.
|
||||
app.session().selectTool(NavigationTool::crop);
|
||||
app.session().setCropRect(QRectF(100, 100, 1000, 600));
|
||||
bool done = false;
|
||||
app.session().commitCrop([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(document(app), QSize(1000, 600));
|
||||
// Canvas Size widens it; Trim takes the clear edges back.
|
||||
done = false;
|
||||
controller.canvasSize([&done] { done = true; });
|
||||
QTRY_VERIFY(app.window.findChild<QDialog *>());
|
||||
app.shot(QStringLiteral("10-canvas-size"));
|
||||
type(app, "canvasWidth", QStringLiteral("1400"));
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(document(app), QSize(1400, 600));
|
||||
done = false;
|
||||
controller.trim([&done] { done = true; });
|
||||
QTRY_VERIFY(app.window.findChild<QDialog *>());
|
||||
QTest::keyClick(app.window.findChild<QDialog *>(), Qt::Key_Return);
|
||||
QTRY_VERIFY(done);
|
||||
// The crop hid the photo's edges; Trim finds them again.
|
||||
QCOMPARE(document(app), QSize(1200, 600));
|
||||
// Image Size halves it.
|
||||
done = false;
|
||||
controller.imageSize([&done] { done = true; });
|
||||
QTRY_VERIFY(app.window.findChild<QDialog *>());
|
||||
type(app, "imageWidth", QStringLiteral("600"));
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(document(app), QSize(600, 300));
|
||||
// PNG and JPEG exports, through their panels and sheet.
|
||||
DialogDesk desk;
|
||||
desk.replies = {app.path("scene.png")};
|
||||
done = false;
|
||||
controller.exportPNG([&done] { done = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
QCOMPARE(QImage(app.path("scene.png")).size(), QSize(600, 300));
|
||||
desk.replies = {app.path("scene.jpg")};
|
||||
done = false;
|
||||
controller.exportJPEG([&done] { done = true; });
|
||||
QTRY_VERIFY(app.window.findChild<QDialog *>());
|
||||
app.shot(QStringLiteral("11-export-jpeg"));
|
||||
QPushButton *exported = app.window.findChild<QDialog *>()->findChild<QPushButton *>(QStringLiteral("jpegExport"));
|
||||
QVERIFY(exported);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(exported->isEnabled(), 30000);
|
||||
exported->click();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
QCOMPARE(QImage(app.path("scene.jpg")).size(), QSize(600, 300));
|
||||
}
|
||||
|
||||
void AcceptanceProjectTests::imports()
|
||||
{
|
||||
App app;
|
||||
app.session().createDocument(800, 600);
|
||||
QVERIFY(scene(200, 150).save(app.path("Scene.jpg")));
|
||||
QVERIFY(scene(200, 150).save(app.path("Scene.png")));
|
||||
QVERIFY(scene(200, 150).save(app.path("Scene.tif")));
|
||||
QVERIFY(QFile::copy(QFINDTESTDATA("fixtures/red.heic"), app.path("Red.heic")));
|
||||
QFile svg(app.path("Mark.svg"));
|
||||
QVERIFY(svg.open(QIODevice::WriteOnly));
|
||||
svg.write(R"(<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><circle cx="60" cy="40" r="30" fill="#e03020"/></svg>)");
|
||||
svg.close();
|
||||
PSDRecord sky = PSDFixture::record(QStringLiteral("Sky"), PSDFixture::colorImage(40, 20, 0, 0, 1), QRectF(0, 0, 40, 20));
|
||||
QFile psd(app.path("Trip.psd"));
|
||||
QVERIFY(psd.open(QIODevice::WriteOnly));
|
||||
psd.write(PSDFixture::data(PSDDocument{40, 20, 72, {sky}}, PSDFixture::colorImage(40, 20, 0, 0, 0, 0)));
|
||||
psd.close();
|
||||
DNGFixture::write(app.path("Gray.dng"), {});
|
||||
QList<QUrl> urls;
|
||||
for (const char *name : {"Scene.jpg", "Scene.png", "Scene.tif", "Red.heic", "Mark.svg", "Trip.psd", "Gray.dng"})
|
||||
urls << QUrl::fromLocalFile(app.path(QString::fromLatin1(name)));
|
||||
bool done = false;
|
||||
app.session().importImages(urls, std::nullopt, [&done] { done = true; });
|
||||
// A camera RAW file asks how to develop it.
|
||||
QTRY_VERIFY_WITH_TIMEOUT(app.session().rawDevelop(), 30000);
|
||||
app.shot(QStringLiteral("12-raw-develop"));
|
||||
app.session().finishRawDevelop(app.session().rawDevelop().value().settings);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
|
||||
QVERIFY(!app.session().importError());
|
||||
QStringList names;
|
||||
for (const ImageLayer &layer : app.session().document().value().layers)
|
||||
names << layer.name;
|
||||
// A layered Photoshop file arrives as a folder of layers.
|
||||
QCOMPARE(names, (QStringList{"Scene", "Scene", "Scene", "Red", "Mark", "Trip", "Sky", "Gray"}));
|
||||
const std::vector<ImageLayer> &layers = app.session().document().value().layers;
|
||||
QVERIFY(layers[5].isGroup);
|
||||
QCOMPARE(layers[6].parentID, std::optional<QUuid>(layers[5].id));
|
||||
app.shot(QStringLiteral("13-imports"));
|
||||
}
|
||||
|
||||
void AcceptanceProjectTests::projectsRecentScrubAndReload()
|
||||
{
|
||||
App app;
|
||||
photo(app);
|
||||
ProjectController &controller = app.workspace.current().controller;
|
||||
const QString project = app.path("Trip.comp");
|
||||
DialogDesk desk;
|
||||
desk.replies = {project};
|
||||
bool saved = false;
|
||||
controller.save(true, [&saved](bool ok) { saved = ok; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved, 30000);
|
||||
// A second save runs while editing goes on.
|
||||
saved = false;
|
||||
app.action("newBlankLayer").trigger();
|
||||
controller.save(false, [&saved](bool ok) { saved = ok; });
|
||||
app.action("newBlankLayer").trigger();
|
||||
QCOMPARE(int(app.session().document().value().layers.size()), 3);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved, 30000);
|
||||
QCOMPARE(int(ProjectStore::load(project).manifest.layers.size()), 2);
|
||||
// Open Recent lists the project.
|
||||
const QMenu *recent = app.action("openRecent").menu();
|
||||
QVERIFY(recent);
|
||||
QCOMPARE(recent->actions().first()->text(), QStringLiteral("Trip"));
|
||||
// Scrubbing the opacity title drags the value.
|
||||
app.session().selectLayer(app.session().document().value().layers.front().id);
|
||||
QLabel *opacity = nullptr;
|
||||
for (QLabel *label : app.window.findChildren<QLabel *>())
|
||||
if (label->text() == QStringLiteral("Opacity") && label->cursor().shape() == Qt::SizeHorCursor)
|
||||
opacity = label;
|
||||
QVERIFY(opacity);
|
||||
QTest::mousePress(opacity, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(-38, 2), opacity->mapToGlobal(QPointF(-38, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(opacity, &move);
|
||||
QTest::mouseRelease(opacity, Qt::LeftButton, Qt::NoModifier, QPoint(-38, 2));
|
||||
QVERIFY2(app.session().activeLayer().value().opacity < 0.9, qPrintable(QString::number(app.session().activeLayer().value().opacity)));
|
||||
app.shot(QStringLiteral("14-scrub"));
|
||||
// Another program rewrites the project; the app asks, then reverts.
|
||||
ProjectSnapshot outside = ProjectStore::load(project);
|
||||
outside.manifest.layers.front().name = QStringLiteral("Changed Outside");
|
||||
QVERIFY(app.session().isModified());
|
||||
const qsizetype asked = desk.seen.size();
|
||||
desk.replies = {QStringLiteral("Revert")};
|
||||
ProjectStore::save(outside, project);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(app.session().document().value().layers.front().name == QStringLiteral("Changed Outside"), 30000);
|
||||
QCOMPARE(desk.seen.size(), asked + 1);
|
||||
QCOMPARE(desk.seen.last(), QString("alert|2|“Trip.comp” was changed on disk.|Another app changed this project. You can revert to the version on "
|
||||
"disk, losing your unsaved changes, or keep what you have.|Revert,Keep Mine"));
|
||||
QVERIFY(desk.replies.isEmpty() && !app.session().isModified());
|
||||
QCOMPARE(int(app.session().document().value().layers.size()), 2);
|
||||
// The shortcuts window lists what the menus bind.
|
||||
app.action("keyboardShortcuts").trigger();
|
||||
app.shot(QStringLiteral("15-shortcuts"));
|
||||
}
|
||||
|
||||
void AcceptanceProjectTests::omarchyTheme()
|
||||
{
|
||||
App app;
|
||||
photo(app);
|
||||
QCOMPARE(app.window.palette().color(QPalette::Base), QColor("#1b1b1b"));
|
||||
// Omarchy's Catppuccin Latte, switched in while the app runs.
|
||||
QVERIFY(QDir().mkpath(app.path("omarchy/theme")));
|
||||
QFile colors(app.path("omarchy/theme/colors.toml"));
|
||||
QVERIFY(colors.open(QIODevice::WriteOnly));
|
||||
colors.write("mode = \"light\"\naccent = \"#1e66f5\"\nselection = \"#ccd0da\"\nmuted = \"#acb0be\"\nbackground = \"#eff1f5\"\n"
|
||||
"dark_background = \"#e3e4e8\"\ndarker_background = \"#d7d8dc\"\nlighter_background = \"#dce0e8\"\nforeground = \"#4c4f69\"\n"
|
||||
"dark_foreground = \"#9ca0b0\"\nlight_foreground = \"#5c5f77\"\nbright_foreground = \"#4c4f69\"\nred = \"#d20f39\"\n"
|
||||
"yellow = \"#df8e1d\"\norange = \"#d84e2b\"\ngreen = \"#40a02b\"\n");
|
||||
colors.close();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(app.window.palette().color(QPalette::Window), QColor("#eff1f5"), 10000);
|
||||
QCOMPARE(app.window.palette().color(QPalette::Highlight), QColor("#1e66f5"));
|
||||
app.shot(QStringLiteral("16-omarchy-light"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(AcceptanceProjectTests)
|
||||
#include "AcceptanceProjectTests.moc"
|
||||
@@ -0,0 +1,175 @@
|
||||
#include "AcceptanceFixtures.h"
|
||||
#include "Document/ColorPalette.h"
|
||||
#include "Document/Guides.h"
|
||||
#include "Document/ShapeTool.h"
|
||||
|
||||
// The acceptance run, part one: layers, selections, painting, arranging.
|
||||
class AcceptanceTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
|
||||
void layersFoldersMasksAndClipping();
|
||||
void selections();
|
||||
void paintingTools();
|
||||
void transformGuidesAndGrid();
|
||||
};
|
||||
|
||||
void AcceptanceTests::layersFoldersMasksAndClipping()
|
||||
{
|
||||
App app;
|
||||
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
|
||||
app.session().createDocument(1200, 800);
|
||||
app.importFile(app.path("photo.png"));
|
||||
const QUuid photo = app.session().activeLayerID().value();
|
||||
// A painted layer, clipped to the photo, multiplied at 60%.
|
||||
app.action("newBlankLayer").trigger();
|
||||
const QUuid paint = app.session().activeLayerID().value();
|
||||
QVERIFY(paint != photo);
|
||||
app.session().selectTool(NavigationTool::brush);
|
||||
app.session().setBrushSettings({.diameter = 120});
|
||||
app.session().setForegroundColor({1, 0.8, 0});
|
||||
app.drag(QPointF(100, 400), QPointF(1100, 400));
|
||||
app.settle();
|
||||
QCOMPARE(pixel(app.session(), paint, 600, 400), QColor(255, 204, 0));
|
||||
QCOMPARE(pixel(app.session(), paint, 600, 100).alpha(), 0);
|
||||
app.action("clippingMask").trigger();
|
||||
app.session().setLayerBlendMode(LayerBlendMode::multiply);
|
||||
app.session().setLayerOpacity(0.6);
|
||||
// A mask on the photo; both wrapped in a folder.
|
||||
app.session().selectLayer(photo);
|
||||
app.session().addLayerMask();
|
||||
app.session().selectLayers({photo, paint}, paint);
|
||||
app.action("groupLayers").trigger();
|
||||
const std::vector<ImageLayer> &layers = app.session().document().value().layers;
|
||||
QCOMPARE(int(layers.size()), 3);
|
||||
const auto find = [&layers](const std::function<bool(const ImageLayer &)> &match) {
|
||||
const auto found = std::find_if(layers.begin(), layers.end(), match);
|
||||
if (found == layers.end())
|
||||
throw std::runtime_error("no such layer");
|
||||
return *found;
|
||||
};
|
||||
const ImageLayer folder = find([](const ImageLayer &layer) { return layer.isGroup; });
|
||||
const ImageLayer photoLayer = find([photo](const ImageLayer &layer) { return layer.id == photo; });
|
||||
const ImageLayer paintLayer = find([paint](const ImageLayer &layer) { return layer.id == paint; });
|
||||
QVERIFY(photoLayer.mask);
|
||||
QCOMPARE(photoLayer.parentID, std::optional<QUuid>(folder.id));
|
||||
QCOMPARE(paintLayer.parentID, std::optional<QUuid>(folder.id));
|
||||
QCOMPARE(paintLayer.maskSourceID, std::optional<QUuid>(photo));
|
||||
QCOMPARE(paintLayer.blendMode, LayerBlendMode::multiply);
|
||||
QCOMPARE(paintLayer.opacity, 0.6);
|
||||
app.shot(QStringLiteral("01-layers"));
|
||||
// Undo walks the steps back one at a time.
|
||||
app.action("undo").trigger();
|
||||
QCOMPARE(int(app.session().document().value().layers.size()), 2);
|
||||
}
|
||||
|
||||
void AcceptanceTests::selections()
|
||||
{
|
||||
App app;
|
||||
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
|
||||
app.session().createDocument(1200, 800);
|
||||
app.importFile(app.path("photo.png"));
|
||||
// A marquee dragged over the canvas.
|
||||
app.session().selectTool(NavigationTool::marquee);
|
||||
app.drag(QPointF(100, 100), QPointF(500, 300));
|
||||
const QRectF marquee = app.session().selection().value().path.boundingRect();
|
||||
QVERIFY2(qAbs(marquee.left() - 100) <= 2 && qAbs(marquee.right() - 500) <= 2, qPrintable(QString::number(marquee.width())));
|
||||
// The wand takes the ground's flat green.
|
||||
app.session().selectTool(NavigationTool::wand);
|
||||
app.press(QPointF(600, 750));
|
||||
app.release(QPointF(600, 750));
|
||||
app.settle();
|
||||
const QRectF ground = app.session().selection().value().path.boundingRect();
|
||||
QVERIFY2(ground.top() >= 598 && ground.top() <= 602 && ground.width() >= 1198, qPrintable(QString::number(ground.top())));
|
||||
// Select Subject finds the red disc.
|
||||
app.action("subject").trigger();
|
||||
app.settle();
|
||||
const QRectF subject = app.session().selection().value().path.boundingRect();
|
||||
QVERIFY2(qAbs(subject.center().x() - 600) < 12 && qAbs(subject.width() - 400) < 40, qPrintable(QString::number(subject.width())));
|
||||
app.shot(QStringLiteral("02-selections"));
|
||||
app.action("deselect").trigger();
|
||||
QVERIFY(!app.session().selection());
|
||||
}
|
||||
|
||||
void AcceptanceTests::paintingTools()
|
||||
{
|
||||
App app;
|
||||
app.session().createDocument(1200, 800, true);
|
||||
const QUuid base = app.session().activeLayerID().value();
|
||||
// A gradient dragged across the empty layer.
|
||||
app.session().selectTool(NavigationTool::gradient);
|
||||
app.session().setForegroundColor({0.1, 0.2, 0.6});
|
||||
app.drag(QPointF(0, 400), QPointF(1200, 400));
|
||||
QTest::keyClick(&app.canvas(), Qt::Key_Return);
|
||||
app.settle();
|
||||
QCOMPARE(pixel(app.session(), base, 2, 400).blue(), 153);
|
||||
// The default style fades the foreground to clear.
|
||||
QVERIFY(qAbs(pixel(app.session(), base, 600, 400).alpha() - 128) <= 2);
|
||||
// A rectangle, then a brush stroke on the base.
|
||||
app.session().selectTool(NavigationTool::shape);
|
||||
app.session().setShapeKind(ShapeKind::rectangle);
|
||||
app.session().setForegroundColor({0.9, 0.3, 0.1});
|
||||
app.drag(QPointF(200, 200), QPointF(500, 500));
|
||||
const QUuid shape = app.session().activeLayerID().value();
|
||||
QVERIFY(shape != base);
|
||||
QVERIFY(app.session().activeLayer().value().liveShape());
|
||||
app.session().selectLayer(base);
|
||||
app.session().selectTool(NavigationTool::brush);
|
||||
app.session().setBrushSettings({.diameter = 60, .hardness = 0.5});
|
||||
app.session().setForegroundColor(PaletteColor::white());
|
||||
app.drag(QPointF(700, 200), QPointF(1000, 600));
|
||||
app.settle();
|
||||
QCOMPARE(pixel(app.session(), base, 850, 400), QColor(Qt::white));
|
||||
// Text typed on the canvas, its first word coloured.
|
||||
app.session().selectTool(NavigationTool::type);
|
||||
app.session().setForegroundColor(PaletteColor::black());
|
||||
app.press(QPointF(600, 650));
|
||||
app.release(QPointF(600, 650));
|
||||
QTRY_VERIFY(app.session().textDraft());
|
||||
QTest::keyClicks(&app.canvas(), QStringLiteral("Hello OmaPhoto"));
|
||||
QCOMPARE(app.session().textDraft().value().style.content, QStringLiteral("Hello OmaPhoto"));
|
||||
QTest::keyClick(&app.canvas(), Qt::Key_Home);
|
||||
for (int letter = 0; letter < 5; ++letter)
|
||||
QTest::keyClick(&app.canvas(), Qt::Key_Right, Qt::ShiftModifier);
|
||||
app.session().setPaletteColor({0.9, 0.1, 0.1}, false);
|
||||
app.shot(QStringLiteral("03-painting"));
|
||||
QTest::keyClick(&app.canvas(), Qt::Key_Return, Qt::ControlModifier);
|
||||
QTRY_VERIFY(!app.session().textDraft());
|
||||
const LayerText text = app.session().activeLayer().value().liveText().value();
|
||||
QCOMPARE(text.style.content, QStringLiteral("Hello OmaPhoto"));
|
||||
// One run: the first word red over black text.
|
||||
QCOMPARE(text.style.colorRuns.value(), (std::vector<LayerTextColorRun>{{.location = 0, .length = 5, .red = 0.9, .green = 0.1, .blue = 0.1}}));
|
||||
QCOMPARE(int(app.session().document().value().layers.size()), 3);
|
||||
}
|
||||
|
||||
void AcceptanceTests::transformGuidesAndGrid()
|
||||
{
|
||||
App app;
|
||||
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
|
||||
app.session().createDocument(1600, 1000);
|
||||
app.importFile(app.path("photo.png"));
|
||||
// The Move tool drags the layer; Transform scales it.
|
||||
app.session().selectTool(NavigationTool::move);
|
||||
const QPointF origin = app.session().activeLayer().value().transform.origin;
|
||||
app.drag(QPointF(800, 500), QPointF(900, 560));
|
||||
// View points round to whole pixels on a reduced canvas.
|
||||
const QPointF moved = app.session().activeLayer().value().transform.origin - origin;
|
||||
QVERIFY2(qAbs(moved.x() - 100) <= 3 && qAbs(moved.y() - 60) <= 3, qPrintable(QString::number(moved.x())));
|
||||
app.action("transformLayer").trigger();
|
||||
QVERIFY(app.session().transformEdit());
|
||||
LayerTransform half = app.session().activeLayer().value().transform;
|
||||
half.size = half.size / 2;
|
||||
app.session().previewTransform(half);
|
||||
QTest::keyClick(&app.canvas(), Qt::Key_Return);
|
||||
QCOMPARE(app.session().activeLayer().value().transform.size, QSizeF(600, 400));
|
||||
// Rulers, a guide and the grid.
|
||||
app.session().setShowsRulers(true);
|
||||
app.session().setShowsGrid(true);
|
||||
app.session().addGuide({.id = QUuid::createUuid(), .axis = CanvasGuide::Axis::horizontal, .position = 500});
|
||||
QCOMPARE(int(app.session().document().value().guides.size()), 1);
|
||||
app.session().selectTool(NavigationTool::move);
|
||||
app.shot(QStringLiteral("04-transform-guides-grid"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(AcceptanceTests)
|
||||
#include "AcceptanceTests.moc"
|
||||
@@ -42,6 +42,7 @@ class KeyboardShortcutsSheetTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void theWindowGoesWhileThePanelHoldsTheKeys();
|
||||
void cleanup();
|
||||
void aRecordedKeyRemapsItsMenuEntryOnSave();
|
||||
void recordingWaitsForARealKey();
|
||||
@@ -182,5 +183,16 @@ void KeyboardShortcutsSheetTests::cancelKeepsWhatWasSaved()
|
||||
QCOMPARE(ShortcutSettings::shared().overrides().size(), 1);
|
||||
}
|
||||
|
||||
// Hiding the panel moves the focus while the menus go.
|
||||
void KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys()
|
||||
{
|
||||
auto bar = std::make_unique<Bar>();
|
||||
QWidget &panel = opened(*bar);
|
||||
QVERIFY(QTest::qWaitForWindowActive(&panel));
|
||||
QTRY_COMPARE(QApplication::activeWindow(), &panel);
|
||||
bar.reset();
|
||||
QVERIFY(!shownPanel());
|
||||
}
|
||||
|
||||
QTEST_MAIN(KeyboardShortcutsSheetTests)
|
||||
#include "KeyboardShortcutsSheetTests.moc"
|
||||
@@ -176,6 +176,10 @@ compositor_test(TextEditingGatesTests)
|
||||
compositor_test(DitherTests)
|
||||
compositor_test(DitherSheetTests)
|
||||
compositor_test(DitherKernelTests)
|
||||
compositor_test(AcceptanceTests)
|
||||
compositor_test(AcceptanceColorTests)
|
||||
compositor_test(AcceptanceProjectTests)
|
||||
compositor_test(WindowTeardownTests)
|
||||
compositor_test(RecentProjectsCoreTests)
|
||||
compositor_test(ProjectWorkspaceSignalTests)
|
||||
compositor_test(ProjectWorkspaceTests)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "AcceptanceFixtures.h"
|
||||
#include "UI/ColorPickerSheet.h"
|
||||
|
||||
// The window goes while a panel's field holds typing.
|
||||
class WindowTeardownTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
|
||||
void theWindowGoesWhileAPanelFieldHoldsTyping_data();
|
||||
void theWindowGoesWhileAPanelFieldHoldsTyping();
|
||||
};
|
||||
|
||||
namespace {
|
||||
// The shown panel field to type into.
|
||||
PickerField &field(const QString &name)
|
||||
{
|
||||
for (QWidget *window : QApplication::topLevelWidgets())
|
||||
if (window->isVisible())
|
||||
for (PickerField *found : window->findChildren<PickerField *>())
|
||||
if (found->isVisible() && (name.isEmpty() || found->objectName() == name))
|
||||
return *found;
|
||||
throw std::runtime_error("no shown panel field");
|
||||
}
|
||||
}
|
||||
|
||||
void WindowTeardownTests::theWindowGoesWhileAPanelFieldHoldsTyping_data()
|
||||
{
|
||||
QTest::addColumn<QString>("entry");
|
||||
QTest::addColumn<QString>("name");
|
||||
QTest::newRow("colour picker") << QString() << QStringLiteral("r");
|
||||
for (const char *entry : {"levels", "hueSaturation", "gaussianBlur"})
|
||||
QTest::newRow(entry) << QString::fromLatin1(entry) << QString();
|
||||
}
|
||||
|
||||
void WindowTeardownTests::theWindowGoesWhileAPanelFieldHoldsTyping()
|
||||
{
|
||||
QFETCH(QString, entry);
|
||||
QFETCH(QString, name);
|
||||
auto app = std::make_unique<App>();
|
||||
QVERIFY(scene(120, 80).save(app->path("photo.png")));
|
||||
app->session().createDocument(120, 80);
|
||||
app->importFile(app->path("photo.png"));
|
||||
if (entry.isEmpty())
|
||||
app->session().openColorPicker(false);
|
||||
else
|
||||
app->action(entry.toLatin1().constData()).trigger();
|
||||
QTRY_VERIFY(QApplication::activeWindow() != &app->window);
|
||||
PickerField &typed = field(name);
|
||||
typed.setFocus();
|
||||
QTRY_VERIFY(typed.hasFocus());
|
||||
typed.selectAll();
|
||||
QTest::keyClicks(&typed, QStringLiteral("12"));
|
||||
QVERIFY(typed.isModified());
|
||||
// The panel commits as it hides, into a going window.
|
||||
app.reset();
|
||||
for (QWidget *window : QApplication::topLevelWidgets())
|
||||
QVERIFY(!window->isVisible());
|
||||
}
|
||||
|
||||
QTEST_MAIN(WindowTeardownTests)
|
||||
#include "WindowTeardownTests.moc"
|
||||