Refactor and Rename to CommandNotch

This commit is contained in:
2026-03-07 23:14:31 +11:00
parent 2bf1cbad2a
commit 5d161bb214
45 changed files with 76 additions and 69 deletions

View File

@@ -0,0 +1,73 @@
import AppKit
import Combine
/// Application delegate that bootstraps the notch overlay system.
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
NotchSettings.registerDefaults()
NSApp.setActivationPolicy(.accessory)
// Sync the launch-at-login toggle with the actual system state
// in case the user toggled it from System Settings.
UserDefaults.standard.set(LaunchAtLoginHelper.isEnabled, forKey: NotchSettings.Keys.launchAtLogin)
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
}
func applicationWillTerminate(_ notification: Notification) {
ScreenManager.shared.stop()
}
// MARK: - Preference observers
/// Only rebuild windows when the display-count preference changes.
private func observeDisplayPreference() {
UserDefaults.standard.publisher(for: \.showOnAllDisplays)
.removeDuplicates()
.dropFirst()
.sink { _ in
ScreenManager.shared.rebuildWindows()
}
.store(in: &cancellables)
}
/// Reposition (not rebuild) when any sizing preference changes.
private func observeSizePreferences() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { _ in
ScreenManager.shared.repositionWindows()
}
.store(in: &cancellables)
}
/// Live-update terminal font size across all sessions.
private func observeFontSizeChanges() {
UserDefaults.standard.publisher(for: \.terminalFontSize)
.removeDuplicates()
.sink { newSize in
guard newSize > 0 else { return }
TerminalManager.shared.updateAllFontSizes(CGFloat(newSize))
}
.store(in: &cancellables)
}
}
// MARK: - KVO key paths
private extension UserDefaults {
@objc var terminalFontSize: Double {
double(forKey: NotchSettings.Keys.terminalFontSize)
}
@objc var showOnAllDisplays: Bool {
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
/// Main entry point for the CommandNotch application.
/// Provides a MenuBarExtra for quick access to settings and app controls.
/// The notch windows and terminal sessions are managed by AppDelegate + ScreenManager.
@main
struct CommandNotchApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@AppStorage(NotchSettings.Keys.showMenuBarIcon)
private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
var body: some Scene {
MenuBarExtra("CommandNotch", systemImage: "terminal", isInserted: $showMenuBarIcon) {
Button("Toggle Notch") {
ScreenManager.shared.toggleNotchOnActiveScreen()
}
.keyboardShortcut(.return, modifiers: .command)
Divider()
Button("Settings...") {
SettingsWindowController.shared.showSettings()
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Quit CommandNotch") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("Q", modifiers: .command)
}
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
import AppKit
/// A clickable field that records a keyboard shortcut when focused.
/// Click it, press a key combination, and it saves the binding.
struct HotkeyRecorderView: View {
let label: String
@Binding var binding: HotkeyBinding
@State private var isRecording = false
var body: some View {
HStack {
Text(label)
.frame(width: 140, alignment: .leading)
HotkeyRecorderField(binding: $binding, isRecording: $isRecording)
.frame(width: 120, height: 24)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
)
}
}
}
/// NSViewRepresentable that captures key events when focused.
struct HotkeyRecorderField: NSViewRepresentable {
@Binding var binding: HotkeyBinding
@Binding var isRecording: Bool
func makeNSView(context: Context) -> HotkeyNSView {
let view = HotkeyNSView()
view.onKeyRecorded = { newBinding in
binding = newBinding
isRecording = false
}
view.onFocusChanged = { focused in
isRecording = focused
}
return view
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding.displayString
nsView.showRecording = isRecording
nsView.needsDisplay = true
}
}
/// The actual NSView that handles key capture.
class HotkeyNSView: NSView {
var currentLabel: String = ""
var showRecording: Bool = false
var onKeyRecorded: ((HotkeyBinding) -> Void)?
var onFocusChanged: ((Bool) -> Void)?
override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) {
let text = showRecording ? "Press keys…" : currentLabel
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: showRecording ? NSColor.controlAccentColor : NSColor.labelColor
]
let str = NSAttributedString(string: text, attributes: attrs)
let size = str.size()
let point = NSPoint(
x: (bounds.width - size.width) / 2,
y: (bounds.height - size.height) / 2
)
str.draw(at: point)
}
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
}
override func becomeFirstResponder() -> Bool {
onFocusChanged?(true)
return true
}
override func resignFirstResponder() -> Bool {
onFocusChanged?(false)
return true
}
override func keyDown(with event: NSEvent) {
guard showRecording else {
super.keyDown(with: event)
return
}
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags)
// Require at least one modifier key
guard !masked.isEmpty else { return }
let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode)
onKeyRecorded?(binding)
// Resign first responder after recording
window?.makeFirstResponder(nil)
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
/// Custom SwiftUI Shape that draws the characteristic MacBook notch outline.
/// Both top and bottom corner radii are animatable, enabling smooth transitions
/// between the compact closed state and the expanded open state.
///
/// The shape uses quadratic Bezier curves to produce the distinctive
/// top-edge cut-ins of the closed notch, and a clean rounded-bottom
/// rectangle when open (topCornerRadius approaches 0).
struct NotchShape: Shape {
/// Radius applied to the top-left and top-right transitions where the notch
/// curves away from the screen edge. When close to 0, the top corners become
/// sharp and the shape is a rectangle with rounded bottom corners.
var topCornerRadius: CGFloat
/// Radius applied to the bottom-left and bottom-right inner corners.
var bottomCornerRadius: CGFloat
// MARK: - Animatable conformance
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(topCornerRadius, bottomCornerRadius) }
set {
topCornerRadius = newValue.first
bottomCornerRadius = newValue.second
}
}
// MARK: - Path
func path(in rect: CGRect) -> Path {
var path = Path()
let minX = rect.minX
let maxX = rect.maxX
let minY = rect.minY
let maxY = rect.maxY
let width = rect.width
let height = rect.height
let topR = min(topCornerRadius, width / 4, height / 2)
let botR = min(bottomCornerRadius, width / 4, height / 2)
// Start at the top-left corner of the rect
path.move(to: CGPoint(x: minX, y: minY))
if topR > 0.5 {
// Leave the screen edge horizontally, then turn into the side wall.
path.addQuadCurve(
to: CGPoint(x: minX + topR, y: minY + topR),
control: CGPoint(x: minX + topR, y: minY)
)
} else {
path.addLine(to: CGPoint(x: minX, y: minY))
}
// Left edge down to bottom-left corner area
path.addLine(to: CGPoint(x: minX + topR, y: maxY - botR))
// Bottom-left inner corner
path.addQuadCurve(
to: CGPoint(x: minX + topR + botR, y: maxY),
control: CGPoint(x: minX + topR, y: maxY)
)
// Bottom edge across
path.addLine(to: CGPoint(x: maxX - topR - botR, y: maxY))
// Bottom-right inner corner
path.addQuadCurve(
to: CGPoint(x: maxX - topR, y: maxY - botR),
control: CGPoint(x: maxX - topR, y: maxY)
)
// Right edge up to the top-right transition
path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR))
if topR > 0.5 {
// Mirror the top-left transition.
path.addQuadCurve(
to: CGPoint(x: maxX, y: minY),
control: CGPoint(x: maxX - topR, y: minY)
)
} else {
path.addLine(to: CGPoint(x: maxX, y: minY))
}
path.closeSubpath()
return path
}
}
// MARK: - Convenience initializers
extension NotchShape {
/// Closed-state shape with tight corner radii that mimic the physical notch.
static var closed: NotchShape {
NotchShape(topCornerRadius: 6, bottomCornerRadius: 14)
}
/// Open-state shape: no top-edge cut-ins, just rounded bottom corners.
/// topCornerRadius is near-zero so the top becomes effectively flat and the panel
/// extends flush to the top edge of the screen.
static var opened: NotchShape {
NotchShape(topCornerRadius: 0, bottomCornerRadius: 24)
}
}

