Show a special message when the HID was closed during deactivation.
[enjoyable.git] / Classes / NJDeviceController.m
index d553d7d..2a1546c 100644 (file)
 #import "NJEvents.h"
 
 @implementation NJDeviceController {
-    IOHIDManagerRef hidManager;
-    NSTimer *continuousTimer;
-    NSMutableArray *runningOutputs;
+    IOHIDManagerRef _hidManager;
+    NSTimer *_continuousOutputsTick;
+    NSMutableArray *_continousOutputs;
     NSMutableArray *_devices;
+    NSMutableArray *_expanded;
 }
 
+#define EXPANDED_MEMORY_MAX_SIZE 100
+
 - (id)init {
     if ((self = [super init])) {
         _devices = [[NSMutableArray alloc] initWithCapacity:16];
-        runningOutputs = [[NSMutableArray alloc] initWithCapacity:32];
+        _continousOutputs = [[NSMutableArray alloc] initWithCapacity:32];
+        
+        NSArray *expanded = [NSUserDefaults.standardUserDefaults objectForKey:@"expanded rows"];
+        if (![expanded isKindOfClass:NSArray.class])
+            expanded = @[];
+        _expanded = [[NSMutableArray alloc] initWithCapacity:MAX(16, _expanded.count)];
+        [_expanded addObjectsFromArray:expanded];
+
+        [NSNotificationCenter.defaultCenter
+             addObserver:self
+             selector:@selector(applicationDidFinishLaunching:)
+             name:NSApplicationDidFinishLaunchingNotification
+             object:nil];
+        
+        // The HID manager uses 5-10ms per second doing basically
+        // nothing if a noisy device is plugged in (the % of that
+        // spent in input_callback is negligible, so it's not
+        // something we can make faster). I don't really think that's
+        // acceptable, CPU/power wise. So if translation is disabled
+        // and the window is closed, just switch off the HID manager
+        // entirely. This probably also has some marginal benefits for
+        // compatibility with other applications that want exclusive
+        // grabs.
+        [NSNotificationCenter.defaultCenter
+            addObserver:self
+            selector:@selector(closeHidIfDisabled:)
+            name:NSApplicationDidResignActiveNotification
+            object:nil];
+        [NSNotificationCenter.defaultCenter
+            addObserver:self
+            selector:@selector(openHid)
+            name:NSApplicationDidBecomeActiveNotification
+            object:nil];
     }
     return self;
 }
 
 - (void)dealloc {
-    [continuousTimer invalidate];
-    IOHIDManagerClose(hidManager, kIOHIDOptionsTypeNone);
-    CFRelease(hidManager);
+    [NSNotificationCenter.defaultCenter removeObserver:self];
+    [_continuousOutputsTick invalidate];
+    [self closeHid];
 }
 
 - (void)expandRecursive:(id <NJInputPathElement>)pathElement {
     }
 }
 
-- (void)addRunningOutput:(NJOutput *)output {
-    if (![runningOutputs containsObject:output]) {
-        [runningOutputs addObject:output];
+- (id)elementForUID:(NSString *)uid {
+    for (NJDevice *dev in _devices) {
+        id item = [dev elementForUID:uid];
+        if (item)
+            return item;
     }
-    if (!continuousTimer) {
-        continuousTimer = [NSTimer scheduledTimerWithTimeInterval:1.f/60.f
+    return nil;
+}
+
+- (void)expandRecursiveByUID:(NSString *)uid {
+    [self expandRecursive:[self elementForUID:uid]];
+}
+
+- (void)addRunningOutput:(NJOutput *)output {
+    // Axis events will trigger every small movement, don't keep
+    // re-adding them or they trigger multiple times each time.
+    if (![_continousOutputs containsObject:output])
+        [_continousOutputs addObject:output];
+    if (!_continuousOutputsTick) {
+        _continuousOutputsTick = [NSTimer scheduledTimerWithTimeInterval:1.0/60.0
                                                            target:self
-                                                         selector:@selector(updateContinuousInputs:)
+                                                         selector:@selector(updateContinuousOutputs:)
                                                          userInfo:nil
                                                           repeats:YES];
-        NSLog(@"Scheduled continuous output timer.");
     }
 }
 
         return;
     
     [self expandRecursive:handler];
-    [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:[outlineView rowForItem:handler]] byExtendingSelection: NO];
+    [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:[outlineView rowForItem:handler]]
+             byExtendingSelection: NO];
     [outputController focusKey];
 }
 
@@ -108,11 +157,14 @@ static int findAvailableIndex(NSArray *list, NJDevice *dev) {
 }
 
 - (void)addDeviceForDevice:(IOHIDDeviceRef)device {
-    IOHIDDeviceRegisterInputValueCallback(device, input_callback, (__bridge void*)self);
+    IOHIDDeviceRegisterInputValueCallback(device, input_callback, (__bridge void *)self);
     NJDevice *dev = [[NJDevice alloc] initWithDevice:device];
     dev.index = findAvailableIndex(_devices, dev);
     [_devices addObject:dev];
     [outlineView reloadData];
+    [self reexpandAll];
+    hidSleepingPrompt.hidden = YES;
+    connectDevicePrompt.hidden = !!_devices.count;
 }
 
 static void add_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDDeviceRef device) {
@@ -138,28 +190,34 @@ static void remove_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDD
     if (match) {
         [_devices removeObject:match];
         [outlineView reloadData];
+        connectDevicePrompt.hidden = !!_devices.count;
+        hidSleepingPrompt.hidden = YES;
     }
-    
+    if (_devices.count == 1)
+        [outlineView expandItem:_devices[0]];
 }
 
