Whether error was set or not, if there's no mapping, that's an error.
[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:
26 NSLocalizedString(@"(default)", @"default name for first the mapping")];
27 _manualMapping = _currentMapping;
28 [_mappings addObject:_currentMapping];
29 }
30 return self;
31 }
32
33 - (void)awakeFromNib {
34 [tableView registerForDraggedTypes:@[PB_ROW, NSURLPboardType]];
35 [tableView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
36 }
37
38 - (NJMapping *)objectForKeyedSubscript:(NSString *)name {
39 for (NJMapping *mapping in _mappings)
40 if ([name isEqualToString:mapping.name])
41 return mapping;
42 return nil;
43 }
44
45 - (NJMapping *)objectAtIndexedSubscript:(NSUInteger)idx {
46 return idx < _mappings.count ? _mappings[idx] : nil;
47 }
48
49 - (void)mappingsSet {
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 - (void)mappingsChanged {
60 [self save];
61 [self mappingsSet];
62 }
63
64 - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state
65 objects:(__unsafe_unretained id [])buffer
66 count:(NSUInteger)len {
67 return [_mappings countByEnumeratingWithState:state
68 objects:buffer
69 count:len];
70 }
71
72 - (void)activateMappingForProcess:(NSRunningApplication *)app {
73 NJMapping *oldMapping = _manualMapping;
74 NSArray *names = app.possibleMappingNames;
75 BOOL found = NO;
76 for (NSString *name in names) {
77 NJMapping *mapping = self[name];
78 if (mapping) {
79 [self activateMapping:mapping];
80 found = YES;
81 break;
82 }
83 }
84
85 if (!found) {
86 [self activateMapping:oldMapping];
87 if ([oldMapping.name.lowercaseString isEqualToString:@"@application"]
88 || [oldMapping.name.lowercaseString isEqualToString:
89 NSLocalizedString(@"@Application", nil).lowercaseString]) {
90 oldMapping.name = app.bestMappingName;
91 [self mappingsChanged];
92 }
93 }
94 _manualMapping = oldMapping;
95 }
96
97 - (void)updateInterfaceForCurrentMapping {
98 NSUInteger selected = [_mappings indexOfObject:_currentMapping];
99 removeButton.enabled = selected != 0;
100 moveUp.enabled = selected > 1;
101 moveDown.enabled = selected && selected != _mappings.count - 1;
102 popoverActivate.title = _currentMapping.name;
103 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:selected] byExtendingSelection:NO];
104 [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"selected"];
105 }
106
107 - (void)activateMapping:(NJMapping *)mapping {
108 if (!mapping)
109 mapping = _manualMapping;
110 if (mapping == _currentMapping)
111 return;
112 NSLog(@"Switching to mapping %@.", mapping.name);
113 _manualMapping = mapping;
114 _currentMapping = mapping;
115 [self updateInterfaceForCurrentMapping];
116 [NSNotificationCenter.defaultCenter
117 postNotificationName:NJEventMappingChanged
118 object:self
119 userInfo:@{ NJMappingKey : _currentMapping }];
120 }
121
122 - (IBAction)addPressed:(id)sender {
123 [self mappingPressed:sender];
124 NJMapping *newMapping = [[NJMapping alloc] init];
125 [_mappings addObject:newMapping];
126 [self activateMapping:newMapping];
127 [self mappingsChanged];
128 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
129 }
130
131 - (IBAction)removePressed:(id)sender {
132 if (tableView.selectedRow == 0)
133 return;
134
135 NSInteger selectedRow = tableView.selectedRow;
136 [_mappings removeObjectAtIndex:selectedRow];
137 [self activateMapping:_mappings[MIN(selectedRow, _mappings.count - 1)]];
138 [self mappingsChanged];
139 }
140
141 - (void)tableViewSelectionDidChange:(NSNotification *)notify {
142 [self activateMapping:self[tableView.selectedRow]];
143 }
144
145 - (id)tableView:(NSTableView *)view objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)index {
146 return self[index].name;
147 }
148
149 - (void)tableView:(NSTableView *)view
150 setObjectValue:(NSString *)obj
151 forTableColumn:(NSTableColumn *)col
152 row:(NSInteger)index {
153 self[index].name = obj;
154 [self mappingsChanged];
155 }
156
157 - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
158 return _mappings.count;
159 }
160
161 - (BOOL)tableView:(NSTableView *)view shouldEditTableColumn:(NSTableColumn *)column row:(NSInteger)index {
162 return YES;
163 }
164
165 - (void)save {
166 NSLog(@"Saving mappings to defaults.");
167 NSMutableArray *ary = [[NSMutableArray alloc] initWithCapacity:_mappings.count];
168 for (NJMapping *mapping in _mappings)
169 [ary addObject:[mapping serialize]];
170 [NSUserDefaults.standardUserDefaults setObject:ary forKey:@"mappings"];
171 }
172
173 - (void)load {
174 NSUInteger selected = [NSUserDefaults.standardUserDefaults integerForKey:@"selected"];
175 NSArray *storedMappings = [NSUserDefaults.standardUserDefaults arrayForKey:@"mappings"];
176 NSMutableArray* newMappings = [[NSMutableArray alloc] initWithCapacity:storedMappings.count];
177
178 // Requires two passes to deal with inter-mapping references. First make
179 // an empty mapping for each serialized mapping. Then, deserialize the
180 // data pointing to the empty mappings. Then merge that data back into
181 // its equivalent empty one, which is the one we finally use.
182 for (NSDictionary *storedMapping in storedMappings) {
183 NJMapping *mapping = [[NJMapping alloc] initWithName:storedMapping[@"name"]];
184 [newMappings addObject:mapping];
185 }
186
187 for (unsigned i = 0; i < storedMappings.count; ++i) {
188 NJMapping *realMapping = [[NJMapping alloc] initWithSerialization:storedMappings[i]
189 mappings:newMappings];
190 [newMappings[i] mergeEntriesFrom:realMapping];
191 }
192
193 if (newMappings.count) {
194 _mappings = newMappings;
195 if (selected >= newMappings.count)
196 selected = 0;
197 [self activateMapping:_mappings[selected]];
198 [self mappingsSet];
199 }
200 }
201
202 - (void)mappingConflictDidResolve:(NSAlert *)alert
203 returnCode:(NSInteger)returnCode
204 contextInfo:(void *)contextInfo {
205 NSDictionary *userInfo = CFBridgingRelease(contextInfo);
206 NJMapping *oldMapping = userInfo[@"old mapping"];
207 NJMapping *newMapping = userInfo[@"new mapping"];
208 switch (returnCode) {
209 case NSAlertFirstButtonReturn: // Merge
210 [oldMapping mergeEntriesFrom:newMapping];
211 [self activateMapping:oldMapping];
212 [self mappingsChanged];
213 break;
214 case NSAlertThirdButtonReturn: // New Mapping
215 [_mappings addObject:newMapping];
216 [self activateMapping:newMapping];
217 [self mappingsChanged];
218 [self mappingPressed:alert];
219 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:_mappings.count - 1] byExtendingSelection:NO];
220 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
221 break;
222 default: // Cancel, other.
223 break;
224 }
225 }
226
227 - (void)addMappingWithContentsOfURL:(NSURL *)url {
228 NSWindow *window = popoverActivate.window;
229 NSError *error;
230 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
231 mappings:_mappings
232 error:&error];
233
234 if (mapping) {
235 NJMapping *mergeInto = self[mapping.name];
236 if ([mergeInto hasConflictWith:mapping]) {
237 NSAlert *conflictAlert = [[NSAlert alloc] init];
238 conflictAlert.messageText = NSLocalizedString(@"import conflict prompt", @"Title of import conflict alert");
239 conflictAlert.informativeText =
240 [NSString stringWithFormat:NSLocalizedString(@"import conflict in %@", @"Explanation of import conflict"),
241 mapping.name];
242 [conflictAlert addButtonWithTitle:NSLocalizedString(@"import and merge", @"button to merge imported mappings")];
243 [conflictAlert addButtonWithTitle:NSLocalizedString(@"cancel import", @"button to cancel import")];
244 [conflictAlert addButtonWithTitle:NSLocalizedString(@"import new mapping", @"button to import as new mapping")];
245 [conflictAlert beginSheetModalForWindow:popoverActivate.window
246 modalDelegate:self
247 didEndSelector:@selector(mappingConflictDidResolve:returnCode:contextInfo:)
248 contextInfo:(void *)CFBridgingRetain(@{ @"old mapping": mergeInto,
249 @"new mapping": mapping })];
250 } else if (mergeInto) {
251 [mergeInto mergeEntriesFrom:mapping];
252 [self activateMapping:mergeInto];
253 [self mappingsChanged];
254 } else {
255 [_mappings addObject:mapping];
256 [self activateMapping:mapping];
257 [self mappingsChanged];
258 }
259 } else {
260 [window presentError:error
261 modalForWindow:window
262 delegate:nil
263 didPresentSelector:nil
264 contextInfo:nil];
265 }
266 }
267
268 - (void)importPressed:(id)sender {
269 NSOpenPanel *panel = [NSOpenPanel openPanel];
270 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
271 NSWindow *window = NSApplication.sharedApplication.keyWindow;
272 [panel beginSheetModalForWindow:window
273 completionHandler:^(NSInteger result) {
274 if (result != NSFileHandlingPanelOKButton)
275 return;
276 [panel close];
277 [self addMappingWithContentsOfURL:panel.URL];
278 }];
279
280 }
281
282 - (void)exportPressed:(id)sender {
283 NSSavePanel *panel = [NSSavePanel savePanel];
284 panel.allowedFileTypes = @[ @"enjoyable" ];
285 NJMapping *mapping = _currentMapping;
286 panel.nameFieldStringValue = [mapping.name stringByFixingPathComponent];
287 NSWindow *window = NSApplication.sharedApplication.keyWindow;
288 [panel beginSheetModalForWindow:window
289 completionHandler:^(NSInteger result) {
290 if (result != NSFileHandlingPanelOKButton)
291 return;
292 [panel close];
293 NSError *error;
294 [mapping writeToURL:panel.URL error:&error];
295 if (error) {
296 [window presentError:error
297 modalForWindow:window
298 delegate:nil
299 didPresentSelector:nil
300 contextInfo:nil];
301 }
302 }];
303 }
304
305 - (IBAction)mappingPressed:(id)sender {
306 [popover showRelativeToRect:popoverActivate.bounds
307 ofView:popoverActivate
308 preferredEdge:NSMinXEdge];
309 }
310
311 - (void)popoverWillShow:(NSNotification *)notification {
312 popoverActivate.state = NSOnState;
313 }
314
315 - (void)popoverWillClose:(NSNotification *)notification {
316 popoverActivate.state = NSOffState;
317 }
318
319 - (IBAction)moveUpPressed:(id)sender {
320 if ([_mappings moveFirstwards:_currentMapping upTo:1])
321 [self mappingsChanged];
322 }
323
324 - (IBAction)moveDownPressed:(id)sender {
325 if ([_mappings moveLastwards:_currentMapping])
326 [self mappingsChanged];
327 }
328
329 - (BOOL)tableView:(NSTableView *)tableView_
330 acceptDrop:(id <NSDraggingInfo>)info
331 row:(NSInteger)row
332 dropOperation:(NSTableViewDropOperation)dropOperation {
333 NSPasteboard *pboard = [info draggingPasteboard];
334 if ([pboard.types containsObject:PB_ROW]) {
335 NSString *value = [pboard stringForType:PB_ROW];
336 NSUInteger srcRow = [value intValue];
337 [_mappings moveObjectAtIndex:srcRow toIndex:row];
338 [self mappingsChanged];
339 return YES;
340 } else if ([pboard.types containsObject:NSURLPboardType]) {
341 NSURL *url = [NSURL URLFromPasteboard:pboard];
342 NSError *error;
343 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
344 mappings:_mappings
345 error:&error];
346 if (error) {
347 [tableView_ presentError:error];
348 return NO;
349 } else {
350 [_mappings insertObject:mapping atIndex:row];
351 [self mappingsChanged];
352 return YES;
353 }
354 } else {
355 return NO;
356 }
357 }
358
359 - (NSDragOperation)tableView:(NSTableView *)tableView_
360 validateDrop:(id <NSDraggingInfo>)info
361 proposedRow:(NSInteger)row
362 proposedDropOperation:(NSTableViewDropOperation)dropOperation {
363 NSPasteboard *pboard = [info draggingPasteboard];
364 if ([pboard.types containsObject:PB_ROW]) {
365 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
366 return NSDragOperationMove;
367 } else if ([pboard.types containsObject:NSURLPboardType]) {
368 NSURL *url = [NSURL URLFromPasteboard:pboard];
369 if ([url.pathExtension isEqualToString:@"enjoyable"]) {
370 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
371 return NSDragOperationCopy;
372 } else {
373 return NSDragOperationNone;
374 }
375 } else {
376 return NSDragOperationNone;
377 }
378 }
379
380 - (NSArray *)tableView:(NSTableView *)tableView_
381 namesOfPromisedFilesDroppedAtDestination:(NSURL *)dropDestination
382 forDraggedRowsWithIndexes:(NSIndexSet *)indexSet {
383 NJMapping *toSave = self[indexSet.firstIndex];
384 NSString *filename = [[toSave.name stringByFixingPathComponent]
385 stringByAppendingPathExtension:@"enjoyable"];
386 NSURL *dst = [dropDestination URLByAppendingPathComponent:filename];
387 dst = [NSFileManager.defaultManager generateUniqueURLWithBase:dst];
388 NSError *error;
389 if (![toSave writeToURL:dst error:&error]) {
390 [tableView_ presentError:error];
391 return @[];
392 } else {
393 return @[dst.lastPathComponent];
394 }
395 }
396
397 - (BOOL)tableView:(NSTableView *)tableView_
398 writeRowsWithIndexes:(NSIndexSet *)rowIndexes
399 toPasteboard:(NSPasteboard *)pboard {
400 if (rowIndexes.count == 1 && rowIndexes.firstIndex != 0) {
401 [pboard declareTypes:@[PB_ROW, NSFilesPromisePboardType] owner:nil];
402 [pboard setString:@(rowIndexes.firstIndex).stringValue forType:PB_ROW];
403 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
404 return YES;
405 } else if (rowIndexes.count == 1 && rowIndexes.firstIndex == 0) {
406 [pboard declareTypes:@[NSFilesPromisePboardType] owner:nil];
407 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
408 return YES;
409 } else {
410 return NO;
411 }
412 }
413
414 @end