View File

@@ -0,0 +1,78 @@
import AppKit
import SwiftUI
/// Borderless, floating NSPanel that hosts the notch overlay.
/// When the notch is open the window accepts key status so the
/// terminal can receive keyboard input. On resignKey the
/// `onResignKey` closure fires to close the notch.
class NotchWindow: NSPanel {
var isNotchOpen: Bool = false
/// Called when the window loses key status while the notch is open.
var onResignKey: (() -> Void)?
override init(
contentRect: NSRect,
styleMask style: NSWindow.StyleMask,
backing backingStoreType: NSWindow.BackingStoreType,
defer flag: Bool
) {
// Start as a plain borderless utility panel.
// .nonactivatingPanel is NOT included so the window can
// properly accept key status when the notch opens.
super.init(
contentRect: contentRect,
styleMask: [.borderless, .utilityWindow, .nonactivatingPanel],
backing: .buffered,
defer: flag
)
configureWindow()
}
private func configureWindow() {
isOpaque = false
backgroundColor = .clear
isFloatingPanel = true
level = .mainMenu + 3
titleVisibility = .hidden
titlebarAppearsTransparent = true
hasShadow = false
isMovable = false
isMovableByWindowBackground = false
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary,
.ignoresCycle
]
appearance = NSAppearance(named: .darkAqua)
// Accepts mouse events when the app is NOT active so the
// user can click the closed notch to open it.
acceptsMouseMovedEvents = true
}
// MARK: - Key window management
override var canBecomeKey: Bool { isNotchOpen }
override var canBecomeMain: Bool { false }
override func resignKey() {
super.resignKey()
if isNotchOpen {
// Brief async dispatch so the new key window settles first
// avoids closing when we're just transferring focus between
// our own windows (e.g. opening settings).
DispatchQueue.main.async { [weak self] in
guard let self, self.isNotchOpen else { return }
self.onResignKey?()
}
}
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
import SwiftTerm
/// NSViewRepresentable wrapper that embeds a SwiftTerm TerminalView.
/// The container has a solid black background matching the notch panel.
/// All transparency is handled by the single `.opacity()` on ContentView.
struct SwiftTermView: NSViewRepresentable {
let session: TerminalSession
func makeNSView(context: Context) -> NSView {
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
embedTerminalView(in: container)
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
let tv = session.terminalView
if nsView.subviews.first !== tv {
nsView.subviews.forEach { $0.removeFromSuperview() }
embedTerminalView(in: nsView)
}
DispatchQueue.main.async {
if let window = nsView.window, window.isKeyWindow {
window.makeFirstResponder(tv)
}
}
}
private func embedTerminalView(in container: NSView) {
let tv = session.terminalView
tv.removeFromSuperview()
container.addSubview(tv)
tv.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tv.topAnchor.constraint(equalTo: container.topAnchor),
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
])
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
/// Horizontal tab bar at the bottom of the open notch panel.
/// Solid black background to match the rest of the notch
/// the single `.opacity()` on ContentView handles transparency.
struct TabBar: View {
@ObservedObject var terminalManager: TerminalManager
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
Spacer()
Button {
terminalManager.newTab()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
}
.frame(height: 28)
.background(.black)
}
@ViewBuilder
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
let isActive = index == terminalManager.activeTabIndex
HStack(spacing: 4) {
Text(abbreviateTitle(tab.title))
.font(.system(size: 11))
.lineLimit(1)
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
if isActive && terminalManager.tabs.count > 1 {
Button {
terminalManager.closeTab(at: index)
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white.opacity(0.4))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? Color.white.opacity(0.12) : Color.clear)
)
.contentShape(Rectangle())
.onTapGesture {
terminalManager.switchToTab(at: index)
}
}
private func abbreviateTitle(_ title: String) -> String {
title.count <= 24 ? title : String(title.prefix(22)) + ""
}
}

