Custom Carbon key event handler fails after mouse events - swift

I'm trying to write a custom NSMenu which will be able to list for key input and intercept the necessary events. This is to provide a simple search-as-you-type functionality for my open source clipboard manager.
It seems like the only way to do this is to install a custom Carbon event handler which will listen for key events and handler them accordingly, but it seems like there is an issue with such a custom handler.
Normally, I can propagate events downwards to other handlers (e.g. system ones) and they should be gracefully handled. This can be done by a simple callback:
let eventHandlerCallback: EventHandlerUPP = { eventHandlerCallRef, eventRef, userData in
let response = CallNextEventHandler(eventHandlerCallRef, eventRef!)
print("Response \(response)")
return response
}
This callback works perfectly and prints Response 0 all the time. This response means that the event is handled correctly.
However, things get weird once we send mouse events before keyboard events. In such case, the callback fails and prints Response -9874. This response means that the event was not handled correctly.
It seems like the event fails to be handled somewhere below my custom view and I don't know where exactly or how to overcome this issue.
To reproduce, I've uploaded the code to Gist which can be added to XCode playground and run. Once you see menu popup, press some keys (preferably arrow keys as they won't close the menu) and observe Response 0 in the console. After that, move cursor inside the menu and press more arrow keys. You should see Response -9874 in the console now.

Unclear if you have an NSTextField as your menu view, but if you use one then it's easy to setup a delegate for that text field that can get the current contents of the field as the user types (this takes care of them moving backwards with the arrow keys and then deleting characters, using the delete key, etc). Your delegate implements the appropriate delegate method and gets called each time the text changes:
extension CustomMenuItemViewController: NSTextFieldDelegate {
func controlTextDidChange( _ obj: Notification) {
if let postingObject = obj.object as? NSTextField {
let text = postingObject.stringValue
print("the text is now: \(text)")
}
}
}
Just to confirm this works as expected, I created the ViewController class for the custom menu item views (label + edit field) in a xib file and then dynamically built a simple test menu with a single menu item that has the custom view controller's view and added it to the menubar inside my app delegate:
func installCustomMenuItem() {
let menuBarItem = NSMenuItem(title: "Test", action: nil, keyEquivalent: "")
let menu = NSMenu(title: "TestMenu" )
let subMenuBarItem = NSMenuItem(title: "Custom View", action: nil, keyEquivalent: "")
subMenuBarItem.view = menuItemVC.view
menu.addItem(subMenuBarItem)
menuBarItem.submenu = menu
NSApp.mainMenu?.addItem(menuBarItem)
}
Looks like this after I typed "hello":
And you can from the console that my handler got called for every character typed:
the text is now: H
the text is now: He
the text is now: Hel
the text is now: Hell
the text is now: Hello
Your situation is probably a little different, but it seems like this approach is very clean and might work for you. If it won't for some reason, add a clarifying comment and we'll see if we can't make it work for you.
Addition:
It occurred to me that you might wish to not use NSTextField and so I was curious if it was as easy to do this with a custom view and it's relatively easy.
Make a subclass of NSView:
class CustomMenuView: NSView {
override var acceptsFirstResponder: Bool {
return true
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
// Drawing code here.
}
override func keyDown(with event: NSEvent) {
print("key down with character: \(String(describing: event.characters)) " )
}
}
Set the class of your root view in the custom view controller to be of this type of class and then all done as before - view controller loaded in applicationDidFinishLaunching and menus built and view controller's view (which is now a CustomMenuView) is set as the menuBarItem.view.
That's it. You now get your keyDown method called for every key down when the menu is dropped down.
key down with character: Optional("H")
key down with character: Optional("e")
key down with character: Optional("l")
key down with character: Optional("l")
key down with character: Optional("o")
key down with character: Optional(" ")
key down with character: Optional("T")
key down with character: Optional("h")
key down with character: Optional("i")
key down with character: Optional("s")
key down with character: Optional(" ")
key down with character: Optional("i")
key down with character: Optional("s")
key down with character: Optional(" ")
key down with character: Optional("c")
key down with character: Optional("o")
key down with character: Optional("o")
key down with character: Optional("l")
:)
Now your custom view (and subviews if you like) can do their own drawing and so on.
Addition with requested sample without the ViewController:
// Simple swift playground test
// The pop-up menu will show up onscreen in the playground at a fixed location.
// Click in the popup and then all key commands will be logged.
// The ViewController in my example above may be taking care of putting the custom view in the responder chain, or the fact that it's in a menubar and being invoked via a MenuItem might be.
// I'd suggest trying it in the actual environment rather than in a playground. In my test app you click the menu name in the menubar to drop down the menu and it is added to the responder chain and works as expected without having to click in the menu first to get the events flowing.
// There is no reason you need to be hooking events either with carbon events or the newer format. If you're in the responder chain of and implement the necessary, method then you'll get the key events you're looking for.
import AppKit
class CustomMenuView: NSView {
override var acceptsFirstResponder: Bool {
return true
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
// Drawing code here.
}
override func keyDown(with event: NSEvent) {
print("key down with character: \(String(describing: event.characters)) " )
}
}
func installCustomMenuItem() -> NSMenu {
// let menuBarItem = NSMenuItem(title: "Test", action: nil, keyEquivalent: "")
let resultMenu = NSMenu(title: "TestMenu" )
let subMenuBarItem = NSMenuItem(title: "Custom View", action: nil, keyEquivalent: "")
subMenuBarItem.view = CustomMenuView(frame: NSRect(x: 0, y: 0, width: 40, height: 44))
resultMenu.addItem(subMenuBarItem)
// menuBarItem.submenu = menu
return resultMenu
}
var menu = installCustomMenuItem()
menu.popUp(positioning: nil, at: NSPoint(x:600,y:400), in: nil)

