diff --git a/AGENTS.md b/AGENTS.md index 6d82d2f..690d8cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,7 @@ Platform adaptations, each forced by a missing Qt analog: - Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held. - Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. - The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`. +- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/TASKS.md b/TASKS.md index 67ad7a4..fb1e633 100644 --- a/TASKS.md +++ b/TASKS.md @@ -372,7 +372,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.9a Version 1.2.4, part one: the finishing filters (Vignette with its colour picker, Bloom / Glow, Tonal Contrast), a preview kept to the grid it was rendered for, effects kept by a filter's commit (`e08dd8c`). - 12.9a round 1 (codex): FAIL, two findings: a job waiting behind another edit's worker kept its old grid's image while Preview off grew the grid, so its result landed stretched into the new placement (reproduced); the sheet's test could not tell which setting a control wrote. Fixed: a growth rebuilds a waiting job on the new grid; each row names its member and only that member may move. Each fix's mutant caught. - 12.9a round 2: PASS, no findings. -- [ ] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10. +- [x] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10. +- 12.9b round 1 (codex): FAIL, a spec mismatch and test gaps: tab titles centred where Swift's are leading; the zoom tolerance, the Meta exclusion, an unsaved long title's elision and the scroll test's unsaved edit were unpinned; and the menu already takes Ctrl with keypad minus, so the filter's zoom-out branch was unreachable in the app. Fixed: `TabTitle` paints from the left, the filter takes Zoom In's plus alone, the key test runs in the app's window; each mutant caught. +- 12.9b round 2: PASS, no findings. - [ ] 12.10 Versions 1.2.5–1.2.7: the project format's version in one place (`4cc9951`), the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews. - [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG). - [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 6454386..a8c5adf 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -315,6 +315,17 @@ void EditorSession::zoom(double value, std::optional<QPointF> anchor) notify(); } +void EditorSession::zoomKeyboard(int step) +{ + if (!m_document) + return; + const double target = viewport.keyboardZoomTarget(step); + if (target == viewport.zoom()) + return; + viewport.setZoom(target, viewport.center(), m_document->size()); + notify(); +} + void EditorSession::setShowsPixelGrid(bool shows) { m_showsPixelGrid = shows; diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 871bf99..38ad4d3 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -253,6 +253,8 @@ public: void createDocument(int width, int height, bool emptyLayer = false); void fit(); void zoom(double value, std::optional<QPointF> anchor = std::nullopt); + // Swift's stepped keyboard zoom about the view's centre. + void zoomKeyboard(int step); // The View menu's grid, shown from 800%. bool showsPixelGrid() const { return m_showsPixelGrid; } void setShowsPixelGrid(bool shows); diff --git a/src/Rendering/CanvasViewport.cpp b/src/Rendering/CanvasViewport.cpp index 1813bf8..68777a3 100644 --- a/src/Rendering/CanvasViewport.cpp +++ b/src/Rendering/CanvasViewport.cpp @@ -56,6 +56,19 @@ void CanvasViewport::setZoom(double value, QPointF anchoredAt, QSizeF documentSi m_followsFit = false; } +double CanvasViewport::keyboardZoomTarget(int step) const +{ + if (step == 0) + return m_zoom; + const double tolerance = std::max(0.000000001, std::abs(m_zoom) * 0.000000001); + if (step > 0) { + const auto next = std::find_if(keyboardZoomLevels.begin(), keyboardZoomLevels.end(), [&](double level) { return level > m_zoom + tolerance; }); + return next == keyboardZoomLevels.end() ? m_zoom : *next; + } + const auto next = std::find_if(keyboardZoomLevels.rbegin(), keyboardZoomLevels.rend(), [&](double level) { return level < m_zoom - tolerance; }); + return next == keyboardZoomLevels.rend() ? m_zoom : *next; +} + void CanvasViewport::translate(QSizeF by) { pan += by; diff --git a/src/Rendering/CanvasViewport.h b/src/Rendering/CanvasViewport.h index 4bddb7d..9bd18c7 100644 --- a/src/Rendering/CanvasViewport.h +++ b/src/Rendering/CanvasViewport.h @@ -2,6 +2,7 @@ #include <QPointF> #include <QRectF> #include <QSizeF> +#include <array> #include <optional> // Document: top-left pixels. View: widget points. Zoom 1: device pixels. @@ -9,6 +10,9 @@ class CanvasViewport { public: static constexpr double minimumZoom = 0.001; static constexpr double maximumZoom = 32; + // Swift's keyboard zoom stops, 12.5% to 1600%. + static constexpr std::array keyboardZoomLevels{0.125, 1.0 / 6.0, 0.25, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0, 1.25, 1.5, + 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0}; QSizeF viewSize{0, 0}; double backingScale = 1; @@ -25,6 +29,8 @@ public: void fit(QSizeF documentSize); void resize(QSizeF to, double backingScale, std::optional<QSizeF> documentSize); void setZoom(double value, QPointF anchoredAt, QSizeF documentSize); + // The next stop past the zoom, else the zoom. + double keyboardZoomTarget(int step) const; void translate(QSizeF by); friend bool operator==(const CanvasViewport &lhs, const CanvasViewport &rhs); diff --git a/src/Rendering/EditorCanvas+Cursors.cpp b/src/Rendering/EditorCanvas+Cursors.cpp index 7c82992..88fc107 100644 --- a/src/Rendering/EditorCanvas+Cursors.cpp +++ b/src/Rendering/EditorCanvas+Cursors.cpp @@ -324,6 +324,8 @@ bool CanvasView::eventFilter(QObject *watched, QEvent *event) key = typed.get(); // Shift-+ and Shift-− step the blend mode, except while typing. const bool typing = qobject_cast<QLineEdit *>(QApplication::focusWidget()) || (m_inlineTextEditor && hasFocus()); + if (event->type() == QEvent::KeyPress && !typing && handleKeyboardZoom(*key)) + return true; if (event->type() != QEvent::KeyPress || typing || key->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)) return false; const bool forward = key->key() == Qt::Key_Plus || key->key() == Qt::Key_Equal; @@ -335,6 +337,22 @@ bool CanvasView::eventFilter(QObject *watched, QEvent *event) return QApplication::focusWidget() != this && !m_session.levels() && brushBracket(key->text()); } +// Zoom In's plus, which its Ctrl+= entry cannot hold. +bool CanvasView::handleKeyboardZoom(const QKeyEvent &key) +{ + const Qt::KeyboardModifiers held = key.modifiers(); + if (!m_session.document() || key.key() != Qt::Key_Plus || !held.testFlag(Qt::ControlModifier) || held & (Qt::AltModifier | Qt::MetaModifier)) + return false; + // Only while the entry keeps its default key, as Swift's. + for (const ShortcutDefinition &definition : ShortcutDefinition::all()) { + if (definition.isMenu() && definition.title == QLatin1String("Zoom In") && !(ShortcutSettings::shared().chord(definition) == definition.original)) + return false; + } + m_session.zoomKeyboard(1); + synchronizeDisplay(); + return true; +} + // A key changed: badge, box and cursor follow at once. void CanvasView::modifiersChanged() { diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index 2a85195..354addf 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -176,6 +176,8 @@ private: Qt::KeyboardModifiers heldModifiers() const; void readModifiers(Qt::KeyboardModifiers modifiers); void modifiersChanged(); + // Swift's zoom keys on key-down; true when it zoomed. + bool handleKeyboardZoom(const QKeyEvent &key); std::optional<PressTarget> transformPressLayer(QPointF pixel, Qt::KeyboardModifiers modifiers) const; bool pressMovesLayer(QPointF point, Qt::KeyboardModifiers modifiers) const; QCursor transformCursor(QPointF point, Qt::KeyboardModifiers modifiers) const; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 7fe79f7..141fa15 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -129,8 +129,13 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi QMenu *view = bar.addMenu(QStringLiteral("&View")); add(view, QStringLiteral("fit"), QStringLiteral("Fit Canvas"), QKeySequence(Qt::CTRL | Qt::Key_0), [this] { session().fit(); }); add(view, QStringLiteral("actualPixels"), QStringLiteral("Actual Pixels"), QKeySequence(Qt::CTRL | Qt::Key_1), [this] { session().zoom(1); }); - add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [this] { session().zoom(session().viewport.zoom() * 1.25); }); - add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [this] { session().zoom(session().viewport.zoom() / 1.25); }); + // Swift's stops; open text keeps the keys, as NSText does. + const auto zoomBy = [this](int step) { + if (!m_field && !(m_canvas && session().textDraft())) + session().zoomKeyboard(step); + }; + add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [zoomBy] { zoomBy(1); }); + add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [zoomBy] { zoomBy(-1); }); add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); }) ->setCheckable(true); add(view, QStringLiteral("snap"), QStringLiteral("Snap"), QKeySequence(), [this] { session().setSnappingEnabled(!session().snappingEnabled()); }) diff --git a/src/UI/ProjectTabs.cpp b/src/UI/ProjectTabs.cpp index ff5e612..5475051 100644 --- a/src/UI/ProjectTabs.cpp +++ b/src/UI/ProjectTabs.cpp @@ -4,9 +4,13 @@ #include <QDragEnterEvent> #include <QDropEvent> #include <QEvent> +#include <QFontMetricsF> #include <QHBoxLayout> #include <QPainter> #include <QScrollBar> +#include <QStyleOptionToolButton> +#include <QStylePainter> +#include <QTimer> namespace { QFont tabFont(bool active) @@ -16,17 +20,40 @@ QFont tabFont(bool active) font.setWeight(active ? QFont::DemiBold : QFont::Medium); return font; } + +// Swift's leading label: text from the left, 8 kept after. +class TabTitle : public QToolButton { +public: + using QToolButton::QToolButton; + +protected: + void paintEvent(QPaintEvent *) override + { + QStylePainter painter(this); + QStyleOptionToolButton option; + initStyleOption(&option); + option.text.clear(); + painter.drawComplexControl(QStyle::CC_ToolButton, option); + painter.setPen(palette().color(isEnabled() ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText)); + painter.drawText(rect().adjusted(0, 0, -8, 0), Qt::AlignLeft | Qt::AlignVCenter, text()); + } +}; + +// Swift's projectTabLabelWidth: the title's own width, 35 to 155. +int labelWidth(const QString &title, bool active, bool modified) +{ + const double titleWidth = QFontMetricsF(tabFont(active)).horizontalAdvance(title); + return int(std::min(155.0, std::max(35.0, std::ceil(titleWidth) + (modified ? 10 : 0)))); +} } ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent) - : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new QToolButton(this)), m_close(new QToolButton(this)) + : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this)) { // 28 high: the strip's 34 less its margins. m_select->setObjectName(QStringLiteral("selectTab")); m_select->setAutoRaise(true); m_select->setFocusPolicy(Qt::NoFocus); - m_select->setMinimumWidth(35); - m_select->setMaximumWidth(155); m_close->setObjectName(QStringLiteral("closeTab")); m_close->setAutoRaise(true); m_close->setFocusPolicy(Qt::NoFocus); @@ -48,9 +75,15 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr< void ProjectTabButton::synchronize() { const bool active = m_workspace.selectedID() == tab->id; - // Swift's dot for unsaved changes stands before the title. - m_select->setText((tab->session.isModified() ? QStringLiteral("● ") : QString()) + tab->title()); + // The unsaved dot leads; fixed widths move later tabs only. + const bool modified = tab->session.isModified(); + const int label = labelWidth(tab->title(), active, modified); + const QString dot = modified ? QStringLiteral("● ") : QString(); + 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)); m_select->setToolTip(tab->title()); m_select->setEnabled(m_workspace.canSwitch() || active); m_close->setToolTip(QStringLiteral("Close %1").arg(tab->title())); @@ -265,18 +298,21 @@ void ProjectTabStrip::synchronize() bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event) { // A title changed size: the row takes its new width. - if (event->type() == QEvent::LayoutRequest) { + if (event->type() == QEvent::LayoutRequest) m_row->resize(m_row->sizeHint()); - showFront(); - } return QScrollArea::eventFilter(watched, event); } -// The front tab scrolls into view. +// Only a new front tab scrolls into view. void ProjectTabStrip::showFront() { - for (ProjectTabButton *button : buttons()) { - if (button->tab->id == m_workspace.selectedID()) - ensureWidgetVisible(button, 0, 0); - } + if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID()) + return; + // Posted, as Swift's: the row's layout request runs first. + QTimer::singleShot(0, this, [this] { + for (ProjectTabButton *button : buttons()) { + if (button->tab->id == m_workspace.selectedID()) + ensureWidgetVisible(button, 0, 0); + } + }); } diff --git a/src/UI/ProjectTabs.h b/src/UI/ProjectTabs.h index 6dc9bc7..300f4eb 100644 --- a/src/UI/ProjectTabs.h +++ b/src/UI/ProjectTabs.h @@ -70,4 +70,6 @@ private: ProjectWorkspace &m_workspace; QWidget *const m_row; + // Swift's lastSelectedID: only a new front tab scrolls. + std::optional<QUuid> m_shownFront; }; diff --git a/src/UI/ProjectWorkspaceView.cpp b/src/UI/ProjectWorkspaceView.cpp index 1991d1c..8decdf1 100644 --- a/src/UI/ProjectWorkspaceView.cpp +++ b/src/UI/ProjectWorkspaceView.cpp @@ -41,14 +41,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget connect(m_newCanvas, &QAction::triggered, this, [this] { m_workspace.current().controller.newCanvas(); }); connect(m_fit, &QAction::triggered, this, [this] { m_workspace.current().session.fit(); }); connect(m_actualPixels, &QAction::triggered, this, [this] { m_workspace.current().session.zoom(1); }); - connect(m_zoomIn, &QAction::triggered, this, [this] { - EditorSession &session = m_workspace.current().session; - session.zoom(session.viewport.zoom() * 1.25); - }); - connect(m_zoomOut, &QAction::triggered, this, [this] { - EditorSession &session = m_workspace.current().session; - session.zoom(session.viewport.zoom() / 1.25); - }); + connect(m_zoomIn, &QAction::triggered, this, [this] { m_workspace.current().session.zoomKeyboard(1); }); + connect(m_zoomOut, &QAction::triggered, this, [this] { m_workspace.current().session.zoomKeyboard(-1); }); connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectWorkspaceView::synchronize); synchronize(); } diff --git a/tests/KeyboardZoomTests.cpp b/tests/KeyboardZoomTests.cpp new file mode 100644 index 0000000..354459f --- /dev/null +++ b/tests/KeyboardZoomTests.cpp @@ -0,0 +1,230 @@ +#include "Rendering/EditorCanvas.h" +#include "UI/KeyboardShortcuts.h" +#include "UI/ProjectWorkspaceView.h" +#include <QLineEdit> +#include <QStandardPaths> +#include <QtTest> + +// Swift's stepped keyboard zoom: the stops, the centre, the keys. +namespace { +void expectClose(double actual, double expected) +{ + QVERIFY2(std::abs(actual - expected) < 0.000001, qPrintable(QString::number(actual, 'g', 12))); +} +} + +class KeyboardZoomTests : public QObject { + Q_OBJECT +private slots: + void initTestCase(); + void keyboardZoomKeepsPannedViewportCenterFixed(); + void keyboardZoomUsesStableStopsAndClampsAtTheEnds(); + void keyboardZoomRoundTripDoesNotDriftAfterTenSteps(); + void keyboardZoomKeepsSmallCanvasCentered(); + void keyboardZoomFromFitUsesNextStopAndLeavesFitMode(); + void commandZoomUpdatesOnKeyDownAndRepeat(); +}; + +void KeyboardZoomTests::initTestCase() +{ + // Remapped keys live in QSettings: the tests' own folder. + QStandardPaths::setTestModeEnabled(true); + ShortcutSettings::shared().save({}); +} + +void KeyboardZoomTests::keyboardZoomKeepsPannedViewportCenterFixed() +{ + EditorSession session; + session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt); + session.createDocument(3000, 2000); + session.zoom(1); + session.viewport.translate(QSizeF(-620, 185)); + const QPointF center = session.viewport.center(); + const QSizeF canvas(3000, 2000); + const QPointF before = session.viewport.documentPoint(center, canvas); + session.zoomKeyboard(1); + const QPointF after = session.viewport.documentPoint(center, canvas); + QCOMPARE(session.viewport.zoom(), 1.25); + expectClose(before.x(), after.x()); + expectClose(before.y(), after.y()); + expectClose(session.viewport.viewPoint(after, canvas).x(), center.x()); + expectClose(session.viewport.viewPoint(after, canvas).y(), center.y()); +} + +void KeyboardZoomTests::keyboardZoomUsesStableStopsAndClampsAtTheEnds() +{ + EditorSession session; + session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt); + session.createDocument(3000, 2000); + session.zoom(0.5); + session.zoomKeyboard(1); + expectClose(session.viewport.zoom(), 2.0 / 3.0); + session.zoomKeyboard(1); + QCOMPARE(session.viewport.zoom(), 1.0); + session.zoomKeyboard(1); + QCOMPARE(session.viewport.zoom(), 1.25); + session.zoom(CanvasViewport::keyboardZoomLevels.front()); + session.zoomKeyboard(-1); + QCOMPARE(session.viewport.zoom(), CanvasViewport::keyboardZoomLevels.front()); + session.zoom(CanvasViewport::keyboardZoomLevels.back()); + session.zoomKeyboard(1); + QCOMPARE(session.viewport.zoom(), CanvasViewport::keyboardZoomLevels.back()); + // Every stop, both ways; past the end, nothing. + const std::array expected{0.125, 1.0 / 6.0, 0.25, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0}; + QVERIFY(CanvasViewport::keyboardZoomLevels == expected); + session.zoom(0.1); + for (const double stop : expected) { + session.zoomKeyboard(1); + QCOMPARE(session.viewport.zoom(), stop); + } + QSignalSpy changed(&session, &EditorSession::changed); + session.zoomKeyboard(1); + session.zoomKeyboard(0); + QCOMPARE(changed.count(), 0); + session.zoom(20); + for (auto stop = expected.rbegin(); stop != expected.rend(); ++stop) { + session.zoomKeyboard(-1); + QCOMPARE(session.viewport.zoom(), *stop); + } + // Step 0 stays; between stops, the next each way. + session.zoom(0.7); + QCOMPARE(session.viewport.keyboardZoomTarget(0), 0.7); + QCOMPARE(session.viewport.keyboardZoomTarget(-1), 2.0 / 3.0); + QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.0); + session.zoom(1.0000000001); + QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.25); + QCOMPARE(session.viewport.keyboardZoomTarget(-1), 2.0 / 3.0); + // The tolerance from each side: its floor and its share. + session.zoom(0.9999999995); + QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.25); + session.zoom(12.000000006); + QCOMPARE(session.viewport.keyboardZoomTarget(-1), 8.0); + session.zoom(0.49999999925); + QCOMPARE(session.viewport.keyboardZoomTarget(1), 2.0 / 3.0); + // No document, nothing. + EditorSession empty; + empty.zoomKeyboard(1); + QCOMPARE(empty.viewport.zoom(), 1.0); +} + +void KeyboardZoomTests::keyboardZoomRoundTripDoesNotDriftAfterTenSteps() +{ + EditorSession session; + session.viewport.resize(QSizeF(1200, 900), 2, std::nullopt); + session.createDocument(4000, 3000); + session.zoom(0.5); + session.viewport.translate(QSizeF(-430, 275)); + const QPointF center = session.viewport.center(); + const QSizeF canvas(4000, 3000); + const QPointF before = session.viewport.documentPoint(center, canvas); + for (int step = 0; step < 10; ++step) + session.zoomKeyboard(1); + for (int step = 0; step < 10; ++step) + session.zoomKeyboard(-1); + const QPointF after = session.viewport.documentPoint(center, canvas); + QCOMPARE(session.viewport.zoom(), 0.5); + expectClose(before.x(), after.x()); + expectClose(before.y(), after.y()); +} + +void KeyboardZoomTests::keyboardZoomKeepsSmallCanvasCentered() +{ + EditorSession session; + session.viewport.resize(QSizeF(1200, 900), 1, std::nullopt); + session.createDocument(200, 100); + session.zoom(0.5); + const QPointF center = session.viewport.center(); + const QSizeF canvas(200, 100); + const QPointF before = session.viewport.documentPoint(center, canvas); + session.zoomKeyboard(1); + const QPointF after = session.viewport.documentPoint(center, canvas); + expectClose(session.viewport.zoom(), 2.0 / 3.0); + expectClose(before.x(), 100); + expectClose(before.y(), 50); + expectClose(before.x(), after.x()); + expectClose(before.y(), after.y()); +} + +void KeyboardZoomTests::keyboardZoomFromFitUsesNextStopAndLeavesFitMode() +{ + EditorSession session; + session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt); + session.createDocument(1500, 1000); + session.fit(); + const QPointF center = session.viewport.center(); + const QSizeF canvas(1500, 1000); + const QPointF before = session.viewport.documentPoint(center, canvas); + session.zoomKeyboard(1); + const QPointF after = session.viewport.documentPoint(center, canvas); + expectClose(session.viewport.zoom(), 2.0 / 3.0); + QVERIFY(!session.viewport.followsFit()); + expectClose(before.x(), after.x()); + expectClose(before.y(), after.y()); +} + +void KeyboardZoomTests::commandZoomUpdatesOnKeyDownAndRepeat() +{ + // The app: its menu holds Ctrl+=, Ctrl+- and keypad minus. + ProjectWorkspace workspace; + ProjectWorkspaceView window(workspace); + window.resize(900, 600); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + EditorSession &session = workspace.current().session; + session.createDocument(3000, 2000); + session.zoom(1); + CanvasView &canvas = *window.findChild<CanvasView *>(); + canvas.setFocus(); + QTRY_VERIFY(canvas.hasFocus()); + QTest::keyClick(&canvas, Qt::Key_Equal, Qt::ControlModifier); + QCOMPARE(session.viewport.zoom(), 1.25); + // Ctrl++ and its repeat, and keypad plus: the canvas's filter. + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.viewport.zoom(), 1.5); + QKeyEvent repeat(QEvent::KeyPress, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier, QStringLiteral("+"), true); + QApplication::sendEvent(&canvas, &repeat); + QCOMPARE(session.viewport.zoom(), 2.0); + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::KeypadModifier); + QCOMPARE(session.viewport.zoom(), 3.0); + QTest::keyClick(&canvas, Qt::Key_Minus, Qt::ControlModifier); + QCOMPARE(session.viewport.zoom(), 2.0); + QTest::keyClick(&canvas, Qt::Key_Minus, Qt::ControlModifier | Qt::KeypadModifier); + QCOMPARE(session.viewport.zoom(), 1.5); + // Shift-minus, Alt or Meta, or no Ctrl: no zoom. + QTest::keyClick(&canvas, Qt::Key_Underscore, Qt::ControlModifier | Qt::ShiftModifier); + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::AltModifier); + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::MetaModifier); + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ShiftModifier); + QCOMPARE(session.viewport.zoom(), 1.5); + // A remapped Zoom In leaves Ctrl++ alone. + QVERIFY(ShortcutSettings::shared().save({{QStringLiteral("Menus:Zoom In"), ShortcutChord(QStringLiteral("k"), 1)}})); + QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.viewport.zoom(), 1.5); + ShortcutSettings::shared().save({}); + // A text field keeps its keys. + auto *field = new QLineEdit(&window); + field->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + QTest::keyClick(field, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.viewport.zoom(), 1.5); + // No document: Ctrl++ passes on to whoever holds focus. + struct Keys : QWidget { + int count = 0; + void keyPressEvent(QKeyEvent *event) override { count += event->key() == Qt::Key_Plus; } + } *keys = new Keys; + keys->setParent(&window); + keys->setFocusPolicy(Qt::StrongFocus); + keys->show(); + keys->setFocus(); + QTRY_VERIFY(keys->hasFocus()); + QTest::keyClick(keys, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(session.viewport.zoom(), 2.0); + QCOMPARE(keys->count, 0); + session.clearProject(); + QTest::keyClick(keys, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(keys->count, 1); +} + +QTEST_MAIN(KeyboardZoomTests) +#include "KeyboardZoomTests.moc" diff --git a/tests/ProjectTabStripTests.cpp b/tests/ProjectTabStripTests.cpp new file mode 100644 index 0000000..d2a5d6e --- /dev/null +++ b/tests/ProjectTabStripTests.cpp @@ -0,0 +1,146 @@ +#include "UI/ProjectTabs.h" +#include <QFontMetricsF> +#include <QScrollBar> +#include <QToolButton> +#include <QtTest> + +// Swift 1.2.4's steady tab strip: fixed widths, a kept offset. +namespace { +QToolButton &select(ProjectTabButton &button) +{ + return *button.findChild<QToolButton *>(QStringLiteral("selectTab")); +} + +// Swift's projectTabLabelWidth, from the font Swift names. +int label(const QString &title, bool active) +{ + QFont font; + font.setPixelSize(12); + font.setWeight(active ? QFont::DemiBold : QFont::Medium); + return int(std::min(155.0, std::max(35.0, std::ceil(QFontMetricsF(font).horizontalAdvance(title))))); +} +} + +class ProjectTabStripTests : public QObject { + Q_OBJECT +private slots: + void eachTabIsAsWideAsItsTitle(); + void aTitleMovesOnlyTheTabsAfterIt(); + void onlyANewFrontTabScrollsIntoView(); +}; + +void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() +{ + ProjectWorkspace workspace; + workspace.current().session.setProjectPath(QStringLiteral("/p/Ab.comp")); + workspace.newCanvas(); + workspace.current().session.setProjectPath(QStringLiteral("/p/A title long enough to pass the pill's widest width.comp")); + workspace.newCanvas(); + ProjectTabStrip strip(workspace); + const QList<ProjectTabButton *> buttons = strip.buttons(); + QCOMPARE(buttons.size(), 3); + // Short: 35 at least. Long: 155, elided. Front: semibold. + 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")); + // Swift's pill: label, 11 before, 8 after, 16, 5. + buttons[2]->adjustSize(); + QCOMPARE(buttons[2]->width(), label(QStringLiteral("Untitled 3"), 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); + // An unsaved long title elides within Swift's width less 10. + workspace.select(workspace.tabs()[1]->id); + workspace.current().session.createDocument(8, 8); + workspace.current().session.addBlankLayer(); + const QString long_ = workspace.current().title(); + const QFontMetrics bold(select(*buttons[1]).font()); + 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); +} + +void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt() +{ + ProjectWorkspace workspace; + workspace.newCanvas(); + workspace.newCanvas(); + ProjectTabStrip strip(workspace); + strip.resize(1000, 34); + strip.show(); + QVERIFY(QTest::qWaitForWindowExposed(&strip)); + const QList<ProjectTabButton *> buttons = strip.buttons(); + // Swift's leading label: a short title's ink starts left. + workspace.tabs()[0]->session.setProjectPath(QStringLiteral("/p/I.comp")); + QToolButton &title = select(*buttons[0]); + QTRY_COMPARE(title.text(), QString("I")); + const QImage drawn = title.grab().toImage(); + const QColor ground = drawn.pixelColor(title.width() - 3, 2); + int left = drawn.width(); + for (int y = 0; y < drawn.height(); ++y) { + for (int x = 0; x < drawn.width(); ++x) { + if (drawn.pixelColor(x, y) != ground) + left = std::min(left, x); + } + } + QVERIFY2(left <= 3 && title.width() == 43, qPrintable(QString::number(left))); + QTRY_COMPARE(buttons[0]->width(), 35 + 40); + QTRY_COMPARE(buttons[1]->x(), 75 + 6); + const int first = buttons[0]->x(), second = buttons[1]->x(), third = buttons[2]->x(); + QCOMPARE(first, 0); + workspace.tabs()[1]->session.setProjectPath(QStringLiteral("/p/A much longer title here.comp")); + QTRY_VERIFY(buttons[2]->x() > third); + QCOMPARE(buttons[0]->x(), first); + QCOMPARE(buttons[1]->x(), second); + // Tabs sit six apart. + QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6); +} + +void ProjectTabStripTests::onlyANewFrontTabScrollsIntoView() +{ + ProjectWorkspace workspace; + for (int tab = 0; tab < 11; ++tab) + workspace.newCanvas(); + ProjectTabStrip strip(workspace); + strip.resize(300, 34); + strip.show(); + QVERIFY(QTest::qWaitForWindowExposed(&strip)); + QScrollBar &bar = *strip.horizontalScrollBar(); + // Scrolled back, another tab's change keeps the offset. + QTRY_VERIFY(bar.value() == bar.maximum() && bar.maximum() > 0); + // Scrolled back by hand, another tab's change keeps the offset. + bar.setValue(40); + workspace.tabs()[3]->session.setProjectPath(QStringLiteral("/p/Renamed.comp")); + QCoreApplication::processEvents(); + QCOMPARE(bar.value(), 40); + // The front tab turns unsaved: a dot, the offset kept. + workspace.current().session.createDocument(8, 8); + workspace.current().session.history.markSaved(); + QVERIFY(!workspace.current().session.isModified()); + workspace.current().session.addBlankLayer(); + QVERIFY(workspace.current().session.isModified()); + QCoreApplication::processEvents(); + QVERIFY(select(*strip.buttons().last()).text().startsWith(QStringLiteral("●"))); + QCOMPARE(bar.value(), 40); + // A tab closed behind is no new front tab either. + const QUuid front = workspace.selectedID(); + workspace.removeTab(workspace.tabs()[5]->id); + QCOMPARE(workspace.selectedID(), front); + QCoreApplication::processEvents(); + QCOMPARE(bar.value(), 40); + // Choosing another tab brings it in, as little as needed. + workspace.select(workspace.tabs()[0]->id); + QTRY_COMPARE(bar.value(), 0); + workspace.select(workspace.tabs()[1]->id); + QCOMPARE(bar.value(), 0); +} + +QTEST_MAIN(ProjectTabStripTests) +#include "ProjectTabStripTests.moc" diff --git a/tests/ProjectWorkspaceViewTests.cpp b/tests/ProjectWorkspaceViewTests.cpp index f9c0276..a62448a 100644 --- a/tests/ProjectWorkspaceViewTests.cpp +++ b/tests/ProjectWorkspaceViewTests.cpp @@ -156,13 +156,14 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() QCOMPARE(buttons[0]->height(), 28); QCOMPARE(buttons[0]->sizeHint().height(), 28); QCOMPARE(buttons[0]->findChild<QToolButton *>("closeTab")->size(), QSize(16, 28)); - // Titles stop at 155 and start at 35. + // Titles span 35 to 155, plus 8 and the dot. QToolButton &title = *buttons[0]->findChild<QToolButton *>("selectTab"); + const int dot = QFontMetrics(title.font()).horizontalAdvance(QStringLiteral("● ")) - 10; workspace.tabs()[0]->session.setProjectPath(QString("/x/") + QString(80, QLatin1Char('W')) + ".comp"); - QTRY_COMPARE(title.width(), 155); + QTRY_COMPARE(title.width(), 155 + 8 + dot); QCOMPARE(buttons[0]->height(), 28); workspace.tabs()[0]->session.setProjectPath(QString("/x/I.comp")); - QTRY_COMPARE(title.width(), 35); + QTRY_COMPARE(title.width(), 35 + 8 + dot); workspace.tabs()[0]->session.setProjectPath(std::nullopt); QCOMPARE(buttons[1]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::DemiBold); QCOMPARE(buttons[0]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::Medium); @@ -210,22 +211,21 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() const auto wholeInView = [&](const ProjectTabButton *button) { return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size())); }; - // In view at once, and still after the layout settles. - QVERIFY(last->tab->id == workspace.selectedID() && wholeInView(last)); + // In view once laid out, as Swift posts its reveal. + QVERIFY(last->tab->id == workspace.selectedID()); + QTRY_VERIFY(wholeInView(last)); QVERIFY(strip.widget()->width() > strip.viewport()->width()); QVERIFY(last->findChild<QToolButton *>("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0); - QCoreApplication::processEvents(); - QVERIFY(wholeInView(last)); - // The front title grows: the scroll follows the layout. + // Since 1.2.4 a growing title keeps the offset. + const int offset = strip.horizontalScrollBar()->value(); workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp"); QCoreApplication::processEvents(); QCoreApplication::processEvents(); - QVERIFY(last->width() > 180 && wholeInView(last)); + QVERIFY(last->width() > 180); + QCOMPARE(strip.horizontalScrollBar()->value(), offset); workspace.select(workspace.tabs()[0]->id); const ProjectTabButton *head = strip.buttons().first(); - QVERIFY(wholeInView(head)); - QCoreApplication::processEvents(); - QVERIFY(wholeInView(head)); + QTRY_VERIFY(wholeInView(head)); } void ProjectWorkspaceViewTests::tabButtonsSelectAndClose() @@ -271,7 +271,7 @@ void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession() QVERIFY(fit.isEnabled() && actual.isEnabled() && in.isEnabled() && out.isEnabled()); actual.trigger(); out.trigger(); - QCOMPARE(session.viewport.zoom(), 0.8); + QCOMPARE(session.viewport.zoom(), 2.0 / 3.0); in.trigger(); in.trigger(); QCOMPARE(session.viewport.zoom(), 1.25); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 662ea65..26663b0 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -150,6 +150,8 @@ compositor_test(ProjectWorkspaceFlowTests) compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests) compositor_test(ProjectWorkspaceViewTests) +compositor_test(ProjectTabStripTests) +compositor_test(KeyboardZoomTests) # It starts the real app beside itself. add_dependencies(ProjectWorkspaceViewTests omaphoto) compositor_test(ProjectSessionTests) diff --git a/tests/ViewMenuTests.cpp b/tests/ViewMenuTests.cpp index 48cd876..20a6655 100644 --- a/tests/ViewMenuTests.cpp +++ b/tests/ViewMenuTests.cpp @@ -1,4 +1,6 @@ #include "MenuFixtures.h" +#include "Rendering/EditorCanvas.h" +#include <QLineEdit> // The View menu: zoom, the pixel grid and Snap. class ViewMenuTests : public QObject { @@ -24,9 +26,32 @@ void ViewMenuTests::viewEntriesZoomTheFrontSession() QCOMPARE(bar.session().viewport.zoom(), 1.25); bar.action("zoomOut").trigger(); bar.action("zoomOut").trigger(); - QCOMPARE(bar.session().viewport.zoom(), 0.8); + QCOMPARE(bar.session().viewport.zoom(), 2.0 / 3.0); bar.action("fit").trigger(); QVERIFY(bar.session().viewport.followsFit()); + // A text field or open text keeps the keys. + bar.window.show(); + QVERIFY(QTest::qWaitForWindowActive(&bar.window)); + bar.session().zoom(1); + auto *field = new QLineEdit(&bar.window); + field->show(); + field->setFocus(); + QTRY_VERIFY(field->hasFocus()); + bar.action("zoomIn").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 1.0); + auto &canvas = *bar.window.findChild<CanvasView *>(); + canvas.setFocus(); + QTRY_VERIFY(canvas.hasFocus()); + bar.session().selectTool(NavigationTool::type); + bar.session().beginText(QPointF(10, 10), true); + QVERIFY(bar.session().textDraft()); + canvas.setFocus(); + QTRY_VERIFY(canvas.hasFocus()); + bar.action("zoomIn").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 1.0); + bar.session().cancelText(); + bar.action("zoomIn").trigger(); + QCOMPARE(bar.session().viewport.zoom(), 1.25); } void ViewMenuTests::thePixelGridEntryTogglesTheSession()