View File

@@ -0,0 +1,186 @@
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 }
}
}
private func scheduleHoverOpenIfNeeded() {
hoverTask?.cancel()
guard openNotchOnHover,
vm.notchState == .closed,
!vm.isCloseTransitionActive,
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 else { return }
vm.requestOpen?()
}
}
private func abbreviate(_ title: String) -> String {
title.count <= 30 ? title : String(title.prefix(28)) + ""
}
}

View File

@@ -0,0 +1,85 @@
import AppKit
extension NSScreen {
// MARK: - Stable display identifier
/// Returns a stable UUID string for this screen by querying CoreGraphics.
/// Falls back to the localized name if the CG UUID is unavailable.
var displayUUID: String {
guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
return localizedName
}
guard let uuid = CGDisplayCreateUUIDFromDisplayID(screenNumber) else {
return localizedName
}
return CFUUIDCreateString(nil, uuid.takeUnretainedValue()) as String
}
// MARK: - Notch detection
/// `true` when this screen has a physical camera notch (safe area inset at top > 0).
var hasNotch: Bool {
safeAreaInsets.top > 0
}
// MARK: - Closed notch sizing
/// Computes the closed-state notch size for this screen,
/// respecting the user's height mode and custom height preferences.
func closedNotchSize() -> CGSize {
let height = closedNotchHeight()
let width = closedNotchWidth()
return CGSize(width: width, height: height)
}
/// Height of the closed notch bar, determined by the user's chosen mode.
private func closedNotchHeight() -> CGFloat {
let defaults = UserDefaults.standard
if hasNotch {
let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode))
?? .matchRealNotchSize
switch mode {
case .matchRealNotchSize:
return safeAreaInsets.top
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.notchHeight)
}
} else {
let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode))
?? .matchMenuBar
switch mode {
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight)
}
}
}
/// Width of the closed notch.
/// On notch screens, spans from one auxiliary top area to the other.
/// On non-notch screens, uses a reasonable fixed width.
private func closedNotchWidth() -> CGFloat {
if hasNotch {
if let topLeft = auxiliaryTopLeftArea,
let topRight = auxiliaryTopRightArea {
// The notch occupies the space between the two menu bar segments
return frame.width - topLeft.width - topRight.width + 4
}
// Fallback for older API approximate from safe area
return 220
} else {
// Non-notch screens: a compact simulated notch
return 220
}
}
/// The effective menu bar height for this screen.
private func menuBarHeight() -> CGFloat {
return frame.maxY - visibleFrame.maxY
}
}

View File

@@ -0,0 +1,241 @@
import AppKit
import Carbon.HIToolbox
/// Manages global and local hotkeys.
///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Tab-level hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active).
class HotkeyManager {
static let shared = HotkeyManager()
// MARK: - Callbacks
var onToggle: (() -> Void)?
var onNewTab: (() -> Void)?
var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)?
var onDetachTab: (() -> Void)?
var onSwitchToTab: ((Int) -> Void)?
/// Tab-level hotkeys only fire when the notch is open.
var isNotchOpen: Bool = false
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any?
private var defaultsObserver: NSObjectProtocol?
private init() {}
// MARK: - Resolved bindings (live from UserDefaults)
private var toggleBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
}
private var newTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
}
private var closeTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
}
private var nextTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
}
private var prevTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
}
private var detachBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
}
private func binding(for key: String) -> HotkeyBinding? {
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
return HotkeyBinding.fromJSON(json)
}
// MARK: - Start / Stop
func start() {
installCarbonHandler()
registerToggleHotkey()
installLocalMonitor()
observeToggleHotkeyChanges()
}
func stop() {
unregisterToggleHotkey()
removeCarbonHandler()
removeLocalMonitor()
if let obs = defaultsObserver {
NotificationCenter.default.removeObserver(obs)
defaultsObserver = nil
}
}
// MARK: - Carbon global hotkey (toggle)
/// Installs a Carbon event handler that receives `kEventHotKeyPressed`
/// events when a registered hotkey fires works system-wide.
private func installCarbonHandler() {
var eventType = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
// Closure must not capture self uses the singleton accessor instead.
let status = InstallEventHandler(
GetApplicationEventTarget(),
{ (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in
guard let theEvent else { return OSStatus(eventNotHandledErr) }
var hotKeyID = EventHotKeyID()
let err = GetEventParameter(
theEvent,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard err == noErr else { return err }
if hotKeyID.id == 1 {
DispatchQueue.main.async {
HotkeyManager.shared.onToggle?()
}
}
return noErr
},
1,
&eventType,
nil,
&eventHandlerRef
)
if status != noErr {
print("[HotkeyManager] Failed to install Carbon event handler: \(status)")
}
}
private func registerToggleHotkey() {
unregisterToggleHotkey()
let binding = toggleBinding
let carbonMods = carbonModifiers(from: binding.modifiers)
var hotKeyID = EventHotKeyID(
signature: OSType(0x444E5452), // "DNTR"
id: 1
)
let status = RegisterEventHotKey(
UInt32(binding.keyCode),
carbonMods,
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
if status != noErr {
print("[HotkeyManager] Failed to register toggle hotkey: \(status)")
}
}
private func unregisterToggleHotkey() {
if let ref = hotKeyRef {
UnregisterEventHotKey(ref)
hotKeyRef = nil
}
}
private func removeCarbonHandler() {
if let ref = eventHandlerRef {
RemoveEventHandler(ref)
eventHandlerRef = nil
}
}
/// Re-register the toggle hotkey whenever the user changes it in settings.
private func observeToggleHotkeyChanges() {
defaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.registerToggleHotkey()
}
}
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
private func installLocalMonitor() {
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
return self.handleLocalKeyEvent(event) ? nil : event
}
}
private func removeLocalMonitor() {
if let m = localMonitor {
NSEvent.removeMonitor(m)
localMonitor = nil
}
}
/// Handles tab-level hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Tab hotkeys only when the notch is open and focused
guard isNotchOpen else { return false }
if newTabBinding.matches(event) {
onNewTab?()
return true
}
if closeTabBinding.matches(event) {
onCloseTab?()
return true
}
if nextTabBinding.matches(event) {
onNextTab?()
return true
}
if prevTabBinding.matches(event) {
onPreviousTab?()
return true
}
if detachBinding.matches(event) {
onDetachTab?()
return true
}
// Cmd+1 through Cmd+9
if event.modifierFlags.contains(.command) {
let digitKeyCodes: [UInt16: Int] = [
18: 0, 19: 1, 20: 2, 21: 3, 23: 4,
22: 5, 26: 6, 28: 7, 25: 8
]
if let tabIndex = digitKeyCodes[event.keyCode] {
onSwitchToTab?(tabIndex)
return true
}
}
return false
}
// MARK: - Carbon modifier conversion
private func carbonModifiers(from nsModifiers: UInt) -> UInt32 {
var carbon: UInt32 = 0
let flags = NSEvent.ModifierFlags(rawValue: nsModifiers)
if flags.contains(.command) { carbon |= UInt32(cmdKey) }
if flags.contains(.shift) { carbon |= UInt32(shiftKey) }
if flags.contains(.option) { carbon |= UInt32(optionKey) }
if flags.contains(.control) { carbon |= UInt32(controlKey) }
return carbon
}
}

