Draw Keychain key editors on the form background

The private key and certificate fields kept TextEditor's own opaque
control background, which inside a grouped Form reads as a white card
sitting on the section rather than as part of it. Hiding it puts them on
the same footing as the skill editor's fields.

Fix the prompt alignment that removing the box exposed. The overlay
carried `.padding(.top, 8)`, but TextEditor insets its text container by
its line fragment padding horizontally and by nothing vertically, so the
prompt stood a line below the caret it was standing in for. Measured
offscreen: leading 5, top 0 — the same inset the composer prompt in
ConversationViews uses, now a named constant.

Both sections repeated their own title in a row above the editor next to
the Choose button. With no box left for that row to attach to it read as
a second heading, so the button moves to the trailing edge of the section
header, matching the Targets section in the snippet editor. The
certificate editor gains the accessibility label its removed label row
had been standing in for, and the private key editor gains a prompt,
since an empty unboxed field is otherwise an unmarked gap in the form.

Verification: build succeeds; check-zh-localization.sh passes;
swift-format reports only the findings this file already had on main.
The failing suites in the test run (ChatComposerStateTests,
ColorSystemTests, WindowStateTests) fail identically on a clean HEAD and
are untouched by this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Opus 5 committed 2026-09-02 16:14:49 +08:00
1 parent cba40eb06b
commit 3115e202c7
2 files changed
+80 -30

No files matched your search

@@ -471,17 +471,12 @@ private struct SoftwareKeyEditor: View {
}
}
Section("Private Key") {
HStack {
Text("Private Key")
Spacer()
Button("Choose") { chooseFile(for: .privateKey) }
.controlSize(.small)
}
TextEditor(text: $privateKey)
.font(.caption.monospaced())
.frame(minHeight: 150)
.accessibilityLabel("Private Key")
Section {
keyTextEditor(
text: $privateKey, minHeight: 150,
placeholder: "Paste a private key, or drag one in"
)
.accessibilityLabel("Private Key")
if let existing, let material,
material.publicKey != existing.publicKey
{
@@ -493,6 +488,8 @@ private struct SoftwareKeyEditor: View {
.foregroundStyle(.orange)
}
SecureField("Passphrase", text: $passphrase, prompt: Text("Optional"))
} header: {
fileSectionHeader("Private Key") { chooseFile(for: .privateKey) }
}
Section("Public Key") {
@@ -502,30 +499,19 @@ private struct SoftwareKeyEditor: View {
.textSelection(.enabled)
}
Section("Certificate") {
HStack {
Text("Certificate")
Spacer()
Button("Choose") { chooseFile(for: .certificate) }
.controlSize(.small)
}
TextEditor(text: $certificate)
.font(.caption.monospaced())
.frame(minHeight: 110)
.overlay(alignment: .topLeading) {
if certificate.isEmpty {
Text("Certificate (optional)")
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.padding(.top, 8).padding(.leading, 5)
.allowsHitTesting(false)
}
}
Section {
keyTextEditor(
text: $certificate, minHeight: 110,
placeholder: "Certificate (optional)"
)
.accessibilityLabel("Certificate")
if let warning = certificateWarning {
Label(warning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
} header: {
fileSectionHeader("Certificate") { chooseFile(for: .certificate) }
}
}
.formStyle(.grouped)
@@ -568,6 +554,59 @@ private struct SoftwareKeyEditor: View {
.task(id: validationToken) { await derivePublicMaterial() }
}
/// A key blob field drawn on the form's own background.
///
/// `TextEditor` brings its own opaque control background, which inside a
/// grouped `Form` reads as a white card sitting on the section rather than
/// as part of it. Hiding it puts these two fields on the same footing as the
/// skill editor's, which is the surface this one is measured against.
///
/// The prompt is the price of that: `TextEditor` has no `prompt:`, and once
/// the box is gone an empty field is an unmarked gap in the form instead of
/// a visibly empty box. It matches the editor's font so the first typed
/// character lands exactly where the prompt started.
private func keyTextEditor(
text: Binding<String>, minHeight: CGFloat, placeholder: LocalizedStringKey
) -> some View {
TextEditor(text: text)
.font(.caption.monospaced())
.frame(minHeight: minHeight)
.scrollContentBackground(.hidden)
.overlay(alignment: .topLeading) {
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.padding(.leading, Self.textInset)
.allowsHitTesting(false)
}
}
}
/// `TextEditor`'s own text-container inset, which the prompt has to match or
/// the first typed character lands somewhere else than the prompt did.
///
/// Only leading: the container is inset by its line fragment padding
/// horizontally and by nothing vertically, so the top padding this used to
/// carry pushed the prompt a line below the caret it was standing in for.
/// Same value the composer prompt in `ConversationViews` uses.
private static let textInset: CGFloat = 5
/// The section title with its file picker at the trailing edge.
///
/// The button used to head a row of its own, next to a label repeating the
/// section title. With the editor no longer drawn as a box, that row had
/// nothing left to attach to and read as a second heading.
private func fileSectionHeader(
_ title: LocalizedStringKey, choose: @escaping () -> Void
) -> some View {
HStack {
Text(title)
Spacer()
Button("Choose", action: choose)
}
}
@MainActor
private func commit() async {
isWorking = true
+11
View File
@@ -7517,6 +7517,17 @@
}
}
},
"Paste a private key, or drag one in" : {
"comment" : "The prompt shown in an empty private key editor.",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "粘贴私钥,或将文件拖到这里"
}
}
}
},
"Pasted image" : {
"localizations" : {
"zh-Hans" : {