From fca6b02fae609b55c59ef22d6d4ee82ff45fd3ed Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 11 Aug 2026 12:22:01 +0900 Subject: [PATCH] Let the can take the whole program, if you ask it twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a workspace meant pressing ✕ on every row, and #44 folded that ✕ into a menu — so the fastest way to start over became one menu per block, which is not a thing a child will do. The can at the bottom already means "throw this away"; now a drop throws away the block you are holding and a tap offers to throw away the program. Different gestures, so they never collide, and the tap asks first. Being a Button is also what let the can stop being invisible. It had been accessibilityHidden since #30 because a drop target is nothing a VoiceOver user can operate; it can now say what it is and what it does. The confirmation is an alert rather than a confirmationDialog. On iPad the latter is a popover hanging off the can, and that form drops the title and the cancel button both — the question never gets asked, and the way out is the one control not on screen. One alert serves both entry points, since the second of two would silently swallow the first. On the Mac it is also Edit ▸ ぜんぶけす (⌘⌫). The menu only asks for the confirmation: what travels through the focused value is WorkspaceUIState, not the editor, which is a value type wrapping a Binding and cannot go that way. Fixes #48 --- App/Localizable.xcstrings | 60 +++++++++++++++++ App/Models/WorkspaceEditor.swift | 24 +++++++ App/Views/AppCommands.swift | 18 +++++ App/Views/CLAUDE.md | 17 ++++- App/Views/ContentView.swift | 1 + App/Views/WorkspaceView.swift | 111 +++++++++++++++++++++---------- 6 files changed, 195 insertions(+), 36 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 0b567fb..3f0ea44 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -184,6 +184,16 @@ } } }, + "Cancel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "やめる" + } + } + } + }, "Canvas" : { "localizations" : { "ja" : { @@ -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" : { @@ -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" : { @@ -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" : { diff --git a/App/Models/WorkspaceEditor.swift b/App/Models/WorkspaceEditor.swift index 405ace5..adfb92d 100644 --- a/App/Models/WorkspaceEditor.swift +++ b/App/Models/WorkspaceEditor.swift @@ -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. @@ -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) diff --git a/App/Views/AppCommands.swift b/App/Views/AppCommands.swift index 2a7e0b9..f5fe61b 100644 --- a/App/Views/AppCommands.swift +++ b/App/Views/AppCommands.swift @@ -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 ?? []) diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index 6f83c8f..a24ea7c 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -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 diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index fe12f2a..1fcfc8b 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -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 diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index ef478a1..217625f 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -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 @@ -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 @@ -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.") } }