Stepped keyboard zoom and a steady tab strip (12.9b)
This commit is contained in:
1 parent
fdb62978c5
commit
9ac3b1b81c
17 files changed
+533
-38
No files matched your search
@@ -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 Line truncated
|
||||
- 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 <title>"), 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()); })
|
||||
|
||||
+49
-13
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+26
-1
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user