import SwiftUI
|
import AppKit
|
|
// MARK: - App Grid Shared Support
|
|
enum AppGridItemMetrics {
|
static let hoverScale: CGFloat = 1.22
|
static let labelHeight: CGFloat = 14
|
|
static func stableWidth(iconSize: CGFloat) -> CGFloat {
|
iconSize * hoverScale + 8
|
}
|
|
static func stableHeight(iconSize: CGFloat) -> CGFloat {
|
iconSize * hoverScale + labelHeight + 22
|
}
|
}
|
|
enum AppBubbleHoverEvent {
|
case entered(canShowBubble: Bool)
|
case exited
|
}
|
|
enum BubblePlacement {
|
case above
|
case below
|
}
|
|
struct AppNameBubble: View {
|
let appName: String
|
let note: String?
|
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
|
let onCancel: () -> Void
|
|
private var displayNote: String {
|
note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
}
|
|
var body: some View {
|
content
|
.onExitCommand(perform: onCancel)
|
}
|
|
private var content: some View {
|
VStack(spacing: isEditing || !displayNote.isEmpty ? 8 : 0) {
|
Text(appName)
|
.font(.system(size: isEditing ? 22 : 24, weight: .bold))
|
.foregroundStyle(.white)
|
.lineLimit(1)
|
.truncationMode(.middle)
|
.frame(maxWidth: .infinity)
|
|
if isEditing {
|
VStack(alignment: .trailing, spacing: 4) {
|
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))
|
)
|
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)
|
.font(.system(size: 14, weight: .medium))
|
.lineSpacing(2)
|
.foregroundStyle(.white.opacity(0.78))
|
.lineLimit(4)
|
.multilineTextAlignment(.center)
|
.fixedSize(horizontal: false, vertical: true)
|
.frame(maxWidth: .infinity)
|
}
|
}
|
.padding(.horizontal, isEditing ? 22 : 24)
|
.padding(.vertical, isEditing ? 18 : 16)
|
.background(
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
.fill(Color.black.opacity(0.92))
|
.shadow(color: .black.opacity(0.34), radius: 24, y: 16)
|
.shadow(color: .black.opacity(0.18), radius: 8, y: 3)
|
)
|
.overlay(alignment: placement == .above ? .bottom : .top) {
|
BubbleArrow(placement: placement)
|
.fill(Color.black.opacity(0.92))
|
.frame(width: 18, height: 9)
|
.offset(x: arrowOffset, y: placement == .above ? 8 : -8)
|
}
|
}
|
}
|
|
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 {
|
let placement: BubblePlacement
|
|
func path(in rect: CGRect) -> Path {
|
var path = Path()
|
switch placement {
|
case .above:
|
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
|
case .below:
|
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
|
}
|
path.closeSubpath()
|
return path
|
}
|
}
|