From 083f060cba79440fe5a715814f53dfdff525e7ee Mon Sep 17 00:00:00 2001 From: Jon Sterling Date: Sun, 15 Feb 2026 16:03:49 +0000 Subject: [PATCH] Add AquaDrawer --- Sources/AquaKit/Drawers/AquaDrawer.swift | 377 ++++++++++++++++++ .../Drawers/AquaDrawerChromeView.swift | 60 +++ .../AquaDrawerChromeViewController.swift | 50 +++ .../AquaKit/Drawers/AquaDrawerFrameView.swift | 163 ++++++++ Sources/AquaKit/Drawers/AquaDrawerPanel.swift | 5 + 5 files changed, 655 insertions(+) create mode 100644 Sources/AquaKit/Drawers/AquaDrawer.swift create mode 100644 Sources/AquaKit/Drawers/AquaDrawerChromeView.swift create mode 100644 Sources/AquaKit/Drawers/AquaDrawerChromeViewController.swift create mode 100644 Sources/AquaKit/Drawers/AquaDrawerFrameView.swift create mode 100644 Sources/AquaKit/Drawers/AquaDrawerPanel.swift diff --git a/Sources/AquaKit/Drawers/AquaDrawer.swift b/Sources/AquaKit/Drawers/AquaDrawer.swift new file mode 100644 index 0000000..3193b4c --- /dev/null +++ b/Sources/AquaKit/Drawers/AquaDrawer.swift @@ -0,0 +1,377 @@ +import AppKit + +extension AquaDrawer { + /// This protocol replaces the AppKit `NSDrawerDelegate` protocol. + @objc public protocol Delegate: NSObjectProtocol { + /// This method is invoked on user-initiated attempts to open a drawer. + @objc optional func drawerShouldOpen(_ drawer: AquaDrawer) -> Bool + + /// This method is invoked on user-initiated attempts to close a drawer. + @objc optional func drawerShouldClose(_ drawer: AquaDrawer) -> Bool + + /// Notifies the delegate that the drawer will open. + @objc optional func drawerWillOpen(_ notification: Notification) + + /// Notifies the delegate that the drawer did open. + @objc optional func drawerDidOpen(_ notification: Notification) + + /// Notifies the delegate that the drawer will close. + @objc optional func drawerWillClose(_ notification: Notification) + + /// Notifies the delegate that the drawer did close. + @objc optional func drawerDidClose(_ notification: Notification) + } +} + +/// This is a replacement of the AppKit `NSDrawer` class, but it is not intended to replicate the interface and behaviour exactly. Instead, ``AquaDrawer`` is built on top of `NSWindowController`. +public class AquaDrawer: NSWindowController { + public enum State: Sendable, Equatable { + case closedState + case openingState + case openState(edge: NSRectEdge) + case closingState + } + + public static let willOpenNotification: NSNotification.Name = NSNotification.Name("DrawerWillOpen") + public static let didOpenNotification: NSNotification.Name = NSNotification.Name("DrawerDidOpen") + public static let willCloseNotification: NSNotification.Name = NSNotification.Name("DrawerWillClose") + public static let didCloseNotification: NSNotification.Name = NSNotification.Name("DrawerDidClose") + + public static let drawerTransitionDuration = 0.25 + public static let defaultDrawerExtent: CGFloat = 200.0 + public static let drawerOverlap: CGFloat = 14.0 + + static let notificationDispatchTable: [Notification.Name: Selector] = [ + willOpenNotification: #selector(Delegate.drawerWillOpen(_:)), + didOpenNotification: #selector(Delegate.drawerDidOpen(_:)), + willCloseNotification: #selector(Delegate.drawerWillClose(_:)), + didCloseNotification: #selector(Delegate.drawerDidClose(_:)) + ] + + public var delegate: Delegate? { + didSet { + if let oldValue { + for notificationName in Self.notificationDispatchTable.keys { + NotificationCenter.default.removeObserver( + oldValue, + name: notificationName, + object: self + ) + } + } + + for mapping in Self.notificationDispatchTable { + if let delegate, delegate.responds(to: mapping.value) { + NotificationCenter.default.addObserver( + delegate, + selector: mapping.value, + name: mapping.key, + object: self + ) + } + } + } + } + + private let drawerChromeViewController: AquaDrawerChromeViewController + + /// The view controller for the inside of the drawer. + public var drawerContentViewController: NSViewController? { + get { drawerChromeViewController.contentViewController } + set { drawerChromeViewController.contentViewController = newValue } + } + + public private(set) var state: State = .closedState + + public var preferredExtents: [NSRectEdge: CGFloat] = [:] + + /// This replaces `NSDrawer.minContentSize`. + public var minExtent: CGFloat? = 70.0 + + /// This replaces `NSDrawer.maxContentSize`. + public var maxExtent: CGFloat? = 200.0 + + public var leadingOffset: CGFloat = 20.0 + public var trailingOffset: CGFloat = 10.0 + public var preferredEdge: NSRectEdge = .minX + + @IBOutlet public var targetWindow: NSWindow? { + didSet { + if let oldValue { + NotificationCenter.default.removeObserver(self, name: NSWindow.didResizeNotification, object: oldValue) + } + + if let targetWindow { + NotificationCenter.default.addObserver( + self, + selector: #selector(parentWindowDidResize(_:)), + name: NSWindow.didResizeNotification, + object: targetWindow + ) + } + } + } + + public override var acceptsFirstResponder: Bool { true } + + /// A drawer acts as its window’s delegate. + public override var window: NSWindow? { + didSet { + oldValue?.delegate = nil + window?.delegate = self + } + } + + public convenience init(preferredExtents: [NSRectEdge: CGFloat], preferredEdge: NSRectEdge) { + self.init(panel: nil) + self.preferredExtents = preferredExtents + self.preferredEdge = preferredEdge + } + + required init(panel: AquaDrawerPanel?) { + let panel = + panel + ?? AquaDrawerPanel( + contentRect: NSRect(origin: .zero, size: .zero), + styleMask: [.resizable, .utilityWindow, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.backgroundColor = .clear + panel.becomesKeyOnlyIfNeeded = false + + self.drawerChromeViewController = AquaDrawerChromeViewController() + super.init(window: panel) + + contentViewController = drawerChromeViewController + panel.initialFirstResponder = drawerChromeViewController.view + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @MainActor + deinit { + delegate = nil + targetWindow = nil + } + + func drawerExtent(along edge: NSRectEdge) -> CGFloat { + let preferred = preferredExtents[edge] ?? Self.defaultDrawerExtent + return preferred.clamped( + lowerBound: minExtent, + upperBound: maxExtent + ) + } + + func drawerFrame(edge: NSRectEdge, state: State) -> NSRect? { + guard let targetWindow, let (axis, extreme) = edge.split, !state.isTransitioning else { return nil } + let extent = drawerExtent(along: edge) + let parentContentRect = targetWindow.contentRect(forFrameRect: targetWindow.frame) + let coextent = parentContentRect.size[axis.opposite] - (leadingOffset + trailingOffset) + + var origin = parentContentRect.origin + + switch axis { + case .horizontal: origin.y += trailingOffset + case .vertical: origin.x += leadingOffset + } + + let overlapCoefficient: CGFloat = + switch extreme { + case .min: 1 + case .max: -1 + } + + origin[axis] += overlapCoefficient * Self.drawerOverlap + + let contentSize = NSSize(extent: extent, coextent: coextent, axis: axis) + var frame = NSWindow.frameRect( + forContentRect: NSRect(origin: origin, size: contentSize), + styleMask: [.resizable] + ) + + switch extreme { + case .max: + frame.origin[axis] += parentContentRect.size[axis] + if case .closedState = state { + frame.origin[axis] -= frame.size[axis] + } + + case .min: + if case .openState = state { + frame.origin[axis] -= frame.size[axis] + } + } + + return frame + } + + /// Computes the visible edge based on available space, taking into account the ``preferredEdge`` and ``preferredExtents``. + var visibleEdge: NSRectEdge { + guard + let targetWindow, + let screenRect = targetWindow.screen?.visibleFrame + else { return preferredEdge } + + let parentRect = targetWindow.frame + + for edge in [ + preferredEdge, preferredEdge.opposite, preferredEdge.counterclockwiseNextEdge, preferredEdge.clockwiseNextEdge + ] { + guard let edge else { continue } + let availableSpace = edge.spaceAvailable(from: parentRect, in: screenRect) + if availableSpace >= drawerExtent(along: edge) { + return edge + } + } + + return preferredEdge + } + + public func open() { + open(edge: visibleEdge) + } + + private func open(edge: NSRectEdge) { + guard + case .closedState = state, let targetWindow, let window, + let startFrame = drawerFrame(edge: edge, state: .closedState), + let endFrame = drawerFrame(edge: edge, state: .openState(edge: edge)) + else { return } + + drawerChromeViewController.drawerEdge = edge + + NotificationCenter.default.post(name: Self.willOpenNotification, object: self) + + targetWindow.makeFirstResponder(self) + window.setFrame(startFrame, display: true, animate: false) + targetWindow.addChildWindow(window, ordered: .below) + + state = .openingState + + resetWindowOrdering() + + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.drawerTransitionDuration + window.animator().setFrame(endFrame, display: true) + } completionHandler: { [weak self] in + if let self { + Task { @MainActor in + self.state = .openState(edge: edge) + NotificationCenter.default.post(name: Self.didOpenNotification, object: self) + } + } + } + } + + public override func close() { + guard + case .openState(let edge) = state, + let targetWindow, let window, + let frame = drawerFrame(edge: edge, state: .closedState) + else { return } + + targetWindow.endEditing(for: nil) + targetWindow.makeFirstResponder(targetWindow) + NotificationCenter.default.post(name: Self.willCloseNotification, object: self) + state = .closingState + + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.drawerTransitionDuration + window.animator().setFrame(frame, display: true) + } completionHandler: { [weak self] in + if let self { + Task { @MainActor in + self.window?.orderOut(nil) + self.state = .closedState + NotificationCenter.default.post(name: Self.didCloseNotification, object: self) + } + } + } + } + + @objc private func parentWindowDidResize(_ notification: Notification) { + guard case .openState(let edge) = state, let window else { return } + guard let frame = drawerFrame(edge: edge, state: state) else { return } + window.setFrame(frame, display: true) + } + + private func resetWindowOrdering() { + guard let targetWindow, let window else { return } + window.order(.above, relativeTo: targetWindow.windowNumber) + window.order(.below, relativeTo: targetWindow.windowNumber) + } +} + +extension AquaDrawer { + @IBAction public func open(_ sender: Any?) { + if let shouldOpen = delegate?.drawerShouldOpen?(self), !shouldOpen { + return + } + + open() + } + + @IBAction public func close(_ sender: Any?) { + if let shouldClose = delegate?.drawerShouldClose?(self), !shouldClose { + return + } + + close() + } + + @IBAction public func toggle(_ sender: Any?) { + switch state { + case .closedState: open(self) + case .openState: close(self) + case .openingState, .closingState: break + } + } +} + +extension AquaDrawer: NSWindowDelegate { + public func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + guard case State.openState(edge: let edge) = state, let axis = edge.axis, let parentWindow = sender.parent else { + return frameSize + } + var frameSize = frameSize + + let parentFrame = parentWindow.frame + let parentContentRect = parentWindow.contentRect(forFrameRect: parentFrame) + let coextent = parentContentRect.size[axis.opposite] - (leadingOffset + trailingOffset) + + frameSize[axis.opposite] = coextent + + if let minExtent, frameSize[axis] < minExtent { + return frameSize + } + + frameSize[axis].clamp(lowerBound: minExtent, upperBound: maxExtent) + + return frameSize + } + + public func windowDidEndLiveResize(_ notification: Notification) { + guard case State.openState(edge: let edge) = state, let axis = edge.axis, + let window = notification.object as? NSWindow + else { return } + let frameSize = window.frame.size + if let minExtent, frameSize[axis] < minExtent { + close(nil) + } else { + preferredExtents[edge] = frameSize[axis] + } + } +} + +extension AquaDrawer.State { + var isTransitioning: Bool { + switch self { + case .openingState, .closingState: true + default: false + } + } +} diff --git a/Sources/AquaKit/Drawers/AquaDrawerChromeView.swift b/Sources/AquaKit/Drawers/AquaDrawerChromeView.swift new file mode 100644 index 0000000..59e1e5d --- /dev/null +++ b/Sources/AquaKit/Drawers/AquaDrawerChromeView.swift @@ -0,0 +1,60 @@ +import AppKit + +final class AquaDrawerChromeView: NSView { + private var maskLayer: CAShapeLayer + var drawerEdge: NSRectEdge? { + didSet { + var insets = NSEdgeInsets() + if let edge = drawerEdge?.opposite { + insets[edge] = AquaDrawer.drawerOverlap + } + additionalSafeAreaInsets = insets + } + } + + override init(frame frameRect: NSRect) { + self.maskLayer = CAShapeLayer() + super.init(frame: frameRect) + + wantsLayer = true + clipsToBounds = true + layer!.mask = maskLayer + + let visualEffectsView = NSVisualEffectView() + visualEffectsView.translatesAutoresizingMaskIntoConstraints = false + visualEffectsView.material = .sheet + visualEffectsView.state = .active + + addSubview(visualEffectsView) + NSLayoutConstraint.activate([ + visualEffectsView.topAnchor.constraint(equalTo: topAnchor), + visualEffectsView.bottomAnchor.constraint(equalTo: bottomAnchor), + visualEffectsView.leftAnchor.constraint(equalTo: leftAnchor), + visualEffectsView.rightAnchor.constraint(equalTo: rightAnchor) + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + override var safeAreaInsets: NSEdgeInsets { + let defaultInset = 10.0 + return NSEdgeInsets( + top: defaultInset + additionalSafeAreaInsets.top, + left: defaultInset + additionalSafeAreaInsets.left, + bottom: defaultInset + additionalSafeAreaInsets.bottom, + right: defaultInset + additionalSafeAreaInsets.right + ) + } + + override func layout() { + super.layout() + + maskLayer.frame = bounds + maskLayer.path = + NSBezierPath( + roundedRect: bounds, + xRadius: AquaDrawerFrameView.cornerRadius, + yRadius: AquaDrawerFrameView.cornerRadius + ).cgPath + } +} diff --git a/Sources/AquaKit/Drawers/AquaDrawerChromeViewController.swift b/Sources/AquaKit/Drawers/AquaDrawerChromeViewController.swift new file mode 100644 index 0000000..0193df5 --- /dev/null +++ b/Sources/AquaKit/Drawers/AquaDrawerChromeViewController.swift @@ -0,0 +1,50 @@ +import AppKit + +class AquaDrawerChromeViewController: NSViewController { + let chromeView = AquaDrawerChromeView() + let frameView = AquaDrawerFrameView() + + var drawerEdge: NSRectEdge? { + get { chromeView.drawerEdge } + set { chromeView.drawerEdge = newValue } + } + + var contentViewController: NSViewController? { + didSet { + if let oldValue { + oldValue.view.removeFromSuperview() + oldValue.removeFromParent() + } + if let contentViewController { + addChild(contentViewController) + let contentView = contentViewController.view + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentView, positioned: .below, relativeTo: frameView) + NSLayoutConstraint.activate([ + contentView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + contentView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + } + } + + override func loadView() { + view = chromeView + } + + override func viewDidLoad() { + super.viewDidLoad() + + frameView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(frameView) + + NSLayoutConstraint.activate([ + frameView.leftAnchor.constraint(equalTo: view.leftAnchor), + frameView.rightAnchor.constraint(equalTo: view.rightAnchor), + frameView.topAnchor.constraint(equalTo: view.topAnchor), + frameView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} diff --git a/Sources/AquaKit/Drawers/AquaDrawerFrameView.swift b/Sources/AquaKit/Drawers/AquaDrawerFrameView.swift new file mode 100644 index 0000000..451ef3d --- /dev/null +++ b/Sources/AquaKit/Drawers/AquaDrawerFrameView.swift @@ -0,0 +1,163 @@ +import AppKit + +final class AquaDrawerFrameView: NSView { + private var dragStart: NSPoint = .zero + private var activePosition: NSCursor.FrameResizePosition? + + static let borderThickness: CGFloat = 6 + static var cornerRadius: CGFloat { borderThickness * 2 } + private static var activeAreaThickness: CGFloat { Self.borderThickness * 2 } + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + addTrackingArea( + NSTrackingArea( + rect: bounds, + options: [.activeAlways, .mouseMoved, .inVisibleRect], + owner: self, + userInfo: nil + ) + ) + } + + var strokePath: NSBezierPath { + let inset = Self.borderThickness / 2 + let strokeRadius = Self.cornerRadius - inset + + let strokeRect = bounds.insetBy(dx: inset, dy: inset) + + let path = NSBezierPath( + roundedRect: strokeRect, + xRadius: strokeRadius, + yRadius: strokeRadius + ) + + path.lineWidth = Self.borderThickness + return path + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = .black.withAlphaComponent(0.7) + shadow.shadowBlurRadius = 5.0 + shadow.set() + + NSColor.controlColor.setStroke() + strokePath.stroke() + NSGraphicsContext.restoreGraphicsState() + } + + required init?(coder: NSCoder) { fatalError() } + + override func hitTest(_ point: NSPoint) -> NSView? { + if frame.insetBy(dx: Self.borderThickness, dy: Self.borderThickness).contains(point) { + return nil + } + return self + } + + override func mouseMoved(with event: NSEvent) { + let loc = convert(event.locationInWindow, from: nil) + activePosition = frameResizePosition(at: loc) + updateCursor() + } + + override func mouseDown(with event: NSEvent) { + dragStart = convert(event.locationInWindow, from: nil) + + if let window { + NotificationCenter.default.post( + name: NSWindow.willStartLiveResizeNotification, + object: window + ) + } + } + + override func mouseDragged(with event: NSEvent) { + guard let window, let activePosition else { return } + + let current = convert(event.locationInWindow, from: nil) + let delta = CGPoint(x: current.x - dragStart.x, y: current.y - dragStart.y) + var frame = window.frame + + if activePosition.containsLeft { + frame.size.width -= delta.x + frame.origin.x += delta.x + } else if activePosition.containsRight { + frame.size.width += delta.x + } + + if activePosition.containsBottom { + frame.size.height -= delta.y + frame.origin.y += delta.y + } else if activePosition.containsTop { + frame.size.height += delta.y + } + + if let constrainedSize = window.delegate?.windowWillResize?(window, to: frame.size) { + let widthDelta = frame.size.width - constrainedSize.width + let heightDelta = frame.size.height - constrainedSize.height + + frame.size = constrainedSize + + if activePosition.containsLeft { frame.origin.x += widthDelta } + if activePosition.containsBottom { frame.origin.y += heightDelta } + } + + window.setFrame(frame, display: true) + } + + override func mouseUp(with event: NSEvent) { + if let window { + NotificationCenter.default.post( + name: NSWindow.didEndLiveResizeNotification, + object: window + ) + } + } + + private func frameResizePosition(at point: NSPoint) -> NSCursor.FrameResizePosition? { + guard let contentView = superview else { return nil } + + let left = point.x <= Self.activeAreaThickness + let right = point.x >= contentView.bounds.width - Self.activeAreaThickness + let bottom = point.y <= Self.activeAreaThickness + let top = point.y >= contentView.bounds.height - Self.activeAreaThickness + + if top, left { return .topLeft } + if top, right { return .topRight } + if bottom, left { return .bottomLeft } + if bottom, right { return .bottomRight } + if top { return .top } + if bottom { return .bottom } + if left { return .left } + if right { return .right } + + return nil + } + + var cursor: NSCursor { + if let activePosition { + NSCursor.frameResize(position: activePosition, directions: .all) + } else { + NSCursor.arrow + } + } + + private func updateCursor() { + cursor.set() + } +} + +extension NSCursor.FrameResizePosition { + fileprivate var containsLeft: Bool { self == .left || self == .topLeft || self == .bottomLeft } + fileprivate var containsRight: Bool { self == .right || self == .topRight || self == .bottomRight } + fileprivate var containsTop: Bool { self == .top || self == .topLeft || self == .topRight } + fileprivate var containsBottom: Bool { self == .bottom || self == .bottomLeft || self == .bottomRight } +} diff --git a/Sources/AquaKit/Drawers/AquaDrawerPanel.swift b/Sources/AquaKit/Drawers/AquaDrawerPanel.swift new file mode 100644 index 0000000..4f7bc54 --- /dev/null +++ b/Sources/AquaKit/Drawers/AquaDrawerPanel.swift @@ -0,0 +1,5 @@ +import AppKit + +open class AquaDrawerPanel: NSPanel { + open override var canBecomeKey: Bool { true } +} -- 2.51.2