Refactor and Rename to CommandNotch
73
Downterm/CommandNotch/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
36
Downterm/CommandNotch/CommandNotchApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Downterm/CommandNotch/Components/HotkeyRecorderView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
109
Downterm/CommandNotch/Components/NotchShape.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
78
Downterm/CommandNotch/Components/NotchWindow.swift
Normal 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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Downterm/CommandNotch/Components/SwiftTermView.swift
Normal 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),
|
||||
])
|
||||
}
|
||||
}
|
||||
73
Downterm/CommandNotch/Components/TabBar.swift
Normal 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)) + "…"
|
||||
}
|
||||
}
|
||||
186
Downterm/CommandNotch/ContentView.swift
Normal 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)) + "…"
|
||||
}
|
||||
}
|
||||
85
Downterm/CommandNotch/Extensions/NSScreen+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
241
Downterm/CommandNotch/Managers/HotkeyManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
24
Downterm/CommandNotch/Managers/LaunchAtLoginHelper.swift
Normal 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
|
||||
}
|
||||
}
|
||||
87
Downterm/CommandNotch/Managers/PopoutWindowController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
268
Downterm/CommandNotch/Managers/ScreenManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
92
Downterm/CommandNotch/Models/HotkeyBinding.swift
Normal 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)
|
||||
}
|
||||
173
Downterm/CommandNotch/Models/NotchSettings.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Downterm/CommandNotch/Models/NotchState.swift
Normal 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
|
||||
}
|
||||
96
Downterm/CommandNotch/Models/NotchViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
107
Downterm/CommandNotch/Models/TerminalManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
Downterm/CommandNotch/Models/TerminalSession.swift
Normal 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>) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 945 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 735 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
30
Downterm/CommandNotch/Resources/Info.plist
Normal 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>
|
||||
385
Downterm/CommandNotch/Views/SettingsView.swift
Normal 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("⌘1–9 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)
|
||||
}
|
||||
}
|
||||