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
11 changes: 9 additions & 2 deletions iOS/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1646,8 +1646,8 @@ The views are touchable ("Touchable backgrounds",
`KeyboardMetrics.reachAboveKeys(compact:)` above it: the strip of the bar
below its controls, (bar height − control height) / 2, 9 pt in portrait and
4 pt in compact height. Such a touch is an ordinary key touch, and its
nearest key is in the top row. While the menu panel is open the reach
is 0.
nearest key is in the top row. Under the candidate strip the strip takes
it instead (below). While the menu panel is open the reach is 0.
- **The bar outside its controls.** The key area also takes any touch in the
bar outside every control's frame
(`KeyboardMetrics.barPointGoesToKeys(x:y:barHeight:compact:controls:)`, in
Expand All @@ -1666,6 +1666,13 @@ The views are touchable ("Touchable backgrounds",
- Each control's tap area is exactly the rectangle it reports
(`contentShape(Rectangle())`), so every point of the bar is a control's
or the keys'.
- **The candidate strip's tap area** is
`KeyboardMetrics.candidateTapArea(strip:barHeight:)`: the strip's width
and the bar's full height, top to bottom, so a tap just above or below a
suggestion picks it rather than typing a top-row key (AJ, 2026-11-04;
the keys took the bar above and below the strip's words, 30 pt tall in
portrait). The 1 pt dividers stay dead. Beside the strip the keys still
take the bar.
- The island's tap area, and the frame it reports, is
`KeyboardMetrics.startTapArea(capsule:barHeight:compact:capturing:)`: its
frame inset by `KeyboardMetrics.startHitInset` (6 pt) at the sides and
Expand Down
24 changes: 21 additions & 3 deletions iOS/Keyboard/DictationBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,13 @@ struct DictationBarView: View {
}
.barControl("neverCorrect", in: controls)
} else if middle == .candidates, let strip = swipe.strip {
CandidateStrip(strip: strip, height: height - 8) { index in
CandidateStrip(strip: strip, height: height - 8, barHeight: barHeight) { index in
onAction()
onCandidate(index)
}
.barControl("strip", in: controls)
.barControl("strip", in: controls) {
KeyboardMetrics.candidateTapArea(strip: $0, barHeight: barHeight)
}
} else {
// While a dictation runs this is plain text: a notice, or "Open LocalFlow to start a session".
Middle(client: client, state: state, height: height - 8, controls: controls, layout: layout,
Expand Down Expand Up @@ -432,6 +434,8 @@ private struct Middle: View {
private struct CandidateStrip: View {
let strip: SwipeStrip
let height: CGFloat
/// The bar's height: each candidate takes taps from the bar's top to its bottom (`KeyboardMetrics.candidateTapArea`).
let barHeight: Double
let onCandidate: (Int) -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
Expand Down Expand Up @@ -468,7 +472,7 @@ private struct CandidateStrip: View {
.foregroundStyle(.primary)
.padding(.horizontal, CGFloat(CandidateStripLayout.padding))
.frame(maxWidth: .infinity, minHeight: height, maxHeight: height)
.contentShape(Rectangle())
.contentShape(CandidateTapShape(barHeight: barHeight))
}
.buttonStyle(PressableStyle())
.accessibilityLabel(quoted ? "Keep \(word)" : word)
Expand Down Expand Up @@ -886,6 +890,20 @@ private struct StartTapShape: Shape {
}
}

/// A candidate's tap area (`KeyboardMetrics.candidateTapArea`) in its own coordinates: its width, the bar's full height.
/// The strip is centred in the bar; it looks the same.
private struct CandidateTapShape: Shape {
let barHeight: Double

func path(in rect: CGRect) -> Path {
let top = (barHeight - Double(rect.height)) / 2
let area = KeyboardMetrics.candidateTapArea(
strip: KeyRect(x: Double(rect.minX), y: Double(rect.minY) + top, width: Double(rect.width), height: Double(rect.height)),
barHeight: barHeight)
return Path(CGRect(x: area.x, y: area.y - top, width: area.width, height: area.height))
}
}

/// Cancel's tap area (`KeyboardMetrics.cancelTapArea`) in its circle's coordinates; it looks the same. The circle is
/// centred in the bar.
private struct CancelTapShape: Shape {
Expand Down
7 changes: 7 additions & 0 deletions iOS/KeyboardCore/KeyboardLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ struct KeyboardMetrics: Equatable, Sendable {
return KeyRect(x: circle.midX - cancelHitSize / 2, y: top, width: cancelHitSize, height: bottom - top)
}

/// The candidate strip's tap area, in the bar's coordinates, from its frame there: its width and the bar's full
/// height, so a tap just above or below a suggestion never types a top-row letter (AJ, 2026-11-04, narrowing the
/// keys' reach into the bar of 2026-10-18). ARCHITECTURE.md, "Reach and touch regions".
static func candidateTapArea(strip: KeyRect, barHeight: Double) -> KeyRect {
KeyRect(x: strip.x, y: 0, width: strip.width, height: barHeight)
}

/// Whether a touch at (x, y) in the key area's coordinates is the Start capsule's: inside `start`, its tap area
/// in the bar's coordinates, which reaches into the keys only while capturing (an edge is the capsule's).
static func keyPointGoesToStart(x: Double, y: Double, barHeight: Double, start: KeyRect?) -> Bool {
Expand Down
26 changes: 26 additions & 0 deletions iOS/Tests/KeyboardLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum KeyboardLayoutTests {
("reachAboveTheKeysIsTheBarBelowItsControls", testReachAboveTheKeysIsTheBarBelowItsControls),
("theSpaceBarGetsTheLeastOfItsGaps", testTheSpaceBarGetsTheLeastOfItsGaps),
("theBarOutsideItsControlsGoesToTheKeys", testTheBarOutsideItsControlsGoesToTheKeys),
("theCandidateStripTakesTheBarsFullHeight", testTheCandidateStripTakesTheBarsFullHeight),
("startReachesUpAndStopReachesIntoTheTopRow", testStartReachesUpAndStopReachesIntoTheTopRow),
("theDeleteKeyReachesTowardsM", testTheDeleteKeyReachesTowardsM),
("letterPitchIsTheTopRowPitch", testLetterPitchIsTheTopRowPitch),
Expand Down Expand Up @@ -229,6 +230,31 @@ enum KeyboardLayoutTests {
"landscape strip without frames")
}

private static func testTheCandidateStripTakesTheBarsFullHeight() {
// ARCHITECTURE.md, "Reach and touch regions" (AJ, 2026-11-04: taps on a suggestion typed a top-row letter): the
// candidate strip's tap area is its width and the bar's full height, so no point above or below a suggestion
// goes to the keys; beside the strip the keys still take the bar.
for compact in [false, true] {
let bar = compact ? KeyboardMetrics.compactBarHeight : KeyboardMetrics.barHeight
let words = (compact ? KeyboardMetrics.compactBarControlHeight : KeyboardMetrics.barControlHeight) - 8
let strip = KeyRect(x: 54, y: (bar - words) / 2, width: 230, height: words)
let area = KeyboardMetrics.candidateTapArea(strip: strip, barHeight: bar)
TestSupport.expectEqual(area, KeyRect(x: strip.x, y: 0, width: strip.width, height: bar))
let start = KeyboardMetrics.startTapArea(capsule: KeyRect(x: 300, y: (bar - words - 8) / 2, width: 92, height: words + 8),
barHeight: bar, compact: compact, capturing: false)
func goes(_ x: Double, _ y: Double) -> Bool {
KeyboardMetrics.barPointGoesToKeys(x: x, y: y, barHeight: bar, compact: compact, controls: [area, start])
}
for x in [strip.x, strip.midX, strip.maxX] {
for y in stride(from: 0, through: bar, by: 0.5) {
TestSupport.expect(!goes(x, y), "(\(x), \(y)) on the strip goes to the keys, compact \(compact)")
}
}
TestSupport.expect(goes(strip.x - 0.5, bar - 1) && goes(strip.maxX + 0.5, bar - 1),
"beside the strip the keys lost the bar, compact \(compact)")
}
}

private static func testStartReachesUpAndStopReachesIntoTheTopRow() {
// ARCHITECTURE.md, "Reach and touch regions": the Start capsule's tap area is its frame inset by
// `startHitInset` (6 pt) at the sides and bottom, reaching up to the bar's top; while dictation is starting
Expand Down
Loading