Skip to content

Commit 33c6615

Browse files
committed
feat: update visuals for macos tahoe and liquid glass
1 parent 91e16f7 commit 33c6615

File tree

8 files changed

+187
-58
lines changed

8 files changed

+187
-58
lines changed

src/api-wrappers/HelperExtensions.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,24 @@ extension NSImage {
206206
func cgImage(maxSize: NSSize) -> CGImage {
207207
// some images like NSRunningApp.icon are from icns. They hosts multiple representations and it's hard to know the highest resolution
208208
// by setting a maxSize, the returned CGImage will be the biggest it can under that maxSize
209-
var rect = NSRect(origin: .zero, size: maxSize)
210-
return cgImage(forProposedRect: &rect, context: nil, hints: nil)!
209+
let ctx = CGContext(
210+
data: nil,
211+
width: Int(maxSize.width),
212+
height: Int(maxSize.height),
213+
bitsPerComponent: 8,
214+
bytesPerRow: 0,
215+
space: CGColorSpaceCreateDeviceRGB(),
216+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
217+
)!
218+
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
219+
self.draw(
220+
in: CGRect(origin: .zero, size: maxSize),
221+
from: .zero,
222+
operation: .copy,
223+
fraction: 1.0
224+
)
225+
NSGraphicsContext.current = nil
226+
return ctx.makeImage()!
211227
}
212228

213229
// NSImage(named) caches/reuses NSImage objects; we force separate instances of images by using copy()

src/logic/Appearance.swift

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,30 @@ import Cocoa
22

33
class Appearance {
44
// size
5+
static var hideThumbnails = Bool(false)
56
static var windowPadding = CGFloat(18)
6-
static var interCellPadding = CGFloat(1)
7-
static var intraCellPadding = CGFloat(5)
8-
static var appIconLabelSpacing = CGFloat(2)
9-
static var edgeInsetsSize = CGFloat(5)
10-
static var cellCornerRadius = CGFloat(10)
117
static var windowCornerRadius = CGFloat(23)
12-
static var hideThumbnails = Bool(false)
8+
static var cellCornerRadius = CGFloat(10)
9+
static var edgeInsetsSize = CGFloat(5)
10+
static var maxWidthOnScreen = CGFloat(0.8)
1311
static var rowsCount = CGFloat(0)
14-
static var windowMinWidthInRow = CGFloat(0)
15-
static var windowMaxWidthInRow = CGFloat(0)
1612
static var iconSize = CGFloat(0)
1713
static var fontHeight = CGFloat(0)
18-
static var maxWidthOnScreen = CGFloat(0.8)
19-
static var maxHeightOnScreen = CGFloat(0.8)
14+
static var windowMinWidthInRow = CGFloat(0)
15+
static var windowMaxWidthInRow = CGFloat(0)
16+
// size: constants
17+
static let maxHeightOnScreen = CGFloat(0.8)
18+
static let interCellPadding = CGFloat(1)
19+
static let intraCellPadding = CGFloat(5)
20+
static let appIconLabelSpacing = CGFloat(2)
2021

2122
// theme
22-
static var material = NSVisualEffectView.Material.dark
2323
static var fontColor = NSColor.white
2424
static var indicatedIconShadowColor: NSColor? = .darkGray
2525
static var titleShadowColor: NSColor? = .darkGray
26-
static var imageShadowColor: NSColor? = .gray // for icon, thumbnail and windowless images
2726
static var highlightMaterial = NSVisualEffectView.Material.selection
27+
static var material = NSVisualEffectView.Material.dark
28+
static var imageShadowColor: NSColor? = .gray // for icon, thumbnail and windowless images
2829
static var highlightFocusedAlphaValue = 1.0
2930
static var highlightHoveredAlphaValue = 0.8
3031
static var highlightFocusedBackgroundColor = NSColor.black.withAlphaComponent(0.5)
@@ -36,7 +37,12 @@ class Appearance {
3637
static var enablePanelShadow = false
3738

3839
// derived
39-
static var font: NSFont { NSFont.systemFont(ofSize: fontHeight) }
40+
static var font: NSFont {
41+
if #available(macOS 26.0, *) {
42+
return NSFont.systemFont(ofSize: fontHeight, weight: .medium)
43+
}
44+
return NSFont.systemFont(ofSize: fontHeight)
45+
}
4046

4147
private static var currentStyle: AppearanceStylePreference { Preferences.appearanceStyle }
4248
private static var currentSize: AppearanceSizePreference { Preferences.appearanceSize }
@@ -72,14 +78,24 @@ class Appearance {
7278
} else {
7379
lightTheme()
7480
}
81+
// for Liquid Glass, we don't want a shadow around the panel
82+
if #available(macOS 26.0, *), currentStyle == .appIcons && LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
83+
enablePanelShadow = false
84+
}
7585
}
7686

