Ariver
2026-07-02 b2a06ec5ebc86c0241e635782ae5a032ab5aad0a
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 {