From 01bab5422712299b49f656e6b4c42a3bf314f519 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 11:02:42 +0800
Subject: [PATCH] Release 8.3.5 free tag order persistence

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

diff --git a/src/Apptag/AppGridSupport.swift b/src/Apptag/AppGridSupport.swift
index b12256d..397a995 100644
--- a/src/Apptag/AppGridSupport.swift
+++ b/src/Apptag/AppGridSupport.swift
@@ -32,6 +32,8 @@
     let isEditing: Bool
     let placement: BubblePlacement
     let arrowOffset: CGFloat
+    let noteQuotaText: String?
+    let noteQuotaWarning: Bool
     @Binding var draftNote: String
     var noteFocused: FocusState<Bool>.Binding
     let onCommit: () -> Void
@@ -57,21 +59,34 @@
 
             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))
+
+                    if let noteQuotaText, !noteQuotaText.isEmpty {
+                        Text(noteQuotaText)
+                            .font(.system(size: 11, weight: .medium))
+                            .foregroundStyle(
+                                noteQuotaWarning
+                                    ? Color.orange.opacity(0.96)
+                                    : Color.white.opacity(0.58)
+                            )
+                            .multilineTextAlignment(.trailing)
+                            .fixedSize(horizontal: false, vertical: true)
+                    }
                 }
             } else if !displayNote.isEmpty {
                 Text(displayNote)
@@ -99,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