diff --git a/AGENTS.md b/AGENTS.md
index bf11ca4..3010bb3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -163,6 +163,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/JPEGExportSheet.h`, defined in `JPEGExportSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `JPEGExportSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average).
- Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`.
- Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`.
+- Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
diff --git a/TASKS.md b/TASKS.md
index f40d3b5..6820881 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -429,7 +429,8 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`)
- 13.5a review (Fable 5.1): round 1 FAIL (test gaps: rounding, the targets without centres, a path's own box, the drag's reach when zoomed); round 2 FAIL (the nearer of two lines); round 3 FAIL (the nearer line neither first nor lowest). All fixed, each mutant caught; closed after the third round.
- [x] 13.5b Part four: View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`).
- 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows).
-- [ ] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
+- [x] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
+- 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS.
- [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
- [ ] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`).
- [ ] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`).
diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h
index d4b495d..4e237ea 100644
--- a/src/Document/EditorSession.h
+++ b/src/Document/EditorSession.h
@@ -85,12 +85,10 @@ public:
// Where the project lives on disk, once opened or saved.
const std::optional &projectPath() const { return m_projectPath; }
void setProjectPath(std::optional path);
-
bool canStartProjectOperation() const;
// `ready` runs at once, or when the session allows.
void waitForFileRequest(std::function ready);
void waitForProjectAccess(std::function ready);
-
void selectLayer(std::optional id);
void selectTool(NavigationTool value);
// Tab: the current tool's next mode.
@@ -432,6 +430,8 @@ private:
mutable std::map m_shapeTransformPreviewCache;
std::optional m_textDraft;
LayerTextStyle m_textDefaults;
+ // The text's style before the font menu previewed faces.
+ std::optional m_fontPreviewOriginal;
std::optional m_cloneSource;
CloneSettings m_cloneSettings;
// The offset aligned strokes keep, fixed as a stroke begins.
diff --git a/src/Document/TypeTool+Session.h b/src/Document/TypeTool+Session.h
index 5199eb3..f318ec4 100644
--- a/src/Document/TypeTool+Session.h
+++ b/src/Document/TypeTool+Session.h
@@ -17,6 +17,12 @@ public:
bool recolorText(QUuid id, const PaletteColor &color);
LayerTextStyle currentTextStyle() const;
void changeTextStyle(const std::function &change);
+ // The open font menu tries the hovered face on text.
+ void previewFont(const QString &name);
+ // Chosen: the text stays as it shows.
+ void keepFontPreview();
+ // Closed without a choice: the text goes back.
+ void endFontPreview();
// A text layer's name: its first words on one line.
static QString layerName(const QString &content);
// Point text's size: what it measures, plus padding.
diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp
index f39543f..170754c 100644
--- a/src/Document/TypeTool.cpp
+++ b/src/Document/TypeTool.cpp
@@ -255,6 +255,39 @@ LayerTextStyle EditorSession::currentTextStyle() const
return active && active->liveText() ? active->liveText()->style : m_textDefaults;
}
+// Only text being edited; a selected layer is not opened.
+void EditorSession::previewFont(const QString &name)
+{
+ if (!m_textDraft)
+ return;
+ TextDraft draft = *m_textDraft;
+ if (!m_fontPreviewOriginal)
+ m_fontPreviewOriginal = draft.style;
+ LayerTextStyle style = *m_fontPreviewOriginal;
+ style.setFont(name, draft.selection);
+ if (!style.isValid() || style == draft.style)
+ return;
+ draft.style = style;
+ setTextDraft(draft);
+}
+
+void EditorSession::keepFontPreview()
+{
+ m_fontPreviewOriginal.reset();
+}
+
+void EditorSession::endFontPreview()
+{
+ if (!m_fontPreviewOriginal)
+ return;
+ const LayerTextStyle original = *std::exchange(m_fontPreviewOriginal, std::nullopt);
+ if (m_textDraft && m_textDraft->style != original) {
+ TextDraft draft = *m_textDraft;
+ draft.style = original;
+ setTextDraft(draft);
+ }
+}
+
void EditorSession::changeTextStyle(const std::function &change)
{
const std::optional active = activeLayer();
diff --git a/src/UI/TypeControls.cpp b/src/UI/TypeControls.cpp
index 28733da..f7b6246 100644
--- a/src/UI/TypeControls.cpp
+++ b/src/UI/TypeControls.cpp
@@ -1,5 +1,8 @@
#include "UI/TypeControls.h"
#include
+#include
+#include
+#include
#include
#include
#include "UI/ColorPaletteControls.h"
@@ -91,13 +94,71 @@ void TextStyleField::focusOutEvent(QFocusEvent *event)
QLineEdit::focusOutEvent(event);
}
-TypeFontPicker::TypeFontPicker(QWidget *parent) : QComboBox(parent)
-{
- setAccessibleName(QStringLiteral("Font"));
-}
-
namespace {
const QString multiple = QStringLiteral("(Multiple)");
+
+// A face draws its own name: each letter, its glyph.
+bool drawsOwnName(const QString &name)
+{
+ const QRawFont face = QRawFont::fromFont(TextLayout::font(LayerTextStyle(), name));
+ for (const char32_t letter : name.toUcs4())
+ if (QChar::isLetter(letter) && !face.supportsCharacter(letter))
+ return false;
+ return true;
+}
+
+// Swift's styledNames: made once, ahead, off the UI thread.
+QHash &ownNames()
+{
+ static QHash names;
+ return names;
+}
+
+QFuture> &preparing()
+{
+ static QFuture> future;
+ return future;
+}
+
+void prepareOwnNames()
+{
+ if (preparing().isValid())
+ return;
+ preparing() = QtConcurrent::run([names = TextLayout::availableFonts()] {
+ QHash made;
+ for (const QString &name : names)
+ made.insert(name, drawsOwnName(name));
+ return made;
+ });
+}
+
+// Finished names join; one still missing is made now.
+bool ownName(const QString &name)
+{
+ if (preparing().isFinished() && ownNames().isEmpty())
+ ownNames() = preparing().result();
+ if (!ownNames().contains(name))
+ ownNames().insert(name, drawsOwnName(name));
+ return ownNames().value(name);
+}
+}
+
+TypeFontPicker::TypeFontPicker(std::function preview, QWidget *parent) : QComboBox(parent), m_preview(std::move(preview))
+{
+ setAccessibleName(QStringLiteral("Font"));
+ prepareOwnNames();
+ // Only a face previews; (Multiple) leaves the last one showing.
+ connect(this, &QComboBox::highlighted, this, [this](int index) {
+ if (!isMultiple(index))
+ m_preview(PreviewStep::show, itemText(index));
+ });
+}
+
+// Qt reports a choice after the close: the revert waits.
+void TypeFontPicker::hidePopup()
+{
+ QComboBox::hidePopup();
+ QMetaObject::invokeMethod(this, [this] { m_preview(PreviewStep::revert, QString()); }, Qt::QueuedConnection);
}
bool TypeFontPicker::isMultiple(int index) const
@@ -144,6 +205,15 @@ void TypeFontPicker::showPopup()
const QSignalBlocker quiet(this);
clear();
addItems(names);
+ // Each face's name in that face, at the menu's size.
+ const int pixels = QFontInfo(font()).pixelSize();
+ for (int index = 0; index < count(); ++index) {
+ if (!ownName(itemText(index)))
+ continue;
+ QFont face = TextLayout::font(LayerTextStyle(), itemText(index));
+ face.setPixelSize(pixels);
+ setItemData(index, face, Qt::FontRole);
+ }
if (selected.isEmpty())
showMultiple();
else
@@ -166,7 +236,14 @@ void TypeFontPicker::paintEvent(QPaintEvent *)
}
TypeControls::TypeControls(EditorSession &session, QWidget *parent)
- : ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session), m_font(new TypeFontPicker),
+ : ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session),
+ m_font(new TypeFontPicker([this](TypeFontPicker::PreviewStep step, const QString &name) {
+ switch (step) {
+ case TypeFontPicker::PreviewStep::show: m_session.previewFont(name); break;
+ case TypeFontPicker::PreviewStep::revert: m_session.endFontPreview(); break;
+ case TypeFontPicker::PreviewStep::keep: m_session.keepFontPreview(); break;
+ }
+ })),
m_size(new TextStyleField(
[this] { return number(m_session.currentTextStyle().fontSize, locale()); },
[this](const QString &text) {
@@ -222,6 +299,8 @@ TypeControls::TypeControls(EditorSession &session, QWidget *parent)
m_font->setFixedWidth(210);
m_font->setToolTip(QStringLiteral("Font face, including bold and italic variants"));
connect(m_font, &QComboBox::activated, this, [this](int index) {
+ // The text already shows the face under the pointer: kept.
+ m_session.keepFontPreview();
const QString name = m_font->itemText(index);
// (Multiple), or the face already shown, changes nothing.
if (m_font->isMultiple(index) || name == shownFont())
diff --git a/src/UI/TypeControls.h b/src/UI/TypeControls.h
index d9ee16a..fdd66af 100644
--- a/src/UI/TypeControls.h
+++ b/src/UI/TypeControls.h
@@ -39,10 +39,14 @@ private:
class TypeFontPicker : public QComboBox {
Q_OBJECT
public:
- explicit TypeFontPicker(QWidget *parent = nullptr);
+ // The open menu trying faces on the text, Swift's PreviewStep.
+ enum class PreviewStep { show, revert, keep };
+ explicit TypeFontPicker(std::function preview, QWidget *parent = nullptr);
// Shows `name`, or (Multiple) when empty; an open menu stays.
void sync(const QString &name);
void showPopup() override;
+ // Closed, the text goes back; a choice already kept it.
+ void hidePopup() override;
// Swift's isMultiple: the top item that is no font.
bool isMultiple(int index) const;
@@ -53,6 +57,7 @@ protected:
private:
void showMultiple();
void hideMultiple();
+ const std::function m_preview;
bool m_loaded = false;
};
diff --git a/tests/InlineTextDrawTests.cpp b/tests/InlineTextDrawTests.cpp
index 582a2ec..0d03ac5 100644
--- a/tests/InlineTextDrawTests.cpp
+++ b/tests/InlineTextDrawTests.cpp
@@ -322,6 +322,11 @@ void InlineTextDrawTests::theEditMenuReachesOpenText()
QTRY_VERIFY(canvas->hasFocus());
QTest::keyClicks(canvas, QStringLiteral("menu"));
const auto content = [&] { return session.textDraft().value().style.content; };
+ // Swift 1.3.5: Ctrl+Z takes back the typing in one step.
+ QTest::keyClick(canvas, Qt::Key_Z, Qt::ControlModifier);
+ QCOMPARE(content(), QString());
+ QTest::keyClick(canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
+ QCOMPARE(content(), QString("menu"));
// Swift's text branch: bare entries, enabled, the text view's.
QCOMPARE(bar.action("undo").text(), QString("Undo"));
QVERIFY(bar.action("undo").isEnabled() && bar.action("redo").isEnabled());
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index c6939f2..80012e2 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -183,6 +183,7 @@ compositor_test(JPEGPreviewTests)
compositor_test(SnapDrawingCanvasTests)
compositor_test(GuideGridTests)
compositor_test(GridSettingsSheetTests)
+compositor_test(TypeFontPreviewTests)
# It calls the colour-range kernel itself.
target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(DitherTests)
diff --git a/tests/TypeFontPreviewTests.cpp b/tests/TypeFontPreviewTests.cpp
new file mode 100644
index 0000000..2393ad6
--- /dev/null
+++ b/tests/TypeFontPreviewTests.cpp
@@ -0,0 +1,137 @@
+#include "InlineTextDrawFixtures.h"
+#include "Rendering/TextLayout.h"
+#include "UI/TypeControls.h"
+#include
+#include
+
+// Swift 1.3.5's font previews: faces in themselves, tried on text.
+namespace {
+const QString sans = QStringLiteral("DejaVuSans");
+const QString mono = QStringLiteral("DejaVuSansMono");
+const QString serif = QStringLiteral("DejaVuSerif");
+
+struct Typed : DrawnText {
+ TypeControls *const bar = new TypeControls(session, &window);
+ TypeFontPicker &menu() const { return *bar->findChild(QStringLiteral("typeFont")); }
+ Typed()
+ {
+ bar->setGeometry(0, 300, 400, 40);
+ bar->show();
+ session.changeTextStyle([](LayerTextStyle &style) { style.fontName = sans; });
+ canvas->setFocus();
+ beginTextAt(session, QPointF(20, 30));
+ QTest::keyClicks(canvas, QStringLiteral("Hello"));
+ QTest::keyClick(canvas, Qt::Key_A, Qt::ControlModifier);
+ }
+ QString face() const { return session.textDraft().value().style.uniformFontName({0, 5}).value_or(QString()); }
+ void hover(const QString &name) { emit menu().highlighted(menu().findText(name)); }
+};
+}
+
+class TypeFontPreviewTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theMenuDrawsEachFaceInItself();
+ void hoveringTriesAFaceAndClosingPutsItBack();
+ void aChoiceKeepsWhatShows();
+ void onlyOpenTextPreviews();
+};
+
+void TypeFontPreviewTests::theMenuDrawsEachFaceInItself()
+{
+ Typed typed;
+ typed.session.changeTextStyle([](LayerTextStyle &style) { style.setFont(mono, {0, 2}); });
+ // A desktop font in points: the shown pixel size counts.
+ QFont points = typed.menu().font();
+ points.setPointSize(11);
+ typed.menu().setFont(points);
+ QCOMPARE(typed.menu().font().pixelSize(), -1);
+ typed.menu().showPopup();
+ const int pixels = QFontInfo(typed.menu().font()).pixelSize();
+ QVERIFY(pixels > 0);
+ QVERIFY(typed.menu().isMultiple(0) && !typed.menu().itemData(0, Qt::FontRole).isValid());
+ QVERIFY(typed.menu().count() > 3);
+ for (int index = 1; index < typed.menu().count(); ++index) {
+ const QString name = typed.menu().itemText(index);
+ const QFont face = typed.menu().itemData(index, Qt::FontRole).value();
+ QCOMPARE(face.family(), TextLayout::font(LayerTextStyle(), name).family());
+ QCOMPARE(face.styleName(), TextLayout::font(LayerTextStyle(), name).styleName());
+ QCOMPARE(face.pixelSize(), pixels);
+ }
+ typed.menu().hidePopup();
+}
+
+void TypeFontPreviewTests::hoveringTriesAFaceAndClosingPutsItBack()
+{
+ Typed typed;
+ typed.menu().showPopup();
+ typed.hover(mono);
+ QCOMPARE(typed.face(), mono);
+ // Each face from the text as it was, never stacked.
+ typed.hover(serif);
+ QCOMPARE(typed.face(), serif);
+ typed.menu().hidePopup();
+ QCOMPARE(typed.face(), serif);
+ QTRY_COMPARE(typed.face(), sans);
+ // The next preview starts from the text as it stands.
+ QTest::keyClick(typed.canvas, Qt::Key_End);
+ QTest::keyClicks(typed.canvas, QStringLiteral("!"));
+ QTest::keyClick(typed.canvas, Qt::Key_A, Qt::ControlModifier);
+ typed.menu().showPopup();
+ typed.hover(mono);
+ QCOMPARE(typed.session.textDraft().value().style.content, QString("Hello!"));
+ typed.menu().hidePopup();
+ QTRY_COMPARE(typed.face(), sans);
+ // Only the selected letters take the face tried.
+ typed.session.setTextDraft([&] {
+ TextDraft draft = typed.session.textDraft().value();
+ draft.selection = {0, 2};
+ return draft;
+ }());
+ typed.session.previewFont(mono);
+ QVERIFY(typed.session.textDraft().value().style.uniformFontName({0, 2}) == mono && typed.session.textDraft().value().style.fontNameAt(3) == sans);
+ // Tried and put back unchanged, the text is no news.
+ typed.session.previewFont(sans);
+ typed.session.endFontPreview();
+ QSignalSpy changed(&typed.session, &EditorSession::changed);
+ typed.session.previewFont(sans);
+ typed.session.endFontPreview();
+ QVERIFY(changed.isEmpty());
+}
+
+void TypeFontPreviewTests::aChoiceKeepsWhatShows()
+{
+ Typed typed;
+ typed.menu().showPopup();
+ typed.hover(mono);
+ // Qt closes the menu, then reports the choice.
+ typed.menu().hidePopup();
+ emit typed.menu().activated(typed.menu().findText(mono));
+ QCoreApplication::processEvents();
+ QCOMPARE(typed.face(), mono);
+ // Kept: the preview's original is gone, so no later revert.
+ QSignalSpy changed(&typed.session, &EditorSession::changed);
+ typed.session.endFontPreview();
+ QVERIFY(changed.isEmpty() && typed.face() == mono);
+ // The face already showing changes nothing, silently.
+ typed.session.previewFont(mono);
+ QVERIFY(changed.isEmpty());
+ typed.session.keepFontPreview();
+ typed.session.endFontPreview();
+ QVERIFY(changed.isEmpty() && typed.face() == mono);
+}
+
+void TypeFontPreviewTests::onlyOpenTextPreviews()
+{
+ Typed typed;
+ QVERIFY(typed.session.finishText());
+ const LayerTextStyle kept = typed.session.activeLayer().value().liveText().value().style;
+ QSignalSpy changed(&typed.session, &EditorSession::changed);
+ typed.session.previewFont(mono);
+ typed.session.endFontPreview();
+ QVERIFY(changed.isEmpty() && !typed.session.textDraft());
+ QCOMPARE(typed.session.activeLayer().value().liveText().value().style, kept);
+}
+
+QTEST_MAIN(TypeFontPreviewTests)
+#include "TypeFontPreviewTests.moc"