Highlight NSWindow under mouse cursor - swift

Since this is quite a lot of code and it probably helps if there is a sample project where you can better understand the current problem I made a simple sample project which you can find on GitHub here: https://github.com/dehlen/Stackoverflow
I want to implement some functionality pretty similar what the macOS screenshot tool does. When the mouse hovers over a window the window should be highlighted. However I am having issues only highlighting the part of the window which is visible to the user.
Here is a screenshot of what the feature should look like:
My current implementation however looks like this:
My current implementation does the following:
1. Get a list of all windows visible on screen
static func all() -> [Window] {
let options = CGWindowListOption(arrayLiteral: .excludeDesktopElements, .optionOnScreenOnly)
let windowsListInfo = CGWindowListCopyWindowInfo(options, CGMainDisplayID()) //current window
let infoList = windowsListInfo as! [[String: Any]]
return infoList
.filter { $0["kCGWindowLayer"] as! Int == 0 }
.map { Window(
frame: CGRect(x: ($0["kCGWindowBounds"] as! [String: Any])["X"] as! CGFloat,
y: ($0["kCGWindowBounds"] as! [String: Any])["Y"] as! CGFloat,
width: ($0["kCGWindowBounds"] as! [String: Any])["Width"] as! CGFloat,
height: ($0["kCGWindowBounds"] as! [String: Any])["Height"] as! CGFloat),
applicationName: $0["kCGWindowOwnerName"] as! String)}
}
2. Get the mouse location
private func registerMouseEvents() {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
self.mouseLocation = NSEvent.mouseLocation
return $0
}
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved]) { _ in
self.mouseLocation = NSEvent.mouseLocation
}
}
3. Highlight the window at the current mouse location:
static func window(at point: CGPoint) -> Window? {
// TODO: only if frontmost
let list = all()
return list.filter { $0.frame.contains(point) }.first
}
var mouseLocation: NSPoint = NSEvent.mouseLocation {
didSet {
//TODO: don't highlight if its the same window
if let window = WindowList.window(at: mouseLocation), !window.isCapture {
highlight(window: window)
} else {
removeHighlight()
}
}
}
private func removeHighlight() {
highlightWindowController?.close()
highlightWindowController = nil
}
func highlight(window: Window) {
removeHighlight()
highlightWindowController = HighlightWindowController()
highlightWindowController?.highlight(frame: window.frame, animate: false)
highlightWindowController?.showWindow(nil)
}
class HighlightWindowController: NSWindowController, NSWindowDelegate {
// MARK: - Initializers
init() {
let bounds = NSRect(x: 0, y: 0, width: 100, height: 100)
let window = NSWindow(contentRect: bounds, styleMask: .borderless, backing: .buffered, defer: true)
window.isOpaque = false
window.level = .screenSaver
window.backgroundColor = NSColor.blue
window.alphaValue = 0.2
window.ignoresMouseEvents = true
super.init(window: window)
window.delegate = self
}
// MARK: - Public API
func highlight(frame: CGRect, animate: Bool) {
if animate {
NSAnimationContext.current.duration = 0.1
}
let target = animate ? window?.animator() : window
target?.setFrame(frame, display: false)
}
}
As you can see the window under the cursor is highlighted however the highlight window is drawn above other windows which might intersect.
Possible Solution
I could iterate over the available windows in the list and only find the rectangle which does not overlap with other windows to draw the highlight rect only for this part instead of the whole window.
I am asking myself whether the would be a more elegant and more performant solution to this problem. Maybe I could solve this with the window level of the drawn HighlightWindow? Or is there any API from Apple which I could leverage to get the desired behavior?

