Refactor and Rename to CommandNotch
This commit is contained in:
111
Downterm/CommandNotch/Components/HotkeyRecorderView.swift
Normal file
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
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
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
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
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)) + "…"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user