Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CodeApp/Managers/TerminalInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
116 changes: 109 additions & 7 deletions CodeApp/Views/TerminalKeyboardToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -20,7 +41,7 @@ struct TerminalKeyboardToolBar: View {
Button(
action: {
if let string = UIPasteboard.general.string {
App.terminalInstance.type(text: string)
typeAndResetModifiers(text: string)
}
},
label: {
Expand All @@ -29,46 +50,101 @@ 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()

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: {
Expand All @@ -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
}
})
}
}
142 changes: 142 additions & 0 deletions Dependencies/terminal.bundle/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -163,6 +304,7 @@
}

term.onData((data) => {
data = applyModifierStates(data);
window.webkit.messageHandlers.toggleMessageHandler2.postMessage({
Event: "Data",
Input: data,
Expand Down