diff --git a/AGENTS.md b/AGENTS.md
index 97e405e..59ea84e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -151,6 +151,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam).
- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`.
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "
. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least).
+- Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through paste, commit, preedit, cut, undo, redo, a replacement by itself, paste and commit around a preedit, a commit over a selection and undo after a preedit, a coloured tail through redo, undone deletions, a commit with a preedit and a paste around one, a preedit over the first letter updated, moved and committed, the first letter's colour at the start for the swatch and the caret, the caret's colour, Swift's swatch keeping the selection, each run drawn without a fringe, the overlay drawing no glyphs, selected or not, with the keys or without), coloured rows in `TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted`, the store's version 10 rows in `ProjectManifestTests`, and version 10 across the store's tests.
- 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 f799a2a..2aad7ff 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -392,7 +392,8 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.14a Version 1.3.1: quitting and closing while text is edited (`a18d86c`, `afd622e`), the Type tool's I-beam over the canvas (`4a4cf85`, `221d6cc`). Codex review: 3 rounds, all test gaps (quit order and refusal across tabs, the real window's close, content and history, existing and blank drafts, the cursor outside the canvas before and after leaving) and one false equivalence claim; fixed and closed after the third.
- [x] 12.14b Version 1.3.2: File > Open Recent (`73dc0f4`), Image Size keeping print sizes through an invalid resolution (`555417e`), Camera Raw's Color Grading under Color (`7e9afbe`); `d62a132` (always dark) judged against the Omarchy theme (holds: alerts follow the app's palette, which follows Omarchy by design). Codex review: 3 rounds; two display defects (a name's ampersand in the menu, a filename read as markup in the alert), two stale test expectations and one false equivalence claim in round 1, the rest test gaps (eye visibility and toggles by group, note boundaries, remembered resolution across units, ranges and resampling, the submenu's place and every gate, the list across processes); fixed and closed after the third.
- [x] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). A fix beyond Swift found by its tests: a sheet or Camera Raw group destroyed while an entry held the keys committed into freed members (`releaseFocus`). Codex review: passed in round 3; round 1 found Camera Raw groups still unsafe to destroy, Hue/Saturation's arrow keys stuck on rounding and right double clicks resetting, with test gaps; round 2 an untested repaint and two stale notes.
-- [ ] 12.14d Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`), sampling keeping a panel's focus (`810c88a`), filters waiting for text editing to finish (`d448625`).
+- [x] 12.14d1 Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): colour runs in the text style, the manifest's version 10, the runs drawn and kept through edits, the palette, picker and Type bar paint the selection, the swatch follows the caret, the selection stays see-through under the picker. Codex review: 3 rounds; round 1 found colours guessed onto the wrong twin letter (now exact steps through every path, undo included) and run glyphs drawn in the clear overlay, with test gaps; round 2 a coloured tail lost across multi-step edits and a preedit over a selection inheriting wrongly; round 3 a leading preedit losing its colour on update or commit and an untested start position; fixed and closed after the third.
+- [ ] 12.14d2 Sampling keeping a panel's focus and the keys going back to open text (`810c88a`), filters, adjustments and Invert waiting for text editing to finish (`d448625`).
- [ ] 12.15 Version 1.3.3: the Dither filter.
- [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun.
- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
diff --git a/docs/project-format.md b/docs/project-format.md
index f53ed44..46290f7 100644
--- a/docs/project-format.md
+++ b/docs/project-format.md
@@ -1,10 +1,10 @@
-# OmaPhoto project format, versions 1–9
+# OmaPhoto project format, versions 1–10
OmaPhoto reads and writes [Compositor](https://github.com/robbietilton/Compositor)'s project format; this page follows Compositor's own description.
A `.comp` project is a folder (a document package on macOS) containing `manifest.json` and an `images/` directory of `.png` assets.
-The manifest identifies `com.compositor.project`, version `9` for new saves (versions `1`–`8` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
+The manifest identifies `com.compositor.project`, version `10` for new saves (versions `1`–`9` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
Embedded PNGs preserve source pixels and transparency; transforms remain separate. Projects survive moving or deleting imported source photos. Saving writes a hidden sibling folder and syncs it. A first save renames it into place; a later save swaps it with the old project atomically (`renameat2` with `RENAME_EXCHANGE`). Where the file system knows no exchange, the old project is moved aside and the new one moved in, two steps with a moment between them and a warning in the log, and the old copy is kept until the new one is synced. Unsupported versions, invalid metadata, missing assets, unsafe paths, and oversized data are rejected before replacing the live document.
@@ -34,6 +34,8 @@ Version 8 lets a folder carry its own `opacity`, which multiplies into every lay
Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian Blur` (`blurRadius`, 0.1–250 document pixels), `Motion Blur` (`motionAngle`, −90 to 90 degrees, and `motionDistance`, 1–2000) and `Add Noise` (`noiseAmount`, 0.1–400, `noiseGaussian`, `noiseMonochromatic` and `noiseSeed`, so the pattern is stable between sessions). Files declaring 1–8 cannot contain these kinds; the earlier adjustment kinds remain valid at version 7 and up.
+Version 10 lets a text layer color some of its letters differently: optional `colorRuns` in its `text` metadata (see Editable text). Files declaring 1–9 cannot contain it.
+
### Additive layer fields
Later fields are optional and not gated on the version, so older readers ignore them and keep the pixels or the linked mask as they were:
@@ -43,7 +45,7 @@ Later fields are optional and not gated on the version, so older readers ignore
### Editable text
-Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then.
+Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). Runs are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color.
### Layer effects
diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md
index 16dc82d..c12b581 100644
--- a/docs/writing-comp-files.md
+++ b/docs/writing-comp-files.md
@@ -28,7 +28,7 @@ A minimal manifest with one full-canvas image layer:
```json
{
"format": "com.compositor.project",
- "version": 9,
+ "version": 10,
"colorSpace": "sRGB",
"documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
"width": 1920,
diff --git a/src/Document/ColorPalette+Session.h b/src/Document/ColorPalette+Session.h
index 7ba3eee..b69eccd 100644
--- a/src/Document/ColorPalette+Session.h
+++ b/src/Document/ColorPalette+Session.h
@@ -27,6 +27,8 @@ public:
void previewEffectColor();
// The open text draft previews the picker's working colour.
void previewTextColor();
+ // Paints the open text's selected letters, or all of it.
+ void setDraftTextColor(const PaletteColor &color);
// Swift writes the picker's hsb in place; here, the session.
void setColorPickerHSB(const PickerHSB &hsb);
void sampleIntoColorPicker(QPointF point);
@@ -34,6 +36,7 @@ public:
std::optional sampleCompositeColor(QPointF point) const;
private:
- void paintText(const PaletteColor &color);
+ // Puts back the open text's colours from the picker's opening.
+ void restoreDraftTextColors(const LayerTextStyle &original);
void setGradientMapColor(const PaletteColor &color, bool highlights);
void setVignetteColor(const PaletteColor &color);
diff --git a/src/Document/ColorPalette.cpp b/src/Document/ColorPalette.cpp
index 4a77594..bb4bd73 100644
--- a/src/Document/ColorPalette.cpp
+++ b/src/Document/ColorPalette.cpp
@@ -156,13 +156,8 @@ void EditorSession::setPaletteColor(const PaletteColor &color, bool background)
} else {
setForegroundColor(color);
// Open text follows the swatch; drafts live under Type.
- if (m_textDraft) {
- changeTextStyle([&color](LayerTextStyle &style) {
- style.red = color.red;
- style.green = color.green;
- style.blue = color.blue;
- });
- }
+ if (m_textDraft)
+ setDraftTextColor(color);
}
}
@@ -198,7 +193,7 @@ void EditorSession::openColorPicker(bool background)
ColorPickerState picker({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
// Open text follows the foreground: it previews, Cancel restores.
if (!background && m_tool == NavigationTool::type && m_textDraft)
- picker.editedText = ColorPickerState::EditedText{m_textDraft->id, {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue}};
+ picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
m_colorPicker = picker;
notify();
}
@@ -207,7 +202,9 @@ PaletteColor EditorSession::typeColor() const
{
if (!m_textDraft)
return foregroundColor();
- return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue};
+ // The first selected letter, else the one before the caret.
+ const TextSpan selection = m_textDraft->selection;
+ return m_textDraft->style.color(selection.length > 0 ? selection.location : std::max(0, selection.location - 1));
}
void EditorSession::openTextColorPicker()
@@ -215,7 +212,10 @@ void EditorSession::openTextColorPicker()
if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type)
return;
const std::optional draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
- m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
+ ColorPickerState picker({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
+ if (m_textDraft)
+ picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
+ m_colorPicker = picker;
notify();
}
@@ -231,12 +231,15 @@ void EditorSession::closeColorPicker(bool commit)
setPaletteColor(color, picker.target.background);
} else if (!commit && picker.target.kind == ColorPickerTarget::Kind::palette && picker.editedText && m_tool == NavigationTool::type
&& draftID == picker.editedText->draftID) {
- paintText(picker.editedText->color);
+ restoreDraftTextColors(picker.editedText->style);
} else if (picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
// The text previewed the working colour; Cancel puts it back.
const PaletteColor chosen = commit ? color : picker.original;
if (draftID) {
- paintText(chosen);
+ if (commit)
+ setDraftTextColor(chosen);
+ else if (picker.editedText)
+ restoreDraftTextColors(picker.editedText->style);
} else if (commit) {
m_textDefaults.red = chosen.red;
m_textDefaults.green = chosen.green;
@@ -295,16 +298,25 @@ void EditorSession::previewTextColor()
? std::optional(m_colorPicker->editedText->draftID)
: std::nullopt;
if (draftID && m_tool == NavigationTool::type && m_textDraft && m_textDraft->id == *draftID)
- paintText(m_colorPicker->color());
+ setDraftTextColor(m_colorPicker->color());
}
-// The open draft's colour.
-void EditorSession::paintText(const PaletteColor &color)
+void EditorSession::setDraftTextColor(const PaletteColor &color)
{
- changeTextStyle([&color](LayerTextStyle &style) {
- style.red = color.red;
- style.green = color.green;
- style.blue = color.blue;
+ if (!m_textDraft)
+ return;
+ const TextSpan selection = m_textDraft->selection;
+ changeTextStyle([&](LayerTextStyle &style) { style.setColor(color, selection); });
+}
+
+void EditorSession::restoreDraftTextColors(const LayerTextStyle &original)
+{
+ changeTextStyle([&original](LayerTextStyle &style) {
+ style.red = original.red;
+ style.green = original.green;
+ style.blue = original.blue;
+ // Retyped since, the runs cannot come back letter for letter.
+ style.colorRuns = original.content == style.content ? original.colorRuns : std::nullopt;
});
}
diff --git a/src/Document/ColorPalette.h b/src/Document/ColorPalette.h
index 1afea3a..b1c6da5 100644
--- a/src/Document/ColorPalette.h
+++ b/src/Document/ColorPalette.h
@@ -1,4 +1,5 @@
#pragma once
+#include "Document/TypeTool.h"
#include
#include
#include
@@ -57,10 +58,10 @@ struct ColorPickerState {
PickerHSB hsb;
// Swift's object identity: a new picker opens its panel anew.
QUuid id = QUuid::createUuid();
- // The foreground picker over open text: that text, its colour.
+ // The picker over open text: that text, its colours then.
struct EditedText {
QUuid draftID;
- PaletteColor color;
+ LayerTextStyle style;
};
std::optional editedText;
PaletteColor color() const { return hsb.rgb().quantized(); }
diff --git a/src/Document/TypeTool.cpp b/src/Document/TypeTool.cpp
index 977a9d4..766002f 100644
--- a/src/Document/TypeTool.cpp
+++ b/src/Document/TypeTool.cpp
@@ -6,7 +6,9 @@
#include "Rendering/TextLayout.h"
#include
#include
+#include
#include
+#include
#include
QString rawValue(TextAlignment alignment)
@@ -44,7 +46,94 @@ bool LayerTextStyle::isValid() const
// Ranges refuse NaN and infinities: Swift's isFinite adds nothing.
const auto within = [](double value, double low, double high) { return value >= low && value <= high; };
return content.size() <= 100'000 && boxIsValid() && within(fontSize, 1, 2000) && within(red, 0, 1) && within(green, 0, 1)
- && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000);
+ && within(blue, 0, 1) && within(tracking, -100, 1000) && within(leading, 0, 5000) && colorRunsAreValid();
+}
+
+bool LayerTextStyle::colorRunsAreValid() const
+{
+ if (!colorRuns)
+ return true;
+ qint64 end = 0;
+ for (const LayerTextColorRun &run : *colorRuns) {
+ // Each after the last, not empty, its end within Int.
+ if (run.location < end || run.length <= 0 || run.location > std::numeric_limits::max() - run.length)
+ return false;
+ for (const double channel : {run.red, run.green, run.blue}) {
+ if (!(channel >= 0 && channel <= 1))
+ return false;
+ }
+ end = run.location + run.length;
+ }
+ return !colorRuns->empty() && end <= content.size();
+}
+
+PaletteColor LayerTextStyle::color(qint64 index) const
+{
+ for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
+ if (run.location <= index && index < run.location + run.length)
+ return {run.red, run.green, run.blue};
+ }
+ return {red, green, blue};
+}
+
+void LayerTextStyle::setColor(const PaletteColor &color, TextSpan span)
+{
+ const qint64 count = content.size();
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ if (start == end || (start == 0 && end == count)) {
+ red = color.red;
+ green = color.green;
+ blue = color.blue;
+ colorRuns = std::nullopt;
+ return;
+ }
+ std::vector colors = unitColors();
+ std::fill(colors.begin() + start, colors.begin() + end, color);
+ setUnitColors(colors);
+}
+
+void LayerTextStyle::replaceCharacters(TextSpan span, qint64 length)
+{
+ if (!colorRuns)
+ return;
+ std::vector colors = unitColors();
+ const qint64 count = qint64(colors.size());
+ const qint64 start = std::clamp(span.location, 0, count);
+ const qint64 end = std::max(start, std::min(span.location + span.length, count));
+ // New letters take the colour before them, as typing does.
+ const PaletteColor inherited = start > 0 ? colors[size_t(start - 1)] : end > start ? colors[size_t(start)] : !colors.empty() ? colors.front() : PaletteColor{red, green, blue};
+ colors.erase(colors.begin() + start, colors.begin() + end);
+ colors.insert(colors.begin() + start, size_t(std::max(0, length)), inherited);
+ setUnitColors(colors);
+}
+
+std::vector LayerTextStyle::unitColors() const
+{
+ std::vector colors(size_t(content.size()), PaletteColor{red, green, blue});
+ for (const LayerTextColorRun &run : colorRuns.value_or(std::vector())) {
+ const qint64 from = std::max(0, run.location), to = std::min(qint64(colors.size()), run.location + run.length);
+ for (qint64 index = from; index < to; ++index)
+ colors[size_t(index)] = {run.red, run.green, run.blue};
+ }
+ return colors;
+}
+
+void LayerTextStyle::setUnitColors(const std::vector &colors)
+{
+ const PaletteColor base{red, green, blue};
+ std::vector runs;
+ for (size_t index = 0; index < colors.size(); ++index) {
+ const PaletteColor &color = colors[index];
+ if (color == base)
+ continue;
+ if (!runs.empty() && size_t(runs.back().location + runs.back().length) == index
+ && PaletteColor{runs.back().red, runs.back().green, runs.back().blue} == color)
+ runs.back().length += 1;
+ else
+ runs.push_back({qint64(index), 1, color.red, color.green, color.blue});
+ }
+ colorRuns = runs.empty() ? std::nullopt : std::optional(runs);
}
std::optional LayerText::loaded(const std::optional &style, const std::optional &image)
@@ -102,7 +191,7 @@ void EditorSession::beginText(QPointF point, bool newLayer)
const QPointF origin = target ? target->origin() : point - QPointF(padding, padding + TextLines(style, QSizeF(100'000, 100'000)).baseline(0));
setTextDraft(TextDraft{.documentID = m_document->id, .layerID = target ? std::optional(target->id) : std::nullopt,
.origin = origin,
- .transform = target ? std::optional(target->transform) : std::nullopt, .style = style});
+ .transform = target ? std::optional(target->transform) : std::nullopt, .style = style, .selection = TextSpan()});
}
void EditorSession::editActiveText()
@@ -112,7 +201,7 @@ void EditorSession::editActiveText()
return;
m_tool = NavigationTool::type;
setTextDraft(TextDraft{.documentID = m_document->id, .layerID = layer->id, .origin = layer->origin(), .transform = layer->transform,
- .style = layer->liveText()->style});
+ .style = layer->liveText()->style, .selection = TextSpan()});
}
bool EditorSession::applyText(TextDraft draft)
@@ -166,6 +255,8 @@ bool EditorSession::applyText(TextDraft draft)
addPixelLayer(image, draft.origin, layerName(draft.style.content), QStringLiteral("New Text Layer"), false, std::nullopt, draft.style);
}
m_textDefaults = draft.style;
+ // The next text starts in one colour.
+ m_textDefaults.colorRuns = std::nullopt;
requestCanvasFocus();
return succeed();
} catch (const ProjectError &error) {
@@ -221,11 +312,9 @@ bool EditorSession::recolorText(QUuid id, const PaletteColor &color)
return false;
const ImageLayer layer = m_document->layers[size_t(index)];
LayerTextStyle style = layer.liveText()->style;
- if (style.red == color.red && style.green == color.green && style.blue == color.blue)
+ if (style.red == color.red && style.green == color.green && style.blue == color.blue && !style.colorRuns)
return true;
- style.red = color.red;
- style.green = color.green;
- style.blue = color.blue;
+ style.setColor(color, TextSpan());
QImage image, thumbnail;
try {
image = textImage(style);
diff --git a/src/Document/TypeTool.h b/src/Document/TypeTool.h
index 0857ab6..4967e92 100644
--- a/src/Document/TypeTool.h
+++ b/src/Document/TypeTool.h
@@ -6,12 +6,32 @@
#include
#include
#include
+#include
enum class TextAlignment { left, center, right };
inline constexpr std::array allTextAlignments{TextAlignment::left, TextAlignment::center, TextAlignment::right};
QString rawValue(TextAlignment alignment);
std::optional textAlignment(const QString &text);
+struct PaletteColor;
+
+// Swift's NSRange: UTF-16 units into a text's content.
+struct TextSpan {
+ qint64 location = 0;
+ qint64 length = 0;
+ friend bool operator==(const TextSpan &, const TextSpan &) = default;
+};
+
+// Letters in a colour of their own, as Swift's LayerTextColorRun.
+struct LayerTextColorRun {
+ qint64 location = 0;
+ qint64 length = 0;
+ double red = 0;
+ double green = 0;
+ double blue = 0;
+ friend bool operator==(const LayerTextColorRun &, const LayerTextColorRun &) = default;
+};
+
// What a text layer says and how, in layer pixels.
struct LayerTextStyle {
QString content = QStringLiteral("Text");
@@ -27,13 +47,26 @@ struct LayerTextStyle {
double leading = 0;
// A paragraph's fixed box; none makes point text.
std::optional boxSize = std::nullopt;
+ // Sorted, apart, within the content; none is one colour.
+ std::optional> colorRuns = std::nullopt;
// The gap between the text and its box, either kind.
static constexpr double padding = 12;
double autoLeading() const { return fontSize * 1.2; }
double lineHeight() const { return leading > 0 ? leading : autoLeading(); }
bool boxIsValid() const;
bool isValid() const;
+ // The colour of the UTF-16 unit at `index`.
+ PaletteColor color(qint64 index) const;
+ // Paints `span`; empty or whole, it recolours all the text.
+ void setColor(const PaletteColor &color, TextSpan span);
+ // Moves the colours as `span` becomes `length` units.
+ void replaceCharacters(TextSpan span, qint64 length);
friend bool operator==(const LayerTextStyle &, const LayerTextStyle &) = default;
+
+private:
+ bool colorRunsAreValid() const;
+ std::vector unitColors() const;
+ void setUnitColors(const std::vector &colors);
};
// A text layer: its style and the image it drew.
@@ -52,5 +85,7 @@ struct TextDraft {
QPointF origin;
std::optional transform = std::nullopt;
LayerTextStyle style;
+ // What the editor selects; colour applies to it alone.
+ TextSpan selection;
friend bool operator==(const TextDraft &, const TextDraft &) = default;
};
diff --git a/src/IO/ProjectStore+Manifest.cpp b/src/IO/ProjectStore+Manifest.cpp
index 7f0538a..1b77a2b 100644
--- a/src/IO/ProjectStore+Manifest.cpp
+++ b/src/IO/ProjectStore+Manifest.cpp
@@ -102,7 +102,25 @@ QSizeF size(const QJsonValue &value)
return {width, height};
}
-// Synthesized Codable skips defaults: all but the box required.
+// A run's every key is required, as synthesized Codable asks.
+LayerTextColorRun colorRun(const QJsonValue &value)
+{
+ const QJsonObject object = value.toObject();
+ return {integer(object.value("location")), integer(object.value("length")), number(object.value("red")), number(object.value("green")),
+ number(object.value("blue"))};
+}
+
+std::vector colorRuns(const QJsonValue &value)
+{
+ if (!value.isArray())
+ refuse();
+ std::vector runs;
+ for (const QJsonValue &each : value.toArray())
+ runs.push_back(colorRun(each));
+ return runs;
+}
+
+// Synthesized Codable: all required but the box and runs.
LayerTextStyle textStyle(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
@@ -112,7 +130,7 @@ LayerTextStyle textStyle(const QJsonValue &value)
return {.content = string(object.value("content")), .fontName = string(object.value("fontName")), .fontSize = number(object.value("fontSize")),
.red = number(object.value("red")), .green = number(object.value("green")), .blue = number(object.value("blue")),
.alignment = *alignment, .tracking = number(object.value("tracking")), .leading = number(object.value("leading")),
- .boxSize = optional(object, "boxSize", size)};
+ .boxSize = optional(object, "boxSize", size), .colorRuns = optional(object, "colorRuns", colorRuns)};
}
CanvasGuide guide(const QJsonValue &value)
@@ -176,6 +194,12 @@ QJsonObject encoded(const LayerTextStyle &text)
{"blue", text.blue}, {"alignment", rawValue(text.alignment)}, {"tracking", text.tracking}, {"leading", text.leading}};
if (text.boxSize)
object.insert("boxSize", QJsonArray{text.boxSize->width(), text.boxSize->height()});
+ if (text.colorRuns) {
+ QJsonArray runs;
+ for (const LayerTextColorRun &run : *text.colorRuns)
+ runs.append(QJsonObject{{"location", run.location}, {"length", run.length}, {"red", run.red}, {"green", run.green}, {"blue", run.blue}});
+ object.insert("colorRuns", runs);
+ }
return object;
}
diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp
index 442b617..dbead78 100644
--- a/src/IO/ProjectStore.cpp
+++ b/src/IO/ProjectStore.cpp
@@ -73,8 +73,8 @@ void validate(const ProjectManifest &manifest)
if (manifest.width < 1 || manifest.width > DocumentLimits::maxSide || manifest.height < 1 || manifest.height > DocumentLimits::maxSide || manifest.layers.size() > 10'000)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const ProjectLayerRecord &layer : manifest.layers) {
- // Text needs a valid style and pixels to show it.
- if (layer.text && (!layer.text->isValid() || !layer.imageFile))
+ // Text needs a valid style, pixels; runs from 10.
+ if (layer.text && (!layer.text->isValid() || !layer.imageFile || (layer.text->colorRuns && manifest.version < 10)))
throw ProjectError(ProjectError::Kind::invalid);
// Version 7's adjustments hold settings, never pixels or children.
if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid()))
diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h
index 5ea9ce9..6ce85d7 100644
--- a/src/IO/ProjectStore.h
+++ b/src/IO/ProjectStore.h
@@ -51,7 +51,7 @@ QString uuidString(const QUuid &id);
struct ProjectManifest {
// Swift's current and supported: what saves write, what loads accept.
- static constexpr qint64 current = 9;
+ static constexpr qint64 current = 10;
static constexpr std::pair supported{1, current};
static bool supports(qint64 version) { return version >= supported.first && version <= supported.second; }
QString format = QStringLiteral("com.compositor.project");
diff --git a/src/Rendering/InlineTextEditor+Editing.cpp b/src/Rendering/InlineTextEditor+Editing.cpp
index 1993a24..9af00e6 100644
--- a/src/Rendering/InlineTextEditor+Editing.cpp
+++ b/src/Rendering/InlineTextEditor+Editing.cpp
@@ -171,27 +171,57 @@ void InlineTextEditor::replace(TextRange range, const QString &text, std::option
// Swift's shouldChangeTextIn: 100,000 UTF-16 units at most.
if (content.size() - (range.end - range.start) + text.size() > 100'000)
return;
+ std::vector steps;
+ // A preedit left behind stays as text: history gains it.
+ if (m_composing)
+ steps.push_back({{m_composing->marked.start, m_composing->marked.start}, m_composing->marked.end - m_composing->marked.start, false, true});
if (!edit || edit != m_lastEdit)
m_undo.push_back(m_composing ? m_composing->before : Snapshot{content, m_anchor, m_position});
m_redo.clear();
m_lastEdit = edit;
m_composing.reset();
m_anchor = m_position = range.start + int(text.size());
- publish(QString(content).replace(range.start, range.end - range.start, text));
+ steps.push_back({range, int(text.size())});
+ publish(QString(content).replace(range.start, range.end - range.start, text), steps);
}
// Swift's textDidChange: the draft takes the text view's string.
-void InlineTextEditor::publish(const QString &content)
+void InlineTextEditor::publish(const QString &content, const std::vector &steps)
{
// An edit ends a drag or resize, like a press.
release();
TextDraft draft = m_session.textDraft().value();
+ // The text's length as each step leaves it.
+ QString shaped = draft.style.content;
+ for (const Step &step : steps) {
+ // Each replacement moves the colours, as shouldChangeTextIn does.
+ if (step.runs) {
+ draft.style.content = shaped;
+ draft.style.replaceCharacters({step.replaced.start, step.replaced.end - step.replaced.start}, step.length);
+ shaped.replace(step.replaced.start, step.replaced.end - step.replaced.start, QString(step.length, u' '));
+ }
+ if (step.history && !m_undo.empty())
+ m_undo.back().since.push_back(step);
+ }
draft.style.content = content;
+ const TextRange range = selection();
+ draft.selection = {range.start, range.end - range.start};
m_goalX.reset();
showCaret();
m_session.setTextDraft(draft);
}
+void InlineTextEditor::noteSelection()
+{
+ const TextRange range = selection();
+ const TextSpan span{range.start, range.end - range.start};
+ if (!m_session.textDraft() || m_session.textDraft()->id != m_draftID || m_session.textDraft()->selection == span)
+ return;
+ TextDraft draft = m_session.textDraft().value();
+ draft.selection = span;
+ m_session.setTextDraft(draft);
+}
+
void InlineTextEditor::moveTo(int position, bool extend, bool keepGoal)
{
m_position = position;
@@ -202,20 +232,34 @@ void InlineTextEditor::moveTo(int position, bool extend, bool keepGoal)
m_lastEdit.reset();
showCaret();
updateInputMethod();
+ noteSelection();
}
void InlineTextEditor::restore(std::vector &from, std::vector &to)
{
if (from.empty())
return;
- to.push_back(m_composing ? m_composing->before : Snapshot{style().content, m_anchor, m_position});
const Snapshot snapshot = from.back();
from.pop_back();
+ std::vector steps;
+ // A preedit goes first; it was never history.
+ if (m_composing)
+ steps.push_back({m_composing->marked, 0, true, false});
+ // NSTextView undoes each replacement: the inverses, newest first.
+ std::vector back;
+ for (auto step = snapshot.since.rbegin(); step != snapshot.since.rend(); ++step)
+ back.push_back({{step->replaced.start, step->replaced.start + step->length}, step->replaced.end - step->replaced.start, true, false});
+ steps.insert(steps.end(), back.begin(), back.end());
+ Snapshot current = m_composing ? m_composing->before : Snapshot{style().content, m_anchor, m_position};
+ for (Step &step : back)
+ step.history = true;
+ current.since = back;
+ to.push_back(current);
m_lastEdit.reset();
m_composing.reset();
m_anchor = snapshot.anchor;
m_position = snapshot.position;
- publish(snapshot.content);
+ publish(snapshot.content, steps);
}
void InlineTextEditor::undo()
diff --git a/src/Rendering/InlineTextEditor+Input.cpp b/src/Rendering/InlineTextEditor+Input.cpp
index 0e08a90..6be88d0 100644
--- a/src/Rendering/InlineTextEditor+Input.cpp
+++ b/src/Rendering/InlineTextEditor+Input.cpp
@@ -38,7 +38,7 @@ void InlineTextEditor::selectAll()
if (m_composing) {
m_lastEdit.reset();
const QString text = plain().content;
- settle(text, 0, int(text.size()));
+ settle(text, 0, int(text.size()), {{m_composing->marked, 0, true, false}});
return;
}
m_anchor = 0;
@@ -65,11 +65,15 @@ void InlineTextEditor::change(const QString &text)
m_redo.clear();
m_lastEdit.reset();
const int cursor = range.start + int(text.size());
- settle(QString(around.content).replace(range.start, range.end - range.start, text), cursor, cursor);
+ std::vector steps;
+ if (m_composing)
+ steps.push_back({m_composing->marked, 0, true, false});
+ steps.push_back({range, int(text.size())});
+ settle(QString(around.content).replace(range.start, range.end - range.start, text), cursor, cursor, steps);
}
// The preedit returns to the cursor, as Qt shows it.
-void InlineTextEditor::settle(QString text, int anchor, int cursor)
+void InlineTextEditor::settle(QString text, int anchor, int cursor, std::vector steps)
{
if (m_composing) {
const TextRange marked = m_composing->marked;
@@ -79,11 +83,12 @@ void InlineTextEditor::settle(QString text, int anchor, int cursor)
m_composing->anchor = anchor;
m_composing->marked = {cursor, cursor + int(preedit.size())};
text.insert(cursor, preedit);
+ steps.push_back({{cursor, cursor}, int(preedit.size()), true, false});
anchor = cursor = cursor + caret;
}
m_anchor = anchor;
m_position = cursor;
- publish(text);
+ publish(text, steps);
}
// Marked text sits in the content, as in NSTextView's storage.
@@ -95,10 +100,14 @@ void InlineTextEditor::inputMethod(const QInputMethodEvent &event)
// Qt's steps, on the text without the preedit.
QString text = style().content, shown;
int cursor = m_position, anchor = m_anchor;
+ const std::optional oldMarked = m_composing ? std::optional(m_composing->marked) : std::nullopt;
+ // The preedit's going moves colours, never history.
+ std::vector steps;
if (m_composing) {
const TextRange marked = m_composing->marked;
shown = text.mid(marked.start, marked.end - marked.start);
text.remove(marked.start, marked.end - marked.start);
+ steps.push_back({marked, 0, true, false});
cursor = marked.start;
anchor = m_composing->anchor;
}
@@ -110,6 +119,7 @@ void InlineTextEditor::inputMethod(const QInputMethodEvent &event)
const bool takes = input && !selected.isEmpty();
if (takes) {
text.remove(selected.start, selected.end - selected.start);
+ steps.push_back({selected, 0});
cursor = selected.start;
}
// Where Qt leaves the cursor when nothing commits.
@@ -130,6 +140,11 @@ void InlineTextEditor::inputMethod(const QInputMethodEvent &event)
m_lastEdit = Edit::typing;
}
text.replace(start, end - start, commit);
+ // Over the selection taken, one replacement, as NSTextView's.
+ if (takes && start == selected.start && end == start)
+ steps.back().length = int(commit.size());
+ else if (end > start || !commit.isEmpty())
+ steps.push_back({{start, end}, int(commit.size())});
cursor = commit.isEmpty() ? std::clamp(after, 0, int(text.size())) : start + int(commit.size());
if (input)
anchor = cursor;
@@ -144,14 +159,25 @@ void InlineTextEditor::inputMethod(const QInputMethodEvent &event)
m_anchor = anchor;
m_position = cursor;
} else {
- compose(preedit, attributes, lands ? Snapshot{text, anchor, cursor} : before, anchor, text, cursor);
+ compose(preedit, attributes, lands ? Snapshot{text, anchor, cursor} : before, anchor, text, steps, cursor);
+ // A lone preedit over the selection replaces it whole.
+ if (takes && commit.isEmpty() && start == end && cursor == selected.start) {
+ steps[steps.size() - 2].runs = false;
+ steps.back() = {selected, int(preedit.size()), true, false};
+ }
}
- publish(text);
+ // Swift replaces marked text whole: its colour carries on.
+ if (oldMarked && !takes && start == end && start == oldMarked->start && (preedit.isEmpty() || cursor == start + int(commit.size()))) {
+ steps.front().length = int(commit.size() + preedit.size());
+ for (size_t index = 1; index < steps.size(); ++index)
+ steps[index].runs = false;
+ }
+ publish(text, steps);
}
// The preedit goes in at the cursor, with Qt's attributes.
void InlineTextEditor::compose(const QString &preedit, const QList &attributes, const Snapshot &before, int anchor,
- QString &text, int cursor)
+ QString &text, std::vector &steps, int cursor)
{
const int length = int(preedit.size());
Composing composing{.marked = {cursor, cursor + length}, .anchor = anchor, .before = before};
@@ -172,6 +198,7 @@ void InlineTextEditor::compose(const QString &preedit, const QList(
- style, QSizeF(std::max(1.0, m_logicalSize.width() - 2 * padding), std::max(1.0, m_logicalSize.height() - 2 * padding)));
+ clear, QSizeF(std::max(1.0, m_logicalSize.width() - 2 * padding), std::max(1.0, m_logicalSize.height() - 2 * padding)));
updateInputMethod();
if (!fresh)
return;
@@ -168,8 +171,11 @@ void InlineTextEditor::draw(QPainter &painter) const
m_lines->draw(painter, QPointF(padding, padding), shownSelection(), formats, highlight);
// The insertion point shows where nothing is selected.
const bool hidden = m_composing && m_composing->caretHidden;
+ // The caret takes the colour of the letter before it.
+ const PaletteColor before = style().color(m_position > 0 ? m_position - 1 : 0);
+ const QColor caretInk = QColor::fromRgbF(float(before.red), float(before.green), float(before.blue));
if (const std::optional caret = m_lines->caret(m_position); caret && caretShown() && selection().isEmpty() && !hidden)
- painter.fillRect(caret->translated(padding, padding), m_composing ? m_composing->caretColour.value_or(ink) : ink);
+ painter.fillRect(caret->translated(padding, padding), m_composing ? m_composing->caretColour.value_or(caretInk) : caretInk);
painter.restore();
}
diff --git a/src/Rendering/InlineTextEditor.h b/src/Rendering/InlineTextEditor.h
index cef712f..3d2db7f 100644
--- a/src/Rendering/InlineTextEditor.h
+++ b/src/Rendering/InlineTextEditor.h
@@ -64,10 +64,20 @@ public:
bool caretShown() const { return m_focused && m_caretShown; }
private:
+ // One replacement: what it took, and the new text's length.
+ struct Step {
+ TextRange replaced;
+ int length;
+ // Moves the colour runs; joins the undo entry's history.
+ bool runs = true;
+ bool history = true;
+ };
struct Snapshot {
QString content;
int anchor;
int position;
+ // Plain steps from this text to the next state.
+ std::vector since = {};
};
// An input method's preedit, which Qt keeps out of history.
struct Composing {
@@ -101,8 +111,11 @@ private:
static TextRange spanning(int anchor, int position);
TextRange selection() const;
void replace(TextRange range, const QString &text, std::optional edit);
- void publish(const QString &content);
+ // Swift's shouldChangeTextIn: each step moves the colours.
+ void publish(const QString &content, const std::vector &steps);
void moveTo(int position, bool extend, bool keepGoal = false);
+ // Swift's textViewDidChangeSelection: the draft learns the selection.
+ void noteSelection();
void restore(std::vector &from, std::vector &to);
void changeSpacing(int key, double step);
bool move(const QKeyEvent &event);
@@ -115,11 +128,11 @@ private:
void beginResize(int handle, QPointF point);
void resizeTo(QPointF point);
void compose(const QString &preedit, const QList &attributes, const Snapshot &before, int anchor, QString &text,
- int cursor);
+ std::vector &steps, int cursor);
TextRange shownSelection() const;
Snapshot plain() const;
void change(const QString &text);
- void settle(QString text, int anchor, int cursor);
+ void settle(QString text, int anchor, int cursor, std::vector steps);
bool erase(const QKeyEvent &event);
int nextCharacter(int position) const;
int previousCharacter(int position) const;
diff --git a/src/Rendering/TextLayout.cpp b/src/Rendering/TextLayout.cpp
index c418ada..658264a 100644
--- a/src/Rendering/TextLayout.cpp
+++ b/src/Rendering/TextLayout.cpp
@@ -93,6 +93,17 @@ TextLines::TextLines(const LayerTextStyle &style, QSizeF container)
const QString paragraph = style.content.mid(range.start, range.end - range.start);
auto layout = std::make_unique(paragraph, font);
layout->setTextOption(option);
+ // Run letters draw once in their colour, as CoreText's.
+ QList runs;
+ for (const LayerTextColorRun &run : style.colorRuns.value_or(std::vector())) {
+ const qint64 start = std::max(run.location, range.start), end = std::min(run.location + run.length, range.end);
+ if (start >= end)
+ continue;
+ QTextCharFormat format;
+ format.setForeground(QColor::fromRgbF(float(run.red), float(run.green), float(run.blue)));
+ runs << QTextLayout::FormatRange{int(start - range.start), int(end - start), format};
+ }
+ layout->setFormats(runs);
layout->beginLayout();
const auto laid = [&] {
const int count = layout->lineCount();
diff --git a/tests/FolderOpacityTests.cpp b/tests/FolderOpacityTests.cpp
index 2e5a71d..9581598 100644
--- a/tests/FolderOpacityTests.cpp
+++ b/tests/FolderOpacityTests.cpp
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
const QString path = root.filePath("Dimmed.comp");
ProjectStore::save(session.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(9));
+ QCOMPARE(loaded.manifest.version, qint64(10));
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
if (record.isGroup == true)
QCOMPARE(record.opacity, std::optional(0.5));
diff --git a/tests/GroupSessionTests.cpp b/tests/GroupSessionTests.cpp
index 4796b27..b7494a0 100644
--- a/tests/GroupSessionTests.cpp
+++ b/tests/GroupSessionTests.cpp
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
session.addBlankLayer();
const QUuid child = session.activeLayerID().value();
const ProjectSnapshot snapshot = session.projectSnapshot().value();
- QCOMPARE(snapshot.manifest.version, qint64(9));
+ QCOMPARE(snapshot.manifest.version, qint64(10));
const auto record = [](const ProjectSnapshot &from, QUuid id) {
for (const ProjectLayerRecord &layer : from.manifest.layers) {
if (layer.id == id)
diff --git a/tests/GroupTests.cpp b/tests/GroupTests.cpp
index 8697db3..ed0cb12 100644
--- a/tests/GroupTests.cpp
+++ b/tests/GroupTests.cpp
@@ -259,10 +259,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
QCOMPARE(invalid.version, std::nullopt);
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
- const ProjectError version = ProjectError::unsupportedVersion(10);
+ const ProjectError version = ProjectError::unsupportedVersion(11);
QCOMPARE(version.kind, ProjectError::Kind::version);
- QCOMPARE(version.version, std::optional(10));
- QCOMPARE(QString(version.what()), QString("This project uses format version 10. This app supports versions 1–9."));
+ QCOMPARE(version.version, std::optional(11));
+ QCOMPARE(QString(version.what()), QString("This project uses format version 11. This app supports versions 1–10."));
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
const ProjectError missingImage(ProjectError::Kind::missingImage);
diff --git a/tests/GuideTests.cpp b/tests/GuideTests.cpp
index 6431f15..a03e49b 100644
--- a/tests/GuideTests.cpp
+++ b/tests/GuideTests.cpp
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
session.addGuide(first);
session.addGuide(second);
ProjectSnapshot snapshot = session.projectSnapshot().value();
- QCOMPARE(snapshot.manifest.version, qint64(9));
+ QCOMPARE(snapshot.manifest.version, qint64(10));
QCOMPARE(snapshot.manifest.guides.value(), (std::vector{first, second}));
// Swift's keys: an array of id, axis and position.
const QByteArray json = snapshot.manifest.encoded();
diff --git a/tests/LayerAppearanceTests.cpp b/tests/LayerAppearanceTests.cpp
index be9de39..208802b 100644
--- a/tests/LayerAppearanceTests.cpp
+++ b/tests/LayerAppearanceTests.cpp
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
const QString path = folder.filePath("Appearance.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(9));
+ QCOMPARE(loaded.manifest.version, qint64(10));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
diff --git a/tests/LayerMaskSessionTests.cpp b/tests/LayerMaskSessionTests.cpp
index 8253c1d..f50a2bd 100644
--- a/tests/LayerMaskSessionTests.cpp
+++ b/tests/LayerMaskSessionTests.cpp
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
ProjectStore::save(session->projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QCOMPARE(loaded.manifest.version, qint64(9));
+ QCOMPARE(loaded.manifest.version, qint64(10));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
- QVERIFY(loaded.manifest.version == 9 && loaded.masks.count(folder) == 1);
+ QVERIFY(loaded.manifest.version == 10 && loaded.masks.count(folder) == 1);
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList{255, 0, 128, 255}));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
diff --git a/tests/LiveTextDoubleClickTests.cpp b/tests/LiveTextDoubleClickTests.cpp
index 8dee90e..e168737 100644
--- a/tests/LiveTextDoubleClickTests.cpp
+++ b/tests/LiveTextDoubleClickTests.cpp
@@ -158,7 +158,7 @@ void LiveTextDoubleClickTests::theCaretWaitsAfterTheText()
shown.session.cancelText();
shown.session.editActiveText();
shown.session.cancelText();
- TextDraft fresh{.documentID = shown.session.document().value().id, .layerID = std::nullopt, .origin = QPointF(20, 200), .style = style};
+ TextDraft fresh{.documentID = shown.session.document().value().id, .layerID = std::nullopt, .origin = QPointF(20, 200), .style = style, .selection = TextSpan()};
fresh.style.content = QStringLiteral("New");
shown.session.setTextDraft(fresh);
QCoreApplication::processEvents();
diff --git a/tests/ProjectManifestTests.cpp b/tests/ProjectManifestTests.cpp
index cacc440..b6d9095 100644
--- a/tests/ProjectManifestTests.cpp
+++ b/tests/ProjectManifestTests.cpp
@@ -313,7 +313,7 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
for (int version = 1; version <= 9; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
- QTest::newRow("version 10") << "" << 10 << std::optional(ProjectError::Kind::version);
+ QTest::newRow("version 11") << "" << 11 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
@@ -366,6 +366,9 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::newRow("an image under another name") << "imageName" << 7 << invalid;
QTest::newRow("an image named in lower case") << "imageLower" << 7 << invalid;
QTest::newRow("an active layer that is not there") << "active" << 7 << invalid;
+ QTest::newRow("letters in colours before version 10") << "textRuns" << 9 << invalid;
+ QTest::newRow("letters in colours at version 10") << "textRuns" << 10 << fine;
+ QTest::newRow("one-colour text before version 10") << "text" << 9 << fine;
}
void ProjectManifestTests::validationFollowsTheFormatsVersions()
@@ -455,7 +458,13 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions()
image.imageFile = "image.png";
else if (change == "imageLower")
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
- else if (change == "active")
+ else if (change == "text" || change == "textRuns") {
+ LayerTextStyle style;
+ style.content = QStringLiteral("Ab");
+ if (change == "textRuns")
+ style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
+ image.text = style;
+ } else if (change == "active")
manifest.activeLayerID = QUuid::createUuid();
else
QVERIFY(change.isEmpty());
diff --git a/tests/ProjectSessionTests.cpp b/tests/ProjectSessionTests.cpp
index 6bfcd3f..39be94f 100644
--- a/tests/ProjectSessionTests.cpp
+++ b/tests/ProjectSessionTests.cpp
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
- QCOMPARE(again.manifest.version, qint64(9));
+ QCOMPARE(again.manifest.version, qint64(10));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
diff --git a/tests/ProjectTests.cpp b/tests/ProjectTests.cpp
index eb5e702..de3ee98 100644
--- a/tests/ProjectTests.cpp
+++ b/tests/ProjectTests.cpp
@@ -53,10 +53,10 @@ private slots:
void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts()
{
QVERIFY(ProjectManifest::supports(ProjectManifest::current));
- QVERIFY((ProjectManifest::supported == std::pair(1, 9)));
+ QVERIFY((ProjectManifest::supported == std::pair(1, 10)));
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
- QCOMPARE(QString(ProjectError::unsupportedVersion(10).what()), QString("This project uses format version 10. This app supports versions 1–9."));
+ QCOMPARE(QString(ProjectError::unsupportedVersion(11).what()), QString("This project uses format version 11. This app supports versions 1–10."));
}
void ProjectTests::projectRoundTripSurvivesPackageMove()
@@ -201,14 +201,14 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
ProjectStore::save(snapshot, path);
const QByteArray good = contents(metadata);
// Each edit below replaces this text; it must be there.
- QVERIFY(good.contains("\"version\": 9"));
+ QVERIFY(good.contains("\"version\": 10"));
const auto loading = [&](const QByteArray &manifest) {
overwrite(metadata, manifest);
return projectError([&] { ProjectStore::load(path); });
};
// A version out of range is named whatever else fails.
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
- for (const qint64 strange : {qint64(42), qint64(10), qint64(0)}) {
+ for (const qint64 strange : {qint64(42), qint64(11), qint64(0)}) {
try {
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
ProjectStore::load(path);
@@ -223,12 +223,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
// Another format is invalid whatever version it claims.
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 0")), std::optional(ProjectError::Kind::version));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 0")), std::optional(ProjectError::Kind::version));
// Swift's Int holds 64 bits, each one: no double between.
const qint64 most = std::numeric_limits::max(), least = std::numeric_limits::min();
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
try {
- overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": " + QByteArray::number(wild)));
+ overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": " + QByteArray::number(wild)));
ProjectStore::load(path);
QFAIL("a wild version opened");
} catch (const ProjectError &error) {
@@ -238,19 +238,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
}
}
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
// Declared: Qt rounds a token just past Int64's minimum.
try {
- overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": -9223372036854775809"));
+ overwrite(metadata, QByteArray(good).replace("\"version\": 10", "\"version\": -9223372036854775809"));
ProjectStore::load(path);
QFAIL("a version below every integer opened");
} catch (const ProjectError &error) {
QCOMPARE(error.version, std::optional(least));
}
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
- QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(loading(QByteArray(good).replace("\"version\": 10", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index f8d74b0..d301bb6 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -169,6 +169,9 @@ compositor_test(QuitWhileEditingTextTests)
compositor_test(OpenRecentTests)
compositor_test(ColoredSliderTests)
compositor_test(SheetTeardownTests)
+compositor_test(TextColorRunsTests)
+compositor_test(TextColorModelTests)
+compositor_test(TextColorEditorTests)
compositor_test(RecentProjectsCoreTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
diff --git a/tests/TextColorEditorTests.cpp b/tests/TextColorEditorTests.cpp
new file mode 100644
index 0000000..a788c2a
--- /dev/null
+++ b/tests/TextColorEditorTests.cpp
@@ -0,0 +1,475 @@
+#include "InlineTextDrawFixtures.h"
+#include "Rendering/TextLayout.h"
+#include "UI/ColorPaletteControls.h"
+#include "UI/TypeControls.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Swift 1.3.2 on the canvas: colours keep to their letters.
+namespace {
+const PaletteColor green{0, 1, 0};
+
+std::optional> runs(const DrawnText &shown)
+{
+ return shown.session.textDraft().value().style.colorRuns;
+}
+
+LayerTextColorRun greenRun(qint64 location, qint64 length)
+{
+ return {location, length, 0, 1, 0};
+}
+
+// The caret's middle on screen, `advance` along the first line.
+QPoint caretAt(const DrawnText &shown, const QString &before)
+{
+ const LayerTextStyle &style = shown.session.textDraft().value().style;
+ const QRectF caret = shown.editor().textTransform().mapRect(
+ QRectF(QPointF(LayerTextStyle::padding, LayerTextStyle::padding), QSizeF(1, style.lineHeight()))
+ .translated(QFontMetricsF(TextLayout::font(style)).horizontalAdvance(before), 0));
+ return caret.center().toPoint();
+}
+
+// A caret a pixel wide may straddle two pixels.
+bool inked(const QImage &image, QPoint at, bool (*test)(QColor))
+{
+ return test(image.pixelColor(at)) || test(image.pixelColor(at - QPoint(1, 0))) || test(image.pixelColor(at + QPoint(1, 0)));
+}
+}
+
+class TextColorEditorTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theSelectionReachesTheDraft();
+ void coloursFollowTypingDeletingAndUndo();
+ void pastingKeepsTheColoursAround();
+ void aLetterTypedBetweenTwinsTakesTheOneBeforeIt();
+ void theCaretTakesTheLetterBeforeIt();
+ void theSwatchKeepsTheSelectionItPaints();
+ void theLayerDrawsEachRunInItsColour();
+ void theEditorDrawsNoGlyphsOfItsOwn();
+ void twinsKeepTheirColoursOnEveryPath_data();
+ void twinsKeepTheirColoursOnEveryPath();
+ void aColouredTailOutlastsEveryStep_data();
+ void aColouredTailOutlastsEveryStep();
+ void aPreeditOverTheFirstLetterTakesItsColour();
+ void atTheStartTheFirstLetterGivesTheColour();
+};
+
+void TextColorEditorTests::theSelectionReachesTheDraft()
+{
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
+ QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{4, 0}));
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{2, 2}));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{0, 0}));
+ // Select All reaches it too.
+ QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
+ QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{0, 4}));
+}
+
+void TextColorEditorTests::coloursFollowTypingDeletingAndUndo()
+{
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
+ QTest::keyClick(shown.canvas, Qt::Key_Left);
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(1, 2)}));
+ // After a green letter, typing is green; after red, red.
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ QTest::keyClicks(shown.canvas, QStringLiteral("x"));
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("abcxd"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(1, 3)}));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ QTest::keyClicks(shown.canvas, QStringLiteral("y"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(2, 3)}));
+ // A deletion takes its letters' colours away.
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ QTest::keyClick(shown.canvas, Qt::Key_Backspace);
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("aycxd"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(2, 2)}));
+ // Undo puts the letter back in the colour before it.
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("aybcxd"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(3, 2)}));
+ // Deleting every green letter leaves one colour.
+ QTest::keyClick(shown.canvas, Qt::Key_End);
+ QTest::keyClick(shown.canvas, Qt::Key_Left);
+ for (int step = 0; step < 3; ++step)
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ QTest::keyClick(shown.canvas, Qt::Key_Delete);
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("ayd"));
+ QVERIFY(!runs(shown));
+}
+
+void TextColorEditorTests::pastingKeepsTheColoursAround()
+{
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(3, 1)}));
+ // Pasted at the start, before every run: runs move on.
+ QGuiApplication::clipboard()->setText(QStringLiteral("123"));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ shown.editor().paste();
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("123abcd"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(6, 1)}));
+ // Pasted after the green letter, green.
+ QTest::keyClick(shown.canvas, Qt::Key_End);
+ shown.editor().paste();
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(6, 4)}));
+}
+
+void TextColorEditorTests::aLetterTypedBetweenTwinsTakesTheOneBeforeIt()
+{
+ // Typed after a green a, before a red one: green.
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("aa"));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 1)}));
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ QCOMPARE(shown.editor().caretPosition(), 1);
+ QTest::keyClicks(shown.canvas, QStringLiteral("a"));
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("aaa"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 2)}));
+}
+
+void TextColorEditorTests::theCaretTakesTheLetterBeforeIt()
+{
+ DrawnText shown;
+ auto *field = new QLineEdit(&shown.window);
+ field->setGeometry(0, 0, 10, 10);
+ field->show();
+ shown.canvas->setFocus();
+ QTRY_VERIFY(shown.canvas->hasFocus());
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("ab"));
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ // After the green b: green; after the red a: red.
+ QTest::keyClick(shown.canvas, Qt::Key_End);
+ QVERIFY(shown.editor().caretShown());
+ const QPoint afterB = caretAt(shown, QStringLiteral("ab"));
+ QVERIFY(inked(shown.grab(), afterB, greenish));
+ QTest::keyClick(shown.canvas, Qt::Key_Left);
+ const QPoint afterA = caretAt(shown, QStringLiteral("a"));
+ QTRY_VERIFY(shown.editor().caretShown());
+ QVERIFY(inked(shown.grab(), afterA, reddish));
+ QVERIFY(!inked(shown.grab(), afterA, greenish));
+}
+
+void TextColorEditorTests::theSwatchKeepsTheSelectionItPaints()
+{
+ // Swift's 4503998: the Type bar's swatch leaves the letters selected.
+ DrawnText shown;
+ TypeControls bar(shown.session, &shown.window);
+ bar.setGeometry(0, 0, 400, 30);
+ bar.show();
+ shown.canvas->setFocus();
+ QTRY_VERIFY(shown.canvas->hasFocus());
+ beginTextAt(shown.session, QPointF(20, 60));
+ QTest::keyClicks(shown.canvas, QStringLiteral("AAAA BBBB"));
+ for (int step = 0; step < 4; ++step)
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ auto &swatch = *bar.findChild(QStringLiteral("typeColor"));
+ QTest::mouseClick(&swatch, Qt::LeftButton);
+ QVERIFY(shown.session.colorPicker().has_value());
+ QCOMPARE(shown.session.textDraft().value().selection, (TextSpan{5, 4}));
+ QVERIFY(shown.editor().anchor() == 9 && shown.editor().caretPosition() == 5);
+ PickerHSB hsb = shown.session.colorPicker().value().hsb;
+ hsb.setRGB(green);
+ shown.session.setColorPickerHSB(hsb);
+ shown.session.closeColorPicker(true);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(5, 4)}));
+ QCOMPARE(shown.session.textDraft().value().style.red, 1.0);
+}
+
+void TextColorEditorTests::theLayerDrawsEachRunInItsColour()
+{
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("MMMM MMMM"));
+ for (int step = 0; step < 4; ++step)
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QVERIFY(shown.session.finishText());
+ const QImage image = shown.session.activeLayer().value().asset.value().image();
+ // Red on the left, green on the right, no fringe.
+ int reds = 0, greens = 0, dark = 0;
+ for (int y = 0; y < image.height(); ++y) {
+ for (int x = 0; x < image.width(); ++x) {
+ const QColor colour = image.pixelColor(x, y);
+ if (colour.alphaF() < 0.05f)
+ continue;
+ const double alpha = colour.alphaF();
+ reds += colour.redF() > 0.8 * alpha && x < image.width() / 2;
+ greens += colour.greenF() > 0.8 * alpha && x > image.width() / 2;
+ // A glyph drawn twice leaves red under green edges.
+ dark += colour.redF() > 0.05 && colour.greenF() > 0.05;
+ }
+ }
+ QVERIFY2(reds > 100 && greens > 100 && dark == 0, qPrintable(QStringLiteral("%1 %2 %3").arg(reds).arg(greens).arg(dark)));
+}
+
+void TextColorEditorTests::theEditorDrawsNoGlyphsOfItsOwn()
+{
+ // The runs belong to the layer: overlay glyphs stay clear.
+ DrawnText shown;
+ beginTextAt(shown.session, QPointF(20, 30));
+ shown.session.changeTextStyle([](LayerTextStyle &style) {
+ style.content = QStringLiteral("MMMM");
+ style.setColor(PaletteColor{0, 1, 0}, {0, 4});
+ style.setColor(PaletteColor{1, 0, 0}, {0, 2});
+ });
+ QVERIFY(runs(shown).has_value());
+ const auto overlay = [&shown] {
+ QImage drawn(shown.canvas->size(), QImage::Format_ARGB32_Premultiplied);
+ drawn.fill(Qt::transparent);
+ QPainter painter(&drawn);
+ shown.editor().draw(painter);
+ painter.end();
+ return drawn;
+ };
+ // Only the frame, handles and translucent highlight may show.
+ const auto glyphs = [](const QImage &drawn) {
+ int count = 0;
+ for (int y = 0; y < drawn.height(); ++y)
+ for (int x = 0; x < drawn.width(); ++x)
+ count += drawn.pixelColor(x, y).alpha() > 128 && (greenish(drawn.pixelColor(x, y)) || reddish(drawn.pixelColor(x, y)));
+ return count;
+ };
+ shown.canvas->clearFocus();
+ QTRY_VERIFY(!shown.editor().caretShown());
+ QCOMPARE(glyphs(overlay()), 0);
+ // Selected, with and without the keys, as under the picker.
+ shown.canvas->setFocus();
+ QTRY_VERIFY(shown.canvas->hasFocus());
+ QTest::keyClick(shown.canvas, Qt::Key_A, Qt::ControlModifier);
+ for (const bool focused : {true, false}) {
+ if (!focused) {
+ shown.canvas->clearFocus();
+ QTRY_VERIFY(!shown.canvas->hasFocus());
+ }
+ const QImage drawn = overlay();
+ QCOMPARE(glyphs(drawn), 0);
+ // The highlight at 45% over the letters' place.
+ const QPoint middle = shown.editor().textTransform().map(QPointF(LayerTextStyle::padding + 10, LayerTextStyle::padding + 20)).toPoint();
+ QVERIFY2(std::abs(drawn.pixelColor(middle).alpha() - 115) <= 2, qPrintable(QString::number(drawn.pixelColor(middle).alpha())));
+ }
+}
+
+void TextColorEditorTests::twinsKeepTheirColoursOnEveryPath_data()
+{
+ QTest::addColumn("path");
+ QTest::addColumn("content");
+ QTest::addColumn("greens");
+ for (const char *path : {"paste", "commit", "preedit"})
+ QTest::newRow(path) << path << "aaa" << qint64(2);
+ QTest::newRow("cut") << "cut" << "a" << qint64(0);
+ QTest::newRow("undo") << "undo" << "aa" << qint64(1);
+ QTest::newRow("redo") << "redo" << "aaa" << qint64(2);
+ QTest::newRow("replaced by itself") << "same" << "aa" << qint64(2);
+ // Around a preedit: it sits green between the twins.
+ QTest::newRow("paste while composing") << "pasteComposing" << "aqxa" << qint64(3);
+ QTest::newRow("commit over a preedit") << "commitOverPreedit" << "aya" << qint64(2);
+ QTest::newRow("commit over a selection") << "commitOverSelection" << "zza" << qint64(2);
+ QTest::newRow("undo of two typed") << "undoTwo" << "aa" << qint64(1);
+ QTest::newRow("a preedit typed over, undone") << "typedOverPreedit" << "aa" << qint64(1);
+ QTest::newRow("undo while composing") << "undoComposing" << "aa" << qint64(1);
+}
+
+void TextColorEditorTests::twinsKeepTheirColoursOnEveryPath()
+{
+ // A green a, then a red one, the caret between.
+ QFETCH(QString, path);
+ QFETCH(QString, content);
+ QFETCH(qint64, greens);
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("aa"));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ // A plain arrow collapses the selection to its own side.
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ QCOMPARE(shown.editor().caretPosition(), 1);
+ const auto input = [&](const QString &commit, const QString &preedit) {
+ QInputMethodEvent event(preedit, {});
+ event.setCommitString(commit);
+ QApplication::sendEvent(shown.canvas, &event);
+ };
+ if (path == "paste") {
+ QGuiApplication::clipboard()->setText(QStringLiteral("a"));
+ shown.editor().paste();
+ } else if (path == "commit") {
+ input(QStringLiteral("a"), QString());
+ } else if (path == "preedit") {
+ input(QString(), QStringLiteral("a"));
+ } else if (path == "cut") {
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.editor().cut();
+ } else if (path == "undo" || path == "redo") {
+ QTest::keyClicks(shown.canvas, QStringLiteral("a"));
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ if (path == "redo")
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
+ } else if (path == "pasteComposing") {
+ input(QString(), QStringLiteral("x"));
+ QGuiApplication::clipboard()->setText(QStringLiteral("q"));
+ shown.editor().paste();
+ } else if (path == "commitOverPreedit") {
+ input(QString(), QStringLiteral("x"));
+ input(QStringLiteral("y"), QString());
+ } else if (path == "commitOverSelection") {
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ input(QStringLiteral("zz"), QString());
+ } else if (path == "undoTwo") {
+ QTest::keyClicks(shown.canvas, QStringLiteral("bc"));
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ } else if (path == "typedOverPreedit") {
+ input(QString(), QStringLiteral("x"));
+ QTest::keyClicks(shown.canvas, QStringLiteral("b"));
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ } else if (path == "undoComposing") {
+ QTest::keyClicks(shown.canvas, QStringLiteral("b"));
+ input(QString(), QStringLiteral("x"));
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ } else {
+ // The red letter replaced by itself takes the green before.
+ QTest::keyClick(shown.canvas, Qt::Key_End);
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ input(QStringLiteral("a"), QString());
+ }
+ QCOMPARE(shown.session.textDraft().value().style.content, content);
+ if (greens == 0)
+ QVERIFY(!runs(shown));
+ else
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, greens)}));
+}
+
+void TextColorEditorTests::aColouredTailOutlastsEveryStep_data()
+{
+ QTest::addColumn("path");
+ QTest::addColumn("content");
+ QTest::newRow("typing undone and redone") << "redo" << "axybcd";
+ QTest::newRow("deletions undone") << "undoDeletes" << "abcd";
+ QTest::newRow("commit with a preedit") << "commitAndPreedit" << "axyzbcd";
+ QTest::newRow("paste around a preedit") << "pasteComposing" << "aqqxbcd";
+}
+
+void TextColorEditorTests::aColouredTailOutlastsEveryStep()
+{
+ // setMarkedText replaces a selection whole: its first colour.
+ QFETCH(QString, path);
+ QFETCH(QString, content);
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("abcd"));
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right);
+ const auto input = [&](const QString &commit, const QString &preedit) {
+ QInputMethodEvent event(preedit, {});
+ event.setCommitString(commit);
+ QApplication::sendEvent(shown.canvas, &event);
+ };
+ if (path == "redo") {
+ QTest::keyClicks(shown.canvas, QStringLiteral("xy"));
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier | Qt::ShiftModifier);
+ } else if (path == "undoDeletes") {
+ QTest::keyClick(shown.canvas, Qt::Key_Delete);
+ QTest::keyClick(shown.canvas, Qt::Key_Delete);
+ QTest::keyClick(shown.canvas, Qt::Key_Z, Qt::ControlModifier);
+ } else if (path == "commitAndPreedit") {
+ input(QStringLiteral("xy"), QStringLiteral("z"));
+ } else {
+ input(QString(), QStringLiteral("x"));
+ QGuiApplication::clipboard()->setText(QStringLiteral("qq"));
+ shown.editor().paste();
+ }
+ QCOMPARE(shown.session.textDraft().value().style.content, content);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(content.size() - 1, 1)}));
+}
+
+void TextColorEditorTests::aPreeditOverTheFirstLetterTakesItsColour()
+{
+ // setMarkedText replaces the selection whole: its first letter's colour.
+ DrawnText shown;
+ shown.canvas->setFocus();
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("abc"));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QTest::keyClick(shown.canvas, Qt::Key_End);
+ QTest::keyClick(shown.canvas, Qt::Key_Left, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ QInputMethodEvent event(QStringLiteral("xy"), {});
+ QApplication::sendEvent(shown.canvas, &event);
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("xybc"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 2), greenRun(3, 1)}));
+ // Updated, its cursor moved, then committed: the colour carries on.
+ QInputMethodEvent updated(QStringLiteral("xyz"), {});
+ QApplication::sendEvent(shown.canvas, &updated);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 3), greenRun(4, 1)}));
+ QInputMethodEvent moved(QStringLiteral("xyz"), {QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, 0, 1)});
+ QApplication::sendEvent(shown.canvas, &moved);
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 3), greenRun(4, 1)}));
+ QInputMethodEvent committed;
+ committed.setCommitString(QStringLiteral("q"));
+ QApplication::sendEvent(shown.canvas, &committed);
+ QCOMPARE(shown.session.textDraft().value().style.content, QString("qbc"));
+ QCOMPARE(runs(shown).value(), (std::vector{greenRun(0, 1), greenRun(2, 1)}));
+}
+
+void TextColorEditorTests::atTheStartTheFirstLetterGivesTheColour()
+{
+ // No letter before the caret: the first one's colour.
+ DrawnText shown;
+ auto *field = new QLineEdit(&shown.window);
+ field->setGeometry(0, 0, 10, 10);
+ field->show();
+ shown.canvas->setFocus();
+ QTRY_VERIFY(shown.canvas->hasFocus());
+ beginTextAt(shown.session, QPointF(20, 30));
+ QTest::keyClicks(shown.canvas, QStringLiteral("ab"));
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QTest::keyClick(shown.canvas, Qt::Key_Right, Qt::ShiftModifier);
+ shown.session.setPaletteColor(green, false);
+ QTest::keyClick(shown.canvas, Qt::Key_Home);
+ QCOMPARE(shown.editor().caretPosition(), 0);
+ QVERIFY(shown.session.typeColor() == green);
+ QTRY_VERIFY(shown.editor().caretShown());
+ QVERIFY(inked(shown.grab(), caretAt(shown, QString()), greenish));
+}
+
+QTEST_MAIN(TextColorEditorTests)
+#include "TextColorEditorTests.moc"
diff --git a/tests/TextColorModelTests.cpp b/tests/TextColorModelTests.cpp
new file mode 100644
index 0000000..9d24a97
--- /dev/null
+++ b/tests/TextColorModelTests.cpp
@@ -0,0 +1,155 @@
+#include "Document/EditorSession.h"
+#include "IO/ProjectStore.h"
+#include
+#include
+
+// Swift's colour runs in a text style: arithmetic and validity.
+namespace {
+const PaletteColor red{1, 0, 0};
+const PaletteColor blue{0, 0, 1};
+const PaletteColor green{0, 1, 0};
+
+LayerTextColorRun run(qint64 location, qint64 length, const PaletteColor &color)
+{
+ return {location, length, color.red, color.green, color.blue};
+}
+
+std::unique_ptr makeSession()
+{
+ auto session = std::make_unique();
+ session->createDocument(800, 600, true);
+ session->selectTool(NavigationTool::type);
+ return session;
+}
+
+void edit(EditorSession &session, const QString &content, TextSpan selection)
+{
+ TextDraft draft = session.textDraft().value();
+ draft.style.content = content;
+ draft.selection = selection;
+ session.setTextDraft(draft);
+}
+}
+
+class TextColorModelTests : public QObject {
+ Q_OBJECT
+private slots:
+ void colorAppliesToSelectionAndFollowsEdits();
+ void invalidColorRunsAreRejected();
+ void everyChannelOfARunIsChecked();
+ void runsOfOneColourJoinOnlyWhereTheyMeet();
+};
+
+void TextColorModelTests::colorAppliesToSelectionAndFollowsEdits()
+{
+ LayerTextStyle style;
+ style.content = QStringLiteral("Hello world");
+ style.setColor(red, {6, 5});
+ QCOMPARE(style.colorRuns.value(), (std::vector{run(6, 5, red)}));
+ QVERIFY(style.color(5) == PaletteColor::black() && style.color(6) == red);
+ // Painting beside a run in its colour joins it.
+ style.setColor(red, {5, 1});
+ QVERIFY(style.colorRuns.value().size() == 1 && style.colorRuns.value().front().location == 5);
+ // Typed letters take the colour before them; deleted ones go.
+ style.replaceCharacters({11, 0}, 1);
+ style.content += u'!';
+ QVERIFY(style.isValid() && style.color(11) == red);
+ style.replaceCharacters({0, 2}, 0);
+ style.content.remove(0, 2);
+ QVERIFY(style.isValid() && style.colorRuns.value().front().location == 3 && style.colorRuns.value().front().length == 7);
+ // Typed at the start: the first letter's colour.
+ style.replaceCharacters({0, 0}, 2);
+ style.content.prepend(QStringLiteral("ab"));
+ QVERIFY(style.color(0) == PaletteColor::black() && style.colorRuns.value().front().location == 5);
+ // No selection, or all of it, recolours the whole text.
+ style.setColor(red, {4, 0});
+ QVERIFY(!style.colorRuns && style.red == 1);
+ style.setColor(blue, {0, style.content.size()});
+ QVERIFY(!style.colorRuns && style.blue == 1 && style.red == 0);
+ // Past the end, a span is clamped to the text.
+ style.setColor(red, {3, 900});
+ QCOMPARE(style.colorRuns.value(), (std::vector{run(3, style.content.size() - 3, red)}));
+ // Runs matching the base colour vanish.
+ style.setColor(blue, {3, 900});
+ QVERIFY(!style.colorRuns);
+ // Without runs, an edit keeps none.
+ style.replaceCharacters({0, 0}, 3);
+ QVERIFY(!style.colorRuns);
+}
+
+
+void TextColorModelTests::invalidColorRunsAreRejected()
+{
+ LayerTextStyle style;
+ style.content = QStringLiteral("Text");
+ style.colorRuns = std::vector{run(2, 3, red)};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{run(0, 2, red), run(1, 2, PaletteColor{0, 1, 0})};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{LayerTextColorRun{0, 1, 2, 0, 0}};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{run(1, 0, red)};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{run(-1, 2, red)};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{run(1, std::numeric_limits::max(), red)};
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector();
+ QVERIFY(!style.isValid());
+ style.colorRuns = std::vector{run(0, 1, red), run(3, 1, blue)};
+ QVERIFY(style.isValid());
+}
+
+
+void TextColorModelTests::everyChannelOfARunIsChecked()
+{
+ LayerTextStyle style;
+ style.content = QStringLiteral("Text");
+ for (int channel = 0; channel < 3; ++channel) {
+ for (const double value : {-0.01, 1.01, std::nan(""), std::numeric_limits::infinity(), 0.0, 1.0}) {
+ LayerTextColorRun run{0, 1, 0.5, 0.5, 0.5};
+ (channel == 0 ? run.red : channel == 1 ? run.green : run.blue) = value;
+ style.colorRuns = std::vector{run};
+ const bool valid = value == 0 || value == 1;
+ QVERIFY2(style.isValid() == valid, qPrintable(QStringLiteral("channel %1 at %2").arg(channel).arg(value)));
+ }
+ }
+ // The store refuses an invalid run on saving too.
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("Text"), {0, 1});
+ session->setPaletteColor(red, false);
+ QVERIFY(session->finishText());
+ ProjectSnapshot snapshot = session->projectSnapshot().value();
+ snapshot.manifest.layers.back().text->colorRuns->front().blue = 2;
+ QTemporaryDir root;
+ try {
+ ProjectStore::save(snapshot, root.filePath(QStringLiteral("Bad.comp")));
+ QFAIL("an invalid run saved");
+ } catch (const ProjectError &error) {
+ QCOMPARE(error.kind, ProjectError::Kind::invalid);
+ }
+}
+
+
+void TextColorModelTests::runsOfOneColourJoinOnlyWhereTheyMeet()
+{
+ LayerTextStyle style;
+ style.content = QStringLiteral("abcde");
+ style.setColor(green, {0, 1});
+ style.setColor(green, {4, 1});
+ // A base-coloured gap keeps two runs apart.
+ QCOMPARE(style.colorRuns.value(), (std::vector{run(0, 1, green), run(4, 1, green)}));
+ QVERIFY(style.color(2) == PaletteColor::black());
+ // Split by the base colour, then repainted: one run again.
+ style.setColor(green, {0, 5});
+ style.setColor(red, {0, 5});
+ style.setColor(green, {1, 3});
+ style.setColor(red, {2, 1});
+ QCOMPARE(style.colorRuns.value(), (std::vector{run(1, 1, green), run(3, 1, green)}));
+ style.setColor(green, {2, 1});
+ QCOMPARE(style.colorRuns.value(), (std::vector{run(1, 3, green)}));
+}
+
+QTEST_MAIN(TextColorModelTests)
+#include "TextColorModelTests.moc"
diff --git a/tests/TextColorRunsTests.cpp b/tests/TextColorRunsTests.cpp
new file mode 100644
index 0000000..ba02d2d
--- /dev/null
+++ b/tests/TextColorRunsTests.cpp
@@ -0,0 +1,398 @@
+#include "Document/EditorSession.h"
+#include "IO/ProjectStore.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Swift 1.3.2: only the selected letters take a colour.
+namespace {
+const PaletteColor red{1, 0, 0};
+const PaletteColor blue{0, 0, 1};
+
+std::unique_ptr makeSession()
+{
+ auto session = std::make_unique();
+ session->createDocument(800, 600, true);
+ session->selectTool(NavigationTool::type);
+ return session;
+}
+
+// Swift writes the draft's content and selection in place.
+void edit(EditorSession &session, const QString &content, TextSpan selection)
+{
+ TextDraft draft = session.textDraft().value();
+ draft.style.content = content;
+ draft.selection = selection;
+ session.setTextDraft(draft);
+}
+
+void pick(EditorSession &session, const PaletteColor &color)
+{
+ PickerHSB hsb = session.colorPicker().value().hsb;
+ hsb.setRGB(color);
+ session.setColorPickerHSB(hsb);
+}
+
+// Opaque pixels mostly red, and those that are dark.
+std::pair redAndDark(const QImage &image)
+{
+ int reds = 0, darks = 0;
+ for (int y = 0; y < image.height(); ++y) {
+ for (int x = 0; x < image.width(); ++x) {
+ const QColor colour = image.pixelColor(x, y);
+ if (colour.alphaF() <= 0.9f)
+ continue;
+ if (colour.redF() > 0.8f && colour.greenF() < 0.2f)
+ ++reds;
+ else if (colour.redF() < 0.2f)
+ ++darks;
+ }
+ }
+ return {reds, darks};
+}
+
+LayerTextColorRun run(qint64 location, qint64 length, const PaletteColor &color)
+{
+ return {location, length, color.red, color.green, color.blue};
+}
+}
+
+class TextColorRunsTests : public QObject {
+ Q_OBJECT
+private slots:
+ void selectedColorPaintsOnlyThoseLettersAndSurvivesReopening();
+ void cancelingPickerRestoresSelectionColors();
+ void theSwatchShowsTheSelectionOrTheLetterBeforeTheCaret();
+ void theTypeBarsPickerPaintsTheSelectionAndCancelsBack();
+ void fillAndNewTextStartInOneColour();
+ void theManifestWritesRunsOnlyWhenThere();
+ void aRunInALaterParagraphColoursItsOwnLetters();
+ void runsDecodeAsSwiftsCodable();
+ void cancelPutsBackMixedColoursForBothPickers();
+};
+
+namespace {
+// A saved text layer with a run, and its manifest.
+struct SavedRun {
+ QTemporaryDir root;
+ QString path = root.filePath(QStringLiteral("Run.comp"));
+ QJsonObject manifest;
+ SavedRun()
+ {
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("Abcd"), {1, 2});
+ session->setPaletteColor(PaletteColor{0.25, 0.5, 0.75}, false);
+ if (!session->finishText())
+ throw std::runtime_error("the text was refused");
+ ProjectStore::save(session->projectSnapshot().value(), path);
+ QFile file(path + QStringLiteral("/manifest.json"));
+ if (!file.open(QIODevice::ReadOnly))
+ throw std::runtime_error("no manifest");
+ manifest = QJsonDocument::fromJson(file.readAll()).object();
+ }
+ // Loads the manifest with its text's runs replaced.
+ std::optional load(const std::function &change) const
+ {
+ QJsonObject edited = manifest;
+ QJsonArray layers = edited.value("layers").toArray();
+ QJsonObject layer = layers.last().toObject();
+ QJsonObject text = layer.value("text").toObject();
+ change(text);
+ layer.insert("text", text);
+ layers.replace(layers.size() - 1, layer);
+ edited.insert("layers", layers);
+ QFile file(path + QStringLiteral("/manifest.json"));
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
+ throw std::runtime_error("no manifest");
+ file.write(QJsonDocument(edited).toJson());
+ file.close();
+ try {
+ ProjectStore::load(path);
+ return std::nullopt;
+ } catch (const ProjectError &error) {
+ return error.kind;
+ }
+ }
+};
+}
+
+void TextColorRunsTests::selectedColorPaintsOnlyThoseLettersAndSurvivesReopening()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("AAAA BBBB"), {5, 4});
+ session->openTextColorPicker();
+ pick(*session, red);
+ session->previewTextColor();
+ session->closeColorPicker(true);
+ QCOMPARE(session->textDraft().value().style.red, 0.0);
+ QVERIFY(session->textDraft().value().style.color(5) == red);
+ QVERIFY(session->finishText());
+ const auto pixels = redAndDark(session->activeLayer().value().asset.value().image());
+ QVERIFY2(pixels.first > 50 && pixels.second > 50, qPrintable(QStringLiteral("%1 %2").arg(pixels.first).arg(pixels.second)));
+ const ProjectSnapshot snapshot = session->projectSnapshot().value();
+ QCOMPARE(snapshot.manifest.version, qint64(10));
+ QTemporaryDir root;
+ const QString path = root.filePath(QStringLiteral("TextColors.comp"));
+ ProjectStore::save(snapshot, path);
+ EditorSession reopened;
+ reopened.installProject(ProjectStore::load(path), path);
+ const LayerText text = reopened.document().value().layers.back().liveText().value();
+ QVERIFY(text.style == session->activeLayer().value().liveText().value().style);
+ QCOMPARE(redAndDark(reopened.document().value().layers.back().asset.value().image()), pixels);
+ // Version 9 cannot hold runs.
+ QFile manifest(path + QStringLiteral("/manifest.json"));
+ QVERIFY(manifest.open(QIODevice::ReadOnly));
+ QJsonObject object = QJsonDocument::fromJson(manifest.readAll()).object();
+ manifest.close();
+ object.insert(QStringLiteral("version"), 9);
+ QVERIFY(manifest.open(QIODevice::WriteOnly | QIODevice::Truncate));
+ manifest.write(QJsonDocument(object).toJson());
+ manifest.close();
+ try {
+ ProjectStore::load(path);
+ QFAIL("version 9 with colour runs loaded");
+ } catch (const ProjectError &error) {
+ QCOMPARE(error.kind, ProjectError::Kind::invalid);
+ }
+ session->undo();
+ QVERIFY(!session->activeLayer() || !session->activeLayer().value().liveText());
+}
+
+void TextColorRunsTests::cancelingPickerRestoresSelectionColors()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("Two words"), {0, 3});
+ session->setPaletteColor(red, false);
+ const LayerTextStyle colored = session->textDraft().value().style;
+ QCOMPARE(colored.colorRuns.value().size(), size_t(1));
+ session->openColorPicker(false);
+ pick(*session, blue);
+ session->previewTextColor();
+ QCOMPARE(session->textDraft().value().style.color(0).blue, 1.0);
+ session->closeColorPicker(false);
+ QVERIFY(session->textDraft().value().style == colored);
+ // Retyped meanwhile, the runs cannot come back: one colour.
+ session->openColorPicker(false);
+ pick(*session, blue);
+ session->previewTextColor();
+ edit(*session, QStringLiteral("Other words"), {0, 0});
+ session->closeColorPicker(false);
+ const LayerTextStyle retyped = session->textDraft().value().style;
+ QVERIFY(!retyped.colorRuns && retyped.red == colored.red && retyped.blue == colored.blue);
+ // Nothing selected: the whole text previews, Cancel restores it.
+ edit(*session, QStringLiteral("Other words"), {3, 0});
+ session->openColorPicker(false);
+ pick(*session, red);
+ session->previewTextColor();
+ QVERIFY(session->textDraft().value().style.red == 1 && !session->textDraft().value().style.colorRuns);
+ session->closeColorPicker(false);
+ QVERIFY(session->textDraft().value().style.red == 0 && session->textDraft().value().style.blue == 0);
+}
+
+void TextColorRunsTests::theSwatchShowsTheSelectionOrTheLetterBeforeTheCaret()
+{
+ const auto session = makeSession();
+ QVERIFY(session->typeColor() == session->foregroundColor());
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("abcdef"), {2, 2});
+ session->setPaletteColor(red, false);
+ // Selected: the first selected letter's colour.
+ QVERIFY(session->typeColor() == red);
+ edit(*session, QStringLiteral("abcdef"), {1, 3});
+ QVERIFY(session->typeColor() == PaletteColor::black());
+ // The Type bar's picker previews the selection; Cancel undoes.
+ edit(*session, QStringLiteral("abcdef"), {4, 0});
+ QVERIFY(session->typeColor() == red);
+ edit(*session, QStringLiteral("abcdef"), {2, 0});
+ QVERIFY(session->typeColor() == PaletteColor::black());
+ edit(*session, QStringLiteral("abcdef"), {0, 0});
+ QVERIFY(session->typeColor() == PaletteColor::black());
+}
+
+void TextColorRunsTests::theTypeBarsPickerPaintsTheSelectionAndCancelsBack()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("AAAA BBBB"), {0, 4});
+ const LayerTextStyle before = session->textDraft().value().style;
+ // Fill recolours every letter, runs gone, even unchanged.
+ session->openTextColorPicker();
+ QVERIFY(session->colorPicker().value().editedText.has_value());
+ pick(*session, blue);
+ session->previewTextColor();
+ QVERIFY(session->textDraft().value().style.color(0) == blue && session->textDraft().value().style.color(5) == PaletteColor::black());
+ session->closeColorPicker(false);
+ QVERIFY(session->textDraft().value().style == before);
+ // OK paints the selection and moves the foreground.
+ session->openTextColorPicker();
+ pick(*session, blue);
+ session->closeColorPicker(true);
+ QVERIFY(session->textDraft().value().style.color(3) == blue && session->textDraft().value().style.color(4) == PaletteColor::black());
+ QVERIFY(session->foregroundColor() == blue);
+}
+
+void TextColorRunsTests::fillAndNewTextStartInOneColour()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("Two words"), {4, 5});
+ session->setPaletteColor(red, false);
+ QVERIFY(session->finishText());
+ const QUuid id = session->activeLayerID().value();
+ // The defaults keep the style, never the runs.
+ QVERIFY(!session->textDefaults().colorRuns);
+ // The next text holds no runs of the last.
+ session->beginText(QPointF(300, 300), true);
+ QVERIFY(!session->textDraft().value().style.colorRuns);
+ session->cancelText();
+ // A run's keys, as Swift's Codable writes them.
+ QVERIFY(session->recolorText(id, PaletteColor::black()));
+ const LayerTextStyle filled = session->activeLayer().value().liveText().value().style;
+ QVERIFY(!filled.colorRuns && filled.red == 0);
+ QCOMPARE(session->history.undoName(), QString("Fill Text"));
+ QCOMPARE(redAndDark(session->activeLayer().value().asset.value().image()).first, 0);
+}
+
+void TextColorRunsTests::theManifestWritesRunsOnlyWhenThere()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("Plain"), {0, 0});
+ QVERIFY(session->finishText());
+ QTemporaryDir root;
+ const QString path = root.filePath(QStringLiteral("Plain.comp"));
+ ProjectStore::save(session->projectSnapshot().value(), path);
+ QFile manifest(path + QStringLiteral("/manifest.json"));
+ QVERIFY(manifest.open(QIODevice::ReadOnly));
+ const QByteArray bytes = manifest.readAll();
+ QVERIFY(!bytes.contains("colorRuns"));
+ // A run's keys, as Swift's Codable writes them.
+ session->editActiveText();
+ edit(*session, QStringLiteral("Plain"), {1, 2});
+ session->setPaletteColor(red, false);
+ QVERIFY(session->finishText());
+ ProjectStore::save(session->projectSnapshot().value(), path);
+ manifest.close();
+ QVERIFY(manifest.open(QIODevice::ReadOnly));
+ const QJsonObject object = QJsonDocument::fromJson(manifest.readAll()).object();
+ const QJsonObject text = object.value("layers").toArray().last().toObject().value("text").toObject();
+ QCOMPARE(text.value("colorRuns").toArray(), (QJsonArray{QJsonObject{{"location", 1}, {"length", 2}, {"red", 1}, {"green", 0}, {"blue", 0}}}));
+}
+
+void TextColorRunsTests::aRunInALaterParagraphColoursItsOwnLetters()
+{
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ edit(*session, QStringLiteral("MM\nMM"), {3, 2});
+ session->setPaletteColor(red, false);
+ QCOMPARE(session->textDraft().value().style.colorRuns.value(), (std::vector{run(3, 2, red)}));
+ QVERIFY(session->finishText());
+ const QImage image = session->activeLayer().value().asset.value().image();
+ // The first line dark, the second red.
+ const auto [topRed, topDark] = redAndDark(image.copy(0, 0, image.width(), image.height() / 2));
+ const auto [lowRed, lowDark] = redAndDark(image.copy(0, image.height() / 2, image.width(), image.height() - image.height() / 2));
+ QVERIFY2(topRed == 0 && topDark > 50 && lowRed > 50 && lowDark == 0,
+ qPrintable(QStringLiteral("%1 %2 %3 %4").arg(topRed).arg(topDark).arg(lowRed).arg(lowDark)));
+ // A run across the break: each line its own share.
+ session->editActiveText();
+ edit(*session, QStringLiteral("MM\nMM"), {1, 3});
+ session->setPaletteColor(PaletteColor{0, 1, 0}, false);
+ QVERIFY(session->finishText());
+ const QImage across = session->activeLayer().value().asset.value().image();
+ const auto count = [&across](bool upper, int channel) {
+ int found = 0;
+ const int from = upper ? 0 : across.height() / 2, to = upper ? across.height() / 2 : across.height();
+ for (int y = from; y < to; ++y) {
+ for (int x = 0; x < across.width(); ++x) {
+ const QColor colour = across.pixelColor(x, y);
+ const std::array rgb{colour.redF(), colour.greenF(), colour.blueF()};
+ const bool dark = channel < 0 && rgb[0] < 0.2f && rgb[1] < 0.2f;
+ found += colour.alphaF() > 0.9f && (dark || (channel >= 0 && rgb[size_t(channel)] > 0.8f && rgb[size_t(1 - channel)] < 0.2f));
+ }
+ }
+ return found;
+ };
+ // Upper: dark then green; lower: green then red.
+ QVERIFY(count(true, -1) > 50 && count(true, 1) > 50 && count(true, 0) == 0);
+ QVERIFY(count(false, -1) == 0 && count(false, 1) > 50 && count(false, 0) > 50);
+}
+
+void TextColorRunsTests::runsDecodeAsSwiftsCodable()
+{
+ const SavedRun saved;
+ const QJsonArray written = saved.manifest.value("layers").toArray().last().toObject().value("text").toObject().value("colorRuns").toArray();
+ QCOMPARE(written, (QJsonArray{QJsonObject{{"location", 1}, {"length", 2}, {"red", 0.25}, {"green", 0.5}, {"blue", 0.75}}}));
+ const LayerTextStyle loaded = ProjectStore::load(saved.path).manifest.layers.back().text.value();
+ QCOMPARE(loaded.colorRuns.value(), (std::vector{LayerTextColorRun{1, 2, 0.25, 0.5, 0.75}}));
+ const auto withRun = [&](const std::function &change) {
+ return saved.load([&](QJsonObject &text) {
+ QJsonObject run = written.first().toObject();
+ change(run);
+ text.insert("colorRuns", QJsonArray{run});
+ });
+ };
+ const std::optional invalid = ProjectError::Kind::invalid;
+ // Each of the five keys required, each a number.
+ for (const char *key : {"location", "length", "red", "green", "blue"}) {
+ QCOMPARE(withRun([key](QJsonObject &run) { run.remove(key); }), invalid);
+ QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, QStringLiteral("1")); }), invalid);
+ QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, QJsonValue::Null); }), invalid);
+ QCOMPARE(withRun([key](QJsonObject &run) { run.insert(key, true); }), invalid);
+ }
+ // Offsets are whole Int64s.
+ QCOMPARE(withRun([](QJsonObject &run) { run.insert("location", 1.5); }), invalid);
+ QCOMPARE(withRun([](QJsonObject &run) { run.insert("length", 1e19); }), invalid);
+ // Unknown keys pass.
+ QCOMPARE(withRun([](QJsonObject &run) { run.insert("alpha", 1); }), std::nullopt);
+ // Not an array, or an entry no object: refused.
+ QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonObject()); }), invalid);
+ QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonArray{5}); }), invalid);
+ // Absent or null: one colour.
+ QCOMPARE(saved.load([](QJsonObject &text) { text.remove("colorRuns"); }), std::nullopt);
+ QVERIFY(!ProjectStore::load(saved.path).manifest.layers.back().text.value().colorRuns);
+ QCOMPARE(saved.load([](QJsonObject &text) { text.insert("colorRuns", QJsonValue::Null); }), std::nullopt);
+ QVERIFY(!ProjectStore::load(saved.path).manifest.layers.back().text.value().colorRuns);
+}
+
+void TextColorRunsTests::cancelPutsBackMixedColoursForBothPickers()
+{
+ for (const bool typeBar : {false, true}) {
+ const auto session = makeSession();
+ session->beginText(QPointF(30, 40));
+ // A blue base; red and green in the coming selection.
+ edit(*session, QStringLiteral("abcdef"), {0, 0});
+ session->setPaletteColor(blue, false);
+ edit(*session, QStringLiteral("abcdef"), {1, 1});
+ session->setPaletteColor(red, false);
+ edit(*session, QStringLiteral("abcdef"), {2, 1});
+ session->setPaletteColor(PaletteColor{0, 1, 0}, false);
+ edit(*session, QStringLiteral("abcdef"), {1, 3});
+ const LayerTextStyle original = session->textDraft().value().style;
+ QCOMPARE(original.colorRuns.value().size(), size_t(2));
+ const auto open = [&] { typeBar ? session->openTextColorPicker() : session->openColorPicker(false); };
+ open();
+ pick(*session, PaletteColor{1, 1, 0});
+ session->previewTextColor();
+ QVERIFY(session->textDraft().value().style.color(1) == (PaletteColor{1, 1, 0}));
+ session->closeColorPicker(false);
+ QVERIFY2(session->textDraft().value().style == original, typeBar ? "type bar" : "palette");
+ // Retyped meanwhile: the new text stays, the base comes back.
+ open();
+ pick(*session, PaletteColor{1, 1, 0});
+ session->previewTextColor();
+ edit(*session, QStringLiteral("xyz"), {0, 0});
+ session->closeColorPicker(false);
+ const LayerTextStyle retyped = session->textDraft().value().style;
+ QVERIFY(retyped.content == "xyz" && !retyped.colorRuns);
+ QVERIFY((PaletteColor{retyped.red, retyped.green, retyped.blue}) == blue);
+ }
+}
+
+QTEST_MAIN(TextColorRunsTests)
+#include "TextColorRunsTests.moc"
diff --git a/tests/TypedTextCanvasTests.cpp b/tests/TypedTextCanvasTests.cpp
index 374a644..4e5c01f 100644
--- a/tests/TypedTextCanvasTests.cpp
+++ b/tests/TypedTextCanvasTests.cpp
@@ -80,13 +80,18 @@ private slots:
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted_data()
{
QTest::addColumn("zoom");
- QTest::newRow("smooth") << 1.5;
- QTest::newRow("hard") << 4.0;
+ QTest::addColumn("coloured");
+ QTest::newRow("smooth") << 1.5 << false;
+ QTest::newRow("hard") << 4.0 << false;
+ // Letters in their own colours: the layer draws them.
+ QTest::newRow("coloured smooth") << 1.5 << true;
+ QTest::newRow("coloured hard") << 4.0 << true;
}
void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
{
QFETCH(double, zoom);
+ QFETCH(bool, coloured);
DrawnText shown;
shown.session.zoom(zoom);
shown.canvas->synchronizeDisplay();
@@ -97,6 +102,8 @@ void TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted()
style.content = QStringLiteral("Sharp");
style.fontSize = 24;
});
+ if (coloured)
+ shown.session.changeTextStyle([](LayerTextStyle &style) { style.setColor(PaletteColor{0, 1, 0}, {1, 3}); });
// Unfocused, no caret draws; the frame stays at the edge.
unfocus(shown);
const QRect inside = shown.editor().boxTransform().mapRect(QRectF(QPointF(0, 0), shown.editor().logicalSize())).toAlignedRect().adjusted(10, 10, -10, -10);