mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
794 lines
31 KiB
Swift
794 lines
31 KiB
Swift
import SwiftTerm
|
|
import SwiftUI
|
|
|
|
/// UIKit bridge for the SwiftTerm terminal emulator.
|
|
///
|
|
/// Wraps SwiftTerm's TerminalView in a UIViewRepresentable to integrate
|
|
/// with SwiftUI, handling terminal configuration, input/output, and resizing.
|
|
struct TerminalHostingView: UIViewRepresentable {
|
|
let session: Session
|
|
@Binding var fontSize: CGFloat
|
|
let theme: TerminalTheme
|
|
let onInput: (String) -> Void
|
|
let onResize: (Int, Int) -> Void
|
|
var viewModel: TerminalViewModel
|
|
@State private var isAutoScrollEnabled = true
|
|
@AppStorage("enableURLDetection")
|
|
private var enableURLDetection = true
|
|
|
|
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
|
let terminal = SwiftTerm.TerminalView()
|
|
|
|
// Configure terminal appearance with theme
|
|
terminal.backgroundColor = UIColor(theme.background)
|
|
terminal.nativeForegroundColor = UIColor(theme.foreground)
|
|
terminal.nativeBackgroundColor = UIColor(theme.background)
|
|
|
|
// Set ANSI colors from theme
|
|
let ansiColors: [SwiftTerm.Color] = [
|
|
UIColor(theme.black).toSwiftTermColor(), // 0
|
|
UIColor(theme.red).toSwiftTermColor(), // 1
|
|
UIColor(theme.green).toSwiftTermColor(), // 2
|
|
UIColor(theme.yellow).toSwiftTermColor(), // 3
|
|
UIColor(theme.blue).toSwiftTermColor(), // 4
|
|
UIColor(theme.magenta).toSwiftTermColor(), // 5
|
|
UIColor(theme.cyan).toSwiftTermColor(), // 6
|
|
UIColor(theme.white).toSwiftTermColor(), // 7
|
|
UIColor(theme.brightBlack).toSwiftTermColor(), // 8
|
|
UIColor(theme.brightRed).toSwiftTermColor(), // 9
|
|
UIColor(theme.brightGreen).toSwiftTermColor(), // 10
|
|
UIColor(theme.brightYellow).toSwiftTermColor(), // 11
|
|
UIColor(theme.brightBlue).toSwiftTermColor(), // 12
|
|
UIColor(theme.brightMagenta).toSwiftTermColor(), // 13
|
|
UIColor(theme.brightCyan).toSwiftTermColor(), // 14
|
|
UIColor(theme.brightWhite).toSwiftTermColor() // 15
|
|
]
|
|
terminal.installColors(ansiColors)
|
|
|
|
// Set cursor color
|
|
terminal.caretColor = UIColor(theme.cursor)
|
|
|
|
// Set selection color
|
|
terminal.selectedTextBackgroundColor = UIColor(theme.selection)
|
|
|
|
// Set up delegates
|
|
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
|
|
terminal.terminalDelegate = context.coordinator
|
|
|
|
// Configure terminal options
|
|
terminal.allowMouseReporting = false
|
|
terminal.optionAsMetaKey = true
|
|
|
|
// URL detection is handled by SwiftTerm automatically
|
|
|
|
// Configure font
|
|
updateFont(terminal, size: fontSize)
|
|
|
|
// Start with default size
|
|
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
|
|
let rows = 24
|
|
terminal.resize(cols: cols, rows: rows)
|
|
|
|
return terminal
|
|
}
|
|
|
|
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
|
updateFont(terminal, size: fontSize)
|
|
|
|
// URL detection is handled by SwiftTerm automatically
|
|
|
|
// Update theme colors
|
|
terminal.backgroundColor = UIColor(theme.background)
|
|
terminal.nativeForegroundColor = UIColor(theme.foreground)
|
|
terminal.nativeBackgroundColor = UIColor(theme.background)
|
|
terminal.caretColor = UIColor(theme.cursor)
|
|
terminal.selectedTextBackgroundColor = UIColor(theme.selection)
|
|
|
|
// Update ANSI colors
|
|
let ansiColors: [SwiftTerm.Color] = [
|
|
UIColor(theme.black).toSwiftTermColor(), // 0
|
|
UIColor(theme.red).toSwiftTermColor(), // 1
|
|
UIColor(theme.green).toSwiftTermColor(), // 2
|
|
UIColor(theme.yellow).toSwiftTermColor(), // 3
|
|
UIColor(theme.blue).toSwiftTermColor(), // 4
|
|
UIColor(theme.magenta).toSwiftTermColor(), // 5
|
|
UIColor(theme.cyan).toSwiftTermColor(), // 6
|
|
UIColor(theme.white).toSwiftTermColor(), // 7
|
|
UIColor(theme.brightBlack).toSwiftTermColor(), // 8
|
|
UIColor(theme.brightRed).toSwiftTermColor(), // 9
|
|
UIColor(theme.brightGreen).toSwiftTermColor(), // 10
|
|
UIColor(theme.brightYellow).toSwiftTermColor(), // 11
|
|
UIColor(theme.brightBlue).toSwiftTermColor(), // 12
|
|
UIColor(theme.brightMagenta).toSwiftTermColor(), // 13
|
|
UIColor(theme.brightCyan).toSwiftTermColor(), // 14
|
|
UIColor(theme.brightWhite).toSwiftTermColor() // 15
|
|
]
|
|
terminal.installColors(ansiColors)
|
|
|
|
// Update terminal content from viewModel
|
|
context.coordinator.terminal = terminal
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(
|
|
onInput: onInput,
|
|
onResize: onResize,
|
|
viewModel: viewModel
|
|
)
|
|
}
|
|
|
|
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
|
// Use system monospaced font which has better compatibility with SwiftTerm
|
|
// The custom SF Mono font seems to have rendering issues
|
|
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
|
|
|
// SwiftTerm uses the font property directly
|
|
terminal.font = font
|
|
}
|
|
|
|
// MARK: - Buffer Types
|
|
|
|
struct BufferSnapshot {
|
|
let cols: Int
|
|
let rows: Int
|
|
let viewportY: Int
|
|
let cursorX: Int
|
|
let cursorY: Int
|
|
let cells: [[BufferCell]]
|
|
}
|
|
|
|
struct BufferCell {
|
|
let char: String
|
|
let width: Int
|
|
let fg: Int?
|
|
let bg: Int?
|
|
let attributes: Int?
|
|
}
|
|
|
|
@MainActor
|
|
class Coordinator: NSObject {
|
|
let onInput: (String) -> Void
|
|
let onResize: (Int, Int) -> Void
|
|
let viewModel: TerminalViewModel
|
|
weak var terminal: SwiftTerm.TerminalView?
|
|
private let logger = Logger(category: "Terminal")
|
|
|
|
// Track previous buffer state for incremental updates
|
|
private var previousSnapshot: BufferSnapshot?
|
|
private var isFirstUpdate = true
|
|
|
|
// Selection support
|
|
private var selectionStart: (x: Int, y: Int)?
|
|
private var selectionEnd: (x: Int, y: Int)?
|
|
|
|
init(
|
|
onInput: @escaping (String) -> Void,
|
|
onResize: @escaping (Int, Int) -> Void,
|
|
viewModel: TerminalViewModel
|
|
) {
|
|
self.onInput = onInput
|
|
self.onResize = onResize
|
|
self.viewModel = viewModel
|
|
super.init()
|
|
|
|
// Set the coordinator reference on the viewModel
|
|
Task { @MainActor in
|
|
viewModel.terminalCoordinator = self
|
|
}
|
|
}
|
|
|
|
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
|
|
func updateBuffer(from snapshot: BufferSnapshot) {
|
|
guard let terminal else { return }
|
|
|
|
logger
|
|
.verbose(
|
|
"updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))"
|
|
)
|
|
|
|
// Update terminal dimensions if needed
|
|
let currentCols = terminal.getTerminal().cols
|
|
let currentRows = terminal.getTerminal().rows
|
|
|
|
if currentCols != snapshot.cols || currentRows != snapshot.rows {
|
|
terminal.resize(cols: snapshot.cols, rows: snapshot.rows)
|
|
// Force full redraw on resize
|
|
isFirstUpdate = true
|
|
}
|
|
|
|
// Handle viewport scrolling
|
|
let viewportChanged = previousSnapshot?.viewportY != snapshot.viewportY
|
|
if viewportChanged && previousSnapshot != nil {
|
|
// Calculate scroll delta
|
|
let scrollDelta = snapshot.viewportY - (previousSnapshot?.viewportY ?? 0)
|
|
handleViewportScroll(delta: scrollDelta, snapshot: snapshot)
|
|
}
|
|
|
|
// Use incremental updates if possible
|
|
let ansiData: String
|
|
if isFirstUpdate || previousSnapshot == nil || viewportChanged {
|
|
// Full redraw needed
|
|
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
|
|
isFirstUpdate = false
|
|
logger.verbose("Full redraw performed")
|
|
} else if let previous = previousSnapshot {
|
|
// Incremental update
|
|
ansiData = generateIncrementalUpdate(from: previous, to: snapshot)
|
|
logger.verbose("Incremental update performed")
|
|
} else {
|
|
// Fallback to full redraw if somehow previousSnapshot is nil
|
|
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: false)
|
|
logger.verbose("Fallback full redraw performed")
|
|
}
|
|
|
|
// Store current snapshot for next update
|
|
previousSnapshot = snapshot
|
|
|
|
// Feed the ANSI data to the terminal
|
|
if !ansiData.isEmpty {
|
|
feedData(ansiData)
|
|
}
|
|
}
|
|
|
|
/// Handle viewport scrolling
|
|
private func handleViewportScroll(delta: Int, snapshot: BufferSnapshot) {
|
|
guard terminal != nil else { return }
|
|
|
|
// SwiftTerm handles scrolling internally, but we can optimize by
|
|
// using scroll region commands if scrolling by small amounts
|
|
if abs(delta) < 5 && abs(delta) > 0 {
|
|
var scrollCommands = ""
|
|
|
|
// Set scroll region to full screen
|
|
scrollCommands += "\u{001B}[1;\(snapshot.rows)r"
|
|
|
|
if delta > 0 {
|
|
// Scrolling down - content moves up
|
|
scrollCommands += "\u{001B}[\(delta)S"
|
|
} else {
|
|
// Scrolling up - content moves down
|
|
scrollCommands += "\u{001B}[\(-delta)T"
|
|
}
|
|
|
|
// Reset scroll region
|
|
scrollCommands += "\u{001B}[r"
|
|
|
|
feedData(scrollCommands)
|
|
}
|
|
}
|
|
|
|
private func convertBufferToOptimizedANSI(_ snapshot: BufferSnapshot, clearScreen: Bool = false) -> String {
|
|
var output = ""
|
|
|
|
if clearScreen {
|
|
// Clear screen and reset cursor for first update
|
|
output += "\u{001B}[2J\u{001B}[H"
|
|
} else {
|
|
// Just reset cursor to home position
|
|
output += "\u{001B}[H"
|
|
}
|
|
|
|
// Track current attributes to minimize escape sequences
|
|
var currentFg: Int?
|
|
var currentBg: Int?
|
|
var currentAttrs: Int = 0
|
|
|
|
// Render each row
|
|
for (rowIndex, row) in snapshot.cells.enumerated() {
|
|
if rowIndex > 0 {
|
|
output += "\r\n"
|
|
}
|
|
|
|
// Check if this is an empty row (marked by empty array or single empty cell)
|
|
if row.isEmpty || (row.count == 1 && row[0].width == 0) {
|
|
// Skip rendering empty rows - terminal will show blank line
|
|
continue
|
|
}
|
|
|
|
var lastNonSpaceIndex = -1
|
|
for (index, cell) in row.enumerated() {
|
|
if cell.char != " " || cell.bg != nil {
|
|
lastNonSpaceIndex = index
|
|
}
|
|
}
|
|
|
|
// Only render up to the last non-space character
|
|
var currentCol = 0
|
|
for (_, cell) in row.enumerated() {
|
|
if currentCol > lastNonSpaceIndex && lastNonSpaceIndex >= 0 {
|
|
break
|
|
}
|
|
|
|
// Handle attributes efficiently
|
|
var needsReset = false
|
|
if let attrs = cell.attributes, attrs != currentAttrs {
|
|
needsReset = true
|
|
currentAttrs = attrs
|
|
}
|
|
|
|
// Handle colors efficiently
|
|
if cell.fg != currentFg || cell.bg != currentBg || needsReset {
|
|
if needsReset {
|
|
output += "\u{001B}[0m"
|
|
currentFg = nil
|
|
currentBg = nil
|
|
|
|
// Apply attributes
|
|
if let attrs = cell.attributes {
|
|
if (attrs & 0x01) != 0 { output += "\u{001B}[1m" } // Bold
|
|
if (attrs & 0x02) != 0 { output += "\u{001B}[3m" } // Italic
|
|
if (attrs & 0x04) != 0 { output += "\u{001B}[4m" } // Underline
|
|
if (attrs & 0x08) != 0 { output += "\u{001B}[2m" } // Dim
|
|
if (attrs & 0x10) != 0 { output += "\u{001B}[7m" } // Inverse
|
|
if (attrs & 0x40) != 0 { output += "\u{001B}[9m" } // Strikethrough
|
|
}
|
|
}
|
|
|
|
// Apply foreground color
|
|
if cell.fg != currentFg {
|
|
currentFg = cell.fg
|
|
if let fg = cell.fg {
|
|
if fg & 0xFF00_0000 != 0 {
|
|
// RGB color
|
|
let red = (fg >> 16) & 0xFF
|
|
let green = (fg >> 8) & 0xFF
|
|
let blue = fg & 0xFF
|
|
output += "\u{001B}[38;2;\(red);\(green);\(blue)m"
|
|
} else if fg <= 255 {
|
|
// Palette color
|
|
output += "\u{001B}[38;5;\(fg)m"
|
|
}
|
|
} else {
|
|
output += "\u{001B}[39m"
|
|
}
|
|
}
|
|
|
|
// Apply background color
|
|
if cell.bg != currentBg {
|
|
currentBg = cell.bg
|
|
if let bg = cell.bg {
|
|
if bg & 0xFF00_0000 != 0 {
|
|
// RGB color
|
|
let red = (bg >> 16) & 0xFF
|
|
let green = (bg >> 8) & 0xFF
|
|
let blue = bg & 0xFF
|
|
output += "\u{001B}[48;2;\(red);\(green);\(blue)m"
|
|
} else if bg <= 255 {
|
|
// Palette color
|
|
output += "\u{001B}[48;5;\(bg)m"
|
|
}
|
|
} else {
|
|
output += "\u{001B}[49m"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the character
|
|
output += cell.char
|
|
currentCol += cell.width
|
|
}
|
|
}
|
|
|
|
// Reset attributes
|
|
output += "\u{001B}[0m"
|
|
|
|
// Position cursor
|
|
output += "\u{001B}[\(snapshot.cursorY + 1);\(snapshot.cursorX + 1)H"
|
|
|
|
return output
|
|
}
|
|
|
|
/// Generate incremental ANSI updates by comparing previous and current snapshots
|
|
private func generateIncrementalUpdate(
|
|
from oldSnapshot: BufferSnapshot,
|
|
to newSnapshot: BufferSnapshot
|
|
)
|
|
-> String
|
|
{
|
|
var output = ""
|
|
var currentFg: Int?
|
|
var currentBg: Int?
|
|
var currentAttrs: Int = 0
|
|
|
|
// Update cursor if changed
|
|
let cursorChanged = oldSnapshot.cursorX != newSnapshot.cursorX || oldSnapshot.cursorY != newSnapshot.cursorY
|
|
|
|
// Check each row for changes
|
|
for rowIndex in 0..<min(newSnapshot.cells.count, oldSnapshot.cells.count) {
|
|
let oldRow = rowIndex < oldSnapshot.cells.count ? oldSnapshot.cells[rowIndex] : []
|
|
let newRow = rowIndex < newSnapshot.cells.count ? newSnapshot.cells[rowIndex] : []
|
|
|
|
// Quick check if rows are identical
|
|
if rowsAreIdentical(oldRow, newRow) {
|
|
continue
|
|
}
|
|
|
|
// Handle empty rows efficiently
|
|
let oldIsEmpty = oldRow.isEmpty || (oldRow.count == 1 && oldRow[0].width == 0)
|
|
let newIsEmpty = newRow.isEmpty || (newRow.count == 1 && newRow[0].width == 0)
|
|
|
|
if oldIsEmpty && newIsEmpty {
|
|
continue // Both empty, no change
|
|
} else if !oldIsEmpty && newIsEmpty {
|
|
// Row became empty - clear it
|
|
output += "\u{001B}[\(rowIndex + 1);1H\u{001B}[2K"
|
|
continue
|
|
} else if oldIsEmpty && !newIsEmpty {
|
|
// Empty row now has content - render full row
|
|
output += "\u{001B}[\(rowIndex + 1);1H"
|
|
for cell in newRow {
|
|
updateColorIfNeeded(&output, ¤tFg, cell.fg, isBackground: false)
|
|
updateColorIfNeeded(&output, ¤tBg, cell.bg, isBackground: true)
|
|
output += cell.char
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Find changed segments in this row
|
|
var segments: [(start: Int, end: Int)] = []
|
|
var currentSegmentStart = -1
|
|
|
|
let maxCells = max(oldRow.count, newRow.count)
|
|
for colIndex in 0..<maxCells {
|
|
let oldCell = colIndex < oldRow.count ? oldRow[colIndex] : nil
|
|
let newCell = colIndex < newRow.count ? newRow[colIndex] : nil
|
|
|
|
if !cellsAreIdentical(oldCell, newCell) {
|
|
if currentSegmentStart == -1 {
|
|
currentSegmentStart = colIndex
|
|
}
|
|
} else if currentSegmentStart >= 0 {
|
|
// End of changed segment
|
|
segments.append((start: currentSegmentStart, end: colIndex - 1))
|
|
currentSegmentStart = -1
|
|
}
|
|
}
|
|
|
|
// Handle last segment if it extends to end
|
|
if currentSegmentStart >= 0 {
|
|
segments.append((start: currentSegmentStart, end: maxCells - 1))
|
|
}
|
|
|
|
// Render each changed segment
|
|
for segment in segments {
|
|
// Move cursor to start of segment
|
|
var colPosition = 0
|
|
for i in 0..<segment.start {
|
|
if i < newRow.count {
|
|
colPosition += newRow[i].width
|
|
}
|
|
}
|
|
output += "\u{001B}[\(rowIndex + 1);\(colPosition + 1)H"
|
|
|
|
// Render cells in segment
|
|
for colIndex in segment.start...segment.end {
|
|
guard colIndex < newRow.count else {
|
|
// Clear remaining cells if old row was longer
|
|
output += "\u{001B}[K"
|
|
break
|
|
}
|
|
let cell = newRow[colIndex]
|
|
|
|
// Handle attributes
|
|
var needsReset = false
|
|
if let attrs = cell.attributes, attrs != currentAttrs {
|
|
needsReset = true
|
|
currentAttrs = attrs
|
|
}
|
|
|
|
// Apply styles if changed
|
|
if cell.fg != currentFg || cell.bg != currentBg || needsReset {
|
|
if needsReset {
|
|
output += "\u{001B}[0m"
|
|
currentFg = nil
|
|
currentBg = nil
|
|
|
|
// Apply attributes
|
|
if let attrs = cell.attributes {
|
|
if (attrs & 0x01) != 0 { output += "\u{001B}[1m" }
|
|
if (attrs & 0x02) != 0 { output += "\u{001B}[3m" }
|
|
if (attrs & 0x04) != 0 { output += "\u{001B}[4m" }
|
|
if (attrs & 0x08) != 0 { output += "\u{001B}[2m" }
|
|
if (attrs & 0x10) != 0 { output += "\u{001B}[7m" }
|
|
if (attrs & 0x40) != 0 { output += "\u{001B}[9m" }
|
|
}
|
|
}
|
|
|
|
// Apply colors
|
|
updateColorIfNeeded(&output, ¤tFg, cell.fg, isBackground: false)
|
|
updateColorIfNeeded(&output, ¤tBg, cell.bg, isBackground: true)
|
|
}
|
|
|
|
output += cell.char
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle newly added rows
|
|
if newSnapshot.cells.count > oldSnapshot.cells.count {
|
|
for rowIndex in oldSnapshot.cells.count..<newSnapshot.cells.count {
|
|
output += "\u{001B}[\(rowIndex + 1);1H"
|
|
output += "\u{001B}[2K" // Clear line
|
|
|
|
let row = newSnapshot.cells[rowIndex]
|
|
for cell in row {
|
|
// Apply styles
|
|
updateColorIfNeeded(&output, ¤tFg, cell.fg, isBackground: false)
|
|
updateColorIfNeeded(&output, ¤tBg, cell.bg, isBackground: true)
|
|
output += cell.char
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update cursor position if changed
|
|
if cursorChanged {
|
|
output += "\u{001B}[\(newSnapshot.cursorY + 1);\(newSnapshot.cursorX + 1)H"
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
private func rowsAreIdentical(_ row1: [BufferCell], _ row2: [BufferCell]) -> Bool {
|
|
guard row1.count == row2.count else { return false }
|
|
|
|
for i in 0..<row1.count {
|
|
if !cellsAreIdentical(row1[i], row2[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func cellsAreIdentical(_ cell1: BufferCell?, _ cell2: BufferCell?) -> Bool {
|
|
guard let cell1, let cell2 else {
|
|
return cell1 == nil && cell2 == nil
|
|
}
|
|
|
|
return cell1.char == cell2.char &&
|
|
cell1.fg == cell2.fg &&
|
|
cell1.bg == cell2.bg &&
|
|
cell1.attributes == cell2.attributes
|
|
}
|
|
|
|
private func updateColorIfNeeded(
|
|
_ output: inout String,
|
|
_ current: inout Int?,
|
|
_ new: Int?,
|
|
isBackground: Bool
|
|
) {
|
|
if new != current {
|
|
current = new
|
|
if let color = new {
|
|
if color & 0xFF00_0000 != 0 {
|
|
// RGB color
|
|
let red = (color >> 16) & 0xFF
|
|
let green = (color >> 8) & 0xFF
|
|
let blue = color & 0xFF
|
|
output += "\u{001B}[\(isBackground ? 48 : 38);2;\(red);\(green);\(blue)m"
|
|
} else if color <= 255 {
|
|
// Palette color
|
|
output += "\u{001B}[\(isBackground ? 48 : 38);5;\(color)m"
|
|
}
|
|
} else {
|
|
// Default color
|
|
output += "\u{001B}[\(isBackground ? 49 : 39)m"
|
|
}
|
|
}
|
|
}
|
|
|
|
func feedData(_ data: String) {
|
|
Task { @MainActor in
|
|
guard let terminal else {
|
|
logger.warning("No terminal instance available")
|
|
return
|
|
}
|
|
|
|
// Debug: Log first 100 chars of data
|
|
let preview = String(data.prefix(100))
|
|
logger.verbose("Feeding \(data.count) bytes: \(preview)")
|
|
|
|
// Store current scroll position before feeding data
|
|
let wasAtBottom = viewModel.isAutoScrollEnabled
|
|
|
|
// Feed the output to the terminal
|
|
terminal.feed(text: data)
|
|
|
|
// Auto-scroll to bottom if enabled
|
|
if wasAtBottom {
|
|
// SwiftTerm automatically scrolls when feeding data at bottom
|
|
// No explicit API needed for auto-scrolling
|
|
}
|
|
}
|
|
}
|
|
|
|
func getBufferContent() -> String? {
|
|
guard let terminal else { return nil }
|
|
|
|
// Get the terminal buffer content
|
|
let terminalInstance = terminal.getTerminal()
|
|
var content = ""
|
|
|
|
// Read all lines from the terminal buffer
|
|
for row in 0..<terminalInstance.rows {
|
|
if let line = terminalInstance.getLine(row: row) {
|
|
var lineText = ""
|
|
for col in 0..<terminalInstance.cols {
|
|
if col < line.count {
|
|
let char = line[col]
|
|
lineText += String(char.getCharacter())
|
|
}
|
|
}
|
|
// Trim trailing spaces
|
|
content += lineText.trimmingCharacters(in: .whitespaces) + "\n"
|
|
}
|
|
}
|
|
|
|
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
// MARK: - TerminalViewDelegate
|
|
|
|
func send(source: SwiftTerm.TerminalView, data: ArraySlice<UInt8>) {
|
|
if let string = String(bytes: data, encoding: .utf8) {
|
|
onInput(string)
|
|
}
|
|
}
|
|
|
|
func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) {
|
|
onResize(newCols, newRows)
|
|
}
|
|
|
|
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
|
|
// Check if user is at bottom
|
|
Task { @MainActor in
|
|
// Estimate if at bottom based on position
|
|
let isAtBottom = position >= 0.95
|
|
viewModel.updateScrollState(isAtBottom: isAtBottom)
|
|
|
|
// The view model will handle button visibility through its state
|
|
}
|
|
}
|
|
|
|
func scrollToBottom() {
|
|
// Scroll to bottom by sending page down keys
|
|
if let terminal {
|
|
terminal.feed(text: "\u{001b}[B")
|
|
}
|
|
}
|
|
|
|
func setMaxWidth(_ maxWidth: Int) {
|
|
// Store the max width preference for terminal rendering
|
|
// When maxWidth is 0, it means unlimited
|
|
// This could be used to constrain terminal rendering in the future
|
|
// For now, just log the preference
|
|
logger.info("Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")")
|
|
}
|
|
|
|
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
|
// Handle title change if needed
|
|
}
|
|
|
|
func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) {
|
|
// Handle directory update if needed
|
|
}
|
|
|
|
func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) {
|
|
// Open URL with haptic feedback
|
|
if let url = URL(string: link) {
|
|
DispatchQueue.main.async {
|
|
HapticFeedback.impact(.light)
|
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) {
|
|
// Handle clipboard copy with improved selection support
|
|
if let string = String(data: content, encoding: .utf8) {
|
|
UIPasteboard.general.string = string
|
|
|
|
// Provide haptic feedback
|
|
HapticFeedback.notification(.success)
|
|
|
|
// If we have buffer data, we can provide additional context
|
|
if previousSnapshot != nil {
|
|
// Log selection range for debugging
|
|
logger.debug("Copied \(string.count) characters")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get selected text from buffer with proper Unicode handling
|
|
func getSelectedText() -> String? {
|
|
guard let start = selectionStart,
|
|
let end = selectionEnd,
|
|
let snapshot = previousSnapshot
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
var selectedText = ""
|
|
|
|
// Normalize selection coordinates
|
|
let startY = min(start.y, end.y)
|
|
let endY = max(start.y, end.y)
|
|
let startX = start.y < end.y ? start.x : min(start.x, end.x)
|
|
let endX = start.y < end.y ? max(start.x, end.x) : end.x
|
|
|
|
// Extract text from buffer
|
|
for y in startY...endY {
|
|
guard y < snapshot.cells.count else { continue }
|
|
let row = snapshot.cells[y]
|
|
|
|
var rowText = ""
|
|
var currentX = 0
|
|
|
|
for cell in row {
|
|
let cellStartX = currentX
|
|
let cellEndX = currentX + cell.width
|
|
|
|
// Check if cell is within selection
|
|
if y == startY && y == endY {
|
|
// Single line selection
|
|
if cellEndX > startX && cellStartX < endX {
|
|
rowText += cell.char
|
|
}
|
|
} else if y == startY {
|
|
// First line of multi-line selection
|
|
if cellStartX >= startX {
|
|
rowText += cell.char
|
|
}
|
|
} else if y == endY {
|
|
// Last line of multi-line selection
|
|
if cellEndX <= endX {
|
|
rowText += cell.char
|
|
}
|
|
} else {
|
|
// Middle lines - include everything
|
|
rowText += cell.char
|
|
}
|
|
|
|
currentX = cellEndX
|
|
}
|
|
|
|
// Add line to result
|
|
if !rowText.isEmpty {
|
|
if !selectedText.isEmpty {
|
|
selectedText += "\n"
|
|
}
|
|
selectedText += rowText.trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|
|
|
|
return selectedText.isEmpty ? nil : selectedText
|
|
}
|
|
|
|
func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) {
|
|
// Handle range change if needed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add conformance with proper isolation
|
|
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}
|
|
|
|
// MARK: - UIColor Extension for SwiftTerm
|
|
|
|
extension UIColor {
|
|
/// Convert UIColor to SwiftTerm.Color (which uses 16-bit color components)
|
|
func toSwiftTermColor() -> SwiftTerm.Color {
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
|
|
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
|
|
// Convert from 0.0-1.0 range to 0-65535 range
|
|
let red16 = UInt16(red * 65_535.0)
|
|
let green16 = UInt16(green * 65_535.0)
|
|
let blue16 = UInt16(blue * 65_535.0)
|
|
|
|
return SwiftTerm.Color(red: red16, green: green16, blue: blue16)
|
|
}
|
|
}
|