diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 015318310..25fde8095 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -233,6 +233,20 @@ 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 } @@ -499,4 +513,17 @@ extension TerminalInstance { func moveCursor(codeSequence: String) { executeScript("term.input(String.fromCharCode(0x1b)+'\(codeSequence)')") } + + func setControlActive(_ active: Bool, generation: Int) { + executeScript("setControlActive(\(active), \(generation))") + } + + func setAltActive(_ active: Bool, generation: Int) { + executeScript("setAltActive(\(active), \(generation))") + } +} + +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..b528d685c 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -12,6 +12,27 @@ struct TerminalKeyboardToolBar: View { @EnvironmentObject var App: MainApp @Environment(\.horizontalSizeClass) var horizontalSizeClass @State var pasteBoardHasContent = false + @State var controlActive = false + @State var controlGeneration = 0 + @State var altActive = false + @State var altGeneration = 0 + + private func resetModifierStates() { + controlActive = false + App.terminalInstance.setControlActive(false, generation: controlGeneration) + altActive = false + App.terminalInstance.setAltActive(false, generation: altGeneration) + } + + private func typeAndResetModifiers(text: String) { + App.terminalInstance.type(text: text) + resetModifierStates() + } + + private func moveCursorAndResetModifiers(codeSequence: String) { + App.terminalInstance.moveCursor(codeSequence: codeSequence) + resetModifierStates() + } var body: some View { HStack(spacing: horizontalSizeClass == .compact ? 8 : 14) { @@ -20,7 +41,7 @@ struct TerminalKeyboardToolBar: View { Button( action: { if let string = UIPasteboard.general.string { - App.terminalInstance.type(text: string) + typeAndResetModifiers(text: string) } }, label: { @@ -29,11 +50,65 @@ 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: { + controlActive.toggle() + controlGeneration += 1 + App.terminalInstance.setControlActive( + controlActive, generation: controlGeneration) + }, + label: { + Text("Ctrl") + .padding(.horizontal, 4) + .background( + controlActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + } + ) + .accessibilityLabel("Control") + .accessibilityValue(controlActive ? "Active" : "Inactive") + Button( + action: { + altActive.toggle() + altGeneration += 1 + App.terminalInstance.setAltActive( + altActive, generation: altGeneration) + }, + label: { + Text("Alt") + .padding(.horizontal, 4) + .background( + altActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + } + ) + .accessibilityLabel("Alt") + .accessibilityValue(altActive ? "Active" : "Inactive") + Button( + action: { + typeAndResetModifiers(text: "\u{1b}[3~") + }, + label: { + Text("Del") + } + ) + .accessibilityLabel("Delete") } Spacer() @@ -41,34 +116,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 +160,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..cadc19f25 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -154,6 +154,147 @@ localEcho.addAutocompleteHandler(autocompleteCommonCommands); localEcho.addAutocompleteHandler(autocompleteCommonFiles); + var controlActive = false; + var controlGeneration = 0; + var altActive = false; + var altGeneration = 0; + + function setControlActive(active, generation) { + controlActive = active; + controlGeneration = generation; + } + + function setAltActive(active, generation) { + altActive = active; + altGeneration = generation; + } + + 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 resetting + var wasControlActive = controlActive; + var wasAltActive = altActive; + + if (!wasControlActive && !wasAltActive) { + return data; + } + + // Reset all active modifiers and notify Swift + if (wasControlActive) { + controlActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "ControlReset", + Generation: controlGeneration, + }); + } + if (wasAltActive) { + 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 startInteractive() { localEcho.detach(); } @@ -163,6 +304,7 @@ } term.onData((data) => { + data = applyModifierStates(data); window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "Data", Input: data,