SameBoy | Accurate GB/GBC emulator |
| download: https://git.y1.nz/archives/sameboy.tar.gz | |
| README | Files | Log | Refs | LICENSE |
iOS/GBPalettePicker.m
1 #import "GBPalettePicker.h"
2 #import "GBPaletteEditor.h"
3 #import <CoreServices/CoreServices.h>
4
5 /* TODO: Unify with Cocoa? */
6 #define MAGIC 'SBPL'
7
8 typedef struct __attribute__ ((packed)) {
9 uint32_t magic;
10 bool manual:1;
11 bool disabled_lcd_color:1;
12 unsigned padding:6;
13 struct GB_color_s colors[5];
14 int32_t brightness_bias;
15 uint32_t hue_bias;
16 uint32_t hue_bias_strength;
17 } theme_t;
18
19
20 @interface GBPalettePicker () <UIDocumentPickerDelegate>
21 @end
22
23 @implementation GBPalettePicker
24 {
25 NSArray <NSString *>* _cacheNames;
26 NSIndexPath *_renamingPath;
27 NSMutableSet *_tempFiles;
28 }
29
30 + (NSString *)makeUnique:(NSString *)name
31 {
32 NSArray *builtins = @[
33 @"Greyscale",
34 @"Lime (Game Boy)",
35 @"Olive (Pocket)",
36 @"Teal (Light)",
37 ];
38
39 NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"];
40 if (dict[name] || [builtins containsObject:name]) {
41 unsigned i = 2;
42 while (true) {
43 NSString *attempt = [NSString stringWithFormat:@"%@ %u", name, i];
44 if (!dict[attempt] && ![builtins containsObject:attempt]) {
45 return attempt;
46 }
47 i++;
48 }
49 }
50 return name;
51 }
52
53 + (const GB_palette_t *)paletteForTheme:(NSString *)theme
54 {
55 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
56 if ([theme isEqualToString:@"Greyscale"]) {
57 return &GB_PALETTE_GREY;
58 }
59 if ([theme isEqualToString:@"Lime (Game Boy)"]) {
60 return &GB_PALETTE_DMG;
61 }
62 if ([theme isEqualToString:@"Olive (Pocket)"]) {
63 return &GB_PALETTE_MGB;
64 }
65 if ([theme isEqualToString:@"Teal (Light)"]) {
66 return &GB_PALETTE_GBL;
67 }
68 static GB_palette_t customPalette;
69 NSArray *colors = [defaults dictionaryForKey:@"GBThemes"][theme][@"Colors"];
70 if (colors.count != 5) return &GB_PALETTE_DMG;
71 unsigned i = 0;
72 for (NSNumber *color in colors) {
73 uint32_t c = [color unsignedIntValue];
74 customPalette.colors[i++] = (struct GB_color_s) {c, c >> 8, c >> 16};
75 }
76 return &customPalette;
77 }
78
79 + (UIColor *)colorFromGBColor:(const struct GB_color_s *)color
80 {
81 return [UIColor colorWithRed:color->r / 255.0
82 green:color->g / 255.0
83 blue:color->b / 255.0
84 alpha:1.0];
85 }
86
87 + (UIImage *)previewImageForTheme:(NSString *)theme
88 {
89 const GB_palette_t *palette = [self paletteForTheme:theme];
90 UIGraphicsBeginImageContextWithOptions((CGSize){29, 29}, false, [UIScreen mainScreen].scale);
91 UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, 29, 29) cornerRadius:7];
92 [[self colorFromGBColor:&palette->colors[4]] set];
93 [path fill];
94
95 path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 4, 9, 9) cornerRadius:2];
96 [[self colorFromGBColor:&palette->colors[0]] set];
97 [path fill];
98
99 path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 4, 9, 9) cornerRadius:2];
100 [[self colorFromGBColor:&palette->colors[1]] set];
101 [path fill];
102
103 path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 16, 9, 9) cornerRadius:2];
104 [[self colorFromGBColor:&palette->colors[2]] set];
105 [path fill];
106
107 path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 16, 9, 9) cornerRadius:2];
108 [[self colorFromGBColor:&palette->colors[3]] set];
109 [path fill];
110
111 UIImage *ret = UIGraphicsGetImageFromCurrentImageContext();
112 UIGraphicsEndImageContext();
113
114 return ret;
115 }
116
117 - (instancetype)initWithStyle:(UITableViewStyle)style
118 {
119 self = [super initWithStyle:style];
120 self.navigationItem.rightBarButtonItem = self.editButtonItem;
121 _tempFiles = [NSMutableSet set];
122 self.tableView.allowsSelectionDuringEditing = true;
123 return self;
124 }
125
126 - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
127 {
128 return 3;
129 }
130
131 - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
132 {
133 if (section == 0) return 4;
134 if (section == 2) return 3;
135 _cacheNames = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].allKeys sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)];
136 return _cacheNames.count;
137 }
138
139 - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
140 {
141 UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil];
142
143 NSString *name = nil;
144 if (indexPath.section == 2) {
145 switch (indexPath.row) {
146 case 0:
147 cell.textLabel.text = @"New Palette";
148 break;
149 case 1:
150 cell.textLabel.text = @"Import Palette";
151 break;
152 case 2:
153 cell.textLabel.text = @"Restore Defaults";
154 cell.textLabel.textColor = [UIColor systemRedColor];
155 break;
156 }
157 return cell;
158 }
159 else if (indexPath.section == 0) {
160 name = @[
161 @"Greyscale",
162 @"Lime (Game Boy)",
163 @"Olive (Pocket)",
164 @"Teal (Light)",
165 ][indexPath.row];
166 }
167 else {
168 name = _cacheNames[indexPath.row];
169 }
170
171 cell.textLabel.text = name;
172 if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:name]) {
173 cell.accessoryType = UITableViewCellAccessoryCheckmark;
174 }
175
176 cell.imageView.image = [self.class previewImageForTheme:name];
177 return cell;
178
179 }
180
181
182 - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url
183 {
184 [url startAccessingSecurityScopedResource];
185 if ([self.class importPalette:url.path]) {
186 [self.tableView reloadData];
187 }
188 else {
189 UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Import Failed"
190 message:@"The imported palette file is invalid."
191 preferredStyle:UIAlertControllerStyleAlert];
192 [alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
193 [self presentViewController:alertController animated:true completion:nil];
194 return;
195 }
196 [url stopAccessingSecurityScopedResource];
197 }
198
199 - (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath
200 {
201 if (self.editing) {
202 if (indexPath.section != 1) return nil;
203 if (@available(iOS 14.0, *)) {
204 [self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text]
205 animated:true];
206 }
207 return nil;
208 }
209 if (indexPath.section == 2) {
210 switch (indexPath.row) {
211 case 0: {
212 if (@available(iOS 14.0, *)) {
213 NSString *name = [self.class makeUnique:@"Untitled Palette"];
214 NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
215 srandom(time(NULL));
216 dict[name] = @{
217 @"BrightnessBias": @((random() & 0xFFF) / (double)0xFFF * 2 - 1),
218 @"Colors": @[@((random() & 0x3f3f3f) | 0xFF000000), @0, @0, @0, @((random() | 0xffc0c0c0))],
219 @"DisabledLCDColor": @YES,
220 @"HueBias": @((random() & 0xFFF) / (double)0xFFF),
221 @"HueBiasStrength": @((random() & 0xFFF) / (double)0xFFF),
222 @"Manual": @NO,
223 };
224 [[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
225 [self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:name]
226 animated:true];
227 }
228 else {
229 UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Palette Editor Unavailable"
230 message:@"The palette editor requires iOS 14 or newer."
231 preferredStyle:UIAlertControllerStyleAlert];
232 [alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
233 [self presentViewController:alertController animated:true completion:nil];
234 }
235 break;
236 }
237 case 1: {
238 [self setEditing:false animated:true];
239 NSString *sbpUTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)@"sbp", NULL);
240
241
242 UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[sbpUTI]
243 inMode:UIDocumentPickerModeImport];
244 if (@available(iOS 13.0, *)) {
245 picker.shouldShowFileExtensions = true;
246 }
247 picker.delegate = self;
248 [self presentViewController:picker animated:true completion:nil];
249 break;
250 }
251 case 2: {
252 UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Restore default palettes?"
253 message: @"The defaults palettes will be restored, changes will be reverted, and created or imported palettes will be deleted. This change cannot be undone."
254 preferredStyle:UIAlertControllerStyleAlert];
255 [alert addAction:[UIAlertAction actionWithTitle:@"Restore"
256 style:UIAlertActionStyleDestructive
257 handler:^(UIAlertAction *action) {
258 [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBCurrentTheme"];
259 [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBThemes"];
260 [self.tableView reloadData];
261 }]];
262 [alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
263 style:UIAlertActionStyleCancel
264 handler:nil]];
265 [self presentViewController:alert animated:true completion:nil];
266 break;
267 }
268 }
269 return nil;
270 }
271 [[NSUserDefaults standardUserDefaults] setObject:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text
272 forKey:@"GBCurrentTheme"];
273 [self.tableView reloadData];
274 return nil;
275 }
276
277
278 - (NSString *)title
279 {
280 return @"Monochrome Palette";
281 }
282
283 - (void)renameRow:(NSIndexPath *)indexPath
284 {
285 if (indexPath.section != 1) return;
286
287 UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
288 CGRect frame = cell.textLabel.frame;
289 frame.size.width = cell.textLabel.superview.frame.size.width - 8 - frame.origin.x;
290 UITextField *field = [[UITextField alloc] initWithFrame:frame];
291 field.font = cell.textLabel.font;
292 field.text = cell.textLabel.text;
293 cell.textLabel.textColor = [UIColor clearColor];
294 [[cell.textLabel superview] addSubview:field];
295 [field becomeFirstResponder];
296 [field selectAll:nil];
297 _renamingPath = indexPath;
298 [field addTarget:self action:@selector(doneRename:) forControlEvents:UIControlEventEditingDidEnd | UIControlEventEditingDidEndOnExit];
299 }
300
301 - (void)doneRename:(UITextField *)sender
302 {
303 if (!_renamingPath) return;
304 NSString *newName = sender.text;
305 NSString *oldName = [self.tableView cellForRowAtIndexPath:_renamingPath].textLabel.text;
306
307 _renamingPath = nil;
308 if ([newName isEqualToString:oldName]) {
309 [self.tableView reloadData];
310 return;
311 }
312
313 NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
314 newName = [self.class makeUnique:newName];
315
316 dict[newName] = dict[oldName];
317 [dict removeObjectForKey:oldName];
318 [[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
319 if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:oldName]) {
320 [[NSUserDefaults standardUserDefaults] setObject:newName forKey:@"GBCurrentTheme"];
321 }
322 [self.tableView reloadData];
323 _renamingPath = nil;
324 }
325
326
327 - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
328 {
329 if (indexPath.section != 1) return;
330
331 if (editingStyle != UITableViewCellEditingStyleDelete) return;
332 NSString *rom = [self.tableView cellForRowAtIndexPath:indexPath].textLabel.text;
333 UIAlertController *alert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"Delete “%@”?", rom]
334 message: @"This change cannot be undone."
335 preferredStyle:UIAlertControllerStyleAlert];
336 [alert addAction:[UIAlertAction actionWithTitle:@"Delete"
337 style:UIAlertActionStyleDestructive
338 handler:^(UIAlertAction *action) {
339 NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
340 NSString *name = _cacheNames[indexPath.row];
341 [dict removeObjectForKey:name];
342 [[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
343 [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
344
345 if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:name]) {
346 [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBCurrentTheme"];
347 [self.tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade];
348 }
349 }]];
350 [alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
351 style:UIAlertActionStyleCancel
352 handler:nil]];
353 [self presentViewController:alert animated:true completion:nil];
354 }
355
356 - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath
357 {
358 return indexPath.section == 1;
359 }
360
361 - (NSString *)exportTheme:(NSString *)name
362 {
363 theme_t theme = {0,};
364 theme.magic = MAGIC;
365
366 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
367 NSDictionary *themeDict = [defaults dictionaryForKey:@"GBThemes"][name];
368 NSArray *colors = themeDict[@"Colors"];
369 if (colors.count <= 5) {
370 unsigned i = 0;
371 for (NSNumber *color in colors) {
372 uint32_t c = [color unsignedIntValue];
373 theme.colors[i++] = (struct GB_color_s){
374 (c & 0xFF),
375 ((c >> 8) & 0xFF),
376 ((c >> 16) & 0xFF)};
377 }
378 }
379
380 theme.manual = [themeDict[@"Manual"] boolValue];
381 theme.disabled_lcd_color = [themeDict[@"DisabledLCDColor"] boolValue];
382
383 theme.brightness_bias = ([themeDict[@"BrightnessBias"] doubleValue] * 0x40000000);
384 theme.hue_bias = round([themeDict[@"HueBias"] doubleValue] * 0x80000000);
385 theme.hue_bias_strength = round([themeDict[@"HueBiasStrength"] doubleValue] * 0x80000000);
386
387 size_t size = sizeof(theme);
388 if (theme.manual) {
389 size = theme.disabled_lcd_color? 5 + 5 * sizeof(theme.colors[0]) : 5 + 4 * sizeof(theme.colors[0]);
390 }
391
392 NSString *path = [[NSTemporaryDirectory() stringByAppendingPathComponent:name] stringByAppendingPathExtension:@"sbp"];
393 [[NSData dataWithBytes:&theme length:size] writeToFile:path atomically:false];
394 [_tempFiles addObject:path];
395 return path;
396 }
397
398 + (bool)importPalette:(NSString *)path
399 {
400 NSData *data = [NSData dataWithContentsOfFile:path];
401 theme_t theme = {0,};
402 memcpy(&theme, data.bytes, MIN(sizeof(theme), data.length));
403 if (theme.magic != MAGIC) {
404 return false;
405 }
406
407 NSMutableDictionary *themeDict = [NSMutableDictionary dictionary];
408 themeDict[@"Manual"] = theme.manual? @YES : @NO;
409 themeDict[@"DisabledLCDColor"] = theme.disabled_lcd_color? @YES : @NO;
410
411 #define COLOR_TO_INT(color) ((color.r << 0) | (color.g << 8) | (color.b << 16) | 0xFF000000)
412 themeDict[@"Colors"] = @[
413 @(COLOR_TO_INT(theme.colors[0])),
414 @(COLOR_TO_INT(theme.colors[1])),
415 @(COLOR_TO_INT(theme.colors[2])),
416 @(COLOR_TO_INT(theme.colors[3])),
417 @(COLOR_TO_INT(theme.colors[theme.disabled_lcd_color? 4 : 3])),
418 ];
419
420
421 themeDict[@"BrightnessBias"] = @(theme.brightness_bias / (double)0x40000000);
422 themeDict[@"HueBias"] = @(theme.hue_bias / (double)0x80000000);
423 themeDict[@"HueBiasStrength"] = @(theme.hue_bias_strength / (double)0x80000000);
424
425 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
426 NSMutableDictionary *themes = [defaults dictionaryForKey:@"GBThemes"].mutableCopy;
427 NSString *baseName = path.lastPathComponent.stringByDeletingPathExtension;
428 NSString *newName = [self.class makeUnique:baseName];
429 themes[newName] = themeDict;
430 [defaults setObject:themes forKey:@"GBThemes"];
431 [defaults setObject:newName forKey:@"GBCurrentTheme"];
432 return true;
433 }
434
435 - (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
436 contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
437 point:(CGPoint)point API_AVAILABLE(ios(13.0))
438 {
439 if (indexPath.section != 1) return nil;
440
441 return [UIContextMenuConfiguration configurationWithIdentifier:nil
442 previewProvider:nil
443 actionProvider:^UIMenu *(NSArray<UIMenuElement *> *suggestedActions) {
444 UIAction *deleteAction = [UIAction actionWithTitle:@"Delete"
445 image:[UIImage systemImageNamed:@"trash"]
446 identifier:nil
447 handler:^(UIAction *action) {
448 [self tableView:tableView
449 commitEditingStyle:UITableViewCellEditingStyleDelete
450 forRowAtIndexPath:indexPath];
451 }];
452
453 deleteAction.attributes = UIMenuElementAttributesDestructive;
454 return [UIMenu menuWithTitle:nil children:@[
455 [UIAction actionWithTitle:@"Edit"
456 image:[UIImage systemImageNamed:@"paintbrush"]
457 identifier:nil
458 handler:^(__kindof UIAction *action) {
459 if (@available(iOS 14.0, *)) {
460 [self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text]
461 animated:true];
462 }
463 else {
464 UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Palette Editor Unavailable"
465 message:@"The palette editor requires iOS 14 or newer."
466 preferredStyle:UIAlertControllerStyleAlert];
467 [alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
468 [self presentViewController:alertController animated:true completion:nil];
469 return;
470 }
471 }],
472 [UIAction actionWithTitle:@"Share"
473 image:[UIImage systemImageNamed:@"square.and.arrow.up"]
474 identifier:nil
475 handler:^(__kindof UIAction *action) {
476 [self setEditing:false animated:true];
477 UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
478 NSString *file = [self exportTheme:cell.textLabel.text];
479 NSURL *url = [NSURL fileURLWithPath:file];
480 UIActivityViewController *controller = [[UIActivityViewController alloc] initWithActivityItems:@[url]
481 applicationActivities:nil];
482
483 if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) {
484 controller.popoverPresentationController.sourceView = cell.contentView;
485 }
486
487 [self presentViewController:controller
488 animated:true
489 completion:nil];
490 }],
491 [UIAction actionWithTitle:@"Rename"
492 image:[UIImage systemImageNamed:@"pencil"]
493 identifier:nil
494 handler:^(__kindof UIAction *action) {
495 [self renameRow:indexPath];
496 }],
497 deleteAction,
498 ]];
499 }];
500 }
501
502 - (void)dealloc
503 {
504 for (NSString *file in _tempFiles) {
505 [[NSFileManager defaultManager] removeItemAtPath:file error:nil];
506 }
507 }
508
509 - (void)viewWillAppear:(BOOL)animated
510 {
511 [self.tableView reloadData];
512 [super viewWillAppear:animated];
513 }
514
515 @end
This webpage is intended to be an accessible preview of this repository. To get a fuller picture, clone it and use the git CLI.