feat: update visuals for macos tahoe and liquid glass
This commit is contained in:
1 parent
91e16f7863
commit
33c661505e
8 files changed
+188
-59
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user