Merge pull request #7906 from utmapp/settings/notes-field

settings: use a bezeled multi-line text field for notes on macOS
This commit is contained in:
osy
2026-09-23 09:33:12 -05:00
committed by GitHub
3 changed files with 129 additions and 3 deletions
+4 -3
View File
@@ -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
+121
View File
@@ -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()
}
}
}
+4
View File
@@ -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; };
@@ -1873,6 +1874,7 @@
84C584DE268E95B3000FCABF /* UTMLegacyAppleConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMLegacyAppleConfiguration.swift; sourceTree = "<group>"; };
84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMQEMUSettingsView.swift; sourceTree = "<group>"; };
84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsView.swift; sourceTree = "<group>"; };
CE7B3A102EA1B00000C0FFEE /* MultilineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextField.swift; sourceTree = "<group>"; };
84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAppleSystemView.swift; sourceTree = "<group>"; };
84C60FB62681A41B00B58C00 /* VMToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarView.swift; sourceTree = "<group>"; };
84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayViewController.swift; sourceTree = "<group>"; };
@@ -2855,6 +2857,7 @@
84A381A9268CB30C0048EE4D /* VMDrivesSettingsView.swift */,
CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */,
84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */,
CE7B3A102EA1B00000C0FFEE /* MultilineTextField.swift */,
845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */,
846F8D572E3850620037162B /* VMKeyboardShortcutsView.swift */,
84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */,
@@ -4150,6 +4153,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 */,