| | |
| | | |
| | | extension Notification.Name { |
| | | static let tagLauncherEditModeChanged = Notification.Name("TagLauncherEditModeChanged") |
| | | static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged") |
| | | static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange") |
| | | static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested") |
| | | } |
| | | |
| | | // MARK: - Edit Phase |
| | |
| | | case none |
| | | case editingTags |
| | | case editingApps |
| | | } |
| | | |
| | | enum EditTagOperation { |
| | | case add |
| | | case remove |
| | | } |
| | | |
| | | // MARK: - Native NSTextField (avoids SwiftUI TextField event issues) |
| | |
| | | return true |
| | | } |
| | | return false |
| | | } |
| | | } |
| | | } |
| | | |
| | | // MARK: - Native Floating Icon Button |
| | | |
| | | final class FloatingIconButtonView: NSView { |
| | | private let imageView = NSImageView() |
| | | var action: (() -> Void)? |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | addSubview(imageView) |
| | | imageView.imageScaling = .scaleProportionallyDown |
| | | imageView.contentTintColor = .secondaryLabelColor |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { fatalError() } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let size: CGFloat = 16 |
| | | imageView.frame = NSRect( |
| | | x: (bounds.width - size) / 2, |
| | | y: (bounds.height - size) / 2, |
| | | width: size, |
| | | height: size |
| | | ) |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | bounds.contains(point) ? self : nil |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | action?() |
| | | } |
| | | |
| | | func setSystemImage(_ systemImage: String) { |
| | | let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) |
| | | imageView.image = NSImage( |
| | | systemSymbolName: systemImage, |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(config) |
| | | } |
| | | } |
| | | |
| | | private struct NativeFloatingIconButton: NSViewRepresentable { |
| | | let systemImage: String |
| | | let action: () -> Void |
| | | |
| | | func makeCoordinator() -> Coordinator { |
| | | Coordinator(action: action) |
| | | } |
| | | |
| | | func makeNSView(context: Context) -> FloatingIconButtonView { |
| | | let view = FloatingIconButtonView(frame: NSRect(x: 0, y: 0, width: 36, height: 36)) |
| | | configure(view, context: context) |
| | | return view |
| | | } |
| | | |
| | | func updateNSView(_ view: FloatingIconButtonView, context: Context) { |
| | | context.coordinator.action = action |
| | | configure(view, context: context) |
| | | } |
| | | |
| | | private func configure(_ view: FloatingIconButtonView, context: Context) { |
| | | view.setSystemImage(systemImage) |
| | | view.action = { |
| | | context.coordinator.action() |
| | | } |
| | | } |
| | | |
| | | final class Coordinator { |
| | | var action: () -> Void |
| | | |
| | | init(action: @escaping () -> Void) { |
| | | self.action = action |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | // MARK: - Full-Screen Overlay |
| | | |
| | | private struct AppBubbleContext { |
| | | let app: AppInfo |
| | | let frame: CGRect |
| | | } |
| | | |
| | | private struct AppBubbleMetrics { |
| | | let centerX: CGFloat |
| | | let centerY: CGFloat |
| | | let arrowX: CGFloat |
| | | let arrowOffset: CGFloat |
| | | } |
| | | |
| | | private struct EditActionFeedback: Identifiable { |
| | | let id = UUID() |
| | | let title: String |
| | | let message: String |
| | | } |
| | | |
| | | private enum SmartStartNoticeMode { |
| | | case autoApplied |
| | | case suggestionOnly |
| | | case manuallyApplied |
| | | } |
| | | |
| | | private struct SmartStartNotice: Identifiable { |
| | | let id = UUID() |
| | | let mode: SmartStartNoticeMode |
| | | let title: String |
| | | let message: String |
| | | let summary: SmartStartSummary |
| | | } |
| | | |
| | | struct ContentView: View { |
| | | let hideOverlay: () -> Void |
| | | |
| | |
| | | @State private var editPhase: EditPhase = .none |
| | | @State private var selectedAppPaths: Set<URL> = [] |
| | | @State private var selectedTagNames: Set<String> = [] |
| | | @State private var successToast: String? = nil |
| | | @State private var removableTagNames: Set<String> = [] |
| | | @State private var editTagOperation: EditTagOperation = .add |
| | | @State private var editActionFeedback: EditActionFeedback? = nil |
| | | @State private var draggedTagNames: [String] = [] // live drag order |
| | | @State private var dragItem: String? = nil // currently dragged tag |
| | | @State private var tagReorderFrames: [String: CGRect] = [:] |
| | |
| | | @State private var filledColorlessContainer: String? = nil |
| | | @State private var appDragModeActive = false |
| | | @State private var dropWarningToast: String? = nil |
| | | @State private var smartStartNotice: SmartStartNotice? = nil |
| | | @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil |
| | | @State private var dropRefreshVisible = false |
| | | @State private var dropRefreshStartedAt: Date? = nil |
| | | @State private var layoutRefreshID = UUID() |
| | | @State private var refreshInProgress = false |
| | | @State private var refreshAgainAfterCurrent = false |
| | | @State private var refreshAgainForceLayout = false |
| | | @State private var hoveredBubble: AppBubbleContext? = nil |
| | | @State private var editingBubble: AppBubbleContext? = nil |
| | | @State private var bubbleDraftNote = "" |
| | | @FocusState private var bubbleNoteFocused: Bool |
| | | |
| | | // Configurable defaults |
| | | @AppStorage("defaultGroupName") private var defaultGroupName = "Other" |
| | | @AppStorage("tagFontSize") private var tagFontSize: Double = 18 |
| | | @AppStorage("iconSize") private var iconSize: Double = 56 |
| | | @AppStorage("tagPosition") private var tagPosition = "left" |
| | | @AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize |
| | | @AppStorage("iconSize") private var iconSize: Double = AppDefaults.iconSize |
| | | @AppStorage("tagPosition") private var tagPosition = AppDefaults.tagPosition |
| | | @State private var notchHeight: CGFloat = 0 |
| | | @AppStorage("displayMode") private var displayMode = "flat" |
| | | @AppStorage("hideAppNames") private var hideAppNames = false |
| | | @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode |
| | | @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames |
| | | |
| | | private let editSidebarWidth: CGFloat = 188 |
| | | private let editSidebarHorizontalInset: CGFloat = 12 |
| | | private let floatingControlsTrailingInset: CGFloat = 20 |
| | | private let floatingControlsReservedWidth: CGFloat = 120 |
| | | private let rightSidebarFloatingClearance: CGFloat = 44 |
| | | |
| | | private var isSideLayout: Bool { |
| | | tagPosition == "left" || tagPosition == "right" |
| | | } |
| | | |
| | | private var floatingControlsTopInset: CGFloat { |
| | | notchHeight > 0 ? notchHeight + 10 : 20 |
| | | } |
| | | |
| | | private var isColorlessContainerMode: Bool { |
| | |
| | | case .editingApps: |
| | | editAppsView |
| | | } |
| | | |
| | | uncommonAppBubbleOverlay |
| | | smartStartNoticeOverlay |
| | | editActionFeedbackOverlay |
| | | |
| | | if let message = dropWarningToast { |
| | | Text(message) |
| | |
| | | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in |
| | | refreshApps() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in |
| | | let appsSnapshot = allApps |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot) |
| | | DispatchQueue.main.async { |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | } |
| | | } |
| | | .onChange(of: editPhase) { _, newPhase in |
| | | let active = newPhase != .none |
| | | dismissAppBubble() |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherEditModeChanged, |
| | | object: nil, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | .onChange(of: bubbleNoteFocused) { _, focused in |
| | | if editingBubble != nil && !focused { |
| | | commitBubbleNote() |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | editPhase = phase |
| | | if phase == .none { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | DispatchQueue.main.async { |
| | | NotificationCenter.default.post(name: .tagLauncherEditModeChanged, object: nil, userInfo: ["active": false]) |
| | | } |
| | |
| | | topLayout |
| | | } |
| | | |
| | | Button { |
| | | setEditPhase(.editingApps) |
| | | } label: { |
| | | Image(systemName: "pencil.line") |
| | | .font(.system(size: 16, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .padding(10) |
| | | .background(Circle().fill(.ultraThinMaterial)) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20) |
| | | .padding(.trailing, 20) |
| | | .keyboardShortcut("e", modifiers: .control) |
| | | .zIndex(2) |
| | | floatingActionButtons |
| | | .padding(.top, floatingControlsTopInset) |
| | | .padding(.trailing, floatingControlsTrailingInset) |
| | | .zIndex(2) |
| | | } |
| | | } |
| | | |
| | | private var floatingActionButtons: some View { |
| | | HStack(spacing: 8) { |
| | | floatingOverlayButton(systemImage: "pencil.line") { |
| | | setEditPhase(.editingApps) |
| | | } |
| | | .keyboardShortcut("e", modifiers: .control) |
| | | .help(tr("edit.title")) |
| | | |
| | | floatingOverlayButton(systemImage: "gearshape") { |
| | | NotificationCenter.default.post(name: .tagLauncherOpenPreferencesRequested, object: nil) |
| | | } |
| | | .help(tr("menu.preferences")) |
| | | } |
| | | } |
| | | |
| | | private func floatingOverlayButton( |
| | | systemImage: String, |
| | | action: @escaping () -> Void |
| | | ) -> some View { |
| | | NativeFloatingIconButton(systemImage: systemImage, action: action) |
| | | .frame(width: 36, height: 36) |
| | | .background(Circle().fill(.ultraThinMaterial)) |
| | | } |
| | | |
| | | // MARK: - Top / Side Layouts |
| | |
| | | HStack(spacing: 0) { |
| | | if tagPosition == "left" { tagSidebar; sideDivider } |
| | | appGridContent |
| | | if tagPosition == "right" { sideDivider; tagSidebar } |
| | | if tagPosition == "right" { sideDivider; rightTagSidebar } |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 24) |
| | | .padding(.leading, 24) |
| | | .padding(.trailing, tagPosition == "top" ? floatingControlsReservedWidth : 24) |
| | | .coordinateSpace(name: "tagNavReorder") |
| | | .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in |
| | | tagNavReorderFrames = frames |
| | |
| | | } |
| | | |
| | | private var tagSidebar: some View { |
| | | tagSidebarList |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private var rightTagSidebar: some View { |
| | | tagSidebarList |
| | | .padding(.top, rightSidebarFloatingClearance) |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private var tagSidebarList: some View { |
| | | ScrollView(.vertical, showsIndicators: false) { |
| | | VStack(spacing: 6) { |
| | | ForEach(tagLabels) { tag in |
| | |
| | | .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in |
| | | tagNavReorderFrames = frames |
| | | } |
| | | }.frame(width: 135) |
| | | } |
| | | } |
| | | |
| | | private var sideDivider: some View { |
| | |
| | | flatGrid |
| | | } |
| | | } |
| | | .id(layoutRefreshID) |
| | | } |
| | | |
| | | private var uncommonAppBubbleOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let context = editingBubble ?? hoveredBubble { |
| | | let rootFrame = proxy.frame(in: .global) |
| | | let editing = editingBubble != nil |
| | | let width = min(editing ? 440 : 520, max(260, proxy.size.width - 48)) |
| | | let placement = bubblePlacement(for: context.frame, rootFrame: rootFrame) |
| | | let metrics = bubbleMetrics( |
| | | for: context, |
| | | rootFrame: rootFrame, |
| | | rootSize: proxy.size, |
| | | width: width, |
| | | placement: placement, |
| | | isEditing: editing |
| | | ) |
| | | |
| | | AppNameBubble( |
| | | appName: context.app.name, |
| | | note: currentNote(for: context.app), |
| | | isEditing: editing, |
| | | placement: placement, |
| | | arrowOffset: metrics.arrowOffset, |
| | | draftNote: $bubbleDraftNote, |
| | | noteFocused: $bubbleNoteFocused, |
| | | onCommit: commitBubbleNote, |
| | | onCancel: dismissAppBubble |
| | | ) |
| | | .frame(width: width) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .position(x: metrics.centerX, y: metrics.centerY) |
| | | .transition(.opacity) |
| | | .zIndex(500) |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(editingBubble != nil) |
| | | } |
| | | |
| | | private var smartStartNoticeOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let notice = smartStartNotice { |
| | | let panelWidth = min(560, max(320, proxy.size.width - 64)) |
| | | |
| | | ZStack { |
| | | Color.black.opacity(0.10) |
| | | .ignoresSafeArea() |
| | | |
| | | VStack(spacing: 18) { |
| | | Image(systemName: notice.mode == .suggestionOnly ? "sparkles" : "checkmark.seal.fill") |
| | | .font(.system(size: 30, weight: .semibold)) |
| | | .foregroundStyle(Color.accentColor) |
| | | .frame(width: 58, height: 58) |
| | | .background( |
| | | Circle() |
| | | .fill(Color.accentColor.opacity(0.12)) |
| | | ) |
| | | |
| | | VStack(spacing: 8) { |
| | | Text(notice.title) |
| | | .font(.system(size: 21, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .multilineTextAlignment(.center) |
| | | |
| | | Text(notice.message) |
| | | .font(.system(size: 15, weight: .regular)) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .lineLimit(4) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | |
| | | HStack(spacing: 12) { |
| | | switch notice.mode { |
| | | case .suggestionOnly: |
| | | Button(tr("smartstart.later")) { |
| | | dismissSmartStartNotice() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.large) |
| | | |
| | | Button(tr("smartstart.apply")) { |
| | | applySmartStartSuggestion() |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.large) |
| | | case .autoApplied, .manuallyApplied: |
| | | if notice.summary.backupPath != nil { |
| | | Button(tr("smartstart.undo")) { |
| | | undoSmartStart() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.large) |
| | | } |
| | | Button(tr("smartstart.ok")) { |
| | | dismissSmartStartNotice() |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.large) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 30) |
| | | .padding(.vertical, 26) |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 16) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.26), radius: 28, y: 16) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 16) |
| | | .stroke(.white.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(width: proxy.size.width, height: proxy.size.height) |
| | | .transition(.scale(scale: 0.96).combined(with: .opacity)) |
| | | .zIndex(650) |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(smartStartNotice != nil) |
| | | } |
| | | private var flatGrid: some View { |
| | | ScrollViewReader { proxy in |
| | | ScrollView { |
| | |
| | | showNames: !hideAppNames, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | onDropApp: { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | } |
| | |
| | | .allowsHitTesting(false) |
| | | } |
| | | .shadow(color: .black.opacity((isColored && isHovered) || isColorlessActive ? 0.22 : 0), |
| | | radius: (isColored && isHovered) || isColorlessActive ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessActive ? 10 : 0) |
| | | .scaleEffect(isColored && isHovered ? 1.015 : 1.0) |
| | | .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered) |
| | | .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isColorlessFilled) |
| | | radius: (isColored && isHovered) || isColorlessActive ? 8 : 0, |
| | | y: (isColored && isHovered) || isColorlessActive ? 3 : 0) |
| | | .zIndex(isHovered ? 50 : 0) |
| | | .animation(.easeOut(duration: 0.045), value: isHovered) |
| | | .animation(.easeOut(duration: 0.08), value: isColorlessFilled) |
| | | .onHover { hovering in |
| | | if isColored || isColorless { |
| | | hoveredContainer = hovering ? group.name : nil |
| | |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | .frame(width: cellWidth) |
| | |
| | | .allowsHitTesting(false) |
| | | } |
| | | .shadow(color: .black.opacity((isColored && isHovered) || isColorlessGridActive ? 0.22 : 0), |
| | | radius: (isColored && isHovered) || isColorlessGridActive ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessGridActive ? 10 : 0) |
| | | .scaleEffect(isColored && isHovered ? 1.015 : 1.0) |
| | | .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered) |
| | | .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isColorlessFilled) |
| | | radius: (isColored && isHovered) || isColorlessGridActive ? 8 : 0, |
| | | y: (isColored && isHovered) || isColorlessGridActive ? 3 : 0) |
| | | .zIndex(isHovered ? 50 : 0) |
| | | .animation(.easeOut(duration: 0.045), value: isHovered) |
| | | .animation(.easeOut(duration: 0.08), value: isColorlessFilled) |
| | | .onHover { hovering in |
| | | if isColored || isColorlessGrid { |
| | | hoveredContainer = hovering ? group.name : nil |
| | |
| | | |
| | | private var editAppsView: some View { |
| | | VStack(spacing: 0) { |
| | | HStack { |
| | | Button { cancelEditApps(); setEditPhase(.none) } label: { |
| | | Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right") |
| | | .font(.system(size: 12)) |
| | | } |
| | | .buttonStyle(.bordered) |
| | | Spacer() |
| | | Text(tr("edit.title")).font(.headline) |
| | | Spacer() |
| | | Button(tr("edit.confirm")) { confirmAssign() } |
| | | .buttonStyle(.borderedProminent) |
| | | .disabled(selectedAppPaths.isEmpty) |
| | | } |
| | | .padding(.horizontal, 24) |
| | | .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20) |
| | | .padding(.bottom, 12) |
| | | EditAppsHeaderView( |
| | | operation: editTagOperation, |
| | | hintText: editModeHintText, |
| | | confirmTitle: editConfirmTitle, |
| | | isConfirmDisabled: selectedAppPaths.isEmpty || pendingOperationTagNames.isEmpty, |
| | | notchHeight: notchHeight, |
| | | onExit: { |
| | | cancelEditApps() |
| | | setEditPhase(.none) |
| | | }, |
| | | onSelectOperation: setEditTagOperation, |
| | | onConfirm: confirmAssign |
| | | ) |
| | | |
| | | Divider().opacity(0.3) |
| | | |
| | | HStack(spacing: 0) { |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text(tr("edit.selectTags")).font(.caption).foregroundStyle(.secondary).padding(.bottom, 4) |
| | | Text(tr("edit.dragHint")).font(.caption2).foregroundStyle(.tertiary).padding(.bottom, 2) |
| | | EditAppsSidebarIntroView( |
| | | width: editSidebarWidth, |
| | | horizontalInset: editSidebarHorizontalInset |
| | | ) |
| | | |
| | | ScrollView(.vertical, showsIndicators: false) { |
| | | VStack(spacing: 4) { |
| | | ForEach(sortedTagNames, id: \.self) { tagName in |
| | |
| | | tagReorderFrames = frames |
| | | } |
| | | } |
| | | .frame(width: 155) |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| | | } |
| | | .frame(width: editSidebarWidth, alignment: .topLeading) |
| | | Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1) |
| | | |
| | | if allApps.isEmpty { |
| | |
| | | } |
| | | } |
| | | |
| | | if let msg = successToast { |
| | | Text(msg).font(.headline) |
| | | .padding(.horizontal, 20).padding(.vertical, 10) |
| | | .background(RoundedRectangle(cornerRadius: 10).fill(.ultraThickMaterial)) |
| | | .transition(.move(edge: .bottom).combined(with: .opacity)) |
| | | .onAppear { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { |
| | | withAnimation { successToast = nil } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private var editAppsGrid: some View { |
| | | ScrollView { |
| | | LazyVStack(alignment: .leading, spacing: 24) { |
| | | ForEach(groups) { group in |
| | | ForEach(editGroups) { group in |
| | | editFlatGroup(group) |
| | | } |
| | | } |
| | | .padding(20) |
| | | } |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| | | } |
| | | |
| | | private func editFlatGroup(_ group: TagGroup) -> some View { |
| | |
| | | ) { |
| | | ForEach(group.apps) { app in editableAppItem(app) } |
| | | } |
| | | .frame(minHeight: group.apps.isEmpty ? max(52, iconSize + 12) : 0) |
| | | } |
| | | } |
| | | |
| | | private func selectableTagItem(_ tagName: String) -> some View { |
| | | let isSelected = selectedTagNames.contains(tagName) |
| | | return HStack(spacing: 6) { |
| | | Circle() |
| | | .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3)) |
| | | .frame(width: 16, height: 16) |
| | | .overlay(isSelected ? Image(systemName: "checkmark").font(.system(size: 8, weight: .bold)).foregroundStyle(.white) : nil) |
| | | Text(tagName).font(.system(size: 13, weight: .medium)).foregroundStyle(.primary) |
| | | } |
| | | .padding(.horizontal, 10).padding(.vertical, 5) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .background(RoundedRectangle(cornerRadius: 6) |
| | | .fill(Color(nsColor: TagColor.nsColor(for: tagColors[tagName] ?? 0).withAlphaComponent(0.3)))) |
| | | .contentShape(RoundedRectangle(cornerRadius: 6)) |
| | | .onTapGesture { |
| | | if isSelected { selectedTagNames.remove(tagName) } else { selectedTagNames.insert(tagName) } |
| | | let isRemovableCandidate = removableTagNames.contains(tagName) |
| | | let isUncommon = tagName == TagDatabase.uncommonTagKey |
| | | let colorIndex = isUncommon ? 0 : (tagColors[tagName] ?? 0) |
| | | return EditSelectableTagItem( |
| | | tagName: tagName, |
| | | displayName: displayTagName(tagName), |
| | | colorIndex: colorIndex, |
| | | operation: editTagOperation, |
| | | isSelected: isSelected, |
| | | isRemovableCandidate: isRemovableCandidate |
| | | ) { |
| | | if isSelected { |
| | | selectedTagNames.remove(tagName) |
| | | } else { |
| | | selectedTagNames.insert(tagName) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func tagReorderGesture(for tagName: String) -> some Gesture { |
| | | DragGesture(minimumDistance: 4, coordinateSpace: .named("tagReorderList")) |
| | | .onChanged { value in |
| | | guard canReorderTag(tagName) else { return } |
| | | if dragItem == nil { |
| | | dragItem = tagName |
| | | } |
| | |
| | | reorderDraggedTag(at: value.location) |
| | | } |
| | | .onEnded { _ in |
| | | guard canReorderTag(tagName) else { return } |
| | | dragItem = nil |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | |
| | | let destination = toIndex > fromIndex ? toIndex + 1 : toIndex |
| | | draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) |
| | | } |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | |
| | | private func confirmAssign() { |
| | | guard !selectedAppPaths.isEmpty else { return } |
| | | let paths = selectedAppPaths.map { $0.path } |
| | | let tags = sortedTagNames.filter { selectedTagNames.contains($0) } |
| | | TagEditor.setTags(tags, to: paths) |
| | | selectedAppPaths = []; selectedTagNames = [] |
| | | let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) } |
| | | let tags = pendingOperationTagNames |
| | | guard !selectedApps.isEmpty, !tags.isEmpty else { return } |
| | | |
| | | let paths = selectedApps.map { $0.path.path } |
| | | let feedback = buildEditActionFeedback(for: selectedApps, tags: tags) |
| | | |
| | | switch editTagOperation { |
| | | case .add: |
| | | TagEditor.appendTags(tags, to: paths) |
| | | case .remove: |
| | | TagEditor.removeTags(tags, from: paths) |
| | | } |
| | | selectedAppPaths = [] |
| | | selectedTagNames = [] |
| | | removableTagNames = [] |
| | | refreshApps() |
| | | withAnimation { successToast = tr("edit.success") } |
| | | showEditActionFeedback(feedback) |
| | | } |
| | | |
| | | private func cancelEditApps() { |
| | | selectedAppPaths = []; selectedTagNames = [] |
| | | selectedAppPaths = [] |
| | | selectedTagNames = [] |
| | | removableTagNames = [] |
| | | editTagOperation = .add |
| | | } |
| | | |
| | | private func editableAppItem(_ app: AppInfo) -> some View { |
| | | let isSelected = selectedAppPaths.contains(app.path) |
| | | return Button { |
| | | return EditableAppSelectionItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | isSelected: isSelected |
| | | ) { |
| | | toggleEditableAppSelection(app) |
| | | } label: { |
| | | VStack(spacing: 4) { |
| | | ZStack(alignment: .topTrailing) { |
| | | Image(nsImage: app.icon).resizable().aspectRatio(contentMode: .fit) |
| | | .frame(width: iconSize, height: iconSize) |
| | | Circle() |
| | | .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3)) |
| | | .frame(width: 20, height: 20) |
| | | .overlay(isSelected ? Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)).foregroundStyle(.white) : nil) |
| | | .offset(x: 6, y: -6) |
| | | } |
| | | Text(app.name).font(.system(size: 11, weight: .medium)).lineLimit(1).truncationMode(.tail) |
| | | .frame(maxWidth: iconSize + 20) |
| | | } |
| | | .padding(.vertical, 8).padding(.horizontal, 4) |
| | | .contentShape(RoundedRectangle(cornerRadius: 10)) |
| | | .opacity(isSelected ? 1.0 : 0.65) |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func toggleEditableAppSelection(_ app: AppInfo) { |
| | |
| | | } |
| | | |
| | | private func syncSelectedTagsFromSelectedApps() { |
| | | let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) } |
| | | guard !selectedApps.isEmpty else { |
| | | guard !selectedAppPaths.isEmpty else { |
| | | selectedTagNames = [] |
| | | removableTagNames = [] |
| | | return |
| | | } |
| | | |
| | | let editableTags = Set(sortedTagNames) |
| | | if selectedApps.count == 1 { |
| | | selectedTagNames = Set(selectedApps[0].tags.filter { editableTags.contains($0) }) |
| | | guard editTagOperation == .remove else { |
| | | removableTagNames = [] |
| | | return |
| | | } |
| | | |
| | | selectedTagNames = selectedApps.reduce(into: Set<String>()) { result, app in |
| | | result.formUnion(app.tags.filter { editableTags.contains($0) }) |
| | | let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) } |
| | | let autoSelectedTags = selectedApps.reduce(into: Set<String>()) { result, app in |
| | | result.formUnion(app.tags) |
| | | if app.isUncommon { |
| | | result.insert(TagDatabase.uncommonTagKey) |
| | | } |
| | | } |
| | | removableTagNames = autoSelectedTags |
| | | selectedTagNames = autoSelectedTags |
| | | } |
| | | |
| | | private var editConfirmTitle: String { |
| | | switch editTagOperation { |
| | | case .add: |
| | | return tr("edit.confirmAdd") |
| | | case .remove: |
| | | return tr("edit.confirmRemove") |
| | | } |
| | | } |
| | | |
| | | private func setEditTagOperation(_ mode: EditTagOperation) { |
| | | guard editTagOperation != mode else { return } |
| | | editTagOperation = mode |
| | | if mode == .add { |
| | | selectedTagNames = [] |
| | | removableTagNames = [] |
| | | } else { |
| | | syncSelectedTagsFromSelectedApps() |
| | | } |
| | | } |
| | | |
| | | private var pendingOperationTagNames: [String] { |
| | | switch editTagOperation { |
| | | case .add: |
| | | return sortedTagNames.filter { selectedTagNames.contains($0) } |
| | | case .remove: |
| | | return sortedTagNames.filter { removableTagNames.contains($0) && !selectedTagNames.contains($0) } |
| | | } |
| | | } |
| | | |
| | | private var editModeHintText: String { |
| | | editTagOperation == .remove ? tr("edit.removeHint") : tr("edit.modeHint") |
| | | } |
| | | |
| | | private var editActionFeedbackOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let feedback = editActionFeedback { |
| | | ZStack { |
| | | Color.black.opacity(0.08) |
| | | .ignoresSafeArea() |
| | | |
| | | EditActionFeedbackBubble( |
| | | title: feedback.title, |
| | | message: feedback.message, |
| | | onClose: dismissEditActionFeedback |
| | | ) |
| | | .frame(width: min(760, max(360, proxy.size.width - 120))) |
| | | .position(x: proxy.size.width / 2, y: proxy.size.height / 2) |
| | | .transition(.scale(scale: 0.94).combined(with: .opacity)) |
| | | } |
| | | .zIndex(700) |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(editActionFeedback != nil) |
| | | } |
| | | |
| | | private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback { |
| | | let appNames = selectedApps.map(\.name) |
| | | .joined(separator: localizedListSeparator) |
| | | |
| | | switch editTagOperation { |
| | | case .add: |
| | | let actualAddCount = selectedApps.reduce(into: 0) { count, app in |
| | | count += tags.reduce(into: 0) { subtotal, tagName in |
| | | if !appHasTag(app, tagName: tagName) { |
| | | subtotal += 1 |
| | | } |
| | | } |
| | | } |
| | | |
| | | return EditActionFeedback( |
| | | title: tr("edit.confirmAdd"), |
| | | message: formattedFeedbackMessage( |
| | | forKey: "edit.feedbackAddFormat", |
| | | replacements: [ |
| | | "%appCount%": "\(selectedApps.count)", |
| | | "%appNames%": appNames, |
| | | "%tagCount%": "\(actualAddCount)" |
| | | ] |
| | | ) |
| | | ) |
| | | |
| | | case .remove: |
| | | let actualRemovedTagNames = tags.filter { tagName in |
| | | selectedApps.contains(where: { appHasTag($0, tagName: tagName) }) |
| | | } |
| | | let actualRemoveCount = selectedApps.reduce(into: 0) { count, app in |
| | | count += tags.reduce(into: 0) { subtotal, tagName in |
| | | if appHasTag(app, tagName: tagName) { |
| | | subtotal += 1 |
| | | } |
| | | } |
| | | } |
| | | |
| | | return EditActionFeedback( |
| | | title: tr("edit.confirmRemove"), |
| | | message: formattedFeedbackMessage( |
| | | forKey: "edit.feedbackRemoveFormat", |
| | | replacements: [ |
| | | "%appCount%": "\(selectedApps.count)", |
| | | "%appNames%": appNames, |
| | | "%tagCount%": "\(actualRemoveCount)", |
| | | "%tagNames%": actualRemovedTagNames |
| | | .map(displayTagName) |
| | | .joined(separator: localizedListSeparator) |
| | | ] |
| | | ) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func appHasTag(_ app: AppInfo, tagName: String) -> Bool { |
| | | if tagName == TagDatabase.uncommonTagKey { |
| | | return app.isUncommon |
| | | } |
| | | return app.tags.contains(tagName) |
| | | } |
| | | |
| | | private func formattedFeedbackMessage(forKey key: String, replacements: [String: String]) -> String { |
| | | replacements.reduce(tr(key)) { partial, replacement in |
| | | partial.replacingOccurrences(of: replacement.key, with: replacement.value) |
| | | } |
| | | } |
| | | |
| | | private func showEditActionFeedback(_ feedback: EditActionFeedback) { |
| | | let feedbackID = feedback.id |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { |
| | | editActionFeedback = feedback |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 2.6) { |
| | | guard editActionFeedback?.id == feedbackID else { return } |
| | | dismissEditActionFeedback() |
| | | } |
| | | } |
| | | |
| | | private func dismissEditActionFeedback() { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | editActionFeedback = nil |
| | | } |
| | | } |
| | | |
| | | private func showSmartStartNotice( |
| | | mode: SmartStartNoticeMode, |
| | | summary: SmartStartSummary |
| | | ) { |
| | | let titleKey: String |
| | | let messageKey: String |
| | | |
| | | switch mode { |
| | | case .autoApplied: |
| | | titleKey = "smartstart.auto.title" |
| | | messageKey = "smartstart.auto.messageFormat" |
| | | case .suggestionOnly: |
| | | titleKey = "smartstart.suggestion.title" |
| | | messageKey = "smartstart.suggestion.messageFormat" |
| | | case .manuallyApplied: |
| | | titleKey = "smartstart.applied.title" |
| | | messageKey = "smartstart.applied.messageFormat" |
| | | } |
| | | |
| | | let notice = SmartStartNotice( |
| | | mode: mode, |
| | | title: tr(titleKey), |
| | | message: formattedFeedbackMessage( |
| | | forKey: messageKey, |
| | | replacements: [ |
| | | "%appCount%": "\(summary.matchedAppCount)", |
| | | "%tagCount%": "\(summary.assignedTagCount)", |
| | | "%createdTagCount%": "\(summary.createdTagCount)" |
| | | ] |
| | | ), |
| | | summary: summary |
| | | ) |
| | | |
| | | withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { |
| | | smartStartNotice = notice |
| | | } |
| | | } |
| | | |
| | | private func handleSmartStartRunResult(_ result: SmartStartRunResult) { |
| | | switch result.mode { |
| | | case .none: |
| | | return |
| | | case .autoApplied: |
| | | pendingSmartStartDraft = nil |
| | | if let summary = result.summary { |
| | | showSmartStartNotice(mode: .autoApplied, summary: summary) |
| | | } |
| | | case .suggestionOnly: |
| | | pendingSmartStartDraft = result.draft |
| | | if let summary = result.summary { |
| | | showSmartStartNotice(mode: .suggestionOnly, summary: summary) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func applySmartStartSuggestion() { |
| | | guard let draft = pendingSmartStartDraft else { |
| | | dismissSmartStartNotice() |
| | | return |
| | | } |
| | | |
| | | let scannedApps = allApps |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let result = SmartStartService.applySuggestion(draft) |
| | | let store = result.store |
| | | let apps = TagEditor.annotate(apps: scannedApps, store: store) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let order = TagEditor.orderedTagNames() |
| | | |
| | | DispatchQueue.main.async { |
| | | pendingSmartStartDraft = nil |
| | | allApps = apps |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | if let summary = result.summary { |
| | | showSmartStartNotice(mode: .manuallyApplied, summary: summary) |
| | | } else { |
| | | dismissSmartStartNotice() |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func undoSmartStart() { |
| | | guard let backupPath = smartStartNotice?.summary.backupPath else { |
| | | dismissSmartStartNotice() |
| | | return |
| | | } |
| | | |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let restored = SmartStartService.restoreBackup(at: backupPath) |
| | | DispatchQueue.main.async { |
| | | pendingSmartStartDraft = nil |
| | | dismissSmartStartNotice() |
| | | if restored { |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func dismissSmartStartNotice() { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | smartStartNotice = nil |
| | | } |
| | | } |
| | | |
| | | private var localizedListSeparator: String { |
| | | switch L10n.currentCode { |
| | | case "zh-Hans", "zh-Hant", "ja": |
| | | return "、" |
| | | case "ar", "ar-Najdi": |
| | | return "، " |
| | | default: |
| | | return ", " |
| | | } |
| | | } |
| | | |
| | |
| | | // Use user-defined order, fall back to alpha |
| | | let ordered = draggedTagNames.filter { filtered.contains($0) } |
| | | let remaining = filtered.filter { !ordered.contains($0) }.sorted() |
| | | return ordered + remaining |
| | | return ordered + remaining + [TagDatabase.uncommonTagKey] |
| | | } |
| | | |
| | | private func displayTagName(_ tagName: String) -> String { |
| | | tagName == TagDatabase.uncommonTagKey ? tr("group.uncommon") : tagName |
| | | } |
| | | |
| | | private var tagLabels: [TagLabel] { |
| | |
| | | } |
| | | } |
| | | |
| | | private var editGroups: [TagGroup] { |
| | | groups + [ |
| | | TagGroup( |
| | | name: tr("group.uncommon"), |
| | | apps: allApps.filter(\.isUncommon).sorted { |
| | | $0.name.localizedStandardCompare($1.name) == .orderedAscending |
| | | } |
| | | ) |
| | | ] |
| | | } |
| | | |
| | | // MARK: - Actions |
| | | |
| | | private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) { |
| | | guard editingBubble == nil else { return } |
| | | if hovering { |
| | | hoveredBubble = AppBubbleContext(app: app, frame: frame) |
| | | } else if hoveredBubble?.app.path == app.path { |
| | | hoveredBubble = nil |
| | | } |
| | | } |
| | | |
| | | private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) { |
| | | bubbleDraftNote = currentNote(for: app) |
| | | hoveredBubble = nil |
| | | editingBubble = AppBubbleContext(app: app, frame: frame) |
| | | notifyAppNoteEditing(active: true) |
| | | DispatchQueue.main.async { |
| | | bubbleNoteFocused = true |
| | | } |
| | | } |
| | | |
| | | private func commitBubbleNote() { |
| | | guard let context = editingBubble else { return } |
| | | let limited = String(bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength)) |
| | | .trimmingCharacters(in: .whitespacesAndNewlines) |
| | | TagEditor.setAppNote(limited, for: context.app.path.path) |
| | | bubbleDraftNote = limited |
| | | editingBubble = nil |
| | | bubbleNoteFocused = false |
| | | notifyAppNoteEditing(active: false) |
| | | refreshApps() |
| | | } |
| | | |
| | | private func dismissAppBubble() { |
| | | hoveredBubble = nil |
| | | if editingBubble != nil { |
| | | notifyAppNoteEditing(active: false) |
| | | } |
| | | editingBubble = nil |
| | | bubbleNoteFocused = false |
| | | } |
| | | |
| | | private func notifyAppNoteEditing(active: Bool) { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherAppNoteEditingChanged, |
| | | object: nil, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | |
| | | private func currentNote(for app: AppInfo) -> String { |
| | | if let latest = allApps.first(where: { $0.path == app.path })?.note { |
| | | return latest |
| | | } |
| | | return app.note ?? "" |
| | | } |
| | | |
| | | private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement { |
| | | frame.minY < 170 ? .below : .above |
| | | } |
| | | |
| | | private func bubbleMetrics( |
| | | for context: AppBubbleContext, |
| | | rootFrame: CGRect, |
| | | rootSize: CGSize, |
| | | width: CGFloat, |
| | | placement: BubblePlacement, |
| | | isEditing: Bool |
| | | ) -> AppBubbleMetrics { |
| | | let appCenterX = context.frame.midX |
| | | let halfWidth = width / 2 |
| | | let centerX = min(max(appCenterX, halfWidth + 24), max(halfWidth + 24, rootSize.width - halfWidth - 24)) |
| | | let estimatedHeight = estimatedBubbleHeight(for: context.app, width: width, isEditing: isEditing) |
| | | let gap: CGFloat = 16 |
| | | let topY: CGFloat |
| | | if placement == .below { |
| | | topY = context.frame.maxY + gap |
| | | } else { |
| | | topY = context.frame.minY - estimatedHeight - gap |
| | | } |
| | | let clampedTopY = min(max(topY, 14), max(14, rootSize.height - estimatedHeight - 14)) |
| | | let arrowX = min(max(appCenterX, centerX - halfWidth + 28), centerX + halfWidth - 28) |
| | | return AppBubbleMetrics( |
| | | centerX: centerX, |
| | | centerY: clampedTopY + estimatedHeight / 2, |
| | | arrowX: arrowX, |
| | | arrowOffset: arrowX - centerX |
| | | ) |
| | | } |
| | | |
| | | private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat { |
| | | if isEditing { |
| | | return 120 |
| | | } |
| | | let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines) |
| | | guard !note.isEmpty else { return 68 } |
| | | let contentWidth = max(160, width - 48) |
| | | let approxCharsPerLine = max(16, Int(contentWidth / 14)) |
| | | let lines = min(4, max(1, Int(ceil(Double(note.count) / Double(approxCharsPerLine))))) |
| | | return 70 + CGFloat(lines) * 20 |
| | | } |
| | | |
| | | func scrollTo(_ id: String) { |
| | | withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) } |
| | |
| | | |
| | | private func fillColorlessContainer(_ id: String) { |
| | | guard isColorlessContainerMode else { return } |
| | | guard filledColorlessContainer != id else { return } |
| | | filledColorlessContainer = id |
| | | } |
| | | |
| | |
| | | let destination = toIndex > fromIndex ? toIndex + 1 : toIndex |
| | | draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) |
| | | } |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | |
| | | private func handleAppDrop(_ providers: [NSItemProvider], targetTag: String) -> Bool { |
| | |
| | | |
| | | private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) { |
| | | appDragModeActive = false |
| | | guard !isSystemDefaultDropTarget(targetTag) else { |
| | | |
| | | if isUncategorizedDropTarget(targetTag) { |
| | | confirmAndMoveAppToUncategorized(path: path) |
| | | return |
| | | } |
| | | |
| | | guard !isAppleBuiltInDropTarget(targetTag) else { |
| | | showDropWarning() |
| | | return |
| | | } |
| | | |
| | | guard tagColors[targetTag] != nil else { return } |
| | | guard sourceTag != targetTag || copy else { return } |
| | | TagEditor.moveApp( |
| | |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool { |
| | | private func confirmAndMoveAppToUncategorized(path: String) { |
| | | guard let app = allApps.first(where: { $0.path.path == path }) else { return } |
| | | let assignedTags = assignedRegularDisplayTags(for: app) |
| | | guard !assignedTags.isEmpty else { return } |
| | | guard confirmUncategorizedDrop(appName: app.name, assignedTags: assignedTags) else { return } |
| | | |
| | | TagEditor.removeTags(app.tags, from: [path]) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func assignedRegularDisplayTags(for app: AppInfo) -> [String] { |
| | | var result: [String] = [] |
| | | for tag in app.tags { |
| | | let name = displayTagName(tag) |
| | | if !result.contains(name) { |
| | | result.append(name) |
| | | } |
| | | } |
| | | return result |
| | | } |
| | | |
| | | private func confirmUncategorizedDrop(appName: String, assignedTags: [String]) -> Bool { |
| | | let alert = NSAlert() |
| | | alert.alertStyle = .warning |
| | | alert.messageText = tr("drop.uncategorizedConfirmTitle") |
| | | alert.informativeText = formattedFeedbackMessage( |
| | | forKey: "drop.uncategorizedConfirmMessage", |
| | | replacements: [ |
| | | "%appName%": appName, |
| | | "%tagCount%": "\(assignedTags.count)", |
| | | "%tagNames%": assignedTags.joined(separator: localizedListSeparator) |
| | | ] |
| | | ) |
| | | alert.addButton(withTitle: tr("edit.confirm")) |
| | | alert.addButton(withTitle: tr("tag.cancel")) |
| | | return alert.runModal() == .alertFirstButtonReturn |
| | | } |
| | | |
| | | private func isUncategorizedDropTarget(_ targetTag: String) -> Bool { |
| | | let defaultNames = [ |
| | | defaultGroupName, |
| | | tr("group.uncategorized"), |
| | | tr("group.uncategorized") |
| | | ] |
| | | return defaultNames.contains(targetTag) |
| | | } |
| | | |
| | | private func isAppleBuiltInDropTarget(_ targetTag: String) -> Bool { |
| | | let defaultNames = [ |
| | | "Mac自带", |
| | | tr("group.appleBuiltIn") |
| | | ] |
| | |
| | | } |
| | | |
| | | func refreshApps(forceLayoutRefresh: Bool = false) { |
| | | guard !refreshInProgress else { |
| | | refreshAgainAfterCurrent = true |
| | | refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh |
| | | return |
| | | } |
| | | |
| | | refreshInProgress = true |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | var apps = AppIndexer.scan() |
| | | let store = TagDatabase.load() |
| | | apps = TagEditor.annotate(apps: apps) |
| | | let scannedApps = AppIndexer.scan() |
| | | let reconciledStore = TagEditor.reconcileScannedApps(scannedApps) |
| | | let smartStartResult = SmartStartService.runIfNeeded( |
| | | apps: scannedApps, |
| | | store: reconciledStore |
| | | ) |
| | | let store = smartStartResult.store |
| | | let apps = TagEditor.annotate(apps: scannedApps, store: store) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let order = TagEditor.orderedTagNames() |
| | | DispatchQueue.main.async { |
| | | allApps = apps |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | handleSmartStartRunResult(smartStartResult) |
| | | if forceLayoutRefresh { |
| | | withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { |
| | | layoutRefreshID = UUID() |
| | | } |
| | | finishDropRefreshAfterMinimumDuration() |
| | | } |
| | | refreshInProgress = false |
| | | if refreshAgainAfterCurrent { |
| | | let shouldForceLayout = refreshAgainForceLayout |
| | | refreshAgainAfterCurrent = false |
| | | refreshAgainForceLayout = false |
| | | refreshApps(forceLayoutRefresh: shouldForceLayout) |
| | | } |
| | | } |
| | | } |
| | |
| | | appDragModeActive = false |
| | | endTagNavReorder() |
| | | hideOverlay() |
| | | DispatchQueue.global(qos: .utility).async { |
| | | TagEditor.recordLauncherOpen(for: app.path.path) |
| | | } |
| | | let configuration = NSWorkspace.OpenConfiguration() |
| | | NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) |
| | | } |