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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ jobs:
destination: generic/platform=iOS Simulator
- name: macOS
destination: platform=macOS
# visionOS (#11). It shares every source file with iPadOS, so what
# this catches is the `#if` that quietly stops applying: SwiftUI
# marks a handful of iOS API unavailable there (`ToolbarSpacer`),
# and an `#if os(iOS)` compiles clean on all three platforms while
# doing nothing on this one.
- name: visionOS
destination: generic/platform=visionOS Simulator
steps:
- uses: actions/checkout@v7

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Back.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.solidimagestacklayer"
},
{
"filename" : "Middle.solidimagestacklayer"
},
{
"filename" : "Back.solidimagestacklayer"
}
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Front.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Middle.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
2 changes: 1 addition & 1 deletion App/TortoiseBlocksApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct TortoiseBlocksApp: App {
// unavailable on macOS and SwiftUI has no empty `Scene` to return in
// its place, so this `#if` can't hide inside a modifier the way the
// ones in `PlatformModifiers` do.
#if os(iOS)
#if !os(macOS)
LaunchScene()
#endif
}
Expand Down
22 changes: 18 additions & 4 deletions App/Views/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,24 @@ damage rather than fixing it: with the label served first, `0.6` in the next row
broke as "0." over "6", which is worse — a chip holds a number, a name or a
colour, all atomic. So `WorkspaceChipButtonStyle` pins its label with
`.fixedSize(horizontal: true, vertical: false)`. Priority rather than
`fixedSize` on the *label*, deliberately: a row two levels deep with a long name
(「はこにかける」) genuinely runs out of room, and there the label should still
wrap instead of overflowing its block. Each level of nesting costs 18pt, so no
column width wins that race — the wrap is accepted there.
`fixedSize` on the *label*, deliberately: a row deep enough with a long name
genuinely runs out of room, and there the label should still wrap instead of
overflowing its block. Each level of nesting costs 18pt, so no column width wins
that race for ever — the wrap is the correct last resort, not the working state.

**And `ideal` is not a starting point on every platform.** macOS and iPadOS 26
both let the split view's divider be dragged, so there the ideal is only where
the workspace column *opens*. **visionOS has no draggable divider**, so there
the ideal is the width, for good, and the *detail* column absorbs every extra
point the window has. On a 1280pt visionOS window that used to read 360 here
and ~690 on the canvas (#11) — with 「くりかえす 10 かい」 wrapping か/い at the
**top** level, no nesting involved, and 「はこにかける」 splitting in the middle
three levels down. The ideal is now 440, measured against exactly that program:
every row fits on one line at three levels, and the canvas still clears its own
420 ideal. `max` (560) has to stay above `ideal`, or the two platforms that can
drag could only ever drag narrower. Judge a change to it on a *nested Japanese*
program — English fits where 「はこにかける」 does not, and the top-level wrap is
invisible in a flat one.

**Drop model**: a `DropGap` between rows carries `(BodyAddress, index)`, so
insertion semantics need no y-coordinate math and every mouth — an if's else
Expand Down
75 changes: 56 additions & 19 deletions App/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ struct ContentView: View {
}
}