7787
private static func thumbnailsSize(_ isHorizontalScreen: Bool) {
7888
hideThumbnails = false
7989
windowPadding = 18
80-
cellCornerRadius = 10
8190
windowCornerRadius = 23
91+
cellCornerRadius = 10
8292
edgeInsetsSize = 12
93+
maxWidthOnScreen = 0.8
94+
if #available(macOS 26.0, *) {
95+
windowPadding = 28
96+
windowCornerRadius = 43
97+
cellCornerRadius = 18
98+
}
8399
switch currentSize {
84100
case .small:
85101
rowsCount = isHorizontalScreen ? 5 : 8
@@ -105,31 +121,47 @@ class Appearance {
105121
private static func appIconsSize() {
106122
hideThumbnails = true
107123
windowPadding = 25
108-
cellCornerRadius = 10
109124
windowCornerRadius = 23
125+
cellCornerRadius = 10
110126
edgeInsetsSize = 5
127+
if #available(macOS 26.0, *) {
128+
edgeInsetsSize = 6
129+
}
130+
maxWidthOnScreen = 0.8
111131
windowMinWidthInRow = 0.04
112132
windowMaxWidthInRow = 0.3
113133
rowsCount = 1
114134
switch currentSize {
115135
case .small:
116136
iconSize = 88
117137
fontHeight = 13
138+
if #available(macOS 26.0, *) {
139+
windowCornerRadius = 50
140+
cellCornerRadius = 28
141+
}
118142
case .medium:
119143
iconSize = 128
120144
fontHeight = 15
145+
if #available(macOS 26.0, *) {
146+
windowCornerRadius = 55
147+
cellCornerRadius = 38
148+
}
121149
case .large:
122150
windowPadding = 28
123151
iconSize = 168
124152
fontHeight = 17
153+
if #available(macOS 26.0, *) {
154+
windowCornerRadius = 75
155+
cellCornerRadius = 48
156+
}
125157
}
126158
}
127159

