import SwiftUI /// T-iOS-33 · 终端内搜索(scrollback find bar)。 /// /// 分层与 web 完全对齐:`public/search.ts` 只管一个输入框 + ↑/↓/× 三个键,真正 /// 的搜索交给 xterm 的 SearchAddon(`terminal-session.ts:547-553`)。iOS 侧同理 /// —— 本文件只有**纯归约**(提交策略 + 命中态)与**一层协议缝**,检索算法用 /// SwiftTerm 1.13.0 自带的 `findNext`/`findPrevious`/`clearSearch` /// (`SearchOptions` 默认 caseSensitive:false / regex:false,与 xterm 一致)。 /// /// 铁律:搜索是**纯读** —— 它只动 SwiftTerm 的选区/滚动位置,绝不产生一个 PTY /// 字节。终端已退出(read-only)时依然可用。 // MARK: - Engine seam /// 搜索引擎缝:生产实现是 `KeyCommandTerminalView`(SwiftTerm),测试注入替身。 /// `AnyObject` 约束让 `TerminalSearchModel` 能弱持有视图(视图归 UIKit 所有)。 @MainActor protocol TerminalSearching: AnyObject { /// 向下一处匹配,命中返回 true(并把选区移到命中处 = 高亮)。 @discardableResult func searchNext(term: String) -> Bool /// 向上一处匹配,命中返回 true。 @discardableResult func searchPrevious(term: String) -> Bool /// 清掉搜索状态与高亮选区。 func searchClear() } /// 搜索方向(web:Enter = next,Shift+Enter = prev)。 enum TerminalSearchDirection: String, Equatable, Sendable { case next case previous } /// 上一次搜索的结果态。`.idle` = 还没搜 / 词已改(旧结论作废)。 enum TerminalSearchOutcome: Equatable, Sendable { case idle case found case notFound } /// 提交策略(纯函数)。**只在空串时拦**:镜像 web 的 `hooks.find(input.value, …)` /// —— 查询词逐字符原样下传,绝不 trim、绝不改大小写(单个空格是合法搜索词)。 enum TerminalSearchQuery { static func isSubmittable(_ raw: String) -> Bool { !raw.isEmpty } } // MARK: - Model /// 搜索面板状态。命中态是**派生值**:只要 `query` 变了,上一次结论自动作废回 /// `.idle`(不需要 didSet,也不会把「无匹配」挂在新词上)。 @MainActor @Observable final class TerminalSearchModel { /// 用户可见文案(named constants)。 enum Copy { static let title = "搜索回滚缓冲" static let placeholder = "搜索终端输出…" static let noMatch = "无匹配" static let previous = "上一处匹配" static let next = "下一处匹配" static let close = "关闭搜索" static let open = "搜索" } /// 一次搜索的结论,连同它对应的词 —— 词一改,`outcome` 立即回 `.idle`。 private struct Attempt: Equatable { let term: String let didHit: Bool } var query: String = "" private(set) var isPresented = false private var lastAttempt: Attempt? /// 弱持有:SwiftTerm 视图归 UIKit 所有,面板绝不延长它的生命周期。 @ObservationIgnored private weak var searcher: (any TerminalSearching)? /// 派生命中态。 var outcome: TerminalSearchOutcome { guard let lastAttempt, lastAttempt.term == query else { return .idle } return lastAttempt.didHit ? .found : .notFound } /// 状态行文案 —— 只有「无匹配」需要说话(命中时选区高亮本身就是反馈)。 var statusText: String? { outcome == .notFound ? Copy.noMatch : nil } /// 绑定 SwiftTerm 视图(`makeUIView` 时调用一次)。 func attach(_ searcher: any TerminalSearching) { self.searcher = searcher } func present() { isPresented = true } /// 关闭面板 —— 镜像 web `hide()`:清高亮(`hooks.clear()`)+ 清输入框。 func dismiss() { isPresented = false query = "" lastAttempt = nil searcher?.searchClear() } /// 搜一次。空词零引擎调用;未绑定引擎(预览/测试)安全无操作。 func find(_ direction: TerminalSearchDirection) { let term = query guard TerminalSearchQuery.isSubmittable(term), let searcher else { return } let didHit: Bool = switch direction { case .next: searcher.searchNext(term: term) case .previous: searcher.searchPrevious(term: term) } lastAttempt = Attempt(term: term, didHit: didHit) } } // MARK: - View /// 顶部右侧浮出的搜索条(位置对齐 web `#searchbox`:`position:fixed; top; right`)。 /// 输入框 submit = 下一处;↑/↓ 手动翻;× 关闭并清高亮。 struct TerminalSearchBar: View { let model: TerminalSearchModel /// 输入框首次出现即取焦(web `open()` 里的 `input.focus()`)。 @FocusState private var isFieldFocused: Bool private enum Metrics { static let fieldMinWidth: CGFloat = 140 static let fieldMaxWidth: CGFloat = 220 } var body: some View { HStack(spacing: DS.Space.xs4) { searchField if let status = model.statusText { Text(status) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) .accessibilityIdentifier("terminal.search.status") } iconButton( systemImage: "chevron.up", title: TerminalSearchModel.Copy.previous, identifier: "terminal.search.previousButton" ) { model.find(.previous) } iconButton( systemImage: "chevron.down", title: TerminalSearchModel.Copy.next, identifier: "terminal.search.nextButton" ) { model.find(.next) } iconButton( systemImage: "xmark", title: TerminalSearchModel.Copy.close, identifier: "terminal.search.closeButton" ) { model.dismiss() } } .padding(.horizontal, DS.Space.sm8) .padding(.vertical, DS.Space.xs4) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay( RoundedRectangle(cornerRadius: DS.Radius.md12) .strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline) ) .onAppear { isFieldFocused = true } .accessibilityLabel(TerminalSearchModel.Copy.title) } private var searchField: some View { TextField( TerminalSearchModel.Copy.placeholder, text: Binding(get: { model.query }, set: { model.query = $0 }) ) .textFieldStyle(.plain) .font(DS.Typography.callout) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.search) .focused($isFieldFocused) .frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth) .onSubmit { model.find(.next) } .accessibilityIdentifier("terminal.search.field") } private func iconButton( systemImage: String, title: String, identifier: String, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemImage) .frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget) } .buttonStyle(.plain) .accessibilityLabel(title) .accessibilityIdentifier(identifier) } }