From a416923b1298c818ed9c991d6b439c5eea30a653 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 23 May 2026 03:10:18 +0800
Subject: [PATCH] Archive QA-passed 7.6.0 window logic build
---
Apptag/ApptagApp.swift | 362 +++++-
Apptag/AppGridItem.swift | 335 -------
Apptag/EditModeViews.swift | 2
.hermes/plans/2026-05-05_124500-apptag-design.md | 2
Apptag/PreferencesView.swift | 114 +
Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv | 2
Apptag/QuickSearch.swift | 4
/dev/null | 112 --
generate_icon.py | 5
Apptag/AppGridCollectionView.swift | 393 +++++++-
Scripts/window_logic_qa.sh | 451 +++++++++
Apptag/ContentView.swift | 795 +---------------
Apptag/DataLayer.swift | 190 +++
Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.base.json | 3
Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.manifest.json | 4
15 files changed, 1,403 insertions(+), 1,371 deletions(-)
diff --git a/.hermes/plans/2026-05-05_124500-apptag-design.md b/.hermes/plans/2026-05-05_124500-apptag-design.md
index f10e8e4..1179aab 100644
--- a/.hermes/plans/2026-05-05_124500-apptag-design.md
+++ b/.hermes/plans/2026-05-05_124500-apptag-design.md
@@ -89,7 +89,7 @@
## Files to Create
-All under `~/Projects/Apptag/Apptag/`:
+All under `~/Projects/Taglauncher/Apptag/`:
- `ApptagApp.swift`
- `TagStore.swift`
- `AppIndexer.swift`
diff --git a/Apptag/AppGridCollectionView.swift b/Apptag/AppGridCollectionView.swift
index 2f46f71..9fdd8af 100644
--- a/Apptag/AppGridCollectionView.swift
+++ b/Apptag/AppGridCollectionView.swift
@@ -1,6 +1,34 @@
import SwiftUI
import AppKit
+fileprivate enum AppGridCollectionDisplayMode: Equatable {
+ case flat
+ case masonryContainer
+ case gridContainer
+
+ init(_ rawValue: String) {
+ switch rawValue {
+ case "container", "coloredContainer":
+ self = .masonryContainer
+ case "gridContainer", "coloredGridContainer":
+ self = .gridContainer
+ default:
+ self = .flat
+ }
+ }
+
+ var usesCardSurface: Bool {
+ self != .flat
+ }
+}
+
+fileprivate struct AppGridBubbleSuppressionReasons: OptionSet {
+ let rawValue: Int
+
+ static let externalInteraction = AppGridBubbleSuppressionReasons(rawValue: 1 << 0)
+ static let scroll = AppGridBubbleSuppressionReasons(rawValue: 1 << 1)
+}
+
struct AppGridCollectionView: NSViewRepresentable {
let groups: [TagGroup]
let tagColors: [String: Int]
@@ -61,7 +89,8 @@
var displayMode = AppDefaults.displayMode
var iconSize: CGFloat = AppDefaults.iconSize
var showNames = true
- var bubbleDisabled = false
+ private var externalBubbleDisabled = false
+ private var scrollBubbleDisabled = false
var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
var highlightedGroupName: String?
var contentRevision = 0
@@ -104,7 +133,7 @@
self.displayMode = displayMode
self.iconSize = iconSize
self.showNames = showNames
- self.bubbleDisabled = bubbleDisabled
+ self.externalBubbleDisabled = bubbleDisabled
self.showUncommonAppBubbles = showUncommonAppBubbles
self.highlightedGroupName = highlightedGroupName
self.contentRevision = contentRevision
@@ -161,6 +190,40 @@
groups.firstIndex { $0.id == tagID || $0.name == tagID }
}
+ fileprivate var displayStyle: AppGridCollectionDisplayMode {
+ AppGridCollectionDisplayMode(displayMode)
+ }
+
+ var isColoredContainerMode: Bool {
+ displayMode == "coloredContainer" || displayMode == "coloredGridContainer"
+ }
+
+ var isColorlessContainerMode: Bool {
+ displayMode == "container" || displayMode == "gridContainer"
+ }
+
+ fileprivate var bubbleSuppressionReasons: AppGridBubbleSuppressionReasons {
+ var reasons: AppGridBubbleSuppressionReasons = []
+ if externalBubbleDisabled {
+ reasons.insert(.externalInteraction)
+ }
+ if scrollBubbleDisabled {
+ reasons.insert(.scroll)
+ }
+ return reasons
+ }
+
+ var bubbleDisabled: Bool {
+ !bubbleSuppressionReasons.isEmpty
+ }
+
+ @discardableResult
+ func setScrollBubbleDisabled(_ disabled: Bool) -> Bool {
+ guard scrollBubbleDisabled != disabled else { return false }
+ scrollBubbleDisabled = disabled
+ return true
+ }
+
private static func signature(
tagColors: [String: Int],
displayMode: String,
@@ -195,6 +258,7 @@
private var scrollObserver: NSObjectProtocol?
private var lastLayoutSize: NSSize = .zero
private var lastReportedBoundsOrigin: NSPoint?
+ private var scrollUnfreezeWorkItem: DispatchWorkItem?
override var isFlipped: Bool { true }
@@ -212,6 +276,7 @@
if let scrollObserver {
NotificationCenter.default.removeObserver(scrollObserver)
}
+ scrollUnfreezeWorkItem?.cancel()
}
func configure(coordinator: AppGridCollectionView.Coordinator) {
@@ -228,9 +293,7 @@
collectionView.reloadData()
gridLayout.invalidateLayout()
} else {
- collectionView.visibleItems().forEach { item in
- (item as? AppGridGroupCollectionItem)?.refreshRuntimeState()
- }
+ refreshVisibleRuntimeState()
}
if coordinator.scrollRequestToken != coordinator.lastHandledScrollRequestToken {
@@ -285,7 +348,39 @@
guard let self,
self.recordScrollIfNeeded()
else { return }
- self.coordinator?.onScrollActivity()
+ self.handleScrollActivity()
+ }
+ }
+
+ private func handleScrollActivity() {
+ if coordinator?.setScrollBubbleDisabled(true) == true {
+ refreshVisibleRuntimeState()
+ }
+ coordinator?.onScrollActivity()
+
+ scrollUnfreezeWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let self else { return }
+ self.replayPointerHover()
+ if self.coordinator?.setScrollBubbleDisabled(false) == true {
+ self.refreshVisibleRuntimeState()
+ }
+ }
+ scrollUnfreezeWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: workItem)
+ }
+
+ private func refreshVisibleRuntimeState() {
+ collectionView.visibleItems().forEach { item in
+ (item as? AppGridGroupCollectionItem)?.refreshRuntimeState()
+ }
+ }
+
+ private func replayPointerHover() {
+ guard let window else { return }
+ let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
+ collectionView.visibleItems().forEach { item in
+ (item as? AppGridGroupCollectionItem)?.replayPointerHover(windowPoint: windowPoint)
}
}
@@ -328,7 +423,8 @@
let plan = Self.makePlan(
groups: visibleGroups,
contentWidth: collectionView.bounds.width,
- iconSize: iconSize
+ iconSize: iconSize,
+ displayMode: coordinator?.displayMode ?? AppDefaults.displayMode
)
var nextAttributes: [IndexPath: NSCollectionViewLayoutAttributes] = [:]
@@ -373,6 +469,22 @@
private static func makePlan(
groups: [TagGroup],
contentWidth: CGFloat,
+ iconSize: CGFloat,
+ displayMode: String
+ ) -> LayoutPlan {
+ switch AppGridCollectionDisplayMode(displayMode) {
+ case .flat:
+ return makeFlatPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ case .masonryContainer:
+ return makeMasonryPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ case .gridContainer:
+ return makeGridPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ }
+ }
+
+ private static func makeGridPlan(
+ groups: [TagGroup],
+ contentWidth: CGFloat,
iconSize: CGFloat
) -> LayoutPlan {
let boundedContentWidth = max(1, contentWidth)
@@ -404,6 +516,79 @@
}
let contentHeight = rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding
+ return LayoutPlan(
+ items: items,
+ contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
+ )
+ }
+
+ private static func makeFlatPlan(
+ groups: [TagGroup],
+ contentWidth: CGFloat,
+ iconSize: CGFloat
+ ) -> LayoutPlan {
+ let boundedContentWidth = max(1, contentWidth)
+ let outerPadding = AppGridCollectionMetrics.outerPadding
+ let availableWidth = max(1, boundedContentWidth - outerPadding * 2)
+ var y = outerPadding
+ var items: [LayoutItem] = []
+
+ for (index, group) in groups.enumerated() {
+ let height = AppGridCollectionMetrics.flatGroupHeight(
+ appCount: group.apps.count,
+ width: availableWidth,
+ iconSize: iconSize
+ )
+ items.append(
+ LayoutItem(
+ index: index,
+ frame: NSRect(x: outerPadding, y: y, width: availableWidth, height: height)
+ )
+ )
+ y += height + AppGridCollectionMetrics.flatGroupGap
+ }
+
+ let contentHeight = groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding
+ return LayoutPlan(
+ items: items,
+ contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
+ )
+ }
+
+ private static func makeMasonryPlan(
+ groups: [TagGroup],
+ contentWidth: CGFloat,
+ iconSize: CGFloat
+ ) -> LayoutPlan {
+ let boundedContentWidth = max(1, contentWidth)
+ let outerPadding = AppGridCollectionMetrics.outerPadding
+ let gap = AppGridCollectionMetrics.cardGap
+ let availableWidth = max(1, boundedContentWidth - outerPadding * 2)
+ let columnCount = preferredMasonryColumnCount(availableWidth: availableWidth)
+ let columnWidth = floor((availableWidth - gap * CGFloat(columnCount - 1)) / CGFloat(columnCount))
+ var columnHeights = Array(repeating: outerPadding, count: columnCount)
+ var items: [LayoutItem] = []
+
+ for (index, group) in groups.enumerated() {
+ let columnIndex = columnHeights.indices.min { columnHeights[$0] < columnHeights[$1] } ?? 0
+ let x = outerPadding + CGFloat(columnIndex) * (columnWidth + gap)
+ let y = columnHeights[columnIndex]
+ let height = AppGridCollectionMetrics.cardHeight(
+ appCount: group.apps.count,
+ width: columnWidth,
+ iconSize: iconSize
+ )
+ items.append(
+ LayoutItem(
+ index: index,
+ frame: NSRect(x: x, y: y, width: columnWidth, height: height)
+ )
+ )
+ columnHeights[columnIndex] = y + height + gap
+ }
+
+ let tallest = columnHeights.max() ?? outerPadding
+ let contentHeight = groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding
return LayoutPlan(
items: items,
contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -505,6 +690,14 @@
return 1
}
+ private static func preferredMasonryColumnCount(availableWidth: CGFloat) -> Int {
+ let preferredColumnWidth: CGFloat = 280
+ return max(
+ 1,
+ Int((availableWidth + AppGridCollectionMetrics.cardGap) / (preferredColumnWidth + AppGridCollectionMetrics.cardGap))
+ )
+ }
+
private static func spanPatterns(trackCount: Int) -> [[Int]] {
switch trackCount {
case 3:
@@ -525,6 +718,7 @@
private enum AppGridCollectionMetrics {
static let outerPadding: CGFloat = 20
static let cardGap: CGFloat = 16
+ static let flatGroupGap: CGFloat = 24
static let cardPadding: CGFloat = 16
static let headerHeight: CGFloat = 28
static let headerBottomGap: CGFloat = 6
@@ -541,10 +735,14 @@
iconSize * hoverScale + labelHeight + 22
}
- static func columns(width: CGFloat, iconSize: CGFloat) -> Int {
- let inner = max(1, width - cardPadding * 2)
+ static func columnsForIconArea(width: CGFloat, iconSize: CGFloat) -> Int {
+ let inner = max(1, width)
let itemW = iconCellWidth(iconSize: iconSize)
return max(1, Int((inner + iconColumnGap) / (itemW + iconColumnGap)))
+ }
+
+ static func columns(width: CGFloat, iconSize: CGFloat) -> Int {
+ columnsForIconArea(width: width - cardPadding * 2, iconSize: iconSize)
}
static func cardHeight(appCount: Int, width: CGFloat, iconSize: CGFloat) -> CGFloat {
@@ -563,6 +761,15 @@
static func iconRows(appCount: Int, width: CGFloat, iconSize: CGFloat) -> Int {
let cols = columns(width: width, iconSize: iconSize)
return max(1, (appCount + cols - 1) / cols)
+ }
+
+ static func flatGroupHeight(appCount: Int, width: CGFloat, iconSize: CGFloat) -> CGFloat {
+ let cols = columnsForIconArea(width: width, iconSize: iconSize)
+ let rows = max(1, (appCount + cols - 1) / cols)
+ return headerHeight
+ + headerBottomGap
+ + CGFloat(rows) * iconCellHeight(iconSize: iconSize)
+ + CGFloat(max(0, rows - 1)) * iconRowGap
}
}
@@ -585,6 +792,10 @@
cardView.refreshRuntimeState()
}
+ func replayPointerHover(windowPoint: NSPoint) {
+ cardView.replayPointerHover(windowPoint: windowPoint)
+ }
+
override func prepareForReuse() {
super.prepareForReuse()
cardView.prepareForReuse()
@@ -596,6 +807,7 @@
private weak var coordinator: AppGridCollectionView.Coordinator?
private var group: TagGroup?
private var iconViews: [AppGridIconNSView] = []
+ private var isMouseInside = false
private var isHovered = false
private var trackingAreaRef: NSTrackingArea?
@@ -629,6 +841,9 @@
AppDragCoordinator.shared.unregister(id: dropTargetID)
group = nil
coordinator = nil
+ isMouseInside = false
+ isHovered = false
+ layer?.shadowOpacity = 0
iconViews.forEach {
$0.prepareForReuse()
$0.removeFromSuperview()
@@ -638,10 +853,11 @@
func refreshRuntimeState() {
let runtime = AppGridIconRuntimeState(
- bubbleDisabled: coordinator?.bubbleDisabled ?? false,
+ bubbleSuppressionReasons: coordinator?.bubbleSuppressionReasons ?? [],
showUncommonAppBubbles: coordinator?.showUncommonAppBubbles ?? AppDefaults.showUncommonAppBubbles
)
iconViews.forEach { $0.runtimeState = runtime }
+ updateHoverPresentation()
needsDisplay = true
}
@@ -670,13 +886,11 @@
}
override func mouseEntered(with event: NSEvent) {
- isHovered = true
- needsDisplay = true
+ setPointerInside(true)
}
override func mouseExited(with event: NSEvent) {
- isHovered = false
- needsDisplay = true
+ setPointerInside(false)
}
override func mouseUp(with event: NSEvent) {
@@ -689,48 +903,47 @@
override func layout() {
super.layout()
guard let coordinator, let group else { return }
- let cols = AppGridCollectionMetrics.columns(width: bounds.width, iconSize: coordinator.iconSize)
- let innerWidth = max(1, bounds.width - AppGridCollectionMetrics.cardPadding * 2)
+ let contentRect = iconContentRect(displayStyle: coordinator.displayStyle)
+ let cols = AppGridCollectionMetrics.columnsForIconArea(width: contentRect.width, iconSize: coordinator.iconSize)
let cellWidth = max(
1,
- (innerWidth - AppGridCollectionMetrics.iconColumnGap * CGFloat(cols - 1)) / CGFloat(cols)
+ (contentRect.width - AppGridCollectionMetrics.iconColumnGap * CGFloat(cols - 1)) / CGFloat(cols)
)
let cellHeight = AppGridCollectionMetrics.iconCellHeight(iconSize: coordinator.iconSize)
- let startY = AppGridCollectionMetrics.cardPadding
- + AppGridCollectionMetrics.headerHeight
- + AppGridCollectionMetrics.headerBottomGap
for index in group.apps.indices {
guard index < iconViews.count else { continue }
let row = index / cols
let col = index % cols
- let x = AppGridCollectionMetrics.cardPadding
+ let x = contentRect.minX
+ CGFloat(col) * (cellWidth + AppGridCollectionMetrics.iconColumnGap)
- let y = startY + CGFloat(row) * (cellHeight + AppGridCollectionMetrics.iconRowGap)
+ let y = contentRect.minY + CGFloat(row) * (cellHeight + AppGridCollectionMetrics.iconRowGap)
iconViews[index].frame = NSRect(x: x, y: y, width: cellWidth, height: cellHeight)
}
}
override func draw(_ dirtyRect: NSRect) {
guard let coordinator, let group else { return }
- let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
- let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
+ let displayStyle = coordinator.displayStyle
let tagColor = TagColor.nsColor(for: coordinator.tagColors[group.name] ?? 0)
- let isColored = coordinator.displayMode == "coloredGridContainer"
- let isColorlessActive = coordinator.displayMode == "gridContainer"
+ let isColorlessActive = coordinator.isColorlessContainerMode
&& (isHovered || coordinator.highlightedGroupName == group.name)
- cardSurfaceColor().setFill()
- path.fill()
- if isColored || isColorlessActive {
- tagColor.withAlphaComponent(0.30).setFill()
+ if displayStyle.usesCardSurface {
+ let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
+ let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
+ cardSurfaceColor().setFill()
path.fill()
+ if coordinator.isColoredContainerMode || isColorlessActive {
+ tagColor.withAlphaComponent(0.30).setFill()
+ path.fill()
+ }
+ NSColor.labelColor.withAlphaComponent(0.08).setStroke()
+ path.lineWidth = 1
+ path.stroke()
}
- NSColor.labelColor.withAlphaComponent(0.08).setStroke()
- path.lineWidth = 1
- path.stroke()
- drawHeader(title: group.name)
+ drawHeader(title: group.name, displayStyle: displayStyle)
}
func performDrop(path: String, source: String, copy: Bool) {
@@ -739,6 +952,11 @@
if source != group.name || copy {
coordinator?.onDragModeChange(false)
}
+ }
+
+ func replayPointerHover(windowPoint: NSPoint) {
+ setPointerInside(bounds.contains(convert(windowPoint, from: nil)))
+ iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) }
}
private func rebuildIconViews() {
@@ -769,6 +987,59 @@
AppDragCoordinator.shared.register(id: dropTargetID, view: self, tag: group.name)
}
+ private func setPointerInside(_ inside: Bool) {
+ isMouseInside = inside
+ updateHoverPresentation()
+ }
+
+ private func updateHoverPresentation() {
+ guard let coordinator else { return }
+ let shouldHover = coordinator.displayStyle.usesCardSurface
+ && isMouseInside
+ && !coordinator.bubbleDisabled
+ guard isHovered != shouldHover else {
+ updateCardShadow()
+ return
+ }
+ isHovered = shouldHover
+ updateCardShadow()
+ needsDisplay = true
+ }
+
+ private func updateCardShadow() {
+ guard let coordinator else { return }
+ let isColorlessActive = coordinator.isColorlessContainerMode
+ && (isHovered || coordinator.highlightedGroupName == group?.name)
+ let shouldShadow = coordinator.displayStyle.usesCardSurface
+ && ((coordinator.isColoredContainerMode && isHovered) || isColorlessActive)
+ layer?.shadowColor = NSColor.black.cgColor
+ layer?.shadowOpacity = shouldShadow ? 0.22 : 0
+ layer?.shadowRadius = shouldShadow ? 8 : 0
+ layer?.shadowOffset = NSSize(width: 0, height: -3)
+ }
+
+ private func iconContentRect(displayStyle: AppGridCollectionDisplayMode) -> NSRect {
+ switch displayStyle {
+ case .flat:
+ return NSRect(
+ x: 0,
+ y: AppGridCollectionMetrics.headerHeight + AppGridCollectionMetrics.headerBottomGap,
+ width: bounds.width,
+ height: max(1, bounds.height - AppGridCollectionMetrics.headerHeight - AppGridCollectionMetrics.headerBottomGap)
+ )
+ case .masonryContainer, .gridContainer:
+ let startY = AppGridCollectionMetrics.cardPadding
+ + AppGridCollectionMetrics.headerHeight
+ + AppGridCollectionMetrics.headerBottomGap
+ return NSRect(
+ x: AppGridCollectionMetrics.cardPadding,
+ y: startY,
+ width: max(1, bounds.width - AppGridCollectionMetrics.cardPadding * 2),
+ height: max(1, bounds.height - startY - AppGridCollectionMetrics.cardPadding)
+ )
+ }
+ }
+
private func cardSurfaceColor() -> NSColor {
if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
return NSColor.white.withAlphaComponent(0.055)
@@ -776,11 +1047,13 @@
return NSColor.white.withAlphaComponent(0.62)
}
- private func drawHeader(title: String) {
+ private func drawHeader(title: String, displayStyle: AppGridCollectionDisplayMode) {
+ let horizontalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
+ let verticalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
let headerRect = NSRect(
- x: AppGridCollectionMetrics.cardPadding,
- y: AppGridCollectionMetrics.cardPadding,
- width: max(1, bounds.width - AppGridCollectionMetrics.cardPadding * 2),
+ x: horizontalInset,
+ y: verticalInset,
+ width: max(1, bounds.width - horizontalInset * 2),
height: AppGridCollectionMetrics.headerHeight
)
let attributes: [NSAttributedString.Key: Any] = [
@@ -818,8 +1091,12 @@
}
private struct AppGridIconRuntimeState {
- var bubbleDisabled: Bool
+ var bubbleSuppressionReasons: AppGridBubbleSuppressionReasons
var showUncommonAppBubbles: Bool
+
+ var bubbleDisabled: Bool {
+ !bubbleSuppressionReasons.isEmpty
+ }
}
private final class AppGridIconNSView: NSView {
@@ -828,6 +1105,7 @@
private var sourceTag = ""
private var iconSize: CGFloat = AppDefaults.iconSize
private var showName = true
+ private var isMouseInside = false
private var isHovered = false
private var trackingAreaRef: NSTrackingArea?
private var mouseDownEvent: NSEvent?
@@ -836,13 +1114,11 @@
private var longPressWorkItem: DispatchWorkItem?
var runtimeState = AppGridIconRuntimeState(
- bubbleDisabled: false,
+ bubbleSuppressionReasons: [],
showUncommonAppBubbles: AppDefaults.showUncommonAppBubbles
) {
didSet {
- if runtimeState.bubbleDisabled {
- setHover(false, notify: true)
- }
+ updateHoverPresentation(notify: true)
}
}
@@ -861,7 +1137,7 @@
self.showName = showName
self.coordinator = coordinator
runtimeState = AppGridIconRuntimeState(
- bubbleDisabled: coordinator.bubbleDisabled,
+ bubbleSuppressionReasons: coordinator.bubbleSuppressionReasons,
showUncommonAppBubbles: coordinator.showUncommonAppBubbles
)
needsDisplay = true
@@ -872,6 +1148,7 @@
if isHovered {
setHover(false, notify: true)
}
+ isMouseInside = false
mouseDownEvent = nil
didStartDrag = false
isLongPressActive = false
@@ -935,17 +1212,16 @@
.foregroundColor: NSColor.labelColor.withAlphaComponent(alpha),
.paragraphStyle: paragraph
]
- app.name.draw(with: labelRect, options: [.usesLineFragmentOrigin], attributes: attributes)
+ app.displayName.draw(with: labelRect, options: [.usesLineFragmentOrigin], attributes: attributes)
}
}
override func mouseEntered(with event: NSEvent) {
- guard !runtimeState.bubbleDisabled else { return }
- setHover(true, notify: true)
+ setPointerInside(true, notify: true)
}
override func mouseExited(with event: NSEvent) {
- setHover(false, notify: true)
+ setPointerInside(false, notify: true)
}
override func mouseDown(with event: NSEvent) {
@@ -956,7 +1232,7 @@
guard let self, self.mouseDownEvent != nil else { return }
self.isLongPressActive = true
self.coordinator?.onDragModeChange(true)
- self.setHover(false, notify: true)
+ self.updateHoverPresentation(notify: true, forceHidden: true)
}
longPressWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
@@ -1007,11 +1283,12 @@
}
override func rightMouseDown(with event: NSEvent) {
- guard let app else { return }
+ guard !runtimeState.bubbleDisabled, let app else { return }
coordinator?.onEditNote(app, rootLocalFrame())
}
override func menu(for event: NSEvent) -> NSMenu? {
+ guard !runtimeState.bubbleDisabled else { return nil }
let menu = NSMenu()
let item = NSMenuItem(title: tr("appNote.edit"), action: #selector(editNoteFromMenu), keyEquivalent: "")
item.target = self
@@ -1020,8 +1297,12 @@
}
@objc private func editNoteFromMenu() {
- guard let app else { return }
+ guard !runtimeState.bubbleDisabled, let app else { return }
coordinator?.onEditNote(app, rootLocalFrame())
+ }
+
+ func replayPointerHover(windowPoint: NSPoint) {
+ setPointerInside(bounds.contains(convert(windowPoint, from: nil)), notify: true)
}
private var shouldShowAppBubble: Bool {
@@ -1050,6 +1331,16 @@
}
}
+ private func setPointerInside(_ inside: Bool, notify: Bool) {
+ isMouseInside = inside
+ updateHoverPresentation(notify: notify)
+ }
+
+ private func updateHoverPresentation(notify: Bool, forceHidden: Bool = false) {
+ let shouldHover = isMouseInside && !runtimeState.bubbleDisabled && !forceHidden
+ setHover(shouldHover, notify: notify)
+ }
+
private func makeDragImage() -> NSImage {
guard let app else { return NSImage(size: .zero) }
let scale: CGFloat = AppGridCollectionMetrics.hoverScale * 1.5
diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index c187acf..5b39626 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -1,30 +1,11 @@
import SwiftUI
import AppKit
-// MARK: - App Grid Item (icon + name card, with Dock-like hover)
+// MARK: - App Grid Shared Types
-struct AppGridItem: View {
- let app: AppInfo
- let iconSize: CGFloat
- var showName: Bool = true
- var sourceTag: String? = nil
- var dragModeActive: Bool = false
- var onDragModeChange: ((Bool) -> Void)? = nil
- var onBubbleHover: ((AppInfo, CGRect, AppBubbleHoverEvent) -> Void)? = nil
- var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
- var bubbleDisabled: Bool = false
- var showUncommonAppBubbles: Bool = AppDefaults.showUncommonAppBubbles
- var itemID: String
- var dragResetToken: Int = 0
- let onSelect: () -> Void
-
- @State private var interactionFrame: CGRect = .zero
- @State private var isHovered = false
-
+enum AppGridItemMetrics {
static let hoverScale: CGFloat = 1.22
static let labelHeight: CGFloat = 14
- private static let hoverInAnimation = Animation.easeOut(duration: 0.07)
- private static let hoverOutAnimation = Animation.easeOut(duration: 0.045)
static func stableWidth(iconSize: CGFloat) -> CGFloat {
iconSize * hoverScale + 8
@@ -33,166 +14,11 @@
static func stableHeight(iconSize: CGFloat) -> CGFloat {
iconSize * hoverScale + labelHeight + 22
}
-
- private var iconSlotSize: CGFloat { iconSize * Self.hoverScale }
- private var labelWidth: CGFloat { iconSize + 20 }
- private var labelHeight: CGFloat { Self.labelHeight }
-
- var body: some View {
- VStack(spacing: 6) {
- ZStack {
- DraggableAppIconView(
- icon: app.icon,
- iconSize: iconSize,
- payload: "\(app.path.path)\n\(sourceTag ?? "")",
- resetToken: dragResetToken,
- onLongPress: { onDragModeChange?(true) },
- onDragEnd: { onDragModeChange?(false) },
- onClick: onSelect
- )
- .frame(width: iconSize, height: iconSize)
- .scaleEffect(isHovered ? Self.hoverScale : 1.0)
- .shadow(
- color: .black.opacity(isHovered ? 0.35 : 0),
- radius: isHovered ? 14 : 0,
- y: isHovered ? 8 : 0
- )
- }
- .frame(width: iconSlotSize, height: iconSlotSize)
-
- Text(app.name)
- .font(.system(size: 11, weight: .medium))
- .lineLimit(1)
- .truncationMode(.tail)
- .frame(width: labelWidth, height: labelHeight)
- .opacity(showName ? 1 : (shouldShowHoverName ? 0.85 : 0))
- }
- .padding(.vertical, 8)
- .padding(.horizontal, 4)
- .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
- .background(
- AppHoverTrackingView { hovering, frame in
- interactionFrame = frame
- if bubbleDisabled || dragModeActive {
- setHoverState(false)
- onBubbleHover?(app, frame, .exited)
- return
- }
- if hovering {
- setHoverState(true)
- onBubbleHover?(app, frame, .entered(canShowBubble: shouldShowAppBubble))
- } else if isHovered {
- setHoverState(false)
- onBubbleHover?(app, frame, .exited)
- } else {
- setHoverState(false)
- }
- }
- )
- .contentShape(Rectangle())
- .contextMenu {
- Button(tr("appNote.edit")) {
- onEditNote?(app, interactionFrame)
- }
- }
- .opacity(dragModeActive ? 0.92 : 1)
- .animation(.easeOut(duration: 0.08), value: dragModeActive)
- .onChange(of: dragModeActive) { _, active in
- if active {
- setHoverState(false)
- onBubbleHover?(app, interactionFrame, .exited)
- }
- }
- .onChange(of: bubbleDisabled) { _, disabled in
- if disabled {
- setHoverState(false)
- onBubbleHover?(app, interactionFrame, .exited)
- }
- }
- .onDisappear {
- if isHovered {
- isHovered = false
- onBubbleHover?(app, interactionFrame, .exited)
- }
- }
- }
-
- private var shouldShowAppBubble: Bool {
- !showUncommonAppBubbles || app.isUncommon
- }
-
- private var shouldShowHoverName: Bool {
- !showName && !shouldShowAppBubble && isHovered
- }
-
- private func setHoverState(_ hovering: Bool) {
- guard isHovered != hovering else { return }
- withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
- isHovered = hovering
- }
- }
}
enum AppBubbleHoverEvent {
case entered(canShowBubble: Bool)
case exited
-}
-
-struct AppHoverTrackingView: NSViewRepresentable {
- let onHover: (Bool, CGRect) -> Void
-
- func makeNSView(context: Context) -> AppHoverTrackingNSView {
- let view = AppHoverTrackingNSView()
- view.onHover = onHover
- return view
- }
-
- func updateNSView(_ view: AppHoverTrackingNSView, context: Context) {
- view.onHover = onHover
- }
-}
-
-final class AppHoverTrackingNSView: NSView {
- var onHover: ((Bool, CGRect) -> Void)?
-
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- trackingAreas.forEach(removeTrackingArea)
- addTrackingArea(
- NSTrackingArea(
- rect: .zero,
- options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- )
- }
-
- override func hitTest(_ point: NSPoint) -> NSView? {
- nil
- }
-
- override func mouseEntered(with event: NSEvent) {
- onHover?(true, rootLocalFrame())
- }
-
- override func mouseExited(with event: NSEvent) {
- onHover?(false, rootLocalFrame())
- }
-
- private func rootLocalFrame() -> CGRect {
- guard let contentView = window?.contentView else { return .zero }
- let rectInContent = contentView.convert(bounds, from: self)
- let y = contentView.isFlipped
- ? rectInContent.minY
- : contentView.bounds.height - rectInContent.maxY
- return CGRect(
- x: rectInContent.minX,
- y: y,
- width: rectInContent.width,
- height: rectInContent.height
- )
- }
}
enum BubblePlacement {
@@ -299,162 +125,5 @@
}
path.closeSubpath()
return path
- }
-}
-
-private struct DraggableAppIconView: NSViewRepresentable {
- let icon: NSImage
- let iconSize: CGFloat
- let payload: String
- let resetToken: Int
- let onLongPress: () -> Void
- let onDragEnd: () -> Void
- let onClick: () -> Void
-
- func makeNSView(context: Context) -> DragIconNSView {
- let view = DragIconNSView()
- view.image = icon
- view.iconSize = iconSize
- view.payload = payload
- view.resetToken = resetToken
- view.onLongPress = onLongPress
- view.onDragEnd = onDragEnd
- view.onClick = onClick
- return view
- }
-
- func updateNSView(_ view: DragIconNSView, context: Context) {
- let imageChanged = view.image !== icon
- let sizeChanged = view.iconSize != iconSize
- view.image = icon
- view.iconSize = iconSize
- view.payload = payload
- if view.resetToken != resetToken {
- view.resetToken = resetToken
- view.resetInteractionState()
- }
- view.onLongPress = onLongPress
- view.onDragEnd = onDragEnd
- view.onClick = onClick
- if imageChanged || sizeChanged {
- view.needsDisplay = true
- }
- }
-}
-
-private final class DragIconNSView: NSView {
- var image: NSImage = NSImage()
- var iconSize: CGFloat = 56
- var payload: String = ""
- var resetToken = 0
- var onLongPress: (() -> Void)?
- var onDragEnd: (() -> Void)?
- var onClick: (() -> Void)?
-
- private var mouseDownEvent: NSEvent?
- private var didStartDrag = false
- private var isLongPressActive = false
- private var longPressWorkItem: DispatchWorkItem?
- override var isFlipped: Bool { true }
-
- func resetInteractionState() {
- longPressWorkItem?.cancel()
- longPressWorkItem = nil
- mouseDownEvent = nil
- didStartDrag = false
- isLongPressActive = false
- }
-
- override func draw(_ dirtyRect: NSRect) {
- super.draw(dirtyRect)
- let rect = NSRect(
- x: (bounds.width - iconSize) / 2,
- y: (bounds.height - iconSize) / 2,
- width: iconSize,
- height: iconSize
- )
- image.draw(in: rect)
- }
-
- override func mouseDown(with event: NSEvent) {
- mouseDownEvent = event
- didStartDrag = false
- isLongPressActive = false
- let workItem = DispatchWorkItem { [weak self] in
- guard let self, self.mouseDownEvent != nil else { return }
- self.isLongPressActive = true
- self.onLongPress?()
- }
- longPressWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
- }
-
- override func mouseDragged(with event: NSEvent) {
- if didStartDrag {
- AppDragCoordinator.shared.updateDragLocation(
- screenPoint(for: event),
- copy: event.modifierFlags.contains(.option)
- )
- return
- }
-
- guard let mouseDownEvent, isLongPressActive else { return }
- let dx = event.locationInWindow.x - mouseDownEvent.locationInWindow.x
- let dy = event.locationInWindow.y - mouseDownEvent.locationInWindow.y
- guard hypot(dx, dy) > 3 else { return }
-
- didStartDrag = true
- longPressWorkItem?.cancel()
- AppDragCoordinator.shared.beginDrag(
- image: makeDragImage(),
- payload: payload,
- at: screenPoint(for: event),
- copy: event.modifierFlags.contains(.option),
- in: window
- )
- }
-
- override func mouseUp(with event: NSEvent) {
- longPressWorkItem?.cancel()
- if didStartDrag {
- AppDragCoordinator.shared.finishDrag(
- at: screenPoint(for: event),
- copy: event.modifierFlags.contains(.option)
- )
- onDragEnd?()
- } else if !isLongPressActive {
- onClick?()
- } else {
- onDragEnd?()
- }
- didStartDrag = false
- isLongPressActive = false
- mouseDownEvent = nil
- longPressWorkItem = nil
- }
-
- private func makeDragImage() -> NSImage {
- let scale: CGFloat = 1.22 * 1.5
- let imageSize = iconSize * scale
- let padding = iconSize * 0.45
- let canvasSize = NSSize(width: imageSize + padding * 2, height: imageSize + padding * 2)
- let dragImage = NSImage(size: canvasSize)
-
- dragImage.lockFocus()
- NSGraphicsContext.current?.imageInterpolation = .high
-
- let shadow = NSShadow()
- shadow.shadowColor = NSColor.black.withAlphaComponent(0.48)
- shadow.shadowBlurRadius = 24
- shadow.shadowOffset = NSSize(width: 0, height: -14)
- shadow.set()
-
- image.draw(in: NSRect(x: padding, y: padding, width: imageSize, height: imageSize))
- dragImage.unlockFocus()
- return dragImage
- }
-
- private func screenPoint(for event: NSEvent) -> NSPoint {
- window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation
}
}
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index c030bc6..b78c05a 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -25,16 +25,21 @@
final class OverlayPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
+
+ override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
+ frameRect
+ }
}
-final class AppDelegate: NSObject, NSApplicationDelegate {
+final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private static let showDockIconKey = "showDockIcon"
private static let statusItemAutosaveName = AppIdentity.statusItemAutosaveName
private static let statusItemButtonIdentifier = NSUserInterfaceItemIdentifier("TagLauncherStatusItemButton")
private static let statusItemAccessibilityLabel = AppIdentity.displayName
private static let showAppListMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherShowAppListMenuItem")
- private static let overlayDefaultLevel = NSWindow.Level.normal
- private static let overlayTextInputLevel = NSWindow.Level.normal
+ private static let launcherOverlayLevel = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue - 1)
+ private static let overlayDefaultLevel = launcherOverlayLevel
+ private static let overlayTextInputLevel = launcherOverlayLevel
private var statusItem: NSStatusItem?
private var overlayWindow: NSWindow?
@@ -50,6 +55,14 @@
private var isEditingAppNote = false
private var isConfiguringApplicationMenu = false
private var lastShowDockIcon: Bool?
+ private var statusMenuScreenForNextOverlay: NSScreen?
+ private var overlayGeneration = 0
+ private var suppressReopenUntil = Date.distantPast
+
+ private struct OverlayPlacementContext {
+ let screen: NSScreen
+ let frame: NSRect
+ }
private var isOverlayVisible: Bool {
overlayWindow?.isVisible == true
@@ -103,11 +116,17 @@
observeChromeSettings()
observeLanguageChanges()
setupLaunchAtLogin()
- configureApplicationMenuWhenAvailable()
+ suppressReopenUntil = Date().addingTimeInterval(1.0)
+ configureApplicationMenuWhenAvailable(retries: 200)
+ }
+
+ func applicationDidBecomeActive(_ notification: Notification) {
+ configureApplicationMenuWhenAvailable(retries: 12)
}
/// Dock icon click → show overlay (same as menubar "Show TagLauncher")
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ guard Date() >= suppressReopenUntil else { return false }
showOverlay()
return false // Suppress default "unhide all windows" behavior
}
@@ -163,13 +182,61 @@
}
}
- private func beginLauncherForegroundOwnership(activate: Bool = true) {
+ private func beginLauncherForegroundOwnership(activate: Bool = true, keyWindow: NSWindow? = nil) {
if NSApp.activationPolicy() != .regular {
NSApp.setActivationPolicy(.regular)
}
if activate {
- NSApp.activate(ignoringOtherApps: true)
- configureApplicationMenuWhenAvailable(retries: 4)
+ claimLauncherForeground(keyWindow: keyWindow)
+ }
+ }
+
+ private func claimLauncherForeground(
+ keyWindow: NSWindow? = nil,
+ retries: Int = 0,
+ overlayGeneration expectedOverlayGeneration: Int? = nil
+ ) {
+ if NSApp.activationPolicy() != .regular {
+ NSApp.setActivationPolicy(.regular)
+ }
+
+ if isOverlayVisible && !NSApp.presentationOptions.contains(.hideDock) {
+ NSApp.presentationOptions = [.hideDock]
+ }
+
+ NSApp.unhide(nil)
+ NSApp.activate(ignoringOtherApps: true)
+
+ if let keyWindow, keyWindow.isVisible {
+ keyWindow.makeKeyAndOrderFront(nil)
+ keyWindow.makeMain()
+ keyWindow.orderFrontRegardless()
+ }
+ configureApplicationMenuWhenAvailable(retries: 4)
+
+ let overlayShouldYieldToSettings = keyWindow == overlayWindow && isSettingsVisible
+ let keyWindowStillNeedsFocus = !overlayShouldYieldToSettings
+ && keyWindow?.isVisible == true
+ && keyWindow?.isKeyWindow == false
+ let shouldRetry = !NSApp.isActive
+ || !NSApp.presentationOptions.contains(.hideDock)
+ || keyWindowStillNeedsFocus
+ guard retries > 0, isOverlayVisible else { return }
+ guard shouldRetry else { return }
+
+ let retryWindow = keyWindow
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
+ guard let self, self.isOverlayVisible else { return }
+ if let expectedOverlayGeneration,
+ expectedOverlayGeneration != self.overlayGeneration {
+ return
+ }
+ self.refreshLauncherChromeState()
+ self.claimLauncherForeground(
+ keyWindow: retryWindow,
+ retries: retries - 1,
+ overlayGeneration: expectedOverlayGeneration
+ )
}
}
@@ -190,8 +257,12 @@
}
if activate && requiresForegroundOwnership {
- NSApp.activate(ignoringOtherApps: true)
- configureApplicationMenuWhenAvailable(retries: 4)
+ let keyWindow = isSettingsVisible ? settingsWindow : (isOverlayVisible ? overlayWindow : nil)
+ claimLauncherForeground(
+ keyWindow: keyWindow,
+ retries: isOverlayVisible && !isSettingsVisible ? 5 : 0,
+ overlayGeneration: isOverlayVisible ? overlayGeneration : nil
+ )
}
}
@@ -200,13 +271,12 @@
NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil)
}
- guard isOverlayVisible else {
- refreshLauncherChromeState()
+ guard !isOverlayVisible else {
return
}
-
- // Once another app takes the menu bar, don't leave the overlay stranded onscreen.
- hideOverlay(force: true)
+ if !requiresForegroundOwnership {
+ refreshLauncherChromeState()
+ }
}
/// Keep app chrome in sync when language changes from any entry point.
@@ -295,14 +365,15 @@
button.imageScaling = .scaleProportionallyDown
button.imagePosition = .imageOnly
button.toolTip = "TagLauncher — Tag-based app launcher"
- button.action = #selector(toggleOverlay)
+ button.action = #selector(toggleOverlay(_:))
button.target = self
}
let menu = NSMenu()
+ menu.delegate = self
let showItem = NSMenuItem(
title: showAppListMenuTitle,
- action: #selector(toggleOverlay),
+ action: #selector(toggleOverlayFromStatusMenu(_:)),
keyEquivalent: ""
)
showItem.target = self
@@ -323,7 +394,7 @@
menu.addItem(.separator())
let prefsItem = NSMenuItem(
title: tr("menu.preferences"),
- action: #selector(openPreferences),
+ action: #selector(openPreferences(_:)),
keyEquivalent: ","
)
prefsItem.target = self
@@ -353,6 +424,19 @@
)
)
statusItem.menu = menu
+ }
+
+ func menuWillOpen(_ menu: NSMenu) {
+ statusMenuScreenForNextOverlay = screenContainingCurrentPointer()
+ ?? statusItem?.button?.window?.screen
+ ?? NSScreen.main
+ ?? NSScreen.screens.first
+ }
+
+ func menuDidClose(_ menu: NSMenu) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
+ self?.statusMenuScreenForNextOverlay = nil
+ }
}
private func makeMenuBarIcon() -> NSImage {
@@ -518,7 +602,7 @@
private func configureShowAppListItem(_ item: NSMenuItem) {
item.title = showAppListMenuTitle
- item.action = #selector(toggleOverlay)
+ item.action = #selector(toggleOverlayFromStatusMenu(_:))
item.target = self
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
@@ -539,7 +623,7 @@
}
item.title = tr("menu.preferences")
- item.action = #selector(openPreferences)
+ item.action = #selector(openPreferences(_:))
item.target = self
item.keyEquivalent = ","
item.keyEquivalentModifierMask = .command
@@ -548,7 +632,7 @@
private func isSettingsMenuItem(_ item: NSMenuItem) -> Bool {
item.action == Selector(("showSettingsWindow:"))
- || item.action == #selector(openPreferences)
+ || item.action == #selector(openPreferences(_:))
|| item.title == tr("menu.preferences")
}
@@ -580,78 +664,106 @@
// MARK: - Overlay Window
- @objc private func toggleOverlay() {
- if overlayWindow?.isVisible == true {
- hideOverlay(force: true)
- } else {
- showOverlay()
+ @objc func toggleOverlay(_ sender: Any) {
+ performToggleOverlay(preferredScreen: nil)
+ }
+
+ @objc func toggleOverlayFromStatusMenu(_ sender: Any) {
+ let preferredScreen = statusMenuScreenForNextOverlay
+ statusMenuScreenForNextOverlay = nil
+ DispatchQueue.main.async { [weak self] in
+ self?.performToggleOverlay(preferredScreen: preferredScreen)
}
}
- private func showOverlay(initialQuickSearchSource: String? = nil) {
- // Use the screen under the mouse cursor — works in fullscreen spaces
- let mousePoint = NSEvent.mouseLocation
- guard let screen = NSScreen.screens.first(where: {
- NSMouseInRect(mousePoint, $0.frame, false)
- }) ?? NSScreen.main ?? NSScreen.screens.first else { return }
-
- let window: NSWindow
- let createdWindow: Bool
- if let existingWindow = overlayWindow {
- window = existingWindow
- createdWindow = false
+ private func performToggleOverlay(preferredScreen: NSScreen?) {
+ if overlayWindow?.isVisible == true {
+ hideOverlay(force: true)
} else {
- window = makeOverlayWindow(on: screen, initialQuickSearchSource: initialQuickSearchSource)
- overlayWindow = window
- createdWindow = true
+ showOverlay(preferredScreen: preferredScreen)
}
+ }
- installOverlayKeyMonitor()
- beginLauncherForegroundOwnership()
+ private func showOverlay(
+ initialQuickSearchSource: String? = nil,
+ preferredScreen: NSScreen? = nil,
+ stagedForAllSpaces: Bool = false
+ ) {
+ guard let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen) else { return }
- let targetFrame = screen.frame
- let targetLevel = overlayLevel(initialQuickSearchSource: initialQuickSearchSource)
- let canReuseVisibleWindow = !createdWindow
- && window.isVisible
- && NSEqualRects(window.frame, targetFrame)
- && window.level == targetLevel
-
- if canReuseVisibleWindow {
- if !window.isKeyWindow {
- window.makeKeyAndOrderFront(nil)
+ if !stagedForAllSpaces && !isSettingsVisible && NSApp.isActive {
+ // Let the all-spaces panel attach to the pointer's display before the app reclaims focus.
+ if NSApp.activationPolicy() != .accessory {
+ NSApp.setActivationPolicy(.accessory)
}
- refreshLauncherChromeState(activate: true)
- if let initialQuickSearchSource {
- NotificationCenter.default.post(
- name: .tagLauncherQuickSearchRequested,
- object: nil,
- userInfo: ["source": initialQuickSearchSource]
+ NSApp.deactivate()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
+ self?.showOverlay(
+ initialQuickSearchSource: initialQuickSearchSource,
+ preferredScreen: placement.screen,
+ stagedForAllSpaces: true
)
}
return
}
- window.setFrame(targetFrame, display: true)
- window.level = targetLevel
-
- if let initialQuickSearchSource, !createdWindow {
- NotificationCenter.default.post(
- name: .tagLauncherQuickSearchRequested,
- object: nil,
- userInfo: ["source": initialQuickSearchSource]
- )
+ if overlayWindow?.isVisible == true {
+ hideOverlay(force: true)
+ } else if let existingWindow = overlayWindow {
+ existingWindow.orderOut(nil)
+ overlayWindow = nil
}
+
+ let window = makeOverlayWindow(
+ on: placement.screen,
+ initialQuickSearchSource: initialQuickSearchSource
+ )
+ overlayGeneration &+= 1
+ overlayWindow = window
+ installOverlayKeyMonitor()
+ if !isSettingsVisible {
+ if NSApp.isActive {
+ NSApp.deactivate()
+ }
+ if NSApp.activationPolicy() != .accessory {
+ NSApp.setActivationPolicy(.accessory)
+ }
+ }
+
+ let targetLevel = overlayLevel(initialQuickSearchSource: initialQuickSearchSource)
+ window.setFrame(placement.frame, display: true)
+ window.level = targetLevel
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
- refreshLauncherChromeState(activate: true)
- NotificationCenter.default.post(name: .tagLauncherOverlayDidShow, object: nil)
+
+ let placementFrame = placement.frame
+ let finishForegroundClaim: () -> Void = { [weak self, weak window] in
+ guard let self, let window, self.overlayWindow === window else { return }
+ self.refreshLauncherChromeState(activate: true)
+ if window.frame != placementFrame {
+ window.setFrame(placementFrame, display: true)
+ window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
+ }
+ if let settingsWindow = self.settingsWindow, settingsWindow.isVisible {
+ self.prepareSettingsWindow(settingsWindow)
+ }
+ NotificationCenter.default.post(name: .tagLauncherOverlayDidShow, object: nil)
+ }
+
+ if !isSettingsVisible && stagedForAllSpaces {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: finishForegroundClaim)
+ } else {
+ finishForegroundClaim()
+ }
}
private func installOverlayKeyMonitor() {
guard overlayKeyMonitor == nil else { return }
overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
+ guard self.shouldHandleOverlayKeyEvent(event) else { return event }
if event.keyCode == 53 { // Escape
if self.isQuickSearchOpen {
NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil)
@@ -670,6 +782,12 @@
}
return event
}
+ }
+
+ private func shouldHandleOverlayKeyEvent(_ event: NSEvent) -> Bool {
+ guard overlayWindow?.isVisible == true else { return false }
+ if event.window == overlayWindow { return true }
+ return event.window == nil && NSApp.keyWindow == overlayWindow
}
private func shouldOpenQuickSearch(for event: NSEvent) -> Bool {
@@ -715,21 +833,22 @@
private func makeOverlayWindow(on screen: NSScreen, initialQuickSearchSource: String? = nil) -> NSWindow {
let panel = OverlayPanel(
contentRect: screen.frame,
- styleMask: [.borderless, .fullSizeContentView],
+ styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
+ // This combination keeps the overlay on the pointer display, including fullscreen Spaces.
panel.collectionBehavior = [
- .moveToActiveSpace,
+ .canJoinAllSpaces,
.fullScreenAuxiliary,
.stationary,
.transient,
.ignoresCycle
]
panel.isOpaque = false
- panel.backgroundColor = .clear
+ panel.backgroundColor = NSColor.black.withAlphaComponent(0.001)
panel.hasShadow = false
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
@@ -750,6 +869,7 @@
private func hideOverlay(force: Bool = false, discardWindow: Bool = false) {
guard force || !isInEditMode else { return }
+ overlayGeneration &+= 1
TagDatabase.flushPendingCategorySchemeBackupBatch()
if let settingsWindow, settingsWindow.parent == overlayWindow {
detachSettingsWindow(settingsWindow)
@@ -856,12 +976,21 @@
if id == LauncherHotkeyKind.quickSearch.eventID {
showQuickSearchFromGlobalHotkey()
} else {
- toggleOverlay()
+ performToggleOverlay(preferredScreen: nil)
}
}
private func showQuickSearchFromGlobalHotkey() {
- guard overlayWindow?.isVisible != true else { return }
+ if overlayWindow?.isVisible == true {
+ guard !isSettingsVisible else { return }
+ refreshLauncherChromeState(activate: true)
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchRequested,
+ object: nil,
+ userInfo: ["source": QuickSearchOpenSource.globalVisible]
+ )
+ return
+ }
showOverlay(initialQuickSearchSource: QuickSearchOpenSource.globalHidden)
}
@@ -901,6 +1030,10 @@
keyWindow != self.overlayWindow,
!self.isInEditMode
else { return }
+
+ if self.isSettingsOwnedPanel(keyWindow) {
+ return
+ }
// Settings/Preferences window -> float it above overlay for real-time preview.
if self.isSettingsWindowCandidate(keyWindow) {
@@ -967,6 +1100,15 @@
return true
}
+ private func isSettingsOwnedPanel(_ window: NSWindow) -> Bool {
+ guard window is NSPanel else { return false }
+ if window is NSSavePanel { return true }
+ guard let settingsWindow else { return false }
+ return window.sheetParent == settingsWindow
+ || settingsWindow.attachedSheet == window
+ || window.parent == settingsWindow
+ }
+
private func center(_ window: NSWindow, over rect: NSRect) {
let frame = window.frame
let origin = NSPoint(
@@ -977,10 +1119,24 @@
}
private func screenUnderMouse() -> NSScreen? {
+ overlayPlacementContextForNextOverlay(preferredScreen: nil)?.screen
+ }
+
+ private func overlayPlacementContextForNextOverlay(preferredScreen: NSScreen?) -> OverlayPlacementContext? {
+ if let screen = preferredScreen {
+ return OverlayPlacementContext(screen: screen, frame: screen.frame)
+ }
+ guard let screen = screenContainingCurrentPointer() ?? NSScreen.main ?? NSScreen.screens.first else {
+ return nil
+ }
+ return OverlayPlacementContext(screen: screen, frame: screen.frame)
+ }
+
+ private func screenContainingCurrentPointer() -> NSScreen? {
let mousePoint = NSEvent.mouseLocation
return NSScreen.screens.first(where: {
NSMouseInRect(mousePoint, $0.frame, false)
- }) ?? NSScreen.main ?? NSScreen.screens.first
+ })
}
/// Clean up settingsWindow reference when the Settings window closes.
@@ -1011,7 +1167,7 @@
}
}
- /// Lower the overlay while text input is active so IME and cursor services are not hidden behind it.
+ /// Keep text-input overlays at the launcher level so Quick Search stays visible in fullscreen Spaces.
private func observeAppNoteEditing() {
NotificationCenter.default.addObserver(
forName: .tagLauncherAppNoteEditingChanged,
@@ -1020,8 +1176,18 @@
) { [weak self] notification in
guard let self else { return }
self.isEditingAppNote = (notification.userInfo?["active"] as? Bool) ?? false
+ if self.isEditingAppNote {
+ self.promoteOverlayToForegroundInput()
+ }
self.updateOverlayLevelForTextInput()
}
+ }
+
+ private func promoteOverlayToForegroundInput() {
+ beginLauncherForegroundOwnership()
+ guard let overlayWindow else { return }
+ overlayWindow.makeKeyAndOrderFront(nil)
+ overlayWindow.orderFrontRegardless()
}
private func observeQuickSearch() {
@@ -1032,6 +1198,9 @@
) { [weak self] notification in
guard let self else { return }
self.isQuickSearchOpen = (notification.userInfo?["active"] as? Bool) ?? false
+ if self.isQuickSearchOpen {
+ self.promoteOverlayToForegroundInput()
+ }
self.updateOverlayLevelForTextInput()
if self.isQuickSearchOpen {
self.installQuickSearchExternalMouseMonitor()
@@ -1095,7 +1264,7 @@
}
}
- @objc private func openPreferences() {
+ @objc private func openPreferences(_ sender: Any? = nil) {
TagDatabase.flushPendingCategorySchemeBackupBatch()
beginLauncherForegroundOwnership()
// Don't hide overlay — keep it visible for real-time setting preview.
@@ -1133,29 +1302,46 @@
final class DismissibleHostingView<Content: View>: NSHostingView<Content> {
private let onBackdropTap: () -> Void
- private var suppressBackdropDismiss = false
+ private var modalInteractionSuppressesBackdropDismiss = false
+ private var quickSearchSuppressesBackdropDismiss = false
private var modalInteractionObserver: NSObjectProtocol?
+ private var quickSearchVisibilityObserver: NSObjectProtocol?
+
+ private var suppressBackdropDismiss: Bool {
+ modalInteractionSuppressesBackdropDismiss || quickSearchSuppressesBackdropDismiss
+ }
@MainActor required init(rootView: Content) {
self.onBackdropTap = {}
super.init(rootView: rootView)
- observeModalInteractionChanges()
+ installWindowServerAnchorLayer()
+ observeBackdropDismissSuppressionChanges()
}
init(rootView: Content, onBackdropTap: @escaping () -> Void) {
self.onBackdropTap = onBackdropTap
super.init(rootView: rootView)
- observeModalInteractionChanges()
+ installWindowServerAnchorLayer()
+ observeBackdropDismissSuppressionChanges()
}
deinit {
if let modalInteractionObserver {
NotificationCenter.default.removeObserver(modalInteractionObserver)
}
+ if let quickSearchVisibilityObserver {
+ NotificationCenter.default.removeObserver(quickSearchVisibilityObserver)
+ }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
+
+ private func installWindowServerAnchorLayer() {
+ wantsLayer = true
+ // A near-transparent backing pixel makes the WindowServer publish the panel immediately.
+ layer?.backgroundColor = NSColor.black.withAlphaComponent(0.001).cgColor
+ }
override func mouseDown(with event: NSEvent) {
let location = convert(event.locationInWindow, from: nil)
@@ -1164,6 +1350,10 @@
return
}
if hit == self {
+ if quickSearchSuppressesBackdropDismiss {
+ NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil)
+ return
+ }
if suppressBackdropDismiss {
super.mouseDown(with: event)
return
@@ -1190,13 +1380,21 @@
super.mouseDown(with: event)
}
- private func observeModalInteractionChanges() {
+ private func observeBackdropDismissSuppressionChanges() {
modalInteractionObserver = NotificationCenter.default.addObserver(
forName: .tagLauncherModalInteractionChanged,
object: nil,
queue: .main
) { [weak self] notification in
- self?.suppressBackdropDismiss = (notification.userInfo?["active"] as? Bool) ?? false
+ self?.modalInteractionSuppressesBackdropDismiss = (notification.userInfo?["active"] as? Bool) ?? false
+ }
+
+ quickSearchVisibilityObserver = NotificationCenter.default.addObserver(
+ forName: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ self?.quickSearchSuppressesBackdropDismiss = (notification.userInfo?["active"] as? Bool) ?? false
}
}
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 7ba68bf..c163918 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -1,6 +1,5 @@
import SwiftUI
import AppKit
-import UniformTypeIdentifiers
// MARK: - Notification for manual re-index
@@ -315,6 +314,7 @@
let id = UUID()
let app: AppInfo
let assignedTags: [String]
+ let removableTags: [String]
}
private enum SmartStartNoticeMode {
@@ -339,11 +339,7 @@
@State private var allApps: [AppInfo] = []
@State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
- @State private var scrollProxy: ScrollViewProxy? = nil
- @StateObject private var scrollInteractionState = AppGridScrollInteractionState()
@State private var groupLayoutVersion = 0
- @State private var cachedGridContainerRowsKey: GridContainerRowsKey? = nil
- @State private var cachedGridContainerRows: [GridContainerLayoutRow] = []
@State private var appGridScrollTargetID: String? = nil
@State private var appGridScrollRequestToken = 0
@@ -361,7 +357,6 @@
@State private var tagNavDragItem: String? = nil
@State private var tagNavReorderFrames: [String: CGRect] = [:]
@State private var tagNavReorderDidMove = false
- @State private var hoveredContainer: String? = nil // colored container lift
// Fixed interaction for "Colorless Container": hover fills persistently; click clears.
@State private var filledColorlessContainer: String? = nil
@State private var appDragModeActive = false
@@ -417,15 +412,9 @@
private let floatingControlsTrailingInset: CGFloat = 20
private let floatingControlsReservedWidth: CGFloat = 120
private var appBubbleDisabled: Bool {
- appDragModeActive || pendingUncategorizedDrop != nil || scrollInteractionState.isFrozen
+ appDragModeActive || pendingUncategorizedDrop != nil
}
private let rightSidebarFloatingClearance: CGFloat = 44
-
- private var cardSurfaceColor: Color {
- colorScheme == .dark
- ? Color.white.opacity(0.055)
- : Color.white.opacity(0.62)
- }
private var floatingButtonSurfaceColor: Color {
colorScheme == .dark
@@ -445,23 +434,19 @@
displayMode == "container" || displayMode == "gridContainer"
}
- private var isColoredContainerMode: Bool {
- displayMode == "coloredContainer" || displayMode == "coloredGridContainer"
- }
-
- private var usesAppKitContainerGrid: Bool {
- displayMode == "gridContainer" || displayMode == "coloredGridContainer"
+ private var shouldRenderAppGridBehindQuickSearch: Bool {
+ !quickSearchVisible || !quickSearchCloseHidesOverlay
}
var body: some View {
ZStack {
- if !quickSearchVisible {
+ if shouldRenderAppGridBehindQuickSearch {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
.allowsHitTesting(false)
}
- if !quickSearchVisible {
+ if shouldRenderAppGridBehindQuickSearch {
if notchHeight > 0 {
VStack {
Rectangle().fill(.black)
@@ -489,7 +474,7 @@
quickSearchOverlay
- if !quickSearchVisible, let message = dropWarningToast {
+ if shouldRenderAppGridBehindQuickSearch, let message = dropWarningToast {
Text(message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
@@ -504,7 +489,7 @@
.allowsHitTesting(false)
}
- if !quickSearchVisible && dropRefreshVisible {
+ if shouldRenderAppGridBehindQuickSearch && dropRefreshVisible {
Color.black.opacity(0.08)
.ignoresSafeArea()
.transition(.opacity)
@@ -955,7 +940,7 @@
Spacer()
ProgressView().scaleEffect(0.8)
Spacer()
- } else if usesAppKitContainerGrid {
+ } else {
AppGridCollectionView(
groups: displayGroups,
tagColors: tagColors,
@@ -975,17 +960,13 @@
dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
},
onGroupActivate: { groupName in
- if displayMode == "gridContainer" {
+ if isColorlessContainerMode {
toggleColorlessFill(groupName)
}
},
onScrollActivity: handleAppGridScrollActivity,
onDragModeChange: { setAppDragMode($0) }
)
- } else if displayMode == "container" || displayMode == "coloredContainer" {
- containerGrid
- } else {
- flatGrid
}
}
}
@@ -1007,7 +988,7 @@
)
AppNameBubble(
- appName: context.app.name,
+ appName: context.app.displayName,
note: currentNote(for: context.app),
isEditing: editing,
placement: placement,
@@ -1112,538 +1093,6 @@
.ignoresSafeArea()
.allowsHitTesting(smartStartNotice != nil)
}
- private var flatGrid: some View {
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 24) {
- ForEach(displayGroups) { group in
- TagGroupView(
- group: group,
- onSelectApp: { app in openApp(app) },
- tagFontSize: tagFontSize,
- iconSize: iconSize,
- showNames: !hideAppNames,
- dragModeActive: appDragModeActive,
- onDragModeChange: { setAppDragMode($0) },
- onBubbleHover: handleBubbleHover,
- onEditNote: beginEditingBubbleNote,
- bubbleDisabled: appBubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- dragResetToken: appDragResetToken,
- onDropApp: { path, source, copy in
- dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
- }
- ).id(group.id)
- }
- }
- .padding(20)
- .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
- }
- .id(displayMode) // force rebuild on mode switch
- .onAppear { scrollProxy = proxy }
- }
- }
-
- private var containerGrid: some View {
- GeometryReader { geo in
- let outerPad: CGFloat = 20
- let gap: CGFloat = 16
- let available = geo.size.width - outerPad * 2
- let colW: CGFloat = 280
- let colCount = max(1, Int((available + gap) / (colW + gap)))
- let actualColW = (available - gap * CGFloat(colCount - 1)) / CGFloat(colCount)
-
- let columns = distributeToColumns(groups: displayGroups, colCount: colCount, colWidth: actualColW)
-
- ScrollViewReader { proxy in
- ScrollView {
- HStack(alignment: .top, spacing: gap) {
- ForEach(0..<colCount, id: \.self) { ci in
- LazyVStack(spacing: gap) {
- ForEach(columns[ci]) { group in
- masonryCard(group, width: actualColW)
- .id(group.id)
- }
- }
- }
- }
- .padding(outerPad)
- .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
- }
- .id(displayMode) // force rebuild on mode switch
- .onAppear { scrollProxy = proxy }
- }
- }
- }
-
- /// Distribute groups to the shortest column.
- private func distributeToColumns(groups: [TagGroup], colCount: Int, colWidth: CGFloat) -> [[TagGroup]] {
- var cols = Array(repeating: [TagGroup](), count: colCount)
- var h = Array(repeating: CGFloat(0), count: colCount)
- for g in groups {
- let est = estimatedCardHeight(g, width: colWidth)
- let ci = h.firstIndex(of: h.min()!)!
- cols[ci].append(g)
- h[ci] += est + 16
- }
- return cols
- }
-
- private func estimatedCardHeight(_ group: TagGroup, width: CGFloat) -> CGFloat {
- let inner = width - 32
- let itemW = AppGridItem.stableWidth(iconSize: iconSize) + 6
- let perRow = max(1, Int(inner / itemW))
- let rows = (group.apps.count + perRow - 1) / perRow
- return 32
- + CGFloat(rows) * AppGridItem.stableHeight(iconSize: iconSize)
- + CGFloat(max(0, rows - 1)) * 2
- }
-
- private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View {
- let isColored = displayMode == "coloredContainer"
- let isColorless = isColorlessContainerMode
- let isColorlessFilled = isColorless && filledColorlessContainer == group.name
- let isHovered = hoveredContainer == group.name
- let isColorlessActive = isColorless && (isColorlessFilled || isHovered)
- let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0))
- return VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 0) {
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- Text(group.name)
- .font(.system(size: tagFontSize, weight: .semibold))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .padding(.horizontal, 10)
- .layoutPriority(1)
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- }
- let itemSize = AppGridItem.stableWidth(iconSize: iconSize)
- LazyVGrid(
- columns: [GridItem(.adaptive(minimum: itemSize, maximum: itemSize + 36), spacing: 6)],
- spacing: 2
- ) {
- ForEach(group.apps) { app in
- AppGridItem(
- app: app,
- iconSize: iconSize,
- showName: !hideAppNames,
- sourceTag: group.name,
- dragModeActive: appDragModeActive,
- onDragModeChange: { setAppDragMode($0) },
- onBubbleHover: handleBubbleHover,
- onEditNote: beginEditingBubbleNote,
- bubbleDisabled: appBubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- itemID: "\(group.name)|\(app.path.path)",
- dragResetToken: appDragResetToken,
- onSelect: { openApp(app) }
- )
- }
- }
- }
- .frame(maxWidth: width)
- .padding(16)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill((isColored || isColorlessActive) ? tagColor.opacity(0.30) : Color.clear)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill(cardSurfaceColor)
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 14)
- .stroke(Color.primary.opacity(0.08), lineWidth: 1)
- )
- .overlay {
- if appDragModeActive {
- AppDropTargetView(targetTag: group.name) { path, source, copy in
- dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
- }
- .allowsHitTesting(false)
- }
- }
- .shadow(color: .black.opacity((isColored && isHovered) || isColorlessActive ? 0.22 : 0),
- 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)
- .background(AppHoverTrackingView { hovering, _ in
- guard !scrollInteractionState.isFrozen else {
- if !hovering && hoveredContainer == group.name {
- hoveredContainer = nil
- }
- return
- }
- if isColored || isColorless {
- hoveredContainer = hovering ? group.name : nil
- }
- })
- .contentShape(RoundedRectangle(cornerRadius: 14))
- .onTapGesture {
- if isColorless {
- toggleColorlessFill(group.name)
- }
- }
- .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
- handleAppDrop(providers, targetTag: group.name)
- }
- }
-
- private var gridContainerGrid: some View {
- GeometryReader { geo in
- let outerPad: CGFloat = 20
- let gap: CGFloat = 16
- let available = geo.size.width - outerPad * 2
- let preferredCount = preferredGridContainersPerRow(availableWidth: available)
- let rowsKey = GridContainerRowsKey(
- groupLayoutVersion: groupLayoutVersion,
- trackCount: preferredCount,
- availableWidth: Int(available.rounded()),
- iconSize: Int(iconSize.rounded())
- )
- let rows = gridContainerRowsForRender(
- key: rowsKey,
- groups: displayGroups,
- trackCount: preferredCount,
- availableWidth: available,
- gap: gap
- )
-
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(alignment: .leading, spacing: gap) {
- ForEach(Array(rows.indices), id: \.self) { rowIndex in
- let row = rows[rowIndex]
-
- HStack(alignment: .top, spacing: gap) {
- ForEach(Array(row.items.indices), id: \.self) { itemIndex in
- let item = row.items[itemIndex]
- gridContainerCard(item.group, width: item.width, fixedRows: row.fixedRows)
- .id(item.group.id)
- }
- }
- }
- }
- .padding(outerPad)
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
- }
- .id(displayMode)
- .onAppear { scrollProxy = proxy }
- .onAppear {
- updateCachedGridContainerRows(
- key: rowsKey,
- groups: displayGroups,
- trackCount: preferredCount,
- availableWidth: available,
- gap: gap
- )
- }
- .onChange(of: rowsKey) { _, newKey in
- updateCachedGridContainerRows(
- key: newKey,
- groups: displayGroups,
- trackCount: preferredCount,
- availableWidth: available,
- gap: gap
- )
- }
- }
- }
- }
-
- private func preferredGridContainersPerRow(availableWidth: CGFloat) -> Int {
- let minCardWidth = max(260, AppGridItem.stableWidth(iconSize: iconSize) * 3 + 64)
- if availableWidth >= minCardWidth * 3 + 32 { return 3 }
- if availableWidth >= minCardWidth * 2 + 16 { return 2 }
- return 1
- }
-
- private struct GridContainerLayoutItem {
- let group: TagGroup
- let width: CGFloat
- }
-
- private struct GridContainerLayoutRow {
- let items: [GridContainerLayoutItem]
- let fixedRows: Int
- }
-
- private struct GridContainerRowsKey: Equatable {
- let groupLayoutVersion: Int
- let trackCount: Int
- let availableWidth: Int
- let iconSize: Int
- }
-
- private struct GridContainerCandidate {
- let spans: [Int]
- let rows: Int
- let cost: CGFloat
- }
-
- private func gridContainerRowsForRender(
- key: GridContainerRowsKey,
- groups: [TagGroup],
- trackCount: Int,
- availableWidth: CGFloat,
- gap: CGFloat
- ) -> [GridContainerLayoutRow] {
- if cachedGridContainerRowsKey == key {
- return cachedGridContainerRows
- }
- return gridContainerRows(
- groups: groups,
- trackCount: trackCount,
- availableWidth: availableWidth,
- gap: gap
- )
- }
-
- private func updateCachedGridContainerRows(
- key: GridContainerRowsKey,
- groups: [TagGroup],
- trackCount: Int,
- availableWidth: CGFloat,
- gap: CGFloat
- ) {
- guard cachedGridContainerRowsKey != key else { return }
- cachedGridContainerRows = gridContainerRows(
- groups: groups,
- trackCount: trackCount,
- availableWidth: availableWidth,
- gap: gap
- )
- cachedGridContainerRowsKey = key
- }
-
- private func gridContainerRows(groups: [TagGroup], trackCount: Int, availableWidth: CGFloat, gap: CGFloat) -> [GridContainerLayoutRow] {
- let trackCount = max(1, trackCount)
- let trackWidth = (availableWidth - gap * CGFloat(trackCount - 1)) / CGFloat(trackCount)
- let patterns = gridContainerSpanPatterns(trackCount: trackCount)
- let n = groups.count
- guard n > 0 else { return [] }
-
- var bestCost = Array(repeating: CGFloat.greatestFiniteMagnitude, count: n + 1)
- var bestPattern = Array(repeating: [Int](), count: n)
- bestCost[n] = 0
-
- for index in stride(from: n - 1, through: 0, by: -1) {
- for pattern in patterns where index + pattern.count <= n {
- let candidate = gridContainerCandidate(
- groups: groups,
- startIndex: index,
- spans: pattern,
- trackWidth: trackWidth,
- availableWidth: availableWidth,
- gap: gap
- )
- let totalCost = candidate.cost + bestCost[index + pattern.count]
- if totalCost < bestCost[index] {
- bestCost[index] = totalCost
- bestPattern[index] = candidate.spans
- }
- }
- }
-
- var rows: [GridContainerLayoutRow] = []
- var index = 0
- while index < n {
- let spans = bestPattern[index].isEmpty ? [trackCount] : bestPattern[index]
- let widths = spans.map { gridContainerWidth(trackWidth: trackWidth, span: $0, gap: gap) }
- let fixedRows = widths.indices.map {
- iconRows(appCount: groups[index + $0].apps.count, width: widths[$0])
- }.max() ?? 1
- let items = widths.indices.map {
- GridContainerLayoutItem(group: groups[index + $0], width: widths[$0])
- }
- rows.append(GridContainerLayoutRow(items: items, fixedRows: fixedRows))
- index += spans.count
- }
- return rows
- }
-
- private func gridContainerSpanPatterns(trackCount: Int) -> [[Int]] {
- switch trackCount {
- case 3:
- return [[1, 1, 1], [1, 2], [2, 1], [3]]
- case 2:
- return [[1, 1], [2]]
- default:
- return [[1]]
- }
- }
-
- private func gridContainerCandidate(
- groups: [TagGroup],
- startIndex: Int,
- spans: [Int],
- trackWidth: CGFloat,
- availableWidth: CGFloat,
- gap: CGFloat
- ) -> GridContainerCandidate {
- let widths = spans.map { gridContainerWidth(trackWidth: trackWidth, span: $0, gap: gap) }
- let rowCounts = widths.indices.map {
- iconRows(appCount: groups[startIndex + $0].apps.count, width: widths[$0])
- }
- let fixedRows = rowCounts.max() ?? 1
- let rowArea = CGFloat(fixedRows) * iconCellHeight() * availableWidth
- let paddingCost = CGFloat(spans.count) * 0.001
- return GridContainerCandidate(spans: spans, rows: fixedRows, cost: rowArea + paddingCost)
- }
-
- private func gridContainerWidth(trackWidth: CGFloat, span: Int, gap: CGFloat) -> CGFloat {
- let span = max(1, span)
- return trackWidth * CGFloat(span) + gap * CGFloat(span - 1)
- }
-
- private func iconColumns(width: CGFloat) -> Int {
- let inner = width - 32
- let itemW = AppGridItem.stableWidth(iconSize: iconSize) + 6
- return max(1, Int((inner + 6) / itemW))
- }
-
- private func iconRows(appCount: Int, width: CGFloat) -> Int {
- let cols = iconColumns(width: width)
- return max(1, (appCount + cols - 1) / cols)
- }
-
- private func gridContainerCard(_ group: TagGroup, width: CGFloat, fixedRows: Int) -> some View {
- let isColored = displayMode == "coloredGridContainer"
- let isColorlessGrid = displayMode == "gridContainer"
- let isColorless = isColorlessContainerMode
- let isColorlessFilled = isColorless && filledColorlessContainer == group.name
- let isHovered = hoveredContainer == group.name
- let isColorlessGridActive = isColorlessGrid && (isColorlessFilled || isHovered)
- let cols = iconColumns(width: width)
- let contentWidth = max(1, width - 32)
- let cellWidth = max(1, (contentWidth - 6 * CGFloat(cols - 1)) / CGFloat(cols))
- let cellHeight = iconCellHeight()
- let gridHeight = CGFloat(fixedRows) * cellHeight + CGFloat(max(0, fixedRows - 1)) * 2
- let rows = appRows(group.apps, columns: cols, fixedRows: fixedRows)
- let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0))
-
- return VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 0) {
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- Text(group.name)
- .font(.system(size: tagFontSize, weight: .semibold))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .padding(.horizontal, 10)
- .layoutPriority(1)
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- }
-
- VStack(alignment: .leading, spacing: 2) {
- ForEach(rows.indices, id: \.self) { rowIndex in
- HStack(alignment: .top, spacing: 6) {
- ForEach(rows[rowIndex]) { app in
- AppGridItem(
- app: app,
- iconSize: iconSize,
- showName: !hideAppNames,
- sourceTag: group.name,
- dragModeActive: appDragModeActive,
- onDragModeChange: { setAppDragMode($0) },
- onBubbleHover: handleBubbleHover,
- onEditNote: beginEditingBubbleNote,
- bubbleDisabled: appBubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- itemID: "\(group.name)|\(app.path.path)",
- dragResetToken: appDragResetToken,
- onSelect: { openApp(app) }
- )
- .frame(width: cellWidth)
- .frame(height: cellHeight)
- }
-
- let emptyCells = max(0, cols - rows[rowIndex].count)
- ForEach(0..<emptyCells, id: \.self) { _ in
- Color.clear
- .frame(width: cellWidth, height: cellHeight)
- }
- }
- .frame(height: cellHeight)
- }
- }
- .frame(height: gridHeight, alignment: .topLeading)
- }
- .frame(width: contentWidth)
- .padding(16)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill((isColored || isColorlessGridActive) ? tagColor.opacity(0.30) : Color.clear)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill(cardSurfaceColor)
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 14)
- .stroke(Color.primary.opacity(0.08), lineWidth: 1)
- )
- .overlay {
- if appDragModeActive {
- AppDropTargetView(targetTag: group.name) { path, source, copy in
- dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
- }
- .allowsHitTesting(false)
- }
- }
- .shadow(color: .black.opacity((isColored && isHovered) || isColorlessGridActive ? 0.22 : 0),
- 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)
- .background(AppHoverTrackingView { hovering, _ in
- guard !scrollInteractionState.isFrozen else {
- if !hovering && hoveredContainer == group.name {
- hoveredContainer = nil
- }
- return
- }
- if isColored || isColorlessGrid {
- hoveredContainer = hovering ? group.name : nil
- } else if hovering {
- fillColorlessContainer(group.name)
- }
- })
- .contentShape(RoundedRectangle(cornerRadius: 14))
- .onTapGesture {
- if isColorlessGrid {
- toggleColorlessFill(group.name)
- }
- }
- .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
- handleAppDrop(providers, targetTag: group.name)
- }
- }
-
- private func iconCellHeight() -> CGFloat {
- AppGridItem.stableHeight(iconSize: iconSize)
- }
-
- private func appRows(_ apps: [AppInfo], columns: Int, fixedRows: Int) -> [[AppInfo]] {
- let columns = max(1, columns)
- let rowCount = max(1, fixedRows)
- return (0..<rowCount).map { rowIndex in
- let start = rowIndex * columns
- guard start < apps.count else { return [] }
- let end = min(start + columns, apps.count)
- return Array(apps[start..<end])
- }
- }
-
// MARK: - Edit Tags View
private var editTagsView: some View {
@@ -1768,8 +1217,8 @@
columns: [
GridItem(
.adaptive(
- minimum: AppGridItem.stableWidth(iconSize: iconSize),
- maximum: AppGridItem.stableWidth(iconSize: iconSize) + 36
+ minimum: AppGridItemMetrics.stableWidth(iconSize: iconSize),
+ maximum: AppGridItemMetrics.stableWidth(iconSize: iconSize) + 36
),
spacing: 6
)
@@ -2227,7 +1676,6 @@
private func rebuildDisplayGroups(apps: [AppInfo], tagOrder: [String]) {
displayGroups = makeDisplayGroups(apps: apps, tagOrder: tagOrder)
groupLayoutVersion &+= 1
- cachedGridContainerRowsKey = nil
}
private var tagLabels: [TagLabel] {
@@ -2261,11 +1709,7 @@
}
private func handleAppGridScrollActivity() {
- if !scrollInteractionState.isFrozen {
- hoveredBubble = nil
- hoveredContainer = nil
- }
- scrollInteractionState.noteScroll()
+ hoveredBubble = nil
}
private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
@@ -2396,12 +1840,8 @@
}
func scrollTo(_ id: String) {
- if usesAppKitContainerGrid {
- appGridScrollTargetID = id
- appGridScrollRequestToken &+= 1
- } else {
- withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) }
- }
+ appGridScrollTargetID = id
+ appGridScrollRequestToken &+= 1
}
private func activateTagNavigation(_ id: String) {
@@ -2504,33 +1944,6 @@
}
}
- private func handleAppDrop(_ providers: [NSItemProvider], targetTag: String) -> Bool {
- guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else {
- return false
- }
- provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
- let text: String?
- if let data = item as? Data {
- text = String(data: data, encoding: .utf8)
- } else if let string = item as? String {
- text = string
- } else if let string = item as? NSString {
- text = string as String
- } else {
- text = nil
- }
- guard let text else { return }
- let parts = text.components(separatedBy: "\n")
- guard let path = parts.first, !path.isEmpty else { return }
- let source = parts.dropFirst().first ?? ""
- let copy = NSEvent.modifierFlags.contains(.option)
- DispatchQueue.main.async {
- dropApp(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
- }
- }
- return true
- }
-
private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
resetTransientDragState(keepingPendingUncategorizedDrop: true)
@@ -2559,18 +1972,34 @@
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 }
+ let removableTags = removableRegularTags(for: app)
+ guard !removableTags.isEmpty else { return }
+ let assignedTags = assignedRegularDisplayTags(for: removableTags)
clearAppBubbleState()
withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
- pendingUncategorizedDrop = PendingUncategorizedDrop(app: app, assignedTags: assignedTags)
+ pendingUncategorizedDrop = PendingUncategorizedDrop(
+ app: app,
+ assignedTags: assignedTags,
+ removableTags: removableTags
+ )
}
}
- private func assignedRegularDisplayTags(for app: AppInfo) -> [String] {
+ private func removableRegularTags(for app: AppInfo) -> [String] {
var result: [String] = []
for tag in app.tags {
+ guard isRemovableRegularTag(tag) else { continue }
+ if !result.contains(tag) {
+ result.append(tag)
+ }
+ }
+ return result
+ }
+
+ private func assignedRegularDisplayTags(for tags: [String]) -> [String] {
+ var result: [String] = []
+ for tag in tags {
let name = displayTagName(tag)
if !result.contains(name) {
result.append(name)
@@ -2583,7 +2012,7 @@
formattedFeedbackMessage(
forKey: "drop.uncategorizedConfirmMessage",
replacements: [
- "%appName%": pendingDrop.app.name,
+ "%appName%": pendingDrop.app.displayName,
"%tagCount%": "\(pendingDrop.assignedTags.count)",
"%tagNames%": pendingDrop.assignedTags.joined(separator: localizedListSeparator)
]
@@ -2600,11 +2029,12 @@
private func confirmPendingUncategorizedDrop() {
guard let pendingDrop = pendingUncategorizedDrop else { return }
let path = pendingDrop.app.path.path
- let tags = pendingDrop.app.tags
+ let tags = pendingDrop.removableTags
resetTransientDragState(keepingPendingUncategorizedDrop: true)
withAnimation(.easeOut(duration: 0.16)) {
pendingUncategorizedDrop = nil
}
+ guard !tags.isEmpty else { return }
TagEditor.removeTags(tags, from: [path])
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
@@ -2624,6 +2054,18 @@
tr("group.appleBuiltIn")
]
return defaultNames.contains(targetTag)
+ }
+
+ private func isRemovableRegularTag(_ tag: String) -> Bool {
+ let protectedNames = [
+ "Mac自带",
+ tr("group.appleBuiltIn"),
+ defaultGroupName,
+ tr("group.uncategorized"),
+ TagDatabase.uncommonTagKey,
+ tr("group.uncommon")
+ ]
+ return !protectedNames.contains(tag) && tagColors[tag] != nil
}
private func showDropWarning() {
@@ -2675,7 +2117,6 @@
private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
let hadAppDragState = appDragModeActive
AppDragCoordinator.shared.cancelDrag()
- scrollInteractionState.reset()
if appDragModeActive {
appDragModeActive = false
}
@@ -2693,9 +2134,6 @@
}
if dragItem != nil {
dragItem = nil
- }
- if hoveredContainer != nil {
- hoveredContainer = nil
}
clearAppBubbleState()
if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil {
@@ -2779,135 +2217,8 @@
}
func openApp(_ app: AppInfo) {
- launchApp(app)
- }
-}
-
-private final class AppGridScrollInteractionState: ObservableObject {
- @Published var isFrozen = false
- private var unfreezeWorkItem: DispatchWorkItem?
-
- func noteScroll() {
- if !isFrozen {
- isFrozen = true
- }
- unfreezeWorkItem?.cancel()
- let workItem = DispatchWorkItem { [weak self] in
- guard let self, self.isFrozen else { return }
- self.isFrozen = false
- }
- unfreezeWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: workItem)
- }
-
- func reset() {
- unfreezeWorkItem?.cancel()
- unfreezeWorkItem = nil
- if isFrozen {
- isFrozen = false
- }
- }
-}
-
-private struct AppGridScrollActivityObserver: NSViewRepresentable {
- let onScroll: () -> Void
-
- func makeCoordinator() -> Coordinator {
- Coordinator(onScroll: onScroll)
- }
-
- func makeNSView(context: Context) -> ScrollActivityNSView {
- let view = ScrollActivityNSView()
- view.coordinator = context.coordinator
- return view
- }
-
- func updateNSView(_ view: ScrollActivityNSView, context: Context) {
- context.coordinator.onScroll = onScroll
- view.coordinator = context.coordinator
- }
-
- final class Coordinator {
- var onScroll: () -> Void
- private weak var observedClipView: NSClipView?
- private var observer: NSObjectProtocol?
- private var lastReportedBoundsOrigin: NSPoint?
-
- init(onScroll: @escaping () -> Void) {
- self.onScroll = onScroll
- }
-
- func install(from view: NSView) {
- guard let clipView = view.enclosingScrollView?.contentView else { return }
- guard observedClipView !== clipView else { return }
- removeObserver()
- observedClipView = clipView
- lastReportedBoundsOrigin = clipView.bounds.origin
- clipView.postsBoundsChangedNotifications = true
- observer = NotificationCenter.default.addObserver(
- forName: NSView.boundsDidChangeNotification,
- object: clipView,
- queue: .main
- ) { [weak self, weak clipView] _ in
- guard let self,
- let clipView,
- self.recordScrollIfNeeded(from: clipView)
- else { return }
- self.onScroll()
- }
- }
-
- private func removeObserver() {
- if let observer {
- NotificationCenter.default.removeObserver(observer)
- }
- observer = nil
- observedClipView = nil
- lastReportedBoundsOrigin = nil
- }
-
- private func recordScrollIfNeeded(from clipView: NSClipView) -> Bool {
- let origin = clipView.bounds.origin
- guard let last = lastReportedBoundsOrigin else {
- lastReportedBoundsOrigin = origin
- return false
- }
- let didScroll = abs(origin.x - last.x) > 0.5
- || abs(origin.y - last.y) > 0.5
- if didScroll {
- lastReportedBoundsOrigin = origin
- }
- return didScroll
- }
-
- deinit {
- removeObserver()
- }
- }
-
- final class ScrollActivityNSView: NSView {
- weak var coordinator: Coordinator?
- private var installScheduled = false
-
- override func viewDidMoveToWindow() {
- super.viewDidMoveToWindow()
- installObserverIfPossible()
- }
-
- override func viewDidMoveToSuperview() {
- super.viewDidMoveToSuperview()
- installObserverIfPossible()
- }
-
- func installObserverIfPossible() {
- guard !installScheduled else { return }
- installScheduled = true
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- installScheduled = false
- coordinator?.install(from: self)
- }
- }
+ hideOverlay()
+ launchApp(app, closeOverlayOnSuccess: false)
}
}
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index d1a09a8..a3fc089 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -11,9 +11,50 @@
let tags: [String]
let bundleIdentifier: String?
let localizedNames: [String]
+ let localizedNamesByLanguage: [String: String]
let icon: NSImage // Pre-loaded during background scan
var isUncommon: Bool = false
var note: String? = nil
+
+ var displayName: String {
+ localizedDisplayName(for: L10n.currentCode)
+ }
+
+ func localizedDisplayName(for languageCode: String) -> String {
+ let candidates = AppInfo.displayLanguageFallbacks(for: languageCode)
+ for code in candidates {
+ if let localizedName = localizedNamesByLanguage[code],
+ !localizedName.isEmpty {
+ return localizedName
+ }
+ }
+ return name
+ }
+
+ private static func displayLanguageFallbacks(for languageCode: String) -> [String] {
+ var candidates = [languageCode]
+ switch languageCode {
+ case "zh-Hans":
+ candidates.append("zh-Hant")
+ case "zh-Hant":
+ candidates.append("zh-Hans")
+ case "nb":
+ candidates.append("no")
+ case "nn":
+ candidates.append("no")
+ case "no":
+ candidates.append(contentsOf: ["nb", "nn"])
+ default:
+ break
+ }
+ candidates.append("en")
+ return uniqueLanguageCodes(candidates)
+ }
+
+ fileprivate static func uniqueLanguageCodes(_ codes: [String]) -> [String] {
+ var seen = Set<String>()
+ return codes.filter { seen.insert($0).inserted }
+ }
/// True if this is an Apple pre-installed app.
var isAppleApp: Bool {
@@ -180,10 +221,14 @@
let bundle = Bundle(url: displayURL) ?? Bundle(url: resolvedURL)
let bundleId = bundle?.bundleIdentifier
- let localizedNames = localizedAppNames(
- for: displayURL,
+ let localizedNamesByLanguage = localizedAppNameMap(
bundle: bundle,
fallbackName: name
+ )
+ let localizedNames = localizedAppNames(
+ for: displayURL,
+ fallbackName: name,
+ localizedNamesByLanguage: localizedNamesByLanguage
)
apps.append(AppInfo(
@@ -192,24 +237,150 @@
tags: [],
bundleIdentifier: bundleId,
localizedNames: localizedNames,
+ localizedNamesByLanguage: localizedNamesByLanguage,
icon: icon
))
}
- private static func localizedAppNames(
- for appURL: URL,
+ private static func localizedAppNameMap(
bundle: Bundle?,
fallbackName: String
+ ) -> [String: String] {
+ guard let bundle else { return [:] }
+ let loctable = infoPlistLoctable(in: bundle)
+ var result: [String: String] = [:]
+
+ for language in L10n.supported {
+ guard let localizedName = localizedAppName(
+ for: language.code,
+ bundle: bundle,
+ loctable: loctable,
+ fallbackName: fallbackName
+ ) else { continue }
+ result[language.code] = localizedName
+ }
+
+ return result
+ }
+
+ private static func localizedAppNames(
+ for appURL: URL,
+ fallbackName: String,
+ localizedNamesByLanguage: [String: String]
) -> [String] {
- var values: [String?] = []
- values.append(bundle?.localizedInfoDictionary?["CFBundleDisplayName"] as? String)
- values.append(bundle?.localizedInfoDictionary?["CFBundleName"] as? String)
- values.append(bundle?.infoDictionary?["CFBundleDisplayName"] as? String)
- values.append(bundle?.infoDictionary?["CFBundleName"] as? String)
+ var values = L10n.supported.map { localizedNamesByLanguage[$0.code] }
values.append(spotlightDisplayName(for: appURL))
values.append(FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: ""))
return uniqueLocalizedNames(values, excluding: fallbackName)
+ }
+
+ private static func localizedAppName(
+ for languageCode: String,
+ bundle: Bundle,
+ loctable: [String: [String: Any]],
+ fallbackName: String
+ ) -> String? {
+ for localization in localizationCandidates(for: languageCode) {
+ if let table = loctable[localization],
+ let value = firstValidLocalizedName(
+ [table["CFBundleDisplayName"], table["CFBundleName"]],
+ excluding: fallbackName
+ ) {
+ return value
+ }
+
+ if let strings = infoPlistStrings(in: bundle, localization: localization),
+ let value = firstValidLocalizedName(
+ [strings["CFBundleDisplayName"], strings["CFBundleName"]],
+ excluding: fallbackName
+ ) {
+ return value
+ }
+ }
+
+ if languageCode == "en",
+ let value = firstValidLocalizedName(
+ [
+ bundle.infoDictionary?["CFBundleDisplayName"],
+ bundle.infoDictionary?["CFBundleName"]
+ ],
+ excluding: fallbackName
+ ) {
+ return value
+ }
+
+ return nil
+ }
+
+ private static func infoPlistLoctable(in bundle: Bundle) -> [String: [String: Any]] {
+ guard let url = bundle.url(forResource: "InfoPlist", withExtension: "loctable"),
+ let rawTable = NSDictionary(contentsOf: url) as? [String: Any]
+ else { return [:] }
+
+ var result: [String: [String: Any]] = [:]
+ for (key, value) in rawTable {
+ if let localizedTable = value as? [String: Any] {
+ result[key] = localizedTable
+ }
+ }
+ return result
+ }
+
+ private static func infoPlistStrings(
+ in bundle: Bundle,
+ localization: String
+ ) -> [String: Any]? {
+ guard let url = bundle.url(
+ forResource: "InfoPlist",
+ withExtension: "strings",
+ subdirectory: nil,
+ localization: localization
+ ) else { return nil }
+ return NSDictionary(contentsOf: url) as? [String: Any]
+ }
+
+ private static func localizationCandidates(for languageCode: String) -> [String] {
+ var candidates = [languageCode, languageCode.replacingOccurrences(of: "-", with: "_")]
+ switch languageCode {
+ case "zh-Hans":
+ candidates.append(contentsOf: ["zh_CN", "zh"])
+ case "zh-Hant":
+ candidates.append(contentsOf: ["zh_TW", "zh_HK", "zh"])
+ case "pt-BR":
+ candidates.append(contentsOf: ["pt_BR", "pt"])
+ case "sr-Cyrl":
+ candidates.append(contentsOf: ["sr_Cyrl", "sr"])
+ case "ar-Najdi":
+ candidates.append(contentsOf: ["ar_Najdi", "ar"])
+ case "nb":
+ candidates.append(contentsOf: ["nb", "no"])
+ case "nn":
+ candidates.append(contentsOf: ["nn", "no"])
+ case "no":
+ candidates.append(contentsOf: ["no", "nb", "nn"])
+ default:
+ if let base = languageCode.split(separator: "-").first.map(String.init) {
+ candidates.append(base)
+ }
+ }
+ return AppInfo.uniqueLanguageCodes(candidates)
+ }
+
+ private static func firstValidLocalizedName(
+ _ values: [Any?],
+ excluding excludedValue: String
+ ) -> String? {
+ let normalizedExcluded = normalizedLocalizedName(excludedValue)
+ for value in values {
+ guard let value = value as? String else { continue }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty,
+ normalizedLocalizedName(trimmed) != normalizedExcluded
+ else { continue }
+ return trimmed
+ }
+ return nil
}
private static func spotlightDisplayName(for appURL: URL) -> String? {
@@ -1081,6 +1252,7 @@
name: app.name, path: app.path, tags: appTags,
bundleIdentifier: app.bundleIdentifier,
localizedNames: app.localizedNames,
+ localizedNamesByLanguage: app.localizedNamesByLanguage,
icon: app.icon,
isUncommon: uncommonPaths.contains(app.path.path),
note: store.appNotes[app.path.path]
diff --git a/Apptag/EditModeViews.swift b/Apptag/EditModeViews.swift
index b64956d..aca3c77 100644
--- a/Apptag/EditModeViews.swift
+++ b/Apptag/EditModeViews.swift
@@ -249,7 +249,7 @@
.offset(x: 6, y: -6)
}
- Text(app.name)
+ Text(app.displayName)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
diff --git a/Apptag/PreferencesView.swift b/Apptag/PreferencesView.swift
index d01e500..e856ff9 100644
--- a/Apptag/PreferencesView.swift
+++ b/Apptag/PreferencesView.swift
@@ -32,6 +32,7 @@
@State private var categoryScheme = TagDatabase.CategorySchemeState()
@State private var isApplyingSystemScheme = false
@State private var showApplySystemSchemeConfirmation = false
+ @State private var isDataFilePanelPresented = false
@State private var hotkeyStatusToast: String? = nil
@State private var hotkeyStatusToastToken: UUID? = nil
@@ -54,48 +55,81 @@
}
private func exportTags() {
- let panel = NSSavePanel()
- panel.title = tr("settings.export")
- TagDatabase.flushPendingCategorySchemeBackupBatch()
- refreshDataState()
- panel.nameFieldStringValue = TagDatabase.exportFileName(for: categoryScheme)
- panel.allowedContentTypes = [.json]
- beginFilePanel(panel) { response in
- guard response == .OK, let url = panel.url else { return }
- do {
- try TagDatabase.exportTo(url)
- } catch {
- fputs("[TagLauncher] Export failed: \(error)\n", stderr)
- showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription)
+ guard prepareDataFilePanelPresentation() else { return }
+ DispatchQueue.main.async {
+ TagDatabase.flushPendingCategorySchemeBackupBatch()
+ let currentScheme = TagDatabase.loadWithEnsuredCategoryScheme().categoryScheme
+ categoryScheme = currentScheme
+
+ let panel = NSSavePanel()
+ panel.title = tr("settings.export")
+ panel.nameFieldStringValue = TagDatabase.exportFileName(for: currentScheme)
+ panel.allowedContentTypes = [.json]
+
+ beginFilePanel(panel) { response in
+ finishDataFilePanelPresentation()
+ guard response == .OK, let url = panel.url else { return }
+ do {
+ try TagDatabase.exportTo(url)
+ } catch {
+ fputs("[TagLauncher] Export failed: \(error)\n", stderr)
+ showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription)
+ }
}
}
}
private func importTags() {
- let panel = NSOpenPanel()
- panel.title = tr("settings.import")
- panel.allowedContentTypes = [.json]
- panel.allowsMultipleSelection = false
- beginFilePanel(panel) { response in
- guard response == .OK, let url = panel.url else { return }
- do {
- TagDatabase.flushPendingCategorySchemeBackupBatch()
- _ = try TagDatabase.importFrom(url)
- scanApps()
- refreshDataState()
- notifyDataChanged()
- } catch {
- fputs("[TagLauncher] Import failed: \(error)\n", stderr)
- showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription)
+ guard prepareDataFilePanelPresentation() else { return }
+ DispatchQueue.main.async {
+ let panel = NSOpenPanel()
+ panel.title = tr("settings.import")
+ panel.allowedContentTypes = [.json]
+ panel.allowsMultipleSelection = false
+
+ beginFilePanel(panel) { response in
+ finishDataFilePanelPresentation()
+ guard response == .OK, let url = panel.url else { return }
+ do {
+ TagDatabase.flushPendingCategorySchemeBackupBatch()
+ _ = try TagDatabase.importFrom(url)
+ scanApps()
+ refreshDataState()
+ notifyDataChanged()
+ } catch {
+ fputs("[TagLauncher] Import failed: \(error)\n", stderr)
+ showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription)
+ }
}
}
+ }
+
+ private func prepareDataFilePanelPresentation() -> Bool {
+ guard !isDataFilePanelPresented else { return false }
+ isDataFilePanelPresented = true
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": true]
+ )
+ NSApp.activate(ignoringOtherApps: true)
+ preferencesWindow?.makeKeyAndOrderFront(nil)
+ return true
+ }
+
+ private func finishDataFilePanelPresentation() {
+ isDataFilePanelPresented = false
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
}
private func beginFilePanel(
_ panel: NSSavePanel,
completion: @escaping (NSApplication.ModalResponse) -> Void
) {
- NSApp.activate(ignoringOtherApps: true)
if let window = preferencesWindow {
panel.beginSheetModal(for: window, completionHandler: completion)
} else {
@@ -106,9 +140,25 @@
}
private var preferencesWindow: NSWindow? {
- NSApp.windows.first {
+ if let taggedWindow = NSApp.windows.first(where: {
$0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible
- } ?? NSApp.keyWindow ?? NSApp.mainWindow
+ }) {
+ return taggedWindow
+ }
+
+ return [NSApp.keyWindow, NSApp.mainWindow]
+ .compactMap { $0 }
+ .first(where: isPreferencesWindowFallback(_:))
+ }
+
+ private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool {
+ guard window.isVisible,
+ !(window is NSPanel),
+ !(window is OverlayPanel)
+ else { return false }
+
+ let preferencesTitle = tr("menu.preferences").replacingOccurrences(of: "…", with: "")
+ return window.title == preferencesTitle
}
private func showDataAlert(title: String, message: String) {
@@ -683,8 +733,10 @@
HStack(spacing: 12) {
Button(tr("settings.export")) { exportTags() }
.buttonStyle(.bordered)
+ .disabled(isDataFilePanelPresented)
Button(tr("settings.import")) { importTags() }
.buttonStyle(.bordered)
+ .disabled(isDataFilePanelPresented)
}
Text(tr("settings.backupDesc"))
.font(.caption)
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index eaf968b..6056150 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -857,7 +857,7 @@
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
- Text(result.app.name)
+ Text(result.app.displayName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
@@ -921,7 +921,7 @@
}
private var accessibilityText: String {
- [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
+ [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ")
}
}
diff --git a/Apptag/TagGroupView.swift b/Apptag/TagGroupView.swift
deleted file mode 100644
index d576054..0000000
--- a/Apptag/TagGroupView.swift
+++ /dev/null
@@ -1,112 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-// MARK: - Tag Group Section (with centered separator-line header)
-
-struct TagGroupView: View {
- let group: TagGroup
- let onSelectApp: (AppInfo) -> Void
- let tagFontSize: CGFloat
- let iconSize: CGFloat
- var showNames: Bool = true
- var dragModeActive: Bool = false
- var onDragModeChange: ((Bool) -> Void)? = nil
- var onBubbleHover: ((AppInfo, CGRect, AppBubbleHoverEvent) -> Void)? = nil
- var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
- var bubbleDisabled: Bool = false
- var showUncommonAppBubbles: Bool = AppDefaults.showUncommonAppBubbles
- var dragResetToken: Int = 0
- var onDropApp: ((String, String, Bool) -> Void)? = nil
-
- /// Adaptive columns — auto-fit based on icon size and available width.
- private var columns: [GridItem] {
- let itemWidth = AppGridItem.stableWidth(iconSize: iconSize)
- return [GridItem(.adaptive(minimum: itemWidth, maximum: itemWidth + 36), spacing: 6)]
- }
-
- // Subtle separator line color — adapts to light/dark mode
- private var lineColor: Color {
- .secondary.opacity(0.25)
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- // Centered separator line with tag name
- HStack(spacing: 0) {
- Rectangle()
- .fill(lineColor)
- .frame(height: 1)
-
- Text(group.name)
- .font(.system(size: tagFontSize, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(.horizontal, 10)
-
- Rectangle()
- .fill(lineColor)
- .frame(height: 1)
- }
- .padding(.bottom, 6)
-
- // App icon grid — columns auto-adjust to icon size
- LazyVGrid(columns: columns, spacing: 2) {
- ForEach(group.apps) { app in
- AppGridItem(
- app: app,
- iconSize: iconSize,
- showName: showNames,
- sourceTag: group.name,
- dragModeActive: dragModeActive,
- onDragModeChange: onDragModeChange,
- onBubbleHover: onBubbleHover,
- onEditNote: onEditNote,
- bubbleDisabled: bubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- itemID: "\(group.name)|\(app.path.path)",
- dragResetToken: dragResetToken,
- onSelect: { onSelectApp(app) }
- )
- }
- }
- }
- .contentShape(Rectangle())
- .overlay {
- if dragModeActive {
- AppDropTargetView(targetTag: group.name) { path, source, copy in
- onDropApp?(path, source, copy)
- }
- .allowsHitTesting(false)
- }
- }
- .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
- handleDrop(providers)
- }
- }
-
- private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
- guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else {
- return false
- }
- provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
- let text: String?
- if let data = item as? Data {
- text = String(data: data, encoding: .utf8)
- } else if let string = item as? String {
- text = string
- } else if let string = item as? NSString {
- text = string as String
- } else {
- text = nil
- }
- guard let text else { return }
- let parts = text.components(separatedBy: "\n")
- guard let path = parts.first, !path.isEmpty else { return }
- let source = parts.dropFirst().first ?? ""
- let copy = NSEvent.modifierFlags.contains(.option)
- DispatchQueue.main.async {
- onDropApp?(path, source, copy)
- }
- }
- return true
- }
-}
diff --git a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.base.json b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.base.json
index 0d12668..7abb2df 100644
--- a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.base.json
+++ b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.base.json
@@ -1145,8 +1145,7 @@
"bundleIdentifier": "com.apple.iBooksX",
"defaultTag": [
"writing",
- "education",
- "entertainment"
+ "education"
],
"sourceEvidence": [
"curated_tager_catalog"
diff --git a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv
index 74764c6..78b6929 100644
--- a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv
+++ b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv
@@ -82,7 +82,7 @@
audio-midi-setup,audio|device-management,com.apple.audio.audiomidisetup,管音频设备和 MIDI 路由,音乐人和调试党很常用
automator,Automation,com.apple.automator,不写代码也能串起一串 Mac 自动化动作
bluetooth-file-exchange,transfer|device-management,com.apple.bluetoothfileexchange,用蓝牙收发文件,古早但关键时能救急
-books,writing|education|entertainment,com.apple.iBooksX,阅读和管理电子书的入口,适合把 Mac 变成书桌
+books,writing|education,com.apple.iBooksX,阅读和管理电子书的入口,适合把 Mac 变成书桌
boot-camp-assistant,system,com.apple.bootcampassistant,Intel Mac 安装 Windows 的官方向导
calculator,utilities,com.apple.calculator,小计算器,单位换算和程序员模式也藏在里面
chess,game,com.apple.chess,系统自带国际象棋,摸鱼时比刷网页更优雅
diff --git a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.manifest.json b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.manifest.json
index 5607d0a..0c1a111 100644
--- a/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.manifest.json
+++ b/Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.manifest.json
@@ -42,8 +42,8 @@
],
"baseResource": {
"file": "SmartStart_UltimateDefaultCatalog.base.json",
- "sha256": "650dcfba5bbf35816aea987baf6c29cfcab827ce72313a53b1aeb592a02949da",
- "bytes": 1025647,
+ "sha256": "ac7f3a7c4dfa412a3196181d5aa03b72f25d8776f368b6f15b30d30db69da8d0",
+ "bytes": 1025622,
"count": 3530
},
"notesResources": {
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
new file mode 100755
index 0000000..c05ae1c
--- /dev/null
+++ b/Scripts/window_logic_qa.sh
@@ -0,0 +1,451 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+APP_BUNDLE="$ROOT_DIR/build/TagLauncher.app"
+APP_PROCESS="TagLauncher"
+LAUNCH_AGENT_LABEL="com.taglauncher.app"
+LAUNCH_AGENT_PLIST="$HOME/Library/LaunchAgents/$LAUNCH_AGENT_LABEL.plist"
+USER_GUI_DOMAIN="gui/$(id -u)"
+RESTORE_LAUNCH_AGENT=false
+CLICK_TOOL="${CLICK_TOOL:-$(command -v cliclick || true)}"
+
+if [[ -z "$CLICK_TOOL" ]]; then
+ echo "FAIL: cliclick is required for window-position click checks." >&2
+ exit 2
+fi
+
+log() {
+ printf '%s\n' "$*"
+}
+
+send_keycode() {
+ local keycode="$1"
+ local modifiers="${2:-}"
+ if [[ -n "$modifiers" ]]; then
+ osascript -e "tell application \"System Events\" to key code $keycode using {$modifiers}"
+ else
+ osascript -e "tell application \"System Events\" to key code $keycode"
+ fi
+}
+
+send_main_hotkey() {
+ osascript -e 'tell application "System Events" to keystroke space using {option down, shift down}'
+}
+
+send_cmd_comma() {
+ osascript -e 'tell application "System Events" to keystroke "," using {command down}'
+}
+
+send_cmd_w() {
+ osascript -e 'tell application "System Events" to keystroke "w" using {command down}'
+}
+
+show_overlay_from_app_menu() {
+ osascript <<'OSA'
+tell application "System Events"
+ tell process "TagLauncher"
+ repeat 50 times
+ set frontmost to true
+ delay 0.2
+ repeat with itemRef in menu items of menu 1 of menu bar item "TagLauncher" of menu bar 1
+ try
+ set itemName to name of itemRef as text
+ if itemName contains "显示应用列表" or itemName contains "Show" then
+ click itemRef
+ return
+ end if
+ end try
+ end repeat
+ end repeat
+ error "TagLauncher Show App List menu item did not become available"
+ end tell
+end tell
+OSA
+}
+
+cliclick_coord() {
+ local value="$1"
+ if [[ "$value" == -* ]]; then
+ printf '=%s' "$value"
+ else
+ printf '%s' "$value"
+ fi
+}
+
+click_xy() {
+ local x="$1"
+ local y="$2"
+ "$CLICK_TOOL" c:"$(cliclick_coord "$x")","$(cliclick_coord "$y")"
+}
+
+move_xy() {
+ local x="$1"
+ local y="$2"
+ "$CLICK_TOOL" m:"$(cliclick_coord "$x")","$(cliclick_coord "$y")"
+}
+
+cleanup() {
+ osascript -e 'tell application "System Events" to key code 53' >/dev/null 2>&1 || true
+ sleep 0.2
+ osascript -e 'tell application "System Events" to key code 53' >/dev/null 2>&1 || true
+ kill_all_taglauncher_instances
+ if [[ "$RESTORE_LAUNCH_AGENT" == true && -f "$LAUNCH_AGENT_PLIST" ]]; then
+ launchctl bootstrap "$USER_GUI_DOMAIN" "$LAUNCH_AGENT_PLIST" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT
+
+kill_all_taglauncher_instances() {
+ local pids
+ pids="$(pgrep -f '/TagLauncher.app/Contents/MacOS/TagLauncher' || true)"
+ if [[ -n "$pids" ]]; then
+ kill $pids >/dev/null 2>&1 || true
+ sleep 0.4
+ fi
+ pids="$(pgrep -f '/TagLauncher.app/Contents/MacOS/TagLauncher' || true)"
+ if [[ -n "$pids" ]]; then
+ kill -9 $pids >/dev/null 2>&1 || true
+ fi
+}
+
+is_qa_app_only_running() {
+ local lines
+ lines="$(pgrep -fl '/TagLauncher.app/Contents/MacOS/TagLauncher' || true)"
+ [[ -n "$lines" ]] || return 1
+ while IFS= read -r line; do
+ [[ "$line" == *"$APP_BUNDLE/Contents/MacOS/TagLauncher"* ]] || return 1
+ done <<<"$lines"
+}
+
+prepare_isolated_app_instance() {
+ if launchctl print "$USER_GUI_DOMAIN/$LAUNCH_AGENT_LABEL" >/dev/null 2>&1; then
+ RESTORE_LAUNCH_AGENT=true
+ launchctl bootout "$USER_GUI_DOMAIN/$LAUNCH_AGENT_LABEL" >/dev/null 2>&1 || true
+ fi
+
+ kill_all_taglauncher_instances
+ open -n "$APP_BUNDLE"
+ sleep 2.5
+
+ if ! is_qa_app_only_running; then
+ echo "FAIL: expected only QA build TagLauncher instance to be running" >&2
+ pgrep -fl '/TagLauncher.app/Contents/MacOS/TagLauncher' >&2 || true
+ exit 1
+ fi
+}
+
+assert_swift="$(mktemp -t taglauncher-window-assert.XXXXXX.swift)"
+coords_swift="$(mktemp -t taglauncher-window-coords.XXXXXX.swift)"
+screens_swift="$(mktemp -t taglauncher-screens.XXXXXX.swift)"
+trap 'rm -f "$assert_swift" "$coords_swift" "$screens_swift"; cleanup' EXIT
+
+cat >"$assert_swift" <<'SWIFT'
+import AppKit
+import CoreGraphics
+import Foundation
+
+struct WindowInfo {
+ let owner: String
+ let name: String
+ let layer: Int
+ let bounds: NSDictionary
+}
+
+func allWindows() -> [WindowInfo] {
+ let raw = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
+ return raw.map {
+ WindowInfo(
+ owner: $0[kCGWindowOwnerName as String] as? String ?? "",
+ name: $0[kCGWindowName as String] as? String ?? "",
+ layer: $0[kCGWindowLayer as String] as? Int ?? -999,
+ bounds: $0[kCGWindowBounds as String] as? NSDictionary ?? [:]
+ )
+ }
+}
+
+func dumpRelevantWindows() {
+ fputs("---- relevant on-screen windows ----\n", stderr)
+ for (index, window) in allWindows().enumerated() {
+ let isRelevant = window.owner == "TagLauncher"
+ || window.owner == "Window Server"
+ || window.owner == "Dock"
+ || window.owner == "loginwindow"
+ guard isRelevant else { continue }
+ fputs("#\(index) owner=\(window.owner) name=\(window.name) layer=\(window.layer) bounds=\(window.bounds)\n", stderr)
+ }
+ fputs("------------------------------------\n", stderr)
+}
+
+func fail(_ message: String) -> Never {
+ fputs("FAIL: \(message)\n", stderr)
+ dumpRelevantWindows()
+ exit(1)
+}
+
+func tagWindows(_ windows: [WindowInfo]) -> [WindowInfo] {
+ windows.filter { $0.owner == "TagLauncher" }
+}
+
+func assertTagLayer(_ tag: [WindowInfo]) {
+ for window in tag where window.layer != 23 {
+ fail("TagLauncher window has unexpected layer \(window.layer); expected 23")
+ }
+}
+
+let mode = CommandLine.arguments.dropFirst().first ?? ""
+let windows = allWindows()
+let tag = tagWindows(windows)
+
+switch mode {
+case "overlay":
+ guard tag.count == 1 else { fail("overlay expected 1 TagLauncher window, got \(tag.count)") }
+ assertTagLayer(tag)
+ let menubarLayer = windows.first { $0.owner == "Window Server" && $0.name == "Menubar" }?.layer
+ guard menubarLayer == 24 else { fail("menubar layer expected 24, got \(String(describing: menubarLayer))") }
+ let dockWindows = windows.filter { $0.owner == "Dock" }
+ guard dockWindows.isEmpty else { fail("Dock should be hidden while overlay is visible; found \(dockWindows.count) Dock windows") }
+ print("PASS overlay: tagLayer=\(tag[0].layer) menubarLayer=24 dockWindows=0")
+
+case "settings":
+ guard tag.count == 2 else { fail("settings expected 2 TagLauncher windows, got \(tag.count)") }
+ assertTagLayer(tag)
+ guard !tag[0].name.isEmpty, tag[1].name.isEmpty else {
+ fail("settings order wrong: \(tag.map(\.name))")
+ }
+ print("PASS settings over overlay: order=\(tag.map(\.name))")
+
+case "file-panel":
+ guard tag.count == 3 else { fail("file panel expected 3 TagLauncher windows, got \(tag.count)") }
+ assertTagLayer(tag)
+ guard !tag[0].name.isEmpty, !tag[1].name.isEmpty, tag[2].name.isEmpty else {
+ fail("file panel order wrong: \(tag.map(\.name))")
+ }
+ print("PASS file panel over settings: order=\(tag.map(\.name))")
+
+case "no-overlay":
+ guard tag.isEmpty else { fail("expected no TagLauncher windows, got \(tag.count)") }
+ print("PASS overlay hidden")
+
+case "force-quit":
+ guard let overlay = tag.first else { fail("force quit check expected overlay window") }
+ guard let forceQuit = windows.first(where: {
+ $0.owner == "loginwindow" && ($0.name.localizedCaseInsensitiveContains("force") || $0.name.contains("强制"))
+ }) else {
+ fail("force quit window not found")
+ }
+ guard forceQuit.layer > overlay.layer else {
+ fail("force quit layer \(forceQuit.layer) is not above overlay layer \(overlay.layer)")
+ }
+ print("PASS force quit above overlay: forceQuitLayer=\(forceQuit.layer) overlayLayer=\(overlay.layer)")
+
+case "screen-count":
+ print("INFO screens=\(NSScreen.screens.count) frames=\(NSScreen.screens.map { NSStringFromRect($0.frame) })")
+
+default:
+ fail("unknown assert mode: \(mode)")
+}
+SWIFT
+
+cat >"$coords_swift" <<'SWIFT'
+import CoreGraphics
+import Foundation
+
+let raw = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
+let tag = raw.filter { ($0[kCGWindowOwnerName as String] as? String) == "TagLauncher" }
+let mode = CommandLine.arguments.dropFirst().first ?? ""
+switch mode {
+case "data-tab":
+ guard let settings = tag.first(where: { (($0[kCGWindowName as String] as? String) ?? "").isEmpty == false }),
+ let bounds = settings[kCGWindowBounds as String] as? NSDictionary,
+ let x = bounds["X"] as? CGFloat,
+ let y = bounds["Y"] as? CGFloat else {
+ fputs("FAIL: could not find settings window bounds\n", stderr)
+ exit(1)
+ }
+ print("\(Int(round(x + 523))) \(Int(round(y + 50)))")
+case "export":
+ guard let settings = tag.first(where: { (($0[kCGWindowName as String] as? String) ?? "").isEmpty == false }),
+ let bounds = settings[kCGWindowBounds as String] as? NSDictionary,
+ let x = bounds["X"] as? CGFloat,
+ let y = bounds["Y"] as? CGFloat else {
+ fputs("FAIL: could not find settings window bounds\n", stderr)
+ exit(1)
+ }
+ print("\(Int(round(x + 375))) \(Int(round(y + 331)))")
+case "overlay-outside":
+ guard let overlay = tag.first(where: { (($0[kCGWindowName as String] as? String) ?? "").isEmpty }),
+ let overlayBounds = overlay[kCGWindowBounds as String] as? NSDictionary,
+ let overlayX = overlayBounds["X"] as? CGFloat,
+ let overlayY = overlayBounds["Y"] as? CGFloat else {
+ fputs("FAIL: could not find overlay window bounds\n", stderr)
+ exit(1)
+ }
+ print("\(Int(round(overlayX + 120))) \(Int(round(overlayY + 160)))")
+default:
+ fputs("FAIL: unknown coords mode \(mode)\n", stderr)
+ exit(1)
+}
+SWIFT
+
+cat >"$screens_swift" <<'SWIFT'
+import AppKit
+import Foundation
+
+for (index, screen) in NSScreen.screens.enumerated() {
+ let frame = screen.frame
+ let cgTop = NSScreen.screens.map { $0.frame.maxY }.max() ?? frame.maxY
+ let cgY = cgTop - frame.maxY
+ let clickY = cgY + frame.height / 2
+ print("\(index)|\(Int(round(frame.midX)))|\(Int(round(clickY)))|\(Int(round(frame.origin.x)))|\(Int(round(cgY)))|\(Int(round(frame.width)))|\(Int(round(frame.height)))")
+}
+SWIFT
+
+swift_assert() {
+ swift "$assert_swift" "$1"
+}
+
+wait_swift_assert() {
+ local mode="$1"
+ local output=""
+ for _ in {1..12}; do
+ if output="$(swift "$assert_swift" "$mode" 2>&1)"; then
+ printf '%s\n' "$output"
+ return 0
+ fi
+ sleep 0.2
+ done
+ printf '%s\n' "$output" >&2
+ return 1
+}
+
+show_overlay() {
+ send_main_hotkey
+ if wait_swift_assert overlay >/dev/null 2>&1; then
+ swift_assert overlay
+ return 0
+ fi
+
+ show_overlay_from_app_menu
+ wait_swift_assert overlay
+}
+
+click_relative_to_settings() {
+ local mode="$1"
+ local coords
+ coords="$(swift "$coords_swift" "$mode")"
+ read -r x y <<<"$coords"
+ click_xy "$x" "$y"
+}
+
+click_overlay_outside_quick_search() {
+ local coords
+ coords="$(swift "$coords_swift" overlay-outside)"
+ read -r x y <<<"$coords"
+ click_xy "$x" "$y"
+}
+
+log "==> Building app"
+bash "$ROOT_DIR/build.sh" >/dev/null
+
+log "==> Starting clean app instance"
+prepare_isolated_app_instance
+
+log "==> QA 1/7: overlay claims foreground, hides Dock, keeps menu bar visible"
+show_overlay
+frontmost="$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true')"
+[[ "$frontmost" == "TagLauncher" ]] || { echo "FAIL: frontmost app is $frontmost, expected TagLauncher" >&2; exit 1; }
+swift_assert overlay
+
+log "==> QA 1/7 and 4/7: settings floats above appgrid and quick search"
+send_keycode 49
+sleep 0.4
+send_cmd_comma
+sleep 0.7
+swift_assert settings
+
+log "==> QA 2/7: import/export file panel floats above settings"
+click_relative_to_settings data-tab
+sleep 0.3
+click_relative_to_settings export
+sleep 0.7
+swift_assert file-panel
+send_keycode 53
+sleep 0.3
+send_cmd_w
+sleep 0.5
+swift_assert overlay
+
+log "==> QA 4/7 and 5/7: appgrid-space quick search and double-Esc behavior"
+send_keycode 49
+sleep 0.4
+send_keycode 53
+sleep 0.4
+swift_assert overlay
+send_keycode 53
+sleep 0.4
+swift_assert no-overlay
+
+log "==> QA 5/7: clicking outside quick search closes search, not appgrid"
+show_overlay
+send_keycode 49
+sleep 0.4
+click_overlay_outside_quick_search
+sleep 0.4
+swift_assert overlay
+
+log "==> QA 3/7: system force-quit window stays above TagLauncher"
+send_keycode 53 "option down, command down"
+sleep 0.7
+swift_assert force-quit
+send_keycode 53
+sleep 0.3
+
+log "==> QA 6/7: screen-following logic"
+swift_assert screen-count
+screen_count="$(swift "$screens_swift" | wc -l | tr -d ' ')"
+if [[ "$screen_count" -gt 1 ]]; then
+ while IFS='|' read -r index cx cy sx sy sw sh; do
+ kill_all_taglauncher_instances
+ open -n "$APP_BUNDLE"
+ sleep 2.5
+ if ! is_qa_app_only_running; then
+ echo "FAIL: expected only QA build TagLauncher instance during screen-following check" >&2
+ pgrep -fl '/TagLauncher.app/Contents/MacOS/TagLauncher' >&2 || true
+ exit 1
+ fi
+ move_xy "$cx" "$cy"
+ sleep 0.2
+ show_overlay
+ wait_swift_assert overlay
+ swift - "$sx" "$sy" "$sw" "$sh" <<'SWIFT'
+import CoreGraphics
+import Foundation
+let expected = CommandLine.arguments.dropFirst().map { Int($0)! }
+let raw = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
+let tag = raw.filter { ($0[kCGWindowOwnerName as String] as? String) == "TagLauncher" }
+guard tag.count == 1, let bounds = tag[0][kCGWindowBounds as String] as? NSDictionary else {
+ fputs("FAIL: expected one overlay for screen-following check\n", stderr)
+ exit(1)
+}
+let actual = ["X", "Y", "Width", "Height"].map { Int(round((bounds[$0] as? Double) ?? 0)) }
+guard actual == expected else {
+ fputs("FAIL: overlay bounds \(actual) did not match screen frame \(expected)\n", stderr)
+ exit(1)
+}
+print("PASS screen frame: \(actual)")
+SWIFT
+ done < <(swift "$screens_swift")
+else
+ rg -Fq 'screenContainingCurrentPointer() ??' "$ROOT_DIR/Apptag/ApptagApp.swift"
+ rg -Fq 'NSMouseInRect(mousePoint, $0.frame, false)' "$ROOT_DIR/Apptag/ApptagApp.swift"
+ log "PASS screen-following static path: single physical display here; code selects NSScreen under current pointer"
+fi
+
+log "==> QA 7/7: final chrome state still valid"
+swift_assert overlay
+send_keycode 53
+sleep 0.4
+swift_assert no-overlay
+
+log "ALL WINDOW LOGIC QA PASSED"
diff --git a/generate_icon.py b/generate_icon.py
index 7ae30dd..8331fc9 100644
--- a/generate_icon.py
+++ b/generate_icon.py
@@ -4,6 +4,7 @@
import math, os, subprocess, tempfile
SIZE = 1024
+PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
def rounded_rectangle_mask(size, radius):
mask = Image.new("L", (size, size), 0)
@@ -97,12 +98,12 @@
print(f" {name} ({size}x{size}): {os.path.getsize(path):,}B")
# Generate .icns to project root
- out_icns = "/Users/ar/Projects/Apptag/icon-icns.icns"
+ out_icns = os.path.join(PROJECT_DIR, "icon-icns.icns")
subprocess.run(["iconutil", "-c", "icns", iconset, "-o", out_icns], check=True)
icns_size = os.path.getsize(out_icns)
print(f"\nGenerated: {out_icns} ({icns_size:,}B, {icns_size/1024:.0f}KB)")
# Also save 1024x1024 preview
-preview_path = "/Users/ar/Projects/Apptag/icon_preview.png"
+preview_path = os.path.join(PROJECT_DIR, "icon_preview.png")
img.save(preview_path, "PNG", optimize=True)
print(f"Preview: {preview_path}")
--
Gitblit v1.9.3