I didn't manage to figure out why this issue was happening or how to fix it, but I understood that it's possible to work around this issue by intercepting all the keys and simulating their behavior manually.
For example, this is how I now handle down arrow key which is supposed to select next item in menu list:
class Menu: NSMenu {
func selectNext() {
var indexToHighlight = 1
if let item = highlightedItem {
indexToHighlight = index(of: item) + 1
}
if let itemToHighlight = self.item(at: indexToHighlight) {
let highlightItemSelector = NSSelectorFromString("highlightItem:")
perform(highlightItemSelector, with: itemToHighlight)
if itemToHighlight.isSeparatorItem || !itemToHighlight.isEnabled || itemToHighlight.isHidden {
selectNext()
}
}
}
}
This way, when I receive a key down event with down arrow key - I can just call the function and return true to prevent the event from reaching default NSMenu handler. Similarly, up arrow key can be done.
In case of a return key, I ended up with the following code:
class Menu: NSMenu {
func select() {
if let item = highlightedItem {
performActionForItem(at: index(of: item))
cancelTracking()
}
}
}
The full commit implementing this is https://github.com/p0deje/Maccy/commit/158610d1d.

Related

How to detect Option key down in Status bar app

I have a Swift Cocoa app that runs as an NSStatusItem, i.e., it presents a dropdown menu in Mac's top-right status bar. I would like to display extra "advanced" menu items when the user holds down the Option key while opening my status item menu. I can detect presses of the Option key, but my problem is that I cannot determine whether the key is down or up.
Here is the code I have so far in my AppDelegate.swift:
func applicationDidFinishLaunching(_ aNotification: Notification) {
NSEvent.addGlobalMonitorForEvents(matching: NSEvent.EventTypeMask.flagsChanged, handler: keyEvent);
}
func keyEvent(event: NSEvent) {
if let event = NSApp.currentEvent {
if event.modifierFlags.contains(.option) {
print("option key up/down event")
}
}
}
This works insofar as it prints the message when the Option key is either pressed down or released back up. The problem is that I don't know how to determine which state the Option key is in at any given time.
How can I adapt this code to know if the Option key is down or up?
EDIT: SO suggested that I edit my question to explain why it is not answered by Hide/Show menu item in application's main menu by pressing Option key. Briefly, the answers on that page, while probably workable, seemed either overly complex (adding timers, adding run loop observers, adding zero height custom views) or they were creating menu item alternates whereas I was trying to add additional menu items. Nevertheless, it is a good page and I recommend studying it if you also have this question.
Just ask for the current event and return a Bool, it's true when the key is pressed at the moment
func isOptionKeyPressed() -> Bool {
return NSEvent.modifierFlags.contains(.option)
}
addGlobalMonitorForEvents is not needed
I was able to find a working solution, inspired by (but not the same as) another answer here posted by vadian. This is my solution.
Eliminate the addGlobalMonitorForEvents and keyEvent code in my original question.
Create an an NSMenuDelegate like so:
class AppDelegate: NSObject, NSApplicationDelegate {
#IBOutlet weak var extrasSeparator: NSMenuItem?
#IBOutlet weak var extrasSubmenu: NSMenuItem?
[...rest of AppDelegate...]
}
extension AppDelegate: NSMenuDelegate {
func menuWillOpen(_ menu: NSMenu) {
if NSEvent.modifierFlags.contains(.option) {
extrasSeparator?.isHidden = false
extrasSubmenu?.isHidden = false
} else {
extrasSeparator?.isHidden = true
extrasSubmenu?.isHidden = true
}
}
}
Caveat: this solution only works if the user presses and holds the Option key before clicking the NSStatusItem's icon. That's what was asked in the original question, but it may not be desirable in all cases.

