Mouse improvements. Segment and snap the mouse move and scroll speed for easier match...
[enjoyable.git] / Classes / NJMappingsController.m
1 //
2 // NJMappingsController.m
3 // Enjoy
4 //
5 // Created by Sam McCall on 4/05/09.
6 //
7
8 #import "NJMappingsController.h"
9
10 #import "NJMapping.h"
11 #import "NJMappingsController.h"
12 #import "NJOutput.h"
13 #import "NJOutputController.h"
14 #import "NJEvents.h"
15
16 #define PB_ROW @"com.yukkurigames.Enjoyable.MappingRow"
17
18 @implementation NJMappingsController {
19 NSMutableArray *_mappings;
20 NJMapping *manualMapping;
21 NSString *draggingName;
22 }
23
24 - (id)init {
25 if ((self = [super init])) {
26 _mappings = [[NSMutableArray alloc] init];
27 _currentMapping = [[NJMapping alloc] initWithName:@"(default)"];
28 manualMapping = _currentMapping;
29 [_mappings addObject:_currentMapping];
30 }
31 return self;
32 }
33
34 - (void)awakeFromNib {
35 [tableView registerForDraggedTypes:@[PB_ROW, NSURLPboardType]];
36 [tableView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
37 }
38
39 - (NJMapping *)objectForKeyedSubscript:(NSString *)name {
40 for (NJMapping *mapping in _mappings)
41 if ([name isEqualToString:mapping.name])
42 return mapping;
43 return nil;
44 }
45
46 - (NJMapping *)objectAtIndexedSubscript:(NSUInteger)idx {
47 return idx < _mappings.count ? _mappings[idx] : nil;
48 }
49
50 - (void)mappingsChanged {
51 [self save];
52 [tableView reloadData];
53 popoverActivate.title = _currentMapping.name;
54 [self updateInterfaceForCurrentMapping];
55 [NSNotificationCenter.defaultCenter
56 postNotificationName:NJEventMappingListChanged
57 object:_mappings];
58 }
59
60 - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state
61 objects:(__unsafe_unretained id [])buffer
62 count:(NSUInteger)len {
63 return [_mappings countByEnumeratingWithState:state
64 objects:buffer
65 count:len];
66 }
67
68 - (void)activateMappingForProcess:(NSRunningApplication *)app {
69 NJMapping *oldMapping = manualMapping;
70 NSArray *names = app.possibleMappingNames;
71 BOOL found = NO;
72 for (NSString *name in names) {
73 NJMapping *mapping = self[name];
74 if (mapping) {
75 [self activateMapping:self[name]];
76 found = YES;
77 break;
78 }
79 }
80
81 if (!found) {
82 [self activateMapping:oldMapping];
83 if ([oldMapping.name.lowercaseString isEqualToString:@"@application"]) {
84 oldMapping.name = app.bestMappingName;
85 [self mappingsChanged];
86 }
87 }
88 manualMapping = oldMapping;
89 }
90
91 - (void)updateInterfaceForCurrentMapping {
92 NSUInteger selected = [_mappings indexOfObject:_currentMapping];
93 [removeButton setEnabled:selected != 0];
94 [moveDown setEnabled:selected && selected != _mappings.count - 1];
95 [moveUp setEnabled:selected > 1];
96 popoverActivate.title = _currentMapping.name;
97 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:selected] byExtendingSelection:NO];
98 [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"selected"];
99 }
100
101 - (void)activateMapping:(NJMapping *)mapping {
102 if (!mapping)
103 mapping = manualMapping;
104 if (mapping == _currentMapping)
105 return;
106 NSLog(@"Switching to mapping %@.", mapping.name);
107 manualMapping = mapping;
108 _currentMapping = mapping;
109 [self updateInterfaceForCurrentMapping];
110 [outputController loadCurrent];
111 [NSNotificationCenter.defaultCenter postNotificationName:NJEventMappingChanged
112 object:_currentMapping];
113 }
114
115 - (IBAction)addPressed:(id)sender {
116 NJMapping *newMapping = [[NJMapping alloc] initWithName:@"Untitled"];
117 [_mappings addObject:newMapping];
118 [self activateMapping:newMapping];
119 [self mappingsChanged];
120 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
121 }
122
123 - (IBAction)removePressed:(id)sender {
124 if (tableView.selectedRow == 0)
125 return;
126
127 NSInteger selectedRow = tableView.selectedRow;
128 [_mappings removeObjectAtIndex:selectedRow];
129 [self activateMapping:_mappings[MIN(selectedRow, _mappings.count - 1)]];
130 [self mappingsChanged];
131 }
132
133 - (void)tableViewSelectionDidChange:(NSNotification *)notify {
134 [self activateMapping:self[tableView.selectedRow]];
135 }
136
137 - (id)tableView:(NSTableView *)view objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)index {
138 return self[index].name;
139 }
140
141 - (void)tableView:(NSTableView *)view
142 setObjectValue:(NSString *)obj
143 forTableColumn:(NSTableColumn *)col
144 row:(NSInteger)index {
145 self[index].name = obj;
146 [self mappingsChanged];
147 }
148
149 - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
150 return _mappings.count;
151 }
152
153 - (BOOL)tableView:(NSTableView *)view shouldEditTableColumn:(NSTableColumn *)column row:(NSInteger)index {
154 return YES;
155 }
156
157 - (void)save {
158 NSLog(@"Saving mappings to defaults.");
159 NSMutableArray *ary = [[NSMutableArray alloc] initWithCapacity:_mappings.count];
160 for (NJMapping *mapping in _mappings)
161 [ary addObject:[mapping serialize]];
162 [NSUserDefaults.standardUserDefaults setObject:ary forKey:@"mappings"];
163 }
164
165 - (void)load {
166 NSUInteger selected = [NSUserDefaults.standardUserDefaults integerForKey:@"selected"];
167 NSArray *mappings = [NSUserDefaults.standardUserDefaults arrayForKey:@"mappings"];
168 [self loadAllFrom:mappings andActivate:selected];
169 }
170
171 - (void)loadAllFrom:(NSArray *)storedMappings andActivate:(NSUInteger)selected {
172 NSMutableArray* newMappings = [[NSMutableArray alloc] initWithCapacity:storedMappings.count];
173
174 // have to do two passes in case mapping1 refers to mapping2 via a NJOutputMapping
175 for (NSDictionary *storedMapping in storedMappings) {
176 NJMapping *mapping = [[NJMapping alloc] initWithName:storedMapping[@"name"]];
177 [newMappings addObject:mapping];
178 }
179
180 for (unsigned i = 0; i < storedMappings.count; ++i) {
181 NSDictionary *entries = storedMappings[i][@"entries"];
182 NJMapping *mapping = newMappings[i];
183 for (id key in entries) {
184 NJOutput *output = [NJOutput outputDeserialize:entries[key]
185 withMappings:newMappings];
186 if (output)
187 mapping.entries[key] = output;
188 }
189 }
190
191 if (newMappings.count) {
192 _mappings = newMappings;
193 if (selected >= newMappings.count)
194 selected = 0;
195 [self activateMapping:_mappings[selected]];
196 [self mappingsChanged];
197 }
198 }
199
200 - (NJMapping *)mappingWithURL:(NSURL *)url error:(NSError **)error {
201 NSInputStream *stream = [NSInputStream inputStreamWithURL:url];
202 [stream open];
203 NSDictionary *serialization = !*error
204 ? [NSJSONSerialization JSONObjectWithStream:stream options:0 error:error]
205 : nil;
206 [stream close];
207
208 if (!([serialization isKindOfClass:NSDictionary.class]
209 && [serialization[@"name"] isKindOfClass:NSString.class]
210 && [serialization[@"entries"] isKindOfClass:NSDictionary.class])) {
211 *error = [NSError errorWithDomain:@"Enjoyable"
212 code:0
213 description:@"This isn't a valid mapping file."];
214 return nil;
215 }
216
217 NSDictionary *entries = serialization[@"entries"];
218 NJMapping *mapping = [[NJMapping alloc] initWithName:serialization[@"name"]];
219 for (id key in entries) {
220 NSDictionary *value = entries[key];
221 if ([key isKindOfClass:NSString.class]) {
222 NJOutput *output = [NJOutput outputDeserialize:value
223 withMappings:_mappings];
224 if (output)
225 mapping.entries[key] = output;
226 }
227 }
228 return mapping;
229 }
230
231 - (void)addMappingWithContentsOfURL:(NSURL *)url {
232 NSWindow *window = popoverActivate.window;
233 NSError *error;
234 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
235 mappings:_mappings
236 error:&error];
237
238 if (mapping && !error) {
239 BOOL conflict = NO;
240 NJMapping *mergeInto = self[mapping.name];
241 for (id key in mapping.entries) {
242 if (mergeInto.entries[key]
243 && ![mergeInto.entries[key] isEqual:mapping.entries[key]]) {
244 conflict = YES;
245 break;
246 }
247 }
248
249 if (conflict) {
250 NSAlert *conflictAlert = [[NSAlert alloc] init];
251 conflictAlert.messageText = @"Replace existing mappings?";
252 conflictAlert.informativeText =
253 [NSString stringWithFormat:
254 @"This file contains inputs you've already mapped in \"%@\". Do you "
255 @"want to merge them and replace your existing mappings, or import this "
256 @"as a separate mapping?", mapping.name];
257 [conflictAlert addButtonWithTitle:@"Merge"];
258 [conflictAlert addButtonWithTitle:@"Cancel"];
259 [conflictAlert addButtonWithTitle:@"New Mapping"];
260 NSInteger res = [conflictAlert runModal];
261 if (res == NSAlertSecondButtonReturn)
262 return;
263 else if (res == NSAlertThirdButtonReturn)
264 mergeInto = nil;
265 }
266
267 if (mergeInto) {
268 [mergeInto.entries addEntriesFromDictionary:mapping.entries];
269 mapping = mergeInto;
270 } else {
271 [_mappings addObject:mapping];
272 }
273
274 [self activateMapping:mapping];
275 [self mappingsChanged];
276
277 if (conflict && !mergeInto) {
278 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:_mappings.count - 1] byExtendingSelection:NO];
279 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
280 }
281 }
282
283 if (error) {
284 [window presentError:error
285 modalForWindow:window
286 delegate:nil
287 didPresentSelector:nil
288 contextInfo:nil];
289 }
290 }
291
292 - (void)importPressed:(id)sender {
293 NSOpenPanel *panel = [NSOpenPanel openPanel];
294 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
295 NSWindow *window = NSApplication.sharedApplication.keyWindow;
296 [panel beginSheetModalForWindow:window
297 completionHandler:^(NSInteger result) {
298 if (result != NSFileHandlingPanelOKButton)
299 return;
300 [panel close];
301 [self addMappingWithContentsOfURL:panel.URL];
302 }];
303
304 }
305
306 - (void)exportPressed:(id)sender {
307 NSSavePanel *panel = [NSSavePanel savePanel];
308 panel.allowedFileTypes = @[ @"enjoyable" ];
309 NJMapping *mapping = _currentMapping;
310 panel.nameFieldStringValue = [mapping.name stringByFixingPathComponent];
311 NSWindow *window = NSApplication.sharedApplication.keyWindow;
312 [panel beginSheetModalForWindow:window
313 completionHandler:^(NSInteger result) {
314 if (result != NSFileHandlingPanelOKButton)
315 return;
316 [panel close];
317 NSError *error;
318 [mapping writeToURL:panel.URL error:&error];
319 if (error) {
320 [window presentError:error
321 modalForWindow:window
322 delegate:nil
323 didPresentSelector:nil
324 contextInfo:nil];
325 }
326 }];
327 }
328
329 - (IBAction)mappingPressed:(id)sender {
330 [popover showRelativeToRect:popoverActivate.bounds
331 ofView:popoverActivate
332 preferredEdge:NSMinXEdge];
333 }
334
335 - (void)popoverWillShow:(NSNotification *)notification {
336 popoverActivate.state = NSOnState;
337 }
338
339 - (void)popoverWillClose:(NSNotification *)notification {
340 popoverActivate.state = NSOffState;
341 }
342
343 - (IBAction)moveUpPressed:(id)sender {
344 NSUInteger idx = [_mappings indexOfObject:_currentMapping];
345 if (idx > 1 && idx != NSNotFound) {
346 [_mappings exchangeObjectAtIndex:idx withObjectAtIndex:idx - 1];
347 [self mappingsChanged];
348 }
349 }
350
351 - (IBAction)moveDownPressed:(id)sender {
352 NSUInteger idx = [_mappings indexOfObject:_currentMapping];
353 if (idx < _mappings.count - 1) {
354 [_mappings exchangeObjectAtIndex:idx withObjectAtIndex:idx + 1];
355 [self mappingsChanged];
356 }
357 }
358
359 - (BOOL)tableView:(NSTableView *)tableView_
360 acceptDrop:(id <NSDraggingInfo>)info
361 row:(NSInteger)row
362 dropOperation:(NSTableViewDropOperation)dropOperation {
363 NSPasteboard *pboard = [info draggingPasteboard];
364 if ([pboard.types containsObject:PB_ROW]) {
365 NSString *value = [pboard stringForType:PB_ROW];
366 NSUInteger srcRow = [value intValue];
367 [_mappings moveObjectAtIndex:srcRow toIndex:row];
368 [self mappingsChanged];
369 return YES;
370 } else if ([pboard.types containsObject:NSURLPboardType]) {
371 NSURL *url = [NSURL URLFromPasteboard:pboard];
372 NSError *error;
373 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
374 mappings:_mappings
375 error:&error];
376 if (error) {
377 [tableView_ presentError:error];
378 return NO;
379 } else {
380 [_mappings insertObject:mapping atIndex:row];
381 [self mappingsChanged];
382 return YES;
383 }
384 } else {
385 return NO;
386 }
387 }
388
389 - (NSDragOperation)tableView:(NSTableView *)tableView_
390 validateDrop:(id <NSDraggingInfo>)info
391 proposedRow:(NSInteger)row
392 proposedDropOperation:(NSTableViewDropOperation)dropOperation {
393 NSPasteboard *pboard = [info draggingPasteboard];
394 if ([pboard.types containsObject:PB_ROW]) {
395 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
396 return NSDragOperationMove;
397 } else if ([pboard.types containsObject:NSURLPboardType]) {
398 NSURL *url = [NSURL URLFromPasteboard:pboard];
399 if ([url.pathExtension isEqualToString:@"enjoyable"]) {
400 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
401 return NSDragOperationCopy;
402 } else {
403 return NSDragOperationNone;
404 }
405 } else {
406 return NSDragOperationNone;
407 }
408 }
409
410 - (NSArray *)tableView:(NSTableView *)tableView_
411 namesOfPromisedFilesDroppedAtDestination:(NSURL *)dropDestination
412 forDraggedRowsWithIndexes:(NSIndexSet *)indexSet {
413 NJMapping *toSave = self[indexSet.firstIndex];
414 NSString *filename = [[toSave.name stringByFixingPathComponent]
415 stringByAppendingPathExtension:@"enjoyable"];
416 NSURL *dst = [dropDestination URLByAppendingPathComponent:filename];
417 dst = [NSFileManager.defaultManager generateUniqueURLWithBase:dst];
418 NSError *error;
419 if (![toSave writeToURL:dst error:&error]) {
420 [tableView_ presentError:error];
421 return @[];
422 } else {
423 return @[dst.lastPathComponent];
424 }
425 }
426
427 - (BOOL)tableView:(NSTableView *)tableView_
428 writeRowsWithIndexes:(NSIndexSet *)rowIndexes
429 toPasteboard:(NSPasteboard *)pboard {
430 if (rowIndexes.count == 1 && rowIndexes.firstIndex != 0) {
431 [pboard declareTypes:@[PB_ROW, NSFilesPromisePboardType] owner:nil];
432 [pboard setString:@(rowIndexes.firstIndex).stringValue forType:PB_ROW];
433 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
434 return YES;
435 } else if (rowIndexes.count == 1 && rowIndexes.firstIndex == 0) {
436 [pboard declareTypes:@[NSFilesPromisePboardType] owner:nil];
437 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
438 return YES;
439 } else {
440 return NO;
441 }
442 }
443
444 @end