diff --git a/iOS/ARCHITECTURE.md b/iOS/ARCHITECTURE.md index 794496f0..0585e03f 100644 --- a/iOS/ARCHITECTURE.md +++ b/iOS/ARCHITECTURE.md @@ -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 @@ -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 diff --git a/iOS/Keyboard/DictationBarView.swift b/iOS/Keyboard/DictationBarView.swift index 9516ea6f..aa054e8f 100644 --- a/iOS/Keyboard/DictationBarView.swift +++ b/iOS/Keyboard/DictationBarView.swift @@ -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, @@ -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 @@ -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) @@ -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 { diff --git a/iOS/KeyboardCore/KeyboardLayout.swift b/iOS/KeyboardCore/KeyboardLayout.swift index 5546c2a8..d29c2e4a 100644 --- a/iOS/KeyboardCore/KeyboardLayout.swift +++ b/iOS/KeyboardCore/KeyboardLayout.swift @@ -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 { diff --git a/iOS/Tests/KeyboardLayoutTests.swift b/iOS/Tests/KeyboardLayoutTests.swift index 613fcf9a..ee6c6607 100644 --- a/iOS/Tests/KeyboardLayoutTests.swift +++ b/iOS/Tests/KeyboardLayoutTests.swift @@ -13,6 +13,7 @@ enum KeyboardLayoutTests { ("reachAboveTheKeysIsTheBarBelowItsControls", testReachAboveTheKeysIsTheBarBelowItsControls), ("theSpaceBarGetsTheLeastOfItsGaps", testTheSpaceBarGetsTheLeastOfItsGaps), ("theBarOutsideItsControlsGoesToTheKeys", testTheBarOutsideItsControlsGoesToTheKeys), + ("theCandidateStripTakesTheBarsFullHeight", testTheCandidateStripTakesTheBarsFullHeight), ("startReachesUpAndStopReachesIntoTheTopRow", testStartReachesUpAndStopReachesIntoTheTopRow), ("theDeleteKeyReachesTowardsM", testTheDeleteKeyReachesTowardsM), ("letterPitchIsTheTopRowPitch", testLetterPitchIsTheTopRowPitch), @@ -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