Remember expanded rows between activations / device insertion.
[enjoyable.git] / Classes / NJDeviceController.m
1 //
2 // NJDeviceController.m
3 // Enjoy
4 //
5 // Created by Sam McCall on 4/05/09.
6 //
7
8 #import "NJDeviceController.h"
9
10 #import "NJMapping.h"
11 #import "NJMappingsController.h"
12 #import "NJDevice.h"
13 #import "NJInput.h"
14 #import "NJOutput.h"
15 #import "NJOutputController.h"
16 #import "NJEvents.h"
17
18 @implementation NJDeviceController {
19 IOHIDManagerRef _hidManager;
20 NSTimer *_continuousOutputsTick;
21 NSMutableArray *_continousOutputs;
22 NSMutableArray *_devices;
23 NSMutableArray *_expanded;
24 }
25
26 #define EXPANDED_MEMORY_MAX_SIZE 100
27
28 - (id)init {
29 if ((self = [super init])) {
30 _devices = [[NSMutableArray alloc] initWithCapacity:16];
31 _continousOutputs = [[NSMutableArray alloc] initWithCapacity:32];
32
33 NSArray *expanded = [NSUserDefaults.standardUserDefaults objectForKey:@"expanded rows"];
34 if (![expanded isKindOfClass:NSArray.class])
35 expanded = @[];
36 _expanded = [[NSMutableArray alloc] initWithCapacity:MAX(16, _expanded.count)];
37 [_expanded addObjectsFromArray:expanded];
38
39 [NSNotificationCenter.defaultCenter
40 addObserver:self
41 selector:@selector(applicationDidFinishLaunching:)
42 name:NSApplicationDidFinishLaunchingNotification
43 object:nil];
44
45 // The HID manager uses 5-10ms per second doing basically
46 // nothing if a noisy device is plugged in (the % of that
47 // spent in input_callback is negligible, so it's not
48 // something we can make faster). I don't really think that's
49 // acceptable, CPU/power wise. So if translation is disabled
50 // and the window is closed, just switch off the HID manager
51 // entirely. This probably also has some marginal benefits for
52 // compatibility with other applications that want exclusive
53 // grabs.
54 [NSNotificationCenter.defaultCenter
55 addObserver:self
56 selector:@selector(closeHidIfDisabled:)
57 name:NSApplicationDidResignActiveNotification
58 object:nil];
59 [NSNotificationCenter.defaultCenter
60 addObserver:self
61 selector:@selector(openHid)
62 name:NSApplicationDidBecomeActiveNotification
63 object:nil];
64 }
65 return self;
66 }
67
68 - (void)dealloc {
69 [NSNotificationCenter.defaultCenter removeObserver:self];
70 [_continuousOutputsTick invalidate];
71 [self closeHid];
72 }
73
74 - (void)expandRecursive:(id <NJInputPathElement>)pathElement {
75 if (pathElement) {
76 [self expandRecursive:pathElement.base];
77 [outlineView expandItem:pathElement];
78 }
79 }
80
81 - (void)expandRecursiveByUID:(NSString *)uid {
82 for (NJDevice *dev in _devices) {
83 id item = [dev elementForUID:uid];
84 if (item)
85 [self expandRecursive:item];
86 }
87 }
88
89 - (void)addRunningOutput:(NJOutput *)output {
90 // Axis events will trigger every small movement, don't keep
91 // re-adding them or they trigger multiple times each time.
92 if (![_continousOutputs containsObject:output])
93 [_continousOutputs addObject:output];
94 if (!_continuousOutputsTick) {
95 _continuousOutputsTick = [NSTimer scheduledTimerWithTimeInterval:1.0/60.0
96 target:self
97 selector:@selector(updateContinuousOutputs:)
98 userInfo:nil
99 repeats:YES];
100 }
101 }
102
103 - (void)runOutputForDevice:(IOHIDDeviceRef)device value:(IOHIDValueRef)value {
104 NJDevice *dev = [self findDeviceByRef:device];
105 NJInput *mainInput = [dev inputForEvent:value];
106 [mainInput notifyEvent:value];
107 NSArray *children = mainInput.children ? mainInput.children : mainInput ? @[mainInput] : @[];
108 for (NJInput *subInput in children) {
109 NJOutput *output = mappingsController.currentMapping[subInput];
110 output.magnitude = subInput.magnitude;
111 output.running = subInput.active;
112 if ((output.running || output.magnitude) && output.isContinuous)
113 [self addRunningOutput:output];
114 }
115 }
116
117 - (void)showOutputForDevice:(IOHIDDeviceRef)device value:(IOHIDValueRef)value {
118 NJDevice *dev = [self findDeviceByRef:device];
119 NJInput *handler = [dev handlerForEvent:value];
120 if (!handler)
121 return;
122
123 [self expandRecursive:handler];
124 [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:[outlineView rowForItem:handler]]
125 byExtendingSelection: NO];
126 [outputController focusKey];
127 }
128
129 static void input_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDValueRef value) {
130 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
131 IOHIDDeviceRef device = IOHIDQueueGetDevice(inSender);
132
133 if (controller.translatingEvents) {
134 [controller runOutputForDevice:device value:value];
135 } else if ([NSApplication sharedApplication].mainWindow.isVisible) {
136 [controller showOutputForDevice:device value:value];
137 }
138 }
139
140 static int findAvailableIndex(NSArray *list, NJDevice *dev) {
141 for (int index = 1; ; index++) {
142 BOOL available = YES;
143 for (NJDevice *used in list) {
144 if ([used.productName isEqualToString:dev.productName] && used.index == index) {
145 available = NO;
146 break;
147 }
148 }
149 if (available)
150 return index;
151 }
152 }
153
154 - (void)addDeviceForDevice:(IOHIDDeviceRef)device {
155 IOHIDDeviceRegisterInputValueCallback(device, input_callback, (__bridge void *)self);
156 NJDevice *dev = [[NJDevice alloc] initWithDevice:device];
157 dev.index = findAvailableIndex(_devices, dev);
158 [_devices addObject:dev];
159 [outlineView reloadData];
160 [self reexpandAll];
161 connectDevicePrompt.hidden = !!_devices.count;
162 }
163
164 static void add_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDDeviceRef device) {
165 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
166 [controller addDeviceForDevice:device];
167 }
168
169 - (NJDevice *)findDeviceByRef:(IOHIDDeviceRef)device {
170 for (NJDevice *dev in _devices)
171 if (dev.device == device)
172 return dev;
173 return nil;
174 }
175
176 static void remove_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDDeviceRef device) {
177 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
178 [controller removeDeviceForDevice:device];
179 }
180
181 - (void)removeDeviceForDevice:(IOHIDDeviceRef)device {
182 NJDevice *match = [self findDeviceByRef:device];
183 IOHIDDeviceRegisterInputValueCallback(device, NULL, NULL);
184 if (match) {
185 [_devices removeObject:match];
186 [outlineView reloadData];
187 connectDevicePrompt.hidden = !!_devices.count;
188 }
189 if (_devices.count == 1)
190 [outlineView expandItem:_devices[0]];
191 }
192
193 - (void)updateContinuousOutputs:(NSTimer *)timer {
194 self.mouseLoc = [NSEvent mouseLocation];
195 for (NJOutput *output in [_continousOutputs copy]) {
196 if (![output update:self]) {
197 [_continousOutputs removeObject:output];
198 }
199 }
200 if (!_continousOutputs.count) {
201 [_continuousOutputsTick invalidate];
202 _continuousOutputsTick = nil;
203 }
204 }
205
206 #define NSSTR(e) ((NSString *)CFSTR(e))
207
208 - (void)openHid {
209 if (_hidManager)
210 return;
211 NSLog(@"Opening HID manager.");
212 _hidManager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
213 IOHIDManagerScheduleWithRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
214 NSArray *criteria = @[ @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
215 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_Joystick) },
216 @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
217 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_GamePad) },
218 @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
219 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_MultiAxisController) }
220 ];
221 IOHIDManagerSetDeviceMatchingMultiple(_hidManager, (__bridge CFArrayRef)criteria);
222 IOReturn ret = IOHIDManagerOpen(_hidManager, kIOHIDOptionsTypeNone);
223 if (ret != kIOReturnSuccess) {
224 [[NSAlert alertWithMessageText:NSLocalizedString(@"input devices unavailable",
225 @"error title when devices can't be read")
226 defaultButton:nil
227 alternateButton:nil
228 otherButton:nil
229 informativeTextWithFormat:NSLocalizedString(@"input error 0x%08x occurred",
230 @"message containing IOReturn failure code when devices can't be read"), ret]
231 beginSheetModalForWindow:outlineView.window
232 modalDelegate:nil
233 didEndSelector:nil
234 contextInfo:nil];
235 [self closeHid];
236 } else {
237 IOHIDManagerRegisterDeviceMatchingCallback(_hidManager, add_callback, (__bridge void *)self);
238 IOHIDManagerRegisterDeviceRemovalCallback(_hidManager, remove_callback, (__bridge void *)self);
239 }
240 }
241
242 - (void)closeHid {
243 if (_hidManager) {
244 NSLog(@"Closing HID manager.");
245 IOHIDManagerUnscheduleFromRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
246 IOHIDManagerClose(_hidManager, kIOHIDOptionsTypeNone);
247 CFRelease(_hidManager);
248 _hidManager = NULL;
249 }
250 [_devices removeAllObjects];
251 [outlineView reloadData];
252 connectDevicePrompt.hidden = !!_devices.count;
253 }
254
255 - (NJInput *)selectedInput {
256 id <NJInputPathElement> item = [outlineView itemAtRow:outlineView.selectedRow];
257 return (!item.children && item.base) ? item : nil;
258 }
259
260 - (NSInteger)outlineView:(NSOutlineView *)outlineView
261 numberOfChildrenOfItem:(id <NJInputPathElement>)item {
262 return item ? item.children.count : _devices.count;
263 }
264
265 - (BOOL)outlineView:(NSOutlineView *)outlineView
266 isItemExpandable:(id <NJInputPathElement>)item {
267 return item ? [[item children] count] > 0: YES;
268 }
269
270 - (id)outlineView:(NSOutlineView *)outlineView
271 child:(NSInteger)index
272 ofItem:(id <NJInputPathElement>)item {
273 return item ? item.children[index] : _devices[index];
274 }
275
276 - (id)outlineView:(NSOutlineView *)outlineView
277 objectValueForTableColumn:(NSTableColumn *)tableColumn
278 byItem:(id <NJInputPathElement>)item {
279 return item ? item.name : @"root";
280 }
281
282 - (void)outlineViewSelectionDidChange:(NSNotification *)notification {
283 [outputController loadCurrent];
284 }
285
286 - (BOOL)outlineView:(NSOutlineView *)outlineView
287 isGroupItem:(id <NJInputPathElement>)item {
288 return [item isKindOfClass:NJDevice.class];
289 }
290
291 - (BOOL)outlineView:(NSOutlineView *)outlineView_
292 shouldSelectItem:(id <NJInputPathElement>)item {
293 return ![self outlineView:outlineView_ isGroupItem:item];
294 }
295
296 - (void)outlineViewItemDidExpand:(NSNotification *)notification {
297 id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
298 NSString *uid = item.uid;
299 if (![_expanded containsObject:uid])
300 [_expanded addObject:uid];
301 while (_expanded.count > EXPANDED_MEMORY_MAX_SIZE)
302 [_expanded removeObjectAtIndex:0];
303 [NSUserDefaults.standardUserDefaults setObject:_expanded
304 forKey:@"expanded rows"];
305 }
306
307 - (void)outlineViewItemDidCollapse:(NSNotification *)notification {
308 id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
309 [_expanded removeObject:item.uid];
310 [NSUserDefaults.standardUserDefaults setObject:_expanded
311 forKey:@"expanded rows"];
312 }
313
314 - (void)setTranslatingEvents:(BOOL)translatingEvents {
315 if (translatingEvents != _translatingEvents) {
316 _translatingEvents = translatingEvents;
317 NSInteger state = translatingEvents ? NSOnState : NSOffState;
318 translatingEventsButton.state = state;
319 NSString *name = translatingEvents
320 ? NJEventTranslationActivated
321 : NJEventTranslationDeactivated;
322 [NSNotificationCenter.defaultCenter postNotificationName:name
323 object:self];
324
325 if (!translatingEvents && !NSApplication.sharedApplication.isActive)
326 [self closeHid];
327 else if (translatingEvents || NSApplication.sharedApplication.isActive)
328 [self openHid];
329 }
330 }
331
332 - (void)reexpandAll {
333 for (NSString *uid in [_expanded copy])
334 [self expandRecursiveByUID:uid];
335 }
336
337 - (void)closeHidIfDisabled:(NSNotification *)application {
338 if (!self.translatingEvents)
339 [self closeHid];
340 }
341
342 - (void)applicationDidFinishLaunching:(NSNotification *)application {
343 // NSApplicationWillBecomeActiveNotification occurs just slightly
344 // too late - there's one tick where the UI is showing "No
345 // devices" even with a device plugged in.
346 [self openHid];
347 }
348
349 - (IBAction)translatingEventsChanged:(NSButton *)sender {
350 self.translatingEvents = sender.state == NSOnState;
351 }
352
353 @end