diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index d2afd5d..dc1ded9 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -4,22 +4,22 @@ "" : { }, - "🎲" : { + "%@ to %@" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "🎲" + "value" : "%@ γ€œ %@" } } } }, - "%@ to %@" : { + "🎲" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%@ γ€œ %@" + "value" : "🎲" } } } @@ -514,6 +514,16 @@ } } }, + "More" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パニγƒ₯γƒΌ" + } + } + } + }, "Motion" : { "localizations" : { "ja" : { diff --git a/App/Views/BlockLabelStyle.swift b/App/Views/BlockLabelStyle.swift index c6f06fe..38a69aa 100644 --- a/App/Views/BlockLabelStyle.swift +++ b/App/Views/BlockLabelStyle.swift @@ -44,5 +44,15 @@ private struct BlockLabel: View { } configuration.title } + // First claim on the row's width (#44). A row is `label + chips + + // Spacer + menu`, and an `HStack` hands space to its flexible children + // together: `Text` can wrap and `Spacer` can grow, so the label was + // breaking onto two lines β€” γ€Œγ‚‚γ€/γ€Œγ—γ€ β€” while 60pt of empty space sat + // beside it waiting for the menu button. The wrapping was never a + // shortage of width, which is why widening the column didn't fix it. + // + // Priority, not `fixedSize`: when the room genuinely isn't there the + // label still wraps, rather than overflowing its block. + .layoutPriority(1) } } diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index 93d788d..6f83c8f 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -71,9 +71,9 @@ Swiping a program should say "γΎγˆγΈγ€γ‹γš 100γ€γ˜γ£γ“γ†γ‘γ‚…γ†" on not stop three times, so a simple row is `.accessibilityElement(children: .combine)`: the kind, its value chips and the running state fuse into one sentence while the chips keep their own actions, which is what leaves editing a -value reachable. The βœ• is `accessibilityHidden` and comes back as a named -action β€” as a child it would be both an extra stop and the word "けす" tacked -onto every block's sentence. Container headers are deliberately left alone: +value reachable. The β‹― is `accessibilityHidden` and its entries come back as +named actions β€” as a child it would be both an extra stop and the word +"パニγƒ₯γƒΌ" tacked onto every block's sentence. Container headers are deliberately left alone: combining them would fold in the "Add Here" toggle, and that toggle *is* the accessible alternative to dragging. Order matters twice here. Accessibility actions belong *after* the combine, where they attach to the element it built @@ -87,6 +87,47 @@ and an empty mouth's "Drop Here" reads as something to do when it isn't. Icon-only row buttons wear `touchTarget()`, which is 44pt of hit area on iPadOS and nothing on macOS, where a pointer never needed it. +**One visible control per row, and it is the menu** (#44). The β‹― replaced the +always-visible βœ• that #21 had put there, and it holds γ†γˆγΈ / γ—γŸγΈ / けす plus +whatever the row adds β€” the if block's そうでγͺγ‘γ‚Œγ°, the else divider's own +"remove". Deleting had three ways in and this was the redundant one; moving a +row had *none* a child would find, because the menu it lived in only opens on +long-press. A visible β‹― costs けす one tap and buys the reorder commands their +first real affordance: children press what looks pressable, and nobody presses a +block hoping for a hidden menu. Long-press still opens the same menu, so the +macOS right-click keeps working. Note the icon is `ellipsis`, not +`ellipsis.circle` β€” the row already carries two circled glyphs (the chips and +the βŠ•), and a third circle read as another control of the same kind. + +**A `Menu` hit-tests its label, so `touchTarget()` goes inside it.** Wrapped +around the outside β€” where it sat for every `Button` before this β€” the 44pt +frame grows the layout and leaves the tappable area the size of the glyph. It +looks identical either way, including with the target painted in for a +screenshot, and only shows up as a control that misses half the taps. The βœ• hid +this for as long as it was there, because a filled circle is a large shape on +its own; three dots on a thin band are not. Note the fix is the target and not +the glyph: enlarging the β‹― was tried alongside it and taken back out once the +44pt label proved enough on device β€” at `.large` the dots sat heavier than the +words beside them, and a control that is merely easy to hit does not need to be +the loudest thing on the row. + +**A row compresses in a fixed order: the spacer, then the label, never a chip.** +Getting there took two modifiers, and neither is the one the symptom suggests. +An if header nested one level deep broke its label onto two lines β€” γ€Œγ‚‚γ€/γ€Œγ—γ€ β€” +on iPad, and *widening the column did nothing*, because the row was not short of +width: 60pt of empty space sat beside the label waiting for the menu button. An +`HStack` hands space to its flexible children together, and a wrapping `Text` +and a growing `Spacer` are both flexible, so the label lost to the gap. +`BlockLabelStyle` therefore carries `.layoutPriority(1)`. That alone moves the +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. + **Drop model**: a `DropGap` between rows carries `(BodyAddress, index)`, so insertion semantics need no y-coordinate math and every mouth β€” an if's else included β€” is a target. Tap-to-add, with the "Add Here" toggle diff --git a/App/Views/PlatformModifiers.swift b/App/Views/PlatformModifiers.swift index 38f42a1..7bd8fe1 100644 --- a/App/Views/PlatformModifiers.swift +++ b/App/Views/PlatformModifiers.swift @@ -27,9 +27,16 @@ extension View { /// Holds an icon-only control to the 44pt finger minimum on iPadOS. /// 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 + /// 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. /// + /// On a `Menu`, apply this to the *label* rather than to the menu: a menu + /// hit-tests what it was handed to draw, so wrapped around the outside this + /// grows the layout and leaves the tappable area the size of the glyph. The + /// symptom is a control that looks right and misses half the taps, and it + /// only showed up when the row's βœ• became a β‹― β€” a filled circle is a large + /// shape by itself, three dots on a thin band are not. + /// /// Only the *hit* area grows, and only where fingers are: the glyph is /// unchanged, and macOS keeps its own (smaller, pointer-sized) metrics /// rather than growing controls a mouse never needed. Rows are already diff --git a/App/Views/ValueEditors.swift b/App/Views/ValueEditors.swift index 9dd86d5..59cb885 100644 --- a/App/Views/ValueEditors.swift +++ b/App/Views/ValueEditors.swift @@ -32,6 +32,12 @@ let variableNameMaxLength = 10 struct WorkspaceChipButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label + // A chip never wraps (#44). It holds a number, a name or a colour β€” + // atomic things, where a second line reads as damage: 0.6 broke as + // "0." over "6" once the label beside it was given first claim on + // the row. So the row compresses in a fixed order β€” the spacer, + // then the label, and the chips never. + .fixedSize(horizontal: true, vertical: false) .foregroundStyle(BlockCategory.ink) .padding(.vertical, 6) .padding(.horizontal, 10) diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index cb3262c..88239d3 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -113,10 +113,10 @@ struct WorkspaceView: 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 -/// carries a βœ• and a context-menu entry. 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). 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. /// /// It is always visible rather than appearing mid-drag because SwiftUI has no /// cross-platform signal for "a drag started" β€” `onDragSessionUpdated` is @@ -315,7 +315,18 @@ struct BlockRowView: View { block: block, childBlocks: body, workspace: workspace, highlightedID: highlightedID, usedVariableNames: usedVariableNames, usedFunctionNames: usedFunctionNames, - elseBlocks: elseBody + elseBlocks: elseBody, + // そうでγͺγ‘γ‚Œγ° lives in the row's menu (#44) β€” the header is + // the widest row in the app, and this is 52pt of it. Offered + // only while there is no else mouth yet, exactly as the button + // it replaced was (#24). + addElseAction: elseBody == nil + ? { + workspace.updateKind( + of: block.id, + to: .ifBlock(condition: condition, body: body, elseBody: [])) + } + : nil ) { Label("If", systemImage: "questionmark.diamond") .labelStyle(BlockLabelStyle()) @@ -327,23 +338,6 @@ struct BlockRowView: View { to: .ifBlock(condition: new, body: body, elseBody: elseBody)) } Text("then") - // Omitted (not just faded) once there's already an else - // mouth, so it stops reserving header width for nothing - // (#24). - if elseBody == nil { - Button("Add Otherwise", systemImage: "arrow.triangle.branch") { - workspace.updateKind( - of: block.id, - to: .ifBlock(condition: condition, body: body, elseBody: [])) - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .controlSize(.large) - .tint(BlockCategory.ink) - .touchTarget() - .accessibilityHint( - "Adds an otherwise mouth that runs when the condition fails") - } } case .defineBlock(let name, let body): ContainerBlockRow( @@ -433,6 +427,9 @@ struct ContainerBlockRow: View { /// instead, which is the whole visual difference between "this runs here" /// and "this is a block being named" (#14). var headerCorners: RowCorners = .containerHeader + /// An extra entry for this header's menu β€” the if block's そうでγͺγ‘γ‚Œγ°, + /// which used to be a button of its own on the header (#44). + var addElseAction: (() -> Void)? = nil @ViewBuilder let header: Header @State private var isDropTargeted = false @@ -458,7 +455,8 @@ struct ContainerBlockRow: View { InsertionTargetButton( address: BodyAddress(containerID: block.id), workspace: workspace) Spacer(minLength: 0) - RowControls(blockID: block.id, workspace: workspace) + RowControls( + blockID: block.id, workspace: workspace, addElseAction: addElseAction) } .blockChrome( block.kind.category.color, corners: headerCorners, @@ -466,6 +464,13 @@ struct ContainerBlockRow: View { ) .draggable(block) .rowContextMenu(blockID: block.id, workspace: workspace) + // The menu holds it, and a menu is reachable β€” but an action says + // it out loud, the way Move Up / Move Down do. + .accessibilityActions { + if let addElseAction { + Button("Add Otherwise", action: addElseAction) + } + } // Dropping onto the header appends into this container's body. .dropDestination(for: Block.self) { items, _ in guard let dropped = items.first else { return false } @@ -542,22 +547,27 @@ struct ElseDividerRow: View { .labelStyle(BlockLabelStyle()) InsertionTargetButton(address: address, workspace: workspace) Spacer(minLength: 0) - Button("Remove Otherwise", systemImage: "xmark.circle", role: .destructive) { - guard let block = BlockTree.block(withID: blockID, in: workspace.blocks), - case .ifBlock(let condition, let body, _) = block.kind - else { return } - workspace.updateKind( - of: blockID, to: .ifBlock(condition: condition, body: body, elseBody: nil)) + // The same control every other row now carries: one menu, opened + // either by the β‹― or by long-press. The mouth is removed from + // inside it, so "けす" means the same thing wherever it is found. + Menu { + removeButton + } label: { + // Inside the label, for the reason `RowControls` gives. + Label("More", systemImage: "ellipsis") + .labelStyle(.iconOnly) + .touchTarget() } - .labelStyle(.iconOnly) + .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) - .touchTarget() + .tint(BlockCategory.ink) } .blockChrome( BlockCategory.control.color, corners: .containerDivider, isDropTargeted: isDropTargeted ) + .contextMenu { removeButton } // Dropping onto the divider appends into the else mouth. .dropDestination(for: Block.self) { items, _ in guard let dropped = items.first else { return false } @@ -565,6 +575,23 @@ struct ElseDividerRow: View { } isTargeted: { isDropTargeted = $0 } + // Reachable without opening the menu, the way every row's Delete is. + .accessibilityAction(named: Text("Remove Otherwise"), removeElse) + } + + private var removeButton: some View { + Button( + "Remove Otherwise", systemImage: "xmark.circle", role: .destructive, action: removeElse) + } + + /// Drops the else mouth, contents included β€” tree-swap undo makes that + /// safe. + private func removeElse() { + guard let block = BlockTree.block(withID: blockID, in: workspace.blocks), + case .ifBlock(let condition, let body, _) = block.kind + else { return } + workspace.updateKind( + of: blockID, to: .ifBlock(condition: condition, body: body, elseBody: nil)) } } @@ -702,21 +729,60 @@ struct InsertionTargetButton: View { } } -/// Delete for one row β€” the only row operation that stays always visible -/// (#21); move up/down live in the row's context menu instead (see -/// `rowContextMenu`), with an explicit VoiceOver custom action alongside it. +/// The row's one visible control: the menu, not the βœ• (#44). +/// +/// Everything a row can do lives in one place β€” γ†γˆγΈ / γ—γŸγΈ / けす, plus +/// whatever the row itself adds (the if block's そうでγͺγ‘γ‚Œγ°). It replaced the +/// always-visible βœ• #21 had put there, and the trade is deliberate. Deleting +/// had three ways in (the βœ•, the long-press menu, the trash can) and this was +/// the redundant one; moving a row had *none* a child would find, because the +/// menu it lived in only opens on long-press. A visible β‹― costs けす one tap +/// and buys γ†γˆγΈ / γ—γŸγΈ their first real affordance β€” children press what +/// looks pressable, and nobody presses a block hoping for a hidden menu. +/// +/// It also buys the widest row its width back: the if header's そうでγͺγ‘γ‚Œγ° +/// button moved in here, which is what stopped the condition chip wrapping at +/// one level of nesting on iPad. struct RowControls: View { let blockID: UUID let workspace: WorkspaceEditor + /// The if block's "add an otherwise mouth", when this row has one. + var addElseAction: (() -> Void)? = nil var body: some View { - Button("Delete", systemImage: "xmark.circle", role: .destructive) { - workspace.delete(blockID) + Menu { + Button("Move Up", systemImage: "chevron.up") { + workspace.move(blockID, by: -1) + } + Button("Move Down", systemImage: "chevron.down") { + workspace.move(blockID, by: 1) + } + if let addElseAction { + Button("Add Otherwise", systemImage: "arrow.triangle.branch", action: addElseAction) + // The hint the button on the header used to carry: what an + // else mouth *is* still needs saying, and a menu entry is + // where it is now read. + .accessibilityHint( + Text("Adds an otherwise mouth that runs when the condition fails")) + } + Divider() + Button("Delete", systemImage: "xmark.circle", role: .destructive) { + workspace.delete(blockID) + } + } label: { + // The finger target belongs *inside* the label: a `Menu` hit-tests + // what it was handed to draw, so a `frame` wrapped around the menu + // grows the layout and not the tappable area. The βœ• hid this β€” a + // filled circle is a big shape on its own β€” and three dots on a + // thin band are not, which is exactly how it came out on iPad. + Label("More", systemImage: "ellipsis") + .labelStyle(.iconOnly) + .touchTarget() } - .labelStyle(.iconOnly) + .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) - .touchTarget() + .tint(BlockCategory.ink) } }