diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index b5c5f5e8..fa608d0e 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -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() diff --git a/src/logic/Appearance.swift b/src/logic/Appearance.swift index 0052897f..e133ef64 100644 --- a/src/logic/Appearance.swift +++ b/src/logic/Appearance.swift @@ -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 diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 395e0cfe..339b2bd2 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -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 diff --git a/src/ui/App.swift b/src/ui/App.swift index bfb315b0..413c0d20 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -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) diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index fab4aefe..9021812d 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -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 diff --git a/src/ui/main-window/ThumbnailsPanel.swift b/src/ui/main-window/ThumbnailsPanel.swift index 67d00158..f39cc627 100644 --- a/src/ui/main-window/ThumbnailsPanel.swift +++ b/src/ui/main-window/ThumbnailsPanel.swift @@ -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 diff --git a/src/ui/main-window/ThumbnailsPanelBackgroundView.swift b/src/ui/main-window/ThumbnailsPanelBackgroundView.swift new file mode 100644 index 00000000..4123d662 --- /dev/null +++ b/src/ui/main-window/ThumbnailsPanelBackgroundView.swift @@ -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) + } +} diff --git a/src/ui/main-window/ThumbnailsView.swift b/src/ui/main-window/ThumbnailsView.swift index 8c29d1ff..6f35f2c8 100644 --- a/src/ui/main-window/ThumbnailsView.swift +++ b/src/ui/main-window/ThumbnailsView.swift @@ -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.. [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