From 10f51cdf7254c5f816555ee20012904009fc7a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 15:52:24 -0400 Subject: [PATCH] 14.29: the blank start tab stays put; Untitled numbers stay low --- AGENTS.md | 3 ++- src/Document/ProjectWorkspace.cpp | 25 +++++++++++++++--- src/Document/ProjectWorkspace.h | 4 ++- src/UI/ProjectTabs.cpp | 7 ++++-- tests/ProjectTabStripTests.cpp | 39 ++++++++++++++++++++++++----- tests/ProjectWorkspaceFlowTests.cpp | 23 +++++++++-------- tests/ProjectWorkspaceTests.cpp | 36 +++++++++++++++++++++++++- tests/ProjectWorkspaceViewTests.cpp | 2 +- tests/WebPImportTests.cpp | 10 +++++++- 9 files changed, 123 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4755505..366dd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,12 +201,13 @@ Platform adaptations, each forced by a missing Qt analog: - Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, Two Colors rounded, every clamp through `FilterSettings` too, clear pixels, the eased glow against a full-size blur), `ScanlinesSheetTests` (the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu. - The Navigator (14.20, Swift 1.4.9: `6490bb3`, `3e09948`; `3c6cf5e`'s conflict resolved toward the minimap, which supersedes the panel): `Rendering/NavigatorGeometry.h/.cpp` twin Swift's file: `NavigatorGeometry` (the document fitted and centred in a box, `thumbnailRect`, `documentPoint` held to the document's edges), and `CanvasViewport::visibleDocumentRect` and `centerView`, declared in `CanvasViewport.h` and defined there, as Swift's extension; `centerView` goes through `translate`, so the view stops following Fit. Swift's `min` and `abs` in `visibleDocumentRect` are dead (the viewport never flips) and left out. `EditorSession::navigatorImage` (`EditorSession+Navigator.cpp`, its fragment `EditorSession+Navigator.h`) twins Swift's: the document drawn at the picture's size, never enlarged, each side rounded, a pixel at least, every layer and placed mask scaled by its axis, blur radius and motion distance by the width's, through a bare session's `drawLiveComposite` so an edit in progress stays out; a context that cannot be allocated, or a painted layer that cannot flatten while it draws, logs on `omaphoto.rendering` and gives no picture, where Swift's `try?` is silent (`NavigatorFailureTests`, a binary of its own: a 4000 by 3000 painted layer under 8 MB of room, the minimap shown without a picture). Swift's medium interpolation on the context is dead here (`LayerRenderer` sets each layer's own) and left out. `UI/NavigatorMinimap` twins Swift's view: a `QWidget` in `ContentView`'s canvas overlay (the mask badge's layout, its margins 12, top right), following the session's `changed()` itself: shown while View › Navigator holds (`navigator.visible` in `QSettings`, Swift's `AppStorage`, off at first), Canvas Only is off, a document is open and the zoom is 300% or more; hidden it drops its picture, key and wait, as SwiftUI drops the view. Its size is `sizeFor` (Swift's `size(for:)`: `QWidget::size` takes the name), fitted inside 126 by 90 and rounded; it fills gray 0.11, draws the 300-pixel picture smoothly over its whole box, the view's box in the accent 2.5 wide inside its rect (three points at least), clipped to a rounded rect of 4, then a 25% white rim. Swift's `task(id:)` is `RenderKey` (the document's id, size and layers and `brushRevision`, so a selection or guide redraws nothing): the first picture renders at once, each later key after a 250 ms single-shot precise `navigatorWait`. A left press or drag centres the view on the point (`centerView`, then `notify`), the arrow its cursor over the canvas's. Known difference: Swift's shadow (radius 6) has no twin, since a widget paints only inside itself. View › Navigator (300% and above) is a checkable entry before Pixel Grid that writes the setting and notifies; the command palette finds it. Tests: `NavigatorTests` (Swift's nine; the geometry's edge cases, both axes and a placed mask scaled, a layer and its mask off the origin, a streak shrunk at its edge, both blurs by the width's factor where the two factors differ, centring at a backing scale of two, the wait restarted by a later edit, a transform in progress left out, the minimap's showing, waiting, clicks, cursor, box, gray and rim), `ViewMenuTests::theNavigatorEntryShowsTheMinimap` (the entry's place and words, the minimap 12 points in at the canvas's top right), the View menu's count in `CompositorMenusTests`. Equivalent mutants: none found. - Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`. -- WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order), the words in their files. +- WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order; its `main` points `TMPDIR` at a folder of its own, since `ImageFileDropTests` counts the dropped copies in the shared one), the words in their files. - Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, and a right-button tip drag waits, as beside the pan. Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, from 75% too, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning, a Hand drag going on and a tip drag waiting beside it). - Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes were raw DEFLATE until 14.27 (xz since, below). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search) (since 14.26 `CameraRawWiringTests::theTableReadsThePicturesBrightness`) and Auto through the encoded mean, a dark pixel on its straight segment. - Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` were 75147a2's and the tables its 285 (`CRT2`, 1.4 MB; 763 since 14.27): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be; pinned beside them: Highlights alone reading the picture, Highlights before Whites, a faint step sharpened at Masking 0) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, two colours' three measures against Python, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer). - Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading as Photoshop's (14.27, Swift 1.4.9: `bdfb9c2`, `9dac4f1`, `7b5a592`, taken as their net change): `kernels/AdjustPixels.c/.h` were 7b5a592's (a stage holds up to eight tables, `adjust_camera_raw_curve_color` takes the Mixer's and Grading's composed tables, `adjust_camera_raw_optics` Exposure's tables for the lens vignette, the post-crop vignette darkens by a gain in linear light) and the tables its 763 (`CRT3`, 1.25 MB): a header of ten bytes (size, count, `step`), each entry a running difference in steps of `step` levels read as a signed byte and clamped, the planes an xz stream as Foundation's `.lzma` writes it, unpacked through liblzma (`lzma_stream_buffer_decode`, `LibLZMA::LibLZMA`; `liblzma-dev` in the dev image, `xz-devel` and `xz` in the Fedora and Arch images, `xz` in the PKGBUILD and the flake); the app no longer links zlib. `CameraRawTables` gains Swift's `blend` (up to eight parts; a null table is no change), `exposureTables`, `fifty` (−100 to 100 by 50), `gradingStages` (the four luminance sliders, then the global, midtone, highlight and shadow wheels: twelve hues by 30, saturations 25, 50 and 100 below which the lower is no change, the shadow and highlight wheels between Blending 0, 50 and 100, Balance as Swift's fitted shares of saturation, a wheel with no share left out) and `mixerStages` (luminance, saturation, hue, eight colours each); `stages` opens with Calibration's six primaries and ends with the parametric curve's four regions while `CameraRawCurveSettings::hasMeasuredDividers` holds (dividers 25, 50, 75 and Refine Saturation 0), when `toneTable` draws the point curve alone. `applyCurveColor` hands the kernel zeros for the Mixer's and Grading's formulas and their tables in their place, and their serializers (`mixerFloats`, `gradeFloats`), which Swift keeps without a caller, are gone; `applyCalibration` draws Shadows Tint alone; `apply` runs the Light table for Calibration or a measured curve too. Equivalent mutants: the shares' floor of zero (a negative share fails the `> 0` test as zero does) and `gradingStages`' `fmod` (a normalized hue lies within 0 to 360, where 360 wraps alike). Tests: Swift's `mixerCalibrationCurveAndVignettesMatchPhotoshop` in `CameraRawTests`; `CameraRawTablesTests` against an independent Python decoder of `CRT3` (the hash and entries; every earlier exact value redone on the stepped tables; the Mixer's, Calibration's, the curve's and Grading's tables and weights, a wheel wrapping past 330, Blending 0's own tables, Balance ±50 weighing each wheel's share and +100 leaving the shadows out, every Calibration binding, midtones' and highlights' luminance, Calibration, Light and the measured curve in order, moved dividers); the Highlights pins, Auto's dark pixel and the grading luminance by the same Python; `CameraRawCurveTests` (the tone table as measured and with dividers moved), `CameraRawLensTests` (Shadows Tint by the kernel, primaries through the table), `CameraRawRulesTests` (the clip views keep Calibration's table), `CameraRawWiringTests` (the curve call's tables). - Camera Raw's preview 5 to 20 times faster (14.28, Swift 1.4.9: `a8630e4`): `kernels/AdjustPixels.c/.h` are a8630e4's, with 13.17's adaptation carried on: Swift threads its passes through GCD blocks (`in_bands` over `dispatch_apply`, and `dispatch_apply` alone for the statistics and the scope), which GCC cannot compile, so each block's body moved word for word into a static band function (`compose_band`, `compose_onto_band`, `camera_raw_band`, `blur_rows_band`, `blur_columns_band`, `effects_luma_band`, `effects_band`, `curve_color_band`, `detail_luma_band`, `sharpen_luma_band`, `sharpen_band`, `optics_band`, `calibration_band`, `statistics_band`, `scope_band`) whose context struct carries what the block captured, handed over as a compound literal, with Swift's split kept (one band under 64 items, else 32; 32 and 16 for the two sums); the bands run through `adjust_bands`, a C hook in `Document/ImageAdjustments.cpp` on `PoolMap::blocking`, as Dither's do. `CameraRawSettings::apply` takes Swift's known brightness (`std::optional`, read before Calibration now, measured there when none comes), composes the Light table always, and folds Curve, the Mixer and Grading into it (`foldingColor`: from `CameraRawTables::identity` when Light made none, the curves through `camera_raw_table_curves`, the Mixer's and Grading's stages through `camera_raw_compose_onto`) unless a point colour, Refine Saturation, a point-colour view, a clip view or the sharpen mask needs the curve pass. `CameraRawTables::Brightness` is declared in `CameraRaw.h`, reopening the namespace, since `apply`'s signature needs it and `CameraRawTables.h` holds the kernel's types, which only the core sees; `brightness(QImage)` draws the picture and measures it, and a picture that cannot be drawn logs on `omaphoto.rendering` and reads 128, where Swift's `try?` is silent. `FilterJob` carries `cameraRawBrightness`, which `previewJob` fills for Camera Raw from `FilterEdit::previewBrightness`, measured once and kept by the preview source's `cacheKey` (Swift keys it by `previewSourceVersion`, which the twin dropped in 13.8). `CameraRawScope::make` reads every other pixel each way through `camera_raw_scope`, straight from an `RGBA8888_Premultiplied` image's bytes, else drawn first; each band starts its own rows, so a short picture counts every row. Equivalent mutants: `foldingColor`'s `shape.adjusts()` (an identity curve leaves the table within a float's rounding), the cache's key (Camera Raw's preview source never changes while its panel is open), the band counts of the `in_bands` split (they only spread the work; the two sums' counts are not: one band reads only its own rows). Tests: `CameraRawFoldTests` (the fold against the kernel's own calls, from identity too; a point colour, Refine Saturation and a point-colour view keeping the pass; the brightness read before Shadows Tint and a known one used as handed; the session's job carrying the preview source's brightness and none for other kinds; every band of a forty-row ramp measured and counted; the calibration kernel's blue hue and saturation by their own effects; a job's brightness reaching the grade), `CameraRawFoldFailureTests` (a binary of its own, under `AddressSpaceLimit`: a measured preview source not drawn again, a premultiplied scope read in place), the scope's sampling and both formats in `CameraRawWiringTests`, the panel's ramp two pixels a level in `CameraRawPanelTests`. +- The blank start tab and low Untitled numbers (14.29, Swift 1.4.9: `d0ec2f3`, issue #231): `ProjectWorkspace::canClose` is Swift's: the only tab, with no document and no path, has nothing to close, so `close` (the tab's cross, Close Project) leaves it be; the counter (`m_nextNumber`) is gone, and a new tab takes `freeUntitledName`, "Untitled" when no tab shows it, else the lowest "Untitled N" free, so closing the last project leaves "Untitled" again. `ProjectTabButton::synchronize` hides the cross while the tab cannot close and closes the pill up round its name, 11 a side (Swift's label + 22 against + 40): the title button 3 wider, the layout's right margin gone. Tests: Swift's `untitledNumbersStayLowAndTheBlankTabStays` in `ProjectWorkspaceTests` (a saved but empty tab closes, a project's own "Untitled 4" taken), `ProjectTabStripTests::theBlankStartTabHasNoCloseButton`, the closing flows and titles brought to the new numbers in `ProjectWorkspaceFlowTests`, `ProjectWorkspaceViewTests` and `ProjectTabStripTests`. - 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` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 8f0f7c9..e77c8fe 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -3,6 +3,7 @@ #include "Logging.h" #include <QFileDialog> #include <QFileInfo> +#include <QSet> #include <QTimer> #include <algorithm> @@ -72,12 +73,30 @@ void ProjectWorkspace::finish(const std::function<void(bool)> &done, bool value) QMetaObject::invokeMethod(this, [done, value] { done(value); }, Qt::QueuedConnection); } +// "Untitled", else the lowest "Untitled N" no tab shows. +QString ProjectWorkspace::freeUntitledName() const +{ + QSet<QString> taken; + for (const std::shared_ptr<ProjectTab> &each : m_tabs) + taken.insert(each->title()); + if (!taken.contains(QStringLiteral("Untitled"))) + return QStringLiteral("Untitled"); + int number = 2; + while (taken.contains(QStringLiteral("Untitled %1").arg(number))) + number += 1; + return QStringLiteral("Untitled %1").arg(number); +} + +bool ProjectWorkspace::canClose(const ProjectTab &tab) const +{ + return !(m_tabs.size() == 1 && !tab.session.document() && !tab.session.projectPath()); +} + ProjectTab &ProjectWorkspace::addTab(bool reuseEmpty) { if (reuseEmpty && m_tabs.size() == 1 && !current().session.document()) return current(); - const auto added = std::make_shared<ProjectTab>(QStringLiteral("Untitled %1").arg(m_nextNumber)); - m_nextNumber += 1; + const auto added = std::make_shared<ProjectTab>(freeUntitledName()); added->controller.workspace = this; added->controller.window = window; m_tabs.push_back(added); @@ -182,7 +201,7 @@ void ProjectWorkspace::loadProject(const QString &path, std::function<void(bool) void ProjectWorkspace::close(QUuid id, std::function<void()> done) { const std::shared_ptr<ProjectTab> closing = tab(id); - if (!canSwitch() || !closing) { + if (!canSwitch() || !closing || !canClose(*closing)) { finish(done); return; } diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index 1b60c84..2fa1cd5 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -34,6 +34,8 @@ public: ProjectTab ¤t() const; std::shared_ptr<ProjectTab> tab(QUuid id) const; bool canSwitch() const; + // The only tab, still blank, has nothing to close. + bool canClose(const ProjectTab &tab) const; ProjectTab &addTab(bool reuseEmpty = true); void select(QUuid id); // A dragged tab lands at `index`, clamped; chrome, never undone. @@ -74,6 +76,7 @@ signals: void changed(); private: + QString freeUntitledName() const; bool finishTextEditing(); struct Baked { EditorSession::BakedImages images; @@ -102,5 +105,4 @@ private: std::vector<std::shared_ptr<ProjectTab>> m_tabs; QUuid m_selectedID; bool m_isManaging = false; - int m_nextNumber = 2; }; diff --git a/src/UI/ProjectTabs.cpp b/src/UI/ProjectTabs.cpp index 77f9cfd..5da1cc7 100644 --- a/src/UI/ProjectTabs.cpp +++ b/src/UI/ProjectTabs.cpp @@ -86,8 +86,11 @@ void ProjectTabButton::synchronize() const QFontMetrics metrics(tabFont(active)); m_select->setText(dot + metrics.elidedText(tab->title(), Qt::ElideRight, label - (modified ? 10 : 0))); m_select->setFont(tabFont(active)); - // Swift's trailing 8 inside; a character dot outgrows Swift's 10. - m_select->setFixedWidth(label + 8 + (modified ? metrics.horizontalAdvance(dot) - 10 : 0)); + // Trailing 8, or 11 closed up; a dot outgrows 10. + const bool closable = m_workspace.canClose(*tab); + m_select->setFixedWidth(label + (closable ? 8 : 11) + (modified ? metrics.horizontalAdvance(dot) - 10 : 0)); + layout()->setContentsMargins(11, 0, closable ? 5 : 0, 0); + m_close->setVisible(closable); m_select->setToolTip(tab->title()); m_select->setEnabled(m_workspace.canSwitch() || active); m_close->setToolTip(QStringLiteral("Close %1").arg(tab->title())); diff --git a/tests/ProjectTabStripTests.cpp b/tests/ProjectTabStripTests.cpp index 2345943..89a91e0 100644 --- a/tests/ProjectTabStripTests.cpp +++ b/tests/ProjectTabStripTests.cpp @@ -25,6 +25,7 @@ class ProjectTabStripTests : public QObject { private slots: void eachTabIsAsWideAsItsTitle(); void aTitleMovesOnlyTheTabsAfterIt(); + void theBlankStartTabHasNoCloseButton(); }; void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() @@ -41,18 +42,18 @@ void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() QCOMPARE(select(*buttons[0]).width(), 35 + 8); QCOMPARE(select(*buttons[1]).width(), 155 + 8); QVERIFY(select(*buttons[1]).text().endsWith(QChar(0x2026))); - QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), true) + 8); - QCOMPARE(select(*buttons[2]).text(), QString("Untitled 3")); + QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled"), true) + 8); + QCOMPARE(select(*buttons[2]).text(), QString("Untitled")); // Swift's pill: label, 11 before, 8 after, 16, 5. buttons[2]->adjustSize(); - QCOMPARE(buttons[2]->width(), label(QStringLiteral("Untitled 3"), true) + 40); + QCOMPARE(buttons[2]->width(), label(QStringLiteral("Untitled"), true) + 40); // The dot: 10 for the title, its character's own width. workspace.current().session.createDocument(8, 8); workspace.current().session.addBlankLayer(); QVERIFY(workspace.current().session.isModified()); const QFontMetrics metrics(select(*buttons[2]).font()); - QCOMPARE(select(*buttons[2]).text(), QString("● Untitled 3")); - QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), true) + 10 + 8 + metrics.horizontalAdvance(QStringLiteral("● ")) - 10); + QCOMPARE(select(*buttons[2]).text(), QString("● Untitled")); + QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled"), true) + 10 + 8 + metrics.horizontalAdvance(QStringLiteral("● ")) - 10); // An unsaved long title elides within Swift's width less 10. workspace.select(workspace.tabs()[1]->id); workspace.current().session.createDocument(8, 8); @@ -62,7 +63,7 @@ void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() QCOMPARE(select(*buttons[1]).text(), QStringLiteral("● ") + bold.elidedText(long_, Qt::ElideRight, 145)); // Chosen, a tab's weight changes its width. workspace.select(workspace.tabs()[0]->id); - QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), false) + 10 + 8 + QFontMetrics(select(*buttons[2]).font()).horizontalAdvance(QStringLiteral("● ")) - 10); + QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled"), false) + 10 + 8 + QFontMetrics(select(*buttons[2]).font()).horizontalAdvance(QStringLiteral("● ")) - 10); } void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt() @@ -101,5 +102,31 @@ void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt() QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6); } +// Swift 1.4.9: the pill closes up round its name. +void ProjectTabStripTests::theBlankStartTabHasNoCloseButton() +{ + ProjectWorkspace workspace; + ProjectTabStrip strip(workspace); + strip.resize(1000, 34); + strip.show(); + QVERIFY(QTest::qWaitForWindowExposed(&strip)); + ProjectTabButton &only = *strip.buttons()[0]; + QToolButton &close = *only.findChild<QToolButton *>(QStringLiteral("closeTab")); + const int width = label(QStringLiteral("Untitled"), true); + QVERIFY(close.isHidden()); + QTRY_COMPARE(only.width(), width + 22); + QCOMPARE(select(only).width(), width + 11); + // A document gives it something to close. + workspace.current().session.createDocument(8, 8); + QVERIFY(!close.isHidden()); + // Round the title: 11 before; 16 and 5 after. + QTRY_COMPARE(only.width() - select(only).width(), 11 + 16 + 5); + // A second tab makes either closable. + workspace.current().session.clearProject(); + QVERIFY(close.isHidden()); + workspace.addTab(false); + QVERIFY(!close.isHidden() && !strip.buttons()[1]->findChild<QToolButton *>(QStringLiteral("closeTab"))->isHidden()); +} + QTEST_MAIN(ProjectTabStripTests) #include "ProjectTabStripTests.moc" diff --git a/tests/ProjectWorkspaceFlowTests.cpp b/tests/ProjectWorkspaceFlowTests.cpp index 01f7d0d..590607b 100644 --- a/tests/ProjectWorkspaceFlowTests.cpp +++ b/tests/ProjectWorkspaceFlowTests.cpp @@ -101,11 +101,11 @@ void ProjectWorkspaceFlowTests::aProjectOpensInATabOfItsOwn() workspace.newCanvas(); store(folder.filePath("Third.comp"), 1); QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Third.comp"), done); }), std::optional(true)); - QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"})); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled", "Third*"})); // While it manages, nothing else begins. workspace.current().session.setIsProjectBusy(true); QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(false)); - QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled 2", "Third*"})); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Second", "Untitled", "Third*"})); } void ProjectWorkspaceFlowTests::aProjectThatIsOpenIsBroughtToTheFront() @@ -116,10 +116,10 @@ void ProjectWorkspaceFlowTests::aProjectThatIsOpenIsBroughtToTheFront() ProjectWorkspace workspace; QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Trip.comp"), done); }), std::optional(true)); workspace.newCanvas(); - QCOMPARE(titles(workspace), (QStringList{"Trip", "Untitled 2*"})); + QCOMPARE(titles(workspace), (QStringList{"Trip", "Untitled*"})); // By a link, with a slash: still the open project. QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Link.comp") + "/", done); }), std::optional(true)); - QCOMPARE(titles(workspace), (QStringList{"Trip*", "Untitled 2"})); + QCOMPARE(titles(workspace), (QStringList{"Trip*", "Untitled"})); QVERIFY(!workspace.isManaging()); } @@ -173,8 +173,11 @@ void ProjectWorkspaceFlowTests::closingATabAsksAboutItsWork() workspace.current().session.setIsProjectBusy(false); workspace.close(extra); QTRY_COMPARE(int(workspace.tabs().size()), 1); - workspace.close(blank); - QTRY_COMPARE(titles(workspace), (QStringList{"Untitled 4*"})); + // The only tab, still blank, stays put (Swift 1.4.9). + done = false; + workspace.close(blank, [&] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(titles(workspace), (QStringList{"Untitled 2*"})); QCOMPARE(desk.seen.size(), 2); QVERIFY(!workspace.isManaging()); } @@ -231,7 +234,7 @@ void ProjectWorkspaceFlowTests::closingTheWindowLeavesOneFreshTab() QCOMPARE(int(workspace.tabs().size()), 2); workspace.closeWindow(&window); QTRY_VERIFY(!window.isVisible()); - QCOMPARE(titles(workspace), (QStringList{"Untitled 3*"})); + QCOMPARE(titles(workspace), (QStringList{"Untitled*"})); QVERIFY(!workspace.current().session.document().has_value()); } @@ -406,15 +409,15 @@ void ProjectWorkspaceFlowTests::aProjectThatMovedAwayIsNoMatchForAnotherMissingO QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+")); QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("Elsewhere.comp"), done); }), std::optional(false)); QCOMPARE(desk.seen.size(), 1); - QCOMPARE(titles(workspace), (QStringList{"Here", "Untitled 2*"})); + QCOMPARE(titles(workspace), (QStringList{"Here", "Untitled*"})); // The same missing place is still the same place, QCOMPARE(answered([&](auto done) { workspace.open(folder.filePath("sub/../Here.comp"), done); }), std::optional(true)); - QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"})); + QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled"})); // also when named from the folder the process stands in. workspace.select(workspace.tabs()[1]->id); QDir::setCurrent(folder.path()); QCOMPARE(answered([&](auto done) { workspace.open(QString("Here.comp"), done); }), std::optional(true)); - QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled 2"})); + QCOMPARE(titles(workspace), (QStringList{"Here*", "Untitled"})); } void ProjectWorkspaceFlowTests::everyCallIsWholeWithoutACompletion() diff --git a/tests/ProjectWorkspaceTests.cpp b/tests/ProjectWorkspaceTests.cpp index 87f26ab..bfad99d 100644 --- a/tests/ProjectWorkspaceTests.cpp +++ b/tests/ProjectWorkspaceTests.cpp @@ -43,6 +43,7 @@ private slots: void aDropThatIsNoLayerIdIsIgnored(); void anOpenFilterHoldsTheSwitch(); void moveTabReordersWithoutTouchingSelectionOrDocuments(); + void untitledNumbersStayLowAndTheBlankTabStays(); }; void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal() @@ -275,7 +276,7 @@ void ProjectWorkspaceTests::removingATabSelectsItsNeighbour() workspace.removeTab(c); QCOMPARE(int(workspace.tabs().size()), 1); QVERIFY(workspace.current().id != c); - QCOMPARE(workspace.current().title(), QString("Untitled 5")); + QCOMPARE(workspace.current().title(), QString("Untitled")); } void ProjectWorkspaceTests::everyChangeIsAnnouncedAndRefusalsAreSilent() @@ -390,5 +391,38 @@ void ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments() QCOMPARE(workspace.selectedID(), c); } +// The blank start tab stays; numbers stay low (#231). +void ProjectWorkspaceTests::untitledNumbersStayLowAndTheBlankTabStays() +{ + ProjectWorkspace workspace; + ProjectTab &first = workspace.current(); + QVERIFY(first.title() == "Untitled" && !workspace.canClose(first)); + bool done = false; + workspace.close(first.id, [&] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(workspace.tabs().size() == 1 && &workspace.current() == &first && first.title() == "Untitled"); + // A saved but empty session can close. + first.session.setProjectPath(QStringLiteral("/x/Trip.comp")); + QVERIFY(workspace.canClose(first)); + first.session.setProjectPath(std::nullopt); + first.session.createDocument(10, 10); + QVERIFY(workspace.canClose(first)); + const QUuid second = workspace.addTab().id; + const QString third = workspace.addTab().title(); + QVERIFY(workspace.tab(second)->title() == "Untitled 2" && third == "Untitled 3"); + // With another tab, a blank one closes. + QVERIFY(workspace.canClose(*workspace.tab(second))); + workspace.removeTab(second); + QCOMPARE(workspace.addTab().title(), QString("Untitled 2")); + // Untitled is free again; a project's Untitled 4 is taken. + first.session.setProjectPath(QStringLiteral("/x/Untitled 4.comp")); + QCOMPARE(workspace.addTab().title(), QString("Untitled")); + QCOMPARE(workspace.addTab().title(), QString("Untitled 5")); + const std::vector<std::shared_ptr<ProjectTab>> all = workspace.tabs(); + for (const std::shared_ptr<ProjectTab> &tab : all) + workspace.removeTab(tab->id); + QVERIFY(workspace.tabs().size() == 1 && workspace.current().title() == "Untitled"); +} + QTEST_MAIN(ProjectWorkspaceTests) #include "ProjectWorkspaceTests.moc" diff --git a/tests/ProjectWorkspaceViewTests.cpp b/tests/ProjectWorkspaceViewTests.cpp index 696cd78..554dbeb 100644 --- a/tests/ProjectWorkspaceViewTests.cpp +++ b/tests/ProjectWorkspaceViewTests.cpp @@ -296,7 +296,7 @@ void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession() workspace.newCanvas(); QVERIFY(!fit.isEnabled() && !window.isWindowModified()); QCOMPARE(window.windowFilePath(), QString()); - QCOMPARE(window.windowTitle(), QString("Untitled 2[*]")); + QCOMPARE(window.windowTitle(), QString("Untitled[*]")); // At its smallest the window still shows the whole editor. window.resize(800, 520); QTRY_COMPARE(window.size(), QSize(800, 520)); diff --git a/tests/WebPImportTests.cpp b/tests/WebPImportTests.cpp index 1975597..5866f01 100644 --- a/tests/WebPImportTests.cpp +++ b/tests/WebPImportTests.cpp @@ -72,5 +72,13 @@ void WebPImportTests::aDroppedPictureTakesWebPAfterTIFF() QCOMPARE(ImageFileDrop::providers(data).front()->formats(), QStringList{"image/tiff"}); } -QTEST_MAIN(WebPImportTests) +int main(int argc, char **argv) +{ + // Its drops copy into its own folder: others count theirs. + QTemporaryDir own; + qputenv("TMPDIR", own.path().toUtf8()); + QApplication app(argc, argv); + WebPImportTests tests; + return QTest::qExec(&tests, argc, argv); +} #include "WebPImportTests.moc"