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
60 changes: 60 additions & 0 deletions App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@
}
}
},
"Cancel" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "やめる"
}
}
}
},
"Canvas" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -274,6 +284,36 @@
}
}
},
"Delete All" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ぜんぶけす"
}
}
}
},
"Delete all blocks?" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブロックを ぜんぶ けす?"
}
}
}
},
"Deletes every block in the program. You can undo it." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "プログラムのブロックをぜんぶけします。もとにもどせます"
}
}
}
},
"Dice" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -1104,6 +1144,16 @@
}
}
},
"Tap to delete every block, or drop one here to delete it" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "タップでぜんぶけす/ドロップで 1 まいけす"
}
}
}
},
"then" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -1284,6 +1334,16 @@
}
}
},
"You can undo this." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "もとに もどす ことが できるよ"
}
}
}
},
"Your block keeps calling itself. Use an if block to stop it." : {
"localizations" : {
"ja" : {
Expand Down
24 changes: 24 additions & 0 deletions App/Models/WorkspaceEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ final class WorkspaceUIState {
/// (nil = top level). Toggled from the container/else rows' target
/// buttons.
var insertionTarget: BodyAddress?

/// Whether the "delete everything?" confirmation is on screen (#48).
///
/// It lives here rather than in the trash can's own `@State` because two
/// places ask for it — the can and the Mac's menu — and there must be
/// exactly one dialog: a second presentation modifier of the same kind
/// silently swallows the first.
var confirmsDeleteAll = false
}

/// Value-type editing facade over the document.
Expand Down Expand Up @@ -57,6 +65,22 @@ struct WorkspaceEditor {
setBlocks(blocks)
}

var confirmsDeleteAll: Bool {
get { uiState.confirmsDeleteAll }
nonmutating set { uiState.confirmsDeleteAll = newValue }
}

/// Empties the workspace (#48) — one edit, so one ⌘Z brings the whole
/// program back. Nothing to delete is a no-op, and `setBlocks` refuses an
/// unchanged tree anyway, so it never registers an empty undo step.
///
/// The insertion target goes with it: the container it pointed into no
/// longer exists.
func deleteAll() {
guard setBlocks([]) else { return }
insertionTarget = nil
}

func delete(_ id: UUID) {
guard let new = BlockTree.removing(blockWithID: id, from: blocks) else { return }
setBlocks(new)
Expand Down
18 changes: 18 additions & 0 deletions App/Views/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,32 @@ import UniformTypeIdentifiers
extension FocusedValues {
@Entry var runner: RunnerModel?
@Entry var workspaceBlocks: [Block]?
/// The front window's non-document editor state (#48). The menu only has
/// to *ask* for the delete-everything confirmation; the workspace owns the
/// dialog and does the deleting, so what travels here is this small
/// observable rather than the `WorkspaceEditor` itself — a value type
/// wrapping a `Binding`, which a focused value cannot carry.
@Entry var workspaceUIState: WorkspaceUIState?
}

/// Run / pause / step / export, wired to the front document window only.
struct TortoiseBlocksCommands: Commands {
@FocusedValue(\.runner) private var runner
@FocusedValue(\.workspaceBlocks) private var workspaceBlocks
@FocusedValue(\.workspaceUIState) private var workspaceUIState

var body: some Commands {
// Edit, after the pasteboard items, which is where a Mac user looks
// for it. The can at the bottom of the workspace is the same command
// (#48) and raises the same confirmation.
CommandGroup(after: .pasteboard) {
Button("Delete All", role: .destructive) {
workspaceUIState?.confirmsDeleteAll = true
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(workspaceUIState == nil || (workspaceBlocks ?? []).isEmpty)
}

CommandMenu("Run") {
Button("Run") {
runner?.run(workspaceBlocks ?? [])
Expand Down
17 changes: 16 additions & 1 deletion App/Views/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,22 @@ included — is a target. Tap-to-add, with the "Add Here" toggle
accessibility alternative and must stay. A permanent trash circle rides a
`safeAreaInset` at the bottom of the workspace (`WorkspaceTrashZone`, #30) —
the way out of a drag you regret, since deleting a *placed* block was never
the hidden part. It can't appear only mid-drag: SwiftUI has no
the hidden part.

**The can does two things, and the second one made it accessible** (#48). A drop
throws away the block you are holding; a *tap* offers to throw away the program,
which is what #44 took away when the row's ✕ became a menu — clearing a
workspace by opening one menu per row is not a thing a child will do. The two
never collide, being different gestures, and the tap asks first. Being a
`Button` is also what let the can stop being `accessibilityHidden`: a drop
target is nothing a VoiceOver user can operate, so it had been invisible since
#30, and it can now say what it is. The confirmation is an **alert**, not a
`confirmationDialog`: on iPad the latter is a popover hanging off the can, and
that form drops the title *and* the cancel button — the question never gets
asked, and the way out is the one control not on screen. It is one alert for
both entry points (the can and the Mac's ⌘⌫), living on `WorkspaceView` and
driven by `WorkspaceUIState.confirmsDeleteAll`, because two presentation
modifiers of the same kind would silently swallow one another. It can't appear only mid-drag: SwiftUI has no
cross-platform "a drag started" signal (`onDragSessionUpdated` is
macOS-only), so a can that appeared on drag could never reliably learn the
drag was cancelled. It replaced drop-on-the-palette deletion, which had no
Expand Down
1 change: 1 addition & 0 deletions App/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct ContentView: View {
RootView(workspace: workspace, runner: runner)
.focusedSceneValue(\.runner, runner)
.focusedSceneValue(\.workspaceBlocks, workspace.blocks)
.focusedSceneValue(\.workspaceUIState, uiState)
// Every run path bumps the generation, so this is the single place
// the document's QuickLook thumbnail is refreshed (#15). It is
// stored, never read back onto the canvas — reopening a document
Expand Down
111 changes: 76 additions & 35 deletions App/Views/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ struct WorkspaceView: View {
.padding(.vertical, 12)
}
}
// One dialog for both ways in — the can and the Mac's Edit menu —
// because a second presentation modifier of the same kind would
// silently swallow this one. It asks even though ⌘Z brings the
// program straight back: a child who has just lost their work does
// not know that yet.
//
// An alert rather than a `confirmationDialog`, which on iPad is a
// popover hanging off the can: that form drops the title *and* the
// cancel button (you dismiss it by tapping away), so the question
// never gets asked and the way out is the one thing not on screen.
// Both matter more here than the tidier anchoring.
.alert(
"Delete all blocks?",
isPresented: Binding(
get: { workspace.confirmsDeleteAll },
set: { workspace.confirmsDeleteAll = $0 })
) {
Button("Delete All", role: .destructive) {
workspace.deleteAll()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You can undo this.")
}
// Puts the inset on the window's bottom edge. Without it SwiftUI keeps
// the home indicator's 20pt *inside* the inset, which reads as 36pt
// under the can against 12pt above it. Note it has to be applied here
Expand Down Expand Up @@ -153,10 +177,15 @@ private struct SampleButton: View {
}

/// 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
/// was no answer for was "I'm holding this and I don't want it", where the only
/// way out was to put it back exactly where it came from.
/// thing dragging was missing (#30) — and, since #48, the way to throw away the
/// whole program.
///
/// Deleting one block was never hidden: every row's menu carries it (a ✕ of its
/// own, when this was written — #44). What there was no answer for was "I'm
/// holding this and I don't want it", where the only way out was to put it back
/// exactly where it came from. Clearing the workspace had no answer either once
/// the row's ✕ went away, and a can you can only drop onto was the obvious
/// place to put one.
///
/// It is always visible rather than appearing mid-drag because SwiftUI has no
/// cross-platform signal for "a drag started" — `onDragSessionUpdated` is
Expand All @@ -172,37 +201,49 @@ struct WorkspaceTrashZone: View {
@ScaledMetric private var diameter: CGFloat = 56

var body: some View {
Image(systemName: isTargeted ? "trash.fill" : "trash")
.font(.title2)
.foregroundStyle(isTargeted ? Color.red : Color.secondary)
.frame(width: diameter, height: diameter)
.background {
Circle()
.fill(isTargeted ? Color.red.opacity(0.15) : Color.clear)
.stroke(
isTargeted ? Color.red : Color.secondary.opacity(0.4),
lineWidth: 2)
}
.scaleEffect(isTargeted ? 1.1 : 1)
.dropDestination(for: Block.self) { items, _ in
guard let dropped = items.first else { return false }
// A palette-origin block has no ID in the tree, so this is a
// no-op for it (`BlockTree.removing` returns nil and
// `delete` bails before touching undo). That is the right
// outcome: throwing away a block you were carrying but never
// placed just means not placing it.
workspace.delete(dropped.id)
return true
} isTargeted: {
isTargeted = $0
}
.animation(.easeOut(duration: 0.12), value: isTargeted)
// The icon carries the meaning on its own, but a pointer gets the
// words too.
.help("Drop to Delete")
// Drop-only, so VoiceOver can't operate it at all; the ✕ on every
// row and the context menu's Delete stay the accessible paths.
.accessibilityHidden(true)
// Tapping is the second thing it does (#48): a drop throws away the
// one block you are holding, a tap offers to throw away the program.
// The two never collide — they are different gestures — and the tap
// asks first.
//
// It also stops being invisible to VoiceOver. The can was hidden
// because a drop target is nothing a VoiceOver user can operate; a
// button is, so it can finally say what it is.
Button {
workspace.confirmsDeleteAll = true
} label: {
Image(systemName: isTargeted ? "trash.fill" : "trash")
.font(.title2)
.foregroundStyle(isTargeted ? Color.red : Color.secondary)
.frame(width: diameter, height: diameter)
.background {
Circle()
.fill(isTargeted ? Color.red.opacity(0.15) : Color.clear)
.stroke(
isTargeted ? Color.red : Color.secondary.opacity(0.4),
lineWidth: 2)
}
.scaleEffect(isTargeted ? 1.1 : 1)
}
.buttonStyle(.plain)
.pointerHover()
.dropDestination(for: Block.self) { items, _ in
guard let dropped = items.first else { return false }
// A palette-origin block has no ID in the tree, so this is a
// no-op for it (`BlockTree.removing` returns nil and
// `delete` bails before touching undo). That is the right
// outcome: throwing away a block you were carrying but never
// placed just means not placing it.
workspace.delete(dropped.id)
return true
} isTargeted: {
isTargeted = $0
}
.animation(.easeOut(duration: 0.12), value: isTargeted)
// Both of its jobs, for a pointer.
.help("Tap to delete every block, or drop one here to delete it")
.accessibilityLabel("Delete All")
.accessibilityHint("Deletes every block in the program. You can undo it.")
}
}

Expand Down