From 9f3fa64d1c3e41cd45c0820c0e021551c2773fec Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 11 Aug 2026 09:32:07 +0900 Subject: [PATCH 1/2] Offer the tree as a sample, and give every sample its picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty workspace could start you on a square, a star or a spiral, and none of them used a box, an if, or a block of your own โ€” so the one feature that needs seeing to be believed had no way in but building it yourself. ๐ŸŒณ is that way in: a block that calls itself, stopped by an if, five levels and 31 branches. It is also the argument for the shape #14 took. The box is scaled on the way into each call and divided back out on the way out, so a single global box is enough โ€” the test pins that by checking the last command is forward(-70), the outermost call retracing the trunk it drew. A first release without parameters is not a compromise if this is what fits in it. Every sample now wears the picture it draws โ€” ๐ŸŸฆ โญ๏ธ ๐ŸŒ€ ๐ŸŒณ. The emoji is artwork rather than text: it is the same in every language, so it goes in as a verbatim icon and the string catalog never sees it. Fixes #45 --- App/Localizable.xcstrings | 10 +++++ App/Views/WorkspaceView.swift | 39 ++++++++++++++++-- .../TortoiseBlocksKit/SampleBlocks.swift | 40 +++++++++++++++++++ .../SampleBlocksTests.swift | 21 ++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index dc1ded9..7c849a9 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -924,6 +924,16 @@ } } }, + "Sample: Tree" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ใ‚ตใƒณใƒ—ใƒซ: ใใ‚’ใ‹ใ" + } + } + } + }, "Share PNG" : { "localizations" : { "ja" : { diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index 88239d3..cdd24ca 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -25,16 +25,23 @@ 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. + VStack(alignment: .leading, spacing: 8) { + SampleButton("๐ŸŸฆ", "Sample: Filled Square") { workspace.insertSample(SampleBlocks.filledSquare()) } - Button("Sample: Star") { + SampleButton("โญ๏ธ", "Sample: Star") { workspace.insertSample(SampleBlocks.star()) } - Button("Sample: Spiral") { + SampleButton("๐ŸŒ€", "Sample: Spiral") { workspace.insertSample(SampleBlocks.spiral()) } + SampleButton("๐ŸŒณ", "Sample: Tree") { + workspace.insertSample(SampleBlocks.fractalTree()) + } } } .frame(maxHeight: .infinity) @@ -112,6 +119,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()) From 88ebbca2489e86cc4137df4ffe5921344a8fa640 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 11 Aug 2026 09:35:59 +0900 Subject: [PATCH 2/2] Say "sample" once, over the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four buttons that all began with ใ€Œใ‚ตใƒณใƒ—ใƒซ:ใ€ were four labels a child had to read past to reach the word that differs. The word moves to a caption above the list, where it is said once, and the names shorten to ใ—ใ‹ใใ‚’ใฌใ‚‹ / ใปใ—ใ‚’ใ‹ใ / ใ†ใšใพใใ‚’ใ‹ใ / ใใ‚’ใ‹ใ โ€” each now telling its own drawing apart from the first character. The old keys retire rather than linger: they had no call site left, and a string catalog full of stale entries is a worse record than none. --- App/Localizable.xcstrings | 54 +++++++++++++++++++++-------------- App/Views/WorkspaceView.swift | 17 ++++++++--- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 7c849a9..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,72 +904,62 @@ } } }, - "Sample: Filled Square" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ใ‚ตใƒณใƒ—ใƒซ: ใ—ใ‹ใใ‚’ใฌใ‚‹" - } - } - } - }, - "Sample: Spiral" : { + "Samples" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ใ‚ตใƒณใƒ—ใƒซ: ใ†ใšใพใใ‚’ใ‹ใ" + "value" : "ใ‚ตใƒณใƒ—ใƒซ" } } } }, - "Sample: Star" : { + "Share PNG" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ใ‚ตใƒณใƒ—ใƒซ: ใปใ—ใ‚’ใ‹ใ" + "value" : "PNG ใ‚’ใใ‚‡ใ†ใ‚†ใ†" } } } }, - "Sample: Tree" : { + "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" : "ใปใ—ใ‚’ใ‹ใ" } } } @@ -1154,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 cdd24ca..ef478a1 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -29,17 +29,26 @@ struct WorkspaceView: View { // 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) { - SampleButton("๐ŸŸฆ", "Sample: Filled Square") { + Text("Samples") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + SampleButton("๐ŸŸฆ", "Filled Square") { workspace.insertSample(SampleBlocks.filledSquare()) } - SampleButton("โญ๏ธ", "Sample: Star") { + SampleButton("โญ๏ธ", "Star") { workspace.insertSample(SampleBlocks.star()) } - SampleButton("๐ŸŒ€", "Sample: Spiral") { + SampleButton("๐ŸŒ€", "Spiral") { workspace.insertSample(SampleBlocks.spiral()) } - SampleButton("๐ŸŒณ", "Sample: Tree") { + SampleButton("๐ŸŒณ", "Tree") { workspace.insertSample(SampleBlocks.fractalTree()) } }