Compare commits

5 Commits

16 changed files with 834 additions and 59 deletions

1
.gitignore vendored
View File

@@ -80,5 +80,6 @@ dist/
build/ build/
**/Release* **/Release*
CommandNotch 20*
**/.DS_Store **/.DS_Store

View File

@@ -13,6 +13,7 @@
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; }; 26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; };
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; }; 295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; };
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; }; 37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; };
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */; };
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */; }; 4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */; };
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */; }; 4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */; };
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */; }; 5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */; };
@@ -50,6 +51,7 @@
5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
665CFC051CF185B71199608D /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 665CFC051CF185B71199608D /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; }; 7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
9547A79F60E46F4521A70674 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.entitlements; sourceTree = "<group>"; }; 9547A79F60E46F4521A70674 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.entitlements; sourceTree = "<group>"; };
AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; }; AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; }; BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
@@ -109,6 +111,7 @@
589421631401C819FE1A7BA9 /* NotchViewModel.swift */, 589421631401C819FE1A7BA9 /* NotchViewModel.swift */,
BA6843B571B41986DE386F5F /* TerminalManager.swift */, BA6843B571B41986DE386F5F /* TerminalManager.swift */,
7B598809B19C892470DE7268 /* TerminalSession.swift */, 7B598809B19C892470DE7268 /* TerminalSession.swift */,
3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -255,6 +258,7 @@
7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */, 7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */,
E9A064422790735E033E534F /* TerminalManager.swift in Sources */, E9A064422790735E033E534F /* TerminalManager.swift in Sources */,
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */, 37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */,
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
observeDisplayPreference() observeDisplayPreference()
observeSizePreferences() observeSizePreferences()
observeFontSizeChanges() observeFontSizeChanges()
observeTerminalThemeChanges()
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
@@ -58,6 +59,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
/// Live-update terminal colors across all sessions.
private func observeTerminalThemeChanges() {
UserDefaults.standard.publisher(for: \.terminalTheme)
.removeDuplicates()
.sink { newTheme in
TerminalManager.shared.updateAllThemes(TerminalTheme.resolve(newTheme))
}
.store(in: &cancellables)
}
} }
// MARK: - KVO key paths // MARK: - KVO key paths
@@ -67,6 +78,10 @@ private extension UserDefaults {
double(forKey: NotchSettings.Keys.terminalFontSize) double(forKey: NotchSettings.Keys.terminalFontSize)
} }
@objc var terminalTheme: String {
string(forKey: NotchSettings.Keys.terminalTheme) ?? NotchSettings.Defaults.terminalTheme
}
@objc var showOnAllDisplays: Bool { @objc var showOnAllDisplays: Bool {
bool(forKey: NotchSettings.Keys.showOnAllDisplays) bool(forKey: NotchSettings.Keys.showOnAllDisplays)
} }

View File