View File

@@ -0,0 +1,24 @@
import ServiceManagement
/// Registers / unregisters the app as a login item using the
/// modern SMAppService API (macOS 13+).
enum LaunchAtLoginHelper {
static func setEnabled(_ enabled: Bool) {
let service = SMAppService.mainApp
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
} catch {
print("[LaunchAtLogin] Failed to \(enabled ? "register" : "unregister"): \(error)")
}
}
/// Reads the current registration state from the system.
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
}

View File

@@ -0,0 +1,87 @@
import AppKit
import SwiftUI
import Combine
/// Manages standalone pop-out terminal windows for detached tabs.
/// Each detached tab gets its own resizable window with the terminal view.
@MainActor
class PopoutWindowController: NSObject, NSWindowDelegate {
static let shared = PopoutWindowController()
/// Tracks open pop-out windows so they aren't released prematurely.
private var windows: [UUID: NSWindow] = [:]
private var sessions: [UUID: TerminalSession] = [:]
private var titleObservers: [UUID: AnyCancellable] = [:]
private override init() {
super.init()
}
/// Creates a new standalone window for the given terminal session.
func popout(session: TerminalSession) {
let windowID = session.id
if let existingWindow = windows[windowID] {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
)
win.title = session.title
win.appearance = NSAppearance(named: .darkAqua)
win.backgroundColor = .black
win.delegate = self
win.isReleasedWhenClosed = false
let hostingView = NSHostingView(
rootView: SwiftTermView(session: session)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.preferredColorScheme(.dark)
)
hostingView.frame = NSRect(origin: .zero, size: win.contentRect(forFrameRect: win.frame).size)
win.contentView = hostingView
win.center()
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
windows[windowID] = win
sessions[windowID] = session
// Update window title when the terminal title changes
titleObservers[windowID] = session.$title
.receive(on: RunLoop.main)
.sink { [weak win] title in win?.title = title }
}
// MARK: - NSWindowDelegate
func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
let entry = windows.first(where: { $0.value === window }),
let terminalView = sessions[entry.key]?.terminalView,
terminalView.window === window else { return }
window.makeFirstResponder(terminalView)
}
func windowWillClose(_ notification: Notification) {
guard let closingWindow = notification.object as? NSWindow else { return }
// Find which session this window belongs to and clean up
if let entry = windows.first(where: { $0.value === closingWindow }) {
sessions[entry.key]?.terminate()
sessions.removeValue(forKey: entry.key)
windows.removeValue(forKey: entry.key)
titleObservers.removeValue(forKey: entry.key)
}
}
}

View File

