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