Files
downterm/Downterm/CommandNotch/ContentView.swift

190 lines
7.3 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 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<Void, Never>?
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)
}
.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(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height)
.onHover { handleHover($0) }
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in
if isClosing {
hoverTask?.cancel()
} else {
scheduleHoverOpenIfNeeded()
}
}
.onDisappear {
hoverTask?.cancel()
}
.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)
}
/// 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)) + ""
}
}