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
18 changes: 14 additions & 4 deletions App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
"" : {

},
"🎲" : {
"%@ to %@" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "🎲"
"value" : "%@ 〜 %@"
}
}
}
},
"%@ to %@" : {
"🎲" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ 〜 %@"
"value" : "🎲"
}
}
}
Expand Down Expand Up @@ -514,6 +514,16 @@
}
}
},
"More" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "メニュー"
}
}
}
},
"Motion" : {
"localizations" : {
"ja" : {
Expand Down
10 changes: 10 additions & 0 deletions App/Views/BlockLabelStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
47 changes: 44 additions & 3 deletions App/Views/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion App/Views/PlatformModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions App/Views/ValueEditors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
142 changes: 104 additions & 38 deletions App/Views/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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(
Expand Down Expand Up @@ -433,6 +427,9 @@ struct ContainerBlockRow<Header: View>: 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
Expand All @@ -458,14 +455,22 @@ struct ContainerBlockRow<Header: View>: 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,
isDropTargeted: isDropTargeted
)
.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 }
Expand Down Expand Up @@ -542,29 +547,51 @@ 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 }
return workspace.handleDrop(dropped, at: elseCount, inBodyAt: address)
} 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))
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down