diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 015318310..6c5819bd1 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -233,14 +233,47 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { if let input = result["Input"] as? String { ts.write(data: "\(input)".data(using: .utf8)!) } + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) default: return } return } - if self.executor?.state == .interactive && event == "Data" { - self.executor?.sendInput(input: result["Input"] as! String) + if self.executor?.state == .interactive { + switch event { + case "Data": + self.executor?.sendInput(input: result["Input"] as! String) + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) + default: + break + } return } @@ -368,6 +401,20 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { executor?.kill() } } + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) default: print("\(result) Event not handled") } @@ -493,10 +540,31 @@ extension TerminalInstance: WKUIDelegate { extension TerminalInstance { func type(text: String) { guard let base64 = text.base64Encoded() else { return } - executeScript("term.input(base64ToString(`\(base64)`))") + executeScript("inputWithModifiers(base64ToString(`\(base64)`))") } func moveCursor(codeSequence: String) { - executeScript("term.input(String.fromCharCode(0x1b)+'\(codeSequence)')") + executeScript("inputWithModifiers(String.fromCharCode(0x1b)+'\(codeSequence)')") + } + + func setControlActive(_ active: Bool, generation: Int) { + executeScript("setControlActive(\(active), \(generation))") } + + func setControlLocked(_ locked: Bool) { + executeScript("setControlLocked(\(locked))") + } + + func setAltActive(_ active: Bool, generation: Int) { + executeScript("setAltActive(\(active), \(generation))") + } + + func setAltLocked(_ locked: Bool) { + executeScript("setAltLocked(\(locked))") + } +} + +extension Notification.Name { + static let terminalControlReset = Notification.Name("terminalControlReset") + static let terminalAltReset = Notification.Name("terminalAltReset") } diff --git a/CodeApp/Views/TerminalKeyboardToolbar.swift b/CodeApp/Views/TerminalKeyboardToolbar.swift index 8cd6327ba..f6ba152d7 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -12,6 +12,99 @@ struct TerminalKeyboardToolBar: View { @EnvironmentObject var App: MainApp @Environment(\.horizontalSizeClass) var horizontalSizeClass @State var pasteBoardHasContent = false + @State var controlActive = false + @State var controlLocked = false + @State var controlLastTapTime: Date? + @State var controlGeneration = 0 + @State var altActive = false + @State var altLocked = false + @State var altLastTapTime: Date? + @State var altGeneration = 0 + + private let doubleTapInterval: TimeInterval = 0.3 + + private func resetModifierStates() { + controlActive = false + controlLocked = false + App.terminalInstance.setControlActive(false, generation: controlGeneration) + App.terminalInstance.setControlLocked(false) + altActive = false + altLocked = false + App.terminalInstance.setAltActive(false, generation: altGeneration) + App.terminalInstance.setAltLocked(false) + } + + private func resetUnlockedModifiers() { + // Reset modifiers only if they are not locked + if !controlLocked { + controlActive = false + App.terminalInstance.setControlActive(false, generation: controlGeneration) + } + if !altLocked { + altActive = false + App.terminalInstance.setAltActive(false, generation: altGeneration) + } + } + + private func handleControlTap() { + let now = Date() + let isDoubleTap = + controlLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + controlLastTapTime = now + + if controlLocked { + // Single tap while locked: unlock and deactivate + controlLocked = false + controlActive = false + controlGeneration += 1 + App.terminalInstance.setControlLocked(false) + App.terminalInstance.setControlActive(false, generation: controlGeneration) + } else if isDoubleTap && controlActive { + // Double tap while active: lock + controlLocked = true + App.terminalInstance.setControlLocked(true) + } else { + // Single tap: toggle active state + controlActive.toggle() + controlGeneration += 1 + App.terminalInstance.setControlActive(controlActive, generation: controlGeneration) + } + } + + private func handleAltTap() { + let now = Date() + let isDoubleTap = + altLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + altLastTapTime = now + + if altLocked { + // Single tap while locked: unlock and deactivate + altLocked = false + altActive = false + altGeneration += 1 + App.terminalInstance.setAltLocked(false) + App.terminalInstance.setAltActive(false, generation: altGeneration) + } else if isDoubleTap && altActive { + // Double tap while active: lock + altLocked = true + App.terminalInstance.setAltLocked(true) + } else { + // Single tap: toggle active state + altActive.toggle() + altGeneration += 1 + App.terminalInstance.setAltActive(altActive, generation: altGeneration) + } + } + + private func typeAndResetModifiers(text: String) { + App.terminalInstance.type(text: text) + resetUnlockedModifiers() + } + + private func moveCursorAndResetModifiers(codeSequence: String) { + App.terminalInstance.moveCursor(codeSequence: codeSequence) + resetUnlockedModifiers() + } var body: some View { HStack(spacing: horizontalSizeClass == .compact ? 8 : 14) { @@ -20,7 +113,7 @@ struct TerminalKeyboardToolBar: View { Button( action: { if let string = UIPasteboard.general.string { - App.terminalInstance.type(text: string) + typeAndResetModifiers(text: string) } }, label: { @@ -29,11 +122,68 @@ struct TerminalKeyboardToolBar: View { } Button( action: { - App.terminalInstance.type(text: "\t") + typeAndResetModifiers(text: "\u{1b}") + }, + label: { + Text("Esc") + } + ) + .accessibilityLabel("Escape") + Button( + action: { + typeAndResetModifiers(text: "\t") }, label: { Text("↹") }) + Button( + action: { + handleControlTap() + }, + label: { + Text("Ctrl") + .padding(.horizontal, 4) + .background( + controlActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: controlLocked ? 2 : 0) + ) + } + ) + .accessibilityLabel("Control") + .accessibilityValue( + controlLocked ? "Locked" : (controlActive ? "Active" : "Inactive")) + Button( + action: { + handleAltTap() + }, + label: { + Text("Alt") + .padding(.horizontal, 4) + .background( + altActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: altLocked ? 2 : 0) + ) + } + ) + .accessibilityLabel("Alt") + .accessibilityValue(altLocked ? "Locked" : (altActive ? "Active" : "Inactive")) + Button( + action: { + typeAndResetModifiers(text: "\u{1b}[3~") + }, + label: { + Text("Del") + } + ) + .accessibilityLabel("Delete") } Spacer() @@ -41,34 +191,35 @@ struct TerminalKeyboardToolBar: View { Group { Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[A") + moveCursorAndResetModifiers(codeSequence: "[A") }, label: { Image(systemName: "arrow.up") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[B") + moveCursorAndResetModifiers(codeSequence: "[B") }, label: { Image(systemName: "arrow.down") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[D") + moveCursorAndResetModifiers(codeSequence: "[D") }, label: { Image(systemName: "arrow.left") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[C") + moveCursorAndResetModifiers(codeSequence: "[C") }, label: { Image(systemName: "arrow.right") }) Button( action: { + resetModifierStates() App.terminalInstance.blur() }, label: { @@ -84,12 +235,38 @@ struct TerminalKeyboardToolBar: View { .ignoresSafeArea() .onReceive( NotificationCenter.default.publisher(for: UIPasteboard.changedNotification), - perform: { val in + perform: { _ in if UIPasteboard.general.hasStrings { pasteBoardHasContent = true } else { pasteBoardHasContent = false } + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalControlReset, + object: App.terminalInstance + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == controlGeneration + { + controlActive = false + } + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalAltReset, + object: App.terminalInstance + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == altGeneration + { + altActive = false + } }) } } diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index 494ca9024..7dfd65bef 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -154,6 +154,161 @@ localEcho.addAutocompleteHandler(autocompleteCommonCommands); localEcho.addAutocompleteHandler(autocompleteCommonFiles); + var controlActive = false; + var controlLocked = false; + var controlGeneration = 0; + var altActive = false; + var altLocked = false; + var altGeneration = 0; + + function setControlActive(active, generation) { + controlActive = active; + controlGeneration = generation; + } + + function setControlLocked(locked) { + controlLocked = locked; + } + + function setAltActive(active, generation) { + altActive = active; + altGeneration = generation; + } + + function setAltLocked(locked) { + altLocked = locked; + } + + function shouldApplyModifierToCsi(final, params) { + if (final >= "A" && final <= "D") { + return true; + } + if (final === "F" || final === "H") { + return true; + } + if (final === "~") { + var primaryParam = params.split(";")[0]; + var keycode = parseInt(primaryParam, 10); + if (isNaN(keycode)) { + return false; + } + // Exclude bracketed paste mode sequences (ESC[200~ and ESC[201~). + // Modifying these would corrupt the paste protocol. + return keycode !== 200 && keycode !== 201; + } + return false; + } + + function applyModifierToEscapeSequence(data, wasControlActive, wasAltActive) { + var modifier = 1 + (wasAltActive ? 2 : 0) + (wasControlActive ? 4 : 0); + + // CSI sequences (ESC [ ...). + var csiMatch = data.match(/^\x1b\[([0-9;]*)([@-~])$/); + if (csiMatch) { + var params = csiMatch[1]; + var final = csiMatch[2]; + + if (!shouldApplyModifierToCsi(final, params)) { + return data; + } + + var parts = params.length ? params.split(";") : []; + if (parts.length === 0) { + parts = ["1", String(modifier)]; + } else if (parts.length === 1) { + parts.push(String(modifier)); + } else { + var lastIndex = parts.length - 1; + var existing = parseInt(parts[lastIndex], 10); + if (!isNaN(existing)) { + var modBits = Math.max(existing, 1) - 1; + if (wasAltActive) { + modBits |= 2; + } + if (wasControlActive) { + modBits |= 4; + } + parts[lastIndex] = String(modBits + 1); + } else { + parts.push(String(modifier)); + } + } + + return "\x1b[" + parts.join(";") + final; + } + + // SS3 sequences (ESC O ...). + var ss3Match = data.match(/^\x1bO([A-Za-z])$/); + if (ss3Match) { + var final = ss3Match[1]; + if ( + (final >= "A" && final <= "D") || + final === "F" || + final === "H" || + (final >= "P" && final <= "S") + ) { + return "\x1b[1;" + String(modifier) + final; + } + } + + return data; + } + + function applyModifierStates(data) { + // Capture which modifiers are active before potentially resetting + var wasControlActive = controlActive; + var wasAltActive = altActive; + + if (!wasControlActive && !wasAltActive) { + return data; + } + + // Reset modifiers only if not locked, and notify Swift + if (wasControlActive && !controlLocked) { + controlActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "ControlReset", + Generation: controlGeneration, + }); + } + if (wasAltActive && !altLocked) { + altActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "AltReset", + Generation: altGeneration, + }); + } + + var result = data; + + if (data.length === 1) { + // Apply Control: convert to control character (A-Z, @, [, \, ], ^, _) + if (wasControlActive) { + const code = result.toUpperCase().charCodeAt(0); + if (code >= 0x40 && code <= 0x5f) { + result = String.fromCharCode(code & 0x1f); + } + } + + // Apply Alt: prepend ESC (meta key behavior) + if (wasAltActive) { + result = "\x1b" + result; + } + } else if (data.charCodeAt(0) === 0x1b) { + result = applyModifierToEscapeSequence( + data, + wasControlActive, + wasAltActive + ); + } + + return result; + } + + function inputWithModifiers(data) { + term.input(applyModifierStates(data)); + } + function startInteractive() { localEcho.detach(); } @@ -163,6 +318,7 @@ } term.onData((data) => { + data = applyModifierStates(data); window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "Data", Input: data,