Skip to content

[fix] the voice pane swipes from anywhere, not only from the record button - #405

Merged
YJack0000 merged 1 commit into
mainfrom
ios-voice-pane-swipe
Sep 22, 2026
Merged

YJack0000 merged 1 commit into
mainfrom
ios-voice-pane-swipe

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

Why

Report from the owner: swiping switches keyboard panes, but on the voice pane only the record button responds. Swiping anywhere else there does nothing.

The pane track in KeyboardRootView read its swipe with a plain .gesture, and a plain gesture loses to whatever the finger lands on. A Button holds the touch until it lifts, so a swipe that began on a key reached the track only at release, and the track never followed the finger. The zero-distance drag behind ⌫ (RepeatingKey) took the touch outright. That arbitration has been in place since #266 (715e1a1), which put the drag on the track. #397 did not touch it.

The track now reads the touch alongside the keys (simultaneousGesture) and disables the panes for as long as its drag is in flight. Once the swipe engages at 24pt, the key under the finger is cancelled the way a scroll view cancels a button's touch, so the swipe neither types the key nor fires the record button. A tap still presses and fires. RepeatingKey keeps its press in a @GestureState, which resets on that cancellation, so a swipe that starts on ⌫ does not leave the repeater deleting.

Scope

  • ios/Keyboard/KeyboardRootView.swift: .gesture becomes .simultaneousGesture on the track, and the panes get .disabled(swiping) while the drag is in flight.
  • ios/Keyboard/KeyboardKeys.swift: RepeatingKey moves its press from @State plus onEnded to @GestureState plus onChange.
  • docs/design/ios-voice-keyboard.md: the pane-track section records the routing.

Out of scope: the UIKit globe key on home-button devices, which arbitrates at the UIKit level and is unchanged.

Tradeoffs

highPriorityGesture on the track also makes every start point swipe, but it withholds touch-down from the keys until the drag fails. Keys would stop darkening under the finger and the record button's press haptic would fire at release. Rejected.

simultaneousGesture alone lets the key fire at release, because the key moves with the finger and the release lands inside it. That is what .disabled(swiping) prevents.

Blast Radius

Every pane's keys, because the track wraps all of them. Taps are unchanged. What changes is a touch that starts on a key and travels sideways past 24pt: it used to stay with the key, now it moves the track and cancels the key. The candidate bar lives in the strip above the track and is not affected.

Verification

The iOS Simulator on this Mac could not run the keyboard, so the before and after runs are on a macOS SwiftUI harness that rebuilds the track's gesture graph (HStack track, @GestureState offset, contentShape, a 24pt DragGesture, a Button child, a zero-distance drag child) and drives it with synthesized mouse events. The SwiftUI gesture arbitration is shared across platforms, but touch on iOS is not mouse on macOS, so treat this as strong evidence rather than a device pass.

Before, with the track's .gesture as on main, an 80pt swipe from a blank area or the text moved the track and switched the pane. From the Button the button pressed and released, the track received no live updates, and the pane switched only at release. From the zero-distance drag child the child pressed and released and the track received nothing.

=== gesture / button / 80pt
button.isPressed=true
button.isPressed=false
track.onEnded dx=-80
stepPane
=== gesture / drag0 / 80pt
drag0 press (action + repeater.start)
drag0 release (repeater.stop)

After, with simultaneousGesture plus .disabled while the drag is in flight, all four start points move the track live and switch the pane. The button presses on touch-down, is cancelled at 24pt, and does not fire. The repeating key presses on touch-down and its @GestureState resets at 24pt, so the repeater stops.

=== simuldis / button / 80pt
button.isPressed=true
track.updating dx=-24
button.isPressed=false
track.updating dx=-80
track.onEnded dx=-80
stepPane
=== simuldis / drag0 / 80pt
drag0 press (action + repeater.start)
track.updating dx=-24
drag0 release (repeater.stop)
track.updating dx=-80
track.onEnded dx=-80
stepPane

Taps (0pt) on the button and on the repeating key press and fire identically before and after.

xcodebuild -target ParleyKeyboard -sdk iphonesimulator26.2 builds with the fix. The simulator itself is blocked on this machine: xcode-select points at the Command Line Tools, the installed CoreSimulator framework is 1048 where Xcode 26.2 expects 1051.17.7, no iOS simulator runtime is installed (simctl runtime list is empty), and xcodebuild -runFirstLaunch waits on an admin authorization this session cannot give. swift test --package-path ios/ParleyKit cannot run either: Xcode's toolchain fails to load libxcrun (arm64 slice only, /usr/bin/xcrun is arm64e) and its XCTestCore.framework is missing, and the Command Line Tools toolchain fails to link Package.swift against its own PackageDescription. Please run the device pass on a phone before merging: swipe from the text, from a blank area, from ⌫, and from the record button on the voice pane, then tap each key and hold ⌫.

🤖 Generated with Claude Code

…utton

The pane track read its swipe with a plain `.gesture`, which loses to whatever
the finger lands on. A `Button` holds the touch until it lifts, so a swipe that
began on a key only reached the track at release and the track never followed
the finger. The zero-distance drag behind ⌫ took the touch outright. On the
voice pane, where the record button is the one big target a thumb drags across
and leaves, that read as "only the record button swipes".