I messed around with your code, and #Ted is correct. NSWindow.order(_:relativeTo) is exactly what you need.
Why NSWindow.level wont work:
Using NSWindow.level will not work for you because normal windows (like the ones in your screenshot) all have a window level of 0, or .normal. If you simply adjusted the window level to, say "1" for instance, your highlight view would appear above all the other windows. On the contrary, if you set it to "-1" your highlight view would appear below all normal windows, and above the desktop.
Problems to be introduced using NSWindow.order(_: relativeTo)
No great solution comes without caveats right? In order to use this method you will have to set the window level to 0 so it can be layerd among the other windows. However, this will cause your highlighting window to be selected in your WindowList.window(at: mouseLocation) method. And when it's selected, your if-statement removes it because it believes it's the main window. This will cause a flicker. (a fix for this is included in the TLDR below)
Also, if you attempt to highlight a window that does not have a level of 0, you will run into issues. To fix such issues you need to find the window level of the window you are highlighting and set your highlighting window to that level. (my code didn't include a fix for this problem)
In addition to the above problems, you need to consider what happens when the user hovers over a background window, and clicks on it without moving the mouse. What will happen is the background window will become front.. without moving the highlight window. A possible fix for this would be to update the highlight window on click events.
Lastly, I noticed you create a new HighlightWindowController + window every time the user moves their mouse. It may be a bit lighter on the system if you simply mutate the frame of an already exsisting HighlightWindowController on mouse movement (instead of creating one). To hide it you could call the NSWindowController.close() function, or even set the frame to {0,0,0,0} (not sure about the 2nd idea).
TLDR; Show us some code
Here's what I did.
1. Change your window struct to include a window number:
struct Window {
let frame: CGRect
let applicationName: String
let windowNumber: Int
init(frame: CGRect, applicationName: String, refNumber: Int) {
self.frame = frame.flippedScreenBounds
self.applicationName = applicationName
self.windowNumber = refNumber
}
var isCapture: Bool {
return applicationName.caseInsensitiveCompare("Capture") == .orderedSame
}
}
2. In your window listing function ie static func all() -> [Window], include the window number:
refNumber: $0["kCGWindowNumber"] as! Int
3. In your window highlighting function, after highlightWindowController?.showWindow(nil), order the window relative to the window you are highlighting!
highlightWindowController!.window!.order(.above, relativeTo: window.windowNumber)
4. In your highlight controller, make sure to set the window level back to normal:
window.level = .normal
5. The window will now flicker, to prevent this, update your view controller if-statement:
if let window = WindowList.window(at: mouseLocation) {
if !window.isCapture {
highlight(window: window)
}
} else {
removeHighlight()
}
Best of luck and have fun swifting!
Edit:
I forgot to mention, my swift version is 4.2 (haven't upgraded yet) so the syntax may be ever so slightly different.

I'm not used to Swift, sorry, but it seems to me the natural solution to this would be to use - orderWindow:relativeTo:. In ObjC that would be (added just after the highlight window is shown):
[highlightWindow orderWindow:NSWindowAbove relativeTo:window];
And let the window server handle all the details of hiding obscured portions. Of course, this creates a different headache of keeping the highlight window directly above the target window as users move stuff around on-screen, but...

Related

How to trigger the hover state of a standardWindowButton?

The icon of the traffic light buttons aren't being shown when hovered on; you have to move your cursor the second time to show it.
What I am trying to do:
I'm trying to change how the traffic lights in NSWindow behave. The color of the traffics lights will be clear when inactive, and shown when active.
By active I mean hovered upon.
Problem:
The issue I'm having is that the icons of the buttons are not being shown, the colors on the other hand are shown. Look at the images below.
You have to move your cursor the second time to have the icons displayed.
What I have tried:
I have tried using .isHighlighted on the standardWindowButton, this does show the icons, however it changes the color to look like as if you clicked on that button; which I do not wish for. Look at the image below
Code:
I am using a trackingRectTag on the closeButton that has a rect big enough to include the minimize and zoom buttons. By default, the buttons are disabled, and when hovered upon it will be enabled. The contentTintColor property does not work on these window buttons which is why I have to use isEnabled.
Source Code:
class AXWindow: NSWindow, NSWindowDelegate {
var trackingTag: NSView.TrackingRectTag?
init() {
super.init(...)
updateTrackingAreas(true)
shouldEnableButtons(false)
}
override func mouseEntered(with theEvent: NSEvent) {
if trackingTag == theEvent.trackingNumber {
shouldEnableButtons(true)
}
}
override func mouseExited(with theEvent: NSEvent) {
if trackingTag == theEvent.trackingNumber {
shouldEnableButtons(false)
}
}
func updateTrackingAreas(_ establish : Bool) {
if let tag = trackingTag {
standardWindowButton(.closeButton)!.removeTrackingRect(tag)
}
if establish, let closeButton = standardWindowButton(.closeButton) {
let newBounds = NSRect(x: 0, y: 0, width: 55, height: 14.5)
trackingTag = closeButton.addTrackingRect(newBounds, owner: self, userData: nil, assumeInside: false)
}
}
fileprivate func shouldEnableButtons(_ b: Bool) {
standardWindowButton(.closeButton)!.isEnabled = b
standardWindowButton(.miniaturizeButton)!.isEnabled = b
standardWindowButton(.zoomButton)!.isEnabled = b
}
}
Result
Expected Result
Inactive State:
Using .isHighlighted
I haven't found a way to change this through changing the NSWindowButton. However, I have found a workaround. The best way to achieve this is by having two NSWindows.
We have a parent window and a child window. The application's contents will be located in the child window, and the parent window will be in the background.
Make sure the child window's height is reduced to show the title bar.
If you want to change the window's shape to show only the titleBar of the parent. You must look into different NSWindow shapes.

Cocoa customise NSView's tooltips Swift

I am trying to create a tooltip with bold text. Some apple apps on macOS use this behaviour. How do I achieve this?
My code currently
btn.tooltip = "Open Options"
//tooltip doesn't accept attributed strings.
Here is an example (screenshot of Xcode using this behaviour) of what I'm trying to achieve.
It seems there is no built-in default behavior for tooltips with NSAttributedStrings. As a solution, one could implement a floating NSPanel.
As long as the mouse is within the button bounds for at least a certain period of time, you could show a popover with an NSAttributedString. You can use the mouseEntered and mouseExited events for this purpose. Unfortunately, this requires that you subclass the NSButton.
Complete, Self-contained Swift Program
From a ViewController we would most likely to call it like this:
import Cocoa
class ViewController: NSViewController {
private let button = ToolTipButton()
override func viewDidLoad() {
super.viewDidLoad()
button.title = "Hoover over me"
let headline = "isEnabled"
let body = "A Boolean value that determines whether the label draws its text in an enabled state."
button.setToolTip(headline: headline, body: body)
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
The ToolTipButton class could look like this:
import Cocoa
class ToolTipButton: NSButton {
private var toolTipHandler: ToolTipHandler?
func setToolTip(headline: String, body: String) {
toolTipHandler = ToolTipHandler(headline: headline, body: body)
}
override func mouseEntered(with event: NSEvent) {
toolTipHandler?.mouseEntered(into: self)
}
override func mouseExited(with event: NSEvent) {
toolTipHandler?.mouseExited()
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
toolTipHandler?.updateTrackingAreas(for: self)
}
}
Finally the ToolTipHandler could look like this:
import Cocoa
final class ToolTipHandler {
private var headline: String
private var body: String
private var mouseStillInside = false
private var panel: NSPanel?
init(headline: String, body: String) {
self.headline = headline
self.body = body
}
func setToolTip(headline: String, body: String) {
self.headline = headline
self.body = body
}
func mouseEntered(into view: NSView) {
mouseStillInside = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.showToolTipIfMouseStillInside(for: view)
}
}
func mouseExited() {
mouseStillInside = false
panel?.close()
panel = nil
}
func updateTrackingAreas(for view: NSView) {
for trackingArea in view.trackingAreas {
view.removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
let trackingArea = NSTrackingArea(rect: view.bounds, options: options, owner: view, userInfo: nil)
view.addTrackingArea(trackingArea)
}
private func showToolTipIfMouseStillInside(for view: NSView) {
guard mouseStillInside && panel == nil else { return }
panel = Self.showToolTip(sender: view, headline: headline, body: body)
}
private static func showToolTip(sender: NSView, headline: String, body: String) -> NSPanel {
let panel = NSPanel()
panel.styleMask = [NSWindow.StyleMask.borderless]
panel.level = .floating
let attributedToolTip = Self.attributedToolTip(headline: headline, body: body)
panel.contentViewController = ToolTipViewController(attributedToolTip: attributedToolTip, width: 200.0)
let lowerLeftOfSender = sender.convert(NSPoint(x: sender.bounds.minX + 4.0, y: sender.bounds.maxY + 10.0), to: nil)
let newOrigin = sender.window?.convertToScreen(NSRect(origin: lowerLeftOfSender, size: .zero)).origin ?? .zero
panel.setFrameOrigin(newOrigin)
panel.orderFrontRegardless()
return panel
}
private static func attributedToolTip(headline: String, body: String) -> NSAttributedString {
let headlineAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.controlTextColor,
.font: NSFont.boldSystemFont(ofSize: 11)
]
let bodyAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.controlTextColor,
.font: NSFont.systemFont(ofSize: 11)
]
let tooltip = NSMutableAttributedString(string: headline, attributes: headlineAttributes)
tooltip.append(NSAttributedString(string: "\n" + body , attributes: bodyAttributes))
return tooltip
}
}
Finally the ToolTipViewController:
import Cocoa
final class ToolTipViewController: NSViewController {
private let attributedToolTip: NSAttributedString
private let width: CGFloat
init(attributedToolTip: NSAttributedString, width: CGFloat) {
self.attributedToolTip = attributedToolTip
self.width = width
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
let label = NSTextField()
label.isEditable = false
label.isBezeled = false
label.attributedStringValue = attributedToolTip
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1.0),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 1.0),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -1.0),
label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -1.0),
label.widthAnchor.constraint(equalToConstant: width)
])
}
}
Depending on the actual requirements, adjustments are probably necessary. But it should at least be a starting point.
Demo
The source code and full-length version of this answer are at this GitHub repo.
Separately from that repo I also extracted the code into a Swift Package, so I could use it in other projects. The dependency to add to your project is "https://github.com/chipjarred/CustomToolTip.git". Use "from" version 1.0.0 or branch "main".
What follows is the version trimmed down to a length SO would let me post.
Stephan's answer prompted me to do my own implementation of tool tips. My solution produces tool tips that look like the standard tool tips, except you can put any view you like inside them, so not just styled text, but images... you could even use a WebKit view, if you wanted to.
Obviously it doesn't make sense to put some kinds of views in it. Anything that only makes sense with user interaction would be meaningless since the tool tip would disappear as soon as they move the mouse cursor to interact with it... though that would be good April Fools joke.
Before I get to my solution, I want to mention that there is another way to make Stephan's solution a little easier to use, which is to use the "decorator" pattern by subclassing NSView to wrap another view. Your wrapper is the part that hooks into to the tool tips, and handles the tracking areas. Just make sure you forward those calls to the wrapped view too, in case it also has tracking areas (perhaps it changes the cursor or something, like NSTextView does.) Using a decorator means you don't subclass every view... just put the view you want to add a tool tip inside of a ToolTippableView or whatever you decide to call it. I don't think you'll need to override all NSView methods as long as you wrap the view by adding it to your subviews. The view heirarchy and responder chain should take care of dispatching the events and messages you're not interested in to the subview. You should only need to forward the ones you handle for the tool tips (mouseEntered, mouseExited, etc...)
My solution
However, I went to an evil extreme... and spent way more time on it than I probably should have, but it seemed like something I might want to use at some point. I swizzled ("monkey patched") NSView methods to handle custom tool tips, which combined with an extension on NSView means I don't have subclass anything to add custom tool tips, I can just write:
myView.customToolTip = myCustomToolTipContent
where myCustomToolTipContent is whatever NSView I want to display in the tool tip.
The Tool Tip itself
The main thing is the tool tip itself. It's just a window. It sizes itself to whatever content you put in it, so make sure you've set your tip content's view frame to the size you want before setting customToolTip. Here's the tool tip window code:
// -------------------------------------
/**
Window for displaying custom tool tips.
*/
class CustomToolTipWindow: NSWindow
{
// -------------------------------------
static func makeAndShow(
toolTipView: NSView,
for owner: NSView) -> CustomToolTipWindow
{
let window = CustomToolTipWindow(toolTipView: toolTipView, for: owner)
window.orderFront(self)
return window
}
// -------------------------------------
init(toolTipView: NSView, for toolTipOwner: NSView)
{
super.init(
contentRect: toolTipView.bounds,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
self.backgroundColor = NSColor.windowBackgroundColor
let border = BorderedView.init(frame: toolTipView.frame)
border.addSubview(toolTipView)
contentView = border
contentView?.isHidden = false
reposition(relativeTo: toolTipOwner)
}
// -------------------------------------
deinit { orderOut(nil) }
// -------------------------------------
/**
Place the tool tip window's frame in a sensible place relative to the
tool tip's owner view on the screen.
If the current layout direction is left-to-right, the preferred location is
below and shifted to the right relative to the owner. If the layout
direction is right-to-left, the preferred location is below and shift to
the left relative to the owner.
The preferred location is overridden when any part of the tool tip would be
drawn off of the screen. For conflicts with horizontal edges, it is moved
to be some "safety" distance within the screen bounds. For conflicts with
the bottom edge, the tool tip is positioned above the owning view.
Non-flipped coordinates (y = 0 at bottom) are assumed.
*/
func reposition(relativeTo toolTipOwner: NSView)
{
guard let ownerRect =
toolTipOwner.window?.convertToScreen(toolTipOwner.frame),
let screenRect = toolTipOwner.window?.screen?.visibleFrame
else { return }
let hPadding: CGFloat = ownerRect.width / 2
let hSafetyPadding: CGFloat = 20
let vPadding: CGFloat = 0
var newRect = frame
newRect.origin = ownerRect.origin
// Position tool tip window slightly below the onwer on the screen
newRect.origin.y -= newRect.height + vPadding
if NSApp.userInterfaceLayoutDirection == .leftToRight
{
/*
Position the tool tip window to the right relative to the owner on
the screen.
*/
newRect.origin.x += hPadding
// Make sure we're not drawing off the right edge
newRect.origin.x = min(
newRect.origin.x,
screenRect.maxX - newRect.width - hSafetyPadding
)
}
else
{
/*
Position the tool tip window to the left relative to the owner on
the screen.
*/
newRect.origin.x -= hPadding
// Make sure we're not drawing off the left edge
newRect.origin.x =
max(newRect.origin.x, screenRect.minX + hSafetyPadding)
}
/*
Make sure we're not drawing off the bottom edge of the visible area.
Non-flipped coordinates (y = 0 at bottom) are assumed.
If we are, move the tool tip above the onwer.
*/
if newRect.minY < screenRect.minY {
newRect.origin.y = ownerRect.maxY + vPadding
}
self.setFrameOrigin(newRect.origin)
}
// -------------------------------------
/// Provides thin border around the tool tip.
private class BorderedView: NSView
{
override func draw(_ dirtyRect: NSRect)
{
super.draw(dirtyRect)
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
context.setStrokeColor(NSColor.black.cgColor)
context.stroke(self.frame, width: 2)
}
}
}
The tool tip window is the easy part. This implementation positions the window relative to its owner (the view to which the tool tip is attached) while also avoiding drawing offscreen. I don't handle the pathalogical case where the tool tip is so large that it can't fit onto screen without obscuring the thing it's a tool tip for. Nor do I handle the case where the thing you're attaching the tool tip to is so large that even though the tool tip itself is a reasonable size, it can't go outside of the area occupied by the view to which it's attached. That case shouldn't be too hard to handle. I just didn't do it. I do handle responding to the currently set layout direction.
If you want to incorporate it into another solution, the code to show the tool tip is
let toolTipWindow = CustomToolTipWindow.makeAndShow(toolTipView: toolTipView, for: ownerView)
where toolTipView is the view to be displayed in the tool tip. ownerView is the view to which you're attaching the tool tip. You'll need to store toolTipWindow somewhere, for example in Stephan's ToolTipHandler.
To hide the tool tip:
toolTipWindow.orderOut(self)
or just set the last reference you keep to it to nil.
I think that gives you everything you need to incorporate it into another solution if you like.
Tool Tip handling code
As a small convenience, I use this extension on NSTrackingArea
// -------------------------------------
/*
Convenice extension for updating a tracking area's `rect` property.
*/
fileprivate extension NSTrackingArea
{
func updateRect(with newRect: NSRect) -> NSTrackingArea
{
return NSTrackingArea(
rect: newRect,
options: options,
owner: owner,
userInfo: nil
)
}
}
Since I'm swizzling NSVew (actually its subclasses as you add tool tips), I don't have a ToolTipHandler-like object. I just put it all in an extension on NSView and use global storage. To do that I have a ToolTipControl struct and a ToolTipControls wrapper around an array of them:
// -------------------------------------
/**
Data structure to hold information used for holding the tool tip and for
controlling when to show or hide it.
*/
fileprivate struct ToolTipControl
{
/**
`Date` when mouse was last moved within the tracking area. Should be
`nil` when the mouse is not in the tracking area.
*/
var mouseEntered: Date?
/// View to which the custom tool tip is attached
weak var onwerView: NSView?
/// The content view of the tool tip
var toolTipView: NSView?
/// `true` when the tool tip is currently displayed. `false` otherwise.
var isVisible: Bool = false
/**
The tool tip's window. Should be `nil` when the tool tip is not being
shown.
*/
var toolTipWindow: NSWindow? = nil
init(
mouseEntered: Date? = nil,
hostView: NSView,
toolTipView: NSView? = nil)
{
self.mouseEntered = mouseEntered
self.onwerView = hostView
self.toolTipView = toolTipView
}
}
// -------------------------------------
/**
Data structure for holding `ToolTipControl` instances. Since we only need
one collection of them for the application, all its methods and properties
are `static`.
*/
fileprivate struct ToolTipControls
{
private static var controlsLock = os_unfair_lock()
private static var controls: [ToolTipControl] = []
// -------------------------------------
static func getControl(for hostView: NSView) -> ToolTipControl? {
withLock { return controls.first { $0.onwerView === hostView } }
}
// -------------------------------------
static func setControl(for hostView: NSView, to control: ToolTipControl)
{
withLock
{
if let i = index(for: hostView) { controls[i] = control }
else { controls.append(control) }
}
}
// -------------------------------------
static func removeControl(for hostView: NSView)
{
withLock
{
controls.removeAll {
$0.onwerView == nil || $0.onwerView === hostView
}
}
}
// -------------------------------------
private static func index(for hostView: NSView) -> Int? {
controls.firstIndex { $0.onwerView == hostView }
}
// -------------------------------------
private static func withLock<R>(_ block: () -> R) -> R
{
os_unfair_lock_lock(&controlsLock)
defer { os_unfair_lock_unlock(&controlsLock) }
return block()
}
// -------------------------------------
private init() { } // prevent instances
}
These are fileprivate in the same file as my extension on NSView. I also have to have a way to differentiate between my tracking areas and others the view might have. They have a userInfo dictionary that I use for that. I don't need to store different individualized information in each one, so I just make a global one I reuse.
fileprivate let bundleID = Bundle.main.bundleIdentifier ?? "com.CustomToolTips"
fileprivate let toolTipKeyTag = bundleID + "CustomToolTips"
fileprivate let customToolTipTag = [toolTipKeyTag: true]
And I need a dispatch queue:
fileprivate let dispatchQueue = DispatchQueue(
label: toolTipKeyTag,
qos: .background
)
NSView extension
My NSView extension has a lot in it, the vast majority of which is private, including swizzled methods, so I'll break it into pieces
In order to be able to attach a custom tool tip as easily as you do for a standard tool tip, I provide a computed property. In addition to actually setting the tool tip view, it also checks to see if Self (that is the particular subclass of NSView) has already been swizzled, and does that if it hasn't been, and it's adds the mouse tracking area.
// -------------------------------------
/**
Adds a custom tool tip to the receiver. If set to `nil`, the custom tool
tip is removed.
This view's `frame.size` will determine the size of the tool tip window
*/
public var customToolTip: NSView?
{
get { toolTipControl?.toolTipView }
set
{
Self.initializeCustomToolTips()
if let newValue = newValue
{
addCustomToolTipTrackingArea()
var current = toolTipControl ?? ToolTipControl(hostView: self)
current.toolTipView = newValue
toolTipControl = current
}
else { toolTipControl = nil }
}
}
// -------------------------------------
/**
Adds a tracking area encompassing the receiver's bounds that will be used
for tracking the mouse for determining when to show the tool tip. If a
tacking area already exists for the receiver, it is removed before the
new tracking area is set. This method should only be called when a new
tool tip is attached to the receiver.
*/
private func addCustomToolTipTrackingArea()
{
if let ta = trackingAreaForCustomToolTip {
removeTrackingArea(ta)
}
addTrackingArea(
NSTrackingArea(
rect: self.bounds,
options:
[.activeInActiveApp, .mouseMoved, .mouseEnteredAndExited],
owner: self,
userInfo: customToolTipTag
)
)
}
// -------------------------------------
/**
Returns the custom tool tip tracking area for the receiver.
*/
private var trackingAreaForCustomToolTip: NSTrackingArea?
{
trackingAreas.first {
$0.owner === self && $0.userInfo?[toolTipKeyTag] != nil
}
}
trackingAreaForCustomToolTip is where I use the global tag to sort my tracking area from any others that the view might have.
Of course, I also have to implement updateTrackingAreas and this where we start to see some of evidence of swizzling.
// -------------------------------------
/**
Updates the custom tooltip tracking aread when `updateTrackingAreas` is
called.
*/
#objc private func updateTrackingAreas_CustomToolTip()
{
if let ta = trackingAreaForCustomToolTip
{
removeTrackingArea(ta)
addTrackingArea(ta.updateRect(with: self.bounds))
}
else { addCustomToolTipTrackingArea() }
callReplacedMethod(for: #selector(self.updateTrackingAreas))
}
The method isn't called updateTrackingAreas because I'm not overriding it in the usual sense. I actually replace the implementation of the current class's updateTrackingAreas with the implementation of my updateTrackingAreas_CustomToolTip, saving off the original implementation so I can forward to it. callReplacedMethod where I do that forwarding. If you look into swizzling, you find lots of examples where people call what looks like an infinite recursion, but isn't because they exchange method implementations. That works most of the time, but it can subtly mess up the underlying Objective-C messaging because the selector used to call the old method is no longer the original selector. The way I've done it preserves the selector, which makes it less fragile when something depends on the actual selector remaining the same. There's more on swizzling in the full answer on GitHub I linked to above. For now, think of callReplacedMethod as similar to calling super if I were doing this by subclassing.
Then there's scheduling to show the tool tip. I do this kind of similarly to Stephan, but I wanted the behavior that the tool tip isn't shown until the mouse stops moving for a certain delay (1 second is what I currently use).
As I'm writing this, I just noticed that I do deviate from the standard behavior once the tool tip is displayed. The standard behavior is that once the tool tip is shown it continues to show the tool tip even if the mouse is moved as long as it remains in the tracking area. So once shown, the standard behavior doesn't hide the tool tip until the mouse leaves the tracking area. I hide it as soon as you move the mouse. Doing it the standard way is actually simpler, but the way I do it would allow for the tool tip to be shown over large views (for example a NSTextView for a large document) where it has to actually in the same area of the screen that it's owner occupies. I don't currently position the tool tip that way, but if I were to, you'd want any mouse movement to hide the tool tip, otherwise the tool tip would obscure part of what you need to interact with.
Anyway, here's what that scheduling code looks like
// -------------------------------------
/**
Controls how many seconds the mouse must be motionless within the tracking
area in order to show the tool tip.
*/
private var customToolTipDelay: TimeInterval { 1 /* seconds */ }
// -------------------------------------
/**
Schedules to potentially show the tool tip after `delay` seconds.
The tool tip is not *necessarily* shown as a result of calling this method,
but rather this method begins a sequence of chained asynchronous calls that
determine whether or not to display the tool tip based on whether the tool
tip is already visible, and how long it's been since the mouse was moved
withn the tracking area.
- Parameters:
- delay: Number of seconds to wait until determining whether or not to
display the tool tip
- mouseEntered: Set to `true` when calling from `mouseEntered`,
otherwise set to `false`
*/
private func scheduleShowToolTip(delay: TimeInterval, mouseEntered: Bool)
{
guard var control = toolTipControl else { return }
if mouseEntered
{
control.mouseEntered = Date()
toolTipControl = control
}
let asyncDelay: DispatchTimeInterval = .milliseconds(Int(delay * 1000))
dispatchQueue.asyncAfter(deadline: .now() + asyncDelay) {
[weak self] in self?.scheduledShowToolTip()
}
}
// -------------------------------------
/**
Display the tool tip now, *if* the mouse is in the tracking area and has
not moved for at least `customToolTipDelay` seconds. Otherwise, schedule
to check again after a short delay.
*/
private func scheduledShowToolTip()
{
let repeatDelay: TimeInterval = 0.1
/*
control.mouseEntered is set to nil when exiting the tracking area,
so this guard terminates the async chain
*/
guard let control = self.toolTipControl,
let mouseEntered = control.mouseEntered
else { return }
if control.isVisible {
scheduleShowToolTip(delay: repeatDelay, mouseEntered: false)
}
else if Date().timeIntervalSince(mouseEntered) >= customToolTipDelay
{
DispatchQueue.main.async
{ [weak self] in
if let self = self
{
self.showToolTip()
self.scheduleShowToolTip(
delay: repeatDelay,
mouseEntered: false
)
}
}
}
else { scheduleShowToolTip(delay: repeatDelay, mouseEntered: false) }
}
Earlier I gave the code for how to show and hide the tool tip window. Here are the functions where that code lives with its interaction with toolTipControl to control the corresponding loop.
// -------------------------------------
/**
Displays the tool tip now.
*/
private func showToolTip()
{
guard var control = toolTipControl else { return }
defer
{
control.mouseEntered = Date.distantPast
toolTipControl = control
}
guard let toolTipView = control.toolTipView else
{
control.isVisible = false
return
}
if !control.isVisible
{
control.isVisible = true
control.toolTipWindow = CustomToolTipWindow.makeAndShow(
toolTipView: toolTipView,
for: self
)
}
}
// -------------------------------------
/**
Hides the tool tip now.
*/
private func hideToolTip(exitTracking: Bool)
{
guard var control = toolTipControl else { return }
control.mouseEntered = exitTracking ? nil : Date()
control.isVisible = false
let window = control.toolTipWindow
control.toolTipWindow = nil
window?.orderOut(self)
control.toolTipWindow = nil
toolTipControl = control
print("Hiding tool tip")
}
The only thing that's left before getting to the actual swizzling is handling the mouse movements. I do this with mouseEntered, mouseExited and mouseMoved, or rather, their swizzled implementations:
// -------------------------------------
/**
Schedules potentially showing the tool tip when the `mouseEntered` is
called.
*/
#objc private func mouseEntered_CustomToolTip(with event: NSEvent)
{
scheduleShowToolTip(delay: customToolTipDelay, mouseEntered: true)
callReplacedEventMethod(
for: #selector(self.mouseEntered(with:)),
with: event
)
}
// -------------------------------------
/**
Hides the tool tip if it's visible when `mouseExited` is called, cancelling
further `async` chaining that checks to show it.
*/
#objc private func mouseExited_CustomToolTip(with event: NSEvent)
{
hideToolTip(exitTracking: true)
callReplacedEventMethod(
for: #selector(self.mouseExited(with:)),
with: event
)
}
// -------------------------------------
/**
Hides the tool tip if it's visible when `mousedMoved` is called, and
resets the time for it to be displayed again.
*/
#objc private func mouseMoved_CustomToolTip(with event: NSEvent)
{
hideToolTip(exitTracking: false)
callReplacedEventMethod(
for: #selector(self.mouseMoved(with:)),
with: event
)
}
Sadly my original version of this post was too long, so I had to cut out the swizzling details, however, I put the whole thing on GitHub, with the complete source code, so you can look at it more in depth. I've never reached the length limit before.
So skipping to the end...
That puts everything in place (or would do if I could have posted the whole thing here), so now you just have to use it.
I was just using Xcode's default Cocoa App template to implement, so it uses a Storyboard (which normally I prefer not to). I just added an ordinary NSButton in the Storyboard. That means I don't start with a reference to it anywhere in the source code, so in ViewController, for the sake of building an example I just do a quick recursive search through the view hierarchy looking for an NSButton.
func findPushButton(in view: NSView) -> NSButton?
{
if let button = view as? NSButton { return button }
for subview in view.subviews
{
if let button = findPushButton(in: subview) {
return button
}
}
return nil
}
And I need to make a tool tip view. I wanted to demonstrate using more than just text, so I hacked this together
func makeCustomToolTip() -> NSView
{
let titleText = "Custom Tool Tip"
let bodyText = "\n\tThis demonstrates that its possible,\n\tand if I can do it, so you can you"
let titleFont = NSFont.systemFont(ofSize: 14, weight: .bold)
let title = NSAttributedString(
string: titleText,
attributes: [.font: titleFont]
)
let bodyFont = NSFont.systemFont(ofSize: 10)
let body = NSAttributedString(
string: bodyText,
attributes: [.font: bodyFont]
)
let attrStr = NSMutableAttributedString(attributedString: title)
attrStr.append(body)
let label = NSTextField(labelWithAttributedString: attrStr)
let imageView = NSImageView(frame: CGRect(origin: .zero, size: CGSize(width: label.frame.height, height: label.frame.height)))
imageView.image = #imageLiteral(resourceName: "Swift_logo")
let toolTipView = NSView(
frame: CGRect(
origin: .zero,
size: CGSize(
width: imageView.frame.width + label.frame.width + 15,
height: imageView.frame.height + 10
)
)
)
imageView.frame.origin.x += 5
imageView.frame.origin.y += 5
toolTipView.addSubview(imageView)
label.frame.origin.x += imageView.frame.maxX + 5
label.frame.origin.y += 5
toolTipView.addSubview(label)
return toolTipView
}
And then in viewDidLoad()
override func viewDidLoad()
{
super.viewDidLoad()
findPushButton(in: view)?.customToolTip = makeCustomToolTip()
}

How to set the window size and position programmatically for a SpriteKit/GameScene app on OSX

I have a bare-bones project created in Xcode as a SpriteKit/GameScene app. I want to set the window size programmatically. I've read a lot of answers here, and several tutorials elsewhere, but none of the things I've read have helped.
This answer talks about overriding WindowController.windowDidLoad, but GameScene doesn't give me a WindowController. It does give me a ViewController. This tutorial says you can call self.view.window?.setFrame() from ViewController.viewDidLoad(), but my window stubbornly remains the same size. A number of the answers I've found on SO talk about auto-layout. I don't have anything to lay out. It's just a SpriteKit starter project.
This answer says you can set preferredContentSize in ViewController.viewWillAppear(), and that does in fact let me set the window size, but if I make the window too big (because I had to guess at a legitimate size), it's not draggable or resizable. This answer says you can get the correct size from view.bounds.size, but that says 800 x 600, which is nowhere near the size of my screen. This answer says you can get the bounds from UIScreen.mainScreen().bounds.size, but my GameScene/SpriteKit starter project seems not to have any kind of UIScreen in it.
How can I get the screen size, and how can I set the window size and position programmatically?
Also, one of the other answers, which I can't seem to find any more, says you should delete GameScene.sks, which I did, and everything seems fine still, except for the size.
Updated for Swift 5
Window Size
Remember to add NSWindowDelegate to your NSViewController class if you wish to implement it there (ie. viewDidLoad(), viewWillAppear(), viewDidAppear(), etc.)
NSView
class ViewController: NSViewController, NSWindowDelegate {
override func viewWillAppear() {
fillWindow()
}
/// Sizes window to fill max screen size
func fillWindow() {
if let screenSize = view.window?.screen?.frame {
view.window!.setFrame(screenSize, display: true)
}
}
}
NSWindow
class WindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
fillWindow()
}
/// Sizes window to fill max screen size
func fillWindow() {
if let screenSize = window?.screen?.frame {
window!.setFrame(screenSize, display: true)
}
}
}
Print to Debugger Console
print(screenSize) // embed within if let screenSize { ... }
Window Position
See the full answer, with implemented code, here.
extension NSWindow {
/// Positions the `NSWindow` at the horizontal-vertical center of the `visibleFrame` (takes Status Bar and Dock sizes into account)
public func positionCenter() {
if let screenSize = screen?.visibleFrame.size {
self.setFrameOrigin(NSPoint(x: (screenSize.width-frame.size.width)/2, y: (screenSize.height-frame.size.height)/2))
}
}
/// Centers the window within the `visibleFrame`, and sizes it with the width-by-height dimensions provided.
public func setCenterFrame(width: Int, height: Int) {
if let screenSize = screen?.visibleFrame.size {
let x = (screenSize.width-frame.size.width)/2
let y = (screenSize.height-frame.size.height)/2
self.setFrame(NSRect(x: x, y: y, width: CGFloat(width), height: CGFloat(height)), display: true)
}
}
/// Returns the center x-point of the `screen.visibleFrame` (the frame between the Status Bar and Dock).
/// Falls back on `screen.frame` when `.visibleFrame` is unavailable (includes Status Bar and Dock).
public func xCenter() -> CGFloat {
if let screenSize = screen?.visibleFrame.size { return (screenSize.width-frame.size.width)/2 }
if let screenSize = screen?.frame.size { return (screenSize.width-frame.size.width)/2 }
return CGFloat(0)
}
/// Returns the center y-point of the `screen.visibleFrame` (the frame between the Status Bar and Dock).
/// Falls back on `screen.frame` when `.visibleFrame` is unavailable (includes Status Bar and Dock).
public func yCenter() -> CGFloat {
if let screenSize = screen?.visibleFrame.size { return (screenSize.height-frame.size.height)/2 }
if let screenSize = screen?.frame.size { return (screenSize.height-frame.size.height)/2 }
return CGFloat(0)
}
}
Usage
NSWindow
Positions the existing window to the center of visibleFrame.
window!.positionCenter()
Sets a new window frame, at the center of visibleFrame, with dimensions
window!.setCenterFrame(width: 900, height: 600)
NSView
Using xCenter() and yCenter() to get the central x-y points of the visibleFrame.
let x = self.view.window?.xCenter() ?? CGFloat(0)
let y = self.view.window?.yCenter() ?? CGFloat(0)
self.view.window?.setFrame(NSRect(x: x, y: y, width: CGFloat(900), height: CGFloat(600)), display: true)
It would be awesome if some UI guru could comment on whether this is the "right" answer, but here's what I did as a wild guess based on "El Tomato's" rather cryptic comments. First, add the following to ViewController.swift:
class WildGuessWindowController: NSWindowController {
override func windowDidLoad() {
if let screenSize = window?.screen?.frame {
window!.setFrame(screenSize, display: true)
print(screenSize)
}
super.windowDidLoad()
}
}
Next, open Main.storyboard in the project navigator. You might see something like the following:
Click on the box that says "Window Controller", and open the right-hand side panel. You should see something like this.
Notice the box next to the arrow, that has a grayed-out class name in it. (Also notice that you might have to click the "Identity inspector" button, the blue one above where it says "Custom Class".) Now click that drop-down, and you should see WildGuessWindowController. Select that, and you're set. Build and run. The framework will instantiate your class, and run windowDidLoad().

