diff --git a/Platform/Shared/VMConfigInfoView.swift b/Platform/Shared/VMConfigInfoView.swift index 2a867c21..e23d5625 100644 --- a/Platform/Shared/VMConfigInfoView.swift +++ b/Platform/Shared/VMConfigInfoView.swift @@ -89,11 +89,12 @@ struct VMConfigInfoView: View { } private var notesField: some View { + #if os(macOS) + MultilineTextField(text: $config.notes.bound, minHeight: 200) + #else TextEditor(text: $config.notes.bound) - #if os(macOS) - .border(Color.primary, width: 0.5) - #endif .frame(minHeight: 200) + #endif } @ViewBuilder diff --git a/Platform/macOS/MultilineTextField.swift b/Platform/macOS/MultilineTextField.swift new file mode 100644 index 00000000..d555b946 --- /dev/null +++ b/Platform/macOS/MultilineTextField.swift @@ -0,0 +1,121 @@ +// +// 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. +// + +import SwiftUI + +/// A bezeled, multi-line text field that grows with its contents. +/// +/// `TextEditor` has no bezel and `TextField(axis: .vertical)` submits on Return, +/// so this wraps an `NSTextField` and lets AppKit draw the same bezel and focus +/// ring as a single-line `TextField`. +struct MultilineTextField: NSViewRepresentable { + @Binding var text: String + var minHeight: CGFloat = 0 + + func makeNSView(context: Context) -> WrappingTextField { + let field = WrappingTextField() + field.minHeight = minHeight + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 0 + field.cell?.wraps = true + field.cell?.isScrollable = false + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + field.delegate = context.coordinator + return field + } + + func updateNSView(_ field: WrappingTextField, context: Context) { + context.coordinator.parent = self + field.disableAutocorrection = context.environment.disableAutocorrection + if field.minHeight != minHeight { + field.minHeight = minHeight + field.invalidateIntrinsicContentSize() + } + if field.stringValue != text { + field.stringValue = text + } + } + + @available(macOS 13, *) + func sizeThatFits(_ proposal: ProposedViewSize, nsView field: WrappingTextField, context: Context) -> CGSize? { + guard let width = proposal.width, width.isFinite, let cell = field.cell else { + return nil + } + guard width > 0 else { + return CGSize(width: 0, height: minHeight) + } + let bounds = NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude) + return CGSize(width: width, height: max(cell.cellSize(forBounds: bounds).height, minHeight)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: MultilineTextField + + init(_ parent: MultilineTextField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { + return + } + parent.text = field.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + textView.insertNewlineIgnoringFieldEditor(nil) + return true + } + return false + } + } + + /// Wraps at the current width so the intrinsic height follows the text on macOS 12, + /// where SwiftUI sizes the view from `intrinsicContentSize`. + class WrappingTextField: NSTextField { + var minHeight: CGFloat = 0 + /// SwiftUI's `disableAutocorrection` does not reach the field editor, so it is applied by hand. + var disableAutocorrection: Bool? + + override var intrinsicContentSize: NSSize { + var size = super.intrinsicContentSize + size.height = max(size.height, minHeight) + return size + } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result, let textView = currentEditor() as? NSTextView { + // the field editor is shared by the window, so always set it + textView.isAutomaticSpellingCorrectionEnabled = disableAutocorrection != true && NSSpellChecker.isAutomaticSpellingCorrectionEnabled + } + return result + } + + override func layout() { + if preferredMaxLayoutWidth != bounds.width { + preferredMaxLayoutWidth = bounds.width + invalidateIntrinsicContentSize() + } + super.layout() + } + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index b21d37bd..9ec7c8e4 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -268,6 +268,7 @@ 84C584E1268E95B3000FCABF /* UTMLegacyAppleConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584DE268E95B3000FCABF /* UTMLegacyAppleConfiguration.swift */; }; 84C584E3268F8AE7000FCABF /* VMQEMUSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */; }; 84C584E5268F8C65000FCABF /* VMAppleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */; }; + CE7B3A112EA1B00000C0FFEE /* MultilineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7B3A102EA1B00000C0FFEE /* MultilineTextField.swift */; }; 84C584EB268FA6D1000FCABF /* VMConfigAppleSystemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */; }; 84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; platformFilter = ios; }; 84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; platformFilter = ios; }; @@ -1866,6 +1867,7 @@ 84C584DE268E95B3000FCABF /* UTMLegacyAppleConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMLegacyAppleConfiguration.swift; sourceTree = ""; }; 84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMQEMUSettingsView.swift; sourceTree = ""; }; 84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsView.swift; sourceTree = ""; }; + CE7B3A102EA1B00000C0FFEE /* MultilineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextField.swift; sourceTree = ""; }; 84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleSystemView.swift; sourceTree = ""; }; 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarView.swift; sourceTree = ""; }; 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayViewController.swift; sourceTree = ""; }; @@ -2846,6 +2848,7 @@ 84A381A9268CB30C0048EE4D /* VMDrivesSettingsView.swift */, CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */, 84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */, + CE7B3A102EA1B00000C0FFEE /* MultilineTextField.swift */, 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */, 846F8D572E3850620037162B /* VMKeyboardShortcutsView.swift */, 84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */, @@ -4136,6 +4139,7 @@ CE25125129C806AF000790AB /* UTMScriptingDeleteCommand.swift in Sources */, CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */, 84C584E5268F8C65000FCABF /* VMAppleSettingsView.swift in Sources */, + CE7B3A112EA1B00000C0FFEE /* MultilineTextField.swift in Sources */, CE9B15442B11A74E003A32DD /* UTMRemoteKeyManager.swift in Sources */, 84F746BB276FF70700A20C87 /* VMDisplayQemuDisplayController.swift in Sources */, CE772AB425C8B7B500E4E379 /* VMCommands.swift in Sources */,