diff --git a/AGENTS.md b/AGENTS.md index bbc1af9..6246c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,7 @@ Platform adaptations, each forced by a missing Qt analog: - Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest). - Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write). - The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project. +- Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam). - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, 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. diff --git a/TASKS.md b/TASKS.md index 85ccbed..c538d52 100644 --- a/TASKS.md +++ b/TASKS.md @@ -389,7 +389,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed. - [x] 12.13b Keep editing while a project saves (`940e188`). Codex review: 3 rounds. Round 1: overlapping writes and a quit before a write finished, a queued save crashing after Undo took the document, a reopen reading a package mid-save, and test gaps. Round 2: an external reload reading mid-save, and test gaps. Round 3: a failed save stranding an external change already seen, and test gaps. All fixed; closed after the third. - [x] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. Codex review: 3 rounds. Round 1: thirteen claims Swift's pages make that the code does not keep (the same-size recipe, replacing a folder, required adjustment fields, folder pass-through, budgets, the save's fallback, text after resizes, fonts, masks, Alt-click) and weak example tests. Round 2: Hue/Saturation's `hsvSettings` precedence, the sampling list, PNG depths, a checked read. Round 3 passed. -- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. +- [x] 12.14a Version 1.3.1: quitting and closing while text is edited (`a18d86c`, `afd622e`), the Type tool's I-beam over the canvas (`4a4cf85`, `221d6cc`). Codex review: 3 rounds, all test gaps (quit order and refusal across tabs, the real window's close, content and history, existing and blank drafts, the cursor outside the canvas before and after leaving) and one false equivalence claim; fixed and closed after the third. +- [ ] 12.14b Version 1.3.2: File > Open Recent (`73dc0f4`), Image Size keeping print sizes through an invalid resolution (`555417e`), Camera Raw's Color Grading under Color (`7e9afbe`); `d62a132` (always dark) judged against the Omarchy theme. +- [ ] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). +- [ ] 12.14d Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`), sampling keeping a panel's focus (`810c88a`), filters waiting for text editing to finish (`d448625`). - [ ] 12.15 Version 1.3.3: the Dither filter. - [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun. - [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table. diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 0d0a4b7..6fe8a5f 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -210,9 +210,19 @@ std::vector<std::shared_ptr<ProjectTab>> ProjectWorkspace::quitOrder() const return order; } +// Swift's finishTextEditing: open text is applied before asking. +bool ProjectWorkspace::finishTextEditing() +{ + for (const std::shared_ptr<ProjectTab> &each : quitOrder()) { + if (each->session.textDraft() && !each->session.finishText()) + return false; + } + return true; +} + void ProjectWorkspace::confirmQuit(std::function<void(bool)> done) { - if (!canSwitch()) { + if (!finishTextEditing() || !canSwitch()) { finish(done, false); return; } diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index 34f104b..f0609c7 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -70,6 +70,7 @@ signals: void changed(); private: + bool finishTextEditing(); struct Baked { EditorSession::BakedImages images; std::optional<QString> failure; diff --git a/tests/InlineTextHandleTests.cpp b/tests/InlineTextHandleTests.cpp index c2174d9..b5b1b04 100644 --- a/tests/InlineTextHandleTests.cpp +++ b/tests/InlineTextHandleTests.cpp @@ -28,6 +28,7 @@ private slots: void aLostReleaseEndsAResize(); void anEditEndsAResize(); void aSquashedLayerTakesFarDrags(); + void theIBeamHoldsAcrossTheCanvas(); }; void InlineTextHandleTests::handlesFollowTheBoxEdges() @@ -262,5 +263,32 @@ void InlineTextHandleTests::aSquashedLayerTakesFarDrags() shown.release(inside + QPointF(0, 1'000'000)); } +// Swift 221d6cc: away from the box, the Type I-beam. +void InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas() +{ + TextCanvas shown; + shown.type(QStringLiteral("Hi")); + const QSizeF size = shown.editor().logicalSize(); + QCOMPARE(hovered(shown, at(shown, size.width(), size.height() / 2)), Qt::SizeHorCursor); + const QPointF corner(shown.canvas->width() - 3, shown.canvas->height() - 3); + QCOMPARE(hovered(shown, corner), Qt::IBeamCursor); + QCOMPARE(hovered(shown, QPointF(3, 3)), Qt::IBeamCursor); + // After a resize drag, the I-beam again, away from it. + const QPointF edge = at(shown, size.width(), size.height() / 2); + shown.drag(edge, edge + QPointF(30, 0)); + QCOMPARE(hovered(shown, corner), Qt::IBeamCursor); + // Nothing outside the canvas takes the I-beam: window or sibling. + QWidget sibling(&shown.window); + QVERIFY(!shown.window.testAttribute(Qt::WA_SetCursor)); + QCOMPARE(sibling.cursor().shape(), Qt::ArrowCursor); + // The canvas owns its cursor: leaving sets none elsewhere. + QEvent leave(QEvent::Leave); + QApplication::sendEvent(shown.canvas, &leave); + QVERIFY(!QGuiApplication::overrideCursor()); + QVERIFY(!shown.window.testAttribute(Qt::WA_SetCursor)); + QCOMPARE(sibling.cursor().shape(), Qt::ArrowCursor); + QCOMPARE(shown.canvas->cursor().shape(), Qt::IBeamCursor); +} + QTEST_MAIN(InlineTextHandleTests) #include "InlineTextHandleTests.moc" diff --git a/tests/QuitWhileEditingTextTests.cpp b/tests/QuitWhileEditingTextTests.cpp new file mode 100644 index 0000000..c23d18f --- /dev/null +++ b/tests/QuitWhileEditingTextTests.cpp @@ -0,0 +1,201 @@ +#include "DialogDesk.h" +#include "Document/ProjectWorkspace.h" +#include "UI/ProjectWorkspaceView.h" +#include <QtTest> + +// Swift 1.3.1: close or quit mid-text applies the text first. +namespace { +// A saved canvas with `content` typed under the Type tool. +void editText(EditorSession &session, const QString &content) +{ + session.createDocument(800, 600, true); + session.history.markSaved(); + session.selectTool(NavigationTool::type); + session.beginText(QPointF(100, 100)); + TextDraft draft = session.textDraft().value(); + draft.style.content = content; + session.setTextDraft(draft); +} + +// The callback's answer; empty if it never came. +std::optional<bool> answer(const std::function<void(std::function<void(bool)>)> &call) +{ + std::optional<bool> result; + call([&](bool value) { result = value; }); + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} + +QString topText(const EditorSession &session) +{ + return session.document().value().layers.back().text.value().style.content; +} +} + +class QuitWhileEditingTextTests : public QObject { + Q_OBJECT +private slots: + void closingTheWindowAppliesTheTextThenAsks(); + void quittingAppliesTextInQuitOrderAsOneStepEach(); + void aRefusedDraftStopsTheQuitWhereItStands(); + void textThatCannotBeAppliedHoldsTheQuit(); + void editedExistingTextIsOneEditStep(); + void unchangedAndBlankDraftsCloseQuietly(); +}; + +void QuitWhileEditingTextTests::closingTheWindowAppliesTheTextThenAsks() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView view(workspace); + view.show(); + const std::shared_ptr<ProjectTab> tab = workspace.tabs().front(); + editText(tab->session, QStringLiteral("Typed")); + DialogDesk desk; + desk.replies = {"Cancel", "Don’t Save"}; + // The text is a layer by the time it asks. + desk.note = [&] { return QStringLiteral("draft=%1 text=%2").arg(tab->session.textDraft().has_value()).arg(topText(tab->session)); }; + // Cancelled: the window stays, the text a step. + view.close(); + QTRY_COMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Untitled?") && desk.seen[0].endsWith("|draft=0 text=Typed"), qPrintable(desk.seen[0])); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(view.isVisible()); + QCOMPARE(tab->session.history.undoName(), QString("New Text Layer")); + QVERIFY(tab->session.isModified()); + // Asked again, Don't Save closes it. + view.close(); + QTRY_VERIFY(!view.isVisible()); + QCOMPARE(desk.seen.size(), 2); +} + +void QuitWhileEditingTextTests::quittingAppliesTextInQuitOrderAsOneStepEach() +{ + ProjectWorkspace workspace; + const std::shared_ptr<ProjectTab> first = workspace.tabs().front(); + workspace.newCanvas(); + const std::shared_ptr<ProjectTab> second = workspace.tabs().back(); + QVERIFY(first != second); + editText(first->session, QStringLiteral("First")); + editText(second->session, QStringLiteral("Second")); + // The tab on screen first; Cancel stops there. + DialogDesk desk; + desk.replies = {"Cancel"}; + desk.note = [&] { return workspace.current().session.document().value().layers.back().text.value().style.content; }; + QCOMPARE(answer([&](auto done) { workspace.confirmQuit(done); }), std::optional<bool>(false)); + QCOMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].endsWith("|Second"), qPrintable(desk.seen[0])); + for (const auto &[tab, text] : {std::pair(first, QStringLiteral("First")), std::pair(second, QStringLiteral("Second"))}) { + QVERIFY(!tab->session.textDraft()); + QCOMPARE(topText(tab->session), text); + QCOMPARE(tab->session.history.undoName(), QString("New Text Layer")); + QVERIFY(tab->session.isModified()); + // One step: undo removes the text, redo restores it. + const size_t layers = tab->session.document().value().layers.size(); + tab->session.undo(); + QCOMPARE(tab->session.document().value().layers.size(), layers - 1); + QVERIFY(!tab->session.isModified()); + tab->session.redo(); + QCOMPARE(topText(tab->session), text); + } +} + +void QuitWhileEditingTextTests::aRefusedDraftStopsTheQuitWhereItStands() +{ + ProjectWorkspace workspace; + const std::shared_ptr<ProjectTab> a = workspace.tabs().front(); + workspace.newCanvas(); + const std::shared_ptr<ProjectTab> b = workspace.tabs().back(); + workspace.newCanvas(); + const std::shared_ptr<ProjectTab> c = workspace.tabs().back(); + // Stored A, B, C; C shown: asked C, A, B. + QCOMPARE(&workspace.current(), c.get()); + editText(a->session, QStringLiteral("A")); + editText(b->session, QStringLiteral("B")); + editText(c->session, QStringLiteral("C")); + a->session.setIsProjectBusy(true); + DialogDesk desk; + QCOMPARE(answer([&](auto done) { workspace.confirmQuit(done); }), std::optional<bool>(false)); + // C applied; A refused; B never reached; nothing asked. + QVERIFY(!c->session.textDraft()); + QCOMPARE(topText(c->session), QString("C")); + QCOMPARE(a->session.textDraft().value().style.content, QString("A")); + QCOMPARE(b->session.textDraft().value().style.content, QString("B")); + QVERIFY(!b->session.isModified()); + QVERIFY(desk.seen.isEmpty()); + QVERIFY(!workspace.isManaging()); + a->session.setIsProjectBusy(false); +} + +void QuitWhileEditingTextTests::textThatCannotBeAppliedHoldsTheQuit() +{ + ProjectWorkspace workspace; + const std::shared_ptr<ProjectTab> tab = workspace.tabs().front(); + editText(tab->session, QStringLiteral("Held")); + // A busy project refuses the text; the quit stops there. + tab->session.setIsProjectBusy(true); + DialogDesk desk; + QCOMPARE(answer([&](auto done) { workspace.confirmQuit(done); }), std::optional<bool>(false)); + QVERIFY(desk.seen.isEmpty()); + QCOMPARE(tab->session.textDraft().value().style.content, QString("Held")); + QVERIFY(!workspace.isManaging()); + tab->session.setIsProjectBusy(false); +} + +void QuitWhileEditingTextTests::editedExistingTextIsOneEditStep() +{ + ProjectWorkspace workspace; + const std::shared_ptr<ProjectTab> tab = workspace.tabs().front(); + EditorSession &session = tab->session; + editText(session, QStringLiteral("Original")); + QVERIFY(session.finishText()); + session.history.markSaved(); + const QUuid layer = session.document().value().layers.back().id; + const int steps = session.history.undoCount(); + // The layer's text opened again and changed. + session.editActiveText(); + TextDraft draft = session.textDraft().value(); + QCOMPARE(draft.layerID.value(), layer); + draft.style.content = QStringLiteral("Edited"); + session.setTextDraft(draft); + DialogDesk desk; + desk.replies = {"Cancel"}; + QCOMPARE(answer([&](auto done) { workspace.confirmQuit(done); }), std::optional<bool>(false)); + QCOMPARE(desk.seen.size(), 1); + QCOMPARE(session.document().value().layers.back().id, layer); + QCOMPARE(topText(session), QString("Edited")); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), QString("Edit Text")); + session.undo(); + QCOMPARE(topText(session), QString("Original")); + QVERIFY(!session.isModified()); + session.redo(); + QCOMPARE(topText(session), QString("Edited")); +} + +void QuitWhileEditingTextTests::unchangedAndBlankDraftsCloseQuietly() +{ + ProjectWorkspace workspace; + const std::shared_ptr<ProjectTab> first = workspace.tabs().front(); + workspace.newCanvas(); + const std::shared_ptr<ProjectTab> second = workspace.tabs().back(); + // First: saved text opened and left as it was. + editText(first->session, QStringLiteral("Kept")); + QVERIFY(first->session.finishText()); + first->session.history.markSaved(); + first->session.editActiveText(); + QVERIFY(first->session.textDraft().has_value()); + // Second: new text with nothing typed. + editText(second->session, QString()); + const size_t layers = second->session.document().value().layers.size(); + const int firstSteps = first->session.history.undoCount(), secondSteps = second->session.history.undoCount(); + DialogDesk desk; + QCOMPARE(answer([&](auto done) { workspace.confirmQuit(done); }), std::optional<bool>(true)); + QVERIFY(desk.seen.isEmpty()); + QVERIFY(!first->session.textDraft() && !second->session.textDraft()); + QCOMPARE(first->session.history.undoCount(), firstSteps); + QCOMPARE(second->session.history.undoCount(), secondSteps); + QCOMPARE(second->session.document().value().layers.size(), layers); + QCOMPARE(topText(first->session), QString("Kept")); +} + +QTEST_MAIN(QuitWhileEditingTextTests) +#include "QuitWhileEditingTextTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index e9bfd92..0613eea 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -165,6 +165,7 @@ compositor_test(ProjectOpenTests) compositor_test(ProjectReceiveTests) compositor_test(ProjectReplaceTests) compositor_test(ProjectWorkspaceFlowTests) +compositor_test(QuitWhileEditingTextTests) compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests) compositor_test(ProjectWorkspaceViewTests)