How to manage multiple NSWindows in a dictionary

What I'm trying to do:
I'm currently developing a Command-Line-Tool that creates a blurred window for each desktop space available. I save these windows in a dictionary where the spaceIdentifier is the keyand the NSWindow is the value.
In order to keep the windows in sync with the spaces I periodically check if there are new spaces or if spaces are closed.
My Code:
import AppKit
import Foundation
func createBlurWindow(screen: NSScreen?, contentRect: NSRect, brightness: Int) -> NSWindow
{
# irrelevant code that creates a blurred window
}
class AppController: NSObject, NSApplicationDelegate
{
var spaceWindowList: [NSNumber: NSWindow] = [:]
func applicationDidFinishLaunching(_ aNotification: Notification)
{
self.manageWindows()
Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(self.manageWindows), userInfo: nil, repeats: true)
}
#objc func manageWindows()
{
// create windows
for scr in NSScreen.screens {
let scrHeight = scr.frame.height
let scrWidth = scr.frame.width
let windowHeight = CGFloat(22)
let size = CGRect(x: 0, y: scrHeight-windowHeight, width: scrWidth, height: windowHeight)
let currentScreenSpaces = WindowHelper.getSpacesforScreen(scr)
for space in currentScreenSpaces!
{
if self.spaceWindowList[space] == nil
{
let w = createBlurWindow(screen: scr, contentRect: size, brightness: 9)
let windowID = NSNumber.init(value: w.windowNumber)
// #TODO this may fail if the space was created just now -> fix this by waiting some time
WindowHelper.moveWindow(windowID, toSpace: space)
self.spaceWindowList[space] = w
// debuginfo
let displayNr = scr.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]!
print(getTime(), " Display:", displayNr, " added Window(", windowID , ") for Space: ", space, separator: "")
}
}
}
let currentSpaces = WindowHelper.getAllSpaces()
for (space, window) in self.spaceWindowList
{
// destroy window if space was closed
if !(currentSpaces?.contains(space))!
{
window.close()
//self.spaceWindowList.removeValue(forKey: space)
// debuginfo
print(getTime(), "Destroyed window of closed Space:", space)
}
}
}
}
let app = NSApplication.shared
let controller = AppController()
app.delegate = controller
app.run()
The complete source can be found here
My Problem:
When detecting that a space was closed I try to programatically close the corresponding NSWindow, however the next time manageWindows() is called the application segfaults on this loop: for (space, window) in self.spaceWindowList. To debug I activated zombieobjects and got the following errormessage -[NSWindow retain]: message sent to deallocated instance 0x101241150
I don't understand why there is still a pointer to my window the next time spaceWindowList is iterated. I also tried to nil the dictionary entry with self.spaceWindowList.removeValue(forKey: space) but that also lead to a segfault.
I'm fairly new to swift and macdev in general so I'm not that confident with memory management in swift.
I would really appreciate if someone could tell me the best way to safely close windows organized in some kind of dictionary/map/whatever.
PS:
If you are wondering why I don't use .canJoinAllSpaces instead of creating the window for each space: When using .canJoinAllSpaces you can see the blurred NSEffectView flickering on a space change if the spaces have different desktop images. The only way to circumvent this is to create individual windows for each space.