128160
private static func titlesSize(_ isHorizontalScreen: Bool) {
129161
hideThumbnails = true
130162
windowPadding = 18
131-
cellCornerRadius = 10
132163
windowCornerRadius = 23
164+
cellCornerRadius = 10
133165
edgeInsetsSize = 7
134166
maxWidthOnScreen = isHorizontalScreen ? 0.6 : 0.8
135167
windowMinWidthInRow = 0.6
@@ -150,10 +182,10 @@ class Appearance {
150182

151183
private static func lightTheme() {
152184
fontColor = .black.withAlphaComponent(0.8)
153-
titleShadowColor = nil
154185
indicatedIconShadowColor = nil
155-
imageShadowColor = .lightGray.withAlphaComponent(0.4)
186+
titleShadowColor = nil
156187
highlightMaterial = .mediumLight
188+
imageShadowColor = .lightGray.withAlphaComponent(0.4)
157189
switch currentVisibility {
158190
case .normal:
159191
material = .light

src/logic/Application.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,24 @@ class Application: NSObject {
3030
kAXApplicationShownNotification,
3131
]
3232

33+
static func appIconWithoutPadding(_ icon: NSImage?) -> CGImage? {
34+
guard let icon else { return nil }
35+
// we can render the icon quite big (e.g. windowless app icon), so we store it high-res
36+
let iconSize = CGFloat(1024)
37+
// NSRunningApplication.icon returns icons with padding; we remove it manually
38+
let paddingToRemove = CGFloat(84)
39+
let croppedSize = iconSize - paddingToRemove * 2
40+
return icon
41+
.cgImage(maxSize: NSSize(width: iconSize, height: iconSize))
42+
.cropping(to: CGRect(x: paddingToRemove, y: paddingToRemove, width: croppedSize, height: croppedSize).integral)!
43+
}
44+
3345
init(_ runningApplication: NSRunningApplication) {
3446
self.runningApplication = runningApplication
3547
pid = runningApplication.processIdentifier
3648
isHidden = runningApplication.isHidden
3749
hasBeenActiveOnce = runningApplication.isActive
38-
icon = runningApplication.icon?.cgImage(maxSize: NSSize(width: 1024, height: 1024))
50+
icon = Application.appIconWithoutPadding(runningApplication.icon)
3951
localizedName = runningApplication.localizedName
4052
bundleIdentifier = runningApplication.bundleIdentifier
4153
bundleURL = runningApplication.bundleURL

src/ui/App.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ class App: AppCenterApplication {
214214
guard appIsBeingUsed else { return }
215215
thumbnailsPanel.thumbnailsView.updateItemsAndLayout()
216216
guard appIsBeingUsed else { return }
217-
thumbnailsPanel.setContentSize(thumbnailsPanel.thumbnailsView.frame.size)
217+
thumbnailsPanel.setContentSize(thumbnailsPanel.thumbnailsView.contentView.frame.size)
218218
thumbnailsPanel.display()
219219
guard appIsBeingUsed else { return }
220220
NSScreen.preferred.repositionPanel(thumbnailsPanel)

src/ui/main-window/ThumbnailView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,11 @@ class ThumbnailView: FlippedView {
176176
setAccessibilityChildren([])
177177
wantsLayer = true
178178
layer!.masksToBounds = false // without this, label will be clipped in app-icons style since its larger than its parentView
179-
setupSharedSubiews()
179+
setupSharedSubviews()
180180
setupStyleSpecificSubviews()
181181
}
182182

183-
private func setupSharedSubiews() {
183+
private func setupSharedSubviews() {
184184
let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor)
185185
thumbnailContainer.wantsLayer = true
186186
thumbnailContainer.layer!.masksToBounds = false // let thumbnail shadows show
@@ -442,7 +442,7 @@ class ThumbnailView: FlippedView {
442442
windowlessAppIndicator.frame.origin.x = ((appIcon.frame.width / 2) - (windowlessAppIndicator.frame.width / 2)).rounded()
443443
+ (App.shared.userInterfaceLayoutDirection == .leftToRight ? 0 : appIcon.frame.origin.x)
444444
}
445-
windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height
445+
windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height + 5
446446
}
447447
// we set dockLabelIcon origin, without checking if .isHidden
448448
// This is because its updated async. We needed it positioned correctly always

src/ui/main-window/ThumbnailsPanel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class ThumbnailsPanel: NSPanel {
1313
hasShadow = Appearance.enablePanelShadow
1414
titleVisibility = .hidden
1515
backgroundColor = .clear
16-
contentView! = thumbnailsView
16+
contentView! = thumbnailsView.contentView
1717
// triggering AltTab before or during Space transition animation brings the window on the Space post-transition
1818
collectionBehavior = .canJoinAllSpaces
1919
// 2nd highest level possible; this allows the app to go on top of context menus
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
@available(macOS 26.0, *)
2+
class LiquidGlassEffectView: NSGlassEffectView, EffectView {
3+
private typealias SetVariantType = @convention(c) (AnyObject, Selector, Int) -> Void
4+
private static let setVariantSelector = NSSelectorFromString("set_variant:")
5+
6+
static func canUsePrivateLiquidGlassLook() -> Bool {
7+
let method = class_getInstanceMethod(object_getClass(NSGlassEffectView()), setVariantSelector)
8+
return method != nil
9+
}
10+
11+
convenience init(_: Int?) {
12+
self.init()
13+
style = .clear
14+
safeSetVariant(3)
15+
updateAppearance()
16+
wantsLayer = true
17+
// without this, there are weird shadows around the corners
18+
layer!.masksToBounds = true
19+
}
20+
21+
func safeSetVariant(_ value: Int) {
22+
if let method = class_getInstanceMethod(object_getClass(self), LiquidGlassEffectView.setVariantSelector) {
23+
let methodImplementation = method_getImplementation(method)
24+
let f = unsafeBitCast(methodImplementation, to: SetVariantType.self)
25+
f(self, LiquidGlassEffectView.setVariantSelector, value)
26+
}
27+
}
28+
29+
func updateAppearance() {
30+
cornerRadius = Appearance.windowCornerRadius
31+
}
32+
}
33+
34+
class FrostedGlassEffectView: NSVisualEffectView, EffectView {
35+
convenience init(_: Int?) {
36+
self.init()
37+
blendingMode = .behindWindow
38+
state = .active
39+
wantsLayer = true
40+
updateAppearance()
41+
}
42+
43+
func updateAppearance() {
44+
material = Appearance.material
45+
updateRoundedCorners(Appearance.windowCornerRadius)
46+
}
47+
48+
/// using layer!.cornerRadius works but the corners are aliased; this custom approach gives smooth rounded corners
49+
/// see https://stackoverflow.com/a/29386935/2249756
50+
private func updateRoundedCorners(_ cornerRadius: CGFloat) {
51+
if cornerRadius == 0 {
52+
maskImage = nil
53+
} else {
54+
let edgeLength = 2.0 * cornerRadius + 1.0
55+
let mask = NSImage(size: NSSize(width: edgeLength, height: edgeLength), flipped: false) { rect in
56+
let bezierPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
57+
NSColor.black.set()
58+
bezierPath.fill()
59+
return true
60+
}
61+
mask.capInsets = NSEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
62+
mask.resizingMode = .stretch
63+
maskImage = mask
64+
}
65+
}
66+
}
67+
68+
protocol EffectView: NSView {
69+
func updateAppearance()
70+
}
71+
72+
func makeAppropriateEffectView() -> EffectView {
73+
if #available(macOS 26.0, *), Preferences.appearanceStyle == .appIcons, LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
74+
Logger.error("liquid")
75+
return LiquidGlassEffectView(nil)
76+
} else {
77+
if #available(macOS 26.0, *), Preferences.appearanceStyle == .appIcons {
78+
let os = ProcessInfo.processInfo.operatingSystemVersion
79+
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
80+
Logger.error("Private API set_variant is no longer available. macOS version: \(version))")
81+
}
82+
Logger.error("frosted")
83+
return FrostedGlassEffectView(nil)
84+
}
85+
}

0 commit comments

Comments
 (0)