@@ -0,0 +1,268 @@
import AppKit
import SwiftUI
import Combine
/// Manages one NotchWindow per connected display.
/// Routes all open/close through centralized methods that handle
/// window activation, key status, and first responder assignment
/// so the terminal can receive keyboard input.
@MainActor
class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let focusRetryDelay: TimeInterval = 0.01
private(set) var windows: [String: NotchWindow] = [:]
private(set) var viewModels: [String: NotchViewModel] = [:]
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
private var cancellables = Set<AnyCancellable>()
private init() {}
// MARK: - Lifecycle
func start() {
observeScreenChanges()
rebuildWindows()
setupHotkeys()
}
func stop() {
cleanupAllWindows()
cancellables.removeAll()
HotkeyManager.shared.stop()
}
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hk = HotkeyManager.shared
let tm = TerminalManager.shared
// Callbacks are invoked on the main thread by HotkeyManager.
// MainActor.assumeIsolated lets us safely call @MainActor methods.
hk.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() }
}
hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } }
hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } }
hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } }
hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } }
hk.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hk.onSwitchToTab = { index in
MainActor.assumeIsolated { tm.switchToTab(at: index) }
}
hk.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
let mouseLocation = NSEvent.mouseLocation
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
?? NSScreen.main
guard let screen = targetScreen else { return }
let uuid = screen.displayUUID
// Close any other open notch first
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
if otherVM.notchState == .open {
closeNotch(screenUUID: otherUUID)
}
}
if let vm = viewModels[uuid] {
if vm.notchState == .open {
closeNotch(screenUUID: uuid)
} else {
openNotch(screenUUID: uuid)
}
}
}
// MARK: - Open / Close
func openNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
vm.cancelCloseTransition()
withAnimation(vm.openAnimation) {
vm.open()
}
window.isNotchOpen = true
HotkeyManager.shared.isNotchOpen = true
// Activate the app so the window can become key.
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
focusActiveTerminal(in: screenUUID)
}
func closeNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
vm.beginCloseTransition()
withAnimation(vm.closeAnimation) {
vm.close()
}
window.isNotchOpen = false
HotkeyManager.shared.isNotchOpen = false
}
private func detachActiveTab() {
if let session = TerminalManager.shared.detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
}
}
// MARK: - Window creation
func rebuildWindows() {
cleanupAllWindows()
let screens: [NSScreen]
if showOnAllDisplays {
screens = NSScreen.screens
} else {
screens = [NSScreen.main].compactMap { $0 }
}
for screen in screens {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let uuid = screen.displayUUID
let vm = NotchViewModel(screenUUID: uuid)
let shadowPadding: CGFloat = 20
let openSize = vm.openNotchSize
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
let windowHeight = openSize.height + shadowPadding
let windowRect = NSRect(
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
let window = NotchWindow(
contentRect: windowRect,
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
// Close the notch when the window loses focus
window.onResignKey = { [weak self] in
self?.closeNotch(screenUUID: uuid)
}
// Wire the ViewModel callbacks so ContentView routes through us
vm.requestOpen = { [weak self] in
self?.openNotch(screenUUID: uuid)
}
vm.requestClose = { [weak self] in
self?.closeNotch(screenUUID: uuid)
}
let hostingView = NSHostingView(
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
.preferredColorScheme(.dark)
)
hostingView.frame = NSRect(origin: .zero, size: windowRect.size)
window.contentView = hostingView
window.setFrame(windowRect, display: true)
window.orderFrontRegardless()
windows[uuid] = window
viewModels[uuid] = vm
}
// MARK: - Repositioning
func repositionWindows() {
for (uuid, window) in windows {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue }
guard let vm = viewModels[uuid] else { continue }
vm.refreshClosedSize()
let shadowPadding: CGFloat = 20
let openSize = vm.openNotchSize
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
let windowHeight = openSize.height + shadowPadding
let newFrame = NSRect(
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
window.setFrame(newFrame, display: true)
}
}
// MARK: - Cleanup
private func cleanupAllWindows() {
for (_, window) in windows {
window.orderOut(nil)
window.close()
}
windows.removeAll()
viewModels.removeAll()
}
// MARK: - Screen observation
private func observeScreenChanges() {
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
.store(in: &cancellables)
}
private func handleScreenConfigurationChange() {
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
let knownUUIDs = Set(windows.keys)
if currentUUIDs != knownUUIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
private func focusActiveTerminal(in screenUUID: String, attemptsRemaining: Int = 12) {
guard let window = windows[screenUUID],
let terminalView = TerminalManager.shared.activeTab?.terminalView else { return }
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
}
}
}

View File

@@ -0,0 +1,58 @@
import AppKit
import SwiftUI
/// Singleton controller that manages the settings window.
/// When the settings panel opens, the app becomes a regular app
/// (visible in Dock / Cmd-Tab). When it closes, the app reverts
/// to an accessory (menu-bar-only) app.
class SettingsWindowController: NSObject, NSWindowDelegate {
static let shared = SettingsWindowController()
private var window: NSWindow?
private override init() {
super.init()
}
// MARK: - Show / Hide
func showSettings() {
if let existing = window {
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let settingsView = SettingsView()
let hostingView = NSHostingView(rootView: settingsView)
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
win.title = "CommandNotch Settings"
win.contentView = hostingView
win.center()
win.delegate = self
win.isReleasedWhenClosed = false
// Appear in Dock while settings are open
NSApp.setActivationPolicy(.regular)
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window = win
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
// Revert to accessory (menu-bar-only) mode
NSApp.setActivationPolicy(.accessory)
window = nil
}
}

View File

@@ -0,0 +1,92 @@
import AppKit
import Carbon.HIToolbox
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
/// Stored in UserDefaults as a JSON string.
struct HotkeyBinding: Codable, Equatable {
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
var keyCode: UInt16
/// Checks whether the given NSEvent matches this binding.
func matches(_ event: NSEvent) -> Bool {
let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
return mask.intersection(relevantFlags).rawValue == modifiers
&& event.keyCode == keyCode
}
/// Human-readable label like "" or "T".
var displayString: String {
var parts: [String] = []
let flags = NSEvent.ModifierFlags(rawValue: modifiers)
if flags.contains(.control) { parts.append("") }
if flags.contains(.option) { parts.append("") }
if flags.contains(.shift) { parts.append("") }
if flags.contains(.command) { parts.append("") }
parts.append(keyName)
return parts.joined()
}
private var keyName: String {
switch keyCode {
case 36: return ""
case 48: return ""
case 49: return "Space"
case 51: return ""
case 53: return ""
case 123: return ""
case 124: return ""
case 125: return ""
case 126: return ""
default:
// Try to get the character from the key code
let src = TISCopyCurrentKeyboardInputSource().takeRetainedValue()
let layoutDataRef = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData)
if let layoutDataRef = layoutDataRef {
let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data
var deadKeyState: UInt32 = 0
var length = 0
var chars = [UniChar](repeating: 0, count: 4)
layoutData.withUnsafeBytes { ptr in
let layoutPtr = ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self)
UCKeyTranslate(
layoutPtr,
keyCode,
UInt16(kUCKeyActionDisplay),
0, // no modifiers for the base character
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
4,
&length,
&chars
)
}
if length > 0 {
return String(utf16CodeUnits: chars, count: length).uppercased()
}
}
return "Key\(keyCode)"
}
}
// MARK: - Serialization
func toJSON() -> String {
(try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? "{}"
}
static func fromJSON(_ string: String) -> HotkeyBinding? {
guard let data = string.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(HotkeyBinding.self, from: data)
}
// MARK: - Presets
static let cmdReturn = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 36)
static let cmdT = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 17)
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
}