The track now reads the touch alongside the keys (`simultaneousGesture`) and
disables the panes while its drag is in flight, so the key under the finger is
cancelled once the swipe engages, the way a scroll view cancels a button's
touch. A tap still presses and fires the key. `RepeatingKey` keeps its press in
a `@GestureState`, which resets on that cancellation, so a swipe that starts on
⌫ does not leave the repeater deleting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

@YJack0000
YJack0000 merged commit bbd3f0c into main Sep 22, 2026
3 checks passed
@YJack0000
YJack0000 deleted the ios-voice-pane-swipe branch September 22, 2026 18:07
YJack0000 added a commit that referenced this pull request Sep 22, 2026
…he panes are tabs (#406)

* [feature] the 注音 pane types like the system keyboard: toneless continuous input, delete under ㄦ

Closes #400. The composer holds a buffer of up to six syllables and
segments a run of untoned symbols on its own, so ㄋㄧㄏㄠ reads as
ㄋㄧ ㄏㄠ and commits as 你好 with no tone key between them. The
dictionary gains `~`-prefixed toneless rows (the union of a reading's
five tones, ordered by corpus frequency) because the first tone is
written with no mark and could not double as "tone not typed yet".
The candidate bar follows the oldest pending syllable; tapping one
commits it and moves on; return commits everything; punctuation from
the symbol planes commits first.

Delete moves from the function row to the eleventh column of the
fourth symbol row, under ㄦ, where the system 注音 keyboard keeps it.
Rows two and three are staggered by thirds rather than centred, and
123 / return are two and a half keys wide, as on the system keyboard.

* [chore] iOS 1.16 (build 31): release notes, and the 注音 pane design as it now is

Three things ship in 1.16, and all three are keyboard-side.

The 注音 pane takes toneless continuous typing (#400). The composer holds an
ordered buffer of up to six syllables instead of one, so a symbol that cannot
extend the last syllable starts a new one rather than overwriting a slot — the
rule that used to turn ㄋㄧ followed by ㄏ into ㄏㄧ, and the reason a sentence
could not be typed without a tone key between every syllable. Tones now sharpen
the guess instead of gating the next syllable, the candidate bar serves the
oldest pending syllable, and the dictionary carries `~`-prefixed toneless rows
because first tone is written with no mark and so cannot double as the toneless
key. Delete moves to the eleventh column of the fourth symbol row, where the
system keyboard keeps it, and rows 2 and 3 are staggered rather than centred.

#405 makes the pane track read the touch alongside the keys, so a swipe between
the voice, English and 注音 panes works from anywhere on the keyboard and does
not type the key it started on.

#404 stops the keyboard's mic tap taking a detour through Parley on every tap:
a backgrounded Parley now tries the microphone before declining, and only the
starts iOS actually refuses fall back to opening the app.

Version bump: CFBundleShortVersionString 1.15 → 1.16 and CFBundleVersion 30 → 31
across all three targets in project.yml, with the Info.plists regenerated by
xcodegen. What's New — 1.16 is written in both locales; asc_submit.py parses
both and they are far under Apple's 4,000-character limit.

* [feature] the 注音 pane predicts by phrase, and the panes are tabs

Two lone 聲母 now offer the word: ㄋㄏ is 你好 before a vowel or tone is
typed, the way the system keyboard has it. A phrase table generated from
McBopomofo's BPMFMappings.txt (libtabe BSD readings, attributed in
THIRD-PARTY.md) is matched by prefix within each syllable and indexed by
the first symbol of the first two; phrases lead the bar, single
characters follow, and return commits a greedy longest-phrase walk. The
generator scores by corpus count plus character frequency and gives a
short list of conversational words a floor, because the corpus is news
and this keyboard types messages. Both tables warm on a background queue
when the 注音 pane becomes current.

The mode strip's dots become named tabs (語音 / 英文 / 注音): in use nobody
took the dots for a control. The swipe is unchanged. The mic chip loses
its minutes to make the row fit at 320pt. The composition chip hugs its
text instead of taking the whole cap.

Design doc, What's New (both locales) and the dictionary's doc comment
updated to match.

* [fix] candidates in the 注音 bar are separated, so two words stop reading as one

The bar now mixes one- and two-character candidates; at 2pt spacing
會出好處會場 read as a single string. Each candidate gets an 11pt gutter
and a hairline between neighbours, as the system keyboard spaces them.

* [refactor] the two 注音 generators share one data module

Sonar flagged the phrase generator for copying the dict generator's
download, parsing and validation helpers (14.6% duplicated lines), a
21-complexity parse function and two nested ternaries. The shared parts
move to scripts/zhuyin-data.mjs; both generators still write
byte-identical resources.
YJack0000 added a commit that referenced this pull request Sep 24, 2026
…ecord button (#420)

A fully transparent point of a keyboard extension never receives the touch. The
voice pane is mostly empty space, so a swipe only started on a drawn control,
which is the owner's report that survived #405: its gesture arbitration was
right, but the touch never reached it. `contentShape` cannot fix this from
inside SwiftUI; a 1% white fill on the pane track does, and also covers the
gaps between keys on the typing panes.

Verified on the iPhone 17 Pro simulator (iOS 26.3): before, a swipe from the
voice pane's blank area did nothing while one from the record button switched
panes. After, a swipe from the blank area, from the record button, and from
between two rows of English keys all switch panes, taps still type, and the
fill is invisible in light and dark mode.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant