From fb1653477538d69ff886241337611bffe4e5e32a Mon Sep 17 00:00:00 2001 From: Jon Sterling Date: Sat, 14 Feb 2026 16:15:02 +0000 Subject: [PATCH] Scroll views --- Sources/AquaKit/General Purpose/NSGradient+Aqua.swift | 18 ++++++++++++++++++ Sources/AquaKit/Scroll Views/AquaScrollCornerView.swift | 9 +++++++++ Sources/AquaKit/Scroll Views/AquaScrollView.swift | 34 ++++++++++++++++++++++++++++++++++ Sources/AquaKit/Scroll Views/AquaScroller.swift | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Sources/AquaKit/Table Headers/AquaTableHeaderCell.swift | 15 +++------------ 5 file(s) changed, 381 insertion(s)(+), 12 deletion(s)(-) diff --git a/Sources/AquaKit/General Purpose/NSGradient+Aqua.swift b/Sources/AquaKit/General Purpose/NSGradient+Aqua.swift new file mode 100644 --- /dev/null +++ b/Sources/AquaKit/General Purpose/NSGradient+Aqua.swift @@ -0,0 +1,18 @@ +import AppKit + +extension NSGradient { + @MainActor + public static var smoothAquaBarGradient: Self! { + let primary = NSColor( + light: .white, + dark: .graphiteColor.shadow(withLevel: 0.4)!.blended(withFraction: 0.5, of: .windowBackgroundColor)! + ) + let secondary = NSColor( + light: NSColor(white: 0.9, alpha: 1), + dark: .graphiteColor.shadow(withLevel: 0.7)!.blended(withFraction: 0.5, of: .windowBackgroundColor)! + ) + return Self(colors: [ + primary, secondary, primary, + ]) + } +} diff --git a/Sources/AquaKit/Scroll Views/AquaScrollCornerView.swift b/Sources/AquaKit/Scroll Views/AquaScrollCornerView.swift new file mode 100644 --- /dev/null +++ b/Sources/AquaKit/Scroll Views/AquaScrollCornerView.swift @@ -0,0 +1,9 @@ +import AppKit +import AquaKit + +class AquaScrollCornerView: NSView { + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + bounds.fill() + } +} diff --git a/Sources/AquaKit/Scroll Views/AquaScrollView.swift b/Sources/AquaKit/Scroll Views/AquaScrollView.swift new file mode 100644 --- /dev/null +++ b/Sources/AquaKit/Scroll Views/AquaScrollView.swift @@ -0,0 +1,34 @@ +import AppKit +import AquaKit + +open class AquaScrollView: NSScrollView { + private let cornerView = AquaScrollCornerView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.verticalScroller = AquaScroller(orientation: .vertical) + self.horizontalScroller = AquaScroller(orientation: .horizontal) + addSubview(cornerView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func tile() { + super.tile() + guard scrollerStyle == .legacy, hasHorizontalScroller, hasVerticalScroller, let verticalScroller, let horizontalScroller, !verticalScroller.isHidden, !horizontalScroller.isHidden + else { + cornerView.isHidden = true + return + } + + cornerView.isHidden = false + cornerView.frame = NSRect( + x: verticalScroller.frame.minX, + y: horizontalScroller.frame.minY, + width: verticalScroller.frame.width, + height: horizontalScroller.frame.height + ) + } +} diff --git a/Sources/AquaKit/Scroll Views/AquaScroller.swift b/Sources/AquaKit/Scroll Views/AquaScroller.swift new file mode 100644 --- /dev/null +++ b/Sources/AquaKit/Scroll Views/AquaScroller.swift @@ -0,0 +1,317 @@ +import AppKit +import AquaKit + +public class AquaScroller: NSScroller { + public enum Orientation { + case vertical, horizontal + } + + public let orientation: Orientation + + public override var floatValue: Float { + didSet { + self.setNeedsDisplay(bounds) + } + } + + public required init(orientation: Orientation) { + self.orientation = orientation + super.init(frame: .zero) + addSubview(WindowStateSentinelView()) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private static var backgroundGradient: NSGradient { + return .smoothAquaBarGradient + } + + public override func draw(_ dirtyRect: NSRect) { + let path = NSBezierPath(rect: bounds) + Self.backgroundGradient.draw(in: path, angle: orientation.crossGradientAngle) + + NSColor.aquaSeparatorColor.setStroke() + for (from, to) in orientation.borderLines(in: bounds) { + NSBezierPath.strokeLine(from: from, to: to) + } + super.draw(dirtyRect) + } + + public override var usableParts: NSScroller.UsableParts { + .allScrollerParts + } + + private static let slotInset = 4.0 + public override func rect(for partCode: NSScroller.Part) -> NSRect { + let axis = orientation + switch partCode { + case .knobSlot: + return axis.inset(bounds, main: Self.slotInset, cross: 0) + case .knob: + let superRect = super.rect(for: partCode) + let mainOrigin = max(Self.slotInset, axis.mainOrigin(of: superRect)) + let crossOrigin: CGFloat = 2 + let crossSize = axis.crossLength(of: bounds) - 4 + let mainSize = knobProportion * (axis.mainLength(of: bounds) - Self.slotInset * 2) + return axis.rect( + mainOrigin: mainOrigin, + crossOrigin: crossOrigin, + mainSize: mainSize, + crossSize: crossSize + ) + default: + return super.rect(for: partCode) + } + } + + var wavePatternImage: NSImage { + let cross: CGFloat = 5 + let main: CGFloat = 15 + let size = NSSize(width: cross, height: main).applying(orientation.transform) + let angle = orientation.mainGradientAngle + return NSImage( + size: size, + flipped: false, + drawingHandler: { rect in + NSGradient(colors: [.white, .black, .white])?.draw(in: rect, angle: angle) + return true + } + ) + } + + private var isInKeyWindow: Bool { + if let window, window.isKeyWindow { + true + } else { + false + } + } + + public override func drawKnob() { + let axis = orientation + let knobRect = rect(for: .knob) + let scrollerWidth = Self.scrollerWidth(for: self.controlSize, scrollerStyle: self.scrollerStyle) + let radius = scrollerWidth / 2.5 + + let knobPath = NSBezierPath(roundedRect: knobRect, xRadius: radius, yRadius: radius) + + NSGraphicsContext.saveGraphicsState() + + do { + NSGraphicsContext.saveGraphicsState() + + let shadow = NSShadow() + shadow.shadowColor = .black + shadow.shadowBlurRadius = 2 + shadow.set() + + NSColor.black.withAlphaComponent(0.4).setStroke() + + knobPath.stroke() + + if isInKeyWindow { + NSColor(patternImage: wavePatternImage).setFill() + knobPath.fill() + } + + NSGraphicsContext.restoreGraphicsState() + } + + do { + NSGraphicsContext.saveGraphicsState() + knobPath.addClip() + + var baseColor: NSColor + if let window, window.isKeyWindow { + baseColor = NSColor.controlAccentColor.usingColorSpace(.displayP3)! + if baseColor.isGrayscale { + baseColor = NSColor.graphiteColor + } else { + baseColor = NSColor( + calibratedHue: baseColor.hueComponent - 0.03, + saturation: 0.8, + brightness: 0.9, + alpha: 1.0 + ) + } + } else { + baseColor = NSColor.graphiteColor.highlight(withLevel: 0.5)! + } + + do { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.cgContext.setAlpha(0.9) + baseColor.setFill() + knobRect.fill() + NSGraphicsContext.restoreGraphicsState() + } + + // shading at the ends of the knob + if isInKeyWindow { + let frontEdge: CGRectEdge = + switch axis { + case .vertical: .minYEdge + case .horizontal: .minXEdge + } + + let backEdge: CGRectEdge = + switch axis { + case .vertical: .maxYEdge + case .horizontal: .maxXEdge + } + + let distance = min(scrollerWidth / 1.5, axis.mainLength(of: knobRect) / 3.0) + let gradient = NSGradient(colors: [baseColor.shadow(withLevel: 0.6)!.withAlphaComponent(0.5), .clear]) + let frontRect = knobRect.divided(atDistance: distance, from: frontEdge).slice + let backRect = knobRect.divided(atDistance: distance, from: backEdge).slice + gradient?.draw(in: frontRect, angle: axis.mainGradientAngle) + gradient?.draw(in: backRect, angle: 180.0 + axis.mainGradientAngle) + } + + // shine highlight along leading cross edge + do { + NSGraphicsContext.saveGraphicsState() + + let shineGradient = NSGradient(colors: [ + .white, + .white.withAlphaComponent(isInKeyWindow ? 0.4 : 0.8), + ])! + + let shineRect = axis.rect( + mainOrigin: axis.mainOrigin(of: knobRect) + 3, + crossOrigin: axis.crossOrigin(of: knobRect) - 6, + mainSize: axis.mainLength(of: knobRect) - 6, + crossSize: 10 + ) + let shinePath = NSBezierPath(roundedRect: shineRect, xRadius: 5, yRadius: 5) + + shineGradient.draw(in: shinePath, angle: axis.crossGradientAngle) + NSGraphicsContext.restoreGraphicsState() + } + + + // trailing cross edge glow + do { + NSGraphicsContext.saveGraphicsState() + + let glowGradient = NSGradient(colors: [ + .clear, + .clear, + baseColor.highlight(withLevel: 0.6)!, + ])! + + let glowPath = NSBezierPath( + roundedRect: axis.inset(knobRect, main: 1, cross: 0), + xRadius: radius, + yRadius: radius + ) + glowGradient.draw(in: glowPath, angle: axis.crossGradientAngle) + + NSGraphicsContext.restoreGraphicsState() + } + + // leading cross edge glow + do { + NSGraphicsContext.saveGraphicsState() + + let glowGradient = NSGradient(colors: [ + baseColor.highlight(withLevel: 0.6)!, + .clear, + .clear, + ])! + + let glowPath = NSBezierPath( + roundedRect: axis.inset(knobRect, main: 1, cross: 0), + xRadius: radius, + yRadius: radius + ) + glowGradient.draw(in: glowPath, angle: axis.crossGradientAngle) + NSGraphicsContext.restoreGraphicsState() + } + + NSGraphicsContext.restoreGraphicsState() + } + + NSGraphicsContext.restoreGraphicsState() + } + + public override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { + let bedRect = orientation.inset(slotRect, main: 0, cross: 1) + let radius = min(bedRect.width, bedRect.height) / 2.5 + let bedPath = NSBezierPath(roundedRect: bedRect, xRadius: radius, yRadius: radius) + NSColor.windowBackgroundColor.highlight(withLevel: 0.05)!.setFill() + bedPath.fill() + + NSGraphicsContext.saveGraphicsState() + + bedPath.addClip() + + let shadow = NSShadow() + shadow.shadowColor = .black.withAlphaComponent(0.6) + shadow.shadowOffset = NSSize(width: 0, height: 0) + shadow.shadowBlurRadius = 3 + shadow.set() + + let outerRect = slotRect.insetBy(dx: -10, dy: -10) + let outerPath = NSBezierPath(rect: outerRect) + outerPath.append(bedPath) + outerPath.windingRule = .evenOdd + NSColor.windowBackgroundColor.setFill() + outerPath.fill() + + NSGraphicsContext.restoreGraphicsState() + } +} + +extension AquaScroller.Orientation { + /// The affine transform that maps between screen space and canonical (vertical) + /// coordinate space. For vertical this is the identity; for horizontal it swaps + /// the x and y axes. This transform is its own inverse. + var transform: CGAffineTransform { + switch self { + case .vertical: return .identity + case .horizontal: return CGAffineTransform(a: 0, b: 1, c: 1, d: 0, tx: 0, ty: 0) + } + } + + var crossGradientAngle: CGFloat { + switch self { + case .vertical: return 0 + case .horizontal: return 90 + } + } + + var mainGradientAngle: CGFloat { + switch self { + case .vertical: return 90 + case .horizontal: return 0 + } + } + + func mainLength(of rect: NSRect) -> CGFloat { rect.applying(transform).height } + func crossLength(of rect: NSRect) -> CGFloat { rect.applying(transform).width } + func mainOrigin(of rect: NSRect) -> CGFloat { rect.applying(transform).origin.y } + func crossOrigin(of rect: NSRect) -> CGFloat { rect.applying(transform).origin.x } + + func inset(_ rect: NSRect, main: CGFloat, cross: CGFloat) -> NSRect { + rect.applying(transform).insetBy(dx: cross, dy: main).applying(transform) + } + + /// Constructs an `NSRect` from main-axis and cross-axis components, mapping them + /// to `x`/`y`/`width`/`height` according to the orientation. + func rect(mainOrigin: CGFloat, crossOrigin: CGFloat, mainSize: CGFloat, crossSize: CGFloat) -> NSRect { + NSRect(x: crossOrigin, y: mainOrigin, width: crossSize, height: mainSize).applying(transform) + } + + /// Returns line segments for stroking the two edges that run along the main axis + /// (i.e. the left/right edges for vertical, or the top/bottom edges for horizontal). + func borderLines(in bounds: NSRect) -> [(NSPoint, NSPoint)] { + let b = bounds.applying(transform) + return [ + (NSPoint(x: b.minX, y: b.minY).applying(transform), NSPoint(x: b.minX, y: b.maxY).applying(transform)), + (NSPoint(x: b.maxX, y: b.minY).applying(transform), NSPoint(x: b.maxX, y: b.maxY).applying(transform)), + ] + } +} diff --git a/Sources/AquaKit/Table Headers/AquaTableHeaderCell.swift b/Sources/AquaKit/Table Headers/AquaTableHeaderCell.swift --- a/Sources/AquaKit/Table Headers/AquaTableHeaderCell.swift +++ b/Sources/AquaKit/Table Headers/AquaTableHeaderCell.swift @@ -1,18 +1,9 @@ import AppKit + open class AquaTableHeaderCell: NSTableHeaderCell { - public static var backgroundFillGradient: NSGradient! { - let primary = NSColor( - light: .white, - dark: .graphiteColor.shadow(withLevel: 0.4)!.withAlphaComponent(0.5) - ) - let secondary = NSColor( - light: NSColor(white: 0.9, alpha: 1), - dark: .graphiteColor.shadow(withLevel: 0.7)!.withAlphaComponent(0.5) - ) - return NSGradient(colors: [ - primary, secondary, primary, - ]) + public static var backgroundFillGradient: NSGradient { + .smoothAquaBarGradient } override open func draw(withFrame cellFrame: NSRect, in controlView: NSView) { -- tangled.sh