From c8a1b811bffd13dc6dfa39a7c5c0d4ee76cd11e5 Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Mon, 11 May 2026 12:53:34 +0800
Subject: [PATCH] Release TagLauncher 5.5.0

---
 Apptag/ApptagApp.swift           |  163 ++++++++++++++++---
 Apptag/AppGridItem.swift         |   90 ++++------
 Apptag/Localization/en.json      |    9 
 Apptag/Localization/zh-Hant.json |    9 
 Apptag/Localization/ja.json      |    9 
 Apptag/Info.plist                |    4 
 Apptag/Localization/fr.json      |    9 
 Apptag/TagGroupView.swift        |    3 
 Apptag/Localization/ko.json      |    9 
 Apptag/Localization/zh-Hans.json |    9 
 Apptag/ContentView.swift         |  116 ++++++++++++-
 Apptag/DataLayer.swift           |   14 +
 Apptag/Localization/it.json      |    9 
 Apptag/Localization/es.json      |    9 
 Apptag/L10n.swift                |    7 
 Apptag/Localization/ru.json      |    9 
 16 files changed, 354 insertions(+), 124 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index f25f223..3b4cc5a 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -15,38 +15,57 @@
     @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) {
-            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)
-            .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)
+            ZStack {
+                DraggableAppIconView(
+                    icon: app.icon,
+                    iconSize: iconSize,
+                    payload: "\(app.path.path)\n\(sourceTag ?? "")",
+                    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
+                )
+                .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
@@ -64,7 +83,6 @@
     let icon: NSImage
     let iconSize: CGFloat
     let payload: String
-    let onHover: (Bool) -> Void
     let onLongPress: () -> Void
     let onDragEnd: () -> Void
     let onClick: () -> Void
@@ -74,7 +92,6 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
-        view.onHover = onHover
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -85,7 +102,6 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
-        view.onHover = onHover
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -97,7 +113,6 @@
     var image: NSImage = NSImage()
     var iconSize: CGFloat = 56
     var payload: String = ""
-    var onHover: ((Bool) -> Void)?
     var onLongPress: (() -> Void)?
     var onDragEnd: (() -> Void)?
     var onClick: (() -> Void)?
@@ -106,24 +121,7 @@
     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)
@@ -134,10 +132,6 @@
             height: iconSize
         )
         image.draw(in: rect)
-    }
-
-    override func mouseEntered(with event: NSEvent) {
-        onHover?(true)
     }
 
     override func mouseDown(with event: NSEvent) {
@@ -194,12 +188,6 @@
         didStartDrag = false
         isLongPressActive = false
         mouseDownEvent = nil
-    }
-
-    override func mouseExited(with event: NSEvent) {
-        if !didStartDrag {
-            onHover?(false)
-        }
     }
 
     private func makeDragImage() -> NSImage {
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index b572f20..ff4466d 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -12,7 +12,7 @@
         Settings {
             PreferencesView()
         }
-        .defaultSize(width: 660, height: 380)
+        .defaultSize(width: 880, height: 420)
     }
 }
 
@@ -42,6 +42,7 @@
         observeSettingsClose()
         observeEditMode()
         observeDockSetting()
+        observeLanguageChanges()
         setupLaunchAtLogin()
     }
 
@@ -77,6 +78,16 @@
         ) { _ 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()
         }
     }
 
@@ -353,18 +364,46 @@
         }
     }
 
-    /// 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)
-            window.orderFrontRegardless()
+        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 settingsWindow == nil {
-            window.minSize = NSSize(width: 660, height: 380)
-            window.maxSize = NSSize(width: 660, height: CGFloat.greatestFiniteMagnitude)
+
+        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()
         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.
@@ -396,10 +435,18 @@
     @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)
             }
         }
     }
@@ -407,8 +454,6 @@
     @objc private func switchLanguage(_ sender: NSMenuItem) {
         guard let code = sender.representedObject as? String else { return }
         L10n.switchTo(code)
-        // Rebuild menu to update checkmarks
-        setupMenuBar()
     }
 }
 
@@ -488,6 +533,12 @@
 // 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"
@@ -496,6 +547,9 @@
     @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] = [:]
 
@@ -557,8 +611,27 @@
     }
 
     var body: some View {
-        TabView {
-            // Tab 1: General
+        ZStack {
+            TabView {
+                // 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) {
@@ -584,7 +657,7 @@
                 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")
@@ -594,7 +667,7 @@
                                 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)
@@ -602,7 +675,7 @@
                     }
                     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")
@@ -610,7 +683,7 @@
                                 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)
@@ -618,7 +691,7 @@
                     }
                     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
