import SwiftTerm import UIKit /// T-iOS-28 · 离屏 SwiftTerm 渲染器:把一段 preview tail(不可信 ANSI 字节, /// **只喂终端解释器,绝不字符串处理**)喂进一个从不进视图层级的 /// `TerminalView`,再用 `UIGraphicsImageRenderer` 对已布局的 view 出快照。 /// /// 与 web 预览逐点对齐(public/preview-grid.ts makePreviewCard/renderPreview): /// - 网格 = 服务器回报的 cols×rows(上游已钳制)——换行与真屏一致; /// - `scrollback: 0` ↔ `changeScrollback(nil)`:buffer 只有当前屏, /// `contentOffset` 恒为 0,离屏 draw 恰取「现在这一屏」; /// - 暗底主题 + 隐藏光标(web 用 cursor==background;此处喂 DECTCEM 隐藏序列, /// iOS 端直接把 caret 子视图摘掉); /// - 快照按 `snapshotTargetWidth` 缩放——缓存的是小位图,不是整格点阵。 /// /// 生命周期:`TerminalView` 在初始化时向主 runloop 挂 CADisplayLink(暂停态), /// 离屏用完必须 `updateUiClosed()` 注销,否则每次渲染泄漏一个 runloop 源。 @MainActor enum SessionThumbnailRenderer { /// 快照宽度预算(pt):列表槽位 88pt 的 2 倍,配 `snapshotScale`=2 后 /// 像素密度足够,单张缓存 ≈ 数百 KB 有界。 static let snapshotTargetWidth: CGFloat = 176 /// 固定 2x:快照是缩略图,不追设备 3x(内存 × 2.25 不值)。 static let snapshotScale: CGFloat = 2 /// 镜像 web 预览字号(public/preview-grid.ts fontSize: 12)。 static let fontSize: CGFloat = 12 /// 布局余量(pt):frame 取 optimal + slack,抵消 `Int(width/cellW)` 的 /// 浮点截断——必须严格小于一个 cell 宽,否则网格会多出一列。 static let layoutSlack: CGFloat = 0.5 /// DECTCEM 隐藏光标(数据喂完后追加;iOS 端会摘掉 caret 子视图)。 /// 这是我们自己的常量序列,不是对服务器数据的处理。 static let hideCursorSequence = "\u{1b}[?25l" /// 镜像 web PREVIEW_THEME(#0e0f13 / #e7e8ec)。 static let backgroundColor = UIColor( red: 14 / 255, green: 15 / 255, blue: 19 / 255, alpha: 1 ) static let foregroundColor = UIColor( red: 231 / 255, green: 232 / 255, blue: 236 / 255, alpha: 1 ) private static let initialProbeFrame = CGRect(x: 0, y: 0, width: 64, height: 64) /// 渲染一张快照。`cols`/`rows` 必须已过 `clampedGeometry`(有界);失败 /// 返回 nil(上游显式降级为占位图)。 static func render(data: String, cols: Int, rows: Int) -> UIImage? { let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) let terminal = TerminalView(frame: initialProbeFrame, font: font) defer { terminal.updateUiClosed() } // 注销 CADisplayLink,绝不泄漏 terminal.changeScrollback(nil) // ↔ web scrollback: 0 terminal.resize(cols: cols, rows: rows) terminal.nativeBackgroundColor = backgroundColor terminal.nativeForegroundColor = foregroundColor let optimal = terminal.getOptimalFrameSize() guard optimal.width > 0, optimal.height > 0 else { return nil } terminal.frame = CGRect( x: 0, y: 0, width: optimal.width + layoutSlack, height: optimal.height + layoutSlack ) terminal.layoutIfNeeded() terminal.feed(text: data) // 唯一合法去处:ANSI 解释器 terminal.feed(text: hideCursorSequence) return snapshot(of: terminal, contentSize: optimal.size) } /// `UIGraphicsImageRenderer` + `layer.render(in:)`:离屏(无 window)视图 /// 走 CoreGraphics draw 路径(SwiftTerm 默认不启 Metal——Metal 层无法被 /// `layer.render` 捕获,此处也永不 `setUseMetal`)。 private static func snapshot(of view: UIView, contentSize: CGSize) -> UIImage? { let scale = min(1, snapshotTargetWidth / contentSize.width) let size = CGSize( width: contentSize.width * scale, height: contentSize.height * scale ) guard size.width >= 1, size.height >= 1 else { return nil } let format = UIGraphicsImageRendererFormat() format.scale = snapshotScale format.opaque = true let renderer = UIGraphicsImageRenderer(size: size, format: format) return renderer.image { context in context.cgContext.scaleBy(x: scale, y: scale) view.layer.render(in: context.cgContext) } } }