NSComboBox disappears immediately after click

I'm working on an application which behaves similarly to Spotlight. However, currently I'm having a trouble with NSComboBox.
To show the application after hitting a hotkey I use activate(ignoringOtherApps: true) and NSWindowController(window: window).showWindow(self). Then when user hits Escape I do window.close() and hide(self).
Everything works great, previous application gets focus. However, when I open again the window, first click on NSComboBox causes a very strange behavior (like on the movie below). First time it instantly disappears.
I found out that it happens because of NSApp.hide. When I don't call it, everything works well. However, I need to call it, because I want the previous app to get the focus.
To workaround this issue I can replace NSWindow with nonactivating NSPanel. It resolves the problem. However, it's not possible in my case because I need to use it also with presentAsModalWindow and presentAsSheet where I can't control whether it's a window or panel.
I also discovered that a single click on window's background before clicking on ComboBox helps. So it seems like this window doesn't have focus, but looks like focused. I also tried all methods like makeKeyAndOrderFront, becomeFirstResponder, NSApp.unhide etc. etc. Nothing helps.
Under the hood NSComboBox has its own window NSComboBoxWindow, so my guess is that when I click it opens its window and then it receives information that the parent window took focus and dismisses itself for some reason.
I'm not sure if this is Cocoa bug or what. Is there any way to fix it?
Minimum Reproducible Example
Create new macOS project with NSComboBox and NSButton. Connect button to IBAction.
import Cocoa
class ViewController: NSViewController {
#IBAction func close(_ sender: Any) {
view.window?.close()
NSApp.hide(self)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
self.view.window?.makeKeyAndOrderFront(self)
NSApp.activate(ignoringOtherApps: true)
}
}
}
Workaround
Finally, I managed to create a workaround. It's ugly but it works. I cover arrow part with a transparent view, intercept click and invoke two times expand via accessibility...
import Cocoa
final class ClickView: NSView {
var onMouseDown: () -> (Bool) = { return false }
override func mouseDown(with event: NSEvent) {
if !onMouseDown() {
super.mouseDown(with: event)
}
}
}
final class FixedComboBox: NSComboBox {
private let clickView = ClickView()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
fix()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func awakeFromNib() {
super.awakeFromNib()
fix()
}
private func fix() {
clickView.onMouseDown = { [weak self] in
guard let cell = self?.cell else { return false }
// first expand will be immediately closed because of Cocoa bug
cell.setAccessibilityExpanded(true)
// we need to schedule another one with a small delay to let it close the first call
// this one almost immediately to avoid blinking
DispatchQueue.main.async { cell.setAccessibilityExpanded(true) }
// in case the first one didn't "catch" the right moment (sometimes it happens)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { cell.setAccessibilityExpanded(true) }
return true
}
addSubview(clickView)
clickView.snp.makeConstraints { make in
make.width.equalTo(20)
make.trailing.top.bottom.equalToSuperview()
}
}
}

How can I show/hide a button added to the title bar of an NSWindow?

