| | |
| | | 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 |
| | |
| | | |
| | | 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) |
| | |
| | | .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 { |