From dd87e8158dfe783934ddeb634748ce65fbe1915e Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 5 Aug 2026 20:58:16 -0400 Subject: [PATCH 1/2] Key-strip taps no longer dismiss the keyboard a TUI is being driven with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone, tapping up/down/left/right/esc/tab on the terminal's key strip hid the keyboard — every arrow press cost a re-summon, which makes a TUI menu undrivable one keystroke at a time. Return did NOT hide it, and that asymmetry was the tell once the resign was traced: the keyboard's own keys live in another window, but strip taps land in the app, where `KeyboardFloatingHost` installs a global tap-to-dismiss recognizer that resigns on any tap outside a text input. The strip's buttons are buttons, not text inputs, so driving the program read as "tap outside" and dismissed. Traced, not guessed: a temporary override on the prompt field logged the resign's callstack on the simulator, and it named the dismiss coordinator's gesture action directly. Fix: while a program is running, the terminal container registers its frame in `KeyboardHoldRegions`, and the dismiss recognizer skips taps that land inside a registered region. Everything else about dismissal stands — taps outside the container (another lane, the gap) still resign, which is the half of the rule worth keeping, and the region unregisters when the program ends. An edge-preview terminal registers an off-screen rect no tap can land in. A first attempt re-claimed the keyboard on `keyboardDidHide` while a program owned the screen. It is not in this diff, and the reason is recorded: with the region in place, the only dismissals it could still catch are the ones the user MEANT — it would have turned "tap off the edge to dismiss" into a fight the user loses. Removed in favor of not causing the hide at all. Verified on the simulator, both directions: `less` running, tap `down` on the strip — the prompt keeps its caret (before the fix this produced the logged resign); tap the margin outside the container — the caret drops. Build clean. The gesture law is upheld, not amended: taps remain content, and the one global exception (tap-outside-dismisses) now excludes the region where taps ARE the content's input. Co-Authored-By: Claude Fable 5 --- swift/Mouse/MouseApp.swift | 24 +++++++++++++++++++++++- swift/Mouse/Terminal.swift | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/swift/Mouse/MouseApp.swift b/swift/Mouse/MouseApp.swift index 8d0f1f1..9461b16 100644 --- a/swift/Mouse/MouseApp.swift +++ b/swift/Mouse/MouseApp.swift @@ -68,6 +68,26 @@ private struct KeyboardFloatingHost: UIViewControllerRepresentabl } } +/// Window regions where a tap must NOT dismiss the keyboard. +/// +/// The dismiss recognizer below fires on any tap outside a text input, and for most of the app +/// that is the right reading of a tap. A terminal running a program is the exception: its key +/// strip and screen are buttons and output, not text inputs, yet tapping them is how the program +/// is DRIVEN — an arrow tap that also drops the keyboard makes a TUI menu undrivable one +/// keystroke at a time, which is exactly how it read on a phone. So the terminal registers its +/// frame while a program runs, and taps inside any registered region keep the keyboard. Taps +/// outside — another container, the gap between lanes — still dismiss, which is the half of the +/// rule worth keeping. +@MainActor +enum KeyboardHoldRegions { + private static var frames: [UUID: CGRect] = [:] + static func set(_ id: UUID, frame: CGRect) { frames[id] = frame } + static func clear(_ id: UUID) { frames[id] = nil } + static func contains(_ point: CGPoint) -> Bool { + frames.values.contains { $0.contains(point) } + } +} + final class KeyboardDismissCoordinator: NSObject, UIGestureRecognizerDelegate { @objc func dismissKeyboard() { UIApplication.shared.sendAction( @@ -75,8 +95,10 @@ final class KeyboardDismissCoordinator: NSObject, UIGestureRecognizerDelegate { ) } - /// Skip taps that land inside a text input (or one of its subviews). + /// Skip taps that land inside a text input (or one of its subviews), and taps inside a + /// keyboard-hold region (a terminal with a program running — see [KeyboardHoldRegions]). func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if KeyboardHoldRegions.contains(touch.location(in: nil)) { return false } var view = touch.view while let current = view { if current is UITextInput { return false } diff --git a/swift/Mouse/Terminal.swift b/swift/Mouse/Terminal.swift index ac8f653..662e411 100644 --- a/swift/Mouse/Terminal.swift +++ b/swift/Mouse/Terminal.swift @@ -7,6 +7,8 @@ struct TerminalContainerView: View { let deck: CarouselDeck? @State private var promptFocused = false + /// This container's entry in [KeyboardHoldRegions] while a program runs. + @State private var holdRegionID = UUID() var body: some View { Group { @@ -93,6 +95,22 @@ struct TerminalContainerView: View { .padding(16) .contentShape(Rectangle()) .onTapGesture { promptFocused = true } + // While a program runs, taps in this container are PROGRAM INPUT (the key + // strip, the screen) and must not double as keyboard dismissal. The region + // follows the container's real frame, so an edge-preview terminal registers + // an off-screen rect no tap can land in. + .background { + if terminal.program != nil { + GeometryReader { geo in + Color.clear + .onAppear { KeyboardHoldRegions.set(holdRegionID, frame: geo.frame(in: .global)) } + .onChange(of: geo.frame(in: .global)) { _, frame in + KeyboardHoldRegions.set(holdRegionID, frame: frame) + } + .onDisappear { KeyboardHoldRegions.clear(holdRegionID) } + } + } + } } else { Text("open a project in the Files container") .font(.custom(AppFont.asciiName, size: 14)) From 555ae024eb4872c6cbba073cee8e130e2d15943c Mon Sep 17 00:00:00 2001 From: ThyFriendlyFox Date: Wed, 5 Aug 2026 21:01:26 -0400 Subject: [PATCH 2/2] Cut 0.1.1: the swipe freeze and the keyboard-eating key strip Both apps move to 0.1.1 (Android versionCode 2), per RELEASING.md: the changelog's two entries are the graph-mount status freeze (merged in #12) and the key-strip keyboard dismissal (this branch). Tagging v0.1.1 on main after merge publishes the release. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +++++++++++++++ kotlin/app/build.gradle.kts | 4 ++-- swift/Mouse.xcodeproj/project.pbxproj | 4 ++-- swift/project.yml | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3529e6..23e5c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ carry breaking changes. ## [Unreleased] +## [0.1.1] - 2026-08-06 + +### Fixed +- iOS: **swiping Files → Viewer (or container 6 → Terminal) no longer freezes the shell.** + Landing beside the Graph container mounts it as an off-screen edge preview, and mounting it + ran `git status` — which read and SHA-1 hashed every byte of every file in the workspace, + synchronously, on the main thread. The worktree diff now runs off the main thread behind a + (size, mtime) hash cache, and the walk holds one file's bytes at a time instead of the whole + tree's. New `verify/gitstatus` gate covers the ways the cache could lie. +- iOS: **the key strip no longer dismisses the keyboard mid-program.** Tapping up/down/left/ + right/esc/tab on a running TUI resigned the keyboard on every press — the strip's buttons + read as "tap outside a text input" to the global tap-to-dismiss recognizer. While a program + runs, the terminal registers a keyboard-hold region the recognizer skips; tapping outside + the container still dismisses. + ## [0.1.0] - 2026-08-05 ### Added diff --git a/kotlin/app/build.gradle.kts b/kotlin/app/build.gradle.kts index a94b5fb..d19b697 100644 --- a/kotlin/app/build.gradle.kts +++ b/kotlin/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.reagentsystems.mouse" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "0.1" + versionCode = 2 + versionName = "0.1.1" } buildTypes { diff --git a/swift/Mouse.xcodeproj/project.pbxproj b/swift/Mouse.xcodeproj/project.pbxproj index f9dcf91..8bb8baf 100644 --- a/swift/Mouse.xcodeproj/project.pbxproj +++ b/swift/Mouse.xcodeproj/project.pbxproj @@ -281,7 +281,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.1.1; OTHER_LDFLAGS = "-lz -lresolv"; PRODUCT_BUNDLE_IDENTIFIER = com.reagentsystems.mouse.swift; SDKROOT = iphoneos; @@ -307,7 +307,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 0.1.1; OTHER_LDFLAGS = "-lz -lresolv"; PRODUCT_BUNDLE_IDENTIFIER = com.reagentsystems.mouse.swift; SDKROOT = iphoneos; diff --git a/swift/project.yml b/swift/project.yml index 5ee1d3b..a568ac3 100644 --- a/swift/project.yml +++ b/swift/project.yml @@ -29,7 +29,7 @@ targets: settings: PRODUCT_BUNDLE_IDENTIFIER: com.reagentsystems.mouse.swift # Keep in step with kotlin/app/build.gradle.kts versionName (see RELEASING.md). - MARKETING_VERSION: "0.1" + MARKETING_VERSION: "0.1.1" CURRENT_PROJECT_VERSION: "1" # libz (system zlib) for the git engine's packfile inflate — GitCore.inflateStream needs # exact zlib-member delimiting, which the Compression framework can't do.