@@ -28,6 +28,36 @@ struct HotkeyRecorderView: View {
} }
} }
struct OptionalHotkeyRecorderView: 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)
OptionalHotkeyRecorderField(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)
)
Button("Clear") {
binding = nil
}
.disabled(binding == nil)
}
}
}
/// NSViewRepresentable that captures key events when focused. /// NSViewRepresentable that captures key events when focused.
struct HotkeyRecorderField: NSViewRepresentable { struct HotkeyRecorderField: NSViewRepresentable {
@Binding var binding: HotkeyBinding @Binding var binding: HotkeyBinding
@@ -52,6 +82,29 @@ struct HotkeyRecorderField: NSViewRepresentable {
} }
} }
struct OptionalHotkeyRecorderField: 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 ?? "Not set"
nsView.showRecording = isRecording
nsView.needsDisplay = true
}
}
/// The actual NSView that handles key capture. /// The actual NSView that handles key capture.
class HotkeyNSView: NSView { class HotkeyNSView: NSView {
var currentLabel: String = "" var currentLabel: String = ""
@@ -59,21 +112,32 @@ class HotkeyNSView: NSView {
var onKeyRecorded: ((HotkeyBinding) -> Void)? var onKeyRecorded: ((HotkeyBinding) -> Void)?
var onFocusChanged: ((Bool) -> Void)? var onFocusChanged: ((Bool) -> Void)?
private let label: NSTextField = {
let field = NSTextField(labelWithString: "")
field.alignment = .center
field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
field.translatesAutoresizingMaskIntoConstraints = false
field.backgroundColor = .clear
field.isBezeled = false
field.lineBreakMode = .byTruncatingTail
return field
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupLabel()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLabel()
}
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) { override func layout() {
let text = showRecording ? "Press keys…" : currentLabel super.layout()
let attrs: [NSAttributedString.Key: Any] = [ updateLabelAppearance()
.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) { override func mouseDown(with event: NSEvent) {
@@ -108,4 +172,19 @@ class HotkeyNSView: NSView {
// Resign first responder after recording // Resign first responder after recording
window?.makeFirstResponder(nil) window?.makeFirstResponder(nil)
} }
private func setupLabel() {
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
label.centerYAnchor.constraint(equalTo: centerYAnchor)
])
updateLabelAppearance()
}
private func updateLabelAppearance() {
label.stringValue = showRecording ? "Press keys..." : currentLabel
label.textColor = showRecording ? .controlAccentColor : .labelColor
}
} }

View File

@@ -28,6 +28,8 @@ struct ContentView: View {
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
@State private var hoverTask: Task<Void, Never>? @State private var hoverTask: Task<Void, Never>?
@State private var resizeStartSize: CGSize?
@State private var resizeStartMouseLocation: CGPoint?
private var hoverAnimation: Animation { private var hoverAnimation: Animation {
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping) .interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
@@ -53,6 +55,11 @@ struct ContentView: View {
.overlay(alignment: .top) { .overlay(alignment: .top) {
Rectangle().fill(.black).frame(height: 1) Rectangle().fill(.black).frame(height: 1)
} }
.overlay(alignment: .bottomTrailing) {
if vm.notchState == .open {
resizeHandle
}
}
.shadow( .shadow(
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear, color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
radius: enableShadow ? shadowRadius : 0 radius: enableShadow ? shadowRadius : 0
@@ -62,8 +69,8 @@ struct ContentView: View {
.opacity(notchOpacity) .opacity(notchOpacity)
.blur(radius: blurRadius) .blur(radius: blurRadius)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState) .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width) .animation(sizeAnimation, value: vm.notchSize.width)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height) .animation(sizeAnimation, value: vm.notchSize.height)
.onHover { handleHover($0) } .onHover { handleHover($0) }
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in .onChange(of: vm.isCloseTransitionActive) { _, isClosing in
if isClosing { if isClosing {
@@ -74,6 +81,9 @@ struct ContentView: View {
} }
.onDisappear { .onDisappear {
hoverTask?.cancel() hoverTask?.cancel()
resizeStartSize = nil
resizeStartMouseLocation = nil
vm.endInteractiveResize()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
@@ -104,6 +114,47 @@ struct ContentView: View {
.background(.black) .background(.black)
} }
private var resizeHandle: some View {
ResizeHandleShape()
.stroke(.white.opacity(0.32), style: StrokeStyle(lineWidth: 1.2, lineCap: .round))
.frame(width: 16, height: 16)
.padding(.trailing, 8)
.padding(.bottom, 8)
.contentShape(Rectangle().inset(by: -8))
.gesture(resizeGesture)
}
private var resizeGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if resizeStartSize == nil {
resizeStartSize = vm.notchSize
resizeStartMouseLocation = NSEvent.mouseLocation
vm.beginInteractiveResize()
}
guard let startSize = resizeStartSize,
let startMouseLocation = resizeStartMouseLocation else { return }
let currentMouseLocation = NSEvent.mouseLocation
vm.resizeOpenNotch(
to: CGSize(
width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2),
height: startSize.height + (startMouseLocation.y - currentMouseLocation.y)
)
)
}
.onEnded { _ in
resizeStartSize = nil
resizeStartMouseLocation = nil
vm.endInteractiveResize()
}
}
private var sizeAnimation: Animation? {
guard !vm.isUserResizing, !vm.isPresetResizing else { return nil }
return vm.notchState == .open ? vm.openAnimation : vm.closeAnimation
}
/// Open layout: VStack with toolbar row on top, terminal in the middle, /// Open layout: VStack with toolbar row on top, terminal in the middle,
/// tab bar at the bottom. Every section has a black background. /// tab bar at the bottom. Every section has a black background.
private var openContent: some View { private var openContent: some View {
@@ -160,6 +211,7 @@ struct ContentView: View {
} else { } else {
hoverTask?.cancel() hoverTask?.cancel()
withAnimation(hoverAnimation) { vm.isHovering = false } withAnimation(hoverAnimation) { vm.isHovering = false }
vm.clearHoverOpenSuppression()
} }
} }
@@ -168,6 +220,7 @@ struct ContentView: View {
guard openNotchOnHover, guard openNotchOnHover,
vm.notchState == .closed, vm.notchState == .closed,
!vm.isCloseTransitionActive, !vm.isCloseTransitionActive,
!vm.suppressHoverOpenUntilHoverExit,
vm.isHovering else { return } vm.isHovering else { return }
hoverTask = Task { @MainActor in hoverTask = Task { @MainActor in
@@ -175,7 +228,8 @@ struct ContentView: View {
guard !Task.isCancelled, guard !Task.isCancelled,
vm.isHovering, vm.isHovering,
vm.notchState == .closed, vm.notchState == .closed,
!vm.isCloseTransitionActive else { return } !vm.isCloseTransitionActive,
!vm.suppressHoverOpenUntilHoverExit else { return }
vm.requestOpen?() vm.requestOpen?()
} }
} }
@@ -184,3 +238,16 @@ struct ContentView: View {
title.count <= 30 ? title : String(title.prefix(28)) + "" title.count <= 30 ? title : String(title.prefix(28)) + ""
} }
} }
private struct ResizeHandleShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10))
path.move(to: CGPoint(x: rect.maxX - 6, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 6))
path.move(to: CGPoint(x: rect.maxX - 2, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 2))
return path
}
}

