278 lines
9.6 KiB
Swift
278 lines
9.6 KiB
Swift
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<Content: View>: 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<Content: View>: View {
|
|
@ObservedObject var workspace: WorkspaceController
|
|
let content: (WorkspaceController) -> Content
|
|
|
|
var body: some View {
|
|
content(workspace)
|
|
}
|
|
}
|