diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index dc1ded9..0b567fb 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -394,6 +394,16 @@ } } }, + "Filled Square" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "しかくをぬる" + } + } + } + }, "Forward" : { "localizations" : { "ja" : { @@ -894,62 +904,62 @@ } } }, - "Sample: Filled Square" : { + "Samples" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サンプル: しかくをぬる" + "value" : "サンプル" } } } }, - "Sample: Spiral" : { + "Share PNG" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サンプル: うずまきをかく" + "value" : "PNG をきょうゆう" } } } }, - "Sample: Star" : { + "Share SVG" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サンプル: ほしをかく" + "value" : "SVG をきょうゆう" } } } }, - "Share PNG" : { + "Speed" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "PNG をきょうゆう" + "value" : "はやさ" } } } }, - "Share SVG" : { + "Spiral" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "SVG をきょうゆう" + "value" : "うずまきをかく" } } } }, - "Speed" : { + "Star" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "はやさ" + "value" : "ほしをかく" } } } @@ -1144,6 +1154,16 @@ } } }, + "Tree" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "きをかく" + } + } + } + }, "Try a smaller repeat count." : { "localizations" : { "ja" : { diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index 88239d3..ef478a1 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -25,16 +25,32 @@ struct WorkspaceView: View { // A one-tap educational on-ramp: dropping in a whole // sample goes through insertSample -> setBlocks, so it's // undoable and dirties the document like any other edit. - VStack(spacing: 8) { - Button("Sample: Filled Square") { + // Each sample wears the picture it draws. The emoji is + // artwork, not text — the same in every language — so it + // is a `verbatim` icon rather than part of the localized + // title, and the string catalog stays free of it. + // + // "Sample" is said once, over the list, rather than at the + // head of all four names: four labels that begin the same + // way are four labels a child has to read past to find the + // word that differs. + VStack(alignment: .leading, spacing: 8) { + Text("Samples") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + SampleButton("🟦", "Filled Square") { workspace.insertSample(SampleBlocks.filledSquare()) } - Button("Sample: Star") { + SampleButton("⭐️", "Star") { workspace.insertSample(SampleBlocks.star()) } - Button("Sample: Spiral") { + SampleButton("🌀", "Spiral") { workspace.insertSample(SampleBlocks.spiral()) } + SampleButton("🌳", "Tree") { + workspace.insertSample(SampleBlocks.fractalTree()) + } } } .frame(maxHeight: .infinity) @@ -112,6 +128,30 @@ struct WorkspaceView: View { } } +/// One entry in the empty workspace's "start from a sample" list: the drawing +/// it makes, then what it is called. +private struct SampleButton: View { + let emoji: String + let title: LocalizedStringResource + let action: () -> Void + + init(_ emoji: String, _ title: LocalizedStringResource, action: @escaping () -> Void) { + self.emoji = emoji + self.title = title + self.action = action + } + + var body: some View { + Button(action: action) { + Label { + Text(title) + } icon: { + Text(verbatim: emoji) + } + } + } +} + /// Somewhere to put a block you have picked up and thought better of — the one /// thing dragging was missing (#30). Deleting was never hidden: every row's /// menu carries it (a ✕ of its own, when this was written — #44). What there diff --git a/TortoiseBlocksKit/Sources/TortoiseBlocksKit/SampleBlocks.swift b/TortoiseBlocksKit/Sources/TortoiseBlocksKit/SampleBlocks.swift index 4700618..1693eed 100644 --- a/TortoiseBlocksKit/Sources/TortoiseBlocksKit/SampleBlocks.swift +++ b/TortoiseBlocksKit/Sources/TortoiseBlocksKit/SampleBlocks.swift @@ -56,6 +56,46 @@ public enum SampleBlocks { ] } + /// A recursive tree: a block the child defined, calling itself (#14). + /// + /// Two branches per level, stopped by an if — the shape recursion exists to + /// draw, and the one sample that uses じぶんのブロック at all. The box is + /// scaled by 0.6 on the way in and divided back out on the way out, which + /// is why a single global box is enough: each call leaves 🌟 exactly as it + /// found it, so the trunk it retraces is the one it drew. + /// + /// 70 down to 6 is five levels — 31 branches, ~155 commands, nesting 11 — + /// well inside both the step cap and `BlockExpander.defaultNestingLimit`. + public static func fractalTree() -> [Block] { + [ + Block(kind: .penColor(.literal(.green))), + Block(kind: .penWidth(.literal(2))), + Block(kind: .setVariable(name: "🌟", value: .literal(70))), + Block(kind: .callBlock(name: "き")), + Block( + kind: .defineBlock( + name: "き", + body: [ + Block( + kind: .ifBlock( + condition: Condition( + lhs: .variable("🌟"), comparison: .greater, rhs: .literal(6)), + body: [ + Block(kind: .forward(.variable("🌟"))), + Block(kind: .multiplyVariable(name: "🌟", value: .literal(0.6))), + Block(kind: .turnRight(.literal(28))), + Block(kind: .callBlock(name: "き")), + Block(kind: .turnLeft(.literal(56))), + Block(kind: .callBlock(name: "き")), + Block(kind: .turnRight(.literal(28))), + Block(kind: .divideVariable(name: "🌟", value: .literal(0.6))), + Block(kind: .backward(.variable("🌟"))), + ], + elseBody: nil)) + ])), + ] + } + /// A filled square — fill color, four repeated sides, then close the fill. public static func filledSquare() -> [Block] { [ diff --git a/TortoiseBlocksKit/Tests/TortoiseBlocksKitTests/SampleBlocksTests.swift b/TortoiseBlocksKit/Tests/TortoiseBlocksKitTests/SampleBlocksTests.swift index 48e0a3b..534f037 100644 --- a/TortoiseBlocksKit/Tests/TortoiseBlocksKitTests/SampleBlocksTests.swift +++ b/TortoiseBlocksKit/Tests/TortoiseBlocksKitTests/SampleBlocksTests.swift @@ -48,6 +48,27 @@ struct SampleBlocksTests { #expect(distances == distances.sorted()) } + @Test("fractalTree draws 31 branches and comes back down its own trunk") + func fractalTreeExpansion() throws { + let expanded = try BlockExpander.expand(SampleBlocks.fractalTree()) + let commands = expanded.map(\.command) + // Pen colour + width, then 31 branches of forward + three turns + + // backward. 70 halved-ish by 0.6 five times drops under the if's 6. + #expect(commands.count == 2 + 31 * 5) + let distances = commands.compactMap { command -> Double? in + guard case .forward(let distance) = command else { return nil } + return distance + } + #expect(distances.count == 31 * 2) + #expect(distances.first == 70) + // The outermost call retraces its own trunk, which is the whole claim + // that one global box is enough: ×0.6 … ÷0.6 leaves 🌟 where it was. + #expect(distances.last == -70) + // And it stays well clear of both limits, so the sample can never be + // the program that trips them. + #expect(commands.count < BlockExpander.defaultLimit) + } + @Test("filledSquare expands to a fill-wrapped square") func filledSquareExpansion() throws { let expanded = try BlockExpander.expand(SampleBlocks.filledSquare())