native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523import AppKitimport Sharingimport SwiftUIimport UniformTypeIdentifiers
struct TerminalSplitTreeView: View { let tree: SplitTree<GhosttySurfaceView> var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (Operation) -> Void
private static let dragType = UTType(exportedAs: "com.onevcat.prowl.ghosttySurfaceId") private static func dragProvider(for surfaceView: GhosttySurfaceView) -> NSItemProvider { let provider = NSItemProvider() let data = surfaceView.id.uuidString.data(using: .utf8) ?? Data() provider.registerDataRepresentation( forTypeIdentifier: dragType.identifier, visibility: .all ) { completion in completion(data, nil) return nil } return provider }
var body: some View { if let node = tree.visibleNode { SubtreeView( node: node, isRoot: node == tree.root, zoomedNode: tree.zoomed, pinnedSize: pinnedSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, splitDivider: splitDivider, hasNotification: hasNotification, action: action ) .id(node.structuralIdentity) } }
enum Operation { case resize(node: SplitTree<GhosttySurfaceView>.Node, ratio: Double) case drop(payloadId: UUID, destinationId: UUID, zone: DropZone) case equalize case toggleZoom(surfaceId: UUID) }
struct SubtreeView: View { let node: SplitTree<GhosttySurfaceView>.Node var isRoot: Bool = false var zoomedNode: SplitTree<GhosttySurfaceView>.Node? var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (Operation) -> Void
var body: some View { switch node { case .leaf(let leafView): LeafView( surfaceView: leafView, isSplit: !isRoot, isZoomed: zoomedNode == node, isFocused: leafView.id == activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, hasNotification: hasNotification(leafView.id), pinnedSize: pinnedSize, action: action ) case .split(let split): let splitViewDirection: SplitView<SubtreeView, SubtreeView>.Direction = switch split.direction { case .horizontal: .horizontal case .vertical: .vertical } let leftPinned = pinnedSize.map { splitChildSize($0, ratio: split.ratio, direction: split.direction) } let rightPinned = pinnedSize.map { splitChildSize($0, ratio: 1 - split.ratio, direction: split.direction) } SplitView( splitViewDirection, .init( get: { CGFloat(split.ratio) }, set: { action(.resize(node: node, ratio: Double($0))) }), dividerColor: splitDivider.color ?? Color(nsColor: .separatorColor), dividerVisibleSize: splitDivider.width ?? SplitView<SubtreeView, SubtreeView>.defaultVisibleSize, resizeIncrements: .init(width: 1, height: 1), left: { SubtreeView( node: split.left, zoomedNode: zoomedNode, pinnedSize: leftPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, splitDivider: splitDivider, hasNotification: hasNotification, action: action ) }, right: { SubtreeView( node: split.right, zoomedNode: zoomedNode, pinnedSize: rightPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, splitDivider: splitDivider, hasNotification: hasNotification, action: action ) }, onEqualize: { action(.equalize) } ) } }
private func splitChildSize( _ size: CGSize, ratio: Double, direction: SplitTree<GhosttySurfaceView>.Direction ) -> CGSize { switch direction { case .horizontal: CGSize(width: size.width * ratio, height: size.height) case .vertical: CGSize(width: size.width, height: size.height * ratio) } } }
struct LeafView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool var isZoomed: Bool = false var isFocused: Bool = true var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let hasNotification: Bool var pinnedSize: CGSize? let action: (Operation) -> Void
@State private var dropState: DropState = .idle @State private var isHandleHovering = false @State private var isZoomButtonHovering = false @Shared(.settingsFile) private var settingsFile: SettingsFile
private var shouldDim: Bool { isSplit && !isFocused && settingsFile.global.dimUnfocusedSplits && unfocusedSplitOverlay.fill != nil && unfocusedSplitOverlay.opacity > 0 }
var body: some View { GeometryReader { geometry in GhosttyTerminalView(surfaceView: surfaceView, pinnedSize: pinnedSize) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { unfocusedSplitOverlay.fill .opacity(shouldDim ? unfocusedSplitOverlay.opacity : 0) .allowsHitTesting(false) .animation(.easeOut(duration: 0.12), value: shouldDim) } .overlay(alignment: .top) { GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state) } .overlay(alignment: .topTrailing) { if surfaceView.bridge.state.searchNeedle != nil { GhosttySurfaceSearchOverlay(surfaceView: surfaceView) } } .overlay(alignment: .topTrailing) { SurfaceNotificationDot() .padding(6) .opacity(hasNotification ? 1 : 0) .allowsHitTesting(false) .animation(.easeInOut(duration: 0.2), value: hasNotification) } .overlay(alignment: .top) { if isSplit { DragHandle(surfaceView: surfaceView, isHovering: $isHandleHovering) } } .overlay(alignment: .topTrailing) { // The zoomed pane keeps a persistent exit button; other panes only // reveal the zoom affordance while the drag handle (or the button // itself, to survive the cursor hand-off) is hovered. if isSplit, isZoomed || isHandleHovering || isZoomButtonHovering { SplitZoomButton(isZoomed: isZoomed) { action(.toggleZoom(surfaceId: surfaceView.id)) } .onHover { isZoomButtonHovering = $0 } .onDisappear { isZoomButtonHovering = false } .padding(6) } } .background { Color.clear .contentShape(.rect) .onDrop( of: [TerminalSplitTreeView.dragType], delegate: SplitDropDelegate( dropState: $dropState, viewSize: geometry.size, destinationId: surfaceView.id, action: action )) } .overlay { if case .dropping(let zone) = dropState { DropOverlayView(zone: zone, size: geometry.size) .allowsHitTesting(false) } } } }
}
struct SplitZoomButton: View { let isZoomed: Bool let action: () -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings
var body: some View { Button(action: action) { Image( systemName: isZoomed ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" ) .font(.callout.weight(.semibold)) .foregroundStyle(.primary) .padding(5) .background(.regularMaterial, in: .rect(cornerRadius: 6)) } .buttonStyle(.plain) .help( AppShortcuts.helpText( title: isZoomed ? "Exit Split Zoom" : "Zoom Split", commandID: AppShortcuts.CommandID.toggleSplitZoom, in: resolvedKeybindings ) ) .accessibilityLabel(isZoomed ? "Exit split zoom" : "Zoom split") } }
struct DragHandle: View { let surfaceView: GhosttySurfaceView @Binding var isHovering: Bool private let handleHeight: CGFloat = 10
var body: some View { Rectangle() .fill(Color.primary.opacity(isHovering ? 0.12 : 0)) .frame(maxWidth: .infinity) .frame(height: handleHeight) .overlay { if isHovering { Image(systemName: "ellipsis") .font(.system(.callout, weight: .semibold)) .foregroundStyle(.primary.opacity(0.5)) .accessibilityHidden(true) } } .contentShape(.rect) .onHover { hovering in guard hovering != isHovering else { return } isHovering = hovering if hovering { NSCursor.openHand.push() } else { NSCursor.pop() } } .onDisappear { if isHovering { isHovering = false NSCursor.pop() } } .onDrag { TerminalSplitTreeView.dragProvider(for: surfaceView) } } }
enum DropState: Equatable { case idle case dropping(DropZone) }
struct SplitDropDelegate: DropDelegate { @Binding var dropState: DropState let viewSize: CGSize let destinationId: UUID let action: (Operation) -> Void
func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [TerminalSplitTreeView.dragType]) }
func dropEntered(info: DropInfo) { dropState = .dropping(.calculate(at: info.location, in: viewSize)) }
func dropUpdated(info: DropInfo) -> DropProposal? { guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } dropState = .dropping(.calculate(at: info.location, in: viewSize)) return DropProposal(operation: .move) }
func dropExited(info: DropInfo) { dropState = .idle }
func performDrop(info: DropInfo) -> Bool { let zone = DropZone.calculate(at: info.location, in: viewSize) dropState = .idle
let providers = info.itemProviders(for: [TerminalSplitTreeView.dragType]) guard let provider = providers.first else { return false } provider.loadDataRepresentation( forTypeIdentifier: TerminalSplitTreeView.dragType.identifier ) { data, _ in guard let data, let raw = String(data: data, encoding: .utf8), let payloadId = UUID(uuidString: raw) else { return } Task { @MainActor in action(.drop(payloadId: payloadId, destinationId: destinationId, zone: zone)) } } return true } }
enum DropZone: String, Equatable { case top case bottom case left case right
static func calculate(at point: CGPoint, in size: CGSize) -> DropZone { let relX = point.x / size.width let relY = point.y / size.height
let distToLeft = relX let distToRight = 1 - relX let distToTop = relY let distToBottom = 1 - relY
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
if minDist == distToLeft { return .left } if minDist == distToRight { return .right } if minDist == distToTop { return .top } return .bottom } }
struct DropOverlayView: View { let zone: DropZone let size: CGSize
var body: some View { let overlayColor = Color.accentColor.opacity(0.3)
switch zone { case .top: VStack(spacing: 0) { Rectangle() .fill(overlayColor) .frame(height: size.height / 2) Spacer() } case .bottom: VStack(spacing: 0) { Spacer() Rectangle() .fill(overlayColor) .frame(height: size.height / 2) } case .left: HStack(spacing: 0) { Rectangle() .fill(overlayColor) .frame(width: size.width / 2) Spacer() } case .right: HStack(spacing: 0) { Spacer() Rectangle() .fill(overlayColor) .frame(width: size.width / 2) } } } }}
private struct SurfaceNotificationDot: View { var body: some View { Circle() .fill(.orange) .frame(width: 8, height: 8) .overlay { Circle().stroke(.background, lineWidth: 1) } .accessibilityLabel("Unread notifications") }}
// MARK: - Accessibility Container
/// Wraps the SwiftUI split tree in an AppKit view so we can expose an ordered/// list of terminal panes to assistive technologies.struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree<GhosttySurfaceView> var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (TerminalSplitTreeView.Operation) -> Void
func makeNSView(context: Context) -> TerminalSplitAXContainerView { TerminalSplitAXContainerView() }
func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { nsView.update( // Concrete type (not `AnyView`): erasing the type defeats SwiftUI's // diffing, forcing a full re-render of the split tree on every assignment // — e.g. once per terminal notification. The concrete root lets the host // skip unchanged content. rootView: TerminalSplitTreeView( tree: tree, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, splitDivider: splitDivider, hasNotification: hasNotification, action: action ), panes: tree.visibleLeaves() ) }}
@MainActorfinal class TerminalSplitAXContainerView: NSView { private var hostingView: NSHostingView<TerminalSplitTreeView>? private var panes: [GhosttySurfaceView] = [] private var panesLabel = String(localized: "Terminal split: 0 panes") private var lastPaneIDs: [UUID] = []
func update(rootView: TerminalSplitTreeView, panes: [GhosttySurfaceView]) { if let hostingView { hostingView.rootView = rootView } else { let hostingView = NSHostingView(rootView: rootView) hostingView.translatesAutoresizingMaskIntoConstraints = false addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), hostingView.topAnchor.constraint(equalTo: topAnchor), hostingView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) self.hostingView = hostingView }
let newPaneIDs = panes.map(\.id) self.panes = panes if panes.count == 1 { panesLabel = String(localized: "Terminal split: \(panes.count) pane") } else { panesLabel = String(localized: "Terminal split: \(panes.count) panes") }
for (index, pane) in panes.enumerated() { pane.setAccessibilityPaneIndex(index: index + 1, total: panes.count) // Expose panes as direct children of this split group for predictable navigation. pane.setAccessibilityParent(self) }
if newPaneIDs != lastPaneIDs { lastPaneIDs = newPaneIDs // Assistive tech may cache the AX tree; nudge it to re-query when pane membership/order changes. NSAccessibility.post(element: self, notification: .layoutChanged) } }
override func isAccessibilityElement() -> Bool { true }
override func accessibilityRole() -> NSAccessibility.Role? { // AppKit doesn't provide a named constant for this role. NSAccessibility.Role(rawValue: "AXSplitGroup") }
override func accessibilityLabel() -> String? { panesLabel }
override func accessibilityChildren() -> [Any]? { panes }}