import SwiftUI import SwiftTerm /// Main view rendered inside each NotchWindow. /// /// Opacity strategy: EVERY element has a solid black background. /// A single `.opacity(notchOpacity)` is applied at the outermost /// level so everything becomes uniformly transparent — no double /// layering, no mismatched areas. struct ContentView: View { @ObservedObject var screen: ScreenContext let orchestrator: NotchOrchestrator @ObservedObject private var settingsController = AppSettingsController.shared @ObservedObject private var screenRegistry = ScreenRegistry.shared @State private var resizeStartSize: CGSize? @State private var resizeStartMouseLocation: CGPoint? private var hoverAnimation: Animation { .interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping) } private var currentShape: NotchShape { screen.notchState == .open ? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14)) : .closed } private var enableShadow: Bool { settingsController.settings.appearance.enableShadow } private var shadowRadius: Double { settingsController.settings.appearance.shadowRadius } private var shadowOpacity: Double { settingsController.settings.appearance.shadowOpacity } private var cornerRadiusScaling: Bool { settingsController.settings.appearance.cornerRadiusScaling } private var notchOpacity: Double { settingsController.settings.appearance.notchOpacity } private var blurRadius: Double { settingsController.settings.appearance.blurRadius } private var hoverSpringResponse: Double { settingsController.settings.animation.hoverSpringResponse } private var hoverSpringDamping: Double { settingsController.settings.animation.hoverSpringDamping } // MARK: - Body var body: some View { notchBody .accessibilityIdentifier("notch.container") .frame( width: screen.notchSize.width, height: screen.notchState == .open ? screen.notchSize.height : screen.closedNotchSize.height, alignment: .top ) .background(.black) .clipShape(currentShape) .overlay(alignment: .top) { Rectangle().fill(.black).frame(height: 1) } .overlay(alignment: .bottomTrailing) { if screen.notchState == .open { resizeHandle } } .shadow( color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear, radius: enableShadow ? shadowRadius : 0 ) // Single opacity control — everything inside is solid black, // so this one modifier makes it all uniformly transparent. .opacity(notchOpacity) .blur(radius: blurRadius) .animation(screen.notchState == .open ? screen.openAnimation : screen.closeAnimation, value: screen.notchState) .animation(sizeAnimation, value: screen.notchSize.width) .animation(sizeAnimation, value: screen.notchSize.height) .onHover { handleHover($0) } .onDisappear { resizeStartSize = nil resizeStartMouseLocation = nil screen.endInteractiveResize() orchestrator.handleHoverChange(false, for: screen.id) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .edgesIgnoringSafeArea(.all) } // MARK: - Content @ViewBuilder private var notchBody: some View { WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in if screen.notchState == .open { openContent(workspace: workspace) .transition(.opacity) } else { closedContent(workspace: workspace) } } } private func closedContent(workspace: WorkspaceController) -> some View { HStack { Spacer() Text(abbreviate(workspace.activeTitle)) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.white.opacity(0.7)) .lineLimit(1) Spacer() } .padding(.horizontal, 8) .background(.black) } private var resizeHandle: some View { ResizeHandleShape() .stroke(.white.opacity(0.32), style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) .frame(width: 16, height: 16) .padding(.trailing, 8) .padding(.bottom, 8) .contentShape(Rectangle().inset(by: -8)) .gesture(resizeGesture) } private var resizeGesture: some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in if resizeStartSize == nil { resizeStartSize = screen.notchSize resizeStartMouseLocation = NSEvent.mouseLocation screen.beginInteractiveResize() } guard let startSize = resizeStartSize, let startMouseLocation = resizeStartMouseLocation else { return } let currentMouseLocation = NSEvent.mouseLocation screen.resizeOpenNotch( to: CGSize( width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2), height: startSize.height + (startMouseLocation.y - currentMouseLocation.y) ) ) } .onEnded { _ in resizeStartSize = nil resizeStartMouseLocation = nil screen.endInteractiveResize() } } private var sizeAnimation: Animation? { guard !screen.isUserResizing, !screen.isPresetResizing else { return nil } return screen.notchState == .open ? screen.openAnimation : screen.closeAnimation } /// Open layout: VStack with toolbar row on top, terminal in the middle, /// tab bar at the bottom. Every section has a black background. private func openContent(workspace: WorkspaceController) -> some View { VStack(spacing: 0) { // Toolbar row — right-aligned, solid black HStack { WorkspaceSwitcherView(screen: screen, orchestrator: orchestrator) Spacer() toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") { if let session = workspace.detachActiveTab() { PopoutWindowController.shared.popout(session: session) } } toolbarButton(icon: "gearshape.fill", help: "Settings") { SettingsWindowController.shared.showSettings() } } .padding(.top, 6) .padding(.leading, 10) .padding(.trailing, 10) .padding(.bottom, 2) .background(.black) // Terminal — fills remaining space if let session = workspace.activeTab { SwiftTermView(session: session) .id(session.id) .padding(.leading, 10) .padding(.trailing, 10) } // Tab bar TabBar(workspace: workspace) } .background(.black) } private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.45)) .padding(4) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(help) .accessibilityIdentifier("notch.toolbar.\(icon)") .help(help) } // MARK: - Hover private func handleHover(_ hovering: Bool) { withAnimation(hoverAnimation) { orchestrator.handleHoverChange(hovering, for: screen.id) } } private func abbreviate(_ title: String) -> String { title.count <= 30 ? title : String(title.prefix(28)) + "…" } } private struct ResizeHandleShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10)) path.move(to: CGPoint(x: rect.maxX - 6, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 6)) path.move(to: CGPoint(x: rect.maxX - 2, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 2)) return path } } private struct WorkspaceScopedView: View { @ObservedObject var screen: ScreenContext @ObservedObject var screenRegistry: ScreenRegistry let content: (WorkspaceController) -> Content init( screen: ScreenContext, screenRegistry: ScreenRegistry, @ViewBuilder content: @escaping (WorkspaceController) -> Content ) { self.screen = screen self.screenRegistry = screenRegistry self.content = content } var body: some View { WorkspaceObservedView(workspace: screenRegistry.workspaceController(for: screen.id), content: content) .id(screen.workspaceID) } } private struct WorkspaceObservedView: View { @ObservedObject var workspace: WorkspaceController let content: (WorkspaceController) -> Content var body: some View { content(workspace) } }