From b92212101624a02012f75a19213d1f0f2910f72c Mon Sep 17 00:00:00 2001 From: ajbarryiii Date: Wed, 4 Nov 2026 11:44:54 -0800 Subject: [PATCH] Give the candidate strip the bar's full height so taps on a suggestion never type a top-row key A tap just above or below a suggestion's 30 pt words landed in bar space the keys took (the 2026-10-18 reach into the bar), typing a top-row letter instead of picking the word. The strip's tap area, and the frame it reports, is now its width and the bar's full height (KeyboardMetrics.candidateTapArea). Beside the strip, and below the bar, the keys keep their reach. --- iOS/ARCHITECTURE.md | 11 +++++++++-- iOS/Keyboard/DictationBarView.swift | 24 +++++++++++++++++++++--- iOS/KeyboardCore/KeyboardLayout.swift | 7 +++++++ iOS/Tests/KeyboardLayoutTests.swift | 26 ++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) 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