diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 2c916dbf..66fbd69c 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -154,6 +154,7 @@ BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */; }; BF0C898511686611E4D7D81E /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B2C057099EDABE46779 /* Button.swift */; }; BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */; }; + BF0C8992399A3277A9A02FFB /* LightImageLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8E64BE7394A6591E74E1 /* LightImageLayer.swift */; }; BF0C89B4B17D7843C32A102E /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8353097840D7DB1E0E2E /* Markdown.swift */; }; BF0C89C49CFBAAB4646BB8CB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82A8848224E76B880A83 /* InfoPlist.strings */; }; BF0C89F0632E0A2EC52D9168 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C87749F1A64E2D3305181 /* InfoPlist.strings */; }; @@ -547,6 +548,7 @@ BF0C8E54658B4DAEA0528200 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = ""; }; BF0C8E5D2BD5F7F93DF77DF5 /* sk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sk; path = InfoPlist.strings; sourceTree = ""; }; BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageView.swift; sourceTree = ""; }; + BF0C8E64BE7394A6591E74E1 /* LightImageLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageLayer.swift; sourceTree = ""; }; BF0C8E7B087DCC97A454C0AE /* uk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = uk; path = InfoPlist.strings; sourceTree = ""; }; BF0C8E94CDFB672663333546 /* lt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = lt; path = InfoPlist.strings; sourceTree = ""; }; BF0C8E9D0B36A8872D5B7653 /* nb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = nb; path = InfoPlist.strings; sourceTree = ""; }; @@ -1503,6 +1505,7 @@ 1C9617C5839903D752070CF2 /* ImageTextButtonView.swift */, BF0C896FD4907CD85D62C124 /* WindowlessAppIndicator.swift */, BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */, + BF0C8E64BE7394A6591E74E1 /* LightImageLayer.swift */, BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */, ); path = "generic-components"; @@ -2540,6 +2543,7 @@ BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */, BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */, BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */, + BF0C8992399A3277A9A02FFB /* LightImageLayer.swift in Sources */, BF0C8F40C2D0FF7601D926D3 /* LightweightTimer.swift in Sources */, BF0C88A9AB286CB7CDC1019E /* MainMenu.swift in Sources */, BF0C826C7F8C5F68732FD75B /* DebugMenu.swift in Sources */, diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 7018c7e2..1fe9dc90 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -89,6 +89,22 @@ extension NSColor { } } +extension CALayer { + func centerInSuperlayer(x: Bool = false, y: Bool = false) { + guard let superlayer else { return } + if x { frame.origin.x = ((superlayer.bounds.width - frame.width) / 2).rounded() } + if y { frame.origin.y = ((superlayer.bounds.height - frame.height) / 2).rounded() } + } + + func applyShadow(_ shadow: NSShadow?) { + guard let shadow else { shadowOpacity = 0; return } + shadowColor = shadow.shadowColor?.cgColor + shadowOffset = shadow.shadowOffset + shadowRadius = shadow.shadowBlurRadius + shadowOpacity = 1.0 + } +} + extension NSView { // constrain size to fittingSize func fit() { diff --git a/src/ui/generic-components/LightImageLayer.swift b/src/ui/generic-components/LightImageLayer.swift new file mode 100644 index 00000000..ce757f65 --- /dev/null +++ b/src/ui/generic-components/LightImageLayer.swift @@ -0,0 +1,104 @@ +import Cocoa + +/// this is a lightweight CALayer which displays an image +/// it is an alternative to NSView-based image display, avoiding AppKit overhead (layout recursion, responder chain, drag-and-drop) +class LightImageLayer: CALayer { + private var outlineLayer: CAShapeLayer! + private var handRaisedLayer: CALayer! + private let withTransparencyChecks: Bool + + init(withTransparencyChecks: Bool = false) { + self.withTransparencyChecks = withTransparencyChecks + super.init() + contentsGravity = .resize + magnificationFilter = .trilinear + minificationFilter = .trilinear + minificationFilterBias = 0.0 + shouldRasterize = false + delegate = NoAnimationDelegate.shared + setupOutlineLayer() + setupHandRaisedLayer() + } + + required init?(coder: NSCoder) { + fatalError("Class only supports programmatic initialization") + } + + override init(layer: Any) { + let other = layer as? LightImageLayer + self.withTransparencyChecks = other?.withTransparencyChecks ?? false + super.init(layer: layer) + } + + private func setupOutlineLayer() { + outlineLayer = CAShapeLayer() + outlineLayer.name = "outline" + outlineLayer.fillColor = nil + outlineLayer.lineWidth = 1.0 + outlineLayer.lineDashPattern = [4, 3] + outlineLayer.lineCap = .round + outlineLayer.lineJoin = .round + outlineLayer.isHidden = true + addSublayer(outlineLayer) + } + + private func setupHandRaisedLayer() { + guard #available(macOS 11.0, *) else { return } + handRaisedLayer = CALayer() + handRaisedLayer.contentsGravity = .resizeAspect + handRaisedLayer.isHidden = true + addSublayer(handRaisedLayer) + } + + private func updateOutlineLayer(_ fullyTransparent: Bool) { + outlineLayer.isHidden = !fullyTransparent + guard fullyTransparent else { return } + let inset: CGFloat = 0.5 + let radius: CGFloat = 8.0 + outlineLayer.frame = bounds + let rect = bounds.insetBy(dx: inset, dy: inset) + let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + outlineLayer.path = path + outlineLayer.strokeColor = Appearance.fontColor.cgColor + } + + private func updateHandRaisedLayer(_ fullyTransparent: Bool) { + guard #available(macOS 11.0, *) else { return } + handRaisedLayer.isHidden = !fullyTransparent + guard fullyTransparent else { return } + let minSize = min(Appearance.iconSize, bounds.width * 0.9, bounds.height * 0.9) + let maxSize = Appearance.iconSize * 3 + let ratio = min(bounds.width / ThumbnailView.maxThumbnailWidth(), bounds.height / ThumbnailView.maxThumbnailHeight()) + let pointSize = max(minSize, maxSize * ratio) + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular) + guard let image = NSImage(systemSymbolName: "hand.raised.fill", accessibilityDescription: nil)?.withSymbolConfiguration(config) else { return } + handRaisedLayer.contents = image.tinted(Appearance.fontColor) + handRaisedLayer.bounds = CGRect(x: 0, y: 0, width: pointSize, height: pointSize) + handRaisedLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + } + + func updateContents(_ caLayerContents: CALayerContents, _ size: NSSize) { + var fullyTransparent = false + switch caLayerContents { + case .cgImage(let image?): + contents = image + if withTransparencyChecks { + fullyTransparent = image.isFullyTransparent() + } + case .pixelBuffer(let pixelBuffer?): + contents = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() + default: break + } + if frame.size != size { + frame.size = size + } + if withTransparencyChecks { + updateOutlineLayer(fullyTransparent) + updateHandRaisedLayer(fullyTransparent) + } + } + + func releaseImage() { + contents = nil + } +} diff --git a/src/ui/generic-components/LightImageView.swift b/src/ui/generic-components/LightImageView.swift index c126263f..31099040 100644 --- a/src/ui/generic-components/LightImageView.swift +++ b/src/ui/generic-components/LightImageView.swift @@ -1,106 +1,35 @@ import Cocoa -/// this is a lightweight view which displays an image using its CALayer -/// it is an alternative to NSImageView, which doesn't have internal complexities and performance costs +/// thin NSView wrapper around LightImageLayer, for use where AppKit requires an NSView (contentView, Auto Layout, NSStackView) class LightImageView: NSView { - private var outlineLayer: CAShapeLayer! - private var handRaisedLayer: CALayer! - private let withTransparencyChecks: Bool + let imageLayer: LightImageLayer required init?(coder: NSCoder) { fatalError("Class only supports programmatic initialization") } init(frame frameRect: NSRect = .zero, withTransparencyChecks: Bool = false) { - self.withTransparencyChecks = withTransparencyChecks + imageLayer = LightImageLayer(withTransparencyChecks: withTransparencyChecks) super.init(frame: frameRect) wantsLayer = true - // configure the layer for efficient GPU-scaling - layer!.contentsGravity = .resize - layer!.magnificationFilter = .trilinear - layer!.minificationFilter = .trilinear - layer!.minificationFilterBias = 0.0 - layer!.shouldRasterize = false - layer!.delegate = NoAnimationDelegate.shared + layer!.addSublayer(imageLayer) layerContentsRedrawPolicy = .never - setupOutlineLayer() - setupHandRaisedLayer() } - private func setupOutlineLayer() { - outlineLayer = CAShapeLayer() - outlineLayer.name = "outline" - outlineLayer.fillColor = nil - outlineLayer.lineWidth = 1.0 - outlineLayer.lineDashPattern = [4, 3] - outlineLayer.lineCap = .round - outlineLayer.lineJoin = .round - outlineLayer.isHidden = true - layer!.addSublayer(outlineLayer) + override func layout() { + super.layout() + imageLayer.frame = bounds } - private func setupHandRaisedLayer() { - guard #available(macOS 11.0, *) else { return } - handRaisedLayer = CALayer() - handRaisedLayer.contentsGravity = .resizeAspect - handRaisedLayer.isHidden = true - layer!.addSublayer(handRaisedLayer) - } - - - private func updateOutlineLayer(_ fullyTransparent: Bool) { - outlineLayer.isHidden = !fullyTransparent - guard fullyTransparent else { return } - let inset: CGFloat = 0.5 - let radius: CGFloat = 8.0 - let bounds = outlineLayer.superlayer!.bounds - outlineLayer.frame = bounds - let rect = bounds.insetBy(dx: inset, dy: inset) - let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) - outlineLayer.path = path - outlineLayer.strokeColor = Appearance.fontColor.cgColor - } - - private func updateHandRaisedLayer(_ fullyTransparent: Bool) { - guard #available(macOS 11.0, *) else { return } - handRaisedLayer.isHidden = !fullyTransparent - guard fullyTransparent else { return } - // we want the icon to be large but not too large, and readable at small sizes - let minSize = min(Appearance.iconSize, bounds.width * 0.9, bounds.height * 0.9) - let maxSize = Appearance.iconSize * 3 - let ratio = min(bounds.width / ThumbnailView.maxThumbnailWidth(), bounds.height / ThumbnailView.maxThumbnailHeight()) - let pointSize = max(minSize, maxSize * ratio) - let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular) - guard let image = NSImage(systemSymbolName: "hand.raised.fill", accessibilityDescription: nil)?.withSymbolConfiguration(config) else { return } - handRaisedLayer.contents = image.tinted(Appearance.fontColor) - handRaisedLayer.bounds = CGRect(x: 0, y: 0, width: pointSize, height: pointSize) - handRaisedLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) - } - - func updateContents(_ caLayerContents: CALayerContents, _ size: NSSize) { - var fullyTransparent = false - switch caLayerContents { - case .cgImage(let image?): - layer!.contents = image - if withTransparencyChecks { - fullyTransparent = image.isFullyTransparent() - } - case .pixelBuffer(let pixelBuffer?): - layer!.contents = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() - default: break - } + imageLayer.updateContents(caLayerContents, size) if frame.size != size { frame.size = size } - if withTransparencyChecks { - updateOutlineLayer(fullyTransparent) - updateHandRaisedLayer(fullyTransparent) - } } func releaseImage() { - layer!.contents = nil + imageLayer.releaseImage() } } diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index 12f5b2e9..3a4c6543 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -6,8 +6,8 @@ class ThumbnailView: FlippedView { static let extraTextForPadding = "lmnopqrstuvw" var window_: Window? - var thumbnail = LightImageView(withTransparencyChecks: true) - var appIcon = LightImageView() + var thumbnail = LightImageLayer(withTransparencyChecks: true) + var appIcon = LightImageLayer() var label = ThumbnailTitleView(font: Appearance.font) var statusIcons = StatusIconsView() var dockLabelIcon = ThumbnailFilledFontIconView( @@ -165,13 +165,13 @@ class ThumbnailView: FlippedView { private func setupSharedSubviews() { let shadow = ThumbnailView.makeShadow(Appearance.imagesShadowColor) - thumbnail.layer!.masksToBounds = false // let thumbnail shadows show - thumbnail.shadow = shadow - appIcon.shadow = shadow + thumbnail.masksToBounds = false // let thumbnail shadows show + thumbnail.applyShadow(shadow) + appIcon.applyShadow(shadow) dockLabelIcon.shadow = shadow - appIcon.setSubviewAbove(dockLabelIcon) + layer!.addSublayer(appIcon) + addSubview(dockLabelIcon) label.fixHeight() - setSubviews([appIcon]) } private func setupStyleSpecificSubviews() { @@ -181,8 +181,9 @@ class ThumbnailView: FlippedView { label.alignment = .center label.isHidden = true } else if Preferences.appearanceStyle == .thumbnails { - addSubviews([thumbnail, label, statusIcons]) - thumbnail.setSubviewAbove(windowlessAppIndicator) + layer!.addSublayer(thumbnail) + addSubviews([label, statusIcons]) + setSubviewAbove(windowlessAppIndicator) } else { setSubviewAbove(windowlessAppIndicator) addSubviews([label, statusIcons]) @@ -260,7 +261,6 @@ class ThumbnailView: FlippedView { private func updateAppIcon(_ element: Window, _ title: String) { let appIconSize = ThumbnailView.iconSize() appIcon.updateContents(.cgImage(element.icon), appIconSize) - appIcon.setAccessibilityLabel(title) } private func updateValues(_ element: Window, _ index: Int, _ newHeight: CGFloat) { @@ -275,7 +275,6 @@ class ThumbnailView: FlippedView { ) )) ) - thumbnail.toolTip = element.isWindowlessApp ? ThumbnailView.noOpenWindowToolTip : nil if !thumbnail.isHidden { if let screenshot = element.thumbnail { let thumbnailSize = ThumbnailView.thumbnailSize(element.size, false) @@ -285,8 +284,6 @@ class ThumbnailView: FlippedView { let thumbnailSize = ThumbnailView.thumbnailSize(element.icon?.size(), true) thumbnail.updateContents(.cgImage(element.icon), thumbnailSize) } - // for Accessibility > "speak items under the pointer" - thumbnail.setAccessibilityLabel(element.title) } let title = getAppOrAndWindowTitle() let labelChanged = label.stringValue != title @@ -342,10 +339,12 @@ class ThumbnailView: FlippedView { if Preferences.appearanceStyle == .thumbnails { let hHeight = max(appIcon.frame.height, ThumbnailsView.layoutCache.labelHeight) assignIfDifferent(&thumbnail.frame.origin, NSPoint(x: edgeInsets, y: edgeInsets + hHeight + Appearance.intraCellPadding)) - thumbnail.centerFrameInParent(x: true) + thumbnail.centerInSuperlayer(x: true) } if !windowlessAppIndicator.isHidden { - if Preferences.appearanceStyle != .titles { + if Preferences.appearanceStyle == .thumbnails { + windowlessAppIndicator.frame.origin.x = thumbnail.frame.origin.x + ((thumbnail.frame.width - windowlessAppIndicator.frame.width) / 2).rounded() + } else if Preferences.appearanceStyle != .titles { windowlessAppIndicator.centerFrameInParent(x: true) } else { windowlessAppIndicator.frame.origin.x = ((appIcon.frame.width / 2) - (windowlessAppIndicator.frame.width / 2)).rounded() @@ -354,7 +353,7 @@ class ThumbnailView: FlippedView { if Preferences.appearanceStyle != .thumbnails { windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height + 5 } else { - windowlessAppIndicator.frame.origin.y = -5 + windowlessAppIndicator.frame.origin.y = thumbnail.frame.origin.y - 5 } } // we set dockLabelIcon origin, without checking if .isHidden @@ -432,9 +431,6 @@ class ThumbnailView: FlippedView { } private func observeDragAndDrop() { - // NSImageView instances are registered to drag-and-drop by default - thumbnail.unregisterDraggedTypes() - appIcon.unregisterDraggedTypes() // we only handle URLs (i.e. not text, image, or other draggable things) registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)]) }