settings: use a bezeled multi-line text field for notes on macOS

The macOS notes box was a TextEditor with a hand-drawn half-point
border, which did not match the bezel of the name field above it and
had no focus ring. A TextField with a vertical axis has the right look
but submits on Return instead of inserting a newline, and a drawn
overlay cannot follow the bezel style of each macOS release.

Wrap an NSTextField configured for wrapping instead, so AppKit draws
the same bezel, highlight and focus ring as the name field on every
supported macOS version. Return is routed through the field editor
to insert a newline, the field grows with its contents from a 200pt
minimum, and the page's disableAutocorrection setting is applied to
the field editor as the TextEditor did. iOS keeps the TextEditor.

Assisted-by: Claude:claude-fable-5-1
This commit is contained in:
osy
2026-09-23 00:19:18 -05:00
parent 100701eb8b
commit dedb00e3b1
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; };
@@ -1866,6 +1867,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>"; };
@@ -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 */,