Browse Source

display(visionOS): dynamic resolution from window resize

osy 1 năm trước cách đây
mục cha
commit
5f7e11e161

+ 2 - 0
Platform/iOS/Display/VMDisplayMetalViewController.h

@@ -42,6 +42,8 @@ NS_ASSUME_NONNULL_BEGIN
 
 @property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
 
+@property (nonatomic) BOOL isDynamicResolutionSupported;
+
 - (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
 - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
 - (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;

+ 88 - 33
Platform/iOS/Display/VMDisplayMetalViewController.m

@@ -29,11 +29,15 @@
 #import "UTM-Swift.h"
 @import CocoaSpiceRenderer;
 
+static const NSInteger kResizeDebounceSecs = 1;
+static const NSInteger kResizeTimeoutSecs = 5;
+
 @interface VMDisplayMetalViewController ()
 
 @property (nonatomic, nullable) CSMetalRenderer *renderer;
-@property (nonatomic) CGFloat windowScaling;
-@property (nonatomic) CGPoint windowOrigin;
+@property (nonatomic, nullable) id debounceResize;
+@property (nonatomic, nullable) id cancelResize;
+@property (nonatomic) BOOL ignoreNextResize;
 
 @end
 
@@ -43,9 +47,6 @@
     if (self = [super initWithNibName:nil bundle:nil]) {
         self.vmDisplay = display;
         self.vmInput = input;
-        self.windowScaling = 1.0;
-        self.windowOrigin = CGPointZero;
-        [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:NSKeyValueObservingOptionNew context:nil];
     }
     return self;
 }
@@ -128,11 +129,13 @@
     [super viewWillDisappear:animated];
     [self stopGCMouse];
     [self.vmDisplay removeRenderer:self.renderer];
+    [self removeObserver:self forKeyPath:@"vmDisplay.displaySize"];
 }
 
 - (void)viewDidAppear:(BOOL)animated {
     [super viewDidAppear:animated];
     self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
+    [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil];
 }
 
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
@@ -140,10 +143,12 @@
     [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
         self.delegate.displayViewSize = [self convertSizeToNative:size];
         [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
+        if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+            if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) {
+                [self requestResolutionChangeToSize:size];
+            }
+        }
     }];
-    if (self.delegate.qemuDisplayIsDynamicResolution) {
-        [self displayResize:size];
-    }
 }
 
 - (void)enterSuspendedWithIsBusy:(BOOL)busy {
@@ -161,8 +166,8 @@
     [super enterLive];
     self.prefersPointerLocked = YES;
     self.view.window.isIndirectPointerTouchIgnored = YES;
-    if (self.delegate.qemuDisplayIsDynamicResolution) {
-        [self displayResize:self.view.bounds.size];
+    if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+        [self requestResolutionChangeToSize:self.view.bounds.size];
     }
     if (self.delegate.qemuHasClipboardSharing) {
         [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
@@ -200,11 +205,21 @@
     return size;
 }
 
-- (void)displayResize:(CGSize)size {
-    UTMLog(@"resizing to (%f, %f)", size.width, size.height);
-    size = [self convertSizeToNative:size];
-    CGRect bounds = CGRectMake(0, 0, size.width, size.height);
-    [self.vmDisplay requestResolution:bounds];
+- (void)requestResolutionChangeToSize:(CGSize)size {
+    self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
+        UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height);
+        CGSize newSize = [self convertSizeToNative:size];
+        CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height);
+        self.debounceResize = nil;
+#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
+        self.cancelResize = [self debounce:kResizeTimeoutSecs context:self.cancelResize action:^{
+            self.cancelResize = nil;
+            UTMLog(@"DISPLAY: requesting resolution cancelled");
+            [self resizeWindowToDisplaySize];
+        }];
+#endif
+        [self.vmDisplay requestResolution:bounds];
+    }];
 }
 
 - (void)setVmDisplay:(CSDisplay *)display {
@@ -217,8 +232,6 @@
 
 - (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
     self.vmDisplay.viewportOrigin = origin;
-    self.windowScaling = scaling;
-    self.windowOrigin = origin;
     if (!self.delegate.qemuDisplayIsNativeResolution) {
         scaling = CGPointToPixel(scaling);
     }
@@ -229,25 +242,67 @@
 
 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
     if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) {
-#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
-        dispatch_async(dispatch_get_main_queue(), ^{
-            CGSize minSize = self.vmDisplay.displaySize;
-            if (self.delegate.qemuDisplayIsNativeResolution) {
-                minSize.width = CGPixelToPoint(minSize.width);
-                minSize.height = CGPixelToPoint(minSize.height);
+        UTMLog(@"DISPLAY: vmDisplay.displaySize changed");
+        if (self.cancelResize) {
+            [self debounce:0 context:self.cancelResize action:^{}];
+            self.cancelResize = nil;
+        }
+        self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
+            [self resizeWindowToDisplaySize];
+        }];
+    }
+}
+
+- (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported {
+    if (_isDynamicResolutionSupported != isDynamicResolutionSupported) {
+        _isDynamicResolutionSupported = isDynamicResolutionSupported;
+        UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported);
+        if (self.delegate.qemuDisplayIsDynamicResolution) {
+            if (isDynamicResolutionSupported) {
+                [self requestResolutionChangeToSize:self.view.bounds.size];
+            } else {
+                [self resizeWindowToDisplaySize];
             }
-            CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling);
-            CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
-            UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize];
-            geoPref.minimumSize = minSize;
-            geoPref.maximumSize = maxSize;
-            geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
-            [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
-        });
+        }
+    }
+}
+
+- (void)resizeWindowToDisplaySize {
+    CGSize displaySize = self.vmDisplay.displaySize;
+    UTMLog(@"DISPLAY: request window resize to (%f, %f)", displaySize.width, displaySize.height);
+#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
+    CGSize minSize = displaySize;
+    if (self.delegate.qemuDisplayIsNativeResolution) {
+        minSize.width = CGPixelToPoint(minSize.width);
+        minSize.height = CGPixelToPoint(minSize.height);
+    }
+    CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
+    UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize];
+    if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+        geoPref.minimumSize = CGSizeMake(800, 600);
+        geoPref.maximumSize = maxSize;
+        geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform;
+    } else {
+        geoPref.minimumSize = minSize;
+        geoPref.maximumSize = maxSize;
+        geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
+    }
+    dispatch_async(dispatch_get_main_queue(), ^{
+        CGSize currentViewSize = self.view.bounds.size;
+        UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height);
+        if (CGSizeEqualToSize(minSize, currentViewSize)) {
+            // since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called
+            self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize];
+            [self.delegate display:self.vmDisplay didResizeTo:displaySize];
+        }
+        [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
+    });
 #else
