From b2a06ec5ebc86c0241e635782ae5a032ab5aad0a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 02:43:51 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.2 App Store candidate

---
 src/Apptag/AppGridSupport.swift |  155 ++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 144 insertions(+), 11 deletions(-)

diff --git a/src/Apptag/AppGridSupport.swift b/src/Apptag/AppGridSupport.swift
index 8ff238e..397a995 100644
--- a/src/Apptag/AppGridSupport.swift
+++ b/src/Apptag/AppGridSupport.swift
@@ -59,19 +59,20 @@
 
             if isEditing {
                 VStack(alignment: .trailing, spacing: 4) {
-                    TextField(tr("appNote.placeholder"), text: limitedDraft)
-                        .textFieldStyle(.plain)
-                        .font(.system(size: 15, weight: .medium))
-                        .foregroundStyle(.white)
+                    AppBubbleNoteTextField(
+                        text: $draftNote,
+                        placeholder: tr("appNote.placeholder"),
+                        isFocused: noteFocused,
+                        onCommit: onCommit,
+                        onCancel: onCancel
+                    )
                         .padding(.horizontal, 12)
                         .frame(height: 36)
                         .background(
                             RoundedRectangle(cornerRadius: 9)
                                 .fill(Color.white.opacity(0.12))
                         )
-                        .focused(noteFocused)
-                        .onSubmit(onCommit)
-                    Text("\(draftNote.count) / \(TagDatabase.maxAppNoteLength)")
+                    Text("\(min(draftNote.count, TagDatabase.maxAppNoteLength)) / \(TagDatabase.maxAppNoteLength)")
                         .font(.system(size: 10, weight: .medium))
                         .foregroundStyle(.white.opacity(0.35))
 
@@ -113,13 +114,145 @@
                 .offset(x: arrowOffset, y: placement == .above ? 8 : -8)
         }
     }
+}
 
-    private var limitedDraft: Binding<String> {
-        Binding(
-            get: { draftNote },
-            set: { draftNote = String($0.prefix(TagDatabase.maxAppNoteLength)) }
+private struct AppBubbleNoteTextField: NSViewRepresentable {
+    @Binding var text: String
+    let placeholder: String
+    var isFocused: FocusState<Bool>.Binding
+    let onCommit: () -> Void
+    let onCancel: () -> Void
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(
+            text: $text,
+            isFocused: isFocused,
+            onCommit: onCommit,
+            onCancel: onCancel
         )
     }
+
+    func makeNSView(context: Context) -> AppBubbleNativeTextField {
+        let field = AppBubbleNativeTextField()
+        field.isBordered = false
+        field.isBezeled = false
+        field.drawsBackground = false
+        field.focusRingType = .none
+        field.font = NSFont.systemFont(ofSize: 15, weight: .medium)
+        field.textColor = .white
+        field.placeholderString = placeholder
+        field.delegate = context.coordinator
+        field.target = context.coordinator
+        field.action = #selector(Coordinator.submitAction(_:))
+        field.cell?.usesSingleLineMode = true
+        field.cell?.wraps = false
+        field.lineBreakMode = .byTruncatingTail
+        field.setAccessibilityLabel(placeholder)
+        context.coordinator.field = field
+        return field
+    }
+
+    func updateNSView(_ field: AppBubbleNativeTextField, context: Context) {
+        context.coordinator.text = $text
+        context.coordinator.isFocused = isFocused
+        context.coordinator.onCommit = onCommit
+        context.coordinator.onCancel = onCancel
+        context.coordinator.field = field
+        field.placeholderString = placeholder
+        field.setAccessibilityLabel(placeholder)
+
+        if !field.hasMarkedTextInput, field.stringValue != text {
+            field.stringValue = text
+        }
+
+        guard isFocused.wrappedValue, field.window != nil, field.currentEditor() == nil else { return }
+        DispatchQueue.main.async { [weak field] in
+            guard let field, field.window != nil, field.currentEditor() == nil else { return }
+            field.window?.makeFirstResponder(field)
+        }
+    }
+
+    final class Coordinator: NSObject, NSTextFieldDelegate {
+        var text: Binding<String>
+        var isFocused: FocusState<Bool>.Binding
+        var onCommit: () -> Void
+        var onCancel: () -> Void
+        weak var field: AppBubbleNativeTextField?
+
+        init(
+            text: Binding<String>,
+            isFocused: FocusState<Bool>.Binding,
+            onCommit: @escaping () -> Void,
+            onCancel: @escaping () -> Void
+        ) {
+            self.text = text
+            self.isFocused = isFocused
+            self.onCommit = onCommit
+            self.onCancel = onCancel
+        }
+
+        func controlTextDidChange(_ notification: Notification) {
+            guard let field = notification.object as? AppBubbleNativeTextField,
+                  !field.hasMarkedTextInput
+            else { return }
+            syncFinalText(from: field)
+        }
+
+        func controlTextDidEndEditing(_ notification: Notification) {
+            if let field = notification.object as? AppBubbleNativeTextField {
+                syncFinalText(from: field)
+            }
+            isFocused.wrappedValue = false
+        }
+
+        func control(
+            _ control: NSControl,
+            textView: NSTextView,
+            doCommandBy commandSelector: Selector
+        ) -> Bool {
+            if textView.hasMarkedText() {
+                return false
+            }
+
+            switch commandSelector {
+            case #selector(NSResponder.insertNewline(_:)):
+                if let field = control as? AppBubbleNativeTextField {
+                    syncFinalText(from: field)
+                }
+                onCommit()
+                return true
+            case #selector(NSResponder.cancelOperation(_:)):
+                isFocused.wrappedValue = false
+                onCancel()
+                return true
+            default:
+                return false
+            }
+        }
+
+        @objc func submitAction(_ sender: Any?) {
+            guard let field = sender as? AppBubbleNativeTextField,
+                  !field.hasMarkedTextInput
+            else { return }
+            syncFinalText(from: field)
+            onCommit()
+        }
+
+        private func syncFinalText(from field: AppBubbleNativeTextField) {
+            guard !field.hasMarkedTextInput else { return }
+            let limited = String(field.stringValue.prefix(TagDatabase.maxAppNoteLength))
+            if field.stringValue != limited {
+                field.stringValue = limited
+            }
+            text.wrappedValue = limited
+        }
+    }
+}
+
+private final class AppBubbleNativeTextField: NSTextField {
+    var hasMarkedTextInput: Bool {
+        (currentEditor() as? NSTextView)?.hasMarkedText() == true
+    }
 }
 
 private struct BubbleArrow: Shape {

--
Gitblit v1.9.3