Archive TagLauncher 5.1.12
30 files modified
12 files deleted
1 files added
| | |
| | | # Apptag Project Rules |
| | | |
| | | 本项目 Hermes 必须严格遵守的规则 |
| | | |
| | | ## 版本管理 & 需求记录铁律(每次任务都必须执行) |
| | | |
| | | 1. 任何修改文件前,必须先执行 checkpoint: |
| | | git add -A && git commit -m "checkpoint: [本次任务简要描述]" |
| | | |
| | | 2. 任务完成后,**必须**更新 DECISIONS.md: |
| | | - 在文件最顶部追加一节新内容(使用当前日期) |
| | | - 格式必须严格遵守下面的模板 |
| | | |
| | | 3. 最后执行正式 commit: |
| | | git add DECISIONS.md .hermes.md && git commit -m "docs: 更新 DECISIONS.md [任务描述]" |
| | | |
| | | 4. 如果是重大功能,自动推送 GitHub PR(可选) |
| | | |
| | | ## DECISIONS.md 记录模板(必须完全按照这个格式) |
| | | ## 上下文管理规则 |
| | | |
| | | ``` |
| | | ## [YYYY-MM-DD] 任务标题 |
| | | **需求来源**:(你当时说的原话或简要描述) |
| | | **决策理由**:(为什么这么实现,有什么取舍) |
| | | **实现内容**:(做了哪些改动) |
| | | **影响文件**:(列出修改的文件) |
| | | **回滚命令**:git revert <本次commit-hash> 或 git checkout <上一个checkpoint> |
| | | **测试命令**:(如果有) |
| | | ``` |
| | | - 每完成 5 个任务后,自动执行一次上下文压缩 |
| | | - 压缩完成后,在 DECISIONS.md 里新增一节 “压缩摘要” |
| | | |
| | | 此文件由 Hermes 自动维护,请勿手动删除历史记录。 |
| | | |
| | | ## |
| | | |
| New file |
| | |
| | | import SwiftUI |
| | | import AppKit |
| | | |
| | | final class AppDragCoordinator { |
| | | static let shared = AppDragCoordinator() |
| | | |
| | | struct DropTarget { |
| | | weak var view: NSView? |
| | | var tag: String |
| | | var onDrop: (String, String, Bool) -> Void |
| | | } |
| | | |
| | | private var targets: [UUID: DropTarget] = [:] |
| | | private weak var dragHostWindow: NSWindow? |
| | | private weak var dragPreviewSuperview: NSView? |
| | | private var dragPreviewView: DragPreviewView? |
| | | private var dragWindow: NSWindow? |
| | | private var dragImageSize: NSSize = .zero |
| | | private var activePayload = "" |
| | | |
| | | private init() {} |
| | | |
| | | func register(id: UUID, view: NSView, tag: String, onDrop: @escaping (String, String, Bool) -> Void) { |
| | | targets[id] = DropTarget(view: view, tag: tag, onDrop: onDrop) |
| | | } |
| | | |
| | | func unregister(id: UUID) { |
| | | targets.removeValue(forKey: id) |
| | | } |
| | | |
| | | func beginDrag(image: NSImage, payload: String, at screenPoint: NSPoint, copy: Bool, in hostWindow: NSWindow?) { |
| | | endDragVisuals() |
| | | activePayload = payload |
| | | dragImageSize = image.size |
| | | dragHostWindow = hostWindow |
| | | |
| | | if let contentView = hostWindow?.contentView { |
| | | let preview = DragPreviewView(image: image, copy: copy, frame: NSRect(origin: .zero, size: image.size)) |
| | | preview.translatesAutoresizingMaskIntoConstraints = true |
| | | preview.autoresizingMask = [] |
| | | contentView.addSubview(preview, positioned: .above, relativeTo: nil) |
| | | preview.layer?.zPosition = 1_000_000 |
| | | dragPreviewSuperview = contentView |
| | | dragPreviewView = preview |
| | | updateDragLocation(screenPoint) |
| | | return |
| | | } |
| | | |
| | | let panel = NSPanel( |
| | | contentRect: NSRect(origin: .zero, size: image.size), |
| | | styleMask: [.borderless, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isOpaque = false |
| | | panel.backgroundColor = .clear |
| | | panel.hasShadow = false |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | panel.ignoresMouseEvents = true |
| | | panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) |
| | | panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, .stationary, .transient, .ignoresCycle] |
| | | panel.isReleasedWhenClosed = false |
| | | |
| | | panel.contentView = DragPreviewView(image: image, copy: copy, frame: NSRect(origin: .zero, size: image.size)) |
| | | |
| | | dragWindow = panel |
| | | updateDragLocation(screenPoint) |
| | | panel.setFrame(panel.frame, display: true) |
| | | panel.orderFrontRegardless() |
| | | } |
| | | |
| | | func updateDragLocation(_ screenPoint: NSPoint, copy: Bool? = nil) { |
| | | if let dragPreviewView, let dragHostWindow, let contentView = dragPreviewSuperview { |
| | | if let copy { |
| | | dragPreviewView.isCopyMode = copy |
| | | } |
| | | let windowPoint = dragHostWindow.convertPoint(fromScreen: screenPoint) |
| | | let contentPoint = contentView.convert(windowPoint, from: nil) |
| | | dragPreviewView.setFrameOrigin( |
| | | NSPoint( |
| | | x: contentPoint.x - dragImageSize.width / 2, |
| | | y: contentPoint.y - dragImageSize.height / 2 |
| | | ) |
| | | ) |
| | | return |
| | | } |
| | | |
| | | guard let dragWindow else { return } |
| | | let origin = NSPoint( |
| | | x: screenPoint.x - dragImageSize.width / 2, |
| | | y: screenPoint.y - dragImageSize.height / 2 |
| | | ) |
| | | dragWindow.setFrameOrigin(origin) |
| | | } |
| | | |
| | | func finishDrag(at screenPoint: NSPoint, copy: Bool) { |
| | | defer { endDragVisuals() } |
| | | let parts = activePayload.components(separatedBy: "\n") |
| | | guard let path = parts.first, !path.isEmpty else { return } |
| | | let source = parts.dropFirst().first ?? "" |
| | | |
| | | let hitTarget = targets.values |
| | | .compactMap { target -> (DropTarget, CGFloat)? in |
| | | guard let frame = target.view?.screenFrame(), frame.contains(screenPoint) else { return nil } |
| | | return (target, frame.width * frame.height) |
| | | } |
| | | .sorted { $0.1 < $1.1 } |
| | | .first?.0 |
| | | |
| | | hitTarget?.onDrop(path, source, copy) |
| | | } |
| | | |
| | | func cancelDrag() { |
| | | endDragVisuals() |
| | | } |
| | | |
| | | private func endDragVisuals() { |
| | | dragPreviewView?.removeFromSuperview() |
| | | dragPreviewView = nil |
| | | dragPreviewSuperview = nil |
| | | dragHostWindow = nil |
| | | dragWindow?.orderOut(nil) |
| | | dragWindow = nil |
| | | activePayload = "" |
| | | dragImageSize = .zero |
| | | } |
| | | } |
| | | |
| | | private final class DragPreviewView: NSView { |
| | | private let image: NSImage |
| | | var isCopyMode: Bool { |
| | | didSet { |
| | | if oldValue != isCopyMode { |
| | | needsDisplay = true |
| | | } |
| | | } |
| | | } |
| | | |
| | | init(image: NSImage, copy: Bool, frame: NSRect) { |
| | | self.image = image |
| | | self.isCopyMode = copy |
| | | super.init(frame: frame) |
| | | wantsLayer = true |
| | | layer?.masksToBounds = false |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | nil |
| | | } |
| | | |
| | | override var isOpaque: Bool { false } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | nil |
| | | } |
| | | |
| | | override func draw(_ dirtyRect: NSRect) { |
| | | NSColor.clear.setFill() |
| | | dirtyRect.fill() |
| | | image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1.0) |
| | | |
| | | guard isCopyMode else { return } |
| | | |
| | | let badgeSize = min(bounds.width, bounds.height) * 0.28 |
| | | let badgeRect = NSRect( |
| | | x: bounds.maxX - badgeSize - badgeSize * 0.22, |
| | | y: bounds.maxY - badgeSize - badgeSize * 0.22, |
| | | width: badgeSize, |
| | | height: badgeSize |
| | | ) |
| | | |
| | | NSGraphicsContext.saveGraphicsState() |
| | | let shadow = NSShadow() |
| | | shadow.shadowColor = NSColor.black.withAlphaComponent(0.30) |
| | | shadow.shadowBlurRadius = 8 |
| | | shadow.shadowOffset = NSSize(width: 0, height: -2) |
| | | shadow.set() |
| | | NSColor.systemGreen.setFill() |
| | | NSBezierPath(ovalIn: badgeRect).fill() |
| | | NSGraphicsContext.restoreGraphicsState() |
| | | |
| | | let plus = "+" |
| | | let attrs: [NSAttributedString.Key: Any] = [ |
| | | .font: NSFont.systemFont(ofSize: badgeSize * 0.78, weight: .bold), |
| | | .foregroundColor: NSColor.white |
| | | ] |
| | | let plusSize = plus.size(withAttributes: attrs) |
| | | plus.draw( |
| | | at: NSPoint( |
| | | x: badgeRect.midX - plusSize.width / 2, |
| | | y: badgeRect.midY - plusSize.height / 2 + badgeSize * 0.03 |
| | | ), |
| | | withAttributes: attrs |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private extension NSView { |
| | | func screenFrame() -> NSRect? { |
| | | guard let window else { return nil } |
| | | let rectInWindow = convert(bounds, to: nil) |
| | | return window.convertToScreen(rectInWindow) |
| | | } |
| | | } |
| | | |
| | | struct AppDropTargetView: NSViewRepresentable { |
| | | let targetTag: String |
| | | let onDropApp: (String, String, Bool) -> Void |
| | | |
| | | func makeNSView(context: Context) -> AppDropTargetNSView { |
| | | let view = AppDropTargetNSView() |
| | | view.targetTag = targetTag |
| | | view.onDropApp = onDropApp |
| | | return view |
| | | } |
| | | |
| | | func updateNSView(_ view: AppDropTargetNSView, context: Context) { |
| | | view.targetTag = targetTag |
| | | view.onDropApp = onDropApp |
| | | view.registerTarget() |
| | | } |
| | | |
| | | static func dismantleNSView(_ view: AppDropTargetNSView, coordinator: ()) { |
| | | AppDragCoordinator.shared.unregister(id: view.id) |
| | | } |
| | | } |
| | | |
| | | final class AppDropTargetNSView: NSView { |
| | | let id = UUID() |
| | | var targetTag = "" |
| | | var onDropApp: ((String, String, Bool) -> Void)? |
| | | |
| | | override func viewDidMoveToWindow() { |
| | | super.viewDidMoveToWindow() |
| | | registerTarget() |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | registerTarget() |
| | | } |
| | | |
| | | func registerTarget() { |
| | | guard window != nil, let onDropApp else { return } |
| | | AppDragCoordinator.shared.register(id: id, view: self, tag: targetTag, onDrop: onDropApp) |
| | | } |
| | | } |
| | |
| | | let app: AppInfo |
| | | let iconSize: CGFloat |
| | | var showName: Bool = true |
| | | var sourceTag: String? = nil |
| | | var dragModeActive: Bool = false |
| | | var onDragModeChange: ((Bool) -> Void)? = nil |
| | | let onSelect: () -> Void |
| | | |
| | | @State private var isHovered = false |
| | | @State private var wiggle = false |
| | | |
| | | private let hoverScale: CGFloat = 1.22 |
| | | |
| | | var body: some View { |
| | | Button(action: onSelect) { |
| | | VStack(spacing: 6) { |
| | | Image(nsImage: app.icon) |
| | | .resizable() |
| | | .aspectRatio(contentMode: .fit) |
| | | DraggableAppIconView( |
| | | icon: app.icon, |
| | | iconSize: iconSize, |
| | | payload: "\(app.path.path)\n\(sourceTag ?? "")", |
| | | onHover: { isHovered = $0 }, |
| | | onLongPress: { onDragModeChange?(true) }, |
| | | onDragEnd: { onDragModeChange?(false) }, |
| | | onClick: onSelect |
| | | ) |
| | | .frame(width: iconSize, height: iconSize) |
| | | .scaleEffect(isHovered ? 1.22 : 1.0) |
| | | .scaleEffect(isHovered ? hoverScale : 1.0) |
| | | .shadow( |
| | | color: .black.opacity(isHovered ? 0.35 : 0), |
| | | radius: isHovered ? 14 : 0, |
| | |
| | | .padding(.vertical, 8) |
| | | .padding(.horizontal, 4) |
| | | .contentShape(RoundedRectangle(cornerRadius: 10)) |
| | | .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0)) |
| | | .animation( |
| | | dragModeActive |
| | | ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true) |
| | | : .default, |
| | | value: wiggle |
| | | ) |
| | | .onChange(of: dragModeActive) { _, active in |
| | | wiggle = active |
| | | } |
| | | .buttonStyle(.plain) |
| | | .onHover { hovering in |
| | | isHovered = hovering |
| | | } |
| | | } |
| | | |
| | | private struct DraggableAppIconView: NSViewRepresentable { |
| | | let icon: NSImage |
| | | let iconSize: CGFloat |
| | | let payload: String |
| | | let onHover: (Bool) -> Void |
| | | 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.onHover = onHover |
| | | view.onLongPress = onLongPress |
| | | view.onDragEnd = onDragEnd |
| | | view.onClick = onClick |
| | | return view |
| | | } |
| | | |
| | | func updateNSView(_ view: DragIconNSView, context: Context) { |
| | | view.image = icon |
| | | view.iconSize = iconSize |
| | | view.payload = payload |
| | | view.onHover = onHover |
| | | view.onLongPress = onLongPress |
| | | view.onDragEnd = onDragEnd |
| | | view.onClick = onClick |
| | | view.needsDisplay = true |
| | | } |
| | | } |
| | | |
| | | private final class DragIconNSView: NSView { |
| | | var image: NSImage = NSImage() |
| | | var iconSize: CGFloat = 56 |
| | | var payload: String = "" |
| | | var onHover: ((Bool) -> Void)? |
| | | 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? |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: bounds, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | trackingAreaRef = area |
| | | addTrackingArea(area) |
| | | } |
| | | |
| | | 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 mouseEntered(with event: NSEvent) { |
| | | onHover?(true) |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | if !didStartDrag { |
| | | onHover?(false) |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | } |
| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import UniformTypeIdentifiers |
| | | |
| | | // MARK: - Notification for manual re-index |
| | | |
| | |
| | | @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 |
| | | @State private var dropWarningToast: String? = nil |
| | | |
| | | // Configurable defaults |
| | | @AppStorage("defaultGroupName") private var defaultGroupName = "Other" |
| | |
| | | editTagsView |
| | | case .editingApps: |
| | | editAppsView |
| | | } |
| | | |
| | | if let message = dropWarningToast { |
| | | Text(message) |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .padding(.horizontal, 22) |
| | | .padding(.vertical, 12) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.22), radius: 18, y: 10) |
| | | ) |
| | | .transition(.scale(scale: 0.96).combined(with: .opacity)) |
| | | .allowsHitTesting(false) |
| | | } |
| | | } |
| | | .onAppear { |
| | |
| | | onSelectApp: { app in openApp(app) }, |
| | | tagFontSize: tagFontSize, |
| | | iconSize: iconSize, |
| | | showNames: !hideAppNames |
| | | showNames: !hideAppNames, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onDropApp: { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | | ).id(group.id) |
| | | } |
| | | }.padding(20) |
| | |
| | | spacing: 2 |
| | | ) { |
| | | ForEach(group.apps) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | AppGridItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | showName: !hideAppNames, |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .stroke(Color.primary.opacity(0.08), lineWidth: 1) |
| | | ) |
| | | .overlay { |
| | | 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 ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessActive ? 10 : 0) |
| | |
| | | if isColorless { |
| | | toggleColorlessFill(group.name) |
| | | } |
| | | } |
| | | .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in |
| | | handleAppDrop(providers, targetTag: group.name) |
| | | } |
| | | } |
| | | |
| | |
| | | ForEach(rows.indices, id: \.self) { rowIndex in |
| | | HStack(alignment: .top, spacing: 6) { |
| | | ForEach(rows[rowIndex]) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | AppGridItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | showName: !hideAppNames, |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | .frame(width: cellWidth) |
| | | .frame(height: cellHeight) |
| | | } |
| | |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .stroke(Color.primary.opacity(0.08), lineWidth: 1) |
| | | ) |
| | | .overlay { |
| | | 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 ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessGridActive ? 10 : 0) |
| | |
| | | if isColorlessGrid { |
| | | toggleColorlessFill(group.name) |
| | | } |
| | | } |
| | | .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in |
| | | handleAppDrop(providers, targetTag: group.name) |
| | | } |
| | | } |
| | | |
| | |
| | | filledColorlessContainer = (filledColorlessContainer == id) ? nil : id |
| | | } |
| | | |
| | | 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) { |
| | | appDragModeActive = false |
| | | guard !isSystemDefaultDropTarget(targetTag) else { |
| | | showDropWarning() |
| | | return |
| | | } |
| | | guard tagColors[targetTag] != nil else { return } |
| | | guard sourceTag != targetTag || copy else { return } |
| | | TagEditor.moveApp( |
| | | path: path, |
| | | from: sourceTag, |
| | | to: targetTag, |
| | | color: tagColors[targetTag] ?? 0, |
| | | copy: copy |
| | | ) |
| | | refreshApps() |
| | | } |
| | | |
| | | private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool { |
| | | let defaultNames = [ |
| | | defaultGroupName, |
| | | tr("group.uncategorized"), |
| | | "Mac自带", |
| | | tr("group.appleBuiltIn") |
| | | ] |
| | | return defaultNames.contains(targetTag) |
| | | } |
| | | |
| | | private func showDropWarning() { |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { |
| | | dropWarningToast = tr("drop.systemDefaultWarning") |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | dropWarningToast = nil |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func setAppDragMode(_ active: Bool) { |
| | | appDragModeActive = active |
| | | if active { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { |
| | | if appDragModeActive { |
| | | appDragModeActive = false |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | func refreshApps() { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | var apps = AppIndexer.scan() |
| | |
| | | } |
| | | |
| | | func openApp(_ app: AppInfo) { |
| | | appDragModeActive = false |
| | | hideOverlay() |
| | | if let bundleIdentifier = app.bundleIdentifier { |
| | | NSWorkspace.shared.launchApplication( |
| | |
| | | default: return NSColor.systemGray |
| | | } |
| | | } |
| | | static let allIndices: [Int] = [0, 1, 2, 3, 4, 5, 6, 7] |
| | | static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7] |
| | | } |
| | | |
| | | // MARK: - App Scanner |
| | |
| | | .appendingPathComponent("Applications") |
| | | ] |
| | | |
| | | /// Scan all standard locations. Tags are NOT read from Finder — |
| | | /// they're annotated from TagDatabase by the caller. |
| | | /// Scan all standard locations. Tags are annotated from TagDatabase by the caller. |
| | | static func scan() -> [AppInfo] { |
| | | var seen = Set<URL>() |
| | | var apps: [AppInfo] = [] |
| | |
| | | var tags: [String: TagDef] = [:] |
| | | var appTags: [String: [String]] = [:] // path → tag names |
| | | var tagOrder: [String] = [] // display order; empty → alpha sort |
| | | var migrated: Bool = false |
| | | } |
| | | |
| | | // MARK: Paths |
| | |
| | | try? data.write(to: storeURL, options: .atomic) |
| | | } |
| | | |
| | | // MARK: Migration (first launch: import Finder tags) |
| | | |
| | | /// Run once on first launch. Reads Finder tags from all scanned apps |
| | | /// and seeds the local database. Also auto-assigns "Mac自带" to Apple apps. |
| | | static func migrateFromFinderIfNeeded(apps: [AppInfo]) -> Store { |
| | | var store = load() |
| | | guard !store.migrated else { return store } |
| | | |
| | | // Read Finder tags from each app |
| | | for app in apps { |
| | | let finderTags = readFinderTags(from: app.path) |
| | | guard !finderTags.isEmpty else { continue } |
| | | store.appTags[app.path.path] = finderTags.map { $0.name } |
| | | for (name, color) in finderTags { |
| | | if store.tags[name] == nil { |
| | | store.tags[name] = TagDef(color: color) |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Auto-assign "Mac自带" tag to Apple apps |
| | | for app in apps where app.isAppleApp { |
| | | var current = store.appTags[app.path.path] ?? [] |
| | | if !current.contains("Mac自带") { |
| | | current.append("Mac自带") |
| | | store.appTags[app.path.path] = current |
| | | } |
| | | } |
| | | if store.tags["Mac自带"] == nil { |
| | | store.tags["Mac自带"] = TagDef(color: 1) // gray |
| | | } |
| | | |
| | | store.migrated = true |
| | | save(store) |
| | | return store |
| | | } |
| | | |
| | | /// Read Finder tags (name, color) from a single .app bundle. |
| | | /// Only used during one-time migration. |
| | | private static func readFinderTags(from url: URL) -> [(name: String, color: Int)] { |
| | | // Read tag names via NSURL resource values |
| | | guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]), |
| | | let tagNames = values.tagNames, !tagNames.isEmpty |
| | | else { return [] } |
| | | |
| | | // Read tag colors from xattr |
| | | let xattrName = "com.apple.metadata:_kMDItemUserTags" |
| | | let path = url.path |
| | | let size = getxattr(path, xattrName, nil, 0, 0, 0) |
| | | guard size > 0 else { |
| | | return tagNames.map { ($0, 0) } |
| | | } |
| | | |
| | | var buffer = [UInt8](repeating: 0, count: size) |
| | | guard getxattr(path, xattrName, &buffer, size, 0, 0) == size else { |
| | | return tagNames.map { ($0, 0) } |
| | | } |
| | | |
| | | let data = Data(buffer) |
| | | guard let plist = try? PropertyListSerialization.propertyList( |
| | | from: data, options: [], format: nil |
| | | ) as? [String] else { |
| | | return tagNames.map { ($0, 0) } |
| | | } |
| | | |
| | | // Build name→color map from xattr entries |
| | | var colorMap: [String: Int] = [:] |
| | | for entry in plist { |
| | | let parts = entry.components(separatedBy: "\n") |
| | | guard !parts[0].isEmpty else { continue } |
| | | let c = (parts.count >= 2) ? (Int(parts[1]) ?? 0) : 0 |
| | | colorMap[parts[0]] = (0...7).contains(c) ? c : 0 |
| | | } |
| | | |
| | | return tagNames.map { ($0, colorMap[$0] ?? 0) } |
| | | } |
| | | |
| | | // MARK: Export / Import |
| | | |
| | | static func exportTo(_ url: URL) throws { |
| | |
| | | let store = try JSONDecoder().decode(Store.self, from: data) |
| | | save(store) |
| | | return store |
| | | } |
| | | |
| | | /// Seed default tags on first launch. Only runs if store doesn't exist yet. |
| | | /// Tag names are loaded from the current language's localization. |
| | | static func seedDefaultTags() { |
| | | guard !FileManager.default.fileExists(atPath: storeURL.path) else { return } |
| | | |
| | | let keys = [ |
| | | "tag.design", "tag.development", "tag.writing", |
| | | "tag.gaming", "tag.entertainment", "tag.system", |
| | | "tag.productivity" |
| | | ] |
| | | let colors: [Int] = [1, 2, 3, 4, 5, 6, 7] |
| | | |
| | | var store = Store() |
| | | for (i, key) in keys.enumerated() { |
| | | let name = tr(key) |
| | | store.tags[name] = TagDef(color: colors[i]) |
| | | store.tagOrder.append(name) |
| | | } |
| | | save(store) |
| | | } |
| | | } |
| | | |
| | |
| | | TagDatabase.save(store) |
| | | } |
| | | |
| | | /// Remove a tag from specific apps. |
| | | static func removeTag(_ tag: String, from paths: [String]) { |
| | | static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) { |
| | | var store = TagDatabase.load() |
| | | for path in paths { |
| | | store.appTags[path]?.removeAll { $0 == tag } |
| | | if store.appTags[path]?.isEmpty == true { |
| | | store.appTags.removeValue(forKey: path) |
| | | if store.tags[targetTag] == nil { |
| | | store.tags[targetTag] = TagDatabase.TagDef(color: color) |
| | | if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) } |
| | | } |
| | | |
| | | var current = store.appTags[path] ?? [] |
| | | if !copy, !sourceTag.isEmpty { |
| | | current.removeAll { $0 == sourceTag } |
| | | } |
| | | if !current.contains(targetTag) { |
| | | current.append(targetTag) |
| | | } |
| | | |
| | | if current.isEmpty { |
| | | store.appTags.removeValue(forKey: path) |
| | | } else { |
| | | store.appTags[path] = current |
| | | } |
| | | TagDatabase.save(store) |
| | | } |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>4.0.7</string> |
| | | <string>5.1.12</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>8</string> |
| | | <string>12</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | | <string>26.0</string> |
| | | <string>15.0</string> |
| | | <key>NSHighResolutionCapable</key> |
| | | <true/> |
| | | <key>NSPrincipalClass</key> |
| | |
| | | UserDefaults.standard.set(code, forKey: "appLanguage") |
| | | } |
| | | |
| | | /// Load a specific key's translation for a given language code without switching. |
| | | static func loadedTranslation(_ key: String, for code: String) -> String? { |
| | | guard let url = Bundle.main.url( |
| | | forResource: code, withExtension: "json", |
| | | subdirectory: "Localization" |
| | | ) else { return nil } |
| | | guard let data = try? Data(contentsOf: url), |
| | | let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] |
| | | else { return nil } |
| | | return dict[key] |
| | | } |
| | | |
| | | private static func load(_ code: String) { |
| | | guard let url = Bundle.main.url( |
| | | forResource: code, withExtension: "json", |
| | |
| | | "app.build": "Build", |
| | | "group.uncategorized": "Uncategorized", |
| | | "group.appleBuiltIn": "Built-in", |
| | | "drop.systemDefaultWarning": "Do not drag apps into system default categories", |
| | | "tag.design": "Design", |
| | | "tag.development": "Development", |
| | | "tag.writing": "Writing", |
| | |
| | | "app.build": "Build", |
| | | "group.uncategorized": "Sin categorizar", |
| | | "group.appleBuiltIn": "Integradas", |
| | | "drop.systemDefaultWarning": "No arrastres a las categorías predeterminadas del sistema", |
| | | "tag.design": "Diseño", |
| | | "tag.development": "Desarrollo", |
| | | "tag.writing": "Escritura", |
| | |
| | | "app.build": "Build", |
| | | "group.uncategorized": "Non classé", |
| | | "group.appleBuiltIn": "Intégrées", |
| | | "drop.systemDefaultWarning": "Ne déposez pas dans les catégories système par défaut", |
| | | "tag.design": "Design", |
| | | "tag.development": "Développement", |
| | | "tag.writing": "Écriture", |
| | |
| | | "app.build": "Build", |
| | | "group.uncategorized": "Non categorizzato", |
| | | "group.appleBuiltIn": "Integrate", |
| | | "drop.systemDefaultWarning": "Non trascinare nelle categorie di sistema predefinite", |
| | | "tag.design": "Design", |
| | | "tag.development": "Sviluppo", |
| | | "tag.writing": "Scrittura", |
| | |
| | | "app.build": "ビルド", |
| | | "group.uncategorized": "未分類", |
| | | "group.appleBuiltIn": "Mac内蔵", |
| | | "drop.systemDefaultWarning": "システム既定カテゴリにはドラッグできません", |
| | | "tag.design": "デザイン", |
| | | "tag.development": "プログラミング", |
| | | "tag.writing": "ライティング", |
| | |
| | | "app.build": "빌드", |
| | | "group.uncategorized": "미분류", |
| | | "group.appleBuiltIn": "Mac 내장", |
| | | "drop.systemDefaultWarning": "시스템 기본 분류로 드래그하지 마세요", |
| | | "tag.design": "디자인", |
| | | "tag.development": "프로그래밍", |
| | | "tag.writing": "글쓰기", |
| | |
| | | "app.build": "Сборка", |
| | | "group.uncategorized": "Без категории", |
| | | "group.appleBuiltIn": "Встроенные", |
| | | "drop.systemDefaultWarning": "Не перетаскивайте в системные категории по умолчанию", |
| | | "tag.design": "Дизайн", |
| | | "tag.development": "Разработка", |
| | | "tag.writing": "Текст", |
| | |
| | | "app.build": "构建", |
| | | "group.uncategorized": "未分类", |
| | | "group.appleBuiltIn": "Mac自带", |
| | | "drop.systemDefaultWarning": "请勿拖动至系统默认分类", |
| | | "tag.design": "设计", |
| | | "tag.development": "编程", |
| | | "tag.writing": "写作", |
| | |
| | | "app.build": "建置", |
| | | "group.uncategorized": "未分類", |
| | | "group.appleBuiltIn": "Mac內建", |
| | | "drop.systemDefaultWarning": "請勿拖動至系統預設分類", |
| | | "tag.design": "設計", |
| | | "tag.development": "程式設計", |
| | | "tag.writing": "寫作", |
| | |
| | | newTagNameText = "" |
| | | newTagColorIndex = 0 |
| | | } label: { |
| | | Label("New Tag", systemImage: "plus") |
| | | Label(tr("tag.newTag"), systemImage: "plus") |
| | | .font(.system(size: 12)) |
| | | } |
| | | .buttonStyle(.bordered) |
| | |
| | | ScrollView { |
| | | VStack(spacing: 0) { |
| | | if sortedTagNames.isEmpty && !addingNewTag { |
| | | Text("No tags yet. Click \"New Tag\" to create one.") |
| | | Text(tr("edit.noTags")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .padding(.vertical, 40) |
| | |
| | | if editingTagName == tagName { |
| | | MacTextField( |
| | | text: $editingTagText, |
| | | placeholder: "Tag name", |
| | | placeholder: tr("tag.name"), |
| | | onSubmit: { commitTagRename(tagName) } |
| | | ) |
| | | .frame(width: 160, height: 24) |
| | | Button("Save") { commitTagRename(tagName) } |
| | | Button(tr("tag.save")) { commitTagRename(tagName) } |
| | | .buttonStyle(.borderedProminent).controlSize(.small) |
| | | Button("Cancel") { editingTagName = nil } |
| | | Button(tr("tag.cancel")) { editingTagName = nil } |
| | | .buttonStyle(.plain).foregroundStyle(.secondary) |
| | | } else { |
| | | Text(tagName).font(.system(size: 14)).frame(width: 160, alignment: .leading) |
| | |
| | | } |
| | | MacTextField( |
| | | text: $newTagNameText, |
| | | placeholder: "New tag name", |
| | | placeholder: tr("tag.newName"), |
| | | onSubmit: { addNewTag() } |
| | | ) |
| | | .frame(width: 160, height: 24) |
| | | Button("Add") { addNewTag() } |
| | | Button(tr("tag.add")) { addNewTag() } |
| | | .buttonStyle(.borderedProminent).controlSize(.small) |
| | | Button("Cancel") { addingNewTag = false; newTagNameText = "" } |
| | | Button(tr("tag.cancel")) { addingNewTag = false; newTagNameText = "" } |
| | | .buttonStyle(.plain).foregroundStyle(.secondary) |
| | | Spacer() |
| | | } |
| | |
| | | tagColors[newName] = tagColors[oldName] |
| | | tagColors.removeValue(forKey: oldName) |
| | | editingTagName = nil |
| | | // Don't call onRefresh — re-scan may re-discover old tag from |
| | | // SIP-protected apps where xattr write silently fails. |
| | | onRefresh?() |
| | | } |
| | | |
| | | /// Add a new tag name+color and persist to the database. |
| | |
| | | import SwiftUI |
| | | import UniformTypeIdentifiers |
| | | |
| | | // MARK: - Tag Group Section (with centered separator-line header) |
| | | |
| | |
| | | let tagFontSize: CGFloat |
| | | let iconSize: CGFloat |
| | | var showNames: Bool = true |
| | | var dragModeActive: Bool = false |
| | | var onDragModeChange: ((Bool) -> Void)? = nil |
| | | var onDropApp: ((String, String, Bool) -> Void)? = nil |
| | | |
| | | /// Adaptive columns — auto-fit based on icon size and available width. |
| | | private var columns: [GridItem] { |
| | |
| | | app: app, |
| | | iconSize: iconSize, |
| | | showName: showNames, |
| | | sourceTag: group.name, |
| | | dragModeActive: dragModeActive, |
| | | onDragModeChange: onDragModeChange, |
| | | onSelect: { onSelectApp(app) } |
| | | ) |
| | | } |
| | | } |
| | | } |
| | | .contentShape(Rectangle()) |
| | | .overlay { |
| | | 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 |
| | | } |
| | | } |
| | |
| | | SWIFT_DIR="$PROJECT_DIR/Apptag" |
| | | |
| | | SDK_PATH=$(xcrun --sdk macosx --show-sdk-path) |
| | | TARGET="arm64-apple-macosx26.0" |
| | | TARGET="arm64-apple-macosx15.0" |
| | | |
| | | # --- Optional: App Store / Sandbox signing --- |
| | | # Set CODESIGN_IDENTITY to your "Apple Distribution" or "Mac Developer" cert name. |
| | |
| | | #!/usr/bin/env python3 |
| | | """Generate Apptag app icon — two overlapping tags (red + blue) on macOS squircle.""" |
| | | from PIL import Image, ImageDraw, ImageFilter, ImageChops |
| | | import math, os, subprocess |
| | | from PIL import Image, ImageDraw, ImageFilter |
| | | import math, os, subprocess, tempfile |
| | | |
| | | SIZE = 1024 |
| | | OUT_DIR = "/Users/ar/Projects/Apptag/Apptag/Assets.xcassets/AppIcon.appiconset" |
| | | os.makedirs(OUT_DIR, exist_ok=True) |
| | | |
| | | def rounded_rectangle_mask(size, radius): |
| | | """Create a mask for a rounded rectangle.""" |
| | | mask = Image.new("L", (size, size), 0) |
| | | draw = ImageDraw.Draw(mask) |
| | | draw.rounded_rectangle([(0, 0), (size-1, size-1)], radius=radius, fill=255) |
| | | return mask |
| | | |
| | | def draw_tag(draw, cx, cy, width, height, color, angle_deg, hole_ratio=0.22): |
| | | """Draw a tag shape (rounded rect with hole) rotated by angle.""" |
| | | # Create a temp image for this tag |
| | | def draw_tag(cx, cy, width, height, color, angle_deg, hole_ratio=0.22): |
| | | padding = int(width * 1.5) |
| | | tag_img = Image.new("RGBA", (padding*2, padding*2), (0,0,0,0)) |
| | | tag_draw = ImageDraw.Draw(tag_img) |
| | | |
| | | x0 = padding - width//2 |
| | | y0 = padding - height//2 |
| | | x1 = padding + width//2 |
| | | y1 = padding + height//2 |
| | | r = int(min(width, height) * 0.18) # corner radius |
| | | |
| | | # Main tag body |
| | | r = int(min(width, height) * 0.18) |
| | | tag_draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=color) |
| | | |
| | | # Hole at top |
| | | hole_r = int(width * hole_ratio) |
| | | hole_x = padding + width//2 - int(width * 0.22) |
| | | hole_y = padding - height//2 + int(height * 0.2) |
| | |
| | | [hole_x - hole_r, hole_y - hole_r, hole_x + hole_r, hole_y + hole_r], |
| | | fill=(0,0,0,0) |
| | | ) |
| | | |
| | | # Rotate |
| | | tag_img = tag_img.rotate(angle_deg, resample=Image.BICUBIC, expand=True) |
| | | |
| | | return tag_img, padding, hole_x, hole_y |
| | | return tag_img |
| | | |
| | | # --- Build the icon --- |
| | | img = Image.new("RGBA", (SIZE, SIZE), (0,0,0,0)) |
| | | draw = ImageDraw.Draw(img) |
| | | |
| | | # macOS Big Sur style squircle background |
| | | squircle_r = int(SIZE * 0.225) # ~225px for 1024 canvas |
| | | squircle_r = int(SIZE * 0.225) |
| | | bg_mask = rounded_rectangle_mask(SIZE, squircle_r) |
| | | bg_color = Image.new("RGBA", (SIZE, SIZE), (240, 241, 245, 255)) # Light gray bg |
| | | bg_color = Image.new("RGBA", (SIZE, SIZE), (240, 241, 245, 255)) |
| | | img = Image.composite(bg_color, img, bg_mask) |
| | | |
| | | # Create two tags |
| | | red_color = (220, 45, 85, 255) # Rich red/pink |
| | | blue_color = (45, 130, 220, 255) # Vibrant blue |
| | | red_color = (220, 45, 85, 255) |
| | | blue_color = (45, 130, 220, 255) |
| | | |
| | | # Red tag - slightly tilted left, on top |
| | | tag_w = int(SIZE * 0.42) |
| | | tag_h = int(SIZE * 0.55) |
| | | red_tag, red_pad, _, _ = draw_tag(None, 0, 0, tag_w, tag_h, red_color, -8) |
| | | # Position center, slightly up |
| | | red_tag = draw_tag(0, 0, tag_w, tag_h, red_color, -8) |
| | | red_x = SIZE//2 - red_tag.width//2 |
| | | red_y = SIZE//2 - red_tag.height//2 - int(SIZE * 0.04) |
| | | |
| | | # Blue tag - tilted right, behind/below |
| | | tag_w2 = int(SIZE * 0.46) |
| | | tag_h2 = int(SIZE * 0.58) |
| | | blue_tag, _, _, _ = draw_tag(None, 0, 0, tag_w2, tag_h2, blue_color, 12) |
| | | blue_tag = draw_tag(0, 0, tag_w2, tag_h2, blue_color, 12) |
| | | blue_x = SIZE//2 - blue_tag.width//2 + int(SIZE * 0.02) |
| | | blue_y = SIZE//2 - blue_tag.height//2 + int(SIZE * 0.03) |
| | | |
| | | # Composite: blue behind, red in front |
| | | # Add drop shadows first |
| | | # Drop shadows |
| | | shadow = blue_tag.copy() |
| | | shadow_data = shadow.getdata() |
| | | shadow.putdata([(0,0,0, min(a//4, 80)) if a > 0 else (0,0,0,0) for (r,g,b,a) in shadow_data]) |
| | | shadow = shadow.filter(ImageFilter.GaussianBlur(radius=SIZE*0.025)) |
| | | img.paste(shadow, (blue_x+int(SIZE*0.015), blue_y+int(SIZE*0.02)), shadow) |
| | | |
| | | img.paste(blue_tag, (blue_x, blue_y), blue_tag) |
| | | |
| | | # Red tag shadow |
| | | shadow2 = red_tag.copy() |
| | | shadow_data2 = shadow2.getdata() |
| | | shadow2.putdata([(0,0,0, min(a//3, 80)) if a > 0 else (0,0,0,0) for (r,g,b,a) in shadow_data2]) |
| | | shadow2 = shadow2.filter(ImageFilter.GaussianBlur(radius=SIZE*0.02)) |
| | | img.paste(shadow2, (red_x+int(SIZE*0.01), red_y+int(SIZE*0.015)), shadow2) |
| | | |
| | | img.paste(red_tag, (red_x, red_y), red_tag) |
| | | |
| | | # Apply squircle clip mask |
| | | mask = rounded_rectangle_mask(SIZE, squircle_r) |
| | | img.putalpha(mask) |
| | | |
| | | # --- Save PNG variants for .icns --- |
| | | # --- Save PNG variants to temp .iconset, then generate .icns --- |
| | | with tempfile.TemporaryDirectory() as tmpdir: |
| | | iconset = os.path.join(tmpdir, "AppIcon.iconset") |
| | | os.makedirs(iconset) |
| | | |
| | | variants = [ |
| | | ("icon_16x16.png", 16), |
| | | ("icon_16x16@2x.png", 32), |
| | |
| | | |
| | | for name, size in variants: |
| | | resized = img.resize((size, size), Image.LANCZOS) |
| | | resized.save(os.path.join(OUT_DIR, name), "PNG") |
| | | print(f" {name} ({size}x{size})") |
| | | path = os.path.join(iconset, name) |
| | | resized.save(path, "PNG", optimize=True) |
| | | print(f" {name} ({size}x{size}): {os.path.getsize(path):,}B") |
| | | |
| | | # Also save a 1024 preview |
| | | img.save(os.path.join(OUT_DIR, "icon_1024_preview.png"), "PNG") |
| | | print(f"\nSaved {len(variants)} variants to {OUT_DIR}") |
| | | # Generate .icns to project root |
| | | out_icns = "/Users/ar/Projects/Apptag/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)") |
| | | |
| | | # --- Generate .icns using iconutil --- |
| | | iconset_dir = OUT_DIR.rstrip('/') |
| | | subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", |
| | | "/Users/ar/Projects/Apptag/build/Apptag.app/Contents/Resources/AppIcon.icns"], |
| | | check=True) |
| | | print("Generated AppIcon.icns") |
| | | |
| | | # Also copy to build dir |
| | | subprocess.run(["cp", os.path.join(OUT_DIR, "icon_1024_preview.png"), |
| | | "/Users/ar/Projects/Apptag/icon_preview.png"], check=True) |
| | | print("Preview: /Users/ar/Projects/Apptag/icon_preview.png") |
| | | # Also save 1024x1024 preview |
| | | preview_path = "/Users/ar/Projects/Apptag/icon_preview.png" |
| | | img.save(preview_path, "PNG", optimize=True) |
| | | print(f"Preview: {preview_path}") |