diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b49981..4c45e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixed +- A program that never set a background color rendered with **no background at all**, even though `Tortoise.backgroundColor` reports white from the moment it is created — so an empty tortoise claimed white but drew nothing. On a dark host the default black pen was nearly invisible, `ImageRenderer` exports came out as fully transparent PNGs, and the SVG carried no background ``. `CommandPlayer.play`'s `initialBackgroundColor` defaulted to `.clear` while `Tortoise` starts at white, and `CanvasModel` / `TortoiseSVG` each fell back to `.clear` of their own for an empty stream. All four now share one constant, the new `Color.defaultBackground` (white) ([#44](https://github.com/temoki/TortoiseGraphics2/issues/44)) + +### Changed +- **Behavior change:** drawings without an explicit `.backgroundColor` command now render on white in both `TortoiseCanvas` and `TortoiseSVG` (previously transparent). If you were relying on the transparent default — for instance to let SwiftUI's `.background()` modifier show through, or to export a PNG with an alpha channel — set it explicitly with `tortoise.backgroundColor = .clear`. Both renderers still skip the fill entirely when the background is transparent, so that path is unchanged. SVG goldens gain one `` line; canvas output is unchanged wherever the view already sat on a white backdrop + ## 2.0.0-beta11 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6dd2b75..608f21d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Tortoise API → [TortoiseCommand] → CommandPlayer.play() → [PlaybackFrame] **`@_exported import TortoiseCore` in `TortoiseUI` and `TortoiseSVG`.** Users only write `import TortoiseUI` / `import TortoiseSVG` and still see all Core types. The underscored attribute has no stability guarantee from Swift; if a future toolchain breaks it, the fallback is to drop the re-export and require users to add `import TortoiseCore` themselves — a breaking change to document in the CHANGELOG, not something to work around with tricks. -**`backgroundColor` defaults to `.clear`.** `TortoiseCanvas` skips the background fill when `alpha == 0`, letting SwiftUI's `.background()` modifier control the canvas background. The SVG renderer likewise omits the `` element when the background is transparent. +**`Color.defaultBackground` is the single source of truth for the initial background (#44).** `Tortoise` starts at it, `CommandPlayer.play`'s `initialBackgroundColor` defaults to it, and `CanvasModel` / `SVGBuilder` use it as their empty-stream fallback. It is white. Renderers must never substitute a fallback of their own: they previously started from `.clear` while `Tortoise.backgroundColor` reported white, so a program that issued no `.backgroundColor` command painted nothing — the tortoise and its own drawing disagreed about the color of the paper. Note that `Tortoise.backgroundColor` is the *current* value, not the initial one, so it must not be threaded in as `initialBackgroundColor`; that would back-date a later background change to frame 0. Transparency is still available, but must be asked for: `tortoise.backgroundColor = .clear`. Both renderers still skip the fill / omit the `` when `alpha == 0`, which is what makes SwiftUI's `.background()` modifier work. **`TortoiseSprite` is a TortoiseUI-only concept.** The sprite (built-in triangle or a user `Image`) is chosen through the `\.tortoiseSprite` environment value, like `\.tortoiseViewport` — it is *not* a `TortoiseCommand`, so it never enters the serialized stream and `TortoiseSVG` is unaffected (SVG output has never drawn the tortoise). Both canvas layers read the environment value even though only `AnimationLayer` draws the sprite: `ViewportMode.autoFit`'s edge inset is `TortoiseSprite.halfExtent * tortoiseScaleMax`, and the two layers must derive the identical transform. `halfExtent` is the sprite's half-*diagonal* so the inset holds at every heading. Image sprites are aspect-fitted into `size` (`ctx.resolve` gives the intrinsic size; a `ResolvedImage` is bound to its context, so this cannot be hoisted out of the per-frame draw). diff --git a/README.md b/README.md index 83c0e2e..bb38c99 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ article for the wire format and its stability guarantee. | Method / Property | Description | |---|---| -| `backgroundColor: Color` | Canvas background color | +| `backgroundColor: Color` | Canvas background color. Defaults to white; set `.clear` for a transparent canvas (then SwiftUI's `.background()` or the host page shows through) | | `clear()` | Erase all drawings (tortoise state is preserved) | | `reset()` | Discard all commands and restore the initial state (`canvasSize` is kept) | | `speed: Double` | Animation speed: 1 (slowest) … 10 (fastest), 0 = instant | diff --git a/Sources/TortoiseCore/Color.swift b/Sources/TortoiseCore/Color.swift index 2f90867..01471b5 100644 --- a/Sources/TortoiseCore/Color.swift +++ b/Sources/TortoiseCore/Color.swift @@ -31,6 +31,16 @@ extension Color { public static let cyan = Color(red: 0, green: 1, blue: 1) public static let magenta = Color(red: 1, green: 0, blue: 1) public static let clear = Color(red: 0, green: 0, blue: 0, alpha: 0) + + /// The background every ``Tortoise`` starts with, and the value + /// ``CommandPlayer`` replays from when a stream sets no background of its + /// own — the single source of truth for "no `.backgroundColor` command + /// was issued". Renderers must not substitute their own fallback, or the + /// tortoise and the drawing disagree about the color of the paper (#44). + /// + /// Pass `Color.clear` explicitly (`tortoise.backgroundColor = .clear`) for + /// a transparent canvas. + public static let defaultBackground = white } extension Double { diff --git a/Sources/TortoiseCore/CommandPlayer.swift b/Sources/TortoiseCore/CommandPlayer.swift index 866625a..b50a552 100644 --- a/Sources/TortoiseCore/CommandPlayer.swift +++ b/Sources/TortoiseCore/CommandPlayer.swift @@ -8,7 +8,7 @@ public enum CommandPlayer { public static func play( commands: [TortoiseCommand], initialState: TortoiseState = .default, - initialBackgroundColor: Color = .clear + initialBackgroundColor: Color = .defaultBackground ) -> [PlaybackFrame] { var frames: [PlaybackFrame] = [] frames.reserveCapacity(commands.count) diff --git a/Sources/TortoiseCore/Tortoise.swift b/Sources/TortoiseCore/Tortoise.swift index be2d835..3b49e80 100644 --- a/Sources/TortoiseCore/Tortoise.swift +++ b/Sources/TortoiseCore/Tortoise.swift @@ -38,7 +38,7 @@ public final class Tortoise { public let canvasSize: Size private var state: TortoiseState = .default - private var _backgroundColor: Color = .white + private var _backgroundColor: Color = .defaultBackground private var _isFilling: Bool = false public init(canvasSize: Size = .defaultCanvas) { @@ -255,7 +255,7 @@ public final class Tortoise { public func reset() { commands = [] state = .default - _backgroundColor = .white + _backgroundColor = .defaultBackground _isFilling = false mutationCount += 1 } diff --git a/Sources/TortoiseSVG/TortoiseSVG.swift b/Sources/TortoiseSVG/TortoiseSVG.swift index d492625..22584bb 100644 --- a/Sources/TortoiseSVG/TortoiseSVG.swift +++ b/Sources/TortoiseSVG/TortoiseSVG.swift @@ -62,7 +62,7 @@ private struct SVGBuilder { func build() -> String { var elements: [SVGElement] = [] - var bgColor: Color = .clear + var bgColor: Color = .defaultBackground // Strokes/arcs drawn while isFillActive are held here until endFill, // then flushed AFTER the fill polygon so the polygon renders below its outline. var pendingFillStrokes: [SVGElement] = [] diff --git a/Sources/TortoiseUI/CanvasModel.swift b/Sources/TortoiseUI/CanvasModel.swift index 3bc9a85..84d76f6 100644 --- a/Sources/TortoiseUI/CanvasModel.swift +++ b/Sources/TortoiseUI/CanvasModel.swift @@ -39,7 +39,7 @@ final class CanvasModel { /// Drawing elements in command-execution order. /// Fill polygons are inserted before their outline strokes so they render below them. private(set) var elements: [DrawElement] = [] - private(set) var backgroundColor: TortoiseCore.Color = .clear + private(set) var backgroundColor: TortoiseCore.Color = .defaultBackground private(set) var tortoiseState: TortoiseState = .default /// Progress (0 → 1) through the animation of the next frame. @@ -145,7 +145,7 @@ final class CanvasModel { elements.removeAll() fillInsertionIndex = nil currentFrameIndex = -1 - backgroundColor = frames.first?.backgroundColor ?? .clear + backgroundColor = frames.first?.backgroundColor ?? .defaultBackground tortoiseState = .default animationProgress = 0 lastTickDate = nil diff --git a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift index 3949ca3..99194e7 100644 --- a/Tests/TortoiseCoreTests/TortoiseCoreTests.swift +++ b/Tests/TortoiseCoreTests/TortoiseCoreTests.swift @@ -103,6 +103,14 @@ struct TortoiseAPITests { #expect(t.backgroundColor == .cyan) } + // The value renderers replay from when no .backgroundColor command is + // issued, so the tortoise and its drawing agree on the paper color (#44). + @Test("a fresh tortoise starts at the shared default background") + func initialBackgroundIsTheSharedDefault() { + #expect(Tortoise().backgroundColor == .defaultBackground) + #expect(Tortoise().commands.isEmpty) + } + @Test("beginFill / endFill append correct commands") func fillCommands() { let t = Tortoise() @@ -441,6 +449,32 @@ struct CommandPlayerTests { #expect(frames[0].backgroundColor == .cyan) } + // A stream that never sets a background must replay from the same color a + // fresh Tortoise reports, or renderers paint nothing while the tortoise + // claims white (#44). + @Test("replay defaults to the Tortoise's own initial background") + func defaultBackgroundMatchesTortoise() { + #expect(Color.defaultBackground == .white) + let frames = CommandPlayer.play(commands: [.forward(40)]) + #expect(frames[0].backgroundColor == .defaultBackground) + } + + // The frames before a later .backgroundColor command must keep the initial + // color: the change belongs to the command that made it, not to frame 0. + @Test("a later backgroundColor command is not back-dated to earlier frames") + func backgroundChangeIsNotBackDated() { + let frames = CommandPlayer.play(commands: [.forward(40), .backgroundColor(.cyan)]) + #expect(frames[0].backgroundColor == .defaultBackground) + #expect(frames[1].backgroundColor == .cyan) + } + + @Test("an explicit clear background stays transparent") + func explicitClearBackground() { + let frames = CommandPlayer.play(commands: [.backgroundColor(.clear)]) + #expect(frames[0].backgroundColor == .clear) + #expect(frames[0].backgroundColor.alpha == 0) + } + @Test("speed clamped to non-negative") func speedClamped() { let frames = CommandPlayer.play(commands: [.speed(-1)]) diff --git a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift index 58ad1fd..6bd430e 100644 --- a/Tests/TortoiseSVGTests/TortoiseSVGTests.swift +++ b/Tests/TortoiseSVGTests/TortoiseSVGTests.swift @@ -35,9 +35,16 @@ struct TortoiseSVGTests { // MARK: Background - @Test("default background is transparent (no rect element)") + @Test("default background is white, matching a fresh Tortoise (#44)") func defaultBackground() { let out = svg() + #expect(out.contains(" + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearAndRedraw.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearAndRedraw.svg index 20fa67c..9eab236 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearAndRedraw.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearAndRedraw.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearDuringFill.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearDuringFill.svg index aac7c30..2138caa 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearDuringFill.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.clearDuringFill.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.dots.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.dots.svg index edb63de..e8396b2 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.dots.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.dots.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.fillWithArc.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.fillWithArc.svg index af43802..096d61f 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.fillWithArc.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.fillWithArc.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.filledShapes.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.filledShapes.svg index 3a5a4ec..a8b878e 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.filledShapes.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.filledShapes.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.hiddenTortoise.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.hiddenTortoise.svg index 186237b..4df40a1 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.hiddenTortoise.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.hiddenTortoise.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.linesAndTurns.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.linesAndTurns.svg index c5f671a..1e12d5e 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.linesAndTurns.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.linesAndTurns.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.negativeRadiusArcs.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.negativeRadiusArcs.svg index 5b26e43..2bb52f7 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.negativeRadiusArcs.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.negativeRadiusArcs.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.penStyles.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.penStyles.svg index a18c0a4..7084e0d 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.penStyles.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.penStyles.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.showAfterHide.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.showAfterHide.svg index 3c10bba..90424eb 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.showAfterHide.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.showAfterHide.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.speedChanges.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.speedChanges.svg index d241fb6..94a3141 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.speedChanges.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.speedChanges.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.teleportAndHome.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.teleportAndHome.svg index a008830..cf9f4d6 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.teleportAndHome.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.teleportAndHome.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg index f88a67b..520ab92 100644 --- a/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg +++ b/Tests/TortoiseSVGTests/__Snapshots__/DrawingScenarioSVGTests/scenario.translucentOverlaps.svg @@ -1,5 +1,6 @@ + diff --git a/Tests/TortoiseUITests/CanvasBackgroundTests.swift b/Tests/TortoiseUITests/CanvasBackgroundTests.swift new file mode 100644 index 0000000..19b1f77 --- /dev/null +++ b/Tests/TortoiseUITests/CanvasBackgroundTests.swift @@ -0,0 +1,130 @@ +import Foundation +import SwiftUI +import Testing +import TortoiseCore + +@testable import TortoiseUI + +@Suite("Canvas background") +@MainActor +struct CanvasBackgroundTests { + @Test("a program with no background command replays as white, not clear (#44)") + func defaultBackgroundIsWhite() { + let tortoise = Tortoise() + tortoise.speed = 0 + tortoise.forward(40) + let model = CanvasModel( + commands: tortoise.commands, canvasSize: tortoise.canvasSize, sourceKey: nil) + #expect(model.backgroundColor == .defaultBackground) + } + + @Test("an empty tortoise reports the same background it renders (#44)") + func emptyStreamMatchesTortoise() { + let tortoise = Tortoise() + let model = CanvasModel( + commands: tortoise.commands, canvasSize: tortoise.canvasSize, sourceKey: nil) + #expect(model.backgroundColor == tortoise.backgroundColor) + } + + @Test("an explicit clear background stays transparent") + func explicitClearStaysTransparent() { + let tortoise = Tortoise() + tortoise.backgroundColor = .clear + tortoise.forward(40) + let model = CanvasModel( + commands: tortoise.commands, canvasSize: tortoise.canvasSize, sourceKey: nil) + #expect(model.backgroundColor == .clear) + } + + @Test("seeking back before the first command keeps the default background") + func seekToStartKeepsDefault() { + let tortoise = Tortoise() + tortoise.forward(40) + tortoise.backgroundColor = .cyan + let model = CanvasModel( + commands: tortoise.commands, canvasSize: tortoise.canvasSize, sourceKey: nil) + model.seek(to: model.frames.count - 1) + #expect(model.backgroundColor == .cyan) + model.seek(to: -1) + #expect(model.backgroundColor == .defaultBackground) + } +} + +#if os(macOS) + /// The issue's own reproduction: put the canvas on a red backdrop and read a + /// corner pixel. Before the fix it came back red (#FF2600) because nothing + /// was painted; it must now be the tortoise's white paper. + @Suite("Canvas background rendering") + @MainActor + struct CanvasBackgroundRenderingTests { + private func cornerPixel(_ view: some View) -> (r: Double, g: Double, b: Double, a: Double)? + { + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + renderer.proposedSize = ProposedViewSize(width: 60, height: 60) + guard let cgImage = renderer.cgImage else { return nil } + var pixel = [UInt8](repeating: 0, count: 4) + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let ctx = CGContext( + data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { return nil } + // Sample a corner pixel — the drawing sits in the middle, so every + // corner is background. + ctx.draw( + cgImage, + in: CGRect( + x: 0, y: -(cgImage.height - 1), width: cgImage.width, height: cgImage.height)) + return ( + Double(pixel[0]) / 255, Double(pixel[1]) / 255, Double(pixel[2]) / 255, + Double(pixel[3]) / 255 + ) + } + + @Test("the canvas paints white over the host's backdrop (#44)") + func paintsOverBackdrop() { + let tortoise = Tortoise() + tortoise.speed = 0 + tortoise.penDown() + tortoise.forward(40) + + let view = TortoiseCanvas(tortoise) + .frame(width: 60, height: 60) + .background(Color(red: 1, green: 0, blue: 0)) + + guard let px = cornerPixel(view) else { + Issue.record("ImageRenderer produced no image") + return + } + // Loose thresholds on purpose: the assertion is "white paper, not + // the red backdrop", and exact components would make the test + // depend on the render target's color space (cf. #43). + #expect(px.a == 1.0) + #expect(px.r > 0.9) + #expect(px.g > 0.9) + #expect(px.b > 0.9) + } + + @Test("an explicit clear background still lets the backdrop through") + func clearLetsBackdropThrough() { + let tortoise = Tortoise() + tortoise.speed = 0 + tortoise.backgroundColor = .clear + tortoise.penDown() + tortoise.forward(40) + + let view = TortoiseCanvas(tortoise) + .frame(width: 60, height: 60) + .background(Color(red: 1, green: 0, blue: 0)) + + guard let px = cornerPixel(view) else { + Issue.record("ImageRenderer produced no image") + return + } + #expect(px.r > 0.8) + #expect(px.g < 0.3) + #expect(px.b < 0.3) + } + } +#endif