/* HBPresetsViewController.m $
This file is part of the HandBrake source code.
Homepage: .
It may be used under the terms of the GNU General Public License. */
#import "HBPresetsViewController.h"
#import "HBAddCategoryController.h"
#import "HBFilePromiseProvider.h"
#import "NSArray+HBAdditions.h"
@import HandBrakeKit;
// KVO Context
static void *HBPresetsViewControllerContext = &HBPresetsViewControllerContext;
@interface HBPresetCellView : NSTableCellView
@end
@implementation HBPresetCellView
- (void)setBackgroundStyle:(NSBackgroundStyle)backgroundStyle
{
[super setBackgroundStyle:backgroundStyle];
// Customize the built-in preset text color
if ([self.objectValue isBuiltIn])
{
if (backgroundStyle == NSBackgroundStyleDark)
{
self.textField.textColor = [NSColor selectedControlTextColor];
}
else
{
self.textField.textColor = [NSColor systemBlueColor];
}
}
else
{
self.textField.textColor = [NSColor controlTextColor];
}
}
@end
@interface HBPresetsViewController ()
@property (nonatomic, strong) HBPresetsManager *presets;
@property (nonatomic, readwrite) HBPreset *selectedPresetInternal;
@property (nonatomic, unsafe_unretained) IBOutlet NSTreeController *treeController;
/**
* Helper var for drag & drop
*/
@property (nonatomic, strong, nullable) NSArray *dragNodesArray;
/**
* The status (expanded or not) of the categories.
*/
@property (nonatomic, strong) NSMutableArray *expandedNodes;
@property (nonatomic, unsafe_unretained) IBOutlet NSOutlineView *outlineView;
@end
@implementation HBPresetsViewController
- (instancetype)initWithPresetManager:(HBPresetsManager *)presetManager
{
self = [super initWithNibName:@"Presets" bundle:nil];
if (self)
{
_presets = presetManager;
_selectedPresetInternal = presetManager.defaultPreset;
_expandedNodes = [[NSArray arrayWithArray:[NSUserDefaults.standardUserDefaults
objectForKey:@"HBPreviewViewExpandedStatus"]] mutableCopy];
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
if (NSAppKitVersionNumber >= NSAppKitVersionNumber10_10 && NSAppKitVersionNumber < 1651)
{
self.view.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
}
// drag and drop support
[self.outlineView registerForDraggedTypes:@[kHandBrakeInternalPBoardType, (NSString *)kUTTypeFileURL]];
[self.outlineView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
[self.outlineView setDraggingSourceOperationMask:NSDragOperationMove forLocal:YES];
// Re-expand the items
[self expandNodes:self.treeController.arrangedObjects.childNodes];
[self.treeController setSelectionIndexPath:[self.presets indexPathOfPreset:self.selectedPreset]];
[self.treeController addObserver:self forKeyPath:@"selectedObjects" options:NSKeyValueObservingOptionNew context:HBPresetsViewControllerContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context == HBPresetsViewControllerContext)
{
HBPreset *selectedNode = self.treeController.selectedObjects.firstObject;
if (selectedNode && selectedNode.isLeaf && selectedNode != self.selectedPresetInternal)
{
self.selectedPresetInternal = selectedNode;
[self.delegate selectionDidChange];
}
}
else
{
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (BOOL)validateUserInterfaceItem:(id < NSValidatedUserInterfaceItem >)anItem
{
SEL action = anItem.action;
if (action == @selector(exportPreset:) ||
action == @selector(deletePreset:))
{
if (!self.treeController.selectedObjects.firstObject)
{
return NO;
}
}
if (action == @selector(setDefault:))
{
if (![self.treeController.selectedObjects.firstObject isLeaf])
{
return NO;
}
}
return YES;
}
#pragma mark - Import Export Preset(s)
- (nonnull NSString *)fileNameForPreset:(HBPreset *)preset
{
NSString *name = preset.name == nil || preset.name.length == 0 ? @"Unnamed preset" : preset.name;
return [name stringByAppendingPathExtension:@"json"];
}
- (nonnull NSURL *)lastPresetExportDirectoryURL
{
NSURL *defaultExportDirectory = [[NSURL fileURLWithPath:NSHomeDirectory()] URLByAppendingPathComponent:@"Desktop" isDirectory:YES];
NSURL *lastPresetExportDirectoryURL = [NSUserDefaults.standardUserDefaults URLForKey:@"LastPresetExportDirectoryURL"];
return lastPresetExportDirectoryURL ? lastPresetExportDirectoryURL : defaultExportDirectory;
}
- (void)doExportPresets:(NSArray *)presets
{
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.title = NSLocalizedString(@"Export presets", @"Export presets save panel title");
panel.directoryURL = self.lastPresetExportDirectoryURL;
panel.canChooseFiles = NO;
panel.canChooseDirectories = YES;
panel.allowsMultipleSelection = NO;
panel.prompt = NSLocalizedString(@"Save", @"Export presets panel prompt");
[panel beginWithCompletionHandler:^(NSInteger result)
{
if (result == NSModalResponseOK)
{
[NSUserDefaults.standardUserDefaults setURL:panel.URL forKey:@"LastPresetExportDirectoryURL"];
for (HBPreset *preset in presets)
{
NSError *error = NULL;
NSString *fileName = [self fileNameForPreset:preset];
NSURL *url = [panel.URL URLByAppendingPathComponent:fileName];
BOOL success = [preset writeToURL:url atomically:YES removeRoot:NO error:&error];
if (success == NO)
{
[self presentError:error];
}
}
}
}];
}
- (void)doExportPreset:(HBPreset *)preset
{
NSSavePanel *panel = [NSSavePanel savePanel];
panel.title = NSLocalizedString(@"Export preset", @"Export presets save panel title");
panel.directoryURL = self.lastPresetExportDirectoryURL;
panel.nameFieldStringValue = [self fileNameForPreset:preset];
[panel beginWithCompletionHandler:^(NSInteger result)
{
if (result == NSModalResponseOK)
{
NSURL *presetExportDirectory = [panel.URL URLByDeletingLastPathComponent];
[NSUserDefaults.standardUserDefaults setURL:presetExportDirectory forKey:@"LastPresetExportDirectoryURL"];
NSError *error = NULL;
BOOL success = [preset writeToURL:panel.URL atomically:YES removeRoot:NO error:&error];
if (success == NO)
{
[self presentError:error];
}
}
}];
}
- (IBAction)exportPreset:(id)sender
{
NSArray *selectedPresets = self.treeController.selectedObjects;
if (selectedPresets.count == 1)
{
[self doExportPreset:selectedPresets.firstObject];
}
else if (selectedPresets.count > 1)
{
[self doExportPresets:selectedPresets];
}
}
- (void)doImportPreset:(NSArray *)URLs atIndexPath:(nullable NSIndexPath *)indexPath
{
if (indexPath == nil)
{
for (HBPreset *preset in self.presets.root.children)
{
if (preset.isBuiltIn == NO && preset.isLeaf == NO)
{
indexPath = [[self.presets indexPathOfPreset:preset] indexPathByAddingIndex:0];
}
}
if (indexPath == nil)
{
indexPath = [NSIndexPath indexPathWithIndex:self.presets.root.countOfChildren];
}
}
for (NSURL *url in URLs)
{
NSError *error;
HBPreset *preset = [[HBPreset alloc] initWithContentsOfURL:url error:&error];
if (preset)
{
for (HBPreset *child in preset.children)
{
[self.treeController insertObject:child atArrangedObjectIndexPath:indexPath];
}
[self.presets savePresets];
}
else
{
[self presentError:error];
}
}
}
- (IBAction)importPreset:(id)sender
{
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.title = NSLocalizedString(@"Import presets", @"Import preset open panel title");
panel.allowsMultipleSelection = YES;
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowedFileTypes = @[@"plist", @"xml", @"json"];
if ([NSUserDefaults.standardUserDefaults URLForKey:@"LastPresetImportDirectoryURL"])
{
panel.directoryURL = [NSUserDefaults.standardUserDefaults URLForKey:@"LastPresetImportDirectoryURL"];
}
else
{
panel.directoryURL = [[NSURL fileURLWithPath:NSHomeDirectory()] URLByAppendingPathComponent:@"Desktop" isDirectory:YES];
}
[panel beginWithCompletionHandler:^(NSInteger result)
{
[NSUserDefaults.standardUserDefaults setURL:panel.directoryURL forKey:@"LastPresetImportDirectoryURL"];
if (result == NSModalResponseOK)
{
[self doImportPreset:panel.URLs atIndexPath:nil];
}
}];
}
#pragma mark - UI Methods
- (IBAction)clicked:(id)sender
{
if (self.delegate && [self.treeController.selectedObjects.firstObject isLeaf])
{
[self.delegate selectionDidChange];
}
}
- (IBAction)renamed:(id)sender
{
if (self.delegate && [self.treeController.selectedObjects.firstObject isLeaf])
{
[self.delegate selectionDidChange];
}
}
- (IBAction)addNewPreset:(id)sender
{
if (self.delegate)
{
[self.delegate showAddPresetPanel:sender];
}
}
- (IBAction)deletePreset:(id)sender
{
if (self.treeController.canRemove)
{
// Alert user before deleting preset
NSAlert *alert = [[NSAlert alloc] init];
alert.alertStyle = NSAlertStyleCritical;
alert.informativeText = NSLocalizedString(@"You can't undo this action.", @"Delete preset alert -> informative text");
if (self.treeController.selectedObjects.count > 1)
{
alert.messageText = NSLocalizedString(@"Are you sure you want to permanently delete the selected presets?", @"Delete preset alert -> message");
[alert addButtonWithTitle:NSLocalizedString(@"Delete Presets", @"Delete preset alert -> first button")];
}
else
{
alert.messageText = NSLocalizedString(@"Are you sure you want to permanently delete the selected preset?", @"Delete preset alert -> message");
[alert addButtonWithTitle:NSLocalizedString(@"Delete Preset", @"Delete preset alert -> first button")];
}
#if defined(NSAppKitVersionNumber10_15)
if (@available(macOS 10.16, *))
{
alert.buttons.lastObject.hasDestructiveAction = true;
}
#endif
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"Delete preset alert -> second button")];
NSInteger status = [alert runModal];
if (status == NSAlertFirstButtonReturn)
{
for (NSIndexPath *indexPath in self.treeController.selectionIndexPaths.reverseObjectEnumerator)
{
[self.presets deletePresetAtIndexPath:indexPath];
}
[self setSelection:self.presets.defaultPreset];
}
}
}
- (IBAction)insertCategory:(id)sender
{
HBAddCategoryController *addCategoryController = [[HBAddCategoryController alloc] initWithPresetManager:self.presets];
NSModalResponse returnCode = [NSApp runModalForWindow:addCategoryController.window];
if (returnCode == NSModalResponseOK)
{
NSIndexPath *indexPath = [self.presets indexPathOfPreset:addCategoryController.category];
[self.treeController setSelectionIndexPath:indexPath];
}
}
- (IBAction)setDefault:(id)sender
{
HBPreset *selectedNode = self.treeController.selectedObjects.firstObject;
if (selectedNode.isLeaf)
{
self.presets.defaultPreset = selectedNode;
}
}
- (void)setSelectedPreset:(HBPreset *)selectedPreset
{
_selectedPresetInternal = selectedPreset;
[self setSelection:selectedPreset];
}
- (HBPreset *)selectedPreset
{
return _selectedPresetInternal;
}
- (void)setSelection:(HBPreset *)preset
{
NSIndexPath *idx = [self.presets indexPathOfPreset:preset];
if (idx)
{
[self.treeController setSelectionIndexPath:idx];
}
}
- (IBAction)updateBuiltInPresets:(id)sender
{
[self.presets generateBuiltInPresets];
// Re-expand the items
[self expandNodes:self.treeController.arrangedObjects.childNodes];
}
#pragma mark - Expanded node persistence methods
- (void)expandNodes:(NSArray *)childNodes
{
for (id node in childNodes)
{
[self expandNodes:[node childNodes]];
if ([self.expandedNodes containsObject:@([[node representedObject] hash])])
[self.outlineView expandItem:node expandChildren:YES];
}
}
- (void)outlineViewItemDidExpand:(NSNotification *)notification
{
HBPreset *node = [notification.userInfo[@"NSObject"] representedObject];
if (![self.expandedNodes containsObject:@(node.hash)])
{
[self.expandedNodes addObject:@(node.hash)];
[NSUserDefaults.standardUserDefaults setObject:self.expandedNodes forKey:@"HBPreviewViewExpandedStatus"];
}
}
- (void)outlineViewItemDidCollapse:(NSNotification *)notification
{
HBPreset *node = [notification.userInfo[@"NSObject"] representedObject];
[self.expandedNodes removeObject:@(node.hash)];
[NSUserDefaults.standardUserDefaults setObject:self.expandedNodes forKey:@"HBPreviewViewExpandedStatus"];
}
#pragma mark - Drag & Drops
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session willBeginAtPoint:(NSPoint)screenPoint forItems:(NSArray *)draggedItems
{
self.dragNodesArray = draggedItems;
}
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation
{
self.dragNodesArray = nil;
}
- (id)outlineView:(NSOutlineView *)outlineView
pasteboardWriterForItem:(id)item {
if (@available(macOS 10.12, *))
{
HBFilePromiseProvider *filePromise = [[HBFilePromiseProvider alloc] initWithFileType:@"public.text" delegate:self];
filePromise.userInfo = [item representedObject];
return filePromise;
}
else
{
return [[NSPasteboardItem alloc] initWithPasteboardPropertyList:@1 ofType:kHandBrakeInternalPBoardType];
}
}
- (nonnull NSString *)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider fileNameForType:(nonnull NSString *)fileType
{
return [self fileNameForPreset:filePromiseProvider.userInfo];
}
- (void)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider writePromiseToURL:(nonnull NSURL *)url completionHandler:(nonnull void (^)(NSError * _Nullable))completionHandler
{
NSError *error = NULL;
[filePromiseProvider.userInfo writeToURL:url atomically:YES removeRoot:NO error:&error];
completionHandler(error);
}
- (NSDragOperation)outlineView:(NSOutlineView *)ov
validateDrop:(id )info
proposedItem:(id)item
proposedChildIndex:(NSInteger)index
{
NSDragOperation result = NSDragOperationNone;
if (info.draggingSource == nil)
{
if ([[item representedObject] isBuiltIn] ||
([[item representedObject] isLeaf] && index == -1))
{
result = NSDragOperationNone;
}
else
{
result = NSDragOperationCopy;
}
}
else if ([self.dragNodesArray allSatisfy:^BOOL(id _Nonnull object) { return [[object representedObject] isBuiltIn] == NO; }])
{
if ([[item representedObject] isBuiltIn] ||
([[item representedObject] isLeaf] && index == -1) ||
[self.dragNodesArray containsObject:item])
{
// don't allow dropping on a child
result = NSDragOperationNone;
}
else
{
// drop location is a container
result = NSDragOperationMove;
}
}
return result;
}
- (void)handleInternalDrops:(NSPasteboard *)pboard withIndexPath:(NSIndexPath *)indexPath
{
// user is doing an intra app drag within the outline view:
NSArray *newNodes = self.dragNodesArray;
// move the items to their new place (we do this backwards, otherwise they will end up in reverse order)
NSInteger idx;
for (idx = newNodes.count - 1; idx >= 0; idx--)
{
[self.treeController moveNode:newNodes[idx] toIndexPath:indexPath];
// Call manually this because the NSTreeController doesn't call
// the KVC accessors method for the root node.
if (indexPath.length == 1)
{
[self.presets performSelector:@selector(nodeDidChange:) withObject:nil];
}
}
// keep the moved nodes selected
NSMutableArray *indexPathList = [NSMutableArray array];
for (id node in newNodes)
{
[indexPathList addObject:[node indexPath]];
}
[self.treeController setSelectionIndexPaths:indexPathList];
}
- (void)handleExternalDrops:(NSPasteboard *)pboard withIndexPath:(NSIndexPath *)indexPath
{
NSArray *URLs = [pboard readObjectsForClasses:@[[NSURL class]] options:nil];
[self doImportPreset:URLs atIndexPath:indexPath];
}
- (BOOL)outlineView:(NSOutlineView *)ov acceptDrop:(id )info item:(id)targetItem childIndex:(NSInteger)index
{
BOOL result = NO;
// note that "targetItem" is a NSTreeNode proxy
// find the index path to insert our dropped object(s)
NSIndexPath *indexPath;
if (targetItem)
{
// drop down inside the tree node: fetch the index path to insert our dropped node
indexPath = index == -1 ? [[targetItem indexPath] indexPathByAddingIndex:0] : [[targetItem indexPath] indexPathByAddingIndex:index];
}
else
{
// drop at the top root level
if (index == -1) // drop area might be ambiguous (not at a particular location)
{
indexPath = [NSIndexPath indexPathWithIndex:self.presets.root.children.count]; // drop at the end of the top level
}
else
{
indexPath = [NSIndexPath indexPathWithIndex:index]; // drop at a particular place at the top level
}
}
NSPasteboard *pboard = info.draggingPasteboard;
// check the dragging type
if ([pboard availableTypeFromArray:@[kHandBrakeInternalPBoardType]])
{
// user is doing an intra-app drag within the outline view
[self handleInternalDrops:pboard withIndexPath:indexPath];
result = YES;
}
else if ([pboard availableTypeFromArray:@[(NSString *)kUTTypeFileURL]])
{
[self handleExternalDrops:pboard withIndexPath:indexPath];
result = YES;
}
return result;
}
@end