View File

@@ -18,6 +18,7 @@ class HotkeyManager {
var onNextTab: (() -> Void)? var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)? var onPreviousTab: (() -> Void)?
var onDetachTab: (() -> Void)? var onDetachTab: (() -> Void)?
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
var onSwitchToTab: ((Int) -> Void)? var onSwitchToTab: ((Int) -> Void)?
/// Tab-level hotkeys only fire when the notch is open. /// Tab-level hotkeys only fire when the notch is open.
@@ -50,6 +51,9 @@ class HotkeyManager {
private var detachBinding: HotkeyBinding { private var detachBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
} }
private var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.load()
}
private func binding(for key: String) -> HotkeyBinding? { private func binding(for key: String) -> HotkeyBinding? {
guard let json = UserDefaults.standard.string(forKey: key) else { return nil } guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
@@ -211,6 +215,13 @@ class HotkeyManager {
onDetachTab?() onDetachTab?()
return true return true
} }
for preset in sizePresets {
guard let binding = preset.hotkey else { continue }
if binding.matches(event) {
onApplySizePreset?(preset)
return true
}
}
// Cmd+1 through Cmd+9 // Cmd+1 through Cmd+9
if event.modifierFlags.contains(.command) { if event.modifierFlags.contains(.command) {

View File

@@ -11,9 +11,11 @@ class ScreenManager: ObservableObject {
static let shared = ScreenManager() static let shared = ScreenManager()
private let focusRetryDelay: TimeInterval = 0.01 private let focusRetryDelay: TimeInterval = 0.01
private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0
private(set) var windows: [String: NotchWindow] = [:] private(set) var windows: [String: NotchWindow] = [:]
private(set) var viewModels: [String: NotchViewModel] = [:] private(set) var viewModels: [String: NotchViewModel] = [:]
private var presetResizeTimers: [String: Timer] = [:]
@AppStorage(NotchSettings.Keys.showOnAllDisplays) @AppStorage(NotchSettings.Keys.showOnAllDisplays)
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
@@ -54,6 +56,9 @@ class ScreenManager: ObservableObject {
hk.onDetachTab = { [weak self] in hk.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() } MainActor.assumeIsolated { self?.detachActiveTab() }
} }
hk.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hk.onSwitchToTab = { index in hk.onSwitchToTab = { index in
MainActor.assumeIsolated { tm.switchToTab(at: index) } MainActor.assumeIsolated { tm.switchToTab(at: index) }
} }
@@ -130,6 +135,18 @@ class ScreenManager: ObservableObject {
} }
} }
func applySizePreset(_ preset: TerminalSizePreset) {
guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else {
UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth)
UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight)
return
}
let startSize = vm.notchSize
let targetSize = vm.setStoredOpenSize(preset.size)
animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration)
}
// MARK: - Window creation // MARK: - Window creation
func rebuildWindows() { func rebuildWindows() {
@@ -149,21 +166,10 @@ class ScreenManager: ObservableObject {
private func createWindow(for screen: NSScreen) { private func createWindow(for screen: NSScreen) {
let uuid = screen.displayUUID let uuid = screen.displayUUID
let vm = NotchViewModel(screenUUID: uuid) let vm = NotchViewModel(screenUUID: uuid)
let initialContentSize = vm.openNotchSize
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( let window = NotchWindow(
contentRect: windowRect, contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)),
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow], styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered, backing: .buffered,
defer: false defer: false
@@ -181,19 +187,29 @@ class ScreenManager: ObservableObject {
vm.requestClose = { [weak self] in vm.requestClose = { [weak self] in
self?.closeNotch(screenUUID: uuid) self?.closeNotch(screenUUID: uuid)
} }
vm.requestWindowResize = { [weak self] in
self?.updateWindowFrame(for: uuid, centerHorizontally: true)
}
let hostingView = NSHostingView( let hostingView = NSHostingView(
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
) )
hostingView.frame = NSRect(origin: .zero, size: windowRect.size) let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size))
window.contentView = hostingView containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
window.setFrame(windowRect, display: true) hostingView.frame = containerView.bounds
window.orderFrontRegardless() hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
window.contentView = containerView
windows[uuid] = window windows[uuid] = window
viewModels[uuid] = vm viewModels[uuid] = vm
updateWindowFrame(for: uuid, centerHorizontally: true)
window.orderFrontRegardless()
} }
// MARK: - Repositioning // MARK: - Repositioning
@@ -205,24 +221,144 @@ class ScreenManager: ObservableObject {
vm.refreshClosedSize() vm.refreshClosedSize()
let shadowPadding: CGFloat = 20 updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true)
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)
} }
} }
private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally)
}
private func updateWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool = false
) {
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: nil
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
private func targetWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool,
contentSize: CGSize?
) -> NSRect {
guard let vm = viewModels[screenUUID] else { return window.frame }
let shadowPadding: CGFloat = 20
let openSize = contentSize ?? vm.openNotchSize
let windowWidth = openSize.width + 40
let windowHeight = openSize.height + shadowPadding
let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2
let x: CGFloat = centerHorizontally || vm.notchState == .closed
? centeredX
: min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth)
return NSRect(
x: x,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
private func animatePresetResize(
for screenUUID: String,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenUUID)
guard let vm = viewModels[screenUUID] else { return }
guard startSize != targetSize else {
vm.notchSize = targetSize
updateWindowFrame(for: screenUUID, centerHorizontally: true)
return
}
vm.isPresetResizing = true
let startTime = CACurrentMediaTime()
let duration = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self, let vm = self.viewModels[screenUUID] else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / duration)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
vm.notchSize = size
self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true)
if progress >= 1 {
vm.notchSize = targetSize
vm.isPresetResizing = false
self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenUUID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenUUID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
private func cancelPresetResize(for screenUUID: String) {
presetResizeTimers[screenUUID]?.invalidate()
presetResizeTimers[screenUUID] = nil
viewModels[screenUUID]?.isPresetResizing = false
}
private func updateWindowFrame(
for screenUUID: String,
contentSize: CGSize,
centerHorizontally: Bool = false
) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: contentSize
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
// MARK: - Cleanup // MARK: - Cleanup
private func cleanupAllWindows() { private func cleanupAllWindows() {
for (_, timer) in presetResizeTimers {
timer.invalidate()
}
presetResizeTimers.removeAll()
for (_, window) in windows { for (_, window) in windows {
window.orderOut(nil) window.orderOut(nil)
window.close() window.close()

View File

@@ -3,7 +3,7 @@ import Carbon.HIToolbox
/// Serializable representation of a keyboard shortcut (modifier flags + key code). /// Serializable representation of a keyboard shortcut (modifier flags + key code).
/// Stored in UserDefaults as a JSON string. /// Stored in UserDefaults as a JSON string.
struct HotkeyBinding: Codable, Equatable { struct HotkeyBinding: Codable, Equatable, Hashable {
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
var keyCode: UInt16 var keyCode: UInt16
@@ -89,4 +89,25 @@ struct HotkeyBinding: Codable, Equatable {
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ] 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 cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
guard let keyCode = keyCode(forDigit: digit) else { return nil }
return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode)
}
static func keyCode(forDigit digit: Int) -> UInt16? {
switch digit {
case 0: return 29
case 1: return 18
case 2: return 19
case 3: return 20
case 4: return 21
case 5: return 23
case 6: return 22
case 7: return 26
case 8: return 28
case 9: return 25
default: return nil
}
}
} }

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import AppKit
/// Central registry of all user-configurable notch settings. /// Central registry of all user-configurable notch settings.
enum NotchSettings { enum NotchSettings {
@@ -36,6 +37,7 @@ enum NotchSettings {
static let closeSpringDamping = "closeSpringDamping" static let closeSpringDamping = "closeSpringDamping"
static let hoverSpringResponse = "hoverSpringResponse" static let hoverSpringResponse = "hoverSpringResponse"
static let hoverSpringDamping = "hoverSpringDamping" static let hoverSpringDamping = "hoverSpringDamping"
static let resizeAnimationDuration = "resizeAnimationDuration"
// Behavior // Behavior
static let enableGestures = "enableGestures" static let enableGestures = "enableGestures"
@@ -44,6 +46,8 @@ enum NotchSettings {
// Terminal // Terminal
static let terminalFontSize = "terminalFontSize" static let terminalFontSize = "terminalFontSize"
static let terminalShell = "terminalShell" static let terminalShell = "terminalShell"
static let terminalTheme = "terminalTheme"
static let terminalSizePresets = "terminalSizePresets"
// Hotkeys each stores a HotkeyBinding JSON string // Hotkeys each stores a HotkeyBinding JSON string
static let hotkeyToggle = "hotkey_toggle" static let hotkeyToggle = "hotkey_toggle"
@@ -82,12 +86,15 @@ enum NotchSettings {
static let closeSpringDamping: Double = 1.0 static let closeSpringDamping: Double = 1.0
static let hoverSpringResponse: Double = 0.38 static let hoverSpringResponse: Double = 0.38
static let hoverSpringDamping: Double = 0.8 static let hoverSpringDamping: Double = 0.8
static let resizeAnimationDuration: Double = 0.42
static let enableGestures: Bool = true static let enableGestures: Bool = true
static let gestureSensitivity: Double = 0.5 static let gestureSensitivity: Double = 0.5
static let terminalFontSize: Double = 13 static let terminalFontSize: Double = 13
static let terminalShell: String = "" static let terminalShell: String = ""
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON()
// Default hotkey bindings as JSON // Default hotkey bindings as JSON
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON() static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
@@ -127,12 +134,15 @@ enum NotchSettings {
Keys.closeSpringDamping: Defaults.closeSpringDamping, Keys.closeSpringDamping: Defaults.closeSpringDamping,
Keys.hoverSpringResponse: Defaults.hoverSpringResponse, Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
Keys.hoverSpringDamping: Defaults.hoverSpringDamping, Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
Keys.resizeAnimationDuration: Defaults.resizeAnimationDuration,
Keys.enableGestures: Defaults.enableGestures, Keys.enableGestures: Defaults.enableGestures,
Keys.gestureSensitivity: Defaults.gestureSensitivity, Keys.gestureSensitivity: Defaults.gestureSensitivity,
Keys.terminalFontSize: Defaults.terminalFontSize, Keys.terminalFontSize: Defaults.terminalFontSize,
Keys.terminalShell: Defaults.terminalShell, Keys.terminalShell: Defaults.terminalShell,
Keys.terminalTheme: Defaults.terminalTheme,
Keys.terminalSizePresets: Defaults.terminalSizePresets,
Keys.hotkeyToggle: Defaults.hotkeyToggle, Keys.hotkeyToggle: Defaults.hotkeyToggle,
Keys.hotkeyNewTab: Defaults.hotkeyNewTab, Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
@@ -171,3 +181,82 @@ enum NonNotchHeightMode: Int, CaseIterable, Identifiable {
} }
} }
} }
struct TerminalSizePreset: Codable, Equatable, Identifiable {
var id: UUID
var name: String
var width: Double
var height: Double
var hotkey: HotkeyBinding?
init(
id: UUID = UUID(),
name: String,
width: Double,
height: Double,
hotkey: HotkeyBinding? = nil
) {
self.id = id
self.name = name
self.width = width
self.height = height
self.hotkey = hotkey
}
var size: CGSize {
CGSize(width: width, height: height)
}
}
enum TerminalSizePresetStore {
static func load() -> [TerminalSizePreset] {
let defaults = UserDefaults.standard
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
let data = json.data(using: .utf8),
let presets = try? JSONDecoder().decode([TerminalSizePreset].self, from: data) else {
return defaultPresets()
}
return presets
}
static func save(_ presets: [TerminalSizePreset]) {
guard let data = try? JSONEncoder().encode(presets),
let json = String(data: data, encoding: .utf8) else { return }
UserDefaults.standard.set(json, forKey: NotchSettings.Keys.terminalSizePresets)
}
static func reset() {
save(defaultPresets())
}
static func loadDefaults() -> [TerminalSizePreset] {
defaultPresets()
}
static func defaultPresetsJSON() -> String {
guard let data = try? JSONEncoder().encode(defaultPresets()),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
}
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
let used = Set(presets.compactMap(\.hotkey))
for digit in 1...9 {
guard let candidate = HotkeyBinding.cmdShiftDigit(digit) else { continue }
if !used.contains(candidate) {
return candidate
}
}
return nil
}
private static func defaultPresets() -> [TerminalSizePreset] {
[
TerminalSizePreset(name: "Compact", width: 480, height: 220, hotkey: HotkeyBinding.cmdShiftDigit(1)),
TerminalSizePreset(name: "Default", width: 640, height: 350, hotkey: HotkeyBinding.cmdShiftDigit(2)),
TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)),
]
}
}

View File

@@ -4,6 +4,10 @@ import Combine
/// Per-screen observable state that drives the notch UI. /// Per-screen observable state that drives the notch UI.
@MainActor @MainActor
class NotchViewModel: ObservableObject { class NotchViewModel: ObservableObject {
private static let minimumOpenWidth: CGFloat = 320
private static let minimumOpenHeight: CGFloat = 140
private static let windowHorizontalPadding: CGFloat = 40
private static let windowVerticalPadding: CGFloat = 20
let screenUUID: String let screenUUID: String
@@ -12,6 +16,9 @@ class NotchViewModel: ObservableObject {
@Published var closedNotchSize: CGSize @Published var closedNotchSize: CGSize
@Published var isHovering: Bool = false @Published var isHovering: Bool = false
@Published var isCloseTransitionActive: Bool = false @Published var isCloseTransitionActive: Bool = false
@Published var suppressHoverOpenUntilHoverExit: Bool = false
@Published var isUserResizing: Bool = false
@Published var isPresetResizing: Bool = false
let terminalManager = TerminalManager.shared let terminalManager = TerminalManager.shared
@@ -19,6 +26,7 @@ class NotchViewModel: ObservableObject {
/// window activation so the terminal receives keyboard input. /// window activation so the terminal receives keyboard input.
var requestOpen: (() -> Void)? var requestOpen: (() -> Void)?
var requestClose: (() -> Void)? var requestClose: (() -> Void)?
var requestWindowResize: (() -> Void)?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -29,6 +37,7 @@ class NotchViewModel: ObservableObject {
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping @AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeAnimationDurationSetting = NotchSettings.Defaults.resizeAnimationDuration
private var closeTransitionTask: Task<Void, Never>? private var closeTransitionTask: Task<Void, Never>?
@@ -38,6 +47,9 @@ class NotchViewModel: ObservableObject {
var closeAnimation: Animation { var closeAnimation: Animation {
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping) .spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
} }
var openAnimationDuration: TimeInterval {
max(0.05, resizeAnimationDurationSetting)
}
init(screenUUID: String) { init(screenUUID: String) {
self.screenUUID = screenUUID self.screenUUID = screenUUID
@@ -48,7 +60,10 @@ class NotchViewModel: ObservableObject {
} }
func open() { func open() {
notchSize = CGSize(width: openWidth, height: openHeight) let size = openNotchSize
openWidth = size.width
openHeight = size.height
notchSize = size
notchState = .open notchState = .open
} }
@@ -64,7 +79,64 @@ class NotchViewModel: ObservableObject {
} }
var openNotchSize: CGSize { var openNotchSize: CGSize {
CGSize(width: openWidth, height: openHeight) clampedOpenSize(CGSize(width: openWidth, height: openHeight))
}
func beginInteractiveResize() {
isUserResizing = true
}
func resizeOpenNotch(to proposedSize: CGSize) {
setOpenSize(proposedSize, notifyWindowResize: true)
}
func endInteractiveResize() {
isUserResizing = false
}
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
}
@discardableResult
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
let clampedSize = clampedOpenSize(proposedSize)
openWidth = clampedSize.width
openHeight = clampedSize.height
return clampedSize
}
@discardableResult
func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
let clampedSize = setStoredOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
if notifyWindowResize {
requestWindowResize?()
}
return clampedSize
}
private func clampedOpenSize(_ size: CGSize) -> CGSize {
CGSize(
width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth),
height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight)
)
}
private var maximumAllowedWidth: CGFloat {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
return Self.minimumOpenWidth
}
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
}
private var maximumAllowedHeight: CGFloat {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
return Self.minimumOpenHeight
}
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
} }
var closeInteractionLockDuration: TimeInterval { var closeInteractionLockDuration: TimeInterval {
@@ -74,6 +146,9 @@ class NotchViewModel: ObservableObject {
func beginCloseTransition() { func beginCloseTransition() {
closeTransitionTask?.cancel() closeTransitionTask?.cancel()
isCloseTransitionActive = true isCloseTransitionActive = true
if isHovering {
suppressHoverOpenUntilHoverExit = true
}
let delay = closeInteractionLockDuration let delay = closeInteractionLockDuration
closeTransitionTask = Task { @MainActor [weak self] in closeTransitionTask = Task { @MainActor [weak self] in
@@ -90,7 +165,17 @@ class NotchViewModel: ObservableObject {
isCloseTransitionActive = false isCloseTransitionActive = false
} }
func clearHoverOpenSuppression() {
suppressHoverOpenUntilHoverExit = false
}
deinit { deinit {
closeTransitionTask?.cancel() closeTransitionTask?.cancel()
} }
} }
private extension CGFloat {
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}

View File

@@ -13,6 +13,8 @@ class TerminalManager: ObservableObject {
@AppStorage(NotchSettings.Keys.terminalFontSize) @AppStorage(NotchSettings.Keys.terminalFontSize)
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalTheme)
private var theme: String = NotchSettings.Defaults.terminalTheme
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -35,7 +37,10 @@ class TerminalManager: ObservableObject {
// MARK: - Tab operations // MARK: - Tab operations
func newTab() { func newTab() {
let session = TerminalSession(fontSize: CGFloat(fontSize)) let session = TerminalSession(
fontSize: CGFloat(fontSize),
theme: TerminalTheme.resolve(theme)
)
// Forward title changes to trigger view updates in this manager // Forward title changes to trigger view updates in this manager
session.$title session.$title
@@ -104,4 +109,10 @@ class TerminalManager: ObservableObject {
tab.updateFontSize(size) tab.updateFontSize(size)
} }
} }
func updateAllThemes(_ theme: TerminalTheme) {
for tab in tabs {
tab.applyTheme(theme)
}
}
} }

View File

@@ -9,25 +9,21 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
let id = UUID() let id = UUID()
let terminalView: TerminalView let terminalView: TerminalView
private var process: LocalProcess? private var process: LocalProcess?
private let backgroundColor = NSColor.black
@Published var title: String = "shell" @Published var title: String = "shell"
@Published var isRunning: Bool = true @Published var isRunning: Bool = true
@Published var currentDirectory: String? @Published var currentDirectory: String?
init(fontSize: CGFloat) { init(fontSize: CGFloat, theme: TerminalTheme) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
super.init() super.init()
terminalView.terminalDelegate = self 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) let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font terminalView.font = font
applyTheme(theme)
startShell() startShell()
} }
@@ -64,6 +60,14 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
} }
func applyTheme(_ theme: TerminalTheme) {
// Keep the notch visually consistent while swapping the terminal's
// default foreground color and ANSI palette for command output.
terminalView.nativeBackgroundColor = backgroundColor
terminalView.nativeForegroundColor = theme.foregroundColor
terminalView.installColors(theme.ansiColors)
}
func terminate() { func terminate() {
process?.terminate() process?.terminate()
process = nil process = nil

View File

@@ -0,0 +1,117 @@
import AppKit
import SwiftTerm
enum TerminalTheme: String, CaseIterable, Identifiable {
case terminalApp
case xterm
case solarizedDark
case dracula
case nord
var id: String { rawValue }
var label: String {
switch self {
case .terminalApp: return "Classic"
case .xterm: return "Xterm"
case .solarizedDark:return "Solarized Dark"
case .dracula: return "Dracula"
case .nord: return "Nord"
}
}
var detail: String {
switch self {
case .terminalApp:
return "Matches the app's current terminal palette."
case .xterm:
return "Traditional xterm-style ANSI colors."
case .solarizedDark:
return "Low-contrast dark palette with Solarized accents."
case .dracula:
return "Higher-contrast dark palette with vivid ANSI colors."
case .nord:
return "Cool blue-grey palette with restrained accents."
}
}
var foregroundColor: NSColor {
switch self {
case .terminalApp:
return Self.nsColor(0xE5E5E5)
case .xterm:
return Self.nsColor(0xE5E5E5)
case .solarizedDark:
return Self.nsColor(0x839496)
case .dracula:
return Self.nsColor(0xF8F8F2)
case .nord:
return Self.nsColor(0xD8DEE9)
}
}
var ansiColors: [Color] {
switch self {
case .terminalApp:
return Self.palette([
0x000000, 0xC23621, 0x25BC24, 0xADAD27,
0x492EE1, 0xD338D3, 0x33BBC8, 0xCBCCCD,
0x818383, 0xFC391F, 0x31E722, 0xEAEC23,
0x5833FF, 0xF935F8, 0x14F0F0, 0xE9EBEB
])
case .xterm:
return Self.palette([
0x000000, 0xCD0000, 0x00CD00, 0xCDCD00,
0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5,
0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00,
0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF
])
case .solarizedDark:
return Self.palette([
0x073642, 0xDC322F, 0x859900, 0xB58900,
0x268BD2, 0xD33682, 0x2AA198, 0xEEE8D5,
0x002B36, 0xCB4B16, 0x586E75, 0x657B83,
0x839496, 0x6C71C4, 0x93A1A1, 0xFDF6E3
])
case .dracula:
return Self.palette([
0x21222C, 0xFF5555, 0x50FA7B, 0xF1FA8C,
0xBD93F9, 0xFF79C6, 0x8BE9FD, 0xF8F8F2,
0x6272A4, 0xFF6E6E, 0x69FF94, 0xFFFFA5,
0xD6ACFF, 0xFF92DF, 0xA4FFFF, 0xFFFFFF
])
case .nord:
return Self.palette([
0x3B4252, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x88C0D0, 0xE5E9F0,
0x4C566A, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x8FBCBB, 0xECEFF4
])
}
}
static func resolve(_ rawValue: String) -> TerminalTheme {
TerminalTheme(rawValue: rawValue) ?? .terminalApp
}
private static func palette(_ hexValues: [UInt32]) -> [Color] {
hexValues.map(terminalColor)
}
private static func terminalColor(_ hex: UInt32) -> Color {
Color(
red: UInt16(((hex >> 16) & 0xFF) * 257),
green: UInt16(((hex >> 8) & 0xFF) * 257),
blue: UInt16((hex & 0xFF) * 257)
)
}
private static func nsColor(_ hex: UInt32) -> NSColor {
NSColor(
deviceRed: CGFloat((hex >> 16) & 0xFF) / 255.0,
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
blue: CGFloat(hex & 0xFF) / 255.0,
alpha: 1.0
)
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import AppKit
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About. /// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View { struct SettingsView: View {
@@ -85,6 +86,14 @@ struct GeneralSettingsView: View {
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
private var maxOpenWidth: Double {
max(openWidth, Double((NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600).rounded()))
}
private var maxOpenHeight: Double {
max(openHeight, Double((NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900).rounded()))
}
var body: some View { var body: some View {
Form { Form {
Section("Display") { Section("Display") {
@@ -146,12 +155,12 @@ struct GeneralSettingsView: View {
Section("Open Notch Size") { Section("Open Notch Size") {
HStack { HStack {
Text("Width") Text("Width")
Slider(value: $openWidth, in: 300...1200, step: 10) Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60) Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
} }
HStack { HStack {
Text("Height") Text("Height")
Slider(value: $openHeight, in: 100...600, step: 10) Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10)
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60) Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
} }
} }
@@ -218,6 +227,7 @@ struct AnimationSettingsView: View {
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
var body: some View { var body: some View {
Form { Form {
@@ -230,6 +240,9 @@ struct AnimationSettingsView: View {
Section("Hover Animation") { Section("Hover Animation") {
springControls(response: $hoverResponse, damping: $hoverDamping) springControls(response: $hoverResponse, damping: $hoverDamping)
} }
Section("Resize Animation") {
durationControl(duration: $resizeDuration)
}
Section { Section {
Button("Reset to Defaults") { Button("Reset to Defaults") {
openResponse = NotchSettings.Defaults.openSpringResponse openResponse = NotchSettings.Defaults.openSpringResponse
@@ -238,6 +251,7 @@ struct AnimationSettingsView: View {
closeDamping = NotchSettings.Defaults.closeSpringDamping closeDamping = NotchSettings.Defaults.closeSpringDamping
hoverResponse = NotchSettings.Defaults.hoverSpringResponse hoverResponse = NotchSettings.Defaults.hoverSpringResponse
hoverDamping = NotchSettings.Defaults.hoverSpringDamping hoverDamping = NotchSettings.Defaults.hoverSpringDamping
resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
} }
} }
} }
@@ -257,6 +271,15 @@ struct AnimationSettingsView: View {
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50) Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
} }
} }
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue)).monospacedDigit().frame(width: 56)
}
}
} }
// MARK: - Terminal // MARK: - Terminal
@@ -265,6 +288,11 @@ struct TerminalSettingsView: View {
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize @AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell @AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
@AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
@State private var sizePresets = TerminalSizePresetStore.load()
var body: some View { var body: some View {
Form { Form {
@@ -275,6 +303,21 @@ struct TerminalSettingsView: View {
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50) Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
} }
} }
Section("Colors") {
Picker("Theme", selection: $theme) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(TerminalTheme.resolve(theme).detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") { Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: $shellPath) TextField("Shell path (empty = $SHELL)", text: $shellPath)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
@@ -282,8 +325,54 @@ struct TerminalSettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("Size Presets") {
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: openWidth,
currentOpenHeight: openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
sizePresets.append(
TerminalSizePreset(
name: "Preset \(sizePresets.count + 1)",
width: openWidth,
height: openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
}
Button("Reset Presets") {
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
} }
.formStyle(.grouped) .formStyle(.grouped)
.onChange(of: sizePresets) { _, newValue in
TerminalSizePresetStore.save(newValue)
}
}
private func deletePreset(id: UUID) {
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
openWidth = preset.width
openHeight = preset.height
ScreenManager.shared.applySizePreset(preset)
} }
} }
@@ -313,7 +402,7 @@ struct HotkeySettingsView: View {
} }
Section { Section {
Text("⌘19 always switch to tab by number.") Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -361,6 +450,52 @@ struct HotkeySettingsView: View {
} }
} }
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}
// MARK: - About // MARK: - About
struct AboutSettingsView: View { struct AboutSettingsView: View {

BIN
icons/.DS_Store vendored

Binary file not shown.