-        [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
-#endif
+    if (CGSizeEqualToSize(displaySize, CGSizeZero)) {
+        return;
     }
+    [self.delegate display:self.vmDisplay didResizeTo:displaySize];
+#endif
 }
 
 @end

+ 11 - 0
Platform/iOS/Display/VMDisplayViewController.swift

@@ -134,4 +134,15 @@ public extension VMDisplayViewController {
     func integerForSetting(_ key: String) -> Int {
         return UserDefaults.standard.integer(forKey: key)
     }
+
+    @discardableResult
+    func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any {
+        if context != nil {
+            let previous = context as! DispatchWorkItem
+            previous.cancel()
+        }
+        let item = DispatchWorkItem(block: action)
+        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item)
+        return item
+    }
 }

+ 1 - 0
Platform/iOS/VMDisplayHostedView.swift

@@ -190,6 +190,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
                 }
                 // some obscure SwiftUI error means we cannot refer to Coordinator's state binding
                 vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
+                vc.isDynamicResolutionSupported = state.isDynamicResolutionSupported
             }
         case .serial(let serial, _):
             if let vc = uiViewController as? VMDisplayTerminalViewController {

+ 9 - 1
Platform/iOS/VMSessionState.swift

@@ -78,7 +78,9 @@ import SwiftUI
     @Published var externalWindowBinding: Binding<VMWindowState>?
     
     @Published var hasShownMemoryWarning: Bool = false
-    
+
+    @Published var isDynamicResolutionSupported: Bool = false
+
     private var hasAutosave: Bool = false
 
     init(for vm: any UTMSpiceVirtualMachine) {
@@ -291,6 +293,12 @@ extension VMSessionState: UTMSpiceIODelegate {
         }
     }
     #endif
+
+    nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
+        Task { @MainActor in
+            isDynamicResolutionSupported = supported
+        }
+    }
 }
 
 #if WITH_USB

+ 2 - 0
Platform/iOS/VMWindowState.swift

@@ -71,6 +71,8 @@ struct VMWindowState: Identifiable {
     var isRunning: Bool = false
     
     var alert: Alert?
+
+    var isDynamicResolutionSupported: Bool = false
 }
 
 // MARK: - VM action alerts

+ 3 - 0
Platform/iOS/VMWindowView.swift

@@ -171,6 +171,9 @@ struct VMWindowView: View {
         .onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
             vmStateUpdated(from: oldValue, to: newValue)
         }
+        .onChange(of: session.isDynamicResolutionSupported) { newValue in
+            state.isDynamicResolutionSupported = newValue
+        }
         .onReceive(keyboardDidShowNotification) { _ in
             state.isKeyboardShown = true
             state.isKeyboardRequested = true