@@ -626,7 +699,7 @@
                                 }
                             }
                             .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)
@@ -634,7 +707,7 @@
                     }
                     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
@@ -642,7 +715,7 @@
                                 }
                             }
                             .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)
@@ -650,10 +723,11 @@
                     }
                 }
             }
+            .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,
@@ -665,7 +739,7 @@
             .tabItem { Label(tr("settings.tags"), systemImage: "tag.fill") }
             .onAppear { scanApps() }
 
-            // Tab 3: Data
+            // Tab 4: Data
             Form {
                 Section {
                     HStack(spacing: 12) {
@@ -684,7 +758,7 @@
             .tabItem { Label(tr("settings.data"), systemImage: "externaldrive.fill") }
             .padding()
 
-            // Tab 4: About
+            // Tab 5: About
             Form {
                 Section {
                     HStack {
@@ -720,7 +794,40 @@
             }
             .tabItem { Label(tr("settings.about"), systemImage: "info.circle") }
             .padding()
+            }
+            .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: 660, maxWidth: 660, minHeight: 380)
+        .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
+            }
+        }
     }
 }
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index b8833eb..c3eb159 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -209,6 +209,9 @@
     @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"
@@ -268,6 +271,26 @@
                             .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)
             }
         }
@@ -421,6 +444,7 @@
                 flatGrid
             }
         }
+        .id(layoutRefreshID)
     }
 
     private var flatGrid: some View {
@@ -494,10 +518,12 @@
 
     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 {
@@ -521,7 +547,7 @@
                 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
@@ -614,7 +640,7 @@
     }
 
     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
@@ -718,7 +744,7 @@
 
     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))
     }
 
@@ -830,7 +856,7 @@
     }
 
     private func iconCellHeight() -> CGFloat {
-        iconSize + (hideAppNames ? 16 : 42)
+        AppGridItem.stableHeight(iconSize: iconSize)
     }
 
     private func appRows(_ apps: [AppInfo], columns: Int, fixedRows: Int) -> [[AppInfo]] {
@@ -889,7 +915,7 @@
                 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)
@@ -974,7 +1000,15 @@
             .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) }
@@ -1002,11 +1036,10 @@
     }
 
     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") }
@@ -1019,7 +1052,7 @@
     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) {
@@ -1039,6 +1072,33 @@
             .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
@@ -1130,7 +1190,8 @@
             color: tagColors[targetTag] ?? 0,
             copy: copy
         )
-        refreshApps()
+        showDropRefresh()
+        refreshApps(forceLayoutRefresh: true)
     }
 
     private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool {
@@ -1154,6 +1215,25 @@
         }
     }
 
+    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 {
@@ -1165,7 +1245,7 @@
         }
     }
 
-    func refreshApps() {
+    func refreshApps(forceLayoutRefresh: Bool = false) {
         DispatchQueue.global(qos: .userInitiated).async {
             var apps = AppIndexer.scan()
             let store = TagDatabase.load()
@@ -1176,6 +1256,12 @@
                 allApps = apps
                 tagColors = colors
                 draggedTagNames = order
+                if forceLayoutRefresh {
+                    withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) {
+                        layoutRefreshID = UUID()
+                    }
+                    finishDropRefreshAfterMinimumDuration()
+                }
             }
         }
     }
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index 84d03d5..6147cac 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -283,6 +283,20 @@
         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 {
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index cb42184..6755fc9 100644
--- a/Apptag/Info.plist
+++ b/Apptag/Info.plist
@@ -19,9 +19,9 @@
 	<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>
diff --git a/Apptag/L10n.swift b/Apptag/L10n.swift
index 9a80d8a..d2d3612 100644
--- a/Apptag/L10n.swift
+++ b/Apptag/L10n.swift
@@ -2,6 +2,10 @@
 
 // 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"
@@ -25,8 +29,11 @@
     ]
 
     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.
diff --git a/Apptag/Localization/en.json b/Apptag/Localization/en.json
index b334e73..bf25473 100644
--- a/Apptag/Localization/en.json
+++ b/Apptag/Localization/en.json
@@ -4,6 +4,9 @@
   "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",
@@ -25,9 +28,9 @@
   "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",
diff --git a/Apptag/Localization/es.json b/Apptag/Localization/es.json
index e32c942..0ce4851 100644
--- a/Apptag/Localization/es.json
+++ b/Apptag/Localization/es.json
@@ -4,6 +4,9 @@
   "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",
