Release TagLauncher 5.5.0
| | |
| | | @State private var isHovered = false |
| | | @State private var wiggle = false |
| | | |
| | | private let hoverScale: CGFloat = 1.22 |
| | | static let hoverScale: CGFloat = 1.22 |
| | | static let labelHeight: CGFloat = 14 |
| | | |
| | | static func stableWidth(iconSize: CGFloat) -> CGFloat { |
| | | iconSize * hoverScale + 8 |
| | | } |
| | | |
| | | static func stableHeight(iconSize: CGFloat) -> CGFloat { |
| | | iconSize * hoverScale + labelHeight + 22 |
| | | } |
| | | |
| | | 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 ?? "")", |
| | | onHover: { isHovered = $0 }, |
| | | onLongPress: { onDragModeChange?(true) }, |
| | | onDragEnd: { onDragModeChange?(false) }, |
| | | onClick: onSelect |
| | | ) |
| | | .frame(width: iconSize, height: iconSize) |
| | | .scaleEffect(isHovered ? hoverScale : 1.0) |
| | | .scaleEffect(isHovered ? Self.hoverScale : 1.0) |
| | | .shadow( |
| | | color: .black.opacity(isHovered ? 0.35 : 0), |
| | | radius: isHovered ? 14 : 0, |
| | | y: isHovered ? 8 : 0 |
| | | ) |
| | | .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered) |
| | | } |
| | | .frame(width: iconSlotSize, height: iconSlotSize) |
| | | |
| | | Text(app.name) |
| | | .font(.system(size: 11, weight: .medium)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | .frame(maxWidth: iconSize + 20) |
| | | .frame(width: labelWidth, height: labelHeight) |
| | | .opacity(showName ? 1 : (isHovered ? 0.85 : 0)) |
| | | } |
| | | .padding(.vertical, 8) |
| | | .padding(.horizontal, 4) |
| | | .contentShape(RoundedRectangle(cornerRadius: 10)) |
| | | .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize)) |
| | | .contentShape(Rectangle()) |
| | | .onHover { hovering in |
| | | isHovered = hovering |
| | | } |
| | | .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0)) |
| | | .animation( |
| | | dragModeActive |
| | |
| | | let icon: NSImage |
| | | let iconSize: CGFloat |
| | | let payload: String |
| | | let onHover: (Bool) -> Void |
| | | let onLongPress: () -> Void |
| | | let onDragEnd: () -> Void |
| | | let onClick: () -> Void |
| | |
| | | view.image = icon |
| | | view.iconSize = iconSize |
| | | view.payload = payload |
| | | view.onHover = onHover |
| | | view.onLongPress = onLongPress |
| | | view.onDragEnd = onDragEnd |
| | | view.onClick = onClick |
| | |
| | | view.image = icon |
| | | view.iconSize = iconSize |
| | | view.payload = payload |
| | | view.onHover = onHover |
| | | view.onLongPress = onLongPress |
| | | view.onDragEnd = onDragEnd |
| | | view.onClick = onClick |
| | |
| | | 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 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) |
| | |
| | | height: iconSize |
| | | ) |
| | | image.draw(in: rect) |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | onHover?(true) |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | |
| | | didStartDrag = false |
| | | isLongPressActive = false |
| | | mouseDownEvent = nil |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | if !didStartDrag { |
| | | onHover?(false) |
| | | } |
| | | } |
| | | |
| | | private func makeDragImage() -> NSImage { |
| | |
| | | Settings { |
| | | PreferencesView() |
| | | } |
| | | .defaultSize(width: 660, height: 380) |
| | | .defaultSize(width: 880, height: 420) |
| | | } |
| | | } |
| | | |
| | |
| | | observeSettingsClose() |
| | | observeEditMode() |
| | | observeDockSetting() |
| | | observeLanguageChanges() |
| | | setupLaunchAtLogin() |
| | | } |
| | | |
| | |
| | | ) { _ in |
| | | let show = UserDefaults.standard.bool(forKey: "showDockIcon") |
| | | NSApp.setActivationPolicy(show ? .regular : .accessory) |
| | | } |
| | | } |
| | | |
| | | /// Keep app chrome in sync when language changes from any entry point. |
| | | private func observeLanguageChanges() { |
| | | NotificationCenter.default.addObserver( |
| | | forName: .appLanguageDidChange, |
| | | object: nil, queue: .main |
| | | ) { [weak self] _ in |
| | | self?.setupMenuBar() |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /// Settings must always float above the overlay so changes can be previewed live. |
| | | /// Settings must always appear centered over the current overlay view and float above it. |
| | | private func prepareSettingsWindow(_ window: NSWindow) { |
| | | if overlayWindow?.isVisible == true { |
| | | let overlayLevel = overlayWindow?.level.rawValue ?? NSWindow.Level.screenSaver.rawValue |
| | | window.level = NSWindow.Level(rawValue: overlayLevel + 1) |
| | | let settingsSize = NSSize(width: 880, height: max(window.frame.height, 420)) |
| | | window.minSize = NSSize(width: 880, height: 420) |
| | | window.maxSize = NSSize(width: 880, height: CGFloat.greatestFiniteMagnitude) |
| | | if abs(window.frame.width - settingsSize.width) > 0.5 || window.frame.height < settingsSize.height { |
| | | window.setFrame( |
| | | NSRect(origin: window.frame.origin, size: settingsSize), |
| | | display: false |
| | | ) |
| | | } |
| | | |
| | | if let overlayWindow, overlayWindow.isVisible { |
| | | center(window, over: overlayWindow.frame) |
| | | window.level = NSWindow.Level(rawValue: overlayWindow.level.rawValue + 1) |
| | | } else if let screen = screenUnderMouse() { |
| | | center(window, over: screen.visibleFrame) |
| | | window.level = .floating |
| | | } |
| | | |
| | | window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary]) |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | } |
| | | if settingsWindow == nil { |
| | | window.minSize = NSSize(width: 660, height: 380) |
| | | window.maxSize = NSSize(width: 660, height: CGFloat.greatestFiniteMagnitude) |
| | | } |
| | | settingsWindow = window |
| | | } |
| | | |
| | | private func center(_ window: NSWindow, over rect: NSRect) { |
| | | let frame = window.frame |
| | | let origin = NSPoint( |
| | | x: rect.midX - frame.width / 2, |
| | | y: rect.midY - frame.height / 2 |
| | | ) |
| | | window.setFrameOrigin(origin) |
| | | } |
| | | |
| | | private func screenUnderMouse() -> 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. |
| | |
| | | @objc private func openPreferences() { |
| | | // Don't hide overlay — keep it visible for real-time setting preview. |
| | | NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) |
| | | DispatchQueue.main.async { [weak self] in |
| | | prepareSettingsWindowWhenAvailable(retries: 5) |
| | | } |
| | | |
| | | private func prepareSettingsWindowWhenAvailable(retries: Int) { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in |
| | | guard let self else { return } |
| | | for window in NSApp.windows where window != self.overlayWindow { |
| | | self.prepareSettingsWindow(window) |
| | | let candidates = NSApp.windows.filter { window in |
| | | window != self.overlayWindow && window.isVisible |
| | | } |
| | | candidates.forEach { self.prepareSettingsWindow($0) } |
| | | if candidates.isEmpty && retries > 0 { |
| | | self.prepareSettingsWindowWhenAvailable(retries: retries - 1) |
| | | } |
| | | } |
| | | } |
| | |
| | | @objc private func switchLanguage(_ sender: NSMenuItem) { |
| | | guard let code = sender.representedObject as? String else { return } |
| | | L10n.switchTo(code) |
| | | // Rebuild menu to update checkmarks |
| | | setupMenuBar() |
| | | } |
| | | } |
| | | |
| | |
| | | // MARK: - Preferences View |
| | | |
| | | struct PreferencesView: View { |
| | | private let settingsWindowWidth: CGFloat = 880 |
| | | private let settingsContentWidth: CGFloat = 820 |
| | | private let settingsLabelWidth: CGFloat = 160 |
| | | private let widePickerWidth: CGFloat = 650 |
| | | private let compactPickerWidth: CGFloat = 320 |
| | | |
| | | @AppStorage("tagFontSize") private var tagFontSize: Double = 18 |
| | | @AppStorage("iconSize") private var iconSize: Double = 56 |
| | | @AppStorage("tagPosition") private var tagPosition = "left" |
| | |
| | | @AppStorage("hideAppNames") private var hideAppNames = false |
| | | @AppStorage("showDockIcon") private var showDockIcon = false |
| | | @AppStorage("launchAtLogin") private var launchAtLogin = true |
| | | @State private var selectedLanguage = L10n.currentCode |
| | | @State private var languageRefreshID = UUID() |
| | | @State private var isRefreshingLanguage = false |
| | | @State private var allApps: [AppInfo] = [] |
| | | @State private var tagColors: [String: Int] = [:] |
| | | |
| | |
| | | } |
| | | |
| | | var body: some View { |
| | | ZStack { |
| | | TabView { |
| | | // Tab 1: General |
| | | // Tab 1: Language |
| | | Form { |
| | | Section { |
| | | Picker(tr("settings.languagePicker"), selection: $selectedLanguage) { |
| | | ForEach(L10n.supported, id: \.code) { language in |
| | | Text(language.name).tag(language.code) |
| | | } |
| | | } |
| | | .pickerStyle(.radioGroup) |
| | | } header: { |
| | | Text(tr("settings.language")) |
| | | } footer: { |
| | | Text(tr("settings.languageDesc")) |
| | | } |
| | | } |
| | | .tabItem { Label(tr("settings.language"), systemImage: "globe") } |
| | | .padding() |
| | | |
| | | // Tab 2: General |
| | | VStack(alignment: .leading, spacing: 0) { |
| | | // Toggle row — centered as a rectangular block |
| | | HStack(spacing: 20) { |
| | |
| | | VStack(spacing: 16) { |
| | | HStack(alignment: .top, spacing: 16) { |
| | | Text(tr("settings.appListStyle")) |
| | | .frame(width: 130, alignment: .trailing) |
| | | .frame(width: settingsLabelWidth, alignment: .trailing) |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Picker("", selection: $displayMode) { |
| | | Text(tr("settings.flat")).tag("flat") |
| | |
| | | Text(tr("settings.coloredGridContainer")).tag("coloredGridContainer") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 520, alignment: .leading) |
| | | .frame(width: widePickerWidth, alignment: .leading) |
| | | Text(tr("settings.flatDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | } |
| | | HStack(alignment: .top, spacing: 16) { |
| | | Text(tr("settings.tagPosition")) |
| | | .frame(width: 130, alignment: .trailing) |
| | | .frame(width: settingsLabelWidth, alignment: .trailing) |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Picker("", selection: $tagPosition) { |
| | | Text(tr("settings.left")).tag("left") |
| | |
| | | Text(tr("settings.top")).tag("top") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 280, alignment: .leading) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | Text(tr("settings.tagPosDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | } |
| | | HStack(alignment: .top, spacing: 16) { |
| | | Text(tr("settings.tagFontSize")) |
| | | .frame(width: 130, alignment: .trailing) |
| | | .frame(width: settingsLabelWidth, alignment: .trailing) |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Picker("", selection: $tagFontSize) { |
| | | ForEach([16.0, 18.0, 20.0, 22.0, 24.0, 26.0], id: \.self) { size in |
| | |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 280, alignment: .leading) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | Text(tr("settings.tagFontDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | } |
| | | HStack(alignment: .top, spacing: 16) { |
| | | Text(tr("settings.iconSize")) |
| | | .frame(width: 130, alignment: .trailing) |
| | | .frame(width: settingsLabelWidth, alignment: .trailing) |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Picker("", selection: $iconSize) { |
| | | ForEach([40.0, 48.0, 56.0, 64.0, 72.0, 80.0], id: \.self) { size in |
| | |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 280, alignment: .leading) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | Text(tr("settings.iconSizeDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | } |
| | | } |
| | | } |
| | | .frame(maxWidth: settingsContentWidth, alignment: .center) |
| | | .padding() |
| | | .tabItem { Label(tr("settings.general"), systemImage: "gearshape") } |
| | | |
| | | // Tab 2: Tags |
| | | // Tab 3: Tags |
| | | VStack(spacing: 0) { |
| | | TagEditorView( |
| | | tagColors: $tagColors, |
| | |
| | | .tabItem { Label(tr("settings.tags"), systemImage: "tag.fill") } |
| | | .onAppear { scanApps() } |
| | | |
| | | // Tab 3: Data |
| | | // Tab 4: Data |
| | | Form { |
| | | Section { |
| | | HStack(spacing: 12) { |
| | |
| | | .tabItem { Label(tr("settings.data"), systemImage: "externaldrive.fill") } |
| | | .padding() |
| | | |
| | | // Tab 4: About |
| | | // Tab 5: About |
| | | Form { |
| | | Section { |
| | | HStack { |
| | |
| | | .tabItem { Label(tr("settings.about"), systemImage: "info.circle") } |
| | | .padding() |
| | | } |
| | | .frame(minWidth: 660, maxWidth: 660, minHeight: 380) |
| | | .id(languageRefreshID) |
| | | .onChange(of: selectedLanguage) { _, code in |
| | | L10n.switchTo(code) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { notification in |
| | | if let code = notification.userInfo?["code"] as? String { |
| | | selectedLanguage = code |
| | | } |
| | | showLanguageRefresh() |
| | | } |
| | | |
| | | if isRefreshingLanguage { |
| | | ZStack { |
| | | Color(nsColor: .windowBackgroundColor) |
| | | .opacity(0.84) |
| | | Image(systemName: "arrow.clockwise") |
| | | .font(.system(size: 30, weight: .regular)) |
| | | .symbolRenderingMode(.hierarchical) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | .transition(.opacity) |
| | | } |
| | | } |
| | | .frame(minWidth: settingsWindowWidth, maxWidth: settingsWindowWidth, minHeight: 420) |
| | | } |
| | | |
| | | private func showLanguageRefresh() { |
| | | isRefreshingLanguage = true |
| | | languageRefreshID = UUID() |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | isRefreshingLanguage = false |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | @State private var filledColorlessContainer: String? = nil |
| | | @State private var appDragModeActive = false |
| | | @State private var dropWarningToast: String? = nil |
| | | @State private var dropRefreshVisible = false |
| | | @State private var dropRefreshStartedAt: Date? = nil |
| | | @State private var layoutRefreshID = UUID() |
| | | |
| | | // Configurable defaults |
| | | @AppStorage("defaultGroupName") private var defaultGroupName = "Other" |
| | |
| | | .shadow(color: .black.opacity(0.22), radius: 18, y: 10) |
| | | ) |
| | | .transition(.scale(scale: 0.96).combined(with: .opacity)) |
| | | .allowsHitTesting(false) |
| | | } |
| | | |
| | | if dropRefreshVisible { |
| | | Color.black.opacity(0.08) |
| | | .ignoresSafeArea() |
| | | .transition(.opacity) |
| | | .allowsHitTesting(false) |
| | | |
| | | ProgressView() |
| | | .progressViewStyle(.circular) |
| | | .controlSize(.large) |
| | | .scaleEffect(1.15) |
| | | .padding(24) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 12) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.26), radius: 24, y: 12) |
| | | ) |
| | | .transition(.scale(scale: 0.92).combined(with: .opacity)) |
| | | .allowsHitTesting(false) |
| | | } |
| | | } |
| | |
| | | flatGrid |
| | | } |
| | | } |
| | | .id(layoutRefreshID) |
| | | } |
| | | |
| | | private var flatGrid: some View { |
| | |
| | | |
| | | private func estimatedCardHeight(_ group: TagGroup, width: CGFloat) -> CGFloat { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 28 + 6 |
| | | 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) * (iconSize + 30) |
| | | return 32 |
| | | + CGFloat(rows) * AppGridItem.stableHeight(iconSize: iconSize) |
| | | + CGFloat(max(0, rows - 1)) * 2 |
| | | } |
| | | |
| | | private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View { |
| | |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | .layoutPriority(0) |
| | | } |
| | | let itemSize = iconSize + 28 |
| | | let itemSize = AppGridItem.stableWidth(iconSize: iconSize) |
| | | LazyVGrid( |
| | | columns: [GridItem(.adaptive(minimum: itemSize, maximum: itemSize + 36), spacing: 6)], |
| | | spacing: 2 |
| | |
| | | } |
| | | |
| | | private func preferredGridContainersPerRow(availableWidth: CGFloat) -> Int { |
| | | let minCardWidth = max(260, iconSize * 3 + 88) |
| | | 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 func iconColumns(width: CGFloat) -> Int { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 34 |
| | | let itemW = AppGridItem.stableWidth(iconSize: iconSize) + 6 |
| | | return max(1, Int((inner + 6) / itemW)) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private func iconCellHeight() -> CGFloat { |
| | | iconSize + (hideAppNames ? 16 : 42) |
| | | AppGridItem.stableHeight(iconSize: iconSize) |
| | | } |
| | | |
| | | private func appRows(_ apps: [AppInfo], columns: Int, fixedRows: Int) -> [[AppInfo]] { |
| | |
| | | Spacer() |
| | | Button(tr("edit.confirm")) { confirmAssign() } |
| | | .buttonStyle(.borderedProminent) |
| | | .disabled(selectedAppPaths.isEmpty || selectedTagNames.isEmpty) |
| | | .disabled(selectedAppPaths.isEmpty) |
| | | } |
| | | .padding(.horizontal, 24) |
| | | .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20) |
| | |
| | | .padding(.bottom, 6) |
| | | |
| | | LazyVGrid( |
| | | columns: [GridItem(.adaptive(minimum: iconSize + 28, maximum: iconSize + 64), spacing: 6)], |
| | | columns: [ |
| | | GridItem( |
| | | .adaptive( |
| | | minimum: AppGridItem.stableWidth(iconSize: iconSize), |
| | | maximum: AppGridItem.stableWidth(iconSize: iconSize) + 36 |
| | | ), |
| | | spacing: 6 |
| | | ) |
| | | ], |
| | | spacing: 2 |
| | | ) { |
| | | ForEach(group.apps) { app in editableAppItem(app) } |
| | |
| | | } |
| | | |
| | | private func confirmAssign() { |
| | | guard !selectedAppPaths.isEmpty, !selectedTagNames.isEmpty else { return } |
| | | guard !selectedAppPaths.isEmpty else { return } |
| | | let paths = selectedAppPaths.map { $0.path } |
| | | for tagName in selectedTagNames { |
| | | TagEditor.assignTag(tagName, color: tagColors[tagName] ?? 0, to: paths) |
| | | } |
| | | let tags = sortedTagNames.filter { selectedTagNames.contains($0) } |
| | | TagEditor.setTags(tags, to: paths) |
| | | selectedAppPaths = []; selectedTagNames = [] |
| | | refreshApps() |
| | | withAnimation { successToast = tr("edit.success") } |
| | |
| | | private func editableAppItem(_ app: AppInfo) -> some View { |
| | | let isSelected = selectedAppPaths.contains(app.path) |
| | | return Button { |
| | | if isSelected { selectedAppPaths.remove(app.path) } else { selectedAppPaths.insert(app.path) } |
| | | toggleEditableAppSelection(app) |
| | | } label: { |
| | | VStack(spacing: 4) { |
| | | ZStack(alignment: .topTrailing) { |
| | |
| | | .opacity(isSelected ? 1.0 : 0.65) |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func toggleEditableAppSelection(_ app: AppInfo) { |
| | | if selectedAppPaths.contains(app.path) { |
| | | selectedAppPaths.remove(app.path) |
| | | } else { |
| | | selectedAppPaths.insert(app.path) |
| | | } |
| | | syncSelectedTagsFromSelectedApps() |
| | | } |
| | | |
| | | private func syncSelectedTagsFromSelectedApps() { |
| | | let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) } |
| | | guard !selectedApps.isEmpty else { |
| | | selectedTagNames = [] |
| | | return |
| | | } |
| | | |
| | | let editableTags = Set(sortedTagNames) |
| | | if selectedApps.count == 1 { |
| | | selectedTagNames = Set(selectedApps[0].tags.filter { editableTags.contains($0) }) |
| | | return |
| | | } |
| | | |
| | | selectedTagNames = selectedApps.reduce(into: Set<String>()) { result, app in |
| | | result.formUnion(app.tags.filter { editableTags.contains($0) }) |
| | | } |
| | | } |
| | | |
| | | // MARK: - Computed |
| | |
| | | color: tagColors[targetTag] ?? 0, |
| | | copy: copy |
| | | ) |
| | | refreshApps() |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool { |
| | |
| | | } |
| | | } |
| | | |
| | | private func showDropRefresh() { |
| | | dropRefreshStartedAt = Date() |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { |
| | | dropRefreshVisible = true |
| | | } |
| | | } |
| | | |
| | | private func finishDropRefreshAfterMinimumDuration() { |
| | | let minimumDuration: TimeInterval = 0.85 |
| | | let elapsed = dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration |
| | | let delay = max(0, minimumDuration - elapsed) |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | dropRefreshVisible = false |
| | | } |
| | | dropRefreshStartedAt = nil |
| | | } |
| | | } |
| | | |
| | | private func setAppDragMode(_ active: Bool) { |
| | | appDragModeActive = active |
| | | if active { |
| | |
| | | } |
| | | } |
| | | |
| | | func refreshApps() { |
| | | func refreshApps(forceLayoutRefresh: Bool = false) { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | var apps = AppIndexer.scan() |
| | | let store = TagDatabase.load() |
| | |
| | | allApps = apps |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | if forceLayoutRefresh { |
| | | withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { |
| | | layoutRefreshID = UUID() |
| | | } |
| | | finishDropRefreshAfterMinimumDuration() |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | TagDatabase.save(store) |
| | | } |
| | | |
| | | /// Replace the full editable tag set for multiple apps. |
| | | static func setTags(_ tags: [String], to paths: [String]) { |
| | | var store = TagDatabase.load() |
| | | let validTags = tags.filter { store.tags[$0] != nil } |
| | | for path in paths { |
| | | if validTags.isEmpty { |
| | | store.appTags.removeValue(forKey: path) |
| | | } else { |
| | | store.appTags[path] = validTags |
| | | } |
| | | } |
| | | TagDatabase.save(store) |
| | | } |
| | | |
| | | static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) { |
| | | var store = TagDatabase.load() |
| | | if store.tags[targetTag] == nil { |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>5.2.0</string> |
| | | <string>5.5.0</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>13</string> |
| | | <string>550</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | | <string>15.0</string> |
| | | <key>NSHighResolutionCapable</key> |
| | |
| | | |
| | | // MARK: - Localization Manager |
| | | |
| | | extension Notification.Name { |
| | | static let appLanguageDidChange = Notification.Name("AppLanguageDidChange") |
| | | } |
| | | |
| | | enum L10n { |
| | | static var current: [String: String] = [:] |
| | | private(set) static var currentCode = "en" |
| | |
| | | ] |
| | | |
| | | static func switchTo(_ code: String) { |
| | | guard supported.contains(where: { $0.code == code }) else { return } |
| | | guard code != currentCode else { return } |
| | | load(code) |
| | | UserDefaults.standard.set(code, forKey: "appLanguage") |
| | | NotificationCenter.default.post(name: .appLanguageDidChange, object: nil, userInfo: ["code": currentCode]) |
| | | } |
| | | |
| | | /// Load a specific key's translation for a given language code without switching. |
| | |
| | | "menu.quit": "Quit", |
| | | "menu.language": "Language", |
| | | "menu.version": "Version", |
| | | "settings.language": "Language", |
| | | "settings.languagePicker": "App language:", |
| | | "settings.languageDesc": "Choose the language used by menus and settings.", |
| | | "settings.general": "General", |
| | | "settings.tags": "Tags", |
| | | "settings.data": "Data", |
| | |
| | | "settings.iconSize": "Icon size:", |
| | | "settings.iconSizeDesc": "Icon display size. Grid columns adjust automatically.", |
| | | "settings.backup": "Backup & Restore", |
| | | "settings.export": "Export Tags…", |
| | | "settings.import": "Import Tags…", |
| | | "settings.backupDesc": "Export saves your tag assignments to a JSON file. Import replaces the current database with one from a file.", |
| | | "settings.export": "Export Categories & Layout…", |
| | | "settings.import": "Import Categories & Layout…", |
| | | "settings.backupDesc": "Export saves your categories and layout to a JSON file. Import replaces the current setup with one from a file.", |
| | | "settings.importFailed": "Import Failed", |
| | | "edit.exit": "Exit editing", |
| | | "edit.tags": "Edit Tags", |
| | |
| | | "menu.quit": "Salir", |
| | | "menu.language": "Idioma", |
| | | "menu.version": "Versión", |
| | | "settings.language": "Idioma", |
| | | "settings.languagePicker": "Idioma de la app:", |
| | | "settings.languageDesc": "Elige el idioma usado en los menús y ajustes.", |
| | | "settings.general": "General", |
| | | "settings.tags": "Etiquetas", |
| | | "settings.data": "Datos", |
| | |
| | | "settings.iconSize": "Tamaño de iconos:", |
| | | "settings.iconSizeDesc": "Tamaño de visualización de iconos. Las columnas se ajustan automáticamente.", |
| | | "settings.backup": "Copia de seguridad", |
| | | "settings.export": "Exportar etiquetas…", |
| | | "settings.import": "Importar etiquetas…", |
| | | "settings.backupDesc": "La exportación guarda las asignaciones de etiquetas en un archivo JSON. La importación reemplaza la base de datos actual.", |
| | | "settings.export": "Exportar categorías y diseño…", |
| | | "settings.import": "Importar categorías y diseño…", |
| | | "settings.backupDesc": "La exportación guarda las categorías y el diseño en un archivo JSON. La importación reemplaza la configuración actual.", |
| | | "settings.importFailed": "Error de importación", |
| | | "edit.exit": "Salir de edición", |
| | | "edit.tags": "Editar etiquetas", |
| | |
| | | "menu.quit": "Quitter", |
| | | "menu.language": "Langue", |
| | | "menu.version": "Version", |
| | | "settings.language": "Langue", |
| | | "settings.languagePicker": "Langue de l'app :", |
| | | "settings.languageDesc": "Choisissez la langue utilisée par les menus et les réglages.", |
| | | "settings.general": "Général", |
| | | "settings.tags": "Étiquettes", |
| | | "settings.data": "Données", |
| | |
| | | "settings.iconSize": "Taille des icônes :", |
| | | "settings.iconSizeDesc": "Taille d'affichage des icônes. Les colonnes s'ajustent automatiquement.", |
| | | "settings.backup": "Sauvegarde et restauration", |
| | | "settings.export": "Exporter les étiquettes…", |
| | | "settings.import": "Importer les étiquettes…", |
| | | "settings.backupDesc": "L'export sauvegarde les attributions d'étiquettes dans un fichier JSON. L'import remplace la base actuelle.", |
| | | "settings.export": "Exporter catégories et disposition…", |
| | | "settings.import": "Importer catégories et disposition…", |
| | | "settings.backupDesc": "L'export sauvegarde les catégories et la disposition dans un fichier JSON. L'import remplace la configuration actuelle.", |
| | | "settings.importFailed": "Échec de l'import", |
| | | "edit.exit": "Quitter l'édition", |
| | | "edit.tags": "Modifier les étiquettes", |
| | |
| | | "menu.quit": "Esci", |
| | | "menu.language": "Lingua", |
| | | "menu.version": "Versione", |
| | | "settings.language": "Lingua", |
| | | "settings.languagePicker": "Lingua app:", |
| | | "settings.languageDesc": "Scegli la lingua usata da menu e impostazioni.", |
| | | "settings.general": "Generali", |
| | | "settings.tags": "Tag", |
| | | "settings.data": "Dati", |
| | |
| | | "settings.iconSize": "Dimensione icone:", |
| | | "settings.iconSizeDesc": "Dimensione di visualizzazione delle icone. Le colonne si adattano automaticamente.", |
| | | "settings.backup": "Backup e ripristino", |
| | | "settings.export": "Esporta tag…", |
| | | "settings.import": "Importa tag…", |
| | | "settings.backupDesc": "L'esportazione salva le assegnazioni tag in un file JSON. L'importazione sostituisce il database corrente.", |
| | | "settings.export": "Esporta categorie e layout…", |
| | | "settings.import": "Importa categorie e layout…", |
| | | "settings.backupDesc": "L'esportazione salva categorie e layout in un file JSON. L'importazione sostituisce la configurazione attuale.", |
| | | "settings.importFailed": "Importazione fallita", |
| | | "edit.exit": "Esci dalla modifica", |
| | | "edit.tags": "Modifica tag", |
| | |
| | | "menu.quit": "終了", |
| | | "menu.language": "言語", |
| | | "menu.version": "バージョン", |
| | | "settings.language": "言語", |
| | | "settings.languagePicker": "アプリの言語:", |
| | | "settings.languageDesc": "メニューと設定で使う言語を選択します。", |
| | | "settings.general": "一般", |
| | | "settings.tags": "タグ", |
| | | "settings.data": "データ", |
| | |
| | | "settings.iconSize": "アイコンサイズ:", |
| | | "settings.iconSizeDesc": "アイコン表示サイズ。グリッド列は自動調整されます。", |
| | | "settings.backup": "バックアップと復元", |
| | | "settings.export": "タグをエクスポート…", |
| | | "settings.import": "タグをインポート…", |
| | | "settings.backupDesc": "エクスポートはタグ割り当てをJSONファイルに保存。インポートはファイルで現在のデータベースを置き換えます。", |
| | | "settings.export": "カテゴリとレイアウトをエクスポート…", |
| | | "settings.import": "カテゴリとレイアウトをインポート…", |
| | | "settings.backupDesc": "エクスポートはカテゴリとレイアウトをJSONファイルに保存します。インポートは現在の設定をファイルの内容で置き換えます。", |
| | | "settings.importFailed": "インポート失敗", |
| | | "edit.exit": "編集を終了", |
| | | "edit.tags": "タグを編集", |
| | |
| | | "menu.quit": "종료", |
| | | "menu.language": "언어", |
| | | "menu.version": "버전", |
| | | "settings.language": "언어", |
| | | "settings.languagePicker": "앱 언어:", |
| | | "settings.languageDesc": "메뉴와 설정에서 사용할 언어를 선택합니다.", |
| | | "settings.general": "일반", |
| | | "settings.tags": "태그", |
| | | "settings.data": "데이터", |
| | |
| | | "settings.iconSize": "아이콘 크기:", |
| | | "settings.iconSizeDesc": "아이콘 표시 크기. 그리드 열은 자동 조정됩니다.", |
| | | "settings.backup": "백업 및 복원", |
| | | "settings.export": "태그 내보내기…", |
| | | "settings.import": "태그 가져오기…", |
| | | "settings.backupDesc": "내보내기는 태그 할당을 JSON 파일로 저장합니다. 가져오기는 파일로 현재 데이터베이스를 대체합니다.", |
| | | "settings.export": "분류와 레이아웃 내보내기…", |
| | | "settings.import": "분류와 레이아웃 가져오기…", |
| | | "settings.backupDesc": "내보내기는 분류와 레이아웃을 JSON 파일로 저장합니다. 가져오기는 파일의 내용으로 현재 설정을 대체합니다.", |
| | | "settings.importFailed": "가져오기 실패", |
| | | "edit.exit": "편집 종료", |
| | | "edit.tags": "태그 편집", |
| | |
| | | "menu.quit": "Выйти", |
| | | "menu.language": "Язык", |
| | | "menu.version": "Версия", |
| | | "settings.language": "Язык", |
| | | "settings.languagePicker": "Язык приложения:", |
| | | "settings.languageDesc": "Выберите язык меню и настроек.", |
| | | "settings.general": "Общие", |
| | | "settings.tags": "Теги", |
| | | "settings.data": "Данные", |
| | |
| | | "settings.iconSize": "Размер значков:", |
| | | "settings.iconSizeDesc": "Размер отображения значков. Колонки сетки подстраиваются автоматически.", |
| | | "settings.backup": "Резервное копирование", |
| | | "settings.export": "Экспорт тегов…", |
| | | "settings.import": "Импорт тегов…", |
| | | "settings.backupDesc": "Экспорт сохраняет назначения тегов в JSON-файл. Импорт заменяет текущую базу данных файлом.", |
| | | "settings.export": "Экспорт категорий и макета…", |
| | | "settings.import": "Импорт категорий и макета…", |
| | | "settings.backupDesc": "Экспорт сохраняет категории и макет в JSON-файл. Импорт заменяет текущие настройки данными из файла.", |
| | | "settings.importFailed": "Ошибка импорта", |
| | | "edit.exit": "Выйти из редактирования", |
| | | "edit.tags": "Редактировать теги", |
| | |
| | | "menu.quit": "退出", |
| | | "menu.language": "语言", |
| | | "menu.version": "版本", |
| | | "settings.language": "语言", |
| | | "settings.languagePicker": "应用语言:", |
| | | "settings.languageDesc": "选择菜单和设置面板使用的语言。", |
| | | "settings.general": "通用", |
| | | "settings.tags": "标签", |
| | | "settings.data": "数据", |
| | |
| | | "settings.iconSize": "图标大小:", |
| | | "settings.iconSizeDesc": "图标显示尺寸。网格列数自动调整。", |
| | | "settings.backup": "备份与恢复", |
| | | "settings.export": "导出标签…", |
| | | "settings.import": "导入标签…", |
| | | "settings.backupDesc": "导出将标签分配保存为 JSON 文件。导入用一个文件替换当前数据库。", |
| | | "settings.export": "导出分类与布局…", |
| | | "settings.import": "导入分类与布局…", |
| | | "settings.backupDesc": "导出将分类与布局保存为 JSON 文件。导入用一个文件替换当前设置。", |
| | | "settings.importFailed": "导入失败", |
| | | "edit.exit": "退出编辑", |
| | | "edit.tags": "编辑标签", |
| | |
| | | "menu.quit": "退出", |
| | | "menu.language": "語言", |
| | | "menu.version": "版本", |
| | | "settings.language": "語言", |
| | | "settings.languagePicker": "應用程式語言:", |
| | | "settings.languageDesc": "選擇選單和設定面板使用的語言。", |
| | | "settings.general": "一般", |
| | | "settings.tags": "標籤", |
| | | "settings.data": "資料", |
| | |
| | | "settings.iconSize": "圖示大小:", |
| | | "settings.iconSizeDesc": "圖示顯示尺寸。網格列數自動調整。", |
| | | "settings.backup": "備份與還原", |
| | | "settings.export": "匯出標籤…", |
| | | "settings.import": "匯入標籤…", |
| | | "settings.backupDesc": "匯出將標籤分配儲存為 JSON 檔案。匯入用一個檔案取代目前資料庫。", |
| | | "settings.export": "匯出分類與版面配置…", |
| | | "settings.import": "匯入分類與版面配置…", |
| | | "settings.backupDesc": "匯出將分類與版面配置儲存為 JSON 檔案。匯入用一個檔案取代目前設定。", |
| | | "settings.importFailed": "匯入失敗", |
| | | "edit.exit": "結束編輯", |
| | | "edit.tags": "編輯標籤", |
| | |
| | | |
| | | /// Adaptive columns — auto-fit based on icon size and available width. |
| | | private var columns: [GridItem] { |
| | | [GridItem(.adaptive(minimum: iconSize + 28, maximum: iconSize + 64), spacing: 6)] |
| | | 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 |