How to reproduce this Xcode blue drag line

I'd like to reproduce the Xcode blue drag line in my app.
Do you know a way to code this ?
I know how to draw a line using Core Graphics ...
But this line has to be over the top of all other items (on the screen).
I'm posting this after you've posted your own answer, so this is probably a huge waste of time. But your answer only covers drawing a really bare-bones line on the screen and doesn't cover a bunch of other interesting stuff that you need to take care of to really replicate Xcode's behavior and even go beyond it:
drawing a nice connection line like Xcode's (with a shadow, an outline, and big rounded ends),
drawing the line across multiple screens,
using Cocoa drag and drop to find the drag target and to support spring-loading.
Here's a demo of what I'm going to explain in this answer:
In this github repo, you can find an Xcode project containing all the code in this answer plus the remaining glue code necessary to run a demo app.
Drawing a nice connection line like Xcode's
Xcode's connection line looks like an old-timey barbell. It has a straight bar of arbitrary length, with a circular bell at each end:
What do we know about that shape? The user provides the start and end points (the centers of the bells) by dragging the mouse, and our user interface designer specifies the radius of the bells and the thickness of the bar:
The length of the bar is the distance from startPoint to endPoint: length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y).
To simplify the process of creating a path for this shape, let's draw it in a standard pose, with the left bell at the origin and the bar parallel to the x axis. In this pose, here's what we know:
We can create this shape as a path by making a circular arc centered at the origin, connected to another (mirror image) circular arc centered at (length, 0). To create these arcs, we need this mysteryAngle:
We can figure out mysteryAngle if we can find any of the arc endpoints where the bell meets the bar. Specifically, we'll find the coordinates of this point:
What do we know about that mysteryPoint? We know it's at the intersection of the bell and the top of the bar. So we know it's at distance bellRadius from the origin, and at distance barThickness / 2 from the x axis:
So immediately we know that mysteryPoint.y = barThickness / 2, and we can use the Pythagorean theorem to compute mysteryPoint.x = sqrt(bellRadius² - mysteryPoint.y²).
With mysteryPoint located, we can compute mysteryAngle using our choice of inverse trigonometry function. Arcsine, I choose you! mysteryAngle = asin(mysteryPoint.y / bellRadius).
We now know everything we need to create the path in the standard pose. To move it from the standard pose to the desired pose (which goes from startPoint to endPoint, remember?), we'll apply an affine transform. The transform will translate (move) the path so the left bell is centered at startPoint and rotate the path so the right bell ends up at endPoint.
In writing the code to create the path, we want to be careful of a few things:
What if the length is so short that the bells overlap? We should handle that gracefully by adjusting mysteryAngle so the bells connect seamlessly with no weird “negative bar” between them.
What if bellRadius is smaller than barThickness / 2? We should handle that gracefully by forcing bellRadius to be at least barThickness / 2.
What if length is zero? We need to avoid division by zero.
Here's my code to create the path, handling all those cases:
extension CGPath {
class func barbell(from start: CGPoint, to end: CGPoint, barThickness proposedBarThickness: CGFloat, bellRadius proposedBellRadius: CGFloat) -> CGPath {
let barThickness = max(0, proposedBarThickness)
let bellRadius = max(barThickness / 2, proposedBellRadius)
let vector = CGPoint(x: end.x - start.x, y: end.y - start.y)
let length = hypot(vector.x, vector.y)
if length == 0 {
return CGPath(ellipseIn: CGRect(origin: start, size: .zero).insetBy(dx: -bellRadius, dy: -bellRadius), transform: nil)
}
var yOffset = barThickness / 2
var xOffset = sqrt(bellRadius * bellRadius - yOffset * yOffset)
let halfLength = length / 2
if xOffset > halfLength {
xOffset = halfLength
yOffset = sqrt(bellRadius * bellRadius - xOffset * xOffset)
}
let jointRadians = asin(yOffset / bellRadius)
let path = CGMutablePath()
path.addArc(center: .zero, radius: bellRadius, startAngle: jointRadians, endAngle: -jointRadians, clockwise: false)
path.addArc(center: CGPoint(x: length, y: 0), radius: bellRadius, startAngle: .pi + jointRadians, endAngle: .pi - jointRadians, clockwise: false)
path.closeSubpath()
let unitVector = CGPoint(x: vector.x / length, y: vector.y / length)
var transform = CGAffineTransform(a: unitVector.x, b: unitVector.y, c: -unitVector.y, d: unitVector.x, tx: start.x, ty: start.y)
return path.copy(using: &transform)!
}
}
Once we have the path, we need to fill it with the correct color, stroke it with the correct color and line width, and draw a shadow around it. I used Hopper Disassembler on IDEInterfaceBuilderKit to figure out Xcode's exact sizes and colors. Xcode draws it all into a graphics context in a custom view's drawRect:, but we'll make our custom view use a CAShapeLayer. We won't end up drawing the shadow precisely the same as Xcode, but it's close enough.
class ConnectionView: NSView {
struct Parameters {
var startPoint = CGPoint.zero
var endPoint = CGPoint.zero
var barThickness = CGFloat(2)
var ballRadius = CGFloat(3)
}
var parameters = Parameters() { didSet { needsLayout = true } }
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
let shapeLayer = CAShapeLayer()
override func makeBackingLayer() -> CALayer { return shapeLayer }
override func layout() {
super.layout()
shapeLayer.path = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness, bellRadius: parameters.ballRadius)
shapeLayer.shadowPath = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness + shapeLayer.lineWidth / 2, bellRadius: parameters.ballRadius + shapeLayer.lineWidth / 2)
}
private func commonInit() {
wantsLayer = true
shapeLayer.lineJoin = kCALineJoinMiter
shapeLayer.lineWidth = 0.75
shapeLayer.strokeColor = NSColor.white.cgColor
shapeLayer.fillColor = NSColor(calibratedHue: 209/360, saturation: 0.83, brightness: 1, alpha: 1).cgColor
shapeLayer.shadowColor = NSColor.selectedControlColor.blended(withFraction: 0.2, of: .black)?.withAlphaComponent(0.85).cgColor
shapeLayer.shadowRadius = 3
shapeLayer.shadowOpacity = 1
shapeLayer.shadowOffset = .zero
}
}
We can test this in a playground to make sure it looks good:
import PlaygroundSupport
let view = NSView()
view.setFrameSize(CGSize(width: 400, height: 200))
view.wantsLayer = true
view.layer!.backgroundColor = NSColor.white.cgColor
PlaygroundPage.current.liveView = view
for i: CGFloat in stride(from: 0, through: 9, by: CGFloat(0.4)) {
let connectionView = ConnectionView(frame: view.bounds)
connectionView.parameters.startPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50)
connectionView.parameters.endPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50 + CGFloat(i))
view.addSubview(connectionView)
}
let connectionView = ConnectionView(frame: view.bounds)
connectionView.parameters.startPoint = CGPoint(x: 50, y: 100)
connectionView.parameters.endPoint = CGPoint(x: 350, y: 150)
view.addSubview(connectionView)
Here's the result:
Drawing across multiple screens
If you have multiple screens (displays) attached to your Mac, and if you have “Displays have separate Spaces” turned on (which is the default) in the Mission Control panel of your System Preferences, then macOS will not let a window span two screens. This means that you can't use a single window to draw the connecting line across multiple monitors. This matters if you want to let the user connect an object in one window to an object in another window, like Xcode does:
Here's the checklist for drawing the line, across multiple screens, on top of our other windows:
We need to create one window per screen.
We need to set up each window to fill its screen and be completely transparent with no shadow.
We need to set the window level of each window to 1 to keep it above our normal windows (which have a window level of 0).
We need to tell each window not to release itself when closed, because we don't like mysterious autorelease pool crashes.
Each window needs its own ConnectionView.
To keep the coordinate systems uniform, we'll adjust the bounds of each ConnectionView so that its coordinate system matches the screen coordinate system.
We'll tell each ConnectionView to draw the entire connecting line; each view will clip what it draws to its own bounds.
It probably won't happen, but we'll arrange to be notified if the screen arrangement changes. If that happens, we'll add/remove/update windows to cover the new arrangement.
Let's make a class to encapsulate all these details. With an instance of LineOverlay, we can update the start and end points of the connection as needed, and remove the overlay from the screen when we're done.
class LineOverlay {
init(startScreenPoint: CGPoint, endScreenPoint: CGPoint) {
self.startScreenPoint = startScreenPoint
self.endScreenPoint = endScreenPoint
NotificationCenter.default.addObserver(self, selector: #selector(LineOverlay.screenLayoutDidChange(_:)), name: .NSApplicationDidChangeScreenParameters, object: nil)
synchronizeWindowsToScreens()
}
var startScreenPoint: CGPoint { didSet { setViewPoints() } }
var endScreenPoint: CGPoint { didSet { setViewPoints() } }
func removeFromScreen() {
windows.forEach { $0.close() }
windows.removeAll()
}
private var windows = [NSWindow]()
deinit {
NotificationCenter.default.removeObserver(self)
removeFromScreen()
}
#objc private func screenLayoutDidChange(_ note: Notification) {
synchronizeWindowsToScreens()
}
private func synchronizeWindowsToScreens() {
var spareWindows = windows
windows.removeAll()
for screen in NSScreen.screens() ?? [] {
let window: NSWindow
if let index = spareWindows.index(where: { $0.screen === screen}) {
window = spareWindows.remove(at: index)
} else {
let styleMask = NSWindowStyleMask.borderless
window = NSWindow(contentRect: .zero, styleMask: styleMask, backing: .buffered, defer: true, screen: screen)
window.contentView = ConnectionView()
window.isReleasedWhenClosed = false
window.ignoresMouseEvents = true
}
windows.append(window)
window.setFrame(screen.frame, display: true)
// Make the view's geometry match the screen geometry for simplicity.
let view = window.contentView!
var rect = view.bounds
rect = view.convert(rect, to: nil)
rect = window.convertToScreen(rect)
view.bounds = rect
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
window.isOneShot = true
window.level = 1
window.contentView?.needsLayout = true
window.orderFront(nil)
}
spareWindows.forEach { $0.close() }
}
private func setViewPoints() {
for window in windows {
let view = window.contentView! as! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}
}
Using Cocoa drag and drop to find the drag target and perform spring-loading
We need a way to find the (potential) drop target of the connection as the user drags the mouse around. It would also be nice to support spring loading.
In case you don't know, spring loading is a macOS feature in which, if you hover a drag over a container for a moment, macOS will automatically open the container without interrupting the drag. Examples:
If you drag onto a window that's not the frontmost window, macOS will bring the window to the front.
if you drag onto a Finder folder icon, and the Finder will open the folder window to let you drag onto an item in the folder.
If you drag onto a tab handle (at the top of the window) in Safari or Chrome, the browser will select the tab, letting you drop your item in the tab.
If you control-drag a connection in Xcode onto a menu item in the menu bar in your storyboard or xib, Xcode will open the item's menu.
If we use the standard Cocoa drag and drop support to track the drag and find the drop target, then we'll get spring loading support “for free”.
To support standard Cocoa drag and drop, we need to implement the NSDraggingSource protocol on some object, so we can drag from something, and the NSDraggingDestination protocol on some other object, so we can drag to something. We'll implement NSDraggingSource in a class called ConnectionDragController, and we'll implement NSDraggingDestination in a custom view class called DragEndpoint.
First, let's look at DragEndpoint (an NSView subclass). NSView already conforms to NSDraggingDestination, but doesn't do much with it. We need to implement four methods of the NSDraggingDestination protocol. The drag session will call these methods to let us know when the drag enters and leaves the destination, when the drag ends entirely, and when to “perform” the drag (assuming this destination was where the drag actually ended). We also need to register the type of dragged data that we can accept.
We want to be careful of two things:
We only want to accept a drag that is a connection attempt. We can figure out whether a drag is a connection attempt by checking whether the source is our custom drag source, ConnectionDragController.
We'll make DragEndpoint appear to be the drag source (visually only, not programmatically). We don't want to let the user connect an endpoint to itself, so we need to make sure the endpoint that is the source of the connection cannot also be used as the target of the connection. We'll do that using a state property that tracks whether this endpoint is idle, acting as the source, or acting as the target.
When the user finally releases the mouse button over a valid drop destination, the drag session makes it the destination's responsibility to “perform” the drag by sending it performDragOperation(_:). The session doesn't tell the drag source where the drop finally happened. But we probably want to do the work of making the connection (in our data model) back in the source. Think about how it works in Xcode: when you control-drag from a button in Main.storyboard to ViewController.swift and create an action, the connection is not recorded in ViewController.swift where the drag ended; it's recorded in Main.storyboard, as part of the button's persistent data. So when the drag session tells the destination to “perform” the drag, we'll make our destination (DragEndpoint) pass itself back to a connect(to:) method on the drag source where the real work can happen.
class DragEndpoint: NSView {
enum State {
case idle
case source
case target
}
var state: State = State.idle { didSet { needsLayout = true } }
public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard case .idle = state else { return [] }
guard (sender.draggingSource() as? ConnectionDragController)?.sourceEndpoint != nil else { return [] }
state = .target
return sender.draggingSourceOperationMask()
}
public override func draggingExited(_ sender: NSDraggingInfo?) {
guard case .target = state else { return }
state = .idle
}
public override func draggingEnded(_ sender: NSDraggingInfo?) {
guard case .target = state else { return }
state = .idle
}
public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let controller = sender.draggingSource() as? ConnectionDragController else { return false }
controller.connect(to: self)
return true
}
override init(frame: NSRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
private func commonInit() {
wantsLayer = true
register(forDraggedTypes: [kUTTypeData as String])
}
// Drawing code omitted here but is in my github repo.
}
Now we can implement ConnectionDragController to act as the drag source and to manage the drag session and the LineOverlay.
To start a drag session, we have to call beginDraggingSession(with:event:source:) on a view; it'll be the DragEndpoint where the mouse-down event happened.
The session notifies the source when the drag actually starts, when it moves, and when it ends. We use those notifications to create and update the LineOverlay.
Since we're not providing any images as part of our NSDraggingItem, the session won't draw anything being dragged. This is good.
By default, if the drag ends outside of a valid destination, the session will animate… nothing… back to the start of the drag, before notifying the source that the drag has ended. During this animation, the line overlay hangs around, frozen. It looks broken. We tell the session not to animate back to the start to avoid this.
Since this is just a demo, the “work” we do to connect the endpoints in connect(to:) is just printing their descriptions. In a real app, you'd actually modify your data model.
class ConnectionDragController: NSObject, NSDraggingSource {
var sourceEndpoint: DragEndpoint?
func connect(to target: DragEndpoint) {
Swift.print("Connect \(sourceEndpoint!) to \(target)")
}
func trackDrag(forMouseDownEvent mouseDownEvent: NSEvent, in sourceEndpoint: DragEndpoint) {
self.sourceEndpoint = sourceEndpoint
let item = NSDraggingItem(pasteboardWriter: NSPasteboardItem(pasteboardPropertyList: "\(view)", ofType: kUTTypeData as String)!)
let session = sourceEndpoint.beginDraggingSession(with: [item], event: mouseDownEvent, source: self)
session.animatesToStartingPositionsOnCancelOrFail = false
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
switch context {
case .withinApplication: return .generic
case .outsideApplication: return []
}
}
func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
sourceEndpoint?.state = .source
lineOverlay = LineOverlay(startScreenPoint: screenPoint, endScreenPoint: screenPoint)
}
func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) {
lineOverlay?.endScreenPoint = screenPoint
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
lineOverlay?.removeFromScreen()
sourceEndpoint?.state = .idle
}
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { return true }
private var lineOverlay: LineOverlay?
}
That's all you need. As a reminder, you can find a link at the top of this answer to a github repo containing a complete demo project.
Using a transparent NSWindow :
var window: NSWindow!
func createLinePath(from: NSPoint, to: NSPoint) -> CGPath {
let path = CGMutablePath()
path.move(to: from)
path.addLine(to: to)
return path
}
override func viewDidLoad() {
super.viewDidLoad()
//Transparent window
window = NSWindow()
window.styleMask = .borderless
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
//Line
let line = CAShapeLayer()
line.path = createLinePath(from: NSPoint(x: 0, y: 0), to: NSPoint(x: 100, y: 100))
line.lineWidth = 10.0
line.strokeColor = NSColor.blue.cgColor
//Update
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
let newPos = NSEvent.mouseLocation()
line.path = self.createLinePath(from: NSPoint(x: 0, y: 0), to: newPos)
return $0
}
window.contentView!.layer = line
window.contentView!.wantsLayer = true
window.setFrame(NSScreen.main()!.frame, display: true)
window.makeKeyAndOrderFront(nil)
}
Trying to adopt Rob Mayoff's excellent solution above into my own project's interface, which is based around an NSOutlineView, I ran into a few problems. In case it helps anyone trying to achieve the same thing, I'll detail those pitfalls in this answer.
The sample code provided in the solution detects the start of a drag by implementing mouseDown(with:) on the view controller, and then calling hittest() on the window's content view in order to obtain the DragEndpoint subview where the (potential) drag is originating. When using outline views, this causes two pitfalls detailed in the next sections.
1. Mouse-Down Event
It seems that when a table view or outline view is involved, mouseDown(with:) never gets called on the view controller, and we need to instead override that method in the outline view itself.
2. Hit Testing
NSTableView -and by extension, NSOutlineView- overrides the NSResponder method validateProposedFirstResponder(_:for:), and this causes the hittest() method to fail: it always returns the outline view itself, and all subviews (including our target DragEndpoint subview inside the cell) remain inaccessible.
From the documentation:
Views or controls in a table sometimes need to respond to incoming
events. To determine whether a particular subview should receive the
current mouse event, a table view calls
validateProposedFirstResponder:forEvent: in its implementation of
hitTest. If you create a table view subclass, you can override
validateProposedFirstResponder:forEvent: to specify which views can
become the first responder. In this way, you receive mouse events.
At first I tried overriding:
override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool {
if responder is DragEndpoint {
return true
}
return super.validateProposedFirstResponder(responder, for: event)
}
...and it worked, but reading the documentation further suggests a smarter, less intrusive approach:
The default NSTableView implementation of
validateProposedFirstResponder:forEvent: uses the following logic:
Return YES for all proposed first responder views unless they are
instances or subclasses of NSControl.
Determine whether the proposed
first responder is an NSControl instance or subclass. If the control
is an NSButton object, return YES. If the control is not an NSButton,
call the control’s hitTestForEvent:inRect:ofView: to see whether the
hit area is trackable (that is, NSCellHitTrackableArea) or is an
editable text area (that is, NSCellHitEditableTextArea), and return
the appropriate value. Note that if a text area is hit, NSTableView
also delays the first responder action.
(emphasis mine)
...which is weird, because it feels like it should say:
Return NO for all proposed first responder views unless they are
instances or subclasses of NSControl.
, but anyway, I instead modified Rob's code to make DragEndpoint a subclass of NSControl (not just NSView), and that works too.
3. Managing the Dragging Session
Because NSOutlineView only exposes a limited number of drag-and-drop events through its data source protocol (and the drag session itself can not be meaningfully modified from the data source's side), it seems that taking full control of the drag session is not possible unless we subclass the outline view and override the NSDraggingSource methods.
Only by overriding draggingSession(_:willBeginAt:) at the outline view itself can we prevent calling the superclass implementation and starting an actual item drag (which displays the dragged row image).
We could start a separate drag session from the mouseDown(with:) method of the DragEndpoint subview: when implemented, it is called before the same method on the outline view (which in turn is what triggers the dragging session to be started). But if we move the dragging session away from the outline view, it seems like it will be impossible to have springloading "for free" when dragging above an expandable item.
So instead, I discarded the ConnectionDragController class and moved all its logic to the outline view subclass: the tackDrag() method, the active DragEndpoint property, and all methods of the NSDraggingSource protocol into the outline view.
Ideally, I would have liked to avoid subclassing NSOutlineView (it is discouraged) and instead implement this behaviour more cleanly, exclusively through the outline view's delegate/data source and/or external classes (like the original ConnectionDragController), but it seems that it is impossible.
I haven't got the springloading part to work yet (it was working at a moment, but not now so I'm still looking into it...).
I too made a sample project, but I'm still fixing minor issues. I'll post a link to the GiHub repository as soon as it is ready.