View File

@@ -0,0 +1,173 @@
import Foundation
/// Central registry of all user-configurable notch settings.
enum NotchSettings {
enum Keys {
// General
static let showOnAllDisplays = "showOnAllDisplays"
static let openNotchOnHover = "openNotchOnHover"
static let minimumHoverDuration = "minimumHoverDuration"
static let showMenuBarIcon = "showMenuBarIcon"
static let launchAtLogin = "launchAtLogin"
// Sizing closed state
static let notchHeight = "notchHeight"
static let nonNotchHeight = "nonNotchHeight"
static let notchHeightMode = "notchHeightMode"
static let nonNotchHeightMode = "nonNotchHeightMode"
// Sizing open state
static let openWidth = "openWidth"
static let openHeight = "openHeight"
// Appearance
static let enableShadow = "enableShadow"
static let shadowRadius = "shadowRadius"
static let shadowOpacity = "shadowOpacity"
static let cornerRadiusScaling = "cornerRadiusScaling"
static let notchOpacity = "notchOpacity"
static let blurRadius = "blurRadius"
// Animation
static let openSpringResponse = "openSpringResponse"
static let openSpringDamping = "openSpringDamping"
static let closeSpringResponse = "closeSpringResponse"
static let closeSpringDamping = "closeSpringDamping"
static let hoverSpringResponse = "hoverSpringResponse"
static let hoverSpringDamping = "hoverSpringDamping"
// Behavior
static let enableGestures = "enableGestures"
static let gestureSensitivity = "gestureSensitivity"
// Terminal
static let terminalFontSize = "terminalFontSize"
static let terminalShell = "terminalShell"
// Hotkeys each stores a HotkeyBinding JSON string
static let hotkeyToggle = "hotkey_toggle"
static let hotkeyNewTab = "hotkey_newTab"
static let hotkeyCloseTab = "hotkey_closeTab"
static let hotkeyNextTab = "hotkey_nextTab"
static let hotkeyPreviousTab = "hotkey_previousTab"
static let hotkeyDetachTab = "hotkey_detachTab"
}
enum Defaults {
static let showOnAllDisplays: Bool = true
static let openNotchOnHover: Bool = true
static let minimumHoverDuration: Double = 0.3
static let showMenuBarIcon: Bool = true
static let launchAtLogin: Bool = false
static let notchHeight: Double = 32
static let nonNotchHeight: Double = 32
static let notchHeightMode: Int = 0
static let nonNotchHeightMode: Int = 1
static let openWidth: Double = 640
static let openHeight: Double = 350
static let enableShadow: Bool = true
static let shadowRadius: Double = 6
static let shadowOpacity: Double = 0.5
static let cornerRadiusScaling: Bool = true
static let notchOpacity: Double = 1.0
static let blurRadius: Double = 0
static let openSpringResponse: Double = 0.42
static let openSpringDamping: Double = 0.8
static let closeSpringResponse: Double = 0.45
static let closeSpringDamping: Double = 1.0
static let hoverSpringResponse: Double = 0.38
static let hoverSpringDamping: Double = 0.8
static let enableGestures: Bool = true
static let gestureSensitivity: Double = 0.5
static let terminalFontSize: Double = 13
static let terminalShell: String = ""
// Default hotkey bindings as JSON
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
static let hotkeyNewTab: String = HotkeyBinding.cmdT.toJSON()
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
}
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
Keys.showOnAllDisplays: Defaults.showOnAllDisplays,
Keys.openNotchOnHover: Defaults.openNotchOnHover,
Keys.minimumHoverDuration: Defaults.minimumHoverDuration,
Keys.showMenuBarIcon: Defaults.showMenuBarIcon,
Keys.launchAtLogin: Defaults.launchAtLogin,
Keys.notchHeight: Defaults.notchHeight,
Keys.nonNotchHeight: Defaults.nonNotchHeight,
Keys.notchHeightMode: Defaults.notchHeightMode,
Keys.nonNotchHeightMode: Defaults.nonNotchHeightMode,
Keys.openWidth: Defaults.openWidth,
Keys.openHeight: Defaults.openHeight,
Keys.enableShadow: Defaults.enableShadow,
Keys.shadowRadius: Defaults.shadowRadius,
Keys.shadowOpacity: Defaults.shadowOpacity,
Keys.cornerRadiusScaling: Defaults.cornerRadiusScaling,
Keys.notchOpacity: Defaults.notchOpacity,
Keys.blurRadius: Defaults.blurRadius,
Keys.openSpringResponse: Defaults.openSpringResponse,
Keys.openSpringDamping: Defaults.openSpringDamping,
Keys.closeSpringResponse: Defaults.closeSpringResponse,
Keys.closeSpringDamping: Defaults.closeSpringDamping,
Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
Keys.enableGestures: Defaults.enableGestures,
Keys.gestureSensitivity: Defaults.gestureSensitivity,
Keys.terminalFontSize: Defaults.terminalFontSize,
Keys.terminalShell: Defaults.terminalShell,
Keys.hotkeyToggle: Defaults.hotkeyToggle,
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
])
}
}
enum NotchHeightMode: Int, CaseIterable, Identifiable {
case matchRealNotchSize = 0
case matchMenuBar = 1
case custom = 2
var id: Int { rawValue }
var label: String {
switch self {
case .matchRealNotchSize: return "Match Notch"
case .matchMenuBar: return "Match Menu Bar"
case .custom: return "Custom"
}
}
}
enum NonNotchHeightMode: Int, CaseIterable, Identifiable {
case matchMenuBar = 1
case custom = 2
var id: Int { rawValue }
var label: String {
switch self {
case .matchMenuBar: return "Match Menu Bar"
case .custom: return "Custom"
}
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
/// Represents the two visual states of the notch overlay.
enum NotchState: String {
/// Compact bar matching the physical notch or menu bar height.
case closed
/// Expanded panel showing content (plain black for now).
case open
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
import Combine
/// Per-screen observable state that drives the notch UI.
@MainActor
class NotchViewModel: ObservableObject {
let screenUUID: String
@Published var notchState: NotchState = .closed
@Published var notchSize: CGSize
@Published var closedNotchSize: CGSize
@Published var isHovering: Bool = false
@Published var isCloseTransitionActive: Bool = false
let terminalManager = TerminalManager.shared
/// Set by ScreenManager routes open/close through proper
/// window activation so the terminal receives keyboard input.
var requestOpen: (() -> Void)?
var requestClose: (() -> Void)?
private var cancellables = Set<AnyCancellable>()
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
private var closeTransitionTask: Task<Void, Never>?
var openAnimation: Animation {
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
}
var closeAnimation: Animation {
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
}
init(screenUUID: String) {
self.screenUUID = screenUUID
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
self.closedNotchSize = closed
self.notchSize = closed
}
func open() {
notchSize = CGSize(width: openWidth, height: openHeight)
notchState = .open
}
func close() {
refreshClosedSize()
notchSize = closedNotchSize
notchState = .closed
}
func refreshClosedSize() {
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
}
var openNotchSize: CGSize {
CGSize(width: openWidth, height: openHeight)
}
var closeInteractionLockDuration: TimeInterval {
max(closeSpringResponse + 0.2, 0.35)
}
func beginCloseTransition() {
closeTransitionTask?.cancel()
isCloseTransitionActive = true
let delay = closeInteractionLockDuration
closeTransitionTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.isCloseTransitionActive = false
self.closeTransitionTask = nil
}
}
func cancelCloseTransition() {
closeTransitionTask?.cancel()
closeTransitionTask = nil
isCloseTransitionActive = false
}
deinit {
closeTransitionTask?.cancel()
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
import Combine
/// Manages multiple terminal tabs. Singleton shared across all screens
/// whichever notch is currently open displays these tabs.
@MainActor
class TerminalManager: ObservableObject {
static let shared = TerminalManager()
@Published var tabs: [TerminalSession] = []
@Published var activeTabIndex: Int = 0
@AppStorage(NotchSettings.Keys.terminalFontSize)
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
private var cancellables = Set<AnyCancellable>()
private init() {
newTab()
}
// MARK: - Active tab
var activeTab: TerminalSession? {
guard tabs.indices.contains(activeTabIndex) else { return nil }
return tabs[activeTabIndex]
}
/// Short title for the closed notch bar the active tab's process name.
var activeTitle: String {
activeTab?.title ?? "shell"
}
// MARK: - Tab operations
func newTab() {
let session = TerminalSession(fontSize: CGFloat(fontSize))
// Forward title changes to trigger view updates in this manager
session.$title
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
tabs.append(session)
activeTabIndex = tabs.count - 1
}
func closeTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
tabs[index].terminate()
tabs.remove(at: index)
// Adjust active index
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
}
func closeActiveTab() {
closeTab(at: activeTabIndex)
}
func switchToTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
activeTabIndex = index
}
func nextTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex + 1) % tabs.count
}
func previousTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
}
/// Removes the tab at the given index and returns the session so it
/// can be hosted in a pop-out window.
func detachTab(at index: Int) -> TerminalSession? {
guard tabs.indices.contains(index) else { return nil }
let session = tabs.remove(at: index)
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
return session
}
func detachActiveTab() -> TerminalSession? {
detachTab(at: activeTabIndex)
}
/// Updates font size on all existing terminal sessions.
func updateAllFontSizes(_ size: CGFloat) {
for tab in tabs {
tab.updateFontSize(size)
}
}
}

