diff --git a/example/gui-linux/main.go b/example/gui-linux/main.go index 5db3f99a..9de96536 100644 --- a/example/gui-linux/main.go +++ b/example/gui-linux/main.go @@ -94,7 +94,7 @@ func run(ctx context.Context) error { } runtime.LockOSThread() - vm.StartGraphicApplication(960, 600) + vm.StartGraphicApplication(960, 600, vz.WithWindowTitle("Linux"), vz.WithController(true)) runtime.UnlockOSThread() cleanup() diff --git a/example/macOS/main.go b/example/macOS/main.go index b97c0999..da6b45ee 100644 --- a/example/macOS/main.go +++ b/example/macOS/main.go @@ -117,7 +117,7 @@ func runVM(ctx context.Context) error { log.Println("finished cleanup") } - vm.StartGraphicApplication(960, 600) + vm.StartGraphicApplication(960, 600, vz.WithWindowTitle("macOS"), vz.WithController(true)) cleanup() diff --git a/virtualization.go b/virtualization.go index 1ca3ba79..f4459b69 100644 --- a/virtualization.go +++ b/virtualization.go @@ -360,16 +360,55 @@ func (v *VirtualMachine) Stop() error { return <-errCh } +type startGraphicApplicationOptions struct { + title string + enableController bool +} + +// StartGraphicApplicationOption is an option for display graphics start. +type StartGraphicApplicationOption func(*startGraphicApplicationOptions) error + +// WithWindowTitle is an option to set window title of display graphics window. +func WithWindowTitle(title string) StartGraphicApplicationOption { + return func(sgao *startGraphicApplicationOptions) error { + sgao.title = title + return nil + } +} + +// WithController is an option to set virtual machine controller on graphics window toolbar. +func WithController(enable bool) StartGraphicApplicationOption { + return func(sgao *startGraphicApplicationOptions) error { + sgao.enableController = enable + return nil + } +} + // StartGraphicApplication starts an application to display graphics of the VM. // // You must to call runtime.LockOSThread before calling this method. // // This is only supported on macOS 12 and newer, error will be returned on older versions. -func (v *VirtualMachine) StartGraphicApplication(width, height float64) error { +func (v *VirtualMachine) StartGraphicApplication(width, height float64, opts ...StartGraphicApplicationOption) error { if err := macOSAvailable(12); err != nil { return err } - C.startVirtualMachineWindow(objc.Ptr(v), C.double(width), C.double(height)) + defaultOpts := &startGraphicApplicationOptions{} + for _, opt := range opts { + if err := opt(defaultOpts); err != nil { + return err + } + } + windowTitle := charWithGoString(defaultOpts.title) + defer windowTitle.Free() + C.startVirtualMachineWindow( + objc.Ptr(v), + v.dispatchQueue, + C.double(width), + C.double(height), + windowTitle.CString(), + C.bool(defaultOpts.enableController), + ) return nil } diff --git a/virtualization_12.h b/virtualization_12.h index 2a62ea51..dd679a7f 100644 --- a/virtualization_12.h +++ b/virtualization_12.h @@ -45,4 +45,4 @@ void setKeyboardsVZVirtualMachineConfiguration(void *config, void setAudioDevicesVZVirtualMachineConfiguration(void *config, void *audioDevices); -void startVirtualMachineWindow(void *machine, double width, double height); \ No newline at end of file +void startVirtualMachineWindow(void *machine, void *queue, double width, double height, const char *title, bool enableController); \ No newline at end of file diff --git a/virtualization_12.m b/virtualization_12.m index 4bcbcfba..1ab6b524 100644 --- a/virtualization_12.m +++ b/virtualization_12.m @@ -343,7 +343,7 @@ void setVZVirtioFileSystemDeviceConfigurationShare(void *config, void *share) RAISE_UNSUPPORTED_MACOS_EXCEPTION(); } -void startVirtualMachineWindow(void *machine, double width, double height) +void startVirtualMachineWindow(void *machine, void *queue, double width, double height, const char *title, bool enableController) { // Create a shared app instance. // This will initialize the global variable @@ -351,10 +351,14 @@ void startVirtualMachineWindow(void *machine, double width, double height) [VZApplication sharedApplication]; if (@available(macOS 12, *)) { @autoreleasepool { + NSString *windowTitle = [NSString stringWithUTF8String:title]; AppDelegate *appDelegate = [[[AppDelegate alloc] initWithVirtualMachine:(VZVirtualMachine *)machine + queue:(dispatch_queue_t)queue windowWidth:(CGFloat)width - windowHeight:(CGFloat)height] autorelease]; + windowHeight:(CGFloat)height + windowTitle:windowTitle + enableController:enableController] autorelease]; NSApp.delegate = appDelegate; [NSApp run]; diff --git a/virtualization_view.h b/virtualization_view.h index ab00b922..09766a7f 100644 --- a/virtualization_view.h +++ b/virtualization_view.h @@ -24,8 +24,11 @@ @end API_AVAILABLE(macos(12.0)) -@interface AppDelegate : NSObject +@interface AppDelegate : NSObject - (instancetype)initWithVirtualMachine:(VZVirtualMachine *)virtualMachine + queue:(dispatch_queue_t)queue windowWidth:(CGFloat)windowWidth - windowHeight:(CGFloat)windowHeight; + windowHeight:(CGFloat)windowHeight + windowTitle:(NSString *)windowTitle + enableController:(BOOL)enableController; @end \ No newline at end of file diff --git a/virtualization_view.m b/virtualization_view.m index 9fbf6d18..1e6e3f25 100644 --- a/virtualization_view.m +++ b/virtualization_view.m @@ -167,14 +167,26 @@ - (instancetype)init @implementation AppDelegate { VZVirtualMachine *_virtualMachine; + dispatch_queue_t _queue; VZVirtualMachineView *_virtualMachineView; - CGFloat _windowWidth; - CGFloat _windowHeight; + NSWindow *_window; + NSToolbar *_toolbar; + BOOL _enableController; + // Overlay for pause mode. + NSVisualEffectView *_pauseOverlayView; + // Zoom function properties. + BOOL _isZoomEnabled; + NSTimer *_scrollTimer; + NSPoint _scrollDelta; + id _mouseMovedMonitor; } - (instancetype)initWithVirtualMachine:(VZVirtualMachine *)virtualMachine + queue:(dispatch_queue_t)queue windowWidth:(CGFloat)windowWidth windowHeight:(CGFloat)windowHeight + windowTitle:(NSString *)windowTitle + enableController:(BOOL)enableController { self = [super init]; _virtualMachine = virtualMachine; @@ -191,17 +203,177 @@ - (instancetype)initWithVirtualMachine:(VZVirtualMachine *)virtualMachine } #endif _virtualMachineView = view; + _queue = queue; // Setup some window configs - _windowWidth = windowWidth; - _windowHeight = windowHeight; + _window = [self createMainWindowWithTitle:windowTitle width:windowWidth height:windowHeight]; + _toolbar = [self createCustomToolbar]; + _enableController = enableController; + [_virtualMachine addObserver:self + forKeyPath:@"state" + options:NSKeyValueObservingOptionNew + context:nil]; + _pauseOverlayView = [self createPauseOverlayEffectView:_virtualMachineView]; + [_virtualMachineView addSubview:_pauseOverlayView]; + _isZoomEnabled = NO; return self; } +- (void)dealloc +{ + if (_mouseMovedMonitor) { + [NSEvent removeMonitor:_mouseMovedMonitor]; + _mouseMovedMonitor = nil; + } + [self stopScrollTimer]; + if (_virtualMachine) { + [_virtualMachine removeObserver:self forKeyPath:@"state"]; + } + _virtualMachineView = nil; + _virtualMachine = nil; + _queue = nil; + _toolbar = nil; + _window = nil; + _pauseOverlayView = nil; + [super dealloc]; +} + +- (BOOL)canStopVirtualMachine +{ + __block BOOL result; + dispatch_sync(_queue, ^{ + result = _virtualMachine.canStop; + }); + return (bool)result; +} + +- (BOOL)canResumeVirtualMachine +{ + __block BOOL result; + dispatch_sync(_queue, ^{ + result = _virtualMachine.canResume; + }); + return (bool)result; +} + +- (BOOL)canPauseVirtualMachine +{ + __block BOOL result; + dispatch_sync(_queue, ^{ + result = _virtualMachine.canPause; + }); + return (bool)result; +} + +- (BOOL)canStartVirtualMachine +{ + __block BOOL result; + dispatch_sync(_queue, ^{ + result = _virtualMachine.canStart; + }); + return (bool)result; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context; +{ + if ([keyPath isEqualToString:@"state"]) { + VZVirtualMachineState newState = (VZVirtualMachineState)[change[NSKeyValueChangeNewKey] integerValue]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateToolbarItems]; + if (newState == VZVirtualMachineStatePaused) { + [self showOverlay]; + } else { + [self hideOverlay]; + } + // Terminating GUI Application from Guest and Host. + // See: https://github.com/Code-Hex/vz/issues/150 + if (newState == VZVirtualMachineStateStopped) { + [NSApp terminate:nil]; + } + }); + } +} + +// Overlay a semi-transparent view on the VZVirtualMachineView when the virtual machine is paused. +// This provides a clear visual indication to the user that the virtual machine is in a paused state. +// The overlay is hidden when the virtual machine resumes or stops. +- (NSVisualEffectView *)createPauseOverlayEffectView:(NSView *)view +{ + NSVisualEffectView *effectView = [[[NSVisualEffectView alloc] initWithFrame:view.bounds] autorelease]; + effectView.wantsLayer = YES; + effectView.blendingMode = NSVisualEffectBlendingModeWithinWindow; + effectView.state = NSVisualEffectStateActive; + effectView.alphaValue = 0.7; + effectView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + effectView.hidden = YES; + return effectView; +} + +- (void)showOverlay +{ + if (_pauseOverlayView) { + _pauseOverlayView.hidden = NO; + } +} + +- (void)hideOverlay +{ + if (_pauseOverlayView) { + _pauseOverlayView.hidden = YES; + } +} + +static NSString *const ZoomToolbarIdentifier = @"Zoom"; +static NSString *const PauseToolbarIdentifier = @"Pause"; +static NSString *const PlayToolbarIdentifier = @"Play"; +static NSString *const PowerToolbarIdentifier = @"Power"; +static NSString *const SpaceToolbarIdentifier = @"Space"; + +- (NSArray *)setupToolbarItemIdentifiers +{ + NSMutableArray *toolbarItems = [NSMutableArray array]; + if (_enableController) { + if ([self canPauseVirtualMachine]) { + [toolbarItems addObject:PauseToolbarIdentifier]; + } + if ([self canResumeVirtualMachine]) { + [toolbarItems addObject:SpaceToolbarIdentifier]; + [toolbarItems addObject:PlayToolbarIdentifier]; + } + if ([self canStopVirtualMachine] || [self canStartVirtualMachine]) { + [toolbarItems addObject:SpaceToolbarIdentifier]; + [toolbarItems addObject:PowerToolbarIdentifier]; + } + } + [toolbarItems addObject:NSToolbarSpaceItemIdentifier]; + [toolbarItems addObject:ZoomToolbarIdentifier]; + [toolbarItems addObject:NSToolbarFlexibleSpaceItemIdentifier]; + return [toolbarItems copy]; +} + +- (void)updateToolbarItems +{ + NSArray *toolbarItems = [self setupToolbarItemIdentifiers]; + [self setToolBarItems:toolbarItems]; +} + +- (void)setToolBarItems:(NSArray *)desiredItems +{ + if (_toolbar) { + while (_toolbar.items.count > 0) { + [_toolbar removeItemAtIndex:0]; + } + + for (NSToolbarItemIdentifier itemIdentifier in desiredItems) { + [_toolbar insertItemWithItemIdentifier:itemIdentifier atIndex:_toolbar.items.count]; + } + } +} + /* IMPORTANT: delegate methods are called from VM's queue */ - (void)guestDidStopVirtualMachine:(VZVirtualMachine *)virtualMachine { - [NSApp performSelectorOnMainThread:@selector(terminate:) withObject:self waitUntilDone:NO]; + // [NSApp performSelectorOnMainThread:@selector(terminate:) withObject:self waitUntilDone:NO]; } - (void)virtualMachine:(VZVirtualMachine *)virtualMachine didStopWithError:(NSError *)error @@ -229,25 +401,392 @@ - (void)windowWillClose:(NSNotification *)notification - (void)setupGraphicWindow { - NSRect rect = NSMakeRect(0, 0, _windowWidth, _windowHeight); + // Set custom title bar + [_window setTitlebarAppearsTransparent:YES]; + [_window setToolbar:_toolbar]; + [_window setOpaque:NO]; + [_window center]; + + // Monitoring mouse movement events to control auto-scrolling behavior + // within the virtual machine window when zoom mode is enabled. + _mouseMovedMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskMouseMoved + handler:^NSEvent *(NSEvent *event) { + [self handleMouseMovement:event]; + return event; + }]; + + // Create scroll view for the virtual machine view + NSScrollView *scrollView = [self createScrollViewForVirtualMachineView:_virtualMachineView]; + [_window setContentView:scrollView]; + + // Configure Auto Layout constraints for VirtualMachineView to resize with the window + [_virtualMachineView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [NSLayoutConstraint activateConstraints:@[ + [_virtualMachineView.leadingAnchor constraintEqualToAnchor:_window.contentView.leadingAnchor], + [_virtualMachineView.trailingAnchor constraintEqualToAnchor:_window.contentView.trailingAnchor], + [_virtualMachineView.topAnchor constraintEqualToAnchor:_window.contentView.topAnchor], + [_virtualMachineView.bottomAnchor constraintEqualToAnchor:_window.contentView.bottomAnchor] + ]]; + + NSSize sizeInPixels = [self getVirtualMachineSizeInPixels]; + if (!NSEqualSizes(sizeInPixels, NSZeroSize)) { + // setContentAspectRatio is used to maintain the aspect ratio when the user resizes the window. + [_window setContentAspectRatio:sizeInPixels]; + + // setContentSize is used to set the initial window size based on the calculated aspect ratio. + CGFloat windowWidth = _window.frame.size.width; + CGFloat initialHeight = windowWidth * (sizeInPixels.height / sizeInPixels.width); + [_window setContentSize:NSMakeSize(windowWidth, initialHeight)]; + } + + [_window setDelegate:self]; + [_window makeKeyAndOrderFront:nil]; + + // This code to prevent crash when called applicationShouldTerminateAfterLast_windowClosed. + // https://stackoverflow.com/a/13470694 + [_window setReleasedWhenClosed:NO]; +} + +// Adjust the window content aspect ratio to match the graphics device resolution +// configured for the virtual machine. This ensures that the display output from +// the virtual machine is rendered with the correct proportions, avoiding any +// distortion within the window. +- (NSSize)getVirtualMachineSizeInPixels +{ + __block NSSize sizeInPixels; + if (@available(macOS 14.0, *)) { + dispatch_sync(_queue, ^{ + if (_virtualMachine.graphicsDevices.count > 0) { + VZGraphicsDevice *graphicsDevice = _virtualMachine.graphicsDevices[0]; + if (graphicsDevice.displays.count > 0) { + VZGraphicsDisplay *displayConfig = graphicsDevice.displays[0]; + sizeInPixels = displayConfig.sizeInPixels; + } + } + }); + } + return sizeInPixels; +} + +- (NSWindow *)createMainWindowWithTitle:(NSString *)title + width:(CGFloat)width + height:(CGFloat)height +{ + NSRect rect = NSMakeRect(0, 0, width, height); NSWindow *window = [[[NSWindow alloc] initWithContentRect:rect - styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable //|NSTexturedBackgroundWindowMask + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable backing:NSBackingStoreBuffered defer:NO] autorelease]; + [window setTitle:title]; + return window; +} - [window setOpaque:NO]; - [window setContentView:_virtualMachineView]; - [window setTitleVisibility:NSWindowTitleHidden]; - [window center]; +- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar +{ + return [self setupToolbarItemIdentifiers]; +} - [window setDelegate:self]; - [window makeKeyAndOrderFront:nil]; +- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar +{ + return @[ + ZoomToolbarIdentifier, + PlayToolbarIdentifier, + PauseToolbarIdentifier, + SpaceToolbarIdentifier, + PowerToolbarIdentifier, + NSToolbarSpaceItemIdentifier, + NSToolbarFlexibleSpaceItemIdentifier + ]; +} - // This code to prevent crash when called applicationShouldTerminateAfterLastWindowClosed. - // https://stackoverflow.com/a/13470694 - [window setReleasedWhenClosed:NO]; +- (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag +{ + NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:itemIdentifier] autorelease]; + + if ([itemIdentifier isEqualToString:PauseToolbarIdentifier]) { + [item setImage:[NSImage imageWithSystemSymbolName:@"pause.fill" accessibilityDescription:nil]]; + [item setLabel:@"Pause"]; + [item setTarget:self]; + [item setToolTip:@"Pause"]; + [item setBordered:YES]; + [item setAction:@selector(pauseButtonClicked:)]; + } else if ([itemIdentifier isEqualToString:PowerToolbarIdentifier]) { + [item setImage:[NSImage imageWithSystemSymbolName:@"power" accessibilityDescription:nil]]; + [item setLabel:@"Power"]; + [item setTarget:self]; + [item setToolTip:@"Power ON/OFF"]; + [item setBordered:YES]; + [item setAction:@selector(powerButtonClicked:)]; + } else if ([itemIdentifier isEqualToString:PlayToolbarIdentifier]) { + [item setImage:[NSImage imageWithSystemSymbolName:@"play.fill" accessibilityDescription:nil]]; + [item setLabel:@"Play"]; + [item setTarget:self]; + [item setToolTip:@"Resume"]; + [item setBordered:YES]; + [item setAction:@selector(playButtonClicked:)]; + } else if ([itemIdentifier isEqualToString:ZoomToolbarIdentifier]) { + NSButton *zoomButton = [[[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 40, 40)] autorelease]; + zoomButton.bezelStyle = NSBezelStyleTexturedRounded; + [zoomButton setImage:[NSImage imageWithSystemSymbolName:@"plus.magnifyingglass" accessibilityDescription:nil]]; + [zoomButton setTarget:self]; + [zoomButton setAction:@selector(toggleZoomMode:)]; + [zoomButton setButtonType:NSButtonTypeToggle]; + [item setView:zoomButton]; + [item setLabel:@"Zoom"]; + [item setToolTip:@"Toggle Zoom"]; + } else if ([itemIdentifier isEqualToString:SpaceToolbarIdentifier]) { + NSView *spaceView = [[[NSView alloc] initWithFrame:NSMakeRect(0, 0, 2, 10)] autorelease]; + item.view = spaceView; + item.minSize = NSMakeSize(1, 10); + item.maxSize = NSMakeSize(1, 10); + } + + return item; +} + +- (NSToolbar *)createCustomToolbar +{ + NSToolbar *toolbar = [[[NSToolbar alloc] initWithIdentifier:@"CustomToolbar"] autorelease]; + [toolbar setDelegate:self]; + [toolbar setDisplayMode:NSToolbarDisplayModeIconOnly]; + [toolbar setShowsBaselineSeparator:NO]; + [toolbar setAllowsUserCustomization:NO]; + [toolbar setAutosavesConfiguration:NO]; + return toolbar; +} + +#pragma mark - Button Actions + +- (void)pauseButtonClicked:(id)sender +{ + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_sync(_queue, ^{ + [_virtualMachine pauseWithCompletionHandler:^(NSError *err) { + if (err) + [self showErrorAlertWithMessage:@"Failed to pause Virtual Machine" error:err]; + }]; + }); + }); +} + +- (void)powerButtonClicked:(id)sender +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self canStartVirtualMachine]) { + dispatch_sync(_queue, ^{ + [_virtualMachine startWithCompletionHandler:^(NSError *err) { + if (err) + [self showErrorAlertWithMessage:@"Failed to start Virtual Machine" error:err]; + }]; + }); + return; + } + if ([self canStopVirtualMachine]) { + NSAlert *alert = [[[NSAlert alloc] init] autorelease]; + [alert setIcon:[NSImage imageNamed:NSImageNameCaution]]; + [alert setMessageText:@"Force Stop Warning"]; + [alert setInformativeText:@"This action will stop the VM without a clean shutdown, similar to unplugging a PC.\n\nDo you want to force stop?"]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert addButtonWithTitle:@"Stop"]; + [alert addButtonWithTitle:@"Cancel"]; + + NSModalResponse response = [alert runModal]; + if (response != NSAlertFirstButtonReturn) { + return; + } + dispatch_sync(_queue, ^{ + [_virtualMachine stopWithCompletionHandler:^(NSError *err) { + if (err) + [self showErrorAlertWithMessage:@"Failed to stop Virtual Machine" error:err]; + }]; + }); + return; + } + }); +} + +- (void)playButtonClicked:(id)sender +{ + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_sync(_queue, ^{ + [_virtualMachine resumeWithCompletionHandler:^(NSError *err) { + if (err) + [self showErrorAlertWithMessage:@"Failed to resume Virtual Machine" error:err]; + }]; + }); + }); +} + +- (void)showErrorAlertWithMessage:(NSString *)message error:(NSError *)error +{ + dispatch_async(dispatch_get_main_queue(), ^{ + NSAlert *alert = [[[NSAlert alloc] init] autorelease]; + [alert setMessageText:message]; + [alert setInformativeText:[NSString stringWithFormat:@"Error: %@\nCode: %ld", [error localizedDescription], (long)[error code]]]; + [alert setAlertStyle:NSAlertStyleCritical]; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; + }); +} + +#pragma mark - Zoom Function + +- (void)toggleZoomMode:(id)sender +{ + _isZoomEnabled = !_isZoomEnabled; + + // Reset zoom when zoom mode is disabled. + if (!_isZoomEnabled) { + [NSAnimationContext + runAnimationGroup:^(NSAnimationContext *context) { + [context setDuration:0.3]; + [[_window.contentView animator] setMagnification:1.0]; + } + completionHandler:nil]; + } +} + +- (NSScrollView *)createScrollViewForVirtualMachineView:(VZVirtualMachineView *)view +{ + NSScrollView *scrollView = [[[NSScrollView alloc] initWithFrame:_window.contentView.bounds] autorelease]; + scrollView.hasVerticalScroller = YES; + scrollView.hasHorizontalScroller = YES; + scrollView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + scrollView.documentView = view; + + scrollView.allowsMagnification = YES; + scrollView.maxMagnification = 4.0; + scrollView.minMagnification = 1.0; + + // Pinch to zoom. Register the NSMagnificationGestureRecognizer + NSMagnificationGestureRecognizer *magnifyRecognizer = [[[NSMagnificationGestureRecognizer alloc] initWithTarget:self action:@selector(handleMagnification:)] autorelease]; + + // Set `delaysMagnificationEvents` to NO to ensure that pinch-to-zoom gestures + // are immediately propagated to the VZVirtualMachineView. the default value is YES. + // + // If set to YES, the magnification gesture recognizer delays event handling, preventing + // pinch-in and pinch-out interactions within the virtual machine view. + // See: https://developer.apple.com/documentation/appkit/nsmagnificationgesturerecognizer + magnifyRecognizer.delaysMagnificationEvents = NO; + + [scrollView addGestureRecognizer:magnifyRecognizer]; + + return scrollView; +} + +// Handles pinch-to-zoom gestures for the virtual machine view. +// If zoom mode is enabled, adjusts the magnification of the content view +// based on the user's pinch gesture, allowing smooth zoom in/out. +- (void)handleMagnification:(NSMagnificationGestureRecognizer *)recognizer +{ + if (!_isZoomEnabled) { + return; + } + + NSScrollView *scrollView = (NSScrollView *)recognizer.view; + CGFloat newMagnification = scrollView.magnification + recognizer.magnification; + newMagnification = MIN(scrollView.maxMagnification, MAX(scrollView.minMagnification, newMagnification)); + + NSPoint locationInView = [recognizer locationInView:scrollView]; + NSPoint centeredPoint = [scrollView.contentView convertPoint:locationInView fromView:scrollView]; + + [scrollView setMagnification:newMagnification centeredAtPoint:centeredPoint]; } +// When the mouse approaches the window's edges, this handler adjusts the scroll position +// to provide a smooth panning experience without requiring manual scroll input. +- (void)handleMouseMovement:(NSEvent *)event +{ + if (!_isZoomEnabled) { + [self stopScrollTimer]; + return; + } + + NSScrollView *scrollView = (NSScrollView *)_window.contentView; + if (![scrollView isKindOfClass:[NSScrollView class]]) { + [self stopScrollTimer]; + return; + } + + // Take the mouse position. + NSPoint mouseLocation = [scrollView.window convertPointToScreen:event.locationInWindow]; + NSRect windowFrame = scrollView.window.frame; + + const CGFloat margin = 24.0; // Set scrolling boundary margins. + const CGFloat baseScrollSpeed = 5.0; // Basic scrolling speed + + // Calculate scroll direction and speed from here. + _scrollDelta = NSMakePoint(0, 0); + + // X-axis scrollmeter + if (mouseLocation.x < NSMinX(windowFrame) + margin) { + _scrollDelta.x = -baseScrollSpeed; + } else if (mouseLocation.x > NSMaxX(windowFrame) - margin) { + _scrollDelta.x = baseScrollSpeed; + } + + CGFloat titleBarHeight = scrollView.window.frame.size.height - scrollView.window.contentView.frame.size.height; + + // Y-axis scrollmeter + // No Y-axis scrolling when the mouse is in the title bar area. + if (mouseLocation.y >= (NSMaxY(windowFrame) - titleBarHeight)) { + _scrollDelta.y = 0; + } else if (mouseLocation.y < NSMinY(windowFrame) + margin) { + _scrollDelta.y = -baseScrollSpeed; + } else if (mouseLocation.y > NSMaxY(windowFrame) - margin - titleBarHeight) { + _scrollDelta.y = baseScrollSpeed; + } + + // Start timer if scrolling is required, stop if not required. + if (_scrollDelta.x != 0 || _scrollDelta.y != 0) { + [self startScrollTimer]; + } else { + [self stopScrollTimer]; + } +} + +- (void)startScrollTimer +{ + if (_scrollTimer == nil) { + _scrollTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / 60.0 + target:self + selector:@selector(scrollTick:) + userInfo:nil + repeats:YES]; + } +} + +- (void)stopScrollTimer +{ + [_scrollTimer invalidate]; + _scrollTimer = nil; +} + +- (void)scrollTick:(NSTimer *)timer +{ + NSScrollView *scrollView = (NSScrollView *)_window.contentView; + if (![scrollView isKindOfClass:[NSScrollView class]]) { + [self stopScrollTimer]; + return; + } + + NSClipView *clipView = scrollView.contentView; + NSPoint currentOrigin = clipView.bounds.origin; + + // Calculate new scroll position + currentOrigin.x += _scrollDelta.x; + currentOrigin.y += _scrollDelta.y; + + // Scroll position controlled. + currentOrigin.x = MAX(0, MIN(currentOrigin.x, clipView.documentView.frame.size.width - clipView.bounds.size.width)); + currentOrigin.y = MAX(0, MIN(currentOrigin.y, clipView.documentView.frame.size.height - clipView.bounds.size.height)); + + // Update scroll position + [clipView setBoundsOrigin:currentOrigin]; +} + +#pragma mark - Application Menu Bar + - (void)setupMenuBar { NSMenu *menuBar = [[[NSMenu alloc] init] autorelease];