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,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>) {}
}