@@ -25,9 +28,9 @@
   "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",
diff --git a/Apptag/Localization/fr.json b/Apptag/Localization/fr.json
index 93a16fd..f0a757c 100644
--- a/Apptag/Localization/fr.json
+++ b/Apptag/Localization/fr.json
@@ -4,6 +4,9 @@
   "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",
@@ -25,9 +28,9 @@
   "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",
diff --git a/Apptag/Localization/it.json b/Apptag/Localization/it.json
index 8c1b403..38ac781 100644
--- a/Apptag/Localization/it.json
+++ b/Apptag/Localization/it.json
@@ -4,6 +4,9 @@
   "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",
@@ -25,9 +28,9 @@
   "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",
diff --git a/Apptag/Localization/ja.json b/Apptag/Localization/ja.json
index 8074fcb..8f024d4 100644
--- a/Apptag/Localization/ja.json
+++ b/Apptag/Localization/ja.json
@@ -4,6 +4,9 @@
   "menu.quit": "終了",
   "menu.language": "言語",
   "menu.version": "バージョン",
+  "settings.language": "言語",
+  "settings.languagePicker": "アプリの言語:",
+  "settings.languageDesc": "メニューと設定で使う言語を選択します。",
   "settings.general": "一般",
   "settings.tags": "タグ",
   "settings.data": "データ",
@@ -25,9 +28,9 @@
   "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": "タグを編集",
diff --git a/Apptag/Localization/ko.json b/Apptag/Localization/ko.json
index 230ca61..ce0a436 100644
--- a/Apptag/Localization/ko.json
+++ b/Apptag/Localization/ko.json
@@ -4,6 +4,9 @@
   "menu.quit": "종료",
   "menu.language": "언어",
   "menu.version": "버전",
+  "settings.language": "언어",
+  "settings.languagePicker": "앱 언어:",
+  "settings.languageDesc": "메뉴와 설정에서 사용할 언어를 선택합니다.",
   "settings.general": "일반",
   "settings.tags": "태그",
   "settings.data": "데이터",
@@ -25,9 +28,9 @@
   "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": "태그 편집",
diff --git a/Apptag/Localization/ru.json b/Apptag/Localization/ru.json
index 0843093..d259399 100644
--- a/Apptag/Localization/ru.json
+++ b/Apptag/Localization/ru.json
@@ -4,6 +4,9 @@
   "menu.quit": "Выйти",
   "menu.language": "Язык",
   "menu.version": "Версия",
+  "settings.language": "Язык",
+  "settings.languagePicker": "Язык приложения:",
+  "settings.languageDesc": "Выберите язык меню и настроек.",
   "settings.general": "Общие",
   "settings.tags": "Теги",
   "settings.data": "Данные",
@@ -25,9 +28,9 @@
   "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": "Редактировать теги",
diff --git a/Apptag/Localization/zh-Hans.json b/Apptag/Localization/zh-Hans.json
index 49e3364..516e047 100644
--- a/Apptag/Localization/zh-Hans.json
+++ b/Apptag/Localization/zh-Hans.json
@@ -4,6 +4,9 @@
   "menu.quit": "退出",
   "menu.language": "语言",
   "menu.version": "版本",
+  "settings.language": "语言",
+  "settings.languagePicker": "应用语言:",
+  "settings.languageDesc": "选择菜单和设置面板使用的语言。",
   "settings.general": "通用",
   "settings.tags": "标签",
   "settings.data": "数据",
@@ -25,9 +28,9 @@
   "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": "编辑标签",
diff --git a/Apptag/Localization/zh-Hant.json b/Apptag/Localization/zh-Hant.json
index 55e5aa7..d0a6bd7 100644
--- a/Apptag/Localization/zh-Hant.json
+++ b/Apptag/Localization/zh-Hant.json
@@ -4,6 +4,9 @@
   "menu.quit": "退出",
   "menu.language": "語言",
   "menu.version": "版本",
+  "settings.language": "語言",
+  "settings.languagePicker": "應用程式語言:",
+  "settings.languageDesc": "選擇選單和設定面板使用的語言。",
   "settings.general": "一般",
   "settings.tags": "標籤",
   "settings.data": "資料",
@@ -25,9 +28,9 @@
   "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": "編輯標籤",
diff --git a/Apptag/TagGroupView.swift b/Apptag/TagGroupView.swift
index c293236..249db2d 100644
--- a/Apptag/TagGroupView.swift
+++ b/Apptag/TagGroupView.swift
@@ -15,7 +15,8 @@
 
     /// 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

--
Gitblit v1.9.3