native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427import AppKitimport SwiftUI
struct GhosttySurfaceSearchOverlay: View { let surfaceView: GhosttySurfaceView @Bindable var state: GhosttySurfaceState @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts @Environment(\.resolvedKeybindings) private var resolvedKeybindings
@State private var searchText: String @State private var corner: GhosttySearchCorner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @State private var isSearchFieldFocused = false @State private var searchTask: Task<Void, Never>?
private let overlayPadding: CGFloat = 8
init(surfaceView: GhosttySurfaceView) { self.surfaceView = surfaceView self._state = Bindable(surfaceView.bridge.state) self._searchText = State(initialValue: surfaceView.bridge.state.searchNeedle ?? "") }
var body: some View { GeometryReader { geo in ZStack(alignment: corner.alignment) { HStack(spacing: 4) { GhosttySearchField( text: $searchText, isFocused: isSearchFieldFocused, onSubmit: { isShifted in navigateSearch(isShifted ? .previous : .next) }, onEscape: { closeSearch() surfaceView.requestFocus() } ) .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .clipShape(.capsule) .overlay(alignment: .trailing) { matchLabel }
Button { navigateSearch(.previous) } label: { SearchButtonLabel( title: String(localized: "Previous"), shortcut: resolvedKeybindings.display(for: AppShortcuts.CommandID.findPrevious), systemImage: "chevron.up" ) } .buttonStyle(GhosttySearchButtonStyle()) .help(searchButtonHelp(String(localized: "Find Previous"), commandID: AppShortcuts.CommandID.findPrevious))
Button { navigateSearch(.next) } label: { SearchButtonLabel( title: String(localized: "Next"), shortcut: resolvedKeybindings.display(for: AppShortcuts.CommandID.findNext), systemImage: "chevron.down" ) } .buttonStyle(GhosttySearchButtonStyle()) .help(searchButtonHelp(String(localized: "Find Next"), commandID: AppShortcuts.CommandID.findNext))
Button { closeSearch() surfaceView.requestFocus() } label: { SearchButtonLabel( title: String(localized: "Close"), shortcut: nil, systemImage: "xmark" ) } .buttonStyle(GhosttySearchButtonStyle()) .help("Close Find Bar (Esc)") } .padding(8) .background(.background) .clipShape(.capsule) .shadow(radius: 4) .background( GeometryReader { barGeo in Color.clear.onAppear { barSize = barGeo.size } } ) .padding(overlayPadding) .offset(dragOffset) .contentShape(.rect) .gesture( DragGesture() .onChanged { value in dragOffset = value.translation } .onEnded { value in let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) let newCenter = CGPoint( x: centerPos.x + value.translation.width, y: centerPos.y + value.translation.height ) let newCorner = closestCorner(to: newCenter, in: geo.size) withAnimation(.easeOut(duration: 0.2)) { corner = newCorner dragOffset = .zero } } ) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) .onAppear { focusSearchField() scheduleSearch(searchText) } .onChange(of: searchText) { _, newValue in scheduleSearch(newValue) } .onChange(of: state.searchNeedle) { _, newValue in guard let newValue else { return } focusSearchField() if !newValue.isEmpty, newValue != searchText { searchText = newValue } } .onChange(of: state.searchTotal) { _, newValue in if let total = newValue, total > 0, state.searchSelected == nil { surfaceView.performBindingAction("navigate_search:next") } } .onChange(of: state.searchFocusCount) { _, _ in focusSearchField() } .onDisappear { searchTask?.cancel() searchTask = nil } } }
@ViewBuilder private var matchLabel: some View { if let selected = state.searchSelected, let total = state.searchTotal { Text("\(total - selected)/\(total)") .font(.caption) .foregroundStyle(.secondary) .padding(.trailing, 8) } else if let total = state.searchTotal { Text("-/\(total)") .font(.caption) .foregroundStyle(.secondary) .padding(.trailing, 8) } }
private func scheduleSearch(_ needle: String) { searchTask?.cancel() if needle.isEmpty || needle.count >= 3 { emitSearch(needle) return }
let text = needle searchTask = Task { @MainActor in do { try await ContinuousClock().sleep(for: .milliseconds(300)) } catch { return } guard !Task.isCancelled else { return } emitSearch(text) } }
private func emitSearch(_ needle: String) { surfaceView.performBindingAction("search:\(needle)") }
private func navigateSearch(_ direction: GhosttySearchDirection) { flushPendingSearch() surfaceView.navigateSearch(direction) }
private func closeSearch() { surfaceView.performBindingAction("end_search") }
private func flushPendingSearch() { guard let searchTask else { return } searchTask.cancel() self.searchTask = nil emitSearch(searchText) }
private func searchButtonHelp(_ title: String, commandID: String) -> String { if let shortcut = resolvedKeybindings.display(for: commandID) { return "\(title) (\(shortcut))" } return title }
private func focusSearchField() { isSearchFieldFocused = false Task { @MainActor in await Task.yield() isSearchFieldFocused = true } }
private func centerPosition( for corner: GhosttySearchCorner, in containerSize: CGSize, barSize: CGSize ) -> CGPoint { let halfWidth = barSize.width / 2 + overlayPadding let halfHeight = barSize.height / 2 + overlayPadding
switch corner { case .topLeft: return CGPoint(x: halfWidth, y: halfHeight) case .topRight: return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) case .bottomLeft: return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) case .bottomRight: return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) } }
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> GhosttySearchCorner { let midX = containerSize.width / 2 let midY = containerSize.height / 2
if point.x < midX { return point.y < midY ? .topLeft : .bottomLeft } return point.y < midY ? .topRight : .bottomRight }}
private enum GhosttySearchCorner { case topLeft case topRight case bottomLeft case bottomRight
var alignment: Alignment { switch self { case .topLeft: return .topLeading case .topRight: return .topTrailing case .bottomLeft: return .bottomLeading case .bottomRight: return .bottomTrailing } }}
private nonisolated struct GhosttySearchOverlayShape: Shape { func path(in rect: CGRect) -> Path { ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true).path(in: rect) }}
private struct SearchButtonLabel: View { let title: String let shortcut: String? let systemImage: String
var body: some View { Image(systemName: systemImage) .accessibilityHidden(true) }}
private struct GhosttySearchField: NSViewRepresentable { @Binding var text: String var isFocused: Bool var onSubmit: (Bool) -> Void var onEscape: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(text: $text) }
func makeNSView(context: Context) -> SearchField { let field = SearchField() field.delegate = context.coordinator field.onSubmit = onSubmit field.onEscape = onEscape field.isBordered = false field.drawsBackground = false field.focusRingType = .none field.placeholderString = String(localized: "Search") field.usesSingleLineMode = true field.lineBreakMode = .byTruncatingTail field.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .regular) return field }
func updateNSView(_ nsView: SearchField, context: Context) { if nsView.stringValue != text { nsView.stringValue = text } nsView.onSubmit = onSubmit nsView.onEscape = onEscape
if isFocused, nsView.window?.firstResponder !== nsView.currentEditor() { nsView.window?.makeFirstResponder(nsView) } }
final class Coordinator: NSObject, NSTextFieldDelegate { @Binding var text: String
init(text: Binding<String>) { _text = text }
func controlTextDidChange(_ obj: Notification) { guard let field = obj.object as? NSTextField else { return } text = field.stringValue }
func control( _ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector ) -> Bool { guard let field = control as? SearchField else { return false } switch commandSelector { case #selector(NSResponder.insertNewline(_:)): let isShifted = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false field.onSubmit?(isShifted) return true case #selector(NSResponder.cancelOperation(_:)): field.onEscape?() return true default: return false } } }
final class SearchField: NSTextField { var onSubmit: ((Bool) -> Void)? var onEscape: (() -> Void)?
override func cancelOperation(_ sender: Any?) { onEscape?() }
override func performKeyEquivalent(with event: NSEvent) -> Bool { guard currentEditor() != nil, event.type == .keyDown, event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] || event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command, .shift], event.charactersIgnoringModifiers?.lowercased() == "g" else { return super.performKeyEquivalent(with: event) } let isShifted = event.modifierFlags.contains(.shift) onSubmit?(isShifted) return true }
override func keyDown(with event: NSEvent) { switch event.keyCode { case 36, 76: onSubmit?(event.modifierFlags.contains(.shift)) case 53: onEscape?() default: super.keyDown(with: event) } } }}
private struct GhosttySearchButtonStyle: ButtonStyle { @State private var isHovered = false
func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) .padding(.horizontal, 6) .frame(height: 26) .background( Capsule() .fill(backgroundColor(isPressed: configuration.isPressed)) ) .onHover { hovering in if hovering != isHovered { isHovered = hovering if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } } .onDisappear { if isHovered { isHovered = false NSCursor.pop() } } }
private func backgroundColor(isPressed: Bool) -> Color { if isPressed { return Color.primary.opacity(0.2) } if isHovered { return Color.primary.opacity(0.1) } return Color.clear }}