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/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)) 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.