diff --git a/Platform/Shared/VMNavigationListView.swift b/Platform/Shared/VMNavigationListView.swift index 2bc7ec4127..81cbfe0234 100644 --- a/Platform/Shared/VMNavigationListView.swift +++ b/Platform/Shared/VMNavigationListView.swift @@ -214,8 +214,10 @@ private struct VMListModifier: ViewModifier { #endif #if !os(visionOS) && !WITH_REMOTE ToolbarItem(placement: .navigationBarTrailing) { - Button("Settings") { + Button { settingsPresented.toggle() + } label: { + Label("Settings", systemImage: "gear") } } #endif diff --git a/Platform/Shared/VMToolbarModifier.swift b/Platform/Shared/VMToolbarModifier.swift index 362d87464f..b2f475b42a 100644 --- a/Platform/Shared/VMToolbarModifier.swift +++ b/Platform/Shared/VMToolbarModifier.swift @@ -46,116 +46,209 @@ struct VMToolbarModifier: ViewModifier { #endif func body(content: Content) -> some View { + toolbar(content) + .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem)) + .modifier(VMConfirmActionModifier(confirmAction: $confirmAction) { action in + if case .confirmMoveVM(let vm) = action { + shareItem = .utmMove(vm) + showSharePopup.toggle() + } + }) + } +} + +// MARK: - Layouts + +private extension VMToolbarModifier { + /// Toolbar content cannot branch on availability before iOS 16, so the view does. + @ViewBuilder + func toolbar(_ content: Content) -> some View { + #if os(iOS) + if #available(iOS 27, *) { + content.toolbar { + prioritizedToolbarContent + } + } else { + content.toolbar { + groupedToolbarContent + } + } + #else content.toolbar { #if os(visionOS) UTMPreferenceButtonToolbarContent() #endif - ToolbarItemGroup(placement: buttonPlacement) { - #if !WITH_REMOTE // FIXME: implement remote feature - if vm.isShortcut { - DestructiveButton { - confirmAction = .confirmDeleteVM(vm: vm) - } label: { - Label("Remove", systemImage: "trash") - .labelStyle(.iconOnly) - }.help("Remove selected shortcut") - .disabled(!vm.isModifyAllowed) - .padding(.leading, padding) - } else { - DestructiveButton { - confirmAction = .confirmDeleteVM(vm: vm) - } label: { - Label("Delete", systemImage: "trash") - .labelStyle(.iconOnly) - }.help("Delete selected VM") - .disabled(!vm.isModifyAllowed) - .padding(.leading, padding) - } - #if !os(macOS) - if bottom { - Spacer() - } - #endif - Button { - confirmAction = .confirmCloneVM(vm: vm) - } label: { - Label("Clone", systemImage: "doc.on.doc") - .labelStyle(.iconOnly) - }.help("Clone selected VM") + groupedToolbarContent + } + #endif + } + + /// One group with manual spacing, which is laid out as a single unit. + var groupedToolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: buttonPlacement) { + #if !WITH_REMOTE // FIXME: implement remote feature + deleteButton .padding(.leading, padding) - #if !os(macOS) - if bottom { - Spacer() - } - #endif - #if os(macOS) - if !vm.isShortcut { - Button { - confirmAction = .confirmMoveVM(vm: vm) - } label: { - Label("Move", systemImage: "arrow.down.doc") - .labelStyle(.iconOnly) - }.help("Move selected VM") - .disabled(!vm.isModifyAllowed) - .padding(.leading, padding) - } - #endif - Button { - shareItem = .utmCopy(vm) - showSharePopup.toggle() - } label: { - Label("Share", systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - }.help("Share selected VM") + groupSpacer + cloneButton .padding(.leading, padding) - #if !os(macOS) - if bottom { - Spacer() - } - #endif - #endif - if vm.hasSuspendState || !vm.isStopped { - Button { - confirmAction = .confirmStopVM(vm: vm) - } label: { - Label("Stop", systemImage: "stop") - .labelStyle(.iconOnly) - }.help("Stop selected VM") - .padding(.leading, padding) - } else { - Button { - data.run(vm: data.selectedVM!) - } label: { - Label("Run", systemImage: "play") - .labelStyle(.iconOnly) - }.help("Run selected VM") + groupSpacer + #if os(macOS) + if !vm.isShortcut { + moveButton .padding(.leading, padding) - } - #if !WITH_REMOTE // FIXME: implement remote feature - #if !os(macOS) - if bottom { - Spacer() - } - #endif - Button { - data.close(vm: vm) // close window - data.edit(vm: vm) - } label: { - Label("Edit", systemImage: "slider.horizontal.3") - .labelStyle(.iconOnly) - }.help("Edit selected VM") - .disabled(vm.hasSuspendState || !vm.isModifyAllowed) - .padding(.leading, padding) - #endif } + #endif + shareButton + .padding(.leading, padding) + groupSpacer + #endif + runStopButton + .padding(.leading, padding) + #if !WITH_REMOTE // FIXME: implement remote feature + groupSpacer + editButton + .padding(.leading, padding) + #endif + } + } + + @ViewBuilder + var groupSpacer: some View { + #if !os(macOS) + if bottom { + Spacer() + } + #endif + } + + #if os(iOS) + /// Separate items so that a bar too short for all of them drops the least important ones first. + /// + /// A vertical bar on the closed iPhone Duo overflows from the bottom, where Run and Stop sit. + @available(iOS 27, *) + @ToolbarContentBuilder + var prioritizedToolbarContent: some ToolbarContent { + #if !WITH_REMOTE // FIXME: implement remote feature + ToolbarItem(placement: buttonPlacement) { + deleteButton + } + if bottom { + ToolbarSpacer(.flexible, placement: buttonPlacement) + } + ToolbarItem(placement: buttonPlacement) { + cloneButton + }.visibilityPriority(.low) + if bottom { + ToolbarSpacer(.flexible, placement: buttonPlacement) + } + ToolbarItem(placement: buttonPlacement) { + shareButton + } + if bottom { + ToolbarSpacer(.flexible, placement: buttonPlacement) + } + #endif + ToolbarItem(placement: buttonPlacement) { + runStopButton + }.visibilityPriority(.high) + #if !WITH_REMOTE // FIXME: implement remote feature + if bottom { + ToolbarSpacer(.flexible, placement: buttonPlacement) + } + ToolbarItem(placement: buttonPlacement) { + editButton + } + #endif + } + #endif +} + +// MARK: - Buttons + +private extension VMToolbarModifier { + #if !WITH_REMOTE + @ViewBuilder + var deleteButton: some View { + if vm.isShortcut { + DestructiveButton { + confirmAction = .confirmDeleteVM(vm: vm) + } label: { + Label("Remove", systemImage: "trash") + .labelStyle(.iconOnly) + }.help("Remove selected shortcut") + .disabled(!vm.isModifyAllowed) + } else { + DestructiveButton { + confirmAction = .confirmDeleteVM(vm: vm) + } label: { + Label("Delete", systemImage: "trash") + .labelStyle(.iconOnly) + }.help("Delete selected VM") + .disabled(!vm.isModifyAllowed) + } + } + + var cloneButton: some View { + Button { + confirmAction = .confirmCloneVM(vm: vm) + } label: { + Label("Clone", systemImage: "doc.on.doc") + .labelStyle(.iconOnly) + }.help("Clone selected VM") + } + + #if os(macOS) + var moveButton: some View { + Button { + confirmAction = .confirmMoveVM(vm: vm) + } label: { + Label("Move", systemImage: "arrow.down.doc") + .labelStyle(.iconOnly) + }.help("Move selected VM") + .disabled(!vm.isModifyAllowed) + } + #endif + + var shareButton: some View { + Button { + shareItem = .utmCopy(vm) + showSharePopup.toggle() + } label: { + Label("Share", systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + }.help("Share selected VM") + } + + var editButton: some View { + Button { + data.close(vm: vm) // close window + data.edit(vm: vm) + } label: { + Label("Edit", systemImage: "slider.horizontal.3") + .labelStyle(.iconOnly) + }.help("Edit selected VM") + .disabled(vm.hasSuspendState || !vm.isModifyAllowed) + } + #endif + + @ViewBuilder + var runStopButton: some View { + if vm.hasSuspendState || !vm.isStopped { + Button { + confirmAction = .confirmStopVM(vm: vm) + } label: { + Label("Stop", systemImage: "stop") + .labelStyle(.iconOnly) + }.help("Stop selected VM") + } else { + Button { + data.run(vm: data.selectedVM!) + } label: { + Label("Run", systemImage: "play") + .labelStyle(.iconOnly) + }.help("Run selected VM") } - .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem)) - .modifier(VMConfirmActionModifier(confirmAction: $confirmAction) { action in - if case .confirmMoveVM(let vm) = action { - shareItem = .utmMove(vm) - showSharePopup.toggle() - } - }) } } diff --git a/Platform/iOS/Display/VMCursor.h b/Platform/iOS/Display/VMCursor.h index 33fdc19879..fc48e10bd9 100644 --- a/Platform/iOS/Display/VMCursor.h +++ b/Platform/iOS/Display/VMCursor.h @@ -24,6 +24,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readwrite) CGRect bounds; +/// The drag speed setting as a factor applied to relative motion. +@property (nonatomic, readonly) CGFloat cursorSpeedMultiplier; + - (id)initWithVMViewController:(VMDisplayMetalViewController *)controller; - (void)startMovement:(CGPoint)startPoint; - (void)updateMovement:(CGPoint)point; diff --git a/Platform/iOS/Display/VMCursor.m b/Platform/iOS/Display/VMCursor.m index fed24dae85..e45e754a56 100644 --- a/Platform/iOS/Display/VMCursor.m +++ b/Platform/iOS/Display/VMCursor.m @@ -18,12 +18,6 @@ #import "VMDisplayMetalViewController+Touch.h" #import "CSDisplay.h" -@interface VMCursor () - -@property (nonatomic, readonly) CGFloat cursorSpeedMultiplier; - -@end - @implementation VMCursor { CGPoint _start; CGPoint _lastCenter; diff --git a/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.h b/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.h index 92cc400753..ac85298c35 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.h +++ b/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.h @@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)keyboardDonePressed:(UIButton *)sender; - (void)keyboardPastePressed:(UIButton *)sender; +- (void)keyboardTouchpadPressed:(UIButton *)sender; +- (void)touchpadKeyboardPressed:(UIButton *)sender; - (void)customKeyTouchDown:(VMKeyboardButton *)sender; - (void)customKeyTouchUp:(VMKeyboardButton *)sender; diff --git a/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.m b/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.m index 3bafa9a78a..bc341c8fa5 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.m +++ b/Platform/iOS/Display/VMDisplayMetalViewController+Keyboard.m @@ -52,6 +52,14 @@ - (void)keyboardPastePressed:(UIButton *)sender { } } +- (void)keyboardTouchpadPressed:(UIButton *)sender { + [self.delegate displayDidRequestInputDeck:VMInputDeckTouchpad]; +} + +- (void)touchpadKeyboardPressed:(UIButton *)sender { + [self.delegate displayDidRequestInputDeck:VMInputDeckKeyboard]; +} + - (void)resetModifierToggles { for (VMKeyboardButton *button in self.inputAccessoryView.modifierButtons) { if (button.isToggled) { diff --git a/Platform/iOS/Display/VMDisplayMetalViewController+Private.h b/Platform/iOS/Display/VMDisplayMetalViewController+Private.h index 25f727b455..6230d095b2 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController+Private.h +++ b/Platform/iOS/Display/VMDisplayMetalViewController+Private.h @@ -73,15 +73,25 @@ NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END +#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION +/// The native scale of the screen the view is on, which can differ from the trait's scale on downsampled displays. +/// +/// A device can have more than one screen so the view's own traits are used until it is in a window. +static inline CGFloat CGViewNativeScale(UIView * _Nonnull view) { + UIScreen *screen = view.window.windowScene.screen; + if (screen) { + return screen.nativeScale; + } else { + return view.traitCollection.displayScale; + } +} +#endif + static inline CGFloat CGPointToPixel(UIView * _Nonnull view, CGFloat point) { #if defined(TARGET_OS_VISION) && TARGET_OS_VISION return point * 2.0; #else - UIScreen *screen = view.window.screen; - if (!screen) { - screen = [UIScreen mainScreen]; - } - return point * screen.nativeScale; + return point * CGViewNativeScale(view); #endif } @@ -89,10 +99,6 @@ static inline CGFloat CGPixelToPoint(UIView * _Nonnull view, CGFloat pixel) { #if defined(TARGET_OS_VISION) && TARGET_OS_VISION return pixel / 2.0; #else - UIScreen *screen = view.window.screen; - if (!screen) { - screen = [UIScreen mainScreen]; - } - return pixel / screen.nativeScale; + return pixel / CGViewNativeScale(view); #endif } diff --git a/Platform/iOS/Display/VMDisplayMetalViewController+Touch.h b/Platform/iOS/Display/VMDisplayMetalViewController+Touch.h index a3ab889938..48baac7283 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController+Touch.h +++ b/Platform/iOS/Display/VMDisplayMetalViewController+Touch.h @@ -55,6 +55,13 @@ NS_ASSUME_NONNULL_BEGIN - (void)scrollWithInertia:(UIPanGestureRecognizer *)sender; - (void)mouseClick:(CSInputButton)button location:(CGPoint)location; +/// Puts the guest pointer into the mode the touchpad drives before its first touch, since the switch takes a round trip to the guest. +- (void)prepareForTouchpad; +/// Moves the pointer like a connected mouse would, by a distance in points on a touchpad surface. +- (void)touchpadMoveBy:(CGPoint)delta; +/// Holds or releases a button of the pointer like a connected mouse would. +- (void)touchpadPressButton:(CSInputButton)button pressed:(BOOL)pressed; + @end NS_ASSUME_NONNULL_END diff --git a/Platform/iOS/Display/VMDisplayMetalViewController+Touch.m b/Platform/iOS/Display/VMDisplayMetalViewController+Touch.m index 6abc76eb52..aa6fd3286b 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController+Touch.m +++ b/Platform/iOS/Display/VMDisplayMetalViewController+Touch.m @@ -622,6 +622,29 @@ - (IBAction)gestureSwipeScroll:(UISwipeGestureRecognizer *)sender { } } +#pragma mark - Touchpad + +- (void)prepareForTouchpad { + [self switchMouseType:VMMouseTypeRelative]; +} + +- (void)touchpadMoveBy:(CGPoint)delta { + [self switchMouseType:VMMouseTypeRelative]; + CGFloat multiplier = self.cursor.cursorSpeedMultiplier; + [self moveMouseRelative:CGPointMake(delta.x * multiplier, delta.y * multiplier)]; +} + +- (void)touchpadPressButton:(CSInputButton)button pressed:(BOOL)pressed { + [self switchMouseType:VMMouseTypeRelative]; + switch (button) { + case kCSInputButtonLeft: self.mouseLeftDown = pressed; break; + case kCSInputButtonRight: self.mouseRightDown = pressed; break; + case kCSInputButtonMiddle: self.mouseMiddleDown = pressed; break; + default: break; + } + [self.vmInput sendMouseButton:button mask:self.mouseButtonDown pressed:pressed]; +} + #pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { @@ -752,16 +775,29 @@ - (BOOL)isTouchGazeGesture:(UITouch *)touch { #pragma mark - Touch event handling +/// Touches on the input deck reach the controller through the responder chain but are not for the guest. +- (NSSet *)displayTouches:(NSSet *)touches { + return [touches objectsPassingTest:^BOOL(UITouch *touch, BOOL *stop) { + return [touch.view isDescendantOfView:self.mtkView]; + }]; +} + - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + NSSet *displayTouches = [self displayTouches:touches]; + if (displayTouches.count == 0) { + [super touchesBegan:touches withEvent:event]; + return; + } + NSUInteger allDisplayTouches = [self displayTouches:event.allTouches].count; if (!self.delegate.qemuInputLegacy) { - for (UITouch *touch in touches) { + for (UITouch *touch in displayTouches) { VMMouseType type = [self touchTypeToMouseType:touch.type]; #if TARGET_OS_VISION if ([self isTouchGazeGesture:touch]) { type = VMMouseTypeRelative; } #endif - if (event.allTouches.count == touches.count) { // a touch joining later does not change the gesture + if (allDisplayTouches == displayTouches.count) { // a touch joining later does not change the gesture self.isTouchScrolling = (type == VMMouseTypeAbsoluteScroll); } if ([self switchMouseType:type]) { @@ -769,7 +805,7 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES]; // reset drag } else if (!self.vmInput.serverModeCursor) { // start click for client mode if (self.isTouchScrolling) { // clicks come from the gestures instead - if (event.allTouches.count == 1) { + if (allDisplayTouches == 1) { CGPoint pos = [touch locationInView:self.mtkView]; if (![self isRepeatTapAtLocation:pos]) { // else leave the cursor for the double click [self.cursor startMovement:pos]; @@ -810,7 +846,7 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { // move cursor in client mode, in server mode we handle in gesturePan if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && !self.isTouchScrolling) { - for (UITouch *touch in touches) { + for (UITouch *touch in [self displayTouches:touches]) { [self.cursor updateMovement:[touch locationInView:self.mtkView]]; break; // handle single touch } @@ -820,7 +856,7 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { // release click in client mode, in server mode we handle in gesturePan - if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && !self.isTouchScrolling) { + if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && !self.isTouchScrolling && [self displayTouches:touches].count > 0) { [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES]; } [super touchesCancelled:touches withEvent:event]; @@ -828,7 +864,7 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { // release click in client mode, in server mode we handle in gesturePan - if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && !self.isTouchScrolling) { + if (!self.delegate.qemuInputLegacy && !self.vmInput.serverModeCursor && !self.isTouchScrolling && [self displayTouches:touches].count > 0) { [self dragCursor:UIGestureRecognizerStateEnded primary:YES secondary:YES middle:YES]; } [super touchesEnded:touches withEvent:event]; diff --git a/Platform/iOS/Display/VMDisplayMetalViewController.h b/Platform/iOS/Display/VMDisplayMetalViewController.h index 7dd9c63f9a..341ecc6aad 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController.h +++ b/Platform/iOS/Display/VMDisplayMetalViewController.h @@ -43,6 +43,18 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL isDynamicResolutionSupported; +/// Height at the bottom of the view given to the keyboard or touchpad of a device folded like a laptop. +@property (nonatomic) CGFloat inputDeckHeight; + +/// Height of the fold above the input deck, which the accessory row stays clear of. +@property (nonatomic) CGFloat inputDeckFoldHeight; + +/// Shows the touchpad in the input deck. +@property (nonatomic) BOOL isTouchpadShown; + +/// Whether the software keyboard is on the screen, which decides if the accessory row shows in the input deck. +@property (nonatomic) BOOL isKeyboardShown; + - (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; diff --git a/Platform/iOS/Display/VMDisplayMetalViewController.m b/Platform/iOS/Display/VMDisplayMetalViewController.m index 51fed91b15..1bf3e36553 100644 --- a/Platform/iOS/Display/VMDisplayMetalViewController.m +++ b/Platform/iOS/Display/VMDisplayMetalViewController.m @@ -37,6 +37,13 @@ @interface VMDisplayMetalViewController () @property (nonatomic, nullable) id debounceResize; @property (nonatomic, nullable) id cancelResize; @property (nonatomic) BOOL ignoreNextResize; +@property (nonatomic) NSLayoutConstraint *displayBottomConstraint; +#if !TARGET_OS_VISION +@property (nonatomic, nullable) VMTouchpadView *touchpadView API_AVAILABLE(ios(26.0)); +@property (nonatomic, nullable) NSLayoutConstraint *deckAccessoryBottomConstraint; +@property (nonatomic, nullable) NSLayoutConstraint *touchpadHeightConstraint; +#endif +@property (nonatomic) BOOL isDeckLayoutPending; @end @@ -59,7 +66,14 @@ - (void)loadView { self.keyboardView.delegate = self; [self.view insertSubview:self.keyboardView atIndex:0]; [self.view insertSubview:self.mtkView atIndex:1]; - [self.mtkView bindFrameToSuperviewBounds]; + self.mtkView.translatesAutoresizingMaskIntoConstraints = NO; + self.displayBottomConstraint = [self.mtkView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor]; + [NSLayoutConstraint activateConstraints:@[ + [self.mtkView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [self.mtkView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.mtkView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + self.displayBottomConstraint, + ]]; } - (BOOL)serverModeCursor { @@ -125,13 +139,13 @@ - (void)viewWillDisappear:(BOOL)animated { - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; - self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size]; + self.delegate.displayViewSize = [self convertSizeToNative:self.displayAreaSize]; [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil]; if ([self integerForSetting:@"QEMURendererFPSLimit"] > 0) { self.mtkView.preferredFramesPerSecond = [self integerForSetting:@"QEMURendererFPSLimit"]; } #if !TARGET_OS_VISION - else if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + else if (self.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad) { // only apply ProMotion by default on iPad which has a larger battery // on iPhone, we depend on the user manually setting the FPS limit to 120 NSInteger maxFps = self.view.window.screen.maximumFramesPerSecond; @@ -145,14 +159,192 @@ - (void)viewDidAppear:(BOOL)animated { - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:nil completion:^(id _Nonnull context) { - self.delegate.displayViewSize = [self convertSizeToNative:size]; + [self displayAreaDidChangeToSize:[self displayAreaSizeForViewSize:size]]; + }]; +} + +#pragma mark - Input deck + +/// Whether the accessory row sits between the guest display and the fold. +/// +/// The keyboard is dismissed and shown again while the row moves, and the row stays for a +/// keyboard that is on its way back. +- (BOOL)isDeckAccessoryShown { +#if !TARGET_OS_VISION + return self.inputDeckHeight > 0 && !self.isTouchpadShown && (self.isKeyboardShown || self.keyboardView.isFirstResponder); +#else + return NO; +#endif +} + +/// Height of the accessory row while it sits between the guest display and the fold. +- (CGFloat)deckAccessoryHeight { +#if !TARGET_OS_VISION + if (!self.isDeckAccessoryShown) { + return 0; + } + return [self.inputAccessoryView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height; +#else + return 0; +#endif +} + +/// Height at the bottom of the view kept clear of the guest display: the deck, the fold and the accessory row. +- (CGFloat)deckReservedHeight { + if (self.inputDeckHeight <= 0) { + return 0; + } + return self.inputDeckHeight + self.inputDeckFoldHeight + self.deckAccessoryHeight; +} + +/// The part of the view that shows the guest display, above the input deck when there is one. +- (CGSize)displayAreaSizeForViewSize:(CGSize)size { + size.height = MAX(0, size.height - self.deckReservedHeight); + return size; +} + +- (CGSize)displayAreaSize { + return [self displayAreaSizeForViewSize:self.view.bounds.size]; +} + +- (void)displayAreaDidChangeToSize:(CGSize)size { + self.delegate.displayViewSize = [self convertSizeToNative:size]; + if (!CGSizeEqualToSize(self.vmDisplay.displaySize, CGSizeZero)) { [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.isDynamicResolutionSupported) { + if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) { + [self requestResolutionChangeToSize:size]; } - }]; + } +} + +- (void)setInputDeckHeight:(CGFloat)inputDeckHeight { + if (fabs(_inputDeckHeight - inputDeckHeight) < 0.5) { + return; + } + [self loadViewIfNeeded]; + _inputDeckHeight = inputDeckHeight; + [self setNeedsDeckLayout]; +} + +- (void)setInputDeckFoldHeight:(CGFloat)inputDeckFoldHeight { + if (fabs(_inputDeckFoldHeight - inputDeckFoldHeight) < 0.5) { + return; + } + [self loadViewIfNeeded]; + _inputDeckFoldHeight = inputDeckFoldHeight; + [self setNeedsDeckLayout]; +} + +- (void)setIsKeyboardShown:(BOOL)isKeyboardShown { + if (_isKeyboardShown == isKeyboardShown) { + return; + } + _isKeyboardShown = isKeyboardShown; + if (self.inputDeckHeight > 0) { + [self setNeedsDeckLayout]; + } +} + +/// The deck's parts arrive one by one from a view update, which must not change the view state +/// itself, so they are laid out together on the next turn of the run loop. +- (void)setNeedsDeckLayout { + if (self.isDeckLayoutPending) { + return; + } + self.isDeckLayoutPending = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + self.isDeckLayoutPending = NO; + [self deckLayoutDidChange]; + }); +} + +- (void)deckLayoutDidChange { +#if !TARGET_OS_VISION + self.inputAccessoryView.isTouchpadKeyShown = self.inputDeckHeight > 0; + [self updateDeckAccessory]; + self.touchpadHeightConstraint.constant = self.inputDeckHeight; +#endif + CGFloat accessoryHeight = self.deckAccessoryHeight; + self.displayBottomConstraint.constant = -self.deckReservedHeight; + [self.view layoutIfNeeded]; + [self displayAreaDidChangeToSize:self.displayAreaSize]; + if (self.delegate.deckAccessoryHeight != accessoryHeight) { + self.delegate.deckAccessoryHeight = accessoryHeight; + } +} + +#if !TARGET_OS_VISION +/// With the deck the accessory row leaves the keyboard and sits between the guest display and the fold. +/// +/// The system would put it there itself, but then it belongs to the keyboard, and the guest +/// display and the toolbar could not lay out around it. +- (void)updateDeckAccessory { + VMKeyboardAccessoryView *accessory = self.inputAccessoryView; + BOOL isInDeck = self.inputDeckHeight > 0; + BOOL isMoving = isInDeck ? accessory.superview != self.view : accessory.superview == self.view; + // reloading the input views while the keyboard is moving between the panels crashes inside + // UIKit, so the keyboard is dismissed and shown again around the change instead + BOOL wasFirstResponder = isMoving && self.keyboardView.isFirstResponder; + if (wasFirstResponder) { + [self.keyboardView resignFirstResponder]; + } + if (isInDeck && accessory.superview != self.view) { + self.keyboardView.inputAccessoryView = nil; + [self.view addSubview:accessory]; + self.deckAccessoryBottomConstraint = [accessory.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:-(self.inputDeckHeight + self.inputDeckFoldHeight)]; + [NSLayoutConstraint activateConstraints:@[ + [accessory.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [accessory.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + self.deckAccessoryBottomConstraint, + ]]; + } else if (!isInDeck && accessory.superview == self.view) { + [accessory removeFromSuperview]; + self.deckAccessoryBottomConstraint = nil; + self.keyboardView.inputAccessoryView = accessory; + } else if (isInDeck) { + self.deckAccessoryBottomConstraint.constant = -(self.inputDeckHeight + self.inputDeckFoldHeight); + } + if (wasFirstResponder) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.keyboardView becomeFirstResponder]; + }); + } + accessory.hidden = isInDeck && !self.isDeckAccessoryShown; +} +#endif + +- (void)setIsTouchpadShown:(BOOL)isTouchpadShown { +#if !TARGET_OS_VISION + if (_isTouchpadShown == isTouchpadShown) { + return; + } + [self loadViewIfNeeded]; + _isTouchpadShown = isTouchpadShown; + if (@available(iOS 27.1, *)) { + if (isTouchpadShown && !self.touchpadView) { + VMTouchpadView *touchpad = [[VMTouchpadView alloc] initWithTarget:self]; + touchpad.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:touchpad]; + self.touchpadHeightConstraint = [touchpad.heightAnchor constraintEqualToConstant:self.inputDeckHeight]; + [NSLayoutConstraint activateConstraints:@[ + self.touchpadHeightConstraint, + [touchpad.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [touchpad.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [touchpad.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + ]]; + self.touchpadView = touchpad; + } + self.touchpadView.hidden = !isTouchpadShown; + } + if (isTouchpadShown) { + [self prepareForTouchpad]; + } + if (self.inputDeckHeight > 0) { + [self setNeedsDeckLayout]; + } +#endif } - (void)enterSuspendedWithIsBusy:(BOOL)busy { @@ -171,7 +363,7 @@ - (void)enterLive { self.prefersPointerLocked = YES; self.view.window.isIndirectPointerTouchIgnored = YES; if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { - [self requestResolutionChangeToSize:self.view.bounds.size]; + [self requestResolutionChangeToSize:self.displayAreaSize]; } if (self.delegate.qemuHasClipboardSharing) { [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self]; @@ -263,7 +455,7 @@ - (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported { UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported); if (self.delegate.qemuDisplayIsDynamicResolution) { if (isDynamicResolutionSupported) { - [self requestResolutionChangeToSize:self.view.bounds.size]; + [self requestResolutionChangeToSize:self.displayAreaSize]; } else { [self resizeWindowToDisplaySize]; } diff --git a/Platform/iOS/Display/VMDisplayTerminalViewController.swift b/Platform/iOS/Display/VMDisplayTerminalViewController.swift index 9973103cec..e212dfe435 100644 --- a/Platform/iOS/Display/VMDisplayTerminalViewController.swift +++ b/Platform/iOS/Display/VMDisplayTerminalViewController.swift @@ -50,9 +50,9 @@ import SwiftUI styleTerminal() } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - setupKeyboardMonitor() + override func viewDidLoad() { + super.viewDidLoad() + layoutTerminal() } override func viewDidAppear(_ animated: Bool) { @@ -104,30 +104,43 @@ import SwiftUI // MARK: - Layout terminal extension VMDisplayTerminalViewController { - // This prevents curved edge from cutting off the content - var additionalTopPadding: CGFloat { - if UIDevice.current.userInterfaceIdiom == .pad { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - guard let window = windowScene?.windows.first else { return 0 } - return window.safeAreaInsets.bottom + /// The safe area is not enough to keep the first line of text out of the rounded corners while + /// the status bar is hidden, so the system's corner-adapted region is used where it exists. + private var contentLayoutGuide: UILayoutGuide { + if #available(iOS 26, visionOS 26, *) { + return view.layoutGuide(for: .safeArea(cornerAdaptation: .vertical)) } else { - return 0 + return view.safeAreaLayoutGuide } } - func setupKeyboardMonitor () - { + private func layoutTerminal() { #if os(visionOS) let inputAccessoryHeight: CGFloat = 0 #else let inputAccessoryHeight = terminalView.inputAccessoryView?.frame.height ?? 0 #endif + let guide = contentLayoutGuide terminalView.translatesAutoresizingMaskIntoConstraints = false - terminalView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: additionalTopPadding).isActive = true - terminalView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true - terminalView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor).isActive = true - terminalView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -inputAccessoryHeight).isActive = true + NSLayoutConstraint.activate([ + terminalView.topAnchor.constraint(equalTo: guide.topAnchor, constant: fallbackTopPadding), + terminalView.leftAnchor.constraint(equalTo: guide.leftAnchor), + terminalView.rightAnchor.constraint(equalTo: guide.rightAnchor), + terminalView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -inputAccessoryHeight), + ]) + } + + /// Before the corner-adapted region existed, an iPad kept the first line out of the rounded + /// corners with the height of the bottom safe area, which the home indicator rounds alike. + private var fallbackTopPadding: CGFloat { + if #available(iOS 26, visionOS 26, *) { + return 0 + } + guard traitCollection.userInterfaceIdiom == .pad else { + return 0 + } + let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene + return windowScene?.windows.first?.safeAreaInsets.bottom ?? 0 } } diff --git a/Platform/iOS/Display/VMDisplayViewControllerDelegate.swift b/Platform/iOS/Display/VMDisplayViewControllerDelegate.swift index 03b139d827..0141e94cd8 100644 --- a/Platform/iOS/Display/VMDisplayViewControllerDelegate.swift +++ b/Platform/iOS/Display/VMDisplayViewControllerDelegate.swift @@ -16,6 +16,12 @@ import Foundation +/// What the bottom half of a device folded like a laptop shows. +@objc enum VMInputDeck: Int { + case keyboard + case touchpad +} + @objc protocol VMDisplayViewControllerDelegate { var qemuInputLegacy: Bool { get } var qemuDisplayUpscaler: MTLSamplerMinMagFilter { get } @@ -27,10 +33,12 @@ import Foundation var displayScale: CGFloat { get set } var displayViewSize: CGSize { get set } var displayIsZoomLocked: Bool { get set } + var deckAccessoryHeight: CGFloat { get set } func displayDidAssertUserInteraction() func displayDidAppear() func display(_ display: CSDisplay, didResizeTo size: CGSize) func serialDidError(_ error: String) func requestInputTablet(_ tablet: Bool) + func displayDidRequestInputDeck(_ deck: VMInputDeck) } diff --git a/Platform/iOS/Display/VMKeyboardAccessoryView.swift b/Platform/iOS/Display/VMKeyboardAccessoryView.swift index 573bf77611..3002d50195 100644 --- a/Platform/iOS/Display/VMKeyboardAccessoryView.swift +++ b/Platform/iOS/Display/VMKeyboardAccessoryView.swift @@ -74,10 +74,21 @@ import UIKit /// Toggleable keys which the target releases after another key is pressed. @objc let modifierButtons: [VMKeyboardButton] + private let touchpadButton: UIButton + + /// Offers the touchpad in place of the keyboard while the device is folded like a laptop. + @objc var isTouchpadKeyShown: Bool = false { + didSet { + touchpadButton.isHidden = !isTouchpadKeyShown + } + } + @objc init(target: VMDisplayMetalViewController) { let style = Self.style modifierButtons = Self.modifierKeys.map { $0.makeButton(style: style, isToggleable: true) } let keyButtons = Self.keys.map { $0.makeButton(style: style) } + touchpadButton = Self.makeActionButton(image: UIImage(systemName: "rectangle.and.hand.point.up.left"), + accessibilityLabel: NSLocalizedString("Show Touchpad", comment: "VMKeyboardAccessoryView")) super.init(frame: .zero, inputViewStyle: .keyboard) allowsSelfSizing = true translatesAutoresizingMaskIntoConstraints = false @@ -92,6 +103,8 @@ import UIKit let hideButton = Self.makeActionButton(imageName: "Keyboard Hide", accessibilityLabel: NSLocalizedString("Hide Keyboard", comment: "VMKeyboardAccessoryView")) hideButton.addTarget(target, action: #selector(VMDisplayMetalViewController.keyboardDonePressed(_:)), for: .touchUpInside) + touchpadButton.addTarget(target, action: #selector(VMDisplayMetalViewController.keyboardTouchpadPressed(_:)), for: .touchUpInside) + touchpadButton.isHidden = true let keySpacing: CGFloat = style == .classic ? 8 : 6 let modifierStack = UIStackView(arrangedSubviews: modifierButtons) @@ -103,7 +116,7 @@ import UIKit scrollView.addSubview(keyStack) Self.pin(keyStack, to: scrollView.contentLayoutGuide) keyStack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor).isActive = true - let contentStack = UIStackView(arrangedSubviews: [modifierStack, scrollView, pasteButton, hideButton]) + let contentStack = UIStackView(arrangedSubviews: [modifierStack, scrollView, touchpadButton, pasteButton, hideButton]) contentStack.spacing = 8 contentStack.alignment = .center @@ -144,8 +157,12 @@ import UIKit } private static func makeActionButton(imageName: String, accessibilityLabel: String) -> UIButton { + makeActionButton(image: UIImage(named: imageName), accessibilityLabel: accessibilityLabel) + } + + static func makeActionButton(image: UIImage?, accessibilityLabel: String) -> UIButton { let button = UIButton(type: .system) - button.setImage(UIImage(named: imageName), for: .normal) + button.setImage(image, for: .normal) button.accessibilityLabel = accessibilityLabel button.widthAnchor.constraint(equalToConstant: 30).isActive = true button.heightAnchor.constraint(equalToConstant: 30).isActive = true @@ -153,7 +170,7 @@ import UIKit } /// - Parameter top: Overrides the top of `guide` when only its other edges should be respected. - private static func pin(_ view: UIView, to guide: LayoutAnchorProviding, top: NSLayoutYAxisAnchor? = nil, insets: UIEdgeInsets = .zero) { + static func pin(_ view: UIView, to guide: LayoutAnchorProviding, top: NSLayoutYAxisAnchor? = nil, insets: UIEdgeInsets = .zero) { view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: top ?? guide.topAnchor, constant: insets.top), @@ -164,7 +181,7 @@ import UIKit } } -private protocol LayoutAnchorProviding { +protocol LayoutAnchorProviding { var topAnchor: NSLayoutYAxisAnchor { get } var bottomAnchor: NSLayoutYAxisAnchor { get } var leadingAnchor: NSLayoutXAxisAnchor { get } @@ -174,7 +191,7 @@ private protocol LayoutAnchorProviding { extension UIView: LayoutAnchorProviding {} extension UILayoutGuide: LayoutAnchorProviding {} -private extension UIColor { +extension UIColor { /// Sits between the backdrops of the iOS 26 and iOS 27 keyboards, which differ slightly. static let glassKeyboardBackdrop = UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor(white: 30/255, alpha: 0.85) : UIColor(red: 216/255, green: 218/255, blue: 222/255, alpha: 0.83) diff --git a/Platform/iOS/Display/VMKeyboardButton.swift b/Platform/iOS/Display/VMKeyboardButton.swift index 2f766994e0..3d480b7252 100644 --- a/Platform/iOS/Display/VMKeyboardButton.swift +++ b/Platform/iOS/Display/VMKeyboardButton.swift @@ -154,7 +154,7 @@ import UIKit } } -private extension UIColor { +extension UIColor { convenience init(light: UIColor, dark: UIColor) { self.init { $0.userInterfaceStyle == .dark ? dark : light } } diff --git a/Platform/iOS/Display/VMKeyboardView.h b/Platform/iOS/Display/VMKeyboardView.h index cf06099d1d..b6b0bb8280 100644 --- a/Platform/iOS/Display/VMKeyboardView.h +++ b/Platform/iOS/Display/VMKeyboardView.h @@ -17,16 +17,12 @@ #import #import "VMKeyboardViewDelegate.h" -extern const int kLargeAccessoryViewHeight; -extern const int kSmallAccessoryViewHeight; -extern const int kSafeAreaHeight; - NS_ASSUME_NONNULL_BEGIN @interface VMKeyboardView : UIView @property (nonatomic, weak) IBOutlet id delegate; -@property (nonatomic, readwrite, strong) IBOutlet UIView *inputAccessoryView; +@property (nonatomic, readwrite, strong, nullable) IBOutlet UIView *inputAccessoryView; @end diff --git a/Platform/iOS/Display/VMTouchpadView.swift b/Platform/iOS/Display/VMTouchpadView.swift new file mode 100644 index 0000000000..0af4b04cec --- /dev/null +++ b/Platform/iOS/Display/VMTouchpadView.swift @@ -0,0 +1,156 @@ +// +// Copyright © 2026 Turing Software, LLC. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if !os(visionOS) +import UIKit + +/// A trackpad for the bottom half of a device folded like a laptop, drawn like the keyboard it replaces. +/// +/// One finger moves the pointer, a tap clicks, a two finger tap right-clicks, two fingers scroll and a +/// finger held down for a moment drags. Clicks come with a haptic tap like a physical trackpad. +/// +/// The view needs the glass of iOS 26; the fold that brings it up is only reported from iOS 27.1. +@available(iOS 26, *) +@objc final class VMTouchpadView: UIView { + private weak var target: VMDisplayMetalViewController? + private let surface = UIView() + private let clickFeedback = UIImpactFeedbackGenerator(style: .rigid) + private let releaseFeedback = UIImpactFeedbackGenerator(style: .light) + private var lastLocation: CGPoint = .zero + private var isHolding = false + + @objc init(target: VMDisplayMetalViewController) { + self.target = target + super.init(frame: .zero) + + let panel = UIVisualEffectView(effect: UIGlassEffect(style: .clear)) + panel.cornerConfiguration = .corners(radius: 14.5) + panel.contentView.cornerConfiguration = panel.cornerConfiguration + panel.contentView.clipsToBounds = true + panel.contentView.backgroundColor = .glassKeyboardBackdrop + addSubview(panel) + VMKeyboardAccessoryView.pin(panel, to: safeAreaLayoutGuide, insets: .init(top: 8, left: 8, bottom: 6, right: 8)) + + surface.backgroundColor = .glassKey + surface.layer.cornerRadius = VMKeyboardButton.Style.glass.cornerRadius + surface.layer.cornerCurve = .continuous + surface.isAccessibilityElement = true + surface.accessibilityLabel = NSLocalizedString("Touchpad", comment: "VMTouchpadView") + panel.contentView.addSubview(surface) + VMKeyboardAccessoryView.pin(surface, to: panel.contentView, insets: .init(top: 6, left: 6, bottom: 6, right: 6)) + + let keyboardButton = VMKeyboardAccessoryView.makeActionButton(image: UIImage(systemName: "keyboard"), + accessibilityLabel: NSLocalizedString("Show Keyboard", comment: "VMTouchpadView")) + keyboardButton.tintColor = .label + keyboardButton.addTarget(target, action: #selector(VMDisplayMetalViewController.touchpadKeyboardPressed(_:)), for: .touchUpInside) + keyboardButton.translatesAutoresizingMaskIntoConstraints = false + panel.contentView.addSubview(keyboardButton) + NSLayoutConstraint.activate([ + keyboardButton.topAnchor.constraint(equalTo: surface.topAnchor, constant: 8), + keyboardButton.trailingAnchor.constraint(equalTo: surface.trailingAnchor, constant: -8), + ]) + + let move = UIPanGestureRecognizer(target: self, action: #selector(handleMove(_:))) + move.maximumNumberOfTouches = 1 + move.delegate = self + let scroll = UIPanGestureRecognizer(target: self, action: #selector(handleScroll(_:))) + scroll.minimumNumberOfTouches = 2 + scroll.maximumNumberOfTouches = 2 + let click = UITapGestureRecognizer(target: self, action: #selector(handleClick(_:))) + let rightClick = UITapGestureRecognizer(target: self, action: #selector(handleRightClick(_:))) + rightClick.numberOfTouchesRequired = 2 + click.require(toFail: rightClick) + let hold = UILongPressGestureRecognizer(target: self, action: #selector(handleHold(_:))) + hold.minimumPressDuration = 0.3 + hold.delegate = self + for recognizer in [move, scroll, click, rightClick, hold] { + surface.addGestureRecognizer(recognizer) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + clickFeedback.prepare() + super.touchesBegan(touches, with: event) + } + + // MARK: - Gestures + + @objc private func handleMove(_ sender: UIPanGestureRecognizer) { + let location = sender.location(in: self) + if sender.state == .changed { + target?.touchpadMove(by: CGPoint(x: location.x - lastLocation.x, y: location.y - lastLocation.y)) + } + lastLocation = location + } + + @objc private func handleScroll(_ sender: UIPanGestureRecognizer) { + target?.scroll(withInertia: sender) + } + + @objc private func handleClick(_ sender: UITapGestureRecognizer) { + if sender.state == .ended { + sendClick(.left) + } + } + + @objc private func handleRightClick(_ sender: UITapGestureRecognizer) { + if sender.state == .ended { + sendClick(.right) + } + } + + /// A held finger keeps the button down so the pointer drags along with it. + @objc private func handleHold(_ sender: UILongPressGestureRecognizer) { + switch sender.state { + case .began: + isHolding = true + clickFeedback.impactOccurred() + target?.touchpadPress(.left, pressed: true) + case .ended, .cancelled, .failed: + guard isHolding else { + break + } + isHolding = false + releaseFeedback.impactOccurred() + target?.touchpadPress(.left, pressed: false) + default: + break + } + } + + private func sendClick(_ button: CSInputButton) { + clickFeedback.impactOccurred() + target?.touchpadPress(button, pressed: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.target?.touchpadPress(button, pressed: false) + } + } + +} + +@available(iOS 26, *) +extension VMTouchpadView: UIGestureRecognizerDelegate { + /// The pointer keeps moving while a held finger drags. + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + (gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UILongPressGestureRecognizer) || + (gestureRecognizer is UILongPressGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer) + } +} +#endif diff --git a/Platform/iOS/UTMApp.swift b/Platform/iOS/UTMApp.swift index 3bd8b7ebde..de0ef83db1 100644 --- a/Platform/iOS/UTMApp.swift +++ b/Platform/iOS/UTMApp.swift @@ -36,8 +36,11 @@ struct UTMApp: App { } } + /// Identifies the window group for `openWindow`. + static let windowID = "main" + var body: some Scene { - WindowGroup { + WindowGroup(id: Self.windowID) { UTMSingleWindowView(data: data) .externalDisplayAccessory() }.commands { diff --git a/Platform/iOS/UTMSettingsView.swift b/Platform/iOS/UTMSettingsView.swift index 11621fec7a..676ac0a7ad 100644 --- a/Platform/iOS/UTMSettingsView.swift +++ b/Platform/iOS/UTMSettingsView.swift @@ -40,7 +40,7 @@ struct UTMSettingsView: View { } } } - } + }.navigationViewStyle(.stack) } } diff --git a/Platform/iOS/VMDisplayHostedView.swift b/Platform/iOS/VMDisplayHostedView.swift index f321c905f4..62816368d0 100644 --- a/Platform/iOS/VMDisplayHostedView.swift +++ b/Platform/iOS/VMDisplayHostedView.swift @@ -110,6 +110,16 @@ struct VMDisplayHostedView: UIViewControllerRepresentable { } } + var deckAccessoryHeight: CGFloat { + get { + state.deckAccessoryHeight + } + + set { + state.deckAccessoryHeight = newValue + } + } + init(with vm: any UTMSpiceVirtualMachine, device: VMWindowState.Device, state: Binding) { self.vm = vm self.device = device @@ -139,6 +149,10 @@ struct VMDisplayHostedView: UIViewControllerRepresentable { func requestInputTablet(_ tablet: Bool) { vm.requestInputTablet(tablet) } + + func displayDidRequestInputDeck(_ deck: VMInputDeck) { + state.requestInputDeck(deck) + } } let vm: any UTMSpiceVirtualMachine @@ -174,9 +188,22 @@ struct VMDisplayHostedView: UIViewControllerRepresentable { return vc } + /// The display takes whatever size it is given and lays its parts out inside. + /// + /// Without this, SwiftUI sizes the view from its constraints, and the space that the input deck + /// reserves below the guest display makes it taller than the window while the keyboard is up. + @available(iOS 16.0, *) + func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: VMDisplayViewController, context: Context) -> CGSize? { + proposal.replacingUnspecifiedDimensions(by: uiViewController.view.bounds.size) + } + func updateUIViewController(_ uiViewController: VMDisplayViewController, context: Context) { if let vc = uiViewController as? VMDisplayMetalViewController { vc.vmInput = session.primaryInput + vc.inputDeckHeight = state.inputDeckLayout?.frame.height ?? 0 + vc.inputDeckFoldHeight = state.inputDeckLayout?.fold.height ?? 0 + vc.isTouchpadShown = state.showsTouchpadDeck + vc.isKeyboardShown = state.isKeyboardShown } #if os(visionOS) let useSystemOsk = !(uiViewController is VMDisplayMetalViewController) diff --git a/Platform/iOS/VMToolbarDisplayMenuView.swift b/Platform/iOS/VMToolbarDisplayMenuView.swift index 775c60a593..e6060e80c6 100644 --- a/Platform/iOS/VMToolbarDisplayMenuView.swift +++ b/Platform/iOS/VMToolbarDisplayMenuView.swift @@ -23,18 +23,22 @@ struct VMToolbarDisplayMenuView: View { #if os(visionOS) @Environment(\.supportsMultipleWindows) private var supportsMultipleWindows @Environment(\.openWindow) private var openWindow - #else - private var supportsMultipleWindows: Bool { - UIApplication.shared.supportsMultipleScenes - } #endif + /// Two views of one display would both request its resolution, so a window only offers what no + /// other window shows, and what it shows itself so that its selection is always in the list. + private func availableDevices(for windowID: VMSessionState.WindowID?, current: VMWindowState.Device?) -> [VMWindowState.Device] { + session.devices.filter { device in + device == current || !session.windowDeviceMap.contains { $0.key != windowID && $0.value == device } + } + } + var body: some View { Menu { Menu { Picker("", selection: $state.device) { MenuLabel("None", systemImage: "rectangle.dashed").tag(nil as VMWindowState.Device?) - ForEach(session.devices) { device in + ForEach(availableDevices(for: state.id, current: state.device)) { device in switch device { case .serial(_, let index): MenuLabel("Serial \(index): \(session.qemuConfig.serials[index].target.prettyValue)", systemImage: "rectangle.connected.to.line.below").tag(device as VMWindowState.Device?) @@ -55,7 +59,7 @@ struct VMToolbarDisplayMenuView: View { } Picker("", selection: $externalDevice) { MenuLabel("None", systemImage: "rectangle.dashed").tag(nil as VMWindowState.Device?) - ForEach(session.devices) { device in + ForEach(availableDevices(for: externalWindowBinding.wrappedValue.id, current: externalWindowBinding.wrappedValue.device)) { device in switch device { case .serial(_, let index): MenuLabel("Serial \(index): \(session.qemuConfig.serials[index].target.prettyValue)", systemImage: "rectangle.connected.to.line.below").tag(device as VMWindowState.Device?) @@ -68,19 +72,29 @@ struct VMToolbarDisplayMenuView: View { MenuLabel("External Monitor", systemImage: "rectangle.on.rectangle") } } + #if os(visionOS) if supportsMultipleWindows { Divider() Button { - #if os(visionOS) openWindow(value: session.newWindow()) - #else - UIApplication.shared.requestSceneSessionActivation(nil, userActivity: nil, options: nil, errorHandler: nil) - #endif } label: { - MenuLabel("New Window…", systemImage: "plus.rectangle.on.rectangle") + NewWindowLabel() } } - + #else + if #available(iOS 16, *) { + NewWindowMenuItem() + } else if UIApplication.shared.supportsMultipleScenes { + Divider() + Button { + UIApplication.shared.requestSceneSessionActivation(nil, userActivity: nil, options: nil) { error in + state.alert = .nonfatalError(error.localizedDescription) + } + } label: { + NewWindowLabel() + } + } + #endif } label: { Label("Display", systemImage: "rectangle.on.rectangle") }.overlay(Badge(count: session.devices.count), alignment: .topTrailing) @@ -90,6 +104,35 @@ struct VMToolbarDisplayMenuView: View { } } +private struct NewWindowLabel: View { + var body: some View { + MenuLabel("New Window…", systemImage: "plus.rectangle.on.rectangle") + } +} + +#if !os(visionOS) +/// Opens another scene through SwiftUI, which also knows when the system will not allow one. +/// +/// `UIApplication.supportsMultipleScenes` stays true on iPhone Duo even though new scenes can +/// only be created on the inner display, so the item hides itself from the environment instead. +@available(iOS 16, *) +private struct NewWindowMenuItem: View { + @Environment(\.supportsMultipleWindows) private var supportsMultipleWindows + @Environment(\.openWindow) private var openWindow + + var body: some View { + if supportsMultipleWindows { + Divider() + Button { + openWindow(id: UTMApp.windowID) + } label: { + NewWindowLabel() + } + } + } +} +#endif + private struct Badge: View { let count: Int diff --git a/Platform/iOS/VMToolbarView.swift b/Platform/iOS/VMToolbarView.swift index 2d0d58a735..c26e56d1a7 100644 --- a/Platform/iOS/VMToolbarView.swift +++ b/Platform/iOS/VMToolbarView.swift @@ -18,19 +18,33 @@ import SwiftUI import TipKit struct VMToolbarView: View { - @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false + /// Where the toolbar goes when it is laid out along a side of the window. + enum Layout: Equatable { + /// The corner of the window, which the user can drag the toolbar between. + case corner(ToolbarLocation) + /// Top-aligned along one side, next to the system's own vertical controls. + case vertical(HorizontalEdge) + + var isVertical: Bool { + if case .vertical = self { + return true + } else { + return false + } + } + } + @AppStorage("ToolbarLocation") private var location: ToolbarLocation = .topRight + @State private var verticalBarRegion: CGRect? @State private var shake: Bool = true @State private var isMoving: Bool = false - @State private var isIdle: Bool = false @State private var dragOffset: CGSize = .zero - @State private var shortIdleTask: DispatchWorkItem? @State private var isKeyShortcutsShown: Bool = false + @StateObject private var idle = ToolbarIdleState() @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass @EnvironmentObject private var session: VMSessionState - @StateObject private var longIdleTimeout = LongIdleTimeout() @Binding var state: VMWindowState @@ -44,27 +58,22 @@ struct VMToolbarView: View { } } - private var nameOfHideIcon: String { - if location == .topLeft || location == .bottomLeft { - return "chevron.right" - } else { - return "chevron.left" - } - } - - private var nameOfShowIcon: String { - if location == .topLeft || location == .bottomLeft { - return "chevron.left" - } else { - return "chevron.right" + /// The chevron points where the buttons unfold to or fold away towards. + private func showHideIcon(for layout: Layout) -> String { + switch layout { + case .corner(let location): + let isLeft = location == .topLeft || location == .bottomLeft + return state.isToolbarCollapsed == isLeft ? "chevron.right" : "chevron.left" + case .vertical: + return state.isToolbarCollapsed ? "chevron.down" : "chevron.up" } } private var toolbarToggleOpacity: Double { - if state.device != nil && !state.isBusy && state.isRunning && isCollapsed && !isMoving { - if !longIdleTimeout.isUserInteracting { + if state.device != nil && !state.isBusy && state.isRunning && state.isToolbarCollapsed && !isMoving { + if idle.isHidden { return 0 - } else if isIdle { + } else if idle.isDimmed { return 0.4 } else { return 1 @@ -86,171 +95,247 @@ struct VMToolbarView: View { @ViewBuilder var toolbarBody: some View { - toolbarContainer { geometry in - if !isCollapsed { - Group { - Button { - if state.isRunning { - state.alert = .powerDown - } else { - state.alert = .terminateApp - } - } label: { - if state.isRunning { - Label("Power Off", systemImage: "power") - } else { - Label("Force Kill", systemImage: "xmark") - } - }.animationUniqueID("power", in: namespace) - Button { - session.pauseResume() - } label: { - Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play") - }.animationUniqueID("pause", in: namespace) - Button { - state.alert = .restart - } label: { - Label("Restart", systemImage: "restart") - }.animationUniqueID("restart", in: namespace) - Button { - if case .serial(_, _) = state.device { - let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand - state.toggleDisplayResize(command: template) - } else { - state.toggleDisplayResize() - } - } label: { - Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - }.animationUniqueID("resize", in: namespace) - #if WITH_USB - if session.vm.hasUsbRedirection { - VMToolbarUSBMenuView() - .animationUniqueID("usb", in: namespace) - } - #endif - VMToolbarDriveMenuView(config: session.qemuConfig) - .animationUniqueID("drive", in: namespace) - VMToolbarDisplayMenuView(state: $state) - .animationUniqueID("display", in: namespace) - Button { - // ignore if we are showing shortcuts - guard !isKeyShortcutsShown else { - return - } - state.isKeyboardRequested = !state.isKeyboardShown - } label: { - Label("Keyboard", systemImage: "keyboard") - }.animationUniqueID("keyboard", in: namespace) - #if !WITH_REMOTE - .simultaneousGesture( - LongPressGesture().onEnded { _ in - isKeyShortcutsShown.toggle() - } - ) - .sheet(isPresented: $isKeyShortcutsShown) { - VMKeyboardShortcutsView { keys in - session.sendKeys(keys: keys) - } - } - #endif - }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) - .disabled(state.isBusy) + toolbarContainer { geometry, layout in + if layout.isVertical { + showHideButton(geometry: geometry, layout: layout) + if !state.isToolbarCollapsed { + buttons + } + } else { + if !state.isToolbarCollapsed { + buttons + } + showHideButton(geometry: geometry, layout: layout) } + } + .onAppear { + resetIdle() + assertUserInteraction() + if state.isToolbarCollapsed { + withOptionalAnimation(.easeInOut(duration: 1)) { + shake.toggle() + } + } + } + .onChange(of: state.isUserInteracting) { newValue in + assertUserInteraction() + session.activeWindow = state.id + } + } + + @ViewBuilder + private var buttons: some View { + Group { Button { - resetIdle() - longIdleTimeout.assertUserInteraction() - withOptionalAnimation { - isCollapsed.toggle() + if state.isRunning { + state.alert = .powerDown + } else { + state.alert = .terminateApp } } label: { - Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon) - }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) - .animationUniqueID("hide", in: namespace) - .modifier(HideToolbarTipModifier(isCollapsed: $isCollapsed, location: location)) - .opacity(toolbarToggleOpacity) - .modifier(Shake(shake: shake)) - .offset(dragOffset) - .highPriorityGesture( - DragGesture(coordinateSpace: .named("Window")) - .onChanged { value in - withOptionalAnimation { - isCollapsed = true - isMoving = true - dragOffset = value.translation - } - } - .onEnded { value in - withOptionalAnimation { - location = closestLocation(to: value.location, for: geometry) - isMoving = false - dragOffset = .zero - } - resetIdle() - longIdleTimeout.assertUserInteraction() - } + if state.isRunning { + Label("Power Off", systemImage: "power") + } else { + Label("Force Kill", systemImage: "xmark") + } + }.animationUniqueID("power", in: namespace) + Button { + session.pauseResume() + } label: { + Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play") + }.animationUniqueID("pause", in: namespace) + Button { + state.alert = .restart + } label: { + Label("Restart", systemImage: "restart") + }.animationUniqueID("restart", in: namespace) + Button { + if case .serial(_, _) = state.device { + let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand + state.toggleDisplayResize(command: template) + } else { + state.toggleDisplayResize() + } + } label: { + Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + }.animationUniqueID("resize", in: namespace) + #if WITH_USB + if session.vm.hasUsbRedirection { + VMToolbarUSBMenuView() + .animationUniqueID("usb", in: namespace) + } + #endif + VMToolbarDriveMenuView(config: session.qemuConfig) + .animationUniqueID("drive", in: namespace) + VMToolbarDisplayMenuView(state: $state) + .animationUniqueID("display", in: namespace) + Button { + // ignore if we are showing shortcuts + guard !isKeyShortcutsShown else { + return + } + if state.inputDeckFrame != nil { + state.cycleInputDeck() + } else { + state.isKeyboardRequested = !state.isKeyboardShown + } + } label: { + Label("Keyboard", systemImage: "keyboard") + }.animationUniqueID("keyboard", in: namespace) + #if !WITH_REMOTE + .simultaneousGesture( + LongPressGesture().onEnded { _ in + isKeyShortcutsShown.toggle() + } ) - .onAppear { - resetIdle() - longIdleTimeout.assertUserInteraction() - if isCollapsed { - withOptionalAnimation(.easeInOut(duration: 1)) { - shake.toggle() - } + .sheet(isPresented: $isKeyShortcutsShown) { + VMKeyboardShortcutsView { keys in + session.sendKeys(keys: keys) } } - .onChange(of: state.isUserInteracting) { newValue in - longIdleTimeout.assertUserInteraction() - session.activeWindow = state.id + #endif + }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) + .disabled(state.isBusy) + } + + /// The vertical layout is pinned next to the system's controls, so only the corner layout can be dragged. + private func showHideButton(geometry: GeometryProxy, layout: Layout) -> some View { + Button { + resetIdle() + assertUserInteraction() + withOptionalAnimation { + state.toggleToolbarCollapsed() } + } label: { + Label("Hide", systemImage: showHideIcon(for: layout)) + }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) + .animationUniqueID("hide", in: namespace) + .modifier(HideToolbarTipModifier(isCollapsed: state.isToolbarCollapsed, layout: layout)) + .opacity(toolbarToggleOpacity) + .modifier(Shake(shake: shake)) + .offset(dragOffset) + .highPriorityGesture( + DragGesture(coordinateSpace: .named("Window")) + .onChanged { value in + withOptionalAnimation { + state.setToolbarCollapsed(true) + isMoving = true + dragOffset = value.translation + } + } + .onEnded { value in + withOptionalAnimation { + location = closestLocation(to: value.location, for: geometry) + isMoving = false + dragOffset = .zero + } + resetIdle() + assertUserInteraction() + }, + including: layout.isVertical ? .subviews : .all + ) + } + + /// The closed iPhone Duo reserves a vertical strip on one side of the window for the status bar and + /// camera, and Apple puts controls in a column next to it. Other phones inset both sides equally in + /// landscape and neither in portrait, so an inset on one side only is what tells the strip apart. + /// + /// The reader stays inside the safe area, which is what makes it report the insets around it. + private func layout(for geometry: GeometryProxy) -> Layout { + let insets = geometry.safeAreaInsets + guard horizontalSizeClass == .compact, abs(insets.leading - insets.trailing) >= 44 else { + return .corner(location) } + return .vertical(insets.trailing > insets.leading ? .trailing : .leading) } @ViewBuilder - private func toolbarContainer(@ViewBuilder body: @escaping (GeometryProxy) -> Content) -> some View { + private func toolbarContainer(@ViewBuilder body: @escaping (GeometryProxy, Layout) -> Content) -> some View { GeometryReader { geometry in - switch location { - case .topRight: - VStack(alignment: .trailing) { - HStack(alignment: .top, spacing: spacing) { + let layout = layout(for: geometry) + Group { + switch layout { + case .vertical(let edge): + if let region = verticalBarRegion { + // inside the strip, where the system puts its own bars + VStack(alignment: .center, spacing: spacing) { + body(geometry, layout) + Spacer() + }.frame(width: region.width, height: region.height) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .offset(x: region.minX - geometry.safeAreaInsets.leading, y: region.minY - geometry.safeAreaInsets.top) + } else { + HStack(alignment: .top) { + if edge == .trailing { + Spacer() + } + VStack(alignment: .center, spacing: spacing) { + body(geometry, layout) + Spacer() + }.padding(.top) + if edge == .leading { + Spacer() + } + }.padding(edge == .trailing ? .trailing : .leading) + } + case .corner(.topRight): + VStack(alignment: .trailing) { + HStack(alignment: .top, spacing: spacing) { + Spacer() + body(geometry, layout) + }.padding(.trailing) Spacer() - body(geometry) - }.padding(.trailing) - Spacer() - }.padding(.top) - case .bottomRight: - VStack(alignment: .trailing) { - Spacer() - HStack(alignment: .bottom, spacing: spacing) { + }.padding(.top) + case .corner(.bottomRight): + VStack(alignment: .trailing) { Spacer() - body(geometry) - }.padding(.trailing) - }.padding(.bottom) - case .topLeft: - VStack(alignment: .leading) { - HStack(alignment: .top, spacing: spacing) { - body(geometry) + HStack(alignment: .bottom, spacing: spacing) { + Spacer() + body(geometry, layout) + }.padding(.trailing) + }.padding(.bottom) + .padding(.bottom, bottomInset(for: geometry)) + case .corner(.topLeft): + VStack(alignment: .leading) { + HStack(alignment: .top, spacing: spacing) { + body(geometry, layout) + Spacer() + }.padding(.leading) Spacer() - }.padding(.leading) - Spacer() - }.padding(.top) - case .bottomLeft: - VStack(alignment: .leading) { - Spacer() - HStack(alignment: .bottom, spacing: spacing) { - body(geometry) + }.padding(.top) + case .corner(.bottomLeft): + VStack(alignment: .leading) { Spacer() - }.padding(.leading) - }.padding(.bottom) + HStack(alignment: .bottom, spacing: spacing) { + body(geometry, layout) + Spacer() + }.padding(.leading) + }.padding(.bottom) + .padding(.bottom, bottomInset(for: geometry)) + } } + .background(verticalBarRegionReader(for: layout)) }.coordinateSpace(name: "Window") } - private func withOptionalAnimation(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result { - if UIAccessibility.isReduceMotionEnabled { - return try body() - } else { - return try withAnimation(animation, body) + /// Keeps a bottom corner above the input deck. + /// + /// The padding is applied inside the reader so that it does not become a minimum height of the + /// window: SwiftUI measures the window against the part above the keyboard, and a window that + /// does not fit there is centred over it. + private func bottomInset(for geometry: GeometryProxy) -> CGFloat { + state.toolbarBottomInset(toolbarBottom: geometry.frame(in: .global).maxY) + } + + /// Reads the region from the window, since the strip lies outside the safe area the toolbar is laid out in. + @ViewBuilder + private func verticalBarRegionReader(for layout: Layout) -> some View { + #if canImport(SwiftUI, _version: 8.0.85) + if #available(iOS 27.1, *), case .vertical(let edge) = layout { + VerticalBarRegionReader(edge: edge, extent: 44, region: $verticalBarRegion) + .ignoresSafeArea() } + #endif } private func closestLocation(to point: CGPoint, for geometry: GeometryProxy) -> ToolbarLocation { @@ -266,20 +351,114 @@ struct VMToolbarView: View { } private func resetIdle() { - if let task = shortIdleTask { - task.cancel() + idle.resetDimming() + } + + private func assertUserInteraction() { + idle.resetHiding() + } +} + +/// Animates unless the user has asked for reduced motion. +private func withOptionalAnimation(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result { + if UIAccessibility.isReduceMotionEnabled { + return try body() + } else { + return try withAnimation(animation, body) + } +} + +/// Idle timers of the toolbar, kept apart from the window state so that they do not update the whole window. +@MainActor private final class ToolbarIdleState: ObservableObject { + /// The show/hide button is dimmed after a short time without interaction. + @Published private(set) var isDimmed: Bool = false + + /// The collapsed toolbar is faded out after a long time without interaction, until the next touch. + @Published private(set) var isHidden: Bool = false + + private var dimTask: DispatchWorkItem? + private var hideTask: DispatchWorkItem? + + func resetDimming() { + dimTask?.cancel() + isDimmed = false + dimTask = schedule(after: 5) { $0.isDimmed = true } + } + + func resetHiding() { + hideTask?.cancel() + withOptionalAnimation { + isHidden = false } - self.isIdle = false - shortIdleTask = DispatchWorkItem { - self.shortIdleTask = nil + hideTask = schedule(after: 15) { $0.isHidden = true } + } + + private func schedule(after seconds: TimeInterval, _ change: @escaping (ToolbarIdleState) -> Void) -> DispatchWorkItem { + let task = DispatchWorkItem { [weak self] in + guard let self = self else { + return + } withOptionalAnimation { - self.isIdle = true + change(self) } } - DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: shortIdleTask!) + DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: task) + return task } } +#if canImport(SwiftUI, _version: 8.0.85) +/// Where the system would lay out a vertical bar of the given width on an edge of the window. +/// +/// On the closed iPhone Duo that is a column inside the strip, below the camera. +@available(iOS 27.1, *) +private struct VerticalBarRegionReader: UIViewRepresentable { + let edge: HorizontalEdge + let extent: CGFloat + @Binding var region: CGRect? + + final class RegionView: UIView { + var edge: NSDirectionalRectEdge = .trailing + var extent: CGFloat = 44 + var onChange: ((CGRect) -> Void)? + private var lastFrame: CGRect? + + override func layoutSubviews() { + super.layoutSubviews() + let frame = layoutGuide(for: .bar(onEdge: edge, extent: extent)).layoutFrame + if frame != lastFrame { + lastFrame = frame + onChange?(frame) + } + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + setNeedsLayout() + } + } + + func makeUIView(context: Context) -> RegionView { + let view = RegionView() + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ view: RegionView, context: Context) { + view.edge = edge == .trailing ? .trailing : .leading + view.extent = extent + view.onChange = { frame in + DispatchQueue.main.async { + if region != frame { + region = frame + } + } + } + view.setNeedsLayout() + } +} +#endif + enum ToolbarLocation: Int { case topRight case bottomRight @@ -447,37 +626,9 @@ extension MenuStyle where Self == ToolbarMenuStyle { } } -@MainActor private class LongIdleTimeout: ObservableObject { - private var longIdleTask: DispatchWorkItem? - - @Published var isUserInteracting: Bool = true - - private func setIsUserInteracting(_ value: Bool) { - if !UIAccessibility.isReduceMotionEnabled { - withAnimation { - self.isUserInteracting = value - } - } else { - self.isUserInteracting = value - } - } - - func assertUserInteraction() { - if let task = longIdleTask { - task.cancel() - } - setIsUserInteracting(true) - longIdleTask = DispatchWorkItem { - self.longIdleTask = nil - self.setIsUserInteracting(false) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!) - } -} - private struct HideToolbarTipModifier: ViewModifier { - @Binding var isCollapsed: Bool - let location: ToolbarLocation + let isCollapsed: Bool + let layout: VMToolbarView.Layout private let _hideToolbarTip: Any? /// Keeps the tip on the side of the toolbar facing the middle of the screen. @@ -485,9 +636,11 @@ private struct HideToolbarTipModifier: ViewModifier { /// From iOS 26 a single arrow edge is only a preference, and when the toolbar spans most of /// the width the system attaches the tip beside the button instead, covering the toolbar. private var arrowEdges: Edge.Set { - switch location { - case .topLeft, .topRight: return .top - case .bottomLeft, .bottomRight: return .bottom + switch layout { + case .corner(.topLeft), .corner(.topRight): return .top + case .corner(.bottomLeft), .corner(.bottomRight): return .bottom + case .vertical(.leading): return .leading + case .vertical(.trailing): return .trailing } } @@ -496,9 +649,9 @@ private struct HideToolbarTipModifier: ViewModifier { _hideToolbarTip as! UTMTipHideToolbar } - init(isCollapsed: Binding, location: ToolbarLocation) { - _isCollapsed = isCollapsed - self.location = location + init(isCollapsed: Bool, layout: VMToolbarView.Layout) { + self.isCollapsed = isCollapsed + self.layout = layout if #available(iOS 17, *) { _hideToolbarTip = UTMTipHideToolbar() } else { diff --git a/Platform/iOS/VMWindowState.swift b/Platform/iOS/VMWindowState.swift index 515dd66b4b..a5d7dc053a 100644 --- a/Platform/iOS/VMWindowState.swift +++ b/Platform/iOS/VMWindowState.swift @@ -73,6 +73,130 @@ struct VMWindowState: Identifiable { var alert: Alert? var isDynamicResolutionSupported: Bool = false + + // MARK: Toolbar + + /// Only the show/hide button is visible. + /// + /// Read from the defaults once so the window keeps its own state while the choice persists. + var isToolbarCollapsed: Bool = UserDefaults.standard.bool(forKey: "ToolbarIsCollapsed") + + // MARK: Input deck + + struct InputDeckLayout: Equatable { + /// The part below the fold, in window coordinates. + var frame: CGRect + /// The fold itself, which neither half uses for controls. + var fold: CGRect + } + + /// The bottom half of a device folded like a laptop, while it is folded so. + var inputDeckLayout: InputDeckLayout? + + var inputDeckFrame: CGRect? { + inputDeckLayout?.frame + } + + /// What the bottom half shows, remembered for the next time the device is folded. + var inputDeck: VMInputDeck = .keyboard + + /// The zoom lock from before the device was folded, restored when it is opened again. + var zoomLockBeforeDeck: Bool? + + /// Whether the keyboard was shown before the device was folded, which is what the window remembers. + var keyboardVisibleBeforeDeck: Bool? + + /// Height reserved for the accessory row at the bottom of the display half while folded. + var deckAccessoryHeight: CGFloat = 0 + + /// How far a toolbar ending at the given window position must move up to clear the fold and accessory row. + /// + /// The keyboard is left to the system, which keeps the toolbar above it. + func toolbarBottomInset(toolbarBottom: CGFloat) -> CGFloat { + guard let layout = inputDeckLayout else { + return 0 + } + return max(0, toolbarBottom - (layout.fold.minY - deckAccessoryHeight)) + } + + /// Only the guest display has a touchpad, so other devices keep the keyboard in the deck. + var showsTouchpadDeck: Bool { + guard inputDeckLayout != nil, inputDeck == .touchpad, case .display = device else { + return false + } + return true + } +} + +// MARK: - Toolbar + +extension VMWindowState { + mutating func toggleToolbarCollapsed() { + setToolbarCollapsed(!isToolbarCollapsed) + } + + mutating func setToolbarCollapsed(_ collapsed: Bool) { + isToolbarCollapsed = collapsed + UserDefaults.standard.set(collapsed, forKey: "ToolbarIsCollapsed") + } +} + +// MARK: - Input deck + +extension VMWindowState { + /// Called when the device is folded like a laptop or opened up again. + mutating func setInputDeck(_ layout: InputDeckLayout?) { + guard layout != inputDeckLayout else { + return + } + inputDeckLayout = layout + if layout != nil { + // the display half is small, so it always shows the whole guest display + if zoomLockBeforeDeck == nil { + zoomLockBeforeDeck = isDisplayZoomLocked + } + if keyboardVisibleBeforeDeck == nil { + keyboardVisibleBeforeDeck = isKeyboardShown + } + isDisplayZoomLocked = true + } else { + if let zoomLock = zoomLockBeforeDeck { + isDisplayZoomLocked = zoomLock + } + if let keyboardVisible = keyboardVisibleBeforeDeck { + isKeyboardRequested = keyboardVisible + } + zoomLockBeforeDeck = nil + keyboardVisibleBeforeDeck = nil + } + } + + /// The keyboard for a freshly folded device is asked for once the pose has settled, since a + /// keyboard shown while the hinge is still moving is dismissed again by the system. + var wantsDeckKeyboard: Bool { + inputDeckLayout != nil && !showsTouchpadDeck && !isKeyboardShown + } + + mutating func requestInputDeck(_ deck: VMInputDeck) { + inputDeck = deck + isKeyboardRequested = deck == .keyboard + } + + /// The keyboard button steps from the keyboard to the touchpad and back while the device is folded. + mutating func cycleInputDeck() { + guard case .display = device else { + isKeyboardRequested = !isKeyboardShown + return + } + switch inputDeck { + case .keyboard where isKeyboardShown: + requestInputDeck(.touchpad) + case .keyboard: + isKeyboardRequested = true + case .touchpad: + requestInputDeck(.keyboard) + } + } } // MARK: - VM action alerts @@ -167,9 +291,9 @@ extension VMWindowState { window.scale = displayScale #if !os(visionOS) window.origin = displayOrigin - window.isDisplayZoomLocked = isDisplayZoomLocked + window.isDisplayZoomLocked = zoomLockBeforeDeck ?? isDisplayZoomLocked #endif - window.isKeyboardVisible = isKeyboardShown + window.isKeyboardVisible = keyboardVisibleBeforeDeck ?? isKeyboardShown registryEntry.windowSettings[id] = window } @@ -183,8 +307,16 @@ extension VMWindowState { isDisplayZoomLocked = true #else displayOrigin = window.origin - isDisplayZoomLocked = window.isDisplayZoomLocked - isKeyboardRequested = window.isKeyboardVisible + if inputDeckLayout != nil { + // the forced fit and keyboard stay while folded and the window's own choices apply once opened + zoomLockBeforeDeck = window.isDisplayZoomLocked + keyboardVisibleBeforeDeck = window.isKeyboardVisible + isDisplayZoomLocked = true + isKeyboardRequested = !showsTouchpadDeck + } else { + isDisplayZoomLocked = window.isDisplayZoomLocked + isKeyboardRequested = window.isKeyboardVisible + } #endif if isDisplayZoomLocked { resizeDisplayToFit(display) diff --git a/Platform/iOS/VMWindowView.swift b/Platform/iOS/VMWindowView.swift index 6701657d77..fc4e22bdb5 100644 --- a/Platform/iOS/VMWindowView.swift +++ b/Platform/iOS/VMWindowView.swift @@ -98,8 +98,10 @@ struct VMWindowView: View { .font(.system(size: 128)) .vibrancyEffect() .vibrancyEffectStyle(.label) + .clearOfReservedDivisions() } }.background(Color.black) + .modifier(InputDeckModifier(state: $state)) .ignoresSafeArea() #if !os(visionOS) if isInteractive && state.isRunning { @@ -186,7 +188,12 @@ struct VMWindowView: View { .onChange(of: session.isDynamicResolutionSupported) { newValue in state.isDynamicResolutionSupported = newValue } - .onReceive(keyboardDidShowNotification) { _ in + .onReceive(keyboardDidShowNotification) { notification in + // a keyboard placed off the screen, which the system does while moving it between the + // panels of a folding device, is not one the user can type on + guard notification.isKeyboardOnScreen else { + return + } state.isKeyboardShown = true state.isKeyboardRequested = true } @@ -298,7 +305,201 @@ private struct HeadlessView: View { .clipShape(RoundedRectangle(cornerRadius: 25.0, style: .continuous)) .vibrancyEffect() .vibrancyEffectStyle(.label) + .clearOfReservedDivisions() + } + } +} + +// MARK: - Folding devices + +/// Gives the bottom half of a device folded like a laptop to the keyboard or touchpad. +/// +/// The trigger is the fold itself, reported as an active horizontal division, since Apple keeps the +/// hinge angle for effects and the reserved regions for layout. +private struct InputDeckModifier: ViewModifier { + @Binding var state: VMWindowState + @State private var hingeGeneration = 0 + @State private var hingeSettling = HingeSettling() + @State private var keyboardRequest = DeferredWork() + + private func requestDeckKeyboard() { + keyboardRequest.schedule(after: [0.8, 2.5]) { + if state.wantsDeckKeyboard { + state.isKeyboardRequested = true + } + } + } + + func body(content: Content) -> some View { + #if !os(visionOS) && canImport(SwiftUI, _version: 8.0.85) + if #available(iOS 27.1, *) { + content.background(GeometryReader { proxy in + let deck = proxy.inputDeck(hingeGeneration: hingeGeneration) + Color.clear + .onAppear { + state.setInputDeck(deck) + requestDeckKeyboard() + } + .onChange(of: deck) { oldValue, newValue in + state.setInputDeck(newValue) + // only a fresh fold asks for the keyboard, so that one the user dismissed stays away + if oldValue == nil && newValue != nil { + requestDeckKeyboard() + } + } + .onChange(of: state.isKeyboardShown) { _, isShown in + // once the keyboard has shown, dismissing it is the user's choice + if isShown { + keyboardRequest.cancel() + } + } + }) + .environment(\.hingeGeneration, hingeGeneration) + .onHingeChange { _, _ in + hingeSettling.refresh($hingeGeneration) + } + } else { + content + } + #else + content + #endif + } +} + +/// Work scheduled for later that can still be called off. +private final class DeferredWork { + private var pending: [DispatchWorkItem] = [] + + func schedule(after delays: [TimeInterval], _ work: @escaping () -> Void) { + cancel() + pending = delays.map { delay in + let item = DispatchWorkItem(block: work) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + return item + } + } + + func cancel() { + pending.forEach { $0.cancel() } + pending = [] + } +} + +/// Re-evaluates the region readers after a hinge change. +/// +/// A fold becoming active does not change the window's size, and it becomes active a moment +/// after the last hinge event, so the readers are asked again a few times after the hinge stops. +/// The window's input deck owns the timers and passes the count down to the other readers. +private final class HingeSettling { + private static let delays: [TimeInterval] = [0, 0.5, 1.5, 3] + private var pending: [DispatchWorkItem] = [] + + func refresh(_ generation: Binding) { + pending.forEach { $0.cancel() } + pending = Self.delays.map { delay in + let item = DispatchWorkItem { + generation.wrappedValue += 1 + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + return item + } + } +} + +#if !os(visionOS) && canImport(SwiftUI, _version: 8.0.85) +@available(iOS 27.1, *) +extension GeometryProxy { + /// The part below a horizontal fold, where a device folded like a laptop lies flat. + /// + /// The readers pass a value that changes as the hinge settles so they are evaluated again. + func inputDeck(hingeGeneration: Int) -> VMWindowState.InputDeckLayout? { + let fold = reservedRegions(kind: .division).first { $0.isActive && $0.frame.width > $0.frame.height } + guard let fold = fold else { + return nil + } + // layout passes differ in the last bits, which must not read as a new deck every time + let foldFrame = fold.frame.roundedToQuarterPoints + let frame = CGRect(x: 0, y: foldFrame.maxY, width: size.width, height: max(0, size.height - foldFrame.maxY)).roundedToQuarterPoints + return VMWindowState.InputDeckLayout(frame: frame, fold: foldFrame) + } + + /// The largest part of the view that no active fold runs through, or all of it. + func frameClearOfDivisions(hingeGeneration: Int) -> CGRect { + var frame = CGRect(origin: .zero, size: size) + for region in reservedRegions(kind: .division) where region.isActive { + let division = region.frame + let parts = [ + frame.part(maxY: division.minY), + frame.part(minY: division.maxY), + frame.part(maxX: division.minX), + frame.part(minX: division.maxX), + ] + frame = parts.max { $0.width * $0.height < $1.width * $1.height }! } + return frame + } +} + +private extension CGRect { + var roundedToQuarterPoints: CGRect { + func round(_ value: CGFloat) -> CGFloat { + (value * 4).rounded() / 4 + } + return CGRect(x: round(minX), y: round(minY), width: round(width), height: round(height)) + } + + /// The rectangle cut down to the given edges, empty when nothing is left. + func part(minX: CGFloat? = nil, minY: CGFloat? = nil, maxX: CGFloat? = nil, maxY: CGFloat? = nil) -> CGRect { + let left = max(self.minX, minX ?? self.minX) + let top = max(self.minY, minY ?? self.minY) + let right = min(self.maxX, maxX ?? self.maxX) + let bottom = min(self.maxY, maxY ?? self.maxY) + return CGRect(x: left, y: top, width: max(0, right - left), height: max(0, bottom - top)) + } +} +#endif + +/// Keeps centred interactive content out of the fold of a partially folded device. +/// +/// Alerts, menus and sheets move out of the fold by themselves; a control centred in the +/// window does not. +private struct ClearOfReservedDivisionsModifier: ViewModifier { + @Environment(\.hingeGeneration) private var hingeGeneration + + func body(content: Content) -> some View { + #if !os(visionOS) && canImport(SwiftUI, _version: 8.0.85) + if #available(iOS 27.1, *) { + GeometryReader { proxy in + let frame = proxy.frameClearOfDivisions(hingeGeneration: hingeGeneration) + content + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + } + } else { + content + } + #else + content + #endif + } +} + +private struct HingeGenerationKey: EnvironmentKey { + static let defaultValue = 0 +} + +private extension EnvironmentValues { + /// Changes while the hinge of a folding device settles, so that readers of reserved regions are evaluated again. + var hingeGeneration: Int { + get { self[HingeGenerationKey.self] } + set { self[HingeGenerationKey.self] = newValue } + } +} + +private extension View { + func clearOfReservedDivisions() -> some View { + modifier(ClearOfReservedDivisionsModifier()) } } @@ -312,6 +513,27 @@ fileprivate struct VMToolbarOrnamentModifier: ViewModifier { } #endif +#if !os(visionOS) +private extension Notification { + /// Whether the keyboard in a keyboard notification ends up on the screen it was posted for. + var isKeyboardOnScreen: Bool { + guard let frame = userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return true + } + guard let screen = object as? UIScreen else { + return true + } + return frame.intersection(screen.bounds).height > 0 + } +} +#else +private extension Notification { + var isKeyboardOnScreen: Bool { + true + } +} +#endif + private extension View { /// A touch that starts at a screen edge is otherwise held back for the system and never reaches the guest func defersSystemGesturesOnAllEdges() -> some View { diff --git a/Services/Swift-Bridging-Header.h b/Services/Swift-Bridging-Header.h index a78f2f77ce..f51ae1eefd 100644 --- a/Services/Swift-Bridging-Header.h +++ b/Services/Swift-Bridging-Header.h @@ -47,6 +47,7 @@ //#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION #include "VMDisplayMetalViewController.h" #include "VMDisplayMetalViewController+Keyboard.h" +#include "VMDisplayMetalViewController+Touch.h" //#endif #include "VMKeyboardView.h" #elif TARGET_OS_OSX diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index d72c01b849..55edf1ae03 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -502,6 +502,7 @@ CE2D92E624AD46670059923A /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; }; CE2D92EB24AD46670059923A /* UTMLocationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = CE059DC7243E9E3400338317 /* UTMLocationManager.m */; platformFilter = ios; }; CE2D92F224AD46670059923A /* VMKeyboardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEB66452284B942002737B2 /* VMKeyboardButton.swift */; }; + F2C573FB05F34EC584B58E73 /* VMTouchpadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB9E02516884021BE006A10 /* VMTouchpadView.swift */; }; CE2D92FC24AD46670059923A /* VMDisplayMetalViewController+Keyboard.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD66240EFBCA002D6A5F /* VMDisplayMetalViewController+Keyboard.m */; }; CE2D930424AD46670059923A /* UTMLegacyQemuConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = CE31C244225E555600A965DD /* UTMLegacyQemuConfiguration.m */; }; CE2D930524AD46670059923A /* VMDisplayMetalViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC94243748C3007E6CBC /* VMDisplayMetalViewController.m */; }; @@ -819,6 +820,7 @@ CEA45ECF263519B5002FA97D /* VMConfigPortForwardForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D955224AD4F980059923A /* VMConfigPortForwardForm.swift */; }; CEA45ED3263519B5002FA97D /* VMSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954C24AD4F980059923A /* VMSettingsView.swift */; }; CEA45ED8263519B5002FA97D /* VMKeyboardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEB66452284B942002737B2 /* VMKeyboardButton.swift */; }; + 106D3764B55E45AC93C0EB34 /* VMTouchpadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB9E02516884021BE006A10 /* VMTouchpadView.swift */; }; CEA45EE8263519B5002FA97D /* VMDisplayMetalViewController+Keyboard.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD66240EFBCA002D6A5F /* VMDisplayMetalViewController+Keyboard.m */; }; CEA45EEA263519B5002FA97D /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; }; CEA45EEC263519B5002FA97D /* UTMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE020BA224AEDC7C00B44AB6 /* UTMData.swift */; }; @@ -1164,6 +1166,7 @@ CEF7F5FC2AEEDCC400E34952 /* VMWizardStartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF0305926A2AFDE00667B63 /* VMWizardStartView.swift */; }; CEF7F5FD2AEEDCC400E34952 /* QEMUConstantGenerated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843BF82728441FAF0029D60D /* QEMUConstantGenerated.swift */; }; CEF7F5FE2AEEDCC400E34952 /* VMKeyboardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEB66452284B942002737B2 /* VMKeyboardButton.swift */; }; + E1376DB5705C4E9389EC6326 /* VMTouchpadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB9E02516884021BE006A10 /* VMTouchpadView.swift */; }; CEF7F5FF2AEEDCC400E34952 /* UTMDownloadVMTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B36D2427B704C200C22685 /* UTMDownloadVMTask.swift */; }; CEF7F6002AEEDCC400E34952 /* GlobalFileImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8432329728C3017F00CFBC97 /* GlobalFileImporter.swift */; }; CEF7F6022AEEDCC400E34952 /* VMWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C2E8642AA429E800B17308 /* VMWizardContent.swift */; }; @@ -2254,6 +2257,7 @@ CEE7ED472A90256100E6B4AB /* VMDisplayMetalViewController+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "VMDisplayMetalViewController+Private.h"; sourceTree = ""; }; CEE8B4C12B71DF4C0035AE86 /* UTMQemuSystemBackends.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuSystemBackends.h; sourceTree = ""; }; CEEB66452284B942002737B2 /* VMKeyboardButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardButton.swift; sourceTree = ""; }; + 6EB9E02516884021BE006A10 /* VMTouchpadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTouchpadView.swift; sourceTree = ""; }; CEEC811A24E48EC600ACB0B3 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; CEEF26A62CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadMacSupportToolsTask.swift; sourceTree = ""; }; @@ -3169,6 +3173,7 @@ 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */, DB38A22C8D69DB2D2C64107F /* VMKeyboardAccessoryView.swift */, CEEB66452284B942002737B2 /* VMKeyboardButton.swift */, + 6EB9E02516884021BE006A10 /* VMTouchpadView.swift */, CE4507D0226A5BE200A28D22 /* VMKeyboardView.h */, CE4507D1226A5BE200A28D22 /* VMKeyboardView.m */, CE4507D3226A5C9900A28D22 /* VMKeyboardViewDelegate.h */, @@ -4130,6 +4135,7 @@ 843BF82828441FAF0029D60D /* QEMUConstantGenerated.swift in Sources */, FD2E48FCF9EC5A44E0A35855 /* VMKeyboardAccessoryView.swift in Sources */, CE2D92F224AD46670059923A /* VMKeyboardButton.swift in Sources */, + F2C573FB05F34EC584B58E73 /* VMTouchpadView.swift in Sources */, 84B36D2527B704C200C22685 /* UTMDownloadVMTask.swift in Sources */, 8432329828C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */, CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */, @@ -4559,6 +4565,7 @@ 83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */, 9DFF2F0A05DA5A4B5FA35917 /* VMKeyboardAccessoryView.swift in Sources */, CEA45ED8263519B5002FA97D /* VMKeyboardButton.swift in Sources */, + 106D3764B55E45AC93C0EB34 /* VMTouchpadView.swift in Sources */, CEF0307526A2B40B00667B63 /* VMWizardHardwareView.swift in Sources */, CE8011212AD4E9E8009001C2 /* UTMApp.swift in Sources */, CE611BEC29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */, @@ -4729,6 +4736,7 @@ CEF7F5FD2AEEDCC400E34952 /* QEMUConstantGenerated.swift in Sources */, 2A3EFF2D980175F4D531E0E3 /* VMKeyboardAccessoryView.swift in Sources */, CEF7F5FE2AEEDCC400E34952 /* VMKeyboardButton.swift in Sources */, + E1376DB5705C4E9389EC6326 /* VMTouchpadView.swift in Sources */, CEF7F5FF2AEEDCC400E34952 /* UTMDownloadVMTask.swift in Sources */, CEF7F6002AEEDCC400E34952 /* GlobalFileImporter.swift in Sources */, CEF7F6022AEEDCC400E34952 /* VMWizardContent.swift in Sources */,