View File

@@ -0,0 +1,131 @@
import AppKit
import SwiftTerm
import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
super.init()
terminalView.terminalDelegate = self
// Solid black matches every other element in the notch.
// The single `.opacity(notchOpacity)` on ContentView makes
// everything uniformly transparent.
terminalView.nativeBackgroundColor = .black
terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0)
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
startShell()
}
// MARK: - Shell management
private func startShell() {
let shellPath = resolveShell()
let shellName = (shellPath as NSString).lastPathComponent
let loginExecName = "-\(shellName)"
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
let proc = LocalProcess(delegate: self)
// Launch as a login shell so user startup files initialize PATH/tools.
proc.startProcess(
executable: shellPath,
args: ["-l"],
environment: nil,
execName: loginExecName
)
process = proc
title = shellName
}
private func resolveShell() -> String {
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func terminate() {
process?.terminate()
process = nil
isRunning = false
}
// MARK: - LocalProcessDelegate
nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
Task { @MainActor in self.isRunning = false }
}
nonisolated func dataReceived(slice: ArraySlice<UInt8>) {
let data = slice
Task { @MainActor in self.terminalView.feed(byteArray: data) }
}
nonisolated func getWindowSize() -> winsize {
var ws = winsize()
ws.ws_col = 80
ws.ws_row = 24
return ws
}
// MARK: - TerminalViewDelegate
func send(source: TerminalView, data: ArraySlice<UInt8>) {
process?.send(data: data)
}
func setTerminalTitle(source: TerminalView, title: String) {
self.title = title.isEmpty ? "shell" : title
}
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return }
guard let proc = process else { return }
let fd = proc.childfd
guard fd >= 0 else { return }
var ws = winsize()
ws.ws_col = UInt16(newCols)
ws.ws_row = UInt16(newRows)
_ = ioctl(fd, TIOCSWINSZ, &ws)
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
currentDirectory = directory
}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
func clipboardCopy(source: TerminalView, content: Data) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setData(content, forType: .string)
}
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
}
func bell(source: TerminalView) { NSSound.beep() }
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
}

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>CommandNotch</string>
<key>CFBundleExecutable</key>
<string>CommandNotch</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CommandNotch</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.3</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 CommandNotch. All rights reserved.</string>
</dict>
</plist>

