X-Git-Url: https://git.yukkurigames.com/?p=enjoyable.git;a=blobdiff_plain;f=Classes%2FEnjoyableApplicationDelegate.m;h=96aa8ddb26b39ff6b2183da81daebd04a20c23fb;hp=5c6847645b8be5f28587c0977f6bd0f7f9865dbf;hb=93d9951d6bd16b37e88b8fb6c38bc4afd6c9401e;hpb=18160be57e656a3733fc29878caddcda5081a2c2 diff --git a/Classes/EnjoyableApplicationDelegate.m b/Classes/EnjoyableApplicationDelegate.m index 5c68476..96aa8dd 100644 --- a/Classes/EnjoyableApplicationDelegate.m +++ b/Classes/EnjoyableApplicationDelegate.m @@ -5,26 +5,25 @@ // Created by Sam McCall on 4/05/09. // +#import + #import "EnjoyableApplicationDelegate.h" #import "NJMapping.h" -#import "NJMappingsController.h" -#import "NJDeviceController.h" -#import "NJOutputController.h" +#import "NJInput.h" #import "NJEvents.h" -@implementation EnjoyableApplicationDelegate +@implementation EnjoyableApplicationDelegate { + NSStatusItem *statusItem; +} - (void)didSwitchApplication:(NSNotification *)note { NSRunningApplication *activeApp = note.userInfo[NSWorkspaceApplicationKey]; - NSString *name = activeApp.localizedName; - if (!name) - name = activeApp.bundleIdentifier; - if (name && ![name isEqualToString:NSRunningApplication.currentApplication.localizedName]) - [self.mappingsController activateMappingForProcess:name]; + if (activeApp) + [self.inputController activateMappingForProcess:activeApp]; } -- (void)applicationDidFinishLaunching:(NSNotification *)notification { +- (void)applicationWillFinishLaunching:(NSNotification *)notification { [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(mappingDidChange:) @@ -32,129 +31,437 @@ object:nil]; [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(mappingListDidChange:) - name:NJEventMappingListChanged - object:nil]; - [NSNotificationCenter.defaultCenter - addObserver:self - selector:@selector(eventTranslationActivated:) - name:NJEventTranslationActivated + selector:@selector(eventSimulationStarted:) + name:NJEventSimulationStarted object:nil]; [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(eventTranslationDeactivated:) - name:NJEventTranslationDeactivated + selector:@selector(eventSimulationStopped:) + name:NJEventSimulationStopped object:nil]; - [self.inputController setup]; - [self.mappingsController load]; + [self.inputController load]; + [self.mvc.mappingList reloadData]; + [self.mvc changedActiveMappingToIndex: + [self.inputController indexOfMapping: + self.inputController.currentMapping]]; + + statusItem = [NSStatusBar.systemStatusBar statusItemWithLength:36]; + statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"]; + statusItem.highlightMode = YES; + statusItem.menu = self.statusItemMenu; + statusItem.target = self; } -- (void)applicationDidBecomeActive:(NSNotification *)notification { - [window makeKeyAndOrderFront:nil]; +- (void)applicationDidFinishLaunching:(NSNotification *)notification { + if ([NSUserDefaults.standardUserDefaults boolForKey:@"hidden in status item"] + && NSRunningApplication.currentApplication.wasLaunchedAsLoginItemOrResume) + [self transformIntoElement:nil]; + else + [self.window makeKeyAndOrderFront:nil]; } - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag { - [window makeKeyAndOrderFront:nil]; + [self restoreToForeground:theApplication]; + return NO; +} + +- (void)restoreToForeground:(id)sender { + ProcessSerialNumber psn = { 0, kCurrentProcess }; + TransformProcessType(&psn, kProcessTransformToForegroundApplication); + [NSApplication.sharedApplication activateIgnoringOtherApps:YES]; + [self.window makeKeyAndOrderFront:sender]; + [NSObject cancelPreviousPerformRequestsWithTarget:self + selector:@selector(transformIntoElement:) + object:self]; + [NSUserDefaults.standardUserDefaults setBool:NO forKey:@"hidden in status item"]; +} + +- (void)applicationWillBecomeActive:(NSNotification *)notification { + if (self.window.isVisible) + [self restoreToForeground:notification]; +} + +- (void)transformIntoElement:(id)sender { + ProcessSerialNumber psn = { 0, kCurrentProcess }; + TransformProcessType(&psn, kProcessTransformToUIElementApplication); + [NSUserDefaults.standardUserDefaults setBool:YES forKey:@"hidden in status item"]; +} + +- (void)flashStatusItem { + if ([statusItem.image.name isEqualToString:@"Status Menu Icon"]) { + statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"]; + } else { + statusItem.image = [NSImage imageNamed:@"Status Menu Icon"]; + } + +} + +- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)theApplication { + [theApplication hide:theApplication]; + // If we turn into a UIElement right away, the application cancels + // the deactivation events. The dock icon disappears, but an + // unresponsive menu bar remains until the user clicks somewhere. + // So delay just long enough to be past the end handling that. + [self performSelector:@selector(transformIntoElement:) withObject:self afterDelay:0.001]; return NO; } -- (void)eventTranslationActivated:(NSNotification *)note { - [NSProcessInfo.processInfo disableAutomaticTermination:@"Input translation is active."]; +- (void)eventSimulationStarted:(NSNotification *)note { + self.simulatingEventsButton.state = NSOnState; + statusItem.image = [NSImage imageNamed:@"Status Menu Icon"]; [NSWorkspace.sharedWorkspace.notificationCenter addObserver:self selector:@selector(didSwitchApplication:) name:NSWorkspaceDidActivateApplicationNotification object:nil]; - NSLog(@"Listening for application changes."); } -- (void)eventTranslationDeactivated:(NSNotification *)note { - [NSProcessInfo.processInfo enableAutomaticTermination:@"Input translation is active."]; +- (void)eventSimulationStopped:(NSNotification *)note { + self.simulatingEventsButton.state = NSOffState; + statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"]; [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self name:NSWorkspaceDidActivateApplicationNotification object:nil]; - NSLog(@"Ignoring application changes."); -} - -- (void)mappingListDidChange:(NSNotification *)note { - NSArray *mappings = note.object; - while (dockMenuBase.lastItem.representedObject) - [dockMenuBase removeLastItem]; - int added = 0; - for (NJMapping *mapping in mappings) { - NSString *keyEquiv = ++added < 10 ? @(added).stringValue : @""; - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:mapping.name - action:@selector(chooseMapping:) - keyEquivalent:keyEquiv]; - item.representedObject = mapping; - item.state = mapping == self.mappingsController.currentMapping; - [dockMenuBase addItem:item]; - } } - (void)mappingDidChange:(NSNotification *)note { - NJMapping *current = note.object; - for (NSMenuItem *item in dockMenuBase.itemArray) - if (item.representedObject) - item.state = item.representedObject == current; + NSUInteger idx = [note.userInfo[NJMappingIndexKey] intValue]; + [self.mvc changedActiveMappingToIndex:idx]; + + if (!self.window.isVisible) + for (int i = 0; i < 4; ++i) + [self performSelector:@selector(flashStatusItem) + withObject:self + afterDelay:0.2 * i]; } -- (void)chooseMapping:(NSMenuItem *)sender { - NJMapping *chosen = sender.representedObject; - [self.mappingsController activateMapping:chosen]; +- (NSMenu *)applicationDockMenu:(NSApplication *)sender { + return self.dockMenu; } -#define OUTPUT_PANE_MIN_WIDTH 390 -#define INPUT_PANE_MIN_WIDTH 160 +- (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename { + [self restoreToForeground:sender]; + NSError *error; + NSURL *URL = [NSURL fileURLWithPath:filename]; + NJMapping *mapping = [NJMapping mappingWithContentsOfURL:URL + error:&error]; + if ([[self.inputController mappingForKey:mapping.name] hasConflictWith:mapping]) { + [self promptForMapping:mapping atIndex:self.inputController.mappings.count]; + } else if ([self.inputController mappingForKey:mapping.name]) { + [[self.inputController mappingForKey:mapping.name] mergeEntriesFrom:mapping]; + } else if (mapping) { + [self.mvc beginUpdates]; + [self.inputController addMapping:mapping]; + [self.mvc addedMappingAtIndex:self.inputController.mappings.count - 1 startEditing:NO]; + [self.mvc endUpdates]; + [self.inputController activateMapping:mapping]; + } else { + [self.window presentError:error + modalForWindow:self.window + delegate:nil + didPresentSelector:nil + contextInfo:nil]; + } + return !!mapping; +} -- (CGFloat)splitView:(NSSplitView *)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(NSInteger)dividerIndex { - return INPUT_PANE_MIN_WIDTH; +- (void)mappingWasChosen:(NJMapping *)mapping { + [self.inputController activateMapping:mapping]; } -- (CGFloat)splitView:(NSSplitView *)sender constrainMaxCoordinate:(CGFloat)proposedMax ofSubviewAt:(NSInteger)offset { - return proposedMax - OUTPUT_PANE_MIN_WIDTH; +- (void)mappingListShouldOpen { + [self restoreToForeground:self]; + [self.mvc mappingTriggerClicked:self]; } -- (void)splitView:(NSSplitView *)splitView resizeSubviewsWithOldSize:(NSSize)oldSize { - NSView *inputView = splitView.subviews[0]; - NSView *outputView = splitView.subviews[1]; - if (outputView.frame.size.width < OUTPUT_PANE_MIN_WIDTH) { - NSSize frameSize = splitView.frame.size; - CGFloat inputWidth = frameSize.width - OUTPUT_PANE_MIN_WIDTH - splitView.dividerThickness; - inputView.frame = NSMakeRect(inputWidth, frameSize.height, - inputView.frame.size.width, - inputView.frame.size.height); - outputView.frame = NSMakeRect(inputWidth + splitView.dividerThickness, - 0, - OUTPUT_PANE_MIN_WIDTH, - frameSize.height); - } else - [splitView adjustSubviews]; +- (void)loginItemPromptDidEnd:(NSWindow *)sheet + returnCode:(int)returnCode + contextInfo:(void *)contextInfo { + if (returnCode == NSAlertDefaultReturn) { + [NSRunningApplication.currentApplication addToLoginItems]; + // If we're going to automatically start, don't bug the user + // about automatic updates next boot - they probably want it, + // and if they don't they probably want a prompt for it less. + SUUpdater.sharedUpdater.automaticallyChecksForUpdates = YES; + } } -- (NSMenu *)applicationDockMenu:(NSApplication *)sender { - NSMenu *menu = [[NSMenu alloc] init]; - int added = 0; - for (NJMapping *mapping in self.mappingsController) { - NSString *keyEquiv = ++added < 10 ? @(added).stringValue : @""; - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:mapping.name - action:@selector(chooseMapping:) - keyEquivalent:keyEquiv]; - item.representedObject = mapping; - item.state = mapping == self.mappingsController.currentMapping; - [menu addItem:item]; +- (void)loginItemPromptDidDismiss:(NSWindow *)sheet + returnCode:(int)returnCode + contextInfo:(void *)contextInfo { + [NSUserDefaults.standardUserDefaults setBool:YES forKey:@"explained login items"]; + [self.window performClose:sheet]; +} + +- (BOOL)windowShouldClose:(NSWindow *)sender { + if (sender != self.window + || NSRunningApplication.currentApplication.isLoginItem + || [NSUserDefaults.standardUserDefaults boolForKey:@"explained login items"]) + return YES; + NSBeginAlertSheet( + NSLocalizedString(@"login items prompt", @"alert prompt for adding to login items"), + NSLocalizedString(@"login items add button", @"button to add to login items"), + NSLocalizedString(@"login items don't add button", @"button to not add to login items"), + nil, self.window, self, + @selector(loginItemPromptDidEnd:returnCode:contextInfo:), + @selector(loginItemPromptDidDismiss:returnCode:contextInfo:), + NULL, + NSLocalizedString(@"login items explanation", @"a brief explanation of login items") + ); + for (int i = 0; i < 10; ++i) + [self performSelector:@selector(flashStatusItem) + withObject:self + afterDelay:0.5 * i]; + return NO; +} + +- (void)importMappingClicked:(id)sender { + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ]; + [panel beginSheetModalForWindow:self.window + completionHandler:^(NSInteger result) { + if (result != NSFileHandlingPanelOKButton) + return; + [panel close]; + NSError *error; + NJMapping *mapping = [NJMapping mappingWithContentsOfURL:panel.URL + error:&error]; + if ([[self.inputController mappingForKey:mapping.name] hasConflictWith:mapping]) { + [self promptForMapping:mapping atIndex:self.inputController.mappings.count]; + } else if ([self.inputController mappingForKey:mapping.name]) { + [[self.inputController mappingForKey:mapping.name] mergeEntriesFrom:mapping]; + } else if (mapping) { + [self.inputController addMapping:mapping]; + } else { + [self.window presentError:error + modalForWindow:self.window + delegate:nil + didPresentSelector:nil + contextInfo:nil]; + } + }]; + +} + +- (void)exportMappingClicked:(id)sender { + NSSavePanel *panel = [NSSavePanel savePanel]; + panel.allowedFileTypes = @[ @"enjoyable" ]; + NJMapping *mapping = self.inputController.currentMapping; + panel.nameFieldStringValue = [mapping.name stringByFixingPathComponent]; + [panel beginSheetModalForWindow:self.window + completionHandler:^(NSInteger result) { + if (result != NSFileHandlingPanelOKButton) + return; + [panel close]; + NSError *error; + if (![mapping writeToURL:panel.URL error:&error]) { + [self.window presentError:error + modalForWindow:self.window + delegate:nil + didPresentSelector:nil + contextInfo:nil]; + } + }]; +} + +- (void)mappingConflictDidResolve:(NSAlert *)alert + returnCode:(NSInteger)returnCode + contextInfo:(void *)contextInfo { + NSDictionary *userInfo = CFBridgingRelease(contextInfo); + NJMapping *oldMapping = userInfo[@"old mapping"]; + NJMapping *newMapping = userInfo[@"new mapping"]; + NSInteger idx = [userInfo[@"index"] intValue]; + [alert.window orderOut:nil]; + switch (returnCode) { + case NSAlertFirstButtonReturn: // Merge + [self.inputController mergeMapping:newMapping intoMapping:oldMapping]; + [self.inputController activateMapping:oldMapping]; + break; + case NSAlertThirdButtonReturn: // New Mapping + [self.mvc beginUpdates]; + [self.inputController addMapping:newMapping]; + [self.mvc addedMappingAtIndex:idx startEditing:YES]; + [self.mvc endUpdates]; + [self.inputController activateMapping:newMapping]; + break; + default: // Cancel, other. + break; } - return menu; } -- (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename { - NSURL *url = [NSURL fileURLWithPath:filename]; - [self.mappingsController addMappingWithContentsOfURL:url]; - return YES; +- (void)promptForMapping:(NJMapping *)mapping atIndex:(NSInteger)idx { + NJMapping *mergeInto = [self.inputController mappingForKey:mapping.name]; + NSAlert *conflictAlert = [[NSAlert alloc] init]; + conflictAlert.messageText = NSLocalizedString(@"import conflict prompt", @"Title of import conflict alert"); + conflictAlert.informativeText = + [NSString stringWithFormat:NSLocalizedString(@"import conflict in %@", @"Explanation of import conflict"), + mapping.name]; + [conflictAlert addButtonWithTitle:NSLocalizedString(@"import and merge", @"button to merge imported mappings")]; + [conflictAlert addButtonWithTitle:NSLocalizedString(@"cancel import", @"button to cancel import")]; + [conflictAlert addButtonWithTitle:NSLocalizedString(@"import new mapping", @"button to import as new mapping")]; + [conflictAlert beginSheetModalForWindow:self.window + modalDelegate:self + didEndSelector:@selector(mappingConflictDidResolve:returnCode:contextInfo:) + contextInfo:(void *)CFBridgingRetain(@{ @"index": @(idx), + @"old mapping": mergeInto, + @"new mapping": mapping })]; +} + +- (NSInteger)numberOfMappings:(NJMappingsViewController *)mvc { + return self.inputController.mappings.count; +} + +- (NJMapping *)mappingsViewController:(NJMappingsViewController *)mvc + mappingForIndex:(NSUInteger)idx { + return self.inputController.mappings[idx]; +} + +- (void)mappingsViewController:(NJMappingsViewController *)mvc + renameMappingAtIndex:(NSInteger)index + toName:(NSString *)name { + [self.inputController renameMapping:self.inputController.mappings[index] + to:name]; } +- (BOOL)mappingsViewController:(NJMappingsViewController *)mvc + canMoveMappingFromIndex:(NSInteger)fromIdx + toIndex:(NSInteger)toIdx { + return fromIdx != toIdx && fromIdx != 0 && toIdx != 0 + && toIdx < (NSInteger)self.inputController.mappings.count; +} + +- (void)mappingsViewController:(NJMappingsViewController *)mvc + moveMappingFromIndex:(NSInteger)fromIdx + toIndex:(NSInteger)toIdx { + [mvc beginUpdates]; + [mvc.mappingList moveRowAtIndex:fromIdx toIndex:toIdx]; + [self.inputController moveMoveMappingFromIndex:fromIdx toIndex:toIdx]; + [mvc endUpdates]; +} + +- (BOOL)mappingsViewController:(NJMappingsViewController *)mvc + canRemoveMappingAtIndex:(NSInteger)idx { + return idx != 0; +} + +- (void)mappingsViewController:(NJMappingsViewController *)mvc + removeMappingAtIndex:(NSInteger)idx { + [mvc beginUpdates]; + [mvc removedMappingAtIndex:idx]; + [self.inputController removeMappingAtIndex:idx]; + [mvc endUpdates]; +} + +- (BOOL)mappingsViewController:(NJMappingsViewController *)mvc + importMappingFromURL:(NSURL *)url + atIndex:(NSInteger)index + error:(NSError **)error { + NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url + error:error]; + if ([[self.inputController mappingForKey:mapping.name] hasConflictWith:mapping]) { + [self promptForMapping:mapping atIndex:index]; + } else if ([self.inputController mappingForKey:mapping.name]) { + [[self.inputController mappingForKey:mapping.name] mergeEntriesFrom:mapping]; + } else if (mapping) { + [self.mvc beginUpdates]; + [self.mvc addedMappingAtIndex:index startEditing:NO]; + [self.inputController insertMapping:mapping atIndex:index]; + [self.mvc endUpdates]; + } + return !!mapping; +} + +- (void)mappingsViewController:(NJMappingsViewController *)mvc + addMapping:(NJMapping *)mapping { + [mvc beginUpdates]; + [mvc addedMappingAtIndex:self.inputController.mappings.count startEditing:YES]; + [self.inputController addMapping:mapping]; + [mvc endUpdates]; + [self.inputController activateMapping:mapping]; +} + +- (void)mappingsViewController:(NJMappingsViewController *)mvc + choseMappingAtIndex:(NSInteger)idx { + [self.inputController activateMapping:self.inputController.mappings[idx]]; +} + +- (id)deviceViewController:(NJDeviceViewController *)dvc + elementForUID:(NSString *)uid { + return [self.inputController elementForUID:uid]; +} + +- (void)deviceViewControllerDidSelectNothing:(NJDeviceViewController *)dvc { + [self.outputController loadInput:dvc.selectedHandler]; +} + +- (void)deviceViewController:(NJDeviceViewController *)dvc + didSelectBranch:(NJInputPathElement *)handler { + [self.outputController loadInput:dvc.selectedHandler]; +} + +- (void)deviceViewController:(NJDeviceViewController *)dvc + didSelectHandler:(NJInputPathElement *)handler { + [self.outputController loadInput:dvc.selectedHandler]; +} + +- (void)deviceViewController:(NJDeviceViewController *)dvc + didSelectDevice:(NJInputPathElement *)device { + [self.outputController loadInput:dvc.selectedHandler]; +} + +- (void)deviceController:(NJInputController *)dc + didAddDevice:(NJDevice *)device { + [self.dvc addedDevice:device atIndex:dc.devices.count - 1]; +} + +- (void)deviceController:(NJInputController *)dc + didRemoveDeviceAtIndex:(NSInteger)idx { + [self.dvc removedDeviceAtIndex:idx]; +} + +- (void)deviceControllerDidStartHID:(NJInputController *)dc { + [self.dvc hidStarted]; +} + +- (void)deviceControllerDidStopHID:(NJInputController *)dc { + [self.dvc hidStopped]; +} + +- (void)deviceController:(NJInputController *)dc didInput:(NJInput *)input { + [self.dvc expandAndSelectItem:input]; + [self.outputController loadInput:input]; + [self.outputController focusKey]; +} + +- (void)deviceController:(NJInputController *)dc didError:(NSError *)error { + // Since the error shows the window, it can trigger another attempt + // to re-open the HID manager, which will also probably fail and error, + // so don't bother repeating ourselves. + if (!self.window.attachedSheet) { + [NSApplication.sharedApplication activateIgnoringOtherApps:YES]; + [self.window makeKeyAndOrderFront:nil]; + [self.window presentError:error + modalForWindow:self.window + delegate:nil + didPresentSelector:nil + contextInfo:nil]; + } +} + +- (NSInteger)numberOfDevicesInDeviceList:(NJDeviceViewController *)dvc { + return self.inputController.devices.count; +} + +- (NJDevice *)deviceViewController:(NJDeviceViewController *)dvc + deviceForIndex:(NSUInteger)idx { + return self.inputController.devices[idx]; +} + +- (IBAction)simulatingEventsChanged:(NSButton *)sender { + self.inputController.simulatingEvents = sender.state == NSOnState; +} @end