-- (void)updateContinuousInputs:(NSTimer *)timer {
+- (void)updateContinuousOutputs:(NSTimer *)timer {
     self.mouseLoc = [NSEvent mouseLocation];
-    for (NJOutput *output in [runningOutputs copy]) {
+    for (NJOutput *output in [_continousOutputs copy]) {
         if (![output update:self]) {
-            [runningOutputs removeObject:output];
+            [_continousOutputs removeObject:output];
         }
     }
-    if (!runningOutputs.count) {
-        [continuousTimer invalidate];
-        continuousTimer = nil;
-        NSLog(@"Unscheduled continuous output timer.");
+    if (!_continousOutputs.count) {
+        [_continuousOutputsTick invalidate];
+        _continuousOutputsTick = nil;
     }
 }
 
 #define NSSTR(e) ((NSString *)CFSTR(e))
 
-- (void)setup {
-    hidManager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
+- (void)openHid {
+    if (_hidManager)
+        return;
+    NSLog(@"Opening HID manager.");
+    _hidManager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
+    IOHIDManagerScheduleWithRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
     NSArray *criteria = @[ @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
                               NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_Joystick) },
                            @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
@@ -167,26 +225,41 @@ static void remove_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDD
                            @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
                               NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_MultiAxisController) }
                            ];
-    IOHIDManagerSetDeviceMatchingMultiple(hidManager, (__bridge CFArrayRef)criteria);
-    
-    IOHIDManagerScheduleWithRunLoop(hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
-    IOReturn ret = IOHIDManagerOpen(hidManager, kIOHIDOptionsTypeNone);
+    IOHIDManagerSetDeviceMatchingMultiple(_hidManager, (__bridge CFArrayRef)criteria);
+    IOReturn ret = IOHIDManagerOpen(_hidManager, kIOHIDOptionsTypeNone);
     if (ret != kIOReturnSuccess) {
-        [[NSAlert alertWithMessageText:@"Input devices are unavailable"
+        [[NSAlert alertWithMessageText:NSLocalizedString(@"input devices unavailable",
+                                                         @"error title when devices can't be read")
                          defaultButton:nil
                        alternateButton:nil
                            otherButton:nil
-             informativeTextWithFormat:@"Error 0x%08x occured trying to access your devices. "
-                                       @"Input may not be correctly detected or mapped.",
-                                       ret]
-         beginSheetModalForWindow:outlineView.window
-                    modalDelegate:nil
-                   didEndSelector:nil
-                      contextInfo:nil];
+             informativeTextWithFormat:NSLocalizedString(@"input error 0x%08x occurred",
+                                                         @"message containing IOReturn failure code when devices can't be read"), ret]
+            beginSheetModalForWindow:outlineView.window
+            modalDelegate:nil
+            didEndSelector:nil
+            contextInfo:nil];
+        [self closeHid];
+    } else {
+        IOHIDManagerRegisterDeviceMatchingCallback(_hidManager, add_callback, (__bridge void *)self);
+        IOHIDManagerRegisterDeviceRemovalCallback(_hidManager, remove_callback, (__bridge void *)self);
+        hidSleepingPrompt.hidden = YES;
+        connectDevicePrompt.hidden = !!_devices.count;
     }
-    
-    IOHIDManagerRegisterDeviceMatchingCallback(hidManager, add_callback, (__bridge void *)self);
-    IOHIDManagerRegisterDeviceRemovalCallback(hidManager, remove_callback, (__bridge void *)self);
+}
+
+- (void)closeHid {
+    if (_hidManager) {
+        NSLog(@"Closing HID manager.");
+        IOHIDManagerUnscheduleFromRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
+        IOHIDManagerClose(_hidManager, kIOHIDOptionsTypeNone);
+        CFRelease(_hidManager);
+        _hidManager = NULL;
+    }
+    [_devices removeAllObjects];
+    [outlineView reloadData];
+    hidSleepingPrompt.hidden = NO;
+    connectDevicePrompt.hidden = YES;
 }
 
 - (NJInput *)selectedInput {
@@ -217,27 +290,85 @@ objectValueForTableColumn:(NSTableColumn *)tableColumn
 }
 
 - (void)outlineViewSelectionDidChange:(NSNotification *)notification {
-    
+    id <NJInputPathElement> item = [outlineView itemAtRow:outlineView.selectedRow];
+    if (item)
+        [NSUserDefaults.standardUserDefaults setObject:item.uid
+                                                forKey:@"selected input"];
     [outputController loadCurrent];
 }
 
+- (BOOL)outlineView:(NSOutlineView *)outlineView
+        isGroupItem:(id <NJInputPathElement>)item {
+    return [item isKindOfClass:NJDevice.class];
+}
+
+- (BOOL)outlineView:(NSOutlineView *)outlineView_
+   shouldSelectItem:(id <NJInputPathElement>)item {
+    return ![self outlineView:outlineView_ isGroupItem:item];
+}
+
+- (void)outlineViewItemDidExpand:(NSNotification *)notification {
+    id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
+    NSString *uid = item.uid;
+    if (![_expanded containsObject:uid])
+        [_expanded addObject:uid];
+    while (_expanded.count > EXPANDED_MEMORY_MAX_SIZE)
+        [_expanded removeObjectAtIndex:0];
+    [NSUserDefaults.standardUserDefaults setObject:_expanded
+                                            forKey:@"expanded rows"];
+}
+
+- (void)outlineViewItemDidCollapse:(NSNotification *)notification {
+    id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
+    [_expanded removeObject:item.uid];
+    [NSUserDefaults.standardUserDefaults setObject:_expanded
+                                            forKey:@"expanded rows"];
+}
+
 - (void)setTranslatingEvents:(BOOL)translatingEvents {
     if (translatingEvents != _translatingEvents) {
         _translatingEvents = translatingEvents;
         NSInteger state = translatingEvents ? NSOnState : NSOffState;
         translatingEventsButton.state = state;
-        translatingEventsMenu.title = translatingEvents ? @"Disable" : @"Enable";
         NSString *name = translatingEvents
             ? NJEventTranslationActivated
             : NJEventTranslationDeactivated;
         [NSNotificationCenter.defaultCenter postNotificationName:name
                                                           object:self];
+
+        if (!translatingEvents && !NSApplication.sharedApplication.isActive)
+            [self closeHid];
+        else if (translatingEvents || NSApplication.sharedApplication.isActive)
+            [self openHid];
     }
 }
 
+- (void)reexpandAll {
+    for (NSString *uid in [_expanded copy])
+        [self expandRecursiveByUID:uid];
+    if (outlineView.selectedRow == -1) {
+        NSString *selectedUid = [NSUserDefaults.standardUserDefaults objectForKey:@"selected input"];
+        id item = [self elementForUID:selectedUid];
+        NSInteger row = [outlineView rowForItem:item];
+        if (row >= 0)
+            [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO];
+    }
+}
+
+- (void)closeHidIfDisabled:(NSNotification *)application {
+    if (!self.translatingEvents)
+        [self closeHid];
+}
+
+- (void)applicationDidFinishLaunching:(NSNotification *)application {
+    // NSApplicationWillBecomeActiveNotification occurs just slightly
+    // too late - there's one tick where the UI is showing "No
+    // devices" even with a device plugged in.
+    [self openHid];
+}
+
 - (IBAction)translatingEventsChanged:(NSButton *)sender {
     self.translatingEvents = sender.state == NSOnState;
 }
 
-
 @end