View File

@@ -0,0 +1,385 @@
import SwiftUI
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
NavigationSplitView {
List(SettingsTab.allCases, selection: $selectedTab) { tab in
Label(tab.label, systemImage: tab.icon)
.tag(tab)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
} detail: {
ScrollView {
detailView.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 600, minHeight: 400)
}
@ViewBuilder
private var detailView: some View {
switch selectedTab {
case .general: GeneralSettingsView()
case .appearance: AppearanceSettingsView()
case .animation: AnimationSettingsView()
case .terminal: TerminalSettingsView()
case .hotkeys: HotkeySettingsView()
case .about: AboutSettingsView()
}
}
}
// MARK: - Tabs
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .animation: return "Animation"
case .terminal: return "Terminal"
case .hotkeys: return "Hotkeys"
case .about: return "About"
}
}
var icon: String {
switch self {
case .general: return "gearshape"
case .appearance: return "paintbrush"
case .animation: return "bolt.fill"
case .terminal: return "terminal"
case .hotkeys: return "keyboard"
case .about: return "info.circle"
}
}
}
// MARK: - General
struct GeneralSettingsView: View {
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
@AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: $showOnAllDisplays)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Toggle("Launch at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: $openNotchOnHover)
if openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", minimumHoverDuration))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: $enableGestures)
if enableGestures {
HStack {
Text("Sensitivity")
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", gestureSensitivity))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: $notchHeightMode) {
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $notchHeight, in: 16...64, step: 1)
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
}
}
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: $openWidth, in: 300...1200, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
}
HStack {
Text("Height")
Slider(value: $openHeight, in: 100...600, step: 10)
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Appearance
struct AppearanceSettingsView: View {
@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
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: $enableShadow)
if enableShadow {
HStack {
Text("Radius")
Slider(value: $shadowRadius, in: 0...30, step: 1)
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: $blurRadius, in: 0...20, step: 0.5)
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Animation
struct AnimationSettingsView: View {
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
var body: some View {
Form {
Section("Open Animation") {
springControls(response: $openResponse, damping: $openDamping)
}
Section("Close Animation") {
springControls(response: $closeResponse, damping: $closeDamping)
}
Section("Hover Animation") {
springControls(response: $hoverResponse, damping: $hoverDamping)
}
Section {
Button("Reset to Defaults") {
openResponse = NotchSettings.Defaults.openSpringResponse
openDamping = NotchSettings.Defaults.openSpringDamping
closeResponse = NotchSettings.Defaults.closeSpringResponse
closeDamping = NotchSettings.Defaults.closeSpringDamping
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
}
}
}
// MARK: - Terminal
struct TerminalSettingsView: View {
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: $fontSize, in: 8...28, step: 1)
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
}
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: $shellPath)
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
// MARK: - Hotkeys
struct HotkeySettingsView: View {
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
}
Section {
Text("⌘19 always switch to tab by number.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
toggleBinding = .cmdReturn
newTabBinding = .cmdT
closeTabBinding = .cmdW
nextTabBinding = .cmdShiftRB
prevTabBinding = .cmdShiftLB
detachBinding = .cmdD
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
}
}
}
.formStyle(.grouped)
}
/// Creates a binding that saves to UserDefaults on every change.
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
Binding(
get: { state.wrappedValue },
set: { newValue in
state.wrappedValue = newValue
save(newValue, key: key)
}
)
}
private func save(_ binding: HotkeyBinding, key: String) {
UserDefaults.standard.set(binding.toJSON(), forKey: key)
}
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
guard let json = UserDefaults.standard.string(forKey: key),
let b = HotkeyBinding.fromJSON(json) else { return fallback }
return b
}
}
// MARK: - About
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version 0.3.0")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}