5faa4bf47d0384dac19695592aacf51335fa36c9
[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 "NJEvents.h"
14
15 #define PB_ROW @"com.yukkurigames.Enjoyable.MappingRow"
16
17 @implementation NJMappingsController {
18 NSMutableArray *_mappings;
19 NJMapping *_manualMapping;
20 }
21
22 - (id)init {
23 if ((self = [super init])) {
24 _mappings = [[NSMutableArray alloc] init];
25 _currentMapping = [[NJMapping alloc] initWithName:@"(default)"];
26 _manualMapping = _currentMapping;
27 [_mappings addObject:_currentMapping];
28 }
29 return self;
30 }
31
32 - (void)awakeFromNib {
33 [tableView registerForDraggedTypes:@[PB_ROW, NSURLPboardType]];
34 [tableView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
35 }
36
37 - (NJMapping *)objectForKeyedSubscript:(NSString *)name {
38 for (NJMapping *mapping in _mappings)
39 if ([name isEqualToString:mapping.name])
40 return mapping;
41 return nil;
42 }
43
44 - (NJMapping *)objectAtIndexedSubscript:(NSUInteger)idx {
45 return idx < _mappings.count ? _mappings[idx] : nil;
46 }
47
48 - (void)mappingsChanged {
49 [self save];
50 [tableView reloadData];
51 [self updateInterfaceForCurrentMapping];
52 [NSNotificationCenter.defaultCenter
53 postNotificationName:NJEventMappingListChanged
54 object:self
55 userInfo:@{ NJMappingListKey: _mappings,
56 NJMappingKey: _currentMapping }];
57 }
58
59 - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state
60 objects:(__unsafe_unretained id [])buffer
61 count:(NSUInteger)len {
62 return [_mappings countByEnumeratingWithState:state
63 objects:buffer
64 count:len];
65 }
66
67 - (void)activateMappingForProcess:(NSRunningApplication *)app {
68 NJMapping *oldMapping = _manualMapping;
69 NSArray *names = app.possibleMappingNames;
70 BOOL found = NO;
71 for (NSString *name in names) {
72 NJMapping *mapping = self[name];
73 if (mapping) {
74 [self activateMapping:mapping];
75 found = YES;
76 break;
77 }
78 }
79
80 if (!found) {
81 [self activateMapping:oldMapping];
82 if ([oldMapping.name.lowercaseString isEqualToString:@"@application"]) {
83 oldMapping.name = app.bestMappingName;
84 [self mappingsChanged];
85 }
86 }
87 _manualMapping = oldMapping;
88 }
89
90 - (void)updateInterfaceForCurrentMapping {
91 NSUInteger selected = [_mappings indexOfObject:_currentMapping];
92 removeButton.enabled = selected != 0;
93 moveUp.enabled = selected > 1;
94 moveDown.enabled = selected && selected != _mappings.count - 1;
95 popoverActivate.title = _currentMapping.name;
96 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:selected] byExtendingSelection:NO];
97 [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"selected"];
98 }
99
100 - (void)activateMapping:(NJMapping *)mapping {
101 if (!mapping)
102 mapping = _manualMapping;
103 if (mapping == _currentMapping)
104 return;
105 NSLog(@"Switching to mapping %@.", mapping.name);
106 _manualMapping = mapping;
107 _currentMapping = mapping;
108 [self updateInterfaceForCurrentMapping];
109 [NSNotificationCenter.defaultCenter
110 postNotificationName:NJEventMappingChanged
111 object:self
112 userInfo:@{ NJMappingKey : _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 // Requires two passes to deal with inter-mapping references. First make
175 // an empty mapping for each serialized mapping. Then, deserialize the
176 // data pointing to the empty mappings. Then merge that data back into
177 // its equivalent empty one, which is the one we finally use.
178 for (NSDictionary *storedMapping in storedMappings) {
179 NJMapping *mapping = [[NJMapping alloc] initWithName:storedMapping[@"name"]];
180 [newMappings addObject:mapping];
181 }
182
183 for (unsigned i = 0; i < storedMappings.count; ++i) {
184 NJMapping *realMapping = [[NJMapping alloc] initWithSerialization:storedMappings[i]
185 mappings:newMappings];
186 [newMappings[i] mergeEntriesFrom:realMapping];
187 }
188
189 if (newMappings.count) {
190 _mappings = newMappings;
191 if (selected >= newMappings.count)
192 selected = 0;
193 [self activateMapping:_mappings[selected]];
194 [self mappingsChanged];
195 }
196 }
197
198 - (void)mappingConflictDidResolve:(NSAlert *)alert
199 returnCode:(NSInteger)returnCode
200 contextInfo:(void *)contextInfo {
201 NSDictionary *userInfo = CFBridgingRelease(contextInfo);
202 NJMapping *oldMapping = userInfo[@"old mapping"];
203 NJMapping *newMapping = userInfo[@"new mapping"];
204 switch (returnCode) {
205 case NSAlertFirstButtonReturn: // Merge
206 [oldMapping mergeEntriesFrom:newMapping];
207 [self activateMapping:oldMapping];
208 [self mappingsChanged];
209 break;
210 case NSAlertThirdButtonReturn: // New Mapping
211 [_mappings addObject:newMapping];
212 [self activateMapping:newMapping];
213 [self mappingsChanged];
214 [self mappingPressed:alert];
215 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:_mappings.count - 1] byExtendingSelection:NO];
216 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
217 break;
218 default: // Cancel, other.
219 break;
220 }
221 }
222
223 - (void)addMappingWithContentsOfURL:(NSURL *)url {
224 NSWindow *window = popoverActivate.window;
225 NSError *error;
226 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
227 mappings:_mappings
228 error:&error];
229
230 if (mapping && !error) {
231 NJMapping *mergeInto = self[mapping.name];
232 if ([mergeInto hasConflictWith:mapping]) {
233 NSAlert *conflictAlert = [[NSAlert alloc] init];
234 conflictAlert.messageText = @"Replace existing mappings?";
235 conflictAlert.informativeText =
236 [NSString stringWithFormat:
237 @"This file contains inputs you've already mapped in \"%@\". Do you "
238 @"want to merge them and replace your existing mappings, or import this "
239 @"as a separate mapping?", mapping.name];
240 [conflictAlert addButtonWithTitle:@"Merge"];
241 [conflictAlert addButtonWithTitle:@"Cancel"];
242 [conflictAlert addButtonWithTitle:@"New Mapping"];
243 [conflictAlert beginSheetModalForWindow:popoverActivate.window
244 modalDelegate:self
245 didEndSelector:@selector(mappingConflictDidResolve:returnCode:contextInfo:)
246 contextInfo:(void *)CFBridgingRetain(@{ @"old mapping": mergeInto,
247 @"new mapping": mapping })];
248 } else {
249 [_mappings addObject:mapping];
250 [self activateMapping:mapping];
251 [self mappingsChanged];
252 }
253 }
254
255 if (error) {
256 [window presentError:error
257 modalForWindow:window
258 delegate:nil
259 didPresentSelector:nil
260 contextInfo:nil];
261 }
262 }
263
264 - (void)importPressed:(id)sender {
265 NSOpenPanel *panel = [NSOpenPanel openPanel];
266 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
267 NSWindow *window = NSApplication.sharedApplication.keyWindow;
268 [panel beginSheetModalForWindow:window
269 completionHandler:^(NSInteger result) {
270 if (result != NSFileHandlingPanelOKButton)
271 return;
272 [panel close];
273 [self addMappingWithContentsOfURL:panel.URL];
274 }];
275
276 }
277
278 - (void)exportPressed:(id)sender {
279 NSSavePanel *panel = [NSSavePanel savePanel];
280 panel.allowedFileTypes = @[ @"enjoyable" ];
281 NJMapping *mapping = _currentMapping;
282 panel.nameFieldStringValue = [mapping.name stringByFixingPathComponent];
283 NSWindow *window = NSApplication.sharedApplication.keyWindow;
284 [panel beginSheetModalForWindow:window
285 completionHandler:^(NSInteger result) {
286 if (result != NSFileHandlingPanelOKButton)
287 return;
288 [panel close];
289 NSError *error;
290 [mapping writeToURL:panel.URL error:&error];
291 if (error) {
292 [window presentError:error
293 modalForWindow:window
294 delegate:nil
295 didPresentSelector:nil
296 contextInfo:nil];
297 }
298 }];
299 }
300
301 - (IBAction)mappingPressed:(id)sender {
302 [popover showRelativeToRect:popoverActivate.bounds
303 ofView:popoverActivate
304 preferredEdge:NSMinXEdge];
305 }
306
307 - (void)popoverWillShow:(NSNotification *)notification {
308 popoverActivate.state = NSOnState;
309 }
310
311 - (void)popoverWillClose:(NSNotification *)notification {
312 popoverActivate.state = NSOffState;
313 }
314
315 - (IBAction)moveUpPressed:(id)sender {
316 if ([_mappings moveFirstwards:_currentMapping upTo:1])
317 [self mappingsChanged];
318 }
319
320 - (IBAction)moveDownPressed:(id)sender {
321 if ([_mappings moveLastwards:_currentMapping])
322 [self mappingsChanged];
323 }
324
325 - (BOOL)tableView:(NSTableView *)tableView_
326 acceptDrop:(id <NSDraggingInfo>)info
327 row:(NSInteger)row
328 dropOperation:(NSTableViewDropOperation)dropOperation {
329 NSPasteboard *pboard = [info draggingPasteboard];
330 if ([pboard.types containsObject:PB_ROW]) {
331 NSString *value = [pboard stringForType:PB_ROW];
332 NSUInteger srcRow = [value intValue];
333 [_mappings moveObjectAtIndex:srcRow toIndex:row];
334 [self mappingsChanged];
335 return YES;
336 } else if ([pboard.types containsObject:NSURLPboardType]) {
337 NSURL *url = [NSURL URLFromPasteboard:pboard];
338 NSError *error;
339 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
340 mappings:_mappings
341 error:&error];
342 if (error) {
343 [tableView_ presentError:error];
344 return NO;
345 } else {
346 [_mappings insertObject:mapping atIndex:row];
347 [self mappingsChanged];
348 return YES;
349 }
350 } else {
351 return NO;
352 }
353 }
354
355 - (NSDragOperation)tableView:(NSTableView *)tableView_
356 validateDrop:(id <NSDraggingInfo>)info
357 proposedRow:(NSInteger)row
358 proposedDropOperation:(NSTableViewDropOperation)dropOperation {
359 NSPasteboard *pboard = [info draggingPasteboard];
360 if ([pboard.types containsObject:PB_ROW]) {
361 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
362 return NSDragOperationMove;
363 } else if ([pboard.types containsObject:NSURLPboardType]) {
364 NSURL *url = [NSURL URLFromPasteboard:pboard];
365 if ([url.pathExtension isEqualToString:@"enjoyable"]) {
366 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
367 return NSDragOperationCopy;
368 } else {
369 return NSDragOperationNone;
370 }
371 } else {
372 return NSDragOperationNone;
373 }
374 }
375
376 - (NSArray *)tableView:(NSTableView *)tableView_
377 namesOfPromisedFilesDroppedAtDestination:(NSURL *)dropDestination
378 forDraggedRowsWithIndexes:(NSIndexSet *)indexSet {
379 NJMapping *toSave = self[indexSet.firstIndex];
380 NSString *filename = [[toSave.name stringByFixingPathComponent]
381 stringByAppendingPathExtension:@"enjoyable"];
382 NSURL *dst = [dropDestination URLByAppendingPathComponent:filename];
383 dst = [NSFileManager.defaultManager generateUniqueURLWithBase:dst];
384 NSError *error;
385 if (![toSave writeToURL:dst error:&error]) {
386 [tableView_ presentError:error];
387 return @[];
388 } else {
389 return @[dst.lastPathComponent];
390 }
391 }
392
393 - (BOOL)tableView:(NSTableView *)tableView_
394 writeRowsWithIndexes:(NSIndexSet *)rowIndexes
395 toPasteboard:(NSPasteboard *)pboard {
396 if (rowIndexes.count == 1 && rowIndexes.firstIndex != 0) {
397 [pboard declareTypes:@[PB_ROW, NSFilesPromisePboardType] owner:nil];
398 [pboard setString:@(rowIndexes.firstIndex).stringValue forType:PB_ROW];
399 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
400 return YES;
401 } else if (rowIndexes.count == 1 && rowIndexes.firstIndex == 0) {
402 [pboard declareTypes:@[NSFilesPromisePboardType] owner:nil];
403 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
404 return YES;
405 } else {
406 return NO;
407 }
408 }
409
410 @end