/// The one layout, on both platforms (#29). The app is iPad and Mac only, and
/// compact width — an iPhone, or an iPad window squeezed into Slide Over — is
/// no longer a design target: three panes' worth of information (palette,
/// program, canvas) folded into one 390pt column never came out usable. A
/// window narrow enough to go compact gets `NavigationSplitView`'s own
/// collapse, not a layout of ours.
/// The one layout, on every platform (#29) — iPad, Mac, and the visionOS
/// window, which is a regular-width scene and needs nothing of its own (#11).
/// Compact width — an iPhone, or an iPad window squeezed into Slide Over — is
/// not a design target: three panes' worth of information (palette, program,
/// canvas) folded into one 390pt column never came out usable. A window narrow
/// enough to go compact gets `NavigationSplitView`'s own collapse, not a layout
/// of ours.
struct RootView: View {
let workspace: WorkspaceEditor
let runner: RunnerModel
Expand All @@ -56,8 +57,21 @@ struct RootView: View {
PaletteView(workspace: workspace)
.navigationSplitViewColumnWidth(paletteWidth)
} content: {
// 440pt is measured, not chosen, and `ideal` carries more weight
// than it looks. macOS and iPadOS both let the divider be dragged,
// so there the ideal is only where the column *opens*; on visionOS
// it can't be, so the ideal is the width, permanently, while the
// detail column absorbs every extra point — a 1280pt visionOS
// window put 360 here and ~690 on the canvas (#11).
// At 360 a three-deep program broke its labels onto two lines, and
// not only at depth: 「くりかえす 10 かい」 wrapped か/い at the top
// level, and 「はこにかける」 split in the middle. 440 fits every one
// of them on one line at three levels of nesting (each level costs
// 18pt), and still leaves the canvas ~600pt — well over its own 420
// ideal. `max` has to stay above `ideal`, or the two platforms that
// can drag could only ever drag narrower.
WorkspaceView(workspace: workspace, runner: runner)
.navigationSplitViewColumnWidth(min: 300, ideal: 360, max: 440)
.navigationSplitViewColumnWidth(min: 300, ideal: 440, max: 560)
} detail: {
// 280pt keeps the canvas usable (#23) — narrower and its own
// playback row starts contesting space with the drawing.
Expand Down Expand Up @@ -150,18 +164,8 @@ struct CanvasPane: View {
// toolbar nor `navigationBarBackButtonHidden` touches it.
.toolbar(removing: .title)
.toolbar {
ToolbarSpacer(.flexible, placement: .primaryAction)

ToolbarItemGroup(placement: .primaryAction) {
CanvasViewToggle(showsCode: $showsCode)
}

ToolbarSpacer(.fixed, placement: .primaryAction)

ToolbarItemGroup(placement: .primaryAction) {
CanvasRollAgainButton(workspace: workspace, runner: runner)
CanvasExportMenu(runner: runner, onExport: export)
}
CanvasToolbar(
workspace: workspace, runner: runner, showsCode: $showsCode, onExport: export)
}
// One alert, switching on why the run failed (`expansionAlert`):
// attaching a second one for the recursion case would silently drop
Expand Down Expand Up @@ -197,6 +201,39 @@ struct CanvasPane: View {
}
}

/// `CanvasPane`'s toolbar: the canvas/code toggle, then ⟳ and the export menu.
///
/// It is a `ToolbarContent` type of its own only so the `#if` below has
/// somewhere to live that isn't the call site — `ToolbarSpacer` is the Liquid
/// Glass grouping separator and is unavailable on visionOS, which lays its
/// toolbar out as an ornament and spaces the groups itself. The items and
/// their order are the same everywhere; only the separators come and go.
struct CanvasToolbar: ToolbarContent {
let workspace: WorkspaceEditor
let runner: RunnerModel
@Binding var showsCode: Bool
let onExport: (Data?, UTType) -> Void

var body: some ToolbarContent {
#if !os(visionOS)
ToolbarSpacer(.flexible, placement: .primaryAction)
#endif

ToolbarItemGroup(placement: .primaryAction) {
CanvasViewToggle(showsCode: $showsCode)
}

#if !os(visionOS)
ToolbarSpacer(.fixed, placement: .primaryAction)
#endif

ToolbarItemGroup(placement: .primaryAction) {
CanvasRollAgainButton(workspace: workspace, runner: runner)
CanvasExportMenu(runner: runner, onExport: onExport)
}
}
}

/// The canvas/code segmented toggle, in `CanvasPane`'s toolbar (#23).
struct CanvasViewToggle: View {
@Binding var showsCode: Bool
Expand Down
7 changes: 4 additions & 3 deletions App/Views/LaunchScene.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#if os(iOS)
#if !os(macOS)

import SwiftUI

/// The screen in front of the system document browser (#32).
///
/// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)`, so this
/// whole file is iOS-only and the Mac keeps the standard open panel.
/// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)` and
/// nothing else, so this file covers iPadOS and visionOS while the Mac
/// keeps the standard open panel.
struct LaunchScene: Scene {
var body: some Scene {
DocumentGroupLaunchScene(
Expand Down
33 changes: 26 additions & 7 deletions App/Views/PlatformModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import SwiftUI

// Small cross-cutting modifiers that hide their `#if os(...)` inside a
// modifier (the SwiftUI-way rule), so call sites stay platform-agnostic.
//
// They are written `#if !os(macOS)` rather than naming iOS wherever the
// behaviour is simply "the touch platforms": these are UIKit-backed and exist
// on visionOS as well as iPadOS (#11), and spelling the condition as "not the
// Mac" is what keeps a new platform from silently taking the no-op branch the
// way visionOS did — an `#if os(iOS)` compiles clean everywhere and just stops
// applying. `pointerHover` is the exception, and says why.
extension View {
/// The number pad for numeric entry on iOS (#24); a no-op elsewhere.
/// `.decimalPad` gives the digits and decimal point kids need; the
/// number blocks don't take negative literals, so its lack of a minus
/// key is intentional.
/// The number pad for numeric entry (#24); a no-op on macOS, which has a
/// hardware keyboard. `.decimalPad` gives the digits and decimal point kids
/// need; the number blocks don't take negative literals, so its lack of a
/// minus key is intentional.
func numericKeyboard() -> some View {
#if os(iOS)
#if !os(macOS)
keyboardType(.decimalPad)
#else
self
Expand All @@ -17,6 +24,16 @@ extension View {

/// The iPad (pointer) hover highlight (#24); a no-op on macOS, which has
/// its own cursor affordances.
///
/// Deliberately *not* extended to visionOS, where gaze feedback would seem
/// to be exactly what this is for: `hoverEffect` on a palette entry crashes
/// the app there. Launching straight into the workspace segfaults in
/// `PaletteEntryButton.body` — a `swift_release` inside SwiftUI's own
/// update, not our code — before a window is ever shown, and it does so
/// with `.automatic` as well as `.highlight`, so it is the modifier and not
/// the effect. (visionOS 26.5 / 27.0 simulators, Xcode 26.6.) Buttons get
/// the system's own hover treatment there in any case; this is only the
/// extra highlight iPadOS needs, so the platform loses nothing visible.
func pointerHover() -> some View {
#if os(iOS)
hoverEffect(.highlight)
Expand All @@ -25,7 +42,9 @@ extension View {
#endif
}

/// Holds an icon-only control to the 44pt finger minimum on iPadOS.
/// Holds an icon-only control to the 44pt finger minimum on iPadOS (and to
/// the same floor on visionOS, where the target is a gaze rather than a
/// finger — 44 is the iPad number, not a measured visionOS one).
/// A borderless SF Symbol button is only as tappable as the glyph is big —
/// around 24pt at body size — so the ⋯ on a block row is a small target on
/// a touch screen even though the row around it is not.
Expand All @@ -44,7 +63,7 @@ extension View {
/// this is used sits after a `Spacer`, so the extra width takes slack
/// instead of pushing the label.
func touchTarget() -> some View {
#if os(iOS)
#if !os(macOS)
frame(minWidth: 44, minHeight: 44).contentShape(.rect)
#else
self
Expand Down
41 changes: 40 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ And a macOS QuickLook extension has to be sandboxed to be loaded, so it carries
even though the app itself has none. `pluginkit -mAvvv | grep -i tortoise`
confirms registration — the `-p com.apple.quicklook.thumbnail` filter does not
match it and will make a working extension look missing.
`TARGETED_DEVICE_FAMILY` is `"2"` — iPad and Mac, no iPhone (#29).
`TARGETED_DEVICE_FAMILY` is `"2,7"` — iPad, Mac and Vision Pro, no iPhone
(#29, #11).
Documents are `.tortoise` files, but the exported UTI keeps the
`tortoiseblocks` spelling (`space.hiraku.tortoiseblocks.project`, and
`.block` for the drag payload), which is also the bundle ID's — the
Expand Down Expand Up @@ -370,6 +371,44 @@ rules apply. The app takes `files.user-selected.read-write` for the
`read-only` and is sandboxed for a different reason (a macOS QuickLook
extension is not loaded otherwise).

**visionOS runs the iPad app, not a port** (#11). `SUPPORTED_PLATFORMS` gains
`xros xrsimulator`, `XROS_DEPLOYMENT_TARGET` is 26.0, and the same three-pane
`NavigationSplitView` fills the window — the scene is regular width, so nothing
in the layout is platform-conditional and no ornament or volumetric anything is
declared. Both packages already shipped `.visionOS(.v26)`. What the platform
actually costs is the `#if`s, in both directions. **A guard written
`#if os(iOS)` stops applying** — it still compiles everywhere, so `LaunchScene`
(`DocumentGroupLaunchScene` is unavailable on *macOS* only) and the numeric
keyboard and touch targets in `PlatformModifiers` were silently dropped until
they were rewritten as `#if !os(macOS)`. And some UIKit-era API is genuinely
gone: `ToolbarSpacer` — the Liquid Glass grouping separator — is unavailable,
which is the only reason `CanvasToolbar` exists as a `ToolbarContent` type of
its own. That is also why CI builds visionOS: nothing else catches an `#if`
that quietly does nothing.
One layout constant *is* load-bearing here, though the layout itself isn't
conditional: visionOS has **no draggable split-view divider** — macOS and
iPadOS 26 both do — so the workspace column is stuck at its `ideal` for good
while the canvas takes the rest of a wide window. That is what set the ideal at
440; the measurement is in `App/Views/CLAUDE.md`.
One thing is known-missing rather than done: **`hoverEffect` crashes** there —
`.automatic` as well as `.highlight`, a `swift_release` segfault inside
SwiftUI's update of `PaletteEntryButton.body`, before a window appears — so
`pointerHover()` stays iOS-only and says so.

**The app icon comes from a second, differently-shaped source.** visionOS wants
a circular layered icon and Icon Composer writes only squares (plus watchOS
circles), so `AppIcon.icon` produces nothing for it —
`Assets.xcassets/AppIcon.solidimagestack` does, three
`.solidimagestacklayer`s (Front / Middle / Back) of 1024×1024 at the `vision`
idiom. The two carry the same name on purpose and do **not** collide:
`ASSETCATALOG_COMPILER_APPICON_NAME` is `AppIcon` for every platform and actool
routes by idiom, so the visionOS `Assets.car` gets a `SolidImageStack` and no
`IconImageStack`, iOS gets the reverse, and macOS still gets `AppIcon.icns`.
Check a change here in the *built* product rather than in Xcode — `assetutil
--info` on each platform's `Assets.car` — because a stack that never made it in
fails the same silent way a missing one does: the system's placeholder, which
looks like a plain app that hasn't been styled yet.

**Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`.

**Localization**: `en` is the source language; Japanese (kid-friendly
Expand Down
Loading