Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<rect>`. `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 `<rect fill="#ffffff"/>` line; canvas output is unchanged wherever the view already sat on a white backdrop

## 2.0.0-beta11

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rect>` 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 `<rect>` 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).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 10 additions & 0 deletions Sources/TortoiseCore/Color.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/TortoiseCore/CommandPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Sources/TortoiseCore/Tortoise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -255,7 +255,7 @@ public final class Tortoise {
public func reset() {
commands = []
state = .default
_backgroundColor = .white
_backgroundColor = .defaultBackground
_isFilling = false
mutationCount += 1
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/TortoiseSVG/TortoiseSVG.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
4 changes: 2 additions & 2 deletions Sources/TortoiseUI/CanvasModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions Tests/TortoiseCoreTests/TortoiseCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)])
Expand Down
9 changes: 8 additions & 1 deletion Tests/TortoiseSVGTests/TortoiseSVGTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<rect"))
#expect(out.contains("fill=\"#ffffff\""))
}

@Test("an explicitly transparent background emits no rect element")
func clearBackground() {
let out = svg(.backgroundColor(.clear))
#expect(!out.contains("<rect"))
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading