SameBoy/iOS/GBPalettePicker.m

516 lines
22 KiB
Objective-C

#import "GBPalettePicker.h"
#import "GBPaletteEditor.h"
#import <CoreServices/CoreServices.h>
/* TODO: Unify with Cocoa? */
#define MAGIC 'SBPL'
typedef struct __attribute__ ((packed)) {
uint32_t magic;
bool manual:1;
bool disabled_lcd_color:1;
unsigned padding:6;
struct GB_color_s colors[5];
int32_t brightness_bias;
uint32_t hue_bias;
uint32_t hue_bias_strength;
} theme_t;
@interface GBPalettePicker () <UIDocumentPickerDelegate>
@end
@implementation GBPalettePicker
{
NSArray <NSString *>* _cacheNames;
NSIndexPath *_renamingPath;
NSMutableSet *_tempFiles;
}
+ (NSString *)makeUnique:(NSString *)name
{
NSArray *builtins = @[
@"Greyscale",
@"Lime (Game Boy)",
@"Olive (Pocket)",
@"Teal (Light)",
];
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"];
if (dict[name] || [builtins containsObject:name]) {
unsigned i = 2;
while (true) {
NSString *attempt = [NSString stringWithFormat:@"%@ %u", name, i];
if (!dict[attempt] && ![builtins containsObject:attempt]) {
return attempt;
}
i++;
}
}
return name;
}
+ (const GB_palette_t *)paletteForTheme:(NSString *)theme
{
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if ([theme isEqualToString:@"Greyscale"]) {
return &GB_PALETTE_GREY;
}
if ([theme isEqualToString:@"Lime (Game Boy)"]) {
return &GB_PALETTE_DMG;
}
if ([theme isEqualToString:@"Olive (Pocket)"]) {
return &GB_PALETTE_MGB;
}
if ([theme isEqualToString:@"Teal (Light)"]) {
return &GB_PALETTE_GBL;
}
static GB_palette_t customPalette;
NSArray *colors = [defaults dictionaryForKey:@"GBThemes"][theme][@"Colors"];
if (colors.count != 5) return &GB_PALETTE_DMG;
unsigned i = 0;
for (NSNumber *color in colors) {
uint32_t c = [color unsignedIntValue];
customPalette.colors[i++] = (struct GB_color_s) {c, c >> 8, c >> 16};
}
return &customPalette;
}
+ (UIColor *)colorFromGBColor:(const struct GB_color_s *)color
{
return [UIColor colorWithRed:color->r / 255.0
green:color->g / 255.0
blue:color->b / 255.0
alpha:1.0];
}
+ (UIImage *)previewImageForTheme:(NSString *)theme
{
const GB_palette_t *palette = [self paletteForTheme:theme];
UIGraphicsBeginImageContextWithOptions((CGSize){29, 29}, false, [UIScreen mainScreen].scale);
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, 29, 29) cornerRadius:7];
[[self colorFromGBColor:&palette->colors[4]] set];
[path fill];
path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 4, 9, 9) cornerRadius:2];
[[self colorFromGBColor:&palette->colors[0]] set];
[path fill];
path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 4, 9, 9) cornerRadius:2];
[[self colorFromGBColor:&palette->colors[1]] set];
[path fill];
path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 16, 9, 9) cornerRadius:2];
[[self colorFromGBColor:&palette->colors[2]] set];
[path fill];
path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 16, 9, 9) cornerRadius:2];
[[self colorFromGBColor:&palette->colors[3]] set];
[path fill];
UIImage *ret = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return ret;
}
- (instancetype)initWithStyle:(UITableViewStyle)style
{
self = [super initWithStyle:style];
self.navigationItem.rightBarButtonItem = self.editButtonItem;
_tempFiles = [NSMutableSet set];
self.tableView.allowsSelectionDuringEditing = true;
return self;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return 3;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
if (section == 0) return 4;
if (section == 2) return 3;
_cacheNames = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].allKeys sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)];
return _cacheNames.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil];
NSString *name = nil;
if (indexPath.section == 2) {
switch (indexPath.row) {
case 0:
cell.textLabel.text = @"New Palette";
break;
case 1:
cell.textLabel.text = @"Import Palette";
break;
case 2:
cell.textLabel.text = @"Restore Defaults";
cell.textLabel.textColor = [UIColor systemRedColor];
break;
}
return cell;
}
else if (indexPath.section == 0) {
name = @[
@"Greyscale",
@"Lime (Game Boy)",
@"Olive (Pocket)",
@"Teal (Light)",
][indexPath.row];
}
else {
name = _cacheNames[indexPath.row];
}
cell.textLabel.text = name;
if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:name]) {
cell.accessoryType = UITableViewCellAccessoryCheckmark;
}
cell.imageView.image = [self.class previewImageForTheme:name];
return cell;
}
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url
{
[url startAccessingSecurityScopedResource];
if ([self.class importPalette:url.path]) {
[self.tableView reloadData];
}
else {
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Import Failed"
message:@"The imported palette file is invalid."
preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alertController animated:true completion:nil];
return;
}
[url stopAccessingSecurityScopedResource];
}
- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
if (self.editing) {
if (indexPath.section != 1) return nil;
if (@available(iOS 14.0, *)) {
[self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text]
animated:true];
}
return nil;
}
if (indexPath.section == 2) {
switch (indexPath.row) {
case 0: {
if (@available(iOS 14.0, *)) {
NSString *name = [self.class makeUnique:@"Untitled Palette"];
NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
srandom(time(NULL));
dict[name] = @{
@"BrightnessBias": @((random() & 0xFFF) / (double)0xFFF * 2 - 1),
@"Colors": @[@((random() & 0x3f3f3f) | 0xFF000000), @0, @0, @0, @((random() | 0xffc0c0c0))],
@"DisabledLCDColor": @YES,
@"HueBias": @((random() & 0xFFF) / (double)0xFFF),
@"HueBiasStrength": @((random() & 0xFFF) / (double)0xFFF),
@"Manual": @NO,
};
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
[self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:name]
animated:true];
}
else {
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Palette Editor Unavailable"
message:@"The palette editor requires iOS 14 or newer."
preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alertController animated:true completion:nil];
}
break;
}
case 1: {
[self setEditing:false animated:true];
NSString *sbpUTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)@"sbp", NULL);
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[sbpUTI]
inMode:UIDocumentPickerModeImport];
if (@available(iOS 13.0, *)) {
picker.shouldShowFileExtensions = true;
}
picker.delegate = self;
[self presentViewController:picker animated:true completion:nil];
break;
}
case 2: {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Restore default palettes?"
message: @"The defaults palettes will be restored, changes will be reverted, and created or imported palettes will be deleted. This change cannot be undone."
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Restore"
style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *action) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBCurrentTheme"];
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBThemes"];
[self.tableView reloadData];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
style:UIAlertActionStyleCancel
handler:nil]];
[self presentViewController:alert animated:true completion:nil];
break;
}
}
return nil;
}
[[NSUserDefaults standardUserDefaults] setObject:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text
forKey:@"GBCurrentTheme"];
[self.tableView reloadData];
return nil;
}
- (NSString *)title
{
return @"Monochrome Palette";
}
- (void)renameRow:(NSIndexPath *)indexPath
{
if (indexPath.section != 1) return;
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
CGRect frame = cell.textLabel.frame;
frame.size.width = cell.textLabel.superview.frame.size.width - 8 - frame.origin.x;
UITextField *field = [[UITextField alloc] initWithFrame:frame];
field.font = cell.textLabel.font;
field.text = cell.textLabel.text;
cell.textLabel.textColor = [UIColor clearColor];
[[cell.textLabel superview] addSubview:field];
[field becomeFirstResponder];
[field selectAll:nil];
_renamingPath = indexPath;
[field addTarget:self action:@selector(doneRename:) forControlEvents:UIControlEventEditingDidEnd | UIControlEventEditingDidEndOnExit];
}
- (void)doneRename:(UITextField *)sender
{
if (!_renamingPath) return;
NSString *newName = sender.text;
NSString *oldName = [self.tableView cellForRowAtIndexPath:_renamingPath].textLabel.text;
_renamingPath = nil;
if ([newName isEqualToString:oldName]) {
[self.tableView reloadData];
return;
}
NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
newName = [self.class makeUnique:newName];
dict[newName] = dict[oldName];
[dict removeObjectForKey:oldName];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:oldName]) {
[[NSUserDefaults standardUserDefaults] setObject:newName forKey:@"GBCurrentTheme"];
}
[self.tableView reloadData];
_renamingPath = nil;
}
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
if (indexPath.section != 1) return;
if (editingStyle != UITableViewCellEditingStyleDelete) return;
NSString *rom = [self.tableView cellForRowAtIndexPath:indexPath].textLabel.text;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"Delete “%@”?", rom]
message: @"This change cannot be undone."
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Delete"
style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *action) {
NSMutableDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBThemes"].mutableCopy;
NSString *name = _cacheNames[indexPath.row];
[dict removeObjectForKey:name];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:@"GBThemes"];
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
if ([[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"] isEqual:name]) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"GBCurrentTheme"];
[self.tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationFade];
}
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
style:UIAlertActionStyleCancel
handler:nil]];
[self presentViewController:alert animated:true completion:nil];
}
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath
{
return indexPath.section == 1;
}
- (NSString *)exportTheme:(NSString *)name
{
theme_t theme = {0,};
theme.magic = MAGIC;
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSDictionary *themeDict = [defaults dictionaryForKey:@"GBThemes"][name];
NSArray *colors = themeDict[@"Colors"];
if (colors.count <= 5) {
unsigned i = 0;
for (NSNumber *color in colors) {
uint32_t c = [color unsignedIntValue];
theme.colors[i++] = (struct GB_color_s){
(c & 0xFF),
((c >> 8) & 0xFF),
((c >> 16) & 0xFF)};
}
}
theme.manual = [themeDict[@"Manual"] boolValue];
theme.disabled_lcd_color = [themeDict[@"DisabledLCDColor"] boolValue];
theme.brightness_bias = ([themeDict[@"BrightnessBias"] doubleValue] * 0x40000000);
theme.hue_bias = round([themeDict[@"HueBias"] doubleValue] * 0x80000000);
theme.hue_bias_strength = round([themeDict[@"HueBiasStrength"] doubleValue] * 0x80000000);
size_t size = sizeof(theme);
if (theme.manual) {
size = theme.disabled_lcd_color? 5 + 5 * sizeof(theme.colors[0]) : 5 + 4 * sizeof(theme.colors[0]);
}
NSString *path = [[NSTemporaryDirectory() stringByAppendingPathComponent:name] stringByAppendingPathExtension:@"sbp"];
[[NSData dataWithBytes:&theme length:size] writeToFile:path atomically:false];
[_tempFiles addObject:path];
return path;
}
+ (bool)importPalette:(NSString *)path
{
NSData *data = [NSData dataWithContentsOfFile:path];
theme_t theme = {0,};
memcpy(&theme, data.bytes, MIN(sizeof(theme), data.length));
if (theme.magic != MAGIC) {
return false;
}
NSMutableDictionary *themeDict = [NSMutableDictionary dictionary];
themeDict[@"Manual"] = theme.manual? @YES : @NO;
themeDict[@"DisabledLCDColor"] = theme.disabled_lcd_color? @YES : @NO;
#define COLOR_TO_INT(color) ((color.r << 0) | (color.g << 8) | (color.b << 16) | 0xFF000000)
themeDict[@"Colors"] = @[
@(COLOR_TO_INT(theme.colors[0])),
@(COLOR_TO_INT(theme.colors[1])),
@(COLOR_TO_INT(theme.colors[2])),
@(COLOR_TO_INT(theme.colors[3])),
@(COLOR_TO_INT(theme.colors[theme.disabled_lcd_color? 4 : 3])),
];
themeDict[@"BrightnessBias"] = @(theme.brightness_bias / (double)0x40000000);
themeDict[@"HueBias"] = @(theme.hue_bias / (double)0x80000000);
themeDict[@"HueBiasStrength"] = @(theme.hue_bias_strength / (double)0x80000000);
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *themes = [defaults dictionaryForKey:@"GBThemes"].mutableCopy;
NSString *baseName = path.lastPathComponent.stringByDeletingPathExtension;
NSString *newName = [self.class makeUnique:baseName];
themes[newName] = themeDict;
[defaults setObject:themes forKey:@"GBThemes"];
[defaults setObject:newName forKey:@"GBCurrentTheme"];
return true;
}
- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
point:(CGPoint)point API_AVAILABLE(ios(13.0))
{
if (indexPath.section != 1) return nil;
return [UIContextMenuConfiguration configurationWithIdentifier:nil
previewProvider:nil
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *suggestedActions) {
UIAction *deleteAction = [UIAction actionWithTitle:@"Delete"
image:[UIImage systemImageNamed:@"trash"]
identifier:nil
handler:^(UIAction *action) {
[self tableView:tableView
commitEditingStyle:UITableViewCellEditingStyleDelete
forRowAtIndexPath:indexPath];
}];
deleteAction.attributes = UIMenuElementAttributesDestructive;
return [UIMenu menuWithTitle:nil children:@[
[UIAction actionWithTitle:@"Edit"
image:[UIImage systemImageNamed:@"paintbrush"]
identifier:nil
handler:^(__kindof UIAction *action) {
if (@available(iOS 14.0, *)) {
[self.navigationController pushViewController:[[GBPaletteEditor alloc] initForPalette:[self.tableView cellForRowAtIndexPath:indexPath].textLabel.text]
animated:true];
}
else {
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Palette Editor Unavailable"
message:@"The palette editor requires iOS 14 or newer."
preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alertController animated:true completion:nil];
return;
}
}],
[UIAction actionWithTitle:@"Share"
image:[UIImage systemImageNamed:@"square.and.arrow.up"]
identifier:nil
handler:^(__kindof UIAction *action) {
[self setEditing:false animated:true];
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
NSString *file = [self exportTheme:cell.textLabel.text];
NSURL *url = [NSURL fileURLWithPath:file];
UIActivityViewController *controller = [[UIActivityViewController alloc] initWithActivityItems:@[url]
applicationActivities:nil];
if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) {
controller.popoverPresentationController.sourceView = cell.contentView;
}
[self presentViewController:controller
animated:true
completion:nil];
}],
[UIAction actionWithTitle:@"Rename"
image:[UIImage systemImageNamed:@"pencil"]
identifier:nil
handler:^(__kindof UIAction *action) {
[self renameRow:indexPath];
}],
deleteAction,
]];
}];
}
- (void)dealloc
{
for (NSString *file in _tempFiles) {
[[NSFileManager defaultManager] removeItemAtPath:file error:nil];
}
}
- (void)viewWillAppear:(BOOL)animated
{
[self.tableView reloadData];
[super viewWillAppear:animated];
}
@end