I have created a method in an NSWindow extension that allows me to add a button next to the text in the title bar. This is similar to the "down chevron" button that appears in the title bar of Pages and Numbers. When the button is clicked, an arbitrary code, expressed as a closure, is run.
While I have that part working fine, I would also like the button to be invisible most of the time and only become visible when the mouse is scrolled into the title bar area. This would be mimicking the way that Pages and Numbers displays the button.
However, I'm having difficulties getting the show/hide to work properly. I believe I can do it if I make it completely custom in the application delegate, and possibly by subclassing NSWindow, but I would really like to keep it as a single method in an NSWindow extension. In this way the code would be easily reusable in multiple applications.
To accomplish this I believe I need to inject an additional handler/listener that will tell me when the mouse enters and leaves the appropriate area. I can define the necessary area using an NSTrackingArea, but I haven't figured out how to "inject" an event listener without the need of subclasses. Does anyone know how (or if) such a thing is possible?
The key to handling the show/hide based on the mouse position was to use an NSTrackingArea to signify the portion that we are interested in, and to handle the mouse enter and mouse exit events. But since this can't be done directly on the title bar view (since we have to subclass the view in order to add the event handlers) we need to create an additional NSView that is invisible but covers the area we want to track.
I'll post the full code below, but the key parts related to this question are the TrackingHelper class defined near the bottom of the file and the way it is added to the titleBarView with its constrains set to be equal to the size of the title bar. The class itself is designed to take three closures, one for the mouse enter event, one for the mouse exit, and one for the action to take when the button is pressed. (Technically the latter doesn't really need to be part of the TrackingHelper, but it is a convenient place to put it to ensure it does not go out of scope while the UI still exists. A more correct solution would be to subclass NSButton to keep the closure, but I have always found subclassing NSButton to be a royal pain.)
Here is the full text of the solution. Note that this has a couple of things that depend on another library of mine - but they are not necessary for the understanding of this problem and are used to deal with the button image. If you wish to use this code you will need to replace the getImage function with one that creates the image you want. (And if you want to see what KSSCocoa is adding, you can obtain it from https://github.com/klassen-software-solutions/KSSCore)
//
// NSWindowExtension.swift
//
// Created by Steven W. Klassen on 2020-02-24.
//
import os
import Cocoa
import KSSCocoa
public extension NSWindow {
/**
Add an action button to the title bar. This will add a "down chevron" icon, similar to the one used in
Numbers and Pages, just to the right of the title in the title bar. When clicked it will run the given
lambda.
*/
#available(OSX 10.14, *)
func addTitleActionButton(_ lambda: #escaping () -> Void) -> NSButton {
guard let titleBarView = getTitleBarView() else {
fatalError("You can only add a title action to an app that has a title bar")
}
guard let titleTextField = getTextFieldChild(of: titleBarView) else {
fatalError("You can only add a title action to an app that has a title field")
}
let trackingHelper = TrackingHelper()
let actionButton = NSButton(image: getImage(),
target: trackingHelper,
action: #selector(trackingHelper.action))
actionButton.setButtonType(.momentaryPushIn)
actionButton.translatesAutoresizingMaskIntoConstraints = false
actionButton.isBordered = false
actionButton.isEnabled = false
actionButton.alphaValue = 0
trackingHelper.translatesAutoresizingMaskIntoConstraints = false
trackingHelper.onButtonAction = lambda
trackingHelper.onMouseEntered = {
actionButton.isEnabled = true
actionButton.alphaValue = 1
}
trackingHelper.onMouseExited = {
actionButton.isEnabled = false
actionButton.alphaValue = 0
}
titleBarView.addSubview(trackingHelper)
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[trackingHelper]-0-|",
options: [], metrics: nil,
views: ["trackingHelper": trackingHelper]))
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[trackingHelper]-0-|",
options: [], metrics: nil,
views: ["trackingHelper": trackingHelper]))
titleBarView.addSubview(actionButton)
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:[titleTextField]-[actionButton(==7)]",
options: [], metrics: nil,
views: ["actionButton": actionButton,
"titleTextField": titleTextField]))
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-1-[actionButton]-3-|",
options: [], metrics: nil,
views: ["actionButton": actionButton]))
DistributedNotificationCenter.default().addObserver(
actionButton,
selector: #selector(actionButton.onThemeChanged(notification:)),
name: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"),
object: nil
)
return actionButton
}
fileprivate func getTitleBarView() -> NSView? {
return standardWindowButton(.closeButton)?.superview
}
fileprivate func getTextFieldChild(of view: NSView) -> NSTextField? {
for subview in view.subviews {
if let textField = subview as? NSTextField {
return textField
}
}
return nil
}
}
fileprivate extension NSButton {
#available(OSX 10.14, *)
#objc func onThemeChanged(notification: NSNotification) {
image = image?.inverted()
}
}
#available(OSX 10.14, *)
fileprivate func getImage() -> NSImage {
var image = NSImage(sfSymbolName: "chevron.down")!
if NSApplication.shared.isDarkMode {
image = image.inverted()
}
return image
}
fileprivate final class TrackingHelper : NSView {
typealias Callback = ()->Void
var onMouseEntered: Callback? = nil
var onMouseExited: Callback? = nil
var onButtonAction: Callback? = nil
override func mouseEntered(with event: NSEvent) {
onMouseEntered?()
}
override func mouseExited(with event: NSEvent) {
onMouseExited?()
}
#objc func action() {
onButtonAction?()
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
for trackingArea in self.trackingAreas {
self.removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(trackingArea)
}
}

How do I keep NSMenuItem selected in a NSPopover with NSMenu?

I have a NSPopUpButton with a built up NSMenu.
However, when an NSMenuItem is selected, it keeps going back to the first item.
In this example, I'd expect "Maged" to be selected.
Any ideas?
Related question/answer: https://stackoverflow.com/a/53387962/157656
Duplicate:
It's been suggested that this is a duplicate of the question
How do I change/modify the displayed title of an NSPopUpButton
"I would like an NSPopUpButton to display a different title than the title of the menu item that is selected."
However, my question was about getting the NSPopUpButton to show the selected item.
In the end, I changed how I did it.
I am using a NSButton to show the menu and NSTextField to display the results.
If anyone is interested in the details, here they are.
Build the menu up and use .representedObject to store whatever you need to access at the other end. I used a struct with the name and code the in it.
You need to assign the NSMenu to the NSButton.menu
Then have a click, something like this.
#IBAction func changeVoiceClicked(_ sender: NSButton)
{
if let event = NSApplication.shared.currentEvent {
NSMenu.popUpContextMenu(sender.menu!, with: event, for: sender)
}
}
Your NSMenuItem should have an action on it, which using a selector points to a function.
Something like this:
#objc func voiceChanged(sender: NSMenuItem)
{
// cope will nil
var voice : VoiceDetail = VoiceDetail();
if (sender.representedObject != nil) {
voice = sender.representedObject as! VoiceDetail;
}
// Do what you need to on menu select.
// update text field.
}

Indicate that keyUp event has been handled in Swift?

In an NSTextView I am trying to make tab and shift-tab play a role in editing text, without tabs being inserted in the text. Currently I am detecting keypresses via NSTextViewDelegate's keyUp method:
override func keyUp(with event: NSEvent) {
if let currChar = event.characters {
if event.keyCode == 48 { // detect tab key
if event.modifierFlags.rawValue == 256 { // detect no modifier key
// do something
} else if event.modifierFlags.rawValue == 131330 { // detect shift modifier
// do another thing
}
}
}
I can't see anything in the documentation how to indicate to the NSTextView that I want it to ignore the tab key (I have tried the answer shown here, but tabs do not appear to trigger this event), or to prevent the event from moving up the responder chain.
I have also tried calling interpretKeyEvents([event]) at the beginning of my keyUp method, and overriding insertTab and insertBacktab. These are successfully called with the right keypresses, but a tab is still inserted into the text. The documentation seems to suggest it should prevent the tab being inserted:
It [keyDown] can pass the event to Cocoa’s text input management system by invoking the NSResponder method interpretKeyEvents:. The input management system checks the pressed key against entries in all relevant key-binding dictionaries and, if there is a match, sends a doCommandBySelector: message back to the view. Otherwise, it sends an insertText: message back to the view, and the view implements this method to extract and display the text. (emphasis mine)
The documentation talks about an event continuing up the responder chain if it has not been handled - how is this indicated? Is it important that I am using keyUp, not keyDown?
Yes, it matters that you’re overriding keyUp: instead of keyDown:. The key-down event happens before the key-up event, and NSTextView acts on the key-down event. By the time the system has called your keyUp: override, it’s too late to prevent the default handling of the key-down event.
Use custom subclass. If these methods are not being called it means the first responder is someone else and has eaten your event. As long as your textview is first responder your keyDown method will be called.
class MyTextView: NSTextView {
override func insertTab(_ sender: Any?) {
self.insertText("HELLO", replacementRange: NSMakeRange(0, 0))
//OR ANY CUSTOM TEXT EDITING, ACTION TO CHANGE FIRST RESPONDER...
}
override func insertBacktab(_ sender: Any?) {
self.insertText("AAAAA", replacementRange: NSMakeRange(0, 0))
//OR ANY CUSTOM TEXT EDITING, ACTION TO CHANGE FIRST RESPONDER...
}
}
Educational: "Key Event Handling in Cocoa Applications from WWDC 2010"