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.") } }