diff --git a/AGENTS.md b/AGENTS.md index 6246c94..304e9f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Platform adaptations, each forced by a missing Qt analog: - Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`. - Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files. - Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too. -- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). +- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no security scopes; recent projects are 12.14b's. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it). - `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayers` (since 12.10b; `copyLayer` hands it one id, and a drop still copies row by row, as Swift's) bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor is the first layer's centre, or with several the centre of the box round every copied layer that is no folder; the `?? centre` fallback is dead (the first id is always among the copies) and is a checked read instead. The copies end up selected, the first active; an id the source tab lacks (two tabs of one project share ids, and the first holding the first id is the source) is left out of the selection, as Swift's `compactMap`. - `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own. - `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`. @@ -149,6 +149,7 @@ Platform adaptations, each forced by a missing Qt analog: - 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). +- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`. - 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/CMakeLists.txt b/CMakeLists.txt index 209c43f..0e1514a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -171,6 +171,7 @@ add_library(compositor_core STATIC src/IO/ProjectDigest.cpp src/IO/ProjectStore.cpp src/IO/ProjectWatcher.cpp + src/IO/RecentProjects.cpp src/IO/ProjectStore+Adjustment.cpp src/IO/ProjectStore+Effects.cpp src/IO/ProjectStore+Manifest.cpp diff --git a/TASKS.md b/TASKS.md index c538d52..0c3795f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -390,7 +390,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [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. - [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. +- [x] 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 (holds: alerts follow the app's palette, which follows Omarchy by design). Codex review: 3 rounds; two display defects (a name's ampersand in the menu, a filename read as markup in the alert), two stale test expectations and one false equivalence claim in round 1, the rest test gaps (eye visibility and toggles by group, note boundaries, remembered resolution across units, ranges and resampling, the submenu's place and every gate, the list across processes); fixed and closed after the third. - [ ] 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. diff --git a/src/IO/ProjectController.cpp b/src/IO/ProjectController.cpp index 4ba08a3..be27a4f 100644 --- a/src/IO/ProjectController.cpp +++ b/src/IO/ProjectController.cpp @@ -1,10 +1,12 @@ #include "IO/ProjectController.h" +#include "IO/RecentProjects.h" #include "Document/ProjectWorkspace.h" #include "IO/ProjectStore.h" #include "Logging.h" #include <QFileDialog> #include <QFileInfo> #include <QFutureWatcher> +#include <QLabel> #include <QMessageBox> #include <QPushButton> #include <QTimer> @@ -187,6 +189,7 @@ void ProjectController::write(const Prepared &prepared, std::function<void(bool) session.history.markSaved(revision); session.setProjectPath(destination); m_saveGeneration += 1; + RecentProjects::shared().note(destination); rememberProjectDigest(destination, [this, destination, landed] { watchProject(destination); externalChanges.saving = false; @@ -241,6 +244,12 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo // A folder's trailing slash would leave it without a name. while (source.endsWith(QLatin1Char('/'))) source.chop(1); + // A listed project deleted since: name it, not its manifest. + if (!QFileInfo::exists(source)) { + RecentProjects::shared().refresh(); + refuse(QStringLiteral("The file “%1” couldn’t be opened because there is no such file.").arg(QFileInfo(source).fileName())); + return; + } // A write in flight lands first: no read mid-save. finishWriting([this, end, refuse, source] { // Validated first: a corrupt project never discards the live document. @@ -258,6 +267,7 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo // Saving in the confirmation can replace the opened file. if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) { session.installProject(*first.snapshot, source); + RecentProjects::shared().note(source); rememberProjectDigest(source, [this, source, end] { watchProject(source); end(true); @@ -270,6 +280,7 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo return; } session.installProject(*again.snapshot, source); + RecentProjects::shared().note(source); rememberProjectDigest(source, [this, source, end] { watchProject(source); end(true); @@ -380,6 +391,11 @@ void ProjectController::showError(const QString &title, const QString &message, alert->setIcon(QMessageBox::Warning); alert->setText(title); alert->setInformativeText(message); + // A file's name is read literally, never as markup. + auto *words = alert->findChild<QLabel *>(QStringLiteral("qt_msgbox_informativelabel")); + if (!words) + throw std::logic_error("the alert has no informative label"); + words->setTextFormat(Qt::PlainText); alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole); connect(alert, &QDialog::finished, this, std::move(then)); alert->open(); diff --git a/src/IO/RecentProjects.cpp b/src/IO/RecentProjects.cpp new file mode 100644 index 0000000..62b61ed --- /dev/null +++ b/src/IO/RecentProjects.cpp @@ -0,0 +1,66 @@ +#include "IO/RecentProjects.h" +#include <QFileInfo> +#include <QGuiApplication> +#include <QSettings> + +namespace { +const QString key = QStringLiteral("recentProjects"); + +// One place however it was reached, as Finder keeps it. +QString resolved(const QString &path) +{ + const QString canonical = QFileInfo(path).canonicalFilePath(); + return canonical.isEmpty() ? QFileInfo(path).absoluteFilePath() : canonical; +} +} + +RecentProjects &RecentProjects::shared() +{ + static RecentProjects recent; + return recent; +} + +RecentProjects::RecentProjects() +{ + refresh(); + // Checked again on activation; a GUI-less run has none. + if (auto *app = qobject_cast<QGuiApplication *>(QCoreApplication::instance())) { + connect(app, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + if (state == Qt::ApplicationActive) + refresh(); + }); + } +} + +void RecentProjects::note(const QString &path) +{ + const QString place = resolved(path); + QStringList stored = QSettings().value(key).toStringList(); + stored.removeAll(place); + stored.prepend(place); + store(stored.mid(0, limit)); +} + +void RecentProjects::clear() +{ + store({}); +} + +void RecentProjects::refresh() +{ + QStringList existing; + for (const QString &path : QSettings().value(key).toStringList()) { + if (QFileInfo::exists(path)) + existing << path; + } + if (existing == m_paths) + return; + m_paths = existing; + emit changed(); +} + +void RecentProjects::store(const QStringList &paths) +{ + QSettings().setValue(key, paths); + refresh(); +} diff --git a/src/IO/RecentProjects.h b/src/IO/RecentProjects.h new file mode 100644 index 0000000..5e827eb --- /dev/null +++ b/src/IO/RecentProjects.h @@ -0,0 +1,28 @@ +#pragma once +#include <QObject> +#include <QStringList> + +// Swift's RecentProjects: newest first, kept in QSettings. +class RecentProjects : public QObject { + Q_OBJECT +public: + static RecentProjects &shared(); + // At most this many, as macOS keeps by default. + static constexpr int limit = 10; + + // The projects that still exist, newest first. + const QStringList &paths() const { return m_paths; } + void note(const QString &path); + void clear(); + // Drops the projects since moved or deleted. + void refresh(); + +signals: + void changed(); + +private: + RecentProjects(); + void store(const QStringList &paths); + + QStringList m_paths; +}; diff --git a/src/UI/CameraRawControls.cpp b/src/UI/CameraRawControls.cpp index a4b0e04..c90f041 100644 --- a/src/UI/CameraRawControls.cpp +++ b/src/UI/CameraRawControls.cpp @@ -21,9 +21,9 @@ namespace { QString title(int section) { - static const std::array<QString, 10> titles{QStringLiteral("Light"), QStringLiteral("Color"), QStringLiteral("Effects"), - QStringLiteral("Curve"), QStringLiteral("Color Mixer"), QStringLiteral("Color Grading"), - QStringLiteral("Detail"), QStringLiteral("Optics"), QStringLiteral("Geometry"), + static const std::array<QString, 10> titles{QStringLiteral("Light"), QStringLiteral("Color"), QStringLiteral("Color Grading"), + QStringLiteral("Effects"), QStringLiteral("Curve"), QStringLiteral("Color Mixer"), + QStringLiteral("Detail"), QStringLiteral("Optics"), QStringLiteral("Geometry"), QStringLiteral("Calibration")}; return titles.at(size_t(section)); } @@ -31,8 +31,8 @@ QString title(int section) // The group's eye in CameraRawGroups, by section. bool &shows(CameraRawGroups &groups, int section) { - const std::array fields{&CameraRawGroups::light, &CameraRawGroups::color, &CameraRawGroups::effects, &CameraRawGroups::curve, - &CameraRawGroups::mixer, &CameraRawGroups::grading, &CameraRawGroups::detail, &CameraRawGroups::optics, + const std::array fields{&CameraRawGroups::light, &CameraRawGroups::color, &CameraRawGroups::grading, &CameraRawGroups::effects, + &CameraRawGroups::curve, &CameraRawGroups::mixer, &CameraRawGroups::detail, &CameraRawGroups::optics, &CameraRawGroups::geometry, &CameraRawGroups::calibration}; return groups.*fields.at(size_t(section)); } @@ -42,10 +42,10 @@ bool adjusts(const CameraRawSettings &raw, int section) switch (section) { case 0: return raw.adjustsLight(); case 1: return raw.adjustsColor(); - case 2: return raw.adjustsEffects(); - case 3: return raw.curve.adjusts(); - case 4: return raw.mixer.adjusts(); - case 5: return raw.grading.adjusts(); + case 2: return raw.grading.adjusts(); + case 3: return raw.adjustsEffects(); + case 4: return raw.curve.adjusts(); + case 5: return raw.mixer.adjusts(); case 6: return raw.detail.adjusts(); case 7: return raw.optics.adjusts(); case 8: return raw.geometry.adjusts(); @@ -194,10 +194,10 @@ CameraRawControls::CameraRawControls(EditorSession &session, QWidget *parent) groups->setSpacing(12); light(group(Section::light, groups)); color(group(Section::color, groups)); + group(Section::colorGrading, groups)->addWidget(new CameraRawGradingControls(session)); effects(group(Section::effects, groups)); group(Section::curve, groups)->addWidget(new CameraRawCurveControls(session)); group(Section::colorMixer, groups)->addWidget(new CameraRawMixerControls(session)); - group(Section::colorGrading, groups)->addWidget(new CameraRawGradingControls(session)); group(Section::detail, groups)->addWidget(new CameraRawDetailControls(session)); group(Section::optics, groups)->addWidget(new CameraRawOpticsControls(session)); group(Section::geometry, groups)->addWidget(new CameraRawGeometryControls(session)); @@ -246,8 +246,8 @@ QVBoxLayout *CameraRawControls::group(Section section, QVBoxLayout *column) auto *rows = new QVBoxLayout(body); rows->setContentsMargins(18, 0, 0, 0); rows->setSpacing(8); - // Light and Color open first, Swift's `expanded`. - body->setVisible(section == Section::light || section == Section::color); + // Light, Color and Color Grading open first, Swift's `expanded`. + body->setVisible(section == Section::light || section == Section::color || section == Section::colorGrading); connect(disclosure, &QToolButton::clicked, this, [this, body] { body->setVisible(body->isHidden()); synchronize(); diff --git a/src/UI/CameraRawControls.h b/src/UI/CameraRawControls.h index 7c6133d..7145f7f 100644 --- a/src/UI/CameraRawControls.h +++ b/src/UI/CameraRawControls.h @@ -23,7 +23,7 @@ protected: bool eventFilter(QObject *watched, QEvent *event) override; private: - enum class Section { light, color, effects, curve, colorMixer, colorGrading, detail, optics, geometry, calibration }; + enum class Section { light, color, colorGrading, effects, curve, colorMixer, detail, optics, geometry, calibration }; struct Group { Section section; QToolButton *disclosure; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index ba4816d..8e7696b 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -1,4 +1,5 @@ #include "UI/CompositorMenus.h" +#include "IO/RecentProjects.h" #include "Logging.h" #include "UI/KeyboardShortcuts.h" #include "UI/NativeLayerList.h" @@ -37,6 +38,10 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi QMenu *file = bar.addMenu(QStringLiteral("&File")); add(file, QStringLiteral("newCanvas"), QStringLiteral("New Canvas…"), QKeySequence(Qt::CTRL | Qt::Key_N), [this] { projects().newCanvas(); }); add(file, QStringLiteral("openProject"), QStringLiteral("Open Project…"), QKeySequence(Qt::CTRL | Qt::Key_O), [this] { projects().open(); }); + QMenu *recent = file->addMenu(QStringLiteral("Open Recent")); + recent->menuAction()->setObjectName(QStringLiteral("openRecent")); + listRecent(recent); + connect(&RecentProjects::shared(), &RecentProjects::changed, this, [this, recent] { listRecent(recent); }); add(file, QStringLiteral("importImages"), QStringLiteral("Import Images…"), QKeySequence(), [this] { session().setShowsImporter(true); }); file->addSeparator(); add(file, QStringLiteral("save"), QStringLiteral("Save"), QKeySequence(Qt::CTRL | Qt::Key_S), [this] { projects().save(); }); @@ -272,6 +277,19 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi watchFront(); } +void CompositorMenus::listRecent(QMenu *recent) +{ + recent->clear(); + for (const QString &path : RecentProjects::shared().paths()) + // A name's ampersand is literal, no mnemonic. + connect(recent->addAction(ProjectTab::nameWithoutSuffix(path).replace(QLatin1Char('&'), QStringLiteral("&&"))), &QAction::triggered, this, [this, path] { projects().open(path); }); + recent->addSeparator(); + QAction *clear = recent->addAction(QStringLiteral("Clear Menu")); + clear->setObjectName(QStringLiteral("clearRecent")); + clear->setEnabled(!RecentProjects::shared().paths().isEmpty()); + connect(clear, &QAction::triggered, this, [] { RecentProjects::shared().clear(); }); +} + QAction *CompositorMenus::add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run) { QAction *made = menu->addAction(text); @@ -327,6 +345,7 @@ void CompositorMenus::synchronize() const bool typing = m_field != nullptr || s.textDraft().has_value(); action(QStringLiteral("newCanvas"))->setEnabled(p.canStart()); action(QStringLiteral("openProject"))->setEnabled(p.canStart()); + action(QStringLiteral("openRecent"))->setEnabled(p.canStart()); action(QStringLiteral("importImages"))->setEnabled(!s.levels() && !s.showsBusy() && !s.isImporting() && !s.showsNewDocument()); action(QStringLiteral("save"))->setEnabled(drawn && p.canStart()); action(QStringLiteral("saveAs"))->setEnabled(drawn && p.canStart()); diff --git a/src/UI/CompositorMenus.h b/src/UI/CompositorMenus.h index 99864e6..18027c3 100644 --- a/src/UI/CompositorMenus.h +++ b/src/UI/CompositorMenus.h @@ -23,6 +23,7 @@ private: void watchFront(); EditorSession &session() const { return m_workspace.current().session; } ProjectController &projects() const { return m_workspace.current().controller; } + void listRecent(QMenu *recent); QAction *add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run); // Each entry's own key, remapped from ShortcutSettings. diff --git a/src/UI/ImageSizeSheet.cpp b/src/UI/ImageSizeSheet.cpp index 4052085..10c9137 100644 --- a/src/UI/ImageSizeSheet.cpp +++ b/src/UI/ImageSizeSheet.cpp @@ -49,7 +49,7 @@ bool physical(const QString &unit) ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent) : QWidget(parent), m_originalWidth(document.width), m_originalHeight(document.height), m_finish(std::move(finish)), - m_width(double(document.width)), m_height(double(document.height)), m_resolution(document.resolution), m_units(new QComboBox(this)), + m_width(double(document.width)), m_height(double(document.height)), m_resolution(document.resolution), m_lastResolution(document.resolution), m_units(new QComboBox(this)), m_widthField(dimension(true)), m_heightField(dimension(false)), m_lock(new QCheckBox(QStringLiteral("Lock aspect ratio"), this)), m_resolutionField(new PickerField([this] { bool number = false; @@ -204,10 +204,11 @@ PickerField *ImageSizeSheet::dimension(bool isWidth) // Swift's binding: pixels, or without resampling the resolution. void ImageSizeSheet::setDimension(double value, bool isWidth) { - if (!(std::isfinite(value) && value > 0)) + // A print size means nothing without a usable resolution. + if (!(std::isfinite(value) && value > 0) || !canScrubDimensions()) return; if (!m_resample) { - m_resolution = (isWidth ? m_width : m_height) / value * (m_unit == QLatin1String("Centimeters") ? 2.54 : 1); + setResolution((isWidth ? m_width : m_height) / value * (m_unit == QLatin1String("Centimeters") ? 2.54 : 1)); return; } double pixels = value; @@ -230,11 +231,15 @@ void ImageSizeSheet::setDimension(double value, bool isWidth) void ImageSizeSheet::setResolution(double value) { - const double old = std::exchange(m_resolution, value); - if (m_resample && physical(m_unit) && old > 0 && value > 0 && std::isfinite(value)) { - m_width *= value / old; - m_height *= value / old; + m_resolution = value; + if (!(std::isfinite(value) && value > 0)) + return; + // Scaled from the last usable one, past any invalid entry. + if (m_resample && physical(m_unit)) { + m_width *= value / m_lastResolution; + m_height *= value / m_lastResolution; } + m_lastResolution = value; } NumericScrub *ImageSizeSheet::scrub(QLabel *title, bool isWidth) diff --git a/src/UI/ImageSizeSheet.h b/src/UI/ImageSizeSheet.h index 923c443..5fbb0aa 100644 --- a/src/UI/ImageSizeSheet.h +++ b/src/UI/ImageSizeSheet.h @@ -39,6 +39,7 @@ private: double m_width; double m_height; double m_resolution; + double m_lastResolution; bool m_locked = true; bool m_resample = true; QString m_unit = QStringLiteral("Pixels"); diff --git a/tests/CameraRawPanelTests.cpp b/tests/CameraRawPanelTests.cpp index fa01f7c..120b487 100644 --- a/tests/CameraRawPanelTests.cpp +++ b/tests/CameraRawPanelTests.cpp @@ -104,6 +104,7 @@ private slots: void aTypedNumberAppliesAndIsHeldInRange(); void altOnALightSliderShowsItsClippingUntilLetGo(); void eachGroupOpensAndItsEyeHidesItFromThePreview(); + void eachEyeHidesItsOwnGroup(); void theScopeSwitchesAndItsTrianglesShowClipping(); void theReadoutNamesThePixel(); void whiteBalanceAutoCustomAndTheEyedropper(); @@ -214,11 +215,18 @@ void CameraRawPanelTests::altOnALightSliderShowsItsClippingUntilLetGo() void CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview() { Panel panel; - const QStringList groups{"Light", "Color", "Effects", "Curve", "ColorMixer", "ColorGrading", "Detail", "Optics", "Geometry", "Calibration"}; + // Swift's 7e9afbe: Color Grading under Color, open. + const QStringList groups{"Light", "Color", "ColorGrading", "Effects", "Curve", "ColorMixer", "Detail", "Optics", "Geometry", "Calibration"}; + int above = -1; + for (const QString &group : groups) { + const int top = panel.child<QToolButton>(group + QStringLiteral("Section")).mapTo(panel.controls.get(), QPoint()).y(); + QVERIFY2(top > above, qPrintable(group)); + above = top; + } for (const QString &group : groups) { auto &disclosure = panel.child<QToolButton>(group + QStringLiteral("Section")); auto *body = disclosure.parentWidget()->findChildren<QWidget *>(QString(), Qt::FindDirectChildrenOnly).last(); - const bool open = group == QLatin1String("Light") || group == QLatin1String("Color"); + const bool open = group == QLatin1String("Light") || group == QLatin1String("Color") || group == QLatin1String("ColorGrading"); QCOMPARE(body->isVisible(), open); disclosure.click(); QCOMPARE(body->isVisible(), !open); @@ -238,6 +246,69 @@ void CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview() QVERIFY(panel.panel().shows.light); } +namespace { +// Undoes the eye test's one adjustment to that group. +void reset(CameraRawSettings &raw, const QString &eye) +{ + if (eye == QLatin1String("ColorGradingEye")) + raw.grading.shadows.saturation = 0; + else if (eye == QLatin1String("EffectsEye")) + raw.clarity = 0; + else if (eye == QLatin1String("CurveEye")) + raw.curve.shadows = 0; + else + raw.mixer.hue[0] = 0; +} +} + +void CameraRawPanelTests::eachEyeHidesItsOwnGroup() +{ + Panel panel; + FilterSettings adjusted = panel.session.filterEdit().value().settings; + adjusted.cameraRaw.grading.shadows.saturation = 20; + adjusted.cameraRaw.clarity = 30; + adjusted.cameraRaw.curve.shadows = 20; + adjusted.cameraRaw.mixer.hue[0] = 20; + panel.session.updateFilter(adjusted, true); + using Adjusts = bool (*)(const CameraRawSettings &); + const std::array<std::tuple<QString, bool CameraRawGroups::*, Adjusts>, 4> eyes{{ + {QStringLiteral("ColorGradingEye"), &CameraRawGroups::grading, [](const CameraRawSettings &raw) { return raw.grading.adjusts(); }}, + {QStringLiteral("EffectsEye"), &CameraRawGroups::effects, [](const CameraRawSettings &raw) { return raw.adjustsEffects(); }}, + {QStringLiteral("CurveEye"), &CameraRawGroups::curve, [](const CameraRawSettings &raw) { return raw.curve.adjusts(); }}, + {QStringLiteral("ColorMixerEye"), &CameraRawGroups::mixer, [](const CameraRawSettings &raw) { return raw.mixer.adjusts(); }}, + }}; + // Alone, each adjustment shows its own eye only. + for (const auto &[name, group, adjusts] : eyes) { + FilterSettings alone = adjusted; + for (const auto &[other, otherGroup, otherAdjusts] : eyes) { + if (other != name) + reset(alone.cameraRaw, other); + } + panel.session.updateFilter(alone, true); + for (const auto &[other, otherGroup, otherAdjusts] : eyes) + QVERIFY2(panel.child<QToolButton>(other).isVisible() == (other == name), qPrintable(name + " " + other)); + reset(alone.cameraRaw, name); + panel.session.updateFilter(alone, true); + QVERIFY2(!panel.child<QToolButton>(name).isVisible(), qPrintable(name)); + } + panel.session.updateFilter(adjusted, true); + for (const auto &[name, group, adjusts] : eyes) { + auto &eye = panel.child<QToolButton>(name); + QVERIFY2(eye.isVisible(), qPrintable(name)); + eye.click(); + // That group alone leaves the preview. + CameraRawGroups expected; + expected.*group = false; + QVERIFY2(panel.panel().shows == expected, qPrintable(name)); + const CameraRawSettings previewed = panel.session.filterEdit().value().previewJob().settings.cameraRaw; + for (const auto &[other, otherGroup, otherAdjusts] : eyes) + QVERIFY2(otherAdjusts(previewed) == (other != name), qPrintable(name + " " + other)); + eye.click(); + QVERIFY2(panel.panel().shows == CameraRawGroups(), qPrintable(name)); + QVERIFY(adjusts(panel.session.filterEdit().value().previewJob().settings.cameraRaw)); + } +} + void CameraRawPanelTests::theScopeSwitchesAndItsTrianglesShowClipping() { Panel panel; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 0806e4c..ce4677d 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -50,7 +50,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() for (const QMenu *menu : menus) titles << menu->title(); QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"})); - QCOMPARE(menus[0]->actions().size(), 11); + QCOMPARE(menus[0]->actions().size(), 12); QCOMPARE(menus[1]->actions().size(), 13); QCOMPARE(menus[2]->actions().size(), 16); QCOMPARE(menus[3]->actions().size(), 10); @@ -76,7 +76,8 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate() { QTemporaryDir folder; Bar bar; - QVERIFY(bar.action("newCanvas").isEnabled() && bar.action("openProject").isEnabled() && bar.action("closeProject").isEnabled()); + QVERIFY(bar.action("newCanvas").isEnabled() && bar.action("openProject").isEnabled() && bar.action("openRecent").isEnabled() + && bar.action("closeProject").isEnabled()); QVERIFY(bar.action("importImages").isEnabled()); // Nothing to save without a canvas. QVERIFY(!bar.action("save").isEnabled() && !bar.action("saveAs").isEnabled()); @@ -84,26 +85,30 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate() QVERIFY(bar.action("save").isEnabled() && bar.action("saveAs").isEnabled()); // Busy holds the file entries; a sheet holds Import. bar.session().setIsProjectBusy(true); - QVERIFY(!bar.action("newCanvas").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("save").isEnabled() + QVERIFY(!bar.action("newCanvas").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("openRecent").isEnabled() + && !bar.action("save").isEnabled() && !bar.action("saveAs").isEnabled() && !bar.action("closeProject").isEnabled()); QVERIFY(bar.action("importImages").isEnabled()); QTRY_VERIFY(!bar.action("importImages").isEnabled()); bar.session().setIsProjectBusy(false); bar.session().setShowsNewDocument(true); - QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("newCanvas").isEnabled()); + QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("newCanvas").isEnabled() && !bar.action("openRecent").isEnabled()); bar.session().setShowsNewDocument(false); + QVERIFY(bar.action("openRecent").isEnabled()); // Importing alone holds Import as well. bar.session().setIsImporting(true); - QVERIFY(!bar.action("importImages").isEnabled()); + QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("openRecent").isEnabled()); bar.session().setIsImporting(false); - QVERIFY(bar.action("importImages").isEnabled()); + QVERIFY(bar.action("importImages").isEnabled() && bar.action("openRecent").isEnabled()); // The entries do what the toolbar and the controller do. bar.action("newCanvas").trigger(); QCOMPARE(int(bar.workspace.tabs().size()), 2); bar.workspace.select(bar.workspace.tabs()[0]->id); bar.action("importImages").trigger(); QVERIFY(bar.session().showsImporter()); + QVERIFY(!bar.action("openRecent").isEnabled()); bar.session().setShowsImporter(false); + QVERIFY(bar.action("openRecent").isEnabled()); DialogDesk desk; desk.replies = {folder.filePath("Saved.comp")}; bar.action("save").trigger(); @@ -119,8 +124,14 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate() QTRY_COMPARE(bar.session().projectPath(), std::optional(folder.filePath("Copy.comp"))); QCOMPARE(desk.seen.size(), 2); desk.replies = {"<cancel>"}; + // The workspace's panel holds Open Recent until answered. + desk.note = [&] { return QStringLiteral("managing=%1 recent=%2").arg(bar.workspace.isManaging()).arg(bar.action("openRecent").isEnabled()); }; bar.action("openProject").trigger(); QTRY_COMPARE(desk.seen.size(), 3); + QVERIFY2(desk.seen[2].endsWith("|managing=1 recent=0"), qPrintable(desk.seen[2])); + desk.note = nullptr; + QTRY_VERIFY(!bar.workspace.isManaging()); + QVERIFY(bar.action("openRecent").isEnabled()); // Close Project closes this tab, not the window. bar.window.show(); bar.action("closeProject").trigger(); diff --git a/tests/ImageSizeSheetTests.cpp b/tests/ImageSizeSheetTests.cpp index 2bc4c67..8cc0d2d 100644 --- a/tests/ImageSizeSheetTests.cpp +++ b/tests/ImageSizeSheetTests.cpp @@ -16,6 +16,7 @@ private slots: void theSheetShowsTheCanvas(); void pixelsFollowUnitsAndTheLock(); void aNewResolutionKeepsThePrintSize(); + void aDocumentsOwnResolutionIsTheLastUsable(); void withoutResamplingOnlyTheResolutionMoves(); void anInvalidSizeRestsResize(); void returnWithoutTypingKeepsTheNumbers(); @@ -24,11 +25,18 @@ private slots: }; namespace { +CanvasDocument document(int width, int height, double resolution) +{ + CanvasDocument made{width, height}; + made.resolution = resolution; + return made; +} + struct Sheet { std::optional<std::optional<ImageSizeOptions>> answer; ImageSizeSheet sheet; - explicit Sheet(int width = 1200, int height = 800) - : sheet(CanvasDocument{width, height}, [this](std::optional<ImageSizeOptions> options) { answer = options; }) + explicit Sheet(int width = 1200, int height = 800, double resolution = 72) + : sheet(document(width, height, resolution), [this](std::optional<ImageSizeOptions> options) { answer = options; }) { sheet.show(); } @@ -152,16 +160,99 @@ void ImageSizeSheetTests::aNewResolutionKeepsThePrintSize() QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); shown.find<QPushButton>("imageResize").click(); QCOMPARE(shown.answer.value().value().resolution, 300.0); - // Zero changes nothing but the resolution, which is refused. + // Swift's 555417e: zero and below keep 8 by 5.333 inches. shown.unit("Inches"); + QCOMPARE(shown.shown("imageWidth"), QString("8")); shown.type("imageResolution", "0"); QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels())); + // A size typed meanwhile is ignored. + shown.type("imageWidth", "2"); + shown.type("imageResolution", "-5"); + shown.type("imageHeight", "3"); shown.type("imageResolution", "150"); - QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); + QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels")); + QCOMPARE(shown.shown("imageWidth"), QString("8")); // Centimetres keep the print size alike. shown.unit("Centimeters"); + shown.type("imageResolution", "0"); shown.type("imageResolution", "300"); - QCOMPARE(shown.result(), QString("Result: 4,800 × 3,200 pixels")); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); +} + +void ImageSizeSheetTests::aDocumentsOwnResolutionIsTheLastUsable() +{ + for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) { + Sheet shown(1200, 800, 300); + shown.unit(unit); + shown.type("imageResolution", "0"); + shown.type("imageWidth", "1"); + shown.type("imageResolution", "-5"); + shown.type("imageResolution", "150"); + QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels")); + } + // Pixels and Percent need no resolution: those edits apply. + for (const auto &[unit, width] : {std::pair(QStringLiteral("Pixels"), QStringLiteral("600")), std::pair(QStringLiteral("Percent"), QStringLiteral("50"))}) { + Sheet shown(1200, 800, 300); + shown.unit(unit); + shown.type("imageResolution", "0"); + shown.type("imageWidth", width); + shown.type("imageResolution", "150"); + QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels")); + } + // Infinity is no usable resolution either. + for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) { + Sheet shown(1200, 800, 300); + shown.unit(unit); + shown.type("imageResolution", "inf"); + QVERIFY(!shown.find<QPushButton>("imageResize").isEnabled()); + shown.type("imageWidth", "1"); + shown.type("imageResolution", "150"); + QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels")); + shown.find<QPushButton>("imageResize").click(); + const ImageSizeOptions options = shown.answer.value().value(); + QVERIFY(options.width == 600 && options.height == 400 && options.resolution == 150); + } + // Without resampling, print sizes wait for a usable resolution too. + for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) { + for (const QString &invalid : {QStringLiteral("0"), QStringLiteral("-5"), QStringLiteral("inf")}) { + Sheet held(1200, 800, 300); + held.unit(unit); + held.find<QCheckBox>("imageResample").click(); + held.type("imageResolution", invalid); + const QString shownResolution = held.shown("imageResolution"); + held.type("imageWidth", "1"); + held.type("imageHeight", "2"); + QCOMPARE(held.shown("imageResolution"), shownResolution); + QVERIFY(!held.find<QPushButton>("imageResize").isEnabled()); + held.type("imageResolution", "150"); + QCOMPARE(held.result(), QString("Result: 1,200 × 800 pixels")); + held.find<QCheckBox>("imageResample").click(); + held.type("imageResolution", "300"); + QCOMPARE(held.result(), QString("Result: 2,400 × 1,600 pixels")); + } + } + // Any finite positive resolution counts, even past Resize's range. + for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) { + Sheet low(12, 8, 300); + low.type("imageResolution", "0.5"); + low.unit(unit); + low.type("imageResolution", "150"); + QCOMPARE(low.result(), QString("Result: 3,600 × 2,400 pixels")); + Sheet high(300, 200, 300); + high.type("imageResolution", "12000"); + high.unit(unit); + high.type("imageResolution", "6000"); + QCOMPARE(high.result(), QString("Result: 150 × 100 pixels")); + } + // Without resampling the pixels stay, across the toggle. + Sheet shown(1200, 800, 300); + shown.type("imageResolution", "0"); + shown.find<QCheckBox>("imageResample").click(); + shown.type("imageResolution", "150"); + QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels")); + shown.find<QCheckBox>("imageResample").click(); + shown.type("imageResolution", "300"); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); } void ImageSizeSheetTests::withoutResamplingOnlyTheResolutionMoves() @@ -195,6 +286,13 @@ void ImageSizeSheetTests::withoutResamplingOnlyTheResolutionMoves() QCOMPARE(shown.units(), (QStringList{"Pixels", "Percent", "Inches", "Centimeters"})); QCOMPARE(shown.find<QComboBox>("imageUnits").currentText(), QString("Centimeters")); QVERIFY(shown.find<QCheckBox>("imageLocked").isEnabled()); + // A print-size edit updates the last usable resolution. + shown.find<QCheckBox>("imageResample").click(); + shown.type("imageHeight", "25.4"); + QCOMPARE(shown.shown("imageResolution"), QString("80")); + shown.find<QCheckBox>("imageResample").click(); + shown.type("imageResolution", "160"); + QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels")); } void ImageSizeSheetTests::anInvalidSizeRestsResize() diff --git a/tests/OmarchyThemeTests.cpp b/tests/OmarchyThemeTests.cpp index 578d827..9221c7e 100644 --- a/tests/OmarchyThemeTests.cpp +++ b/tests/OmarchyThemeTests.cpp @@ -4,6 +4,7 @@ #include <QApplication> #include <QDir> #include <QLabel> +#include <QMessageBox> #include <QStyle> #include <QStyleFactory> #include <QTemporaryDir> @@ -202,6 +203,10 @@ void OmarchyThemeTests::withoutAThemeTheBuiltInDarkApplies() QCOMPARE(QApplication::palette().color(QPalette::PlaceholderText), QColor(0x8a, 0x8a, 0x8a)); QCOMPARE(QApplication::palette().color(QPalette::BrightText), QColor(0xff, 0x9f, 0x0a)); QCOMPARE(QApplication::style()->objectName(), QString("fusion")); + // Swift's d62a132: alerts are dark too. + QMessageBox alert(QMessageBox::Warning, QStringLiteral("Title"), QStringLiteral("Text")); + QCOMPARE(alert.palette().color(QPalette::Window), QColor(0x24, 0x24, 0x24)); + QCOMPARE(alert.style()->objectName(), QString("fusion")); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); // A theme appearing later is taken up, unnamed or not. diff --git a/tests/OpenRecentTests.cpp b/tests/OpenRecentTests.cpp new file mode 100644 index 0000000..a3e23b4 --- /dev/null +++ b/tests/OpenRecentTests.cpp @@ -0,0 +1,273 @@ +#include "ExternalChangeFixtures.h" +#include "IO/RecentProjects.h" +#include "MenuFixtures.h" +#include <QApplication> +#include <QLabel> +#include <QMenu> +#include <QSettings> +#include <QStandardPaths> + +// Swift's 73dc0f4: File > Open Recent. +namespace { +QString canonical(const QString &path) +{ + return QFileInfo(path).canonicalFilePath(); +} + +// A saved project named `name` in `root`. +QString project(const QTemporaryDir &root, const QString &name) +{ + const QString path = root.filePath(name + QStringLiteral(".comp")); + if (!QDir().rename(savedProject(root), path)) + throw std::runtime_error("could not name " + path.toStdString()); + return path; +} + +QStringList titles(QMenu &menu) +{ + QStringList shown; + for (const QAction *entry : menu.actions()) + shown << (entry->isSeparator() ? QStringLiteral("-") : entry->text() + (entry->isEnabled() ? "" : " (off)")); + return shown; +} +} + +class OpenRecentTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void init(); + void opensAndSavesAreNotedNewestFirst(); + void theListKeepsTenAndOneEntryAPlace(); + void movedProjectsLeaveTheList(); + void theMenuListsOpensAndClears(); + void aProjectDeletedWhileListedIsNamed(); + void comingBackToTheAppChecksAgain(); + void onlyWhatLandsIsNoted(); + void aReloadAfterSavingNotesItsPlaceLast(); + +private: + QTemporaryDir m_stored; +}; + +void OpenRecentTests::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + // Starts from what earlier runs stored, missing places dropped. + QVERIFY(m_stored.isValid()); + QSettings().setValue(QStringLiteral("recentProjects"), QStringList{m_stored.filePath("Missing.comp"), m_stored.path()}); + QCOMPARE(RecentProjects::shared().paths(), QStringList{m_stored.path()}); +} + +void OpenRecentTests::init() +{ + RecentProjects::shared().clear(); +} + +void OpenRecentTests::opensAndSavesAreNotedNewestFirst() +{ + QTemporaryDir root; + const QString opened = project(root, QStringLiteral("Opened")); + EditorSession session; + ProjectController controller(session); + QVERIFY(answered([&](auto done) { controller.open(opened + QStringLiteral("/"), done); })); + QCOMPARE(RecentProjects::shared().paths(), QStringList{canonical(opened)}); + // Saved elsewhere: the copy comes first. + const QString copy = root.filePath(QStringLiteral("Copy.comp")); + DialogDesk desk; + desk.replies = {copy}; + QVERIFY(answered([&](auto done) { controller.save(true, done); })); + QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(copy), canonical(opened)})); + // Opened again: back to the front, listed once. + QVERIFY(answered([&](auto done) { controller.open(opened, done); })); + QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(opened), canonical(copy)})); + // A failed open notes nothing. + QDir(root.filePath("Broken.comp")).mkpath("."); + desk.replies = {"OK"}; + QVERIFY(!answered([&](auto done) { controller.open(root.filePath("Broken.comp"), done); })); + QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(opened), canonical(copy)})); + QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths()); +} + +void OpenRecentTests::theListKeepsTenAndOneEntryAPlace() +{ + QTemporaryDir root; + QStringList made; + for (int index = 0; index < 12; ++index) { + const QString place = root.filePath(QString::number(index)); + QVERIFY(QDir().mkpath(place)); + made.prepend(canonical(place)); + RecentProjects::shared().note(place); + } + QCOMPARE(RecentProjects::shared().paths(), made.mid(0, 10)); + // A link to a listed place is that place. + QVERIFY(QFile::link(root.filePath("5"), root.filePath("link"))); + RecentProjects::shared().note(root.filePath("link")); + QCOMPARE(RecentProjects::shared().paths().front(), made[6]); + QCOMPARE(RecentProjects::shared().paths().size(), 10); +} + +void OpenRecentTests::movedProjectsLeaveTheList() +{ + QTemporaryDir root; + const QString kept = project(root, QStringLiteral("Kept")); + const QString moved = project(root, QStringLiteral("Moved")); + RecentProjects::shared().note(kept); + RecentProjects::shared().note(moved); + int announced = 0; + connect(&RecentProjects::shared(), &RecentProjects::changed, this, [&] { ++announced; }); + QVERIFY(QDir().rename(moved, root.filePath("Elsewhere.comp"))); + RecentProjects::shared().refresh(); + QCOMPARE(RecentProjects::shared().paths(), QStringList{canonical(kept)}); + QCOMPARE(announced, 1); + // Nothing changed: no signal. + RecentProjects::shared().refresh(); + QCOMPARE(announced, 1); + // Back in place, it is listed again. + QVERIFY(QDir().rename(root.filePath("Elsewhere.comp"), moved)); + RecentProjects::shared().refresh(); + QCOMPARE(RecentProjects::shared().paths().size(), 2); + disconnect(&RecentProjects::shared(), nullptr, this, nullptr); +} + +void OpenRecentTests::theMenuListsOpensAndClears() +{ + QTemporaryDir root; + const QString first = project(root, QStringLiteral("First")); + const QString second = project(root, QStringLiteral("Second")); + Bar bar; + QMenu &recent = *bar.action("openRecent").menu(); + // Swift's place: right after Open Project…, no key. + const QList<QAction *> file = bar.menus->parent()->findChildren<QMenu *>(QString(), Qt::FindDirectChildrenOnly).front()->actions(); + QCOMPARE(file.indexOf(&bar.action("openRecent")), file.indexOf(&bar.action("openProject")) + 1); + QCOMPARE(file.indexOf(&bar.action("importImages")), file.indexOf(&bar.action("openRecent")) + 1); + QCOMPARE(bar.action("openRecent").text(), QString("Open Recent")); + QVERIFY(bar.action("openRecent").shortcut().isEmpty()); + QCOMPARE(titles(recent), (QStringList{"-", "Clear Menu (off)"})); + RecentProjects::shared().note(first); + RecentProjects::shared().note(second); + QCOMPARE(titles(recent), (QStringList{"Second", "First", "-", "Clear Menu"})); + // An entry opens its project in a tab. + recent.actions()[1]->trigger(); + QTRY_VERIFY(bar.session().projectPath().has_value()); + QCOMPARE(bar.session().projectPath().value(), canonical(first)); + QCOMPARE(titles(recent), (QStringList{"First", "Second", "-", "Clear Menu"})); + // Busy holds the whole menu, as Open Project. + QVERIFY(bar.action("openRecent").isEnabled()); + bar.session().setIsProjectBusy(true); + QVERIFY(!bar.action("openRecent").isEnabled()); + bar.session().setIsProjectBusy(false); + QVERIFY(bar.action("openRecent").isEnabled()); + bar.action("clearRecent").trigger(); + QCOMPARE(titles(recent), (QStringList{"-", "Clear Menu (off)"})); + QVERIFY(RecentProjects::shared().paths().isEmpty()); + // An ampersand is part of the name, no mnemonic. + RecentProjects::shared().note(project(root, QStringLiteral("R&D"))); + QCOMPARE(recent.actions()[0]->text(), QString("R&&D")); + QCOMPARE(recent.actions()[0]->iconText(), QString("R&D")); +} + +void OpenRecentTests::aProjectDeletedWhileListedIsNamed() +{ + QTemporaryDir root; + const QString gone = project(root, QStringLiteral("<b>Gone")); + RecentProjects::shared().note(gone); + const QString listed = canonical(gone); + QVERIFY(QDir(gone).removeRecursively()); + EditorSession session; + ProjectController controller(session); + DialogDesk desk; + desk.replies = {"OK"}; + // The name shows literally, never as markup. + desk.note = [] { + for (QWidget *top : QApplication::topLevelWidgets()) { + if (auto *label = top->findChild<QLabel *>(QStringLiteral("qt_msgbox_informativelabel"))) + return QStringLiteral("format=%1").arg(int(label->textFormat())); + } + return QStringLiteral("no label"); + }; + QVERIFY(!answered([&](auto done) { controller.open(listed, done); })); + QCOMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].startsWith("alert|2|Couldn’t open the project|The file “<b>Gone.comp” couldn’t be opened because there is no such file.") + && desk.seen[0].endsWith("|format=0"), + qPrintable(desk.seen[0])); + QVERIFY(RecentProjects::shared().paths().isEmpty()); + QVERIFY(!session.isProjectBusy()); +} + +void OpenRecentTests::comingBackToTheAppChecksAgain() +{ + QTemporaryDir root; + const QString moved = project(root, QStringLiteral("Moved")); + RecentProjects::shared().note(moved); + QVERIFY(QDir().rename(moved, root.filePath("Elsewhere.comp"))); + // Only becoming active looks again. + emit qGuiApp->applicationStateChanged(Qt::ApplicationInactive); + QCOMPARE(RecentProjects::shared().paths().size(), 1); + emit qGuiApp->applicationStateChanged(Qt::ApplicationActive); + QVERIFY(RecentProjects::shared().paths().isEmpty()); +} + +void OpenRecentTests::onlyWhatLandsIsNoted() +{ + QTemporaryDir root; + const QString kept = project(root, QStringLiteral("Kept")), other = project(root, QStringLiteral("Other")); + const QString incoming = project(root, QStringLiteral("Incoming")); + EditorSession session; + ProjectController controller(session); + QVERIFY(answered([&](auto done) { controller.open(kept, done); })); + RecentProjects::shared().note(other); + const QStringList before{canonical(other), canonical(kept)}; + // Replacing unsaved work cancelled: the incoming project stays out. + session.addBlankLayer(); + DialogDesk desk; + desk.replies = {"Cancel", "<cancel>"}; + QVERIFY(!answered([&](auto done) { controller.open(incoming, done); })); + QCOMPARE(RecentProjects::shared().paths(), before); + // Save As cancelled. + QVERIFY(!answered([&](auto done) { controller.save(true, done); })); + QCOMPARE(RecentProjects::shared().paths(), before); + // A failed save over a listed place promotes nothing. + QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner)); + desk.replies = {"OK"}; + QVERIFY(!answered([&](auto done) { controller.save(false, done); })); + QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + QCOMPARE(RecentProjects::shared().paths(), before); + // A save is noted once it lands. + std::optional<bool> saved; + controller.save(false, [&](bool value) { saved = value; }); + QVERIFY(controller.externalChanges.saving); + QCOMPARE(RecentProjects::shared().paths(), before); + QTRY_VERIFY(saved.has_value()); + QVERIFY(saved.value()); + QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(kept), canonical(other)})); + QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths()); +} + +void OpenRecentTests::aReloadAfterSavingNotesItsPlaceLast() +{ + QTemporaryDir root; + const QString reopened = project(root, QStringLiteral("Reopened")), other = project(root, QStringLiteral("Other")); + EditorSession session; + ProjectController controller(session); + QVERIFY(answered([&](auto done) { controller.open(reopened, done); })); + RecentProjects::shared().note(other); + // Another place noted as the save lands, before the reload. + bool armed = true; + connect(&RecentProjects::shared(), &RecentProjects::changed, this, [&] { + if (armed && RecentProjects::shared().paths().front() == canonical(reopened)) { + armed = false; + RecentProjects::shared().note(other); + } + }); + session.addBlankLayer(); + DialogDesk desk; + desk.replies = {"Save"}; + QVERIFY(answered([&](auto done) { controller.open(reopened, done); })); + disconnect(&RecentProjects::shared(), nullptr, this, nullptr); + QVERIFY(!armed); + QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(reopened), canonical(other)})); +} + +QTEST_MAIN(OpenRecentTests) +#include "OpenRecentTests.moc" diff --git a/tests/ProjectControllerTests.cpp b/tests/ProjectControllerTests.cpp index 06b0b93..33aafe2 100644 --- a/tests/ProjectControllerTests.cpp +++ b/tests/ProjectControllerTests.cpp @@ -246,11 +246,11 @@ void ProjectControllerTests::aCompletionNeverRunsInsideTheCall() int calls = 0; controller.save(false, [&](bool) { calls += 1; }); controller.confirmQuit([&](bool) { calls += 1; }); - controller.open(QString("/nowhere.comp"), [&](bool) { calls += 1; }); - QCOMPARE(calls, 0); DialogDesk desk; desk.replies = {"OK"}; QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); + controller.open(QString("/nowhere.comp"), [&](bool) { calls += 1; }); + QCOMPARE(calls, 0); QTRY_COMPARE(calls, 3); } diff --git a/tests/ProjectOpenTests.cpp b/tests/ProjectOpenTests.cpp index 63b488d..e553b3d 100644 --- a/tests/ProjectOpenTests.cpp +++ b/tests/ProjectOpenTests.cpp @@ -155,9 +155,12 @@ void ProjectOpenTests::aDamagedOrMissingProjectKeepsTheLiveDocument() QCOMPARE(desk.seen, (QStringList{damaged + "|busy"})); QCOMPARE(described(session), before); QVERIFY(session.isModified() && !session.isProjectBusy()); - QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged."); + // A missing project is named. + QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: The file “Nowhere.comp” couldn’t be opened because there is no such file."); QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false)); QCOMPARE(desk.seen.size(), 2); + QVERIFY2(desk.seen[1].startsWith("alert|2|Couldn’t open the project|The file “Nowhere.comp” couldn’t be opened because there is no such file.|"), + qPrintable(desk.seen[1])); QCOMPARE(described(session), before); } diff --git a/tests/RecentProjectsCoreTests.cpp b/tests/RecentProjectsCoreTests.cpp new file mode 100644 index 0000000..7c66865 --- /dev/null +++ b/tests/RecentProjectsCoreTests.cpp @@ -0,0 +1,78 @@ +#include "IO/RecentProjects.h" +#include <QDir> +#include <QProcess> +#include <QSettings> +#include <QStandardPaths> +#include <QTemporaryDir> +#include <QtTest> +#include <cstdio> + +// The list without a GUI application: no activation to follow. +class RecentProjectsCoreTests : public QObject { + Q_OBJECT +private slots: + void theListWorksWithoutAGuiApplication(); + void theListOutlivesItsProcess(); +}; + +namespace { +// One step in a fresh process: its output. +QString step(const QString &what) +{ + QProcess child; + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("RECENT_STEP"), what); + child.setProcessEnvironment(environment); + child.start(QCoreApplication::applicationFilePath(), {}); + if (!child.waitForFinished(30'000) || child.exitStatus() != QProcess::NormalExit || child.exitCode() != 0) + throw std::runtime_error("the step failed: " + what.toStdString()); + return QString::fromUtf8(child.readAllStandardOutput()); +} +} + +void RecentProjectsCoreTests::theListWorksWithoutAGuiApplication() +{ + QVERIFY(!qobject_cast<QGuiApplication *>(QCoreApplication::instance())); + QTemporaryDir root; + RecentProjects::shared().clear(); + RecentProjects::shared().note(root.path()); + QCOMPARE(RecentProjects::shared().paths(), QStringList{QDir(root.path()).canonicalPath()}); + QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths()); + RecentProjects::shared().clear(); + QVERIFY(RecentProjects::shared().paths().isEmpty()); +} + +void RecentProjectsCoreTests::theListOutlivesItsProcess() +{ + QTemporaryDir first, second; + step(QStringLiteral("clear")); + step(QStringLiteral("note:") + first.path()); + step(QStringLiteral("note:") + second.path()); + // A later launch reads what the earlier ones stored. + QCOMPARE(step(QStringLiteral("read")), QDir(second.path()).canonicalPath() + "\n" + QDir(first.path()).canonicalPath() + "\n"); + step(QStringLiteral("clear")); + QCOMPARE(step(QStringLiteral("read")), QString()); +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QStandardPaths::setTestModeEnabled(true); + // A child run does one step, then exits. + const QString what = qEnvironmentVariable("RECENT_STEP"); + if (what == QLatin1String("clear")) { + RecentProjects::shared().clear(); + } else if (what.startsWith(QLatin1String("note:"))) { + RecentProjects::shared().note(what.mid(5)); + } else if (what == QLatin1String("read")) { + for (const QString &path : RecentProjects::shared().paths()) + std::printf("%s\n", qPrintable(path)); + } else if (!what.isEmpty()) { + return 2; + } else { + RecentProjectsCoreTests tests; + return QTest::qExec(&tests, argc, argv); + } + return 0; +} +#include "RecentProjectsCoreTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 0613eea..d415fd0 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -166,6 +166,8 @@ compositor_test(ProjectReceiveTests) compositor_test(ProjectReplaceTests) compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) +compositor_test(OpenRecentTests) +compositor_test(RecentProjectsCoreTests) compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests) compositor_test(ProjectWorkspaceViewTests)