Support magic @Application token for automatic mapping names. Fall back to the bundle...
[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 @implementation NJMappingsController {
17 NSMutableArray *_mappings;
18 NJMapping *manualMapping;
19 }
20
21 - (id)init {
22 if ((self = [super init])) {
23 _mappings = [[NSMutableArray alloc] init];
24 _currentMapping = [[NJMapping alloc] initWithName:@"(default)"];
25 manualMapping = _currentMapping;
26 [_mappings addObject:_currentMapping];
27 }
28 return self;
29 }
30
31 - (NJMapping *)objectForKeyedSubscript:(NSString *)name {
32 for (NJMapping *mapping in _mappings)
33 if ([name isEqualToString:mapping.name])
34 return mapping;
35 return nil;
36 }
37
38 - (NJMapping *)objectAtIndexedSubscript:(NSUInteger)idx {
39 return idx < _mappings.count ? _mappings[idx] : nil;
40 }
41
42 - (void)mappingsChanged {
43 [self save];
44 [tableView reloadData];
45 popoverActivate.title = _currentMapping.name;
46 [NSNotificationCenter.defaultCenter
47 postNotificationName:NJEventMappingListChanged
48 object:_mappings];
49 }
50
51 - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state
52 objects:(__unsafe_unretained id [])buffer
53 count:(NSUInteger)len {
54 return [_mappings countByEnumeratingWithState:state
55 objects:buffer
56 count:len];
57 }
58
59 - (void)activateMappingForProcess:(NSString *)processName {
60 if ([manualMapping.name.lowercaseString isEqualToString:@"@application"]) {
61 manualMapping.name = processName;
62 [self mappingsChanged];
63 } else {
64 NJMapping *oldMapping = manualMapping;
65 NJMapping *newMapping = self[processName];
66 if (!newMapping)
67 newMapping = oldMapping;
68 if (newMapping != _currentMapping)
69 [self activateMapping:newMapping];
70 manualMapping = oldMapping;
71 }
72 }
73
74 - (void)activateMapping:(NJMapping *)mapping {
75 if (!mapping)
76 mapping = manualMapping;
77 if (mapping == _currentMapping)
78 return;
79 NSLog(@"Switching to mapping %@.", mapping.name);
80 manualMapping = mapping;
81 _currentMapping = mapping;
82 [removeButton setEnabled:_mappings[0] != mapping];
83 [outputController loadCurrent];
84 popoverActivate.title = _currentMapping.name;
85 NSUInteger selected = [_mappings indexOfObject:mapping];
86 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:selected] byExtendingSelection:NO];
87 [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"selected"];
88 [NSNotificationCenter.defaultCenter postNotificationName:NJEventMappingChanged
89 object:_currentMapping];
90 }
91
92 - (IBAction)addPressed:(id)sender {
93 NJMapping *newMapping = [[NJMapping alloc] initWithName:@"Untitled"];
94 [_mappings addObject:newMapping];
95 [self mappingsChanged];
96 [self activateMapping:newMapping];
97 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
98 }
99
100 - (IBAction)removePressed:(id)sender {
101 if (tableView.selectedRow == 0)
102 return;
103
104 [_mappings removeObjectAtIndex:tableView.selectedRow];
105 [self mappingsChanged];
106 [self activateMapping:_mappings[0]];
107 }
108
109 -(void)tableViewSelectionDidChange:(NSNotification *)notify {
110 [self activateMapping:self[tableView.selectedRow]];
111 }
112
113 - (id)tableView:(NSTableView *)view objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)index {
114 return self[index].name;
115 }
116
117 - (void)tableView:(NSTableView *)view
118 setObjectValue:(NSString *)obj
119 forTableColumn:(NSTableColumn *)col
120 row:(NSInteger)index {
121 self[index].name = obj;
122 [self mappingsChanged];
123 }
124
125 - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
126 return _mappings.count;
127 }
128
129 - (BOOL)tableView:(NSTableView *)view shouldEditTableColumn:(NSTableColumn *)column row:(NSInteger)index {
130 return YES;
131 }
132
133 - (void)save {
134 NSLog(@"Saving mappings to defaults.");
135 NSMutableArray *ary = [[NSMutableArray alloc] initWithCapacity:_mappings.count];
136 for (NJMapping *mapping in _mappings)
137 [ary addObject:[mapping serialize]];
138 [NSUserDefaults.standardUserDefaults setObject:ary forKey:@"mappings"];
139 }
140
141 - (void)load {
142 NSUInteger selected = [NSUserDefaults.standardUserDefaults integerForKey:@"selected"];
143 NSArray *mappings = [NSUserDefaults.standardUserDefaults arrayForKey:@"mappings"];
144 [self loadAllFrom:mappings andActivate:selected];
145 }
146
147 - (void)loadAllFrom:(NSArray *)storedMappings andActivate:(NSUInteger)selected {
148 NSMutableArray* newMappings = [[NSMutableArray alloc] initWithCapacity:storedMappings.count];
149
150 // have to do two passes in case mapping1 refers to mapping2 via a NJOutputMapping
151 for (NSDictionary *storedMapping in storedMappings) {
152 NJMapping *mapping = [[NJMapping alloc] initWithName:storedMapping[@"name"]];
153 [newMappings addObject:mapping];
154 }
155
156 for (unsigned i = 0; i < storedMappings.count; ++i) {
157 NSDictionary *entries = storedMappings[i][@"entries"];
158 NJMapping *mapping = newMappings[i];
159 for (id key in entries) {
160 NJOutput *output = [NJOutput outputDeserialize:entries[key]
161 withMappings:newMappings];
162 if (output)
163 mapping.entries[key] = output;
164 }
165 }
166
167 if (newMappings.count) {
168 _mappings = newMappings;
169 if (selected >= newMappings.count)
170 selected = 0;
171 [self mappingsChanged];
172 [self activateMapping:_mappings[selected]];
173 }
174 }
175
176 - (NJMapping *)mappingWithURL:(NSURL *)url error:(NSError **)error {
177 NSInputStream *stream = [NSInputStream inputStreamWithURL:url];
178 [stream open];
179 NSDictionary *serialization = !*error
180 ? [NSJSONSerialization JSONObjectWithStream:stream options:0 error:error]
181 : nil;
182 [stream close];
183
184 if (!([serialization isKindOfClass:NSDictionary.class]
185 && [serialization[@"name"] isKindOfClass:NSString.class]
186 && [serialization[@"entries"] isKindOfClass:NSDictionary.class])) {
187 *error = [NSError errorWithDomain:@"Enjoyable"
188 code:0
189 description:@"This isn't a valid mapping file."];
190 return nil;
191 }
192
193 NSDictionary *entries = serialization[@"entries"];
194 NJMapping *mapping = [[NJMapping alloc] initWithName:serialization[@"name"]];
195 for (id key in entries) {
196 NSDictionary *value = entries[key];
197 if ([key isKindOfClass:NSString.class]) {
198 NJOutput *output = [NJOutput outputDeserialize:value
199 withMappings:_mappings];
200 if (output)
201 mapping.entries[key] = output;
202 }
203 }
204 return mapping;
205 }
206
207 - (void)importPressed:(id)sender {
208 NSOpenPanel *panel = [NSOpenPanel openPanel];
209 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
210 NSWindow *window = NSApplication.sharedApplication.keyWindow;
211 [panel beginSheetModalForWindow:window
212 completionHandler:^(NSInteger result) {
213 if (result != NSFileHandlingPanelOKButton)
214 return;
215
216 [panel close];
217 NSError *error;
218 NJMapping *mapping = [self mappingWithURL:panel.URL error:&error];
219
220 if (!error) {
221 BOOL conflict = NO;
222 NJMapping *mergeInto = self[mapping.name];
223 for (id key in mapping.entries) {
224 if (mergeInto.entries[key]
225 && ![mergeInto.entries[key] isEqual:mapping.entries[key]]) {
226 conflict = YES;
227 break;
228 }
229 }
230
231 if (conflict) {
232 NSAlert *conflictAlert = [[NSAlert alloc] init];
233 conflictAlert.messageText = @"Replace existing mappings?";
234 conflictAlert.informativeText =
235 [NSString stringWithFormat:
236 @"This file contains inputs you've already mapped in \"%@\". Do you "
237 @"want to merge them and replace your existing mappings, or import this "
238 @"as a separate mapping?", mapping.name];
239 [conflictAlert addButtonWithTitle:@"Merge"];
240 [conflictAlert addButtonWithTitle:@"Cancel"];
241 [conflictAlert addButtonWithTitle:@"New Mapping"];
242 NSInteger res = [conflictAlert runModal];
243 if (res == NSAlertSecondButtonReturn)
244 return;
245 else if (res == NSAlertThirdButtonReturn)
246 mergeInto = nil;
247 }
248
249 if (mergeInto) {
250 [mergeInto.entries addEntriesFromDictionary:mapping.entries];
251 mapping = mergeInto;
252 } else {
253 [_mappings addObject:mapping];
254 }
255
256 [self mappingsChanged];
257 [self activateMapping:mapping];
258 [outputController loadCurrent];
259
260 if (conflict && !mergeInto) {
261 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:_mappings.count - 1] byExtendingSelection:NO];
262 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
263 }
264 }
265
266 if (error) {
267 [window presentError:error
268 modalForWindow:window
269 delegate:nil
270 didPresentSelector:nil
271 contextInfo:nil];
272 }
273 }];
274
275 }
276
277 - (void)exportPressed:(id)sender {
278 NSSavePanel *panel = [NSSavePanel savePanel];
279 panel.allowedFileTypes = @[ @"enjoyable" ];
280 NJMapping *mapping = _currentMapping;
281 panel.nameFieldStringValue = mapping.name;
282 NSWindow *window = NSApplication.sharedApplication.keyWindow;
283 [panel beginSheetModalForWindow:window
284 completionHandler:^(NSInteger result) {
285 if (result != NSFileHandlingPanelOKButton)
286 return;
287 [panel close];
288 [NSProcessInfo.processInfo disableSuddenTermination];
289 NSError *error;
290 NSDictionary *serialization = [mapping serialize];
291 NSData *json = [NSJSONSerialization dataWithJSONObject:serialization
292 options:NSJSONWritingPrettyPrinted
293 error:&error];
294 if (!error)
295 [json writeToURL:panel.URL options:NSDataWritingAtomic error:&error];
296
297 [NSProcessInfo.processInfo enableSuddenTermination];
298 if (error) {
299 [window presentError:error
300 modalForWindow:window
301 delegate:nil
302 didPresentSelector:nil
303 contextInfo:nil];
304 }
305 }];
306 }
307
308 - (IBAction)mappingPressed:(id)sender {
309 [popover showRelativeToRect:popoverActivate.bounds ofView:popoverActivate preferredEdge:NSMinXEdge];
310 }
311
312 - (void)popoverWillShow:(NSNotification *)notification {
313 popoverActivate.state = NSOnState;
314 }
315
316 - (void)popoverWillClose:(NSNotification *)notification {
317 popoverActivate.state = NSOffState;
318 }
319
320 @end