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 vm: NotchViewModel @ObservedObject var terminalManager: TerminalManager // MARK: - Settings @AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover @AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration @AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow @AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius @AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity @AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling @AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity @AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping @State private var hoverTask: Task? @State private var resizeStartSize: CGSize? @State private var resizeStartMouseLocation: CGPoint? private var hoverAnimation: Animation { .interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping) } private var currentShape: NotchShape { vm.notchState == .open ? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14)) : .closed } // MARK: - Body var body: some View { notchBody .frame( width: vm.notchSize.width, height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height, alignment: .top ) .background(.black) .clipShape(currentShape) .overlay(alignment: .top) { Rectangle().fill(.black).frame(height: 1) } .overlay(alignment: .bottomTrailing) { if vm.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(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState) .animation(sizeAnimation, value: vm.notchSize.width) .animation(sizeAnimation, value: vm.notchSize.height) .onHover { handleHover($0) } .onChange(of: vm.isCloseTransitionActive) { _, isClosing in if isClosing { hoverTask?.cancel() } else { scheduleHoverOpenIfNeeded() } } .onDisappear { hoverTask?.cancel() resizeStartSize = nil resizeStartMouseLocation = nil vm.endInteractiveResize() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .edgesIgnoringSafeArea(.all) } // MARK: - Content @ViewBuilder private var notchBody: some View { if vm.notchState == .open { openContent .transition(.opacity) } else { closedContent } } private var closedContent: some View { HStack { Spacer() Text(abbreviate(terminalManager.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 = vm.notchSize resizeStartMouseLocation = NSEvent.mouseLocation vm.beginInteractiveResize() } guard let startSize = resizeStartSize, let startMouseLocation = resizeStartMouseLocation else { return } let currentMouseLocation = NSEvent.mouseLocation vm.resizeOpenNotch( to: CGSize( width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2), height: startSize.height + (startMouseLocation.y - currentMouseLocation.y) ) ) } .onEnded { _ in resizeStartSize = nil resizeStartMouseLocation = nil vm.endInteractiveResize() } } private var sizeAnimation: Animation? { guard !vm.isUserResizing, !vm.isPresetResizing else { return nil } return vm.notchState == .open ? vm.openAnimation : vm.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 var openContent: some View { VStack(spacing: 0) { // Toolbar row — right-aligned, solid black HStack { Spacer() toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") { if let session = terminalManager.detachActiveTab() { PopoutWindowController.shared.popout(session: session) } } toolbarButton(icon: "gearshape.fill", help: "Settings") { SettingsWindowController.shared.showSettings() } } .padding(.top, 6) .padding(.trailing, 10) .padding(.bottom, 2) .background(.black) // Terminal — fills remaining space if let session = terminalManager.activeTab { SwiftTermView(session: session) .id(session.id) .padding(.leading, 10) .padding(.trailing, 10) } // Tab bar TabBar(terminalManager: terminalManager) } .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) .help(help) } // MARK: - Hover private func handleHover(_ hovering: Bool) { if hovering { withAnimation(hoverAnimation) { vm.isHovering = true } scheduleHoverOpenIfNeeded() } else { hoverTask?.cancel() withAnimation(hoverAnimation) { vm.isHovering = false } vm.clearHoverOpenSuppression() } } private func scheduleHoverOpenIfNeeded() { hoverTask?.cancel() guard openNotchOnHover, vm.notchState == .closed, !vm.isCloseTransitionActive, !vm.suppressHoverOpenUntilHoverExit, vm.isHovering else { return } hoverTask = Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000)) guard !Task.isCancelled, vm.isHovering, vm.notchState == .closed, !vm.isCloseTransitionActive, !vm.suppressHoverOpenUntilHoverExit else { return } vm.requestOpen?() } } 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 } }