perf: use calayer instead of nsview for lightimageview

This commit is contained in:
lwouis committed 2026-02-11 21:35:31 +01:00
1 parent 3a8c0f3bee
commit 7dec9da401
5 files changed
+148 -99

No files matched your search

+4
View File
@@ -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 = "<group>"; };
BF0C8E5D2BD5F7F93DF77DF5 /* sk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sk; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageView.swift; sourceTree = "<group>"; };
BF0C8E64BE7394A6591E74E1 /* LightImageLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageLayer.swift; sourceTree = "<group>"; };
BF0C8E7B087DCC97A454C0AE /* uk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = uk; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E94CDFB672663333546 /* lt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = lt; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E9D0B36A8872D5B7653 /* nb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = nb; path = InfoPlist.strings; sourceTree = "<group>"; };
@@ -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 */,
+16
View File
@@ -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() {
@@ -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
}
}
+9 -80
View File
@@ -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()
}
}
+15 -19
View File
@@ -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)])
}