feat: update visuals for macos tahoe and liquid glass

This commit is contained in:
lwouis committed 2025-11-02 12:37:03 +01:00
1 parent 91e16f7863
commit 33c661505e
8 files changed
+188 -59

No files matched your search

+18 -2
View File
@@ -206,8 +206,24 @@ extension NSImage {
func cgImage(maxSize: NSSize) -> CGImage {
// some images like NSRunningApp.icon are from icns. They hosts multiple representations and it's hard to know the highest resolution
// by setting a maxSize, the returned CGImage will be the biggest it can under that maxSize
var rect = NSRect(origin: .zero, size: maxSize)
return cgImage(forProposedRect: &rect, context: nil, hints: nil)!
let ctx = CGContext(
data: nil,
width: Int(maxSize.width),
height: Int(maxSize.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
self.draw(
in: CGRect(origin: .zero, size: maxSize),
from: .zero,
operation: .copy,
fraction: 1.0
)
NSGraphicsContext.current = nil
return ctx.makeImage()!
}
// NSImage(named) caches/reuses NSImage objects; we force separate instances of images by using copy()
+51 -19
View File
@@ -2,29 +2,30 @@ import Cocoa
class Appearance {
// size
static var windowPadding = CGFloat(18)
static var interCellPadding = CGFloat(1)
static var intraCellPadding = CGFloat(5)
static var appIconLabelSpacing = CGFloat(2)
static var edgeInsetsSize = CGFloat(5)
static var cellCornerRadius = CGFloat(10)
static var windowCornerRadius = CGFloat(23)
static var hideThumbnails = Bool(false)
static var windowPadding = CGFloat(18)
static var windowCornerRadius = CGFloat(23)
static var cellCornerRadius = CGFloat(10)
static var edgeInsetsSize = CGFloat(5)
static var maxWidthOnScreen = CGFloat(0.8)
static var rowsCount = CGFloat(0)
static var windowMinWidthInRow = CGFloat(0)
static var windowMaxWidthInRow = CGFloat(0)
static var iconSize = CGFloat(0)
static var fontHeight = CGFloat(0)
static var maxWidthOnScreen = CGFloat(0.8)
static var maxHeightOnScreen = CGFloat(0.8)
static var windowMinWidthInRow = CGFloat(0)
static var windowMaxWidthInRow = CGFloat(0)
// size: constants
static let maxHeightOnScreen = CGFloat(0.8)
static let interCellPadding = CGFloat(1)
static let intraCellPadding = CGFloat(5)
static let appIconLabelSpacing = CGFloat(2)
// theme
static var material = NSVisualEffectView.Material.dark
static var fontColor = NSColor.white
static var indicatedIconShadowColor: NSColor? = .darkGray
static var titleShadowColor: NSColor? = .darkGray
static var imageShadowColor: NSColor? = .gray // for icon, thumbnail and windowless images
static var highlightMaterial = NSVisualEffectView.Material.selection
static var material = NSVisualEffectView.Material.dark
static var imageShadowColor: NSColor? = .gray // for icon, thumbnail and windowless images
static var highlightFocusedAlphaValue = 1.0
static var highlightHoveredAlphaValue = 0.8
static var highlightFocusedBackgroundColor = NSColor.black.withAlphaComponent(0.5)
@@ -36,7 +37,12 @@ class Appearance {
static var enablePanelShadow = false
// derived
static var font: NSFont { NSFont.systemFont(ofSize: fontHeight) }
static var font: NSFont {
if #available(macOS 26.0, *) {
return NSFont.systemFont(ofSize: fontHeight, weight: .medium)
}
return NSFont.systemFont(ofSize: fontHeight)
}
private static var currentStyle: AppearanceStylePreference { Preferences.appearanceStyle }
private static var currentSize: AppearanceSizePreference { Preferences.appearanceSize }
@@ -72,14 +78,24 @@ class Appearance {
} else {
lightTheme()
}
// for Liquid Glass, we don't want a shadow around the panel
if #available(macOS 26.0, *), currentStyle == .appIcons && LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
enablePanelShadow = false
}
}
private static func thumbnailsSize(_ isHorizontalScreen: Bool) {
hideThumbnails = false
windowPadding = 18
cellCornerRadius = 10
windowCornerRadius = 23
cellCornerRadius = 10
edgeInsetsSize = 12
maxWidthOnScreen = 0.8
if #available(macOS 26.0, *) {
windowPadding = 28
windowCornerRadius = 43
cellCornerRadius = 18
}
switch currentSize {
case .small:
rowsCount = isHorizontalScreen ? 5 : 8
@@ -105,9 +121,13 @@ class Appearance {
private static func appIconsSize() {
hideThumbnails = true
windowPadding = 25
cellCornerRadius = 10
windowCornerRadius = 23
cellCornerRadius = 10
edgeInsetsSize = 5
if #available(macOS 26.0, *) {
edgeInsetsSize = 6
}
maxWidthOnScreen = 0.8
windowMinWidthInRow = 0.04
windowMaxWidthInRow = 0.3
rowsCount = 1
@@ -115,21 +135,33 @@ class Appearance {
case .small:
iconSize = 88
fontHeight = 13
if #available(macOS 26.0, *) {
windowCornerRadius = 50
cellCornerRadius = 28
}
case .medium:
iconSize = 128
fontHeight = 15
if #available(macOS 26.0, *) {
windowCornerRadius = 55
cellCornerRadius = 38
}
case .large:
windowPadding = 28
iconSize = 168
fontHeight = 17
if #available(macOS 26.0, *) {
windowCornerRadius = 75
cellCornerRadius = 48
}
}
}
private static func titlesSize(_ isHorizontalScreen: Bool) {
hideThumbnails = true
windowPadding = 18
cellCornerRadius = 10
windowCornerRadius = 23
cellCornerRadius = 10
edgeInsetsSize = 7
maxWidthOnScreen = isHorizontalScreen ? 0.6 : 0.8
windowMinWidthInRow = 0.6
@@ -150,10 +182,10 @@ class Appearance {
private static func lightTheme() {
fontColor = .black.withAlphaComponent(0.8)
titleShadowColor = nil
indicatedIconShadowColor = nil
imageShadowColor = .lightGray.withAlphaComponent(0.4)
titleShadowColor = nil
highlightMaterial = .mediumLight
imageShadowColor = .lightGray.withAlphaComponent(0.4)
switch currentVisibility {
case .normal:
material = .light
+13 -1
View File
@@ -30,12 +30,24 @@ class Application: NSObject {
kAXApplicationShownNotification,
]
static func appIconWithoutPadding(_ icon: NSImage?) -> CGImage? {
guard let icon else { return nil }
// we can render the icon quite big (e.g. windowless app icon), so we store it high-res
let iconSize = CGFloat(1024)
// NSRunningApplication.icon returns icons with padding; we remove it manually
let paddingToRemove = CGFloat(84)
let croppedSize = iconSize - paddingToRemove * 2
return icon
.cgImage(maxSize: NSSize(width: iconSize, height: iconSize))
.cropping(to: CGRect(x: paddingToRemove, y: paddingToRemove, width: croppedSize, height: croppedSize).integral)!
}
init(_ runningApplication: NSRunningApplication) {
self.runningApplication = runningApplication
pid = runningApplication.processIdentifier
isHidden = runningApplication.isHidden
hasBeenActiveOnce = runningApplication.isActive
icon = runningApplication.icon?.cgImage(maxSize: NSSize(width: 1024, height: 1024))
icon = Application.appIconWithoutPadding(runningApplication.icon)
localizedName = runningApplication.localizedName
bundleIdentifier = runningApplication.bundleIdentifier
bundleURL = runningApplication.bundleURL
+1 -1
View File
@@ -214,7 +214,7 @@ class App: AppCenterApplication {
guard appIsBeingUsed else { return }
thumbnailsPanel.thumbnailsView.updateItemsAndLayout()
guard appIsBeingUsed else { return }
thumbnailsPanel.setContentSize(thumbnailsPanel.thumbnailsView.frame.size)
thumbnailsPanel.setContentSize(thumbnailsPanel.thumbnailsView.contentView.frame.size)
thumbnailsPanel.display()
guard appIsBeingUsed else { return }
NSScreen.preferred.repositionPanel(thumbnailsPanel)
+3 -3
View File
@@ -176,11 +176,11 @@ class ThumbnailView: FlippedView {
setAccessibilityChildren([])
wantsLayer = true
layer!.masksToBounds = false // without this, label will be clipped in app-icons style since its larger than its parentView
setupSharedSubiews()
setupSharedSubviews()
setupStyleSpecificSubviews()
}
private func setupSharedSubiews() {
private func setupSharedSubviews() {
let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor)
thumbnailContainer.wantsLayer = true
thumbnailContainer.layer!.masksToBounds = false // let thumbnail shadows show
@@ -442,7 +442,7 @@ class ThumbnailView: FlippedView {
windowlessAppIndicator.frame.origin.x = ((appIcon.frame.width / 2) - (windowlessAppIndicator.frame.width / 2)).rounded()
+ (App.shared.userInterfaceLayoutDirection == .leftToRight ? 0 : appIcon.frame.origin.x)
}
windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height
windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height + 5
}
// we set dockLabelIcon origin, without checking if .isHidden
// This is because its updated async. We needed it positioned correctly always
+1 -1
View File
@@ -13,7 +13,7 @@ class ThumbnailsPanel: NSPanel {
hasShadow = Appearance.enablePanelShadow
titleVisibility = .hidden
backgroundColor = .clear
contentView! = thumbnailsView
contentView! = thumbnailsView.contentView
// triggering AltTab before or during Space transition animation brings the window on the Space post-transition
collectionBehavior = .canJoinAllSpaces
// 2nd highest level possible; this allows the app to go on top of context menus
@@ -0,0 +1,85 @@
@available(macOS 26.0, *)
class LiquidGlassEffectView: NSGlassEffectView, EffectView {
private typealias SetVariantType = @convention(c) (AnyObject, Selector, Int) -> Void
private static let setVariantSelector = NSSelectorFromString("set_variant:")
static func canUsePrivateLiquidGlassLook() -> Bool {
let method = class_getInstanceMethod(object_getClass(NSGlassEffectView()), setVariantSelector)
return method != nil
}
convenience init(_: Int?) {
self.init()
style = .clear
safeSetVariant(3)
updateAppearance()
wantsLayer = true
// without this, there are weird shadows around the corners
layer!.masksToBounds = true
}
func safeSetVariant(_ value: Int) {
if let method = class_getInstanceMethod(object_getClass(self), LiquidGlassEffectView.setVariantSelector) {
let methodImplementation = method_getImplementation(method)
let f = unsafeBitCast(methodImplementation, to: SetVariantType.self)
f(self, LiquidGlassEffectView.setVariantSelector, value)
}
}
func updateAppearance() {
cornerRadius = Appearance.windowCornerRadius
}
}
class FrostedGlassEffectView: NSVisualEffectView, EffectView {
convenience init(_: Int?) {
self.init()
blendingMode = .behindWindow
state = .active
wantsLayer = true
updateAppearance()
}
func updateAppearance() {
material = Appearance.material
updateRoundedCorners(Appearance.windowCornerRadius)
}
/// using layer!.cornerRadius works but the corners are aliased; this custom approach gives smooth rounded corners
/// see https://stackoverflow.com/a/29386935/2249756
private func updateRoundedCorners(_ cornerRadius: CGFloat) {
if cornerRadius == 0 {
maskImage = nil
} else {
let edgeLength = 2.0 * cornerRadius + 1.0
let mask = NSImage(size: NSSize(width: edgeLength, height: edgeLength), flipped: false) { rect in
let bezierPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
NSColor.black.set()
bezierPath.fill()
return true
}
mask.capInsets = NSEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
mask.resizingMode = .stretch
maskImage = mask
}
}
}
protocol EffectView: NSView {
func updateAppearance()
}
func makeAppropriateEffectView() -> EffectView {
if #available(macOS 26.0, *), Preferences.appearanceStyle == .appIcons, LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
Logger.error("liquid")
return LiquidGlassEffectView(nil)
} else {
if #available(macOS 26.0, *), Preferences.appearanceStyle == .appIcons {
let os = ProcessInfo.processInfo.operatingSystemVersion
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
Logger.error("Private API set_variant is no longer available. macOS version: \(version))")
}
Logger.error("frosted")
return FrostedGlassEffectView(nil)
}
}
+16 -32
View File
@@ -1,34 +1,37 @@
import Cocoa
class ThumbnailsView: NSVisualEffectView {
class ThumbnailsView {
let scrollView = ScrollView()
var contentView: EffectView!
static var recycledViews = [ThumbnailView]()
var rows = [[ThumbnailView]]()
static var thumbnailsWidth = CGFloat(0.0)
static var thumbnailsHeight = CGFloat(0.0)
convenience init() {
self.init(frame: .zero)
material = Appearance.material
blendingMode = .behindWindow
state = .active
wantsLayer = true
updateRoundedCorners(Appearance.windowCornerRadius)
addSubview(scrollView)
init() {
contentView = makeAppropriateEffectView()
contentView.addSubview(scrollView)
// TODO: think about this optimization more
(1...20).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) }
}
func updateBackgroundView() {
let newEffectView = makeAppropriateEffectView()
scrollView.removeFromSuperview()
newEffectView.addSubview(scrollView)
contentView.superview?.replaceSubview(contentView, with: newEffectView)
contentView = newEffectView
}
func reset() {
// it would be nicer to remove this whole "reset" logic, and instead update each component to check Appearance properties before showing
// Maybe in some Appkit willDraw() function that triggers before drawing it
NSScreen.updatePreferred()
Appearance.update()
material = Appearance.material
updateBackgroundView()
for i in 0..<ThumbnailsView.recycledViews.count {
ThumbnailsView.recycledViews[i] = ThumbnailView()
}
updateRoundedCorners(Appearance.windowCornerRadius)
}
static func highlight(_ indexInRecycledViews: Int) {
@@ -39,25 +42,6 @@ class ThumbnailsView: NSVisualEffectView {
}
}
/// using layer!.cornerRadius works but the corners are aliased; this custom approach gives smooth rounded corners
/// see https://stackoverflow.com/a/29386935/2249756
func updateRoundedCorners(_ cornerRadius: CGFloat) {
if cornerRadius == 0 {
maskImage = nil
} else {
let edgeLength = 2.0 * cornerRadius + 1.0
let mask = NSImage(size: NSSize(width: edgeLength, height: edgeLength), flipped: false) { rect in
let bezierPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
NSColor.black.set()
bezierPath.fill()
return true
}
mask.capInsets = NSEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
mask.resizingMode = .stretch
maskImage = mask
}
}
func nextRow(_ direction: Direction, allowWrap: Bool = true) -> [ThumbnailView]? {
let step = direction == .down ? 1 : -1
if let currentRow = Windows.focusedWindow()?.rowIndex {
@@ -88,7 +72,7 @@ class ThumbnailsView: NSVisualEffectView {
let focusedViewFrame = ThumbnailsView.recycledViews[Windows.focusedWindowIndex].frame
let originCenter = NSMidX(focusedViewFrame)
if let targetRow = nextRow(direction, allowWrap: allowWrap) {
let leftSide = originCenter < NSMidX(frame)
let leftSide = originCenter < NSMidX(contentView.frame)
let leadingSide = App.shared.userInterfaceLayoutDirection == .leftToRight ? leftSide : !leftSide
let iterable = leadingSide ? targetRow : targetRow.reversed()
let targetView = iterable.first {
@@ -191,7 +175,7 @@ class ThumbnailsView: NSVisualEffectView {
frameHeight = frameHeight - Appearance.intraCellPadding - labelHeight
originY = originY - Appearance.intraCellPadding - labelHeight
}
frame.size = NSSize(width: frameWidth, height: frameHeight)
contentView.frame.size = NSSize(width: frameWidth, height: frameHeight)
scrollView.frame.size = NSSize(width: min(maxX, widthMax), height: min(maxY, heightMax))
scrollView.frame.origin = CGPoint(x: originX, y: originY)
scrollView.contentView.frame.size = scrollView.frame.size