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