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
44 changes: 32 additions & 12 deletions App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,16 @@
}
}
},
"Filled Square" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "しかくをぬる"
}
}
}
},
"Forward" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -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" : "ほしをかく"
}
}
}
Expand Down Expand Up @@ -1144,6 +1154,16 @@
}
}
},
"Tree" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "きをかく"
}
}
}
},
"Try a smaller repeat count." : {
"localizations" : {
"ja" : {
Expand Down
48 changes: 44 additions & 4 deletions App/Views/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions TortoiseBlocksKit/Sources/TortoiseBlocksKit/SampleBlocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down