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

View File

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

View File

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

View File

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

View File

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