diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png new file mode 100644 index 00000000..7a4090cb Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128_dark.png b/Assets.xcassets/AppIcon.appiconset/128_dark.png new file mode 100644 index 00000000..c5bf91f7 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/128_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png new file mode 100644 index 00000000..3b0996a9 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16_dark.png b/Assets.xcassets/AppIcon.appiconset/16_dark.png new file mode 100644 index 00000000..5c08817d Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/16_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png new file mode 100644 index 00000000..1ae50d6b Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256_dark.png b/Assets.xcassets/AppIcon.appiconset/256_dark.png new file mode 100644 index 00000000..7a4090cb Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/256_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png new file mode 100644 index 00000000..a952979d Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32_dark.png b/Assets.xcassets/AppIcon.appiconset/32_dark.png new file mode 100644 index 00000000..3b0996a9 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/32_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png new file mode 100644 index 00000000..08e06a8f Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512_dark.png b/Assets.xcassets/AppIcon.appiconset/512_dark.png new file mode 100644 index 00000000..1ae50d6b Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/512_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json index 93a6772e..b63ce430 100644 --- a/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,188 @@ { - "images" : [ + "images": [ { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "filename": "16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "16@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "16_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename": "16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "32@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "16@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename": "32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "32_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename": "32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "32@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename": "128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" }, { - "filename" : "512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "128_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "128@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "256_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "256@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "512_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "512@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/Assets.xcassets/AppIconDark.imageset/AppIconDark.png b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png new file mode 100644 index 00000000..08e06a8f Binary files /dev/null and b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png differ diff --git a/Assets.xcassets/AppIconDark.imageset/Contents.json b/Assets.xcassets/AppIconDark.imageset/Contents.json new file mode 100644 index 00000000..ef554911 --- /dev/null +++ b/Assets.xcassets/AppIconDark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AppIconDark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/AppIconLight.imageset/AppIconLight.png b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png new file mode 100644 index 00000000..5a099e36 Binary files /dev/null and b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png differ diff --git a/Assets.xcassets/AppIconLight.imageset/Contents.json b/Assets.xcassets/AppIconLight.imageset/Contents.json new file mode 100644 index 00000000..c2e50ab0 --- /dev/null +++ b/Assets.xcassets/AppIconLight.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AppIconLight.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9116527e..a8718fb8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6249,8 +6249,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func ensureApplicationIcon() { - if let icon = NSImage(named: NSImage.applicationIconName) { - NSApplication.shared.applicationIconImage = icon + let mode = AppIconSettings.resolvedMode() + if mode == .automatic { + // Let the asset catalog handle appearance-based icon selection. + if let icon = NSImage(named: NSImage.applicationIconName) { + NSApplication.shared.applicationIconImage = icon + } + } else { + AppIconSettings.applyIcon(mode) } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 443cf148..41b8f160 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2601,6 +2601,60 @@ enum AppearanceSettings { } } +enum AppIconMode: String, CaseIterable, Identifiable { + case automatic + case light + case dark + + var id: String { rawValue } + + var displayName: String { + switch self { + case .automatic: return "Automatic" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var imageName: String? { + switch self { + case .automatic: return nil + case .light: return "AppIconLight" + case .dark: return "AppIconDark" + } + } +} + +enum AppIconSettings { + static let modeKey = "appIconMode" + static let defaultMode: AppIconMode = .automatic + + static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode { + guard let raw = defaults.string(forKey: modeKey), + let mode = AppIconMode(rawValue: raw) else { + return defaultMode + } + return mode + } + + static func applyIcon(_ mode: AppIconMode) { + switch mode { + case .automatic: + // Let the asset catalog handle appearance-based icon selection (macOS 15+). + // Reset to the default bundle icon. + NSApplication.shared.applicationIconImage = nil + case .light: + if let icon = NSImage(named: "AppIconLight") { + NSApplication.shared.applicationIconImage = icon + } + case .dark: + if let icon = NSImage(named: "AppIconDark") { + NSApplication.shared.applicationIconImage = icon + } + } + } +} + enum QuitWarningSettings { static let warnBeforeQuitKey = "warnBeforeQuitShortcut" static let defaultWarnBeforeQuit = true @@ -2661,6 +2715,7 @@ struct SettingsView: View { private let pickerColumnWidth: CGFloat = 196 @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue + @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -2834,6 +2889,16 @@ struct SettingsView: View { SettingsCardDivider() + AppIconPickerRow( + selectedMode: appIconMode, + onSelect: { mode in + appIconMode = mode.rawValue + AppIconSettings.applyIcon(mode) + } + ) + + SettingsCardDivider() + SettingsCardRow( "New Workspace Placement", subtitle: selectedWorkspacePlacement.description, @@ -3526,6 +3591,8 @@ struct SettingsView: View { private func resetAllSettings() { appearanceMode = AppearanceSettings.defaultMode.rawValue + appIconMode = AppIconSettings.defaultMode.rawValue + AppIconSettings.applyIcon(.automatic) socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry @@ -3740,6 +3807,79 @@ private struct SettingsCardNote: View { } } +private struct AppIconPickerRow: View { + let selectedMode: String + let onSelect: (AppIconMode) -> Void + + private let iconSize: CGFloat = 48 + private let autoIconSize: CGFloat = 36 + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("App Icon") + .font(.system(size: 13, weight: .medium)) + + HStack(spacing: 12) { + ForEach(AppIconMode.allCases) { mode in + let isSelected = selectedMode == mode.rawValue + Button { + onSelect(mode) + } label: { + VStack(spacing: 6) { + Group { + if mode == .automatic { + // Show both icons overlapping + ZStack { + Image("AppIconLight") + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: -10) + Image("AppIconDark") + .resizable() + .interpolation(.high) + .frame(width: autoIconSize, height: autoIconSize) + .clipShape(RoundedRectangle(cornerRadius: autoIconSize * 0.22, style: .continuous)) + .offset(x: 10) + } + .frame(width: iconSize, height: iconSize) + } else { + Image(mode.imageName ?? "AppIconLight") + .resizable() + .interpolation(.high) + .frame(width: iconSize, height: iconSize) + .clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous)) + } + } + + Text(mode.displayName) + .font(.system(size: 11)) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(0.12) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private struct ShortcutSettingRow: View { let action: KeyboardShortcutSettings.Action @State private var shortcut: StoredShortcut diff --git a/design/cmux-icon-chevron.png b/design/cmux-icon-chevron.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/design/cmux-icon-chevron.png differ diff --git a/scripts/generate_dark_icon.py b/scripts/generate_dark_icon.py new file mode 100755 index 00000000..b5ea13fe --- /dev/null +++ b/scripts/generate_dark_icon.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Generate dark mode app icon variants. + +Composites the Figma chevron layer (on transparent background) over a dark +squircle background derived from the light icon's alpha channel. This +preserves the exact chevron colors and glow without any halo artifacts. + +Requires the Figma export at: design/cmux-icon-chevron.png +Falls back to mathematical recomposition if the Figma layer is missing. +""" +import json +import os +import sys + +from PIL import Image, ImageFilter + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Apple systemBackground dark +DARK_BG = (28, 28, 30) + +# Figma chevron layer (exported from Figma at native resolution) +FIGMA_CHEVRON = os.path.join(REPO, "design", "cmux-icon-chevron.png") + +# The Figma export is ~25% larger than the repo icon. Scale and offset +# computed by matching the solid chevron (sat>0.5) bounding box center +# between the repo light icon and the scaled Figma chevron layer. +FIGMA_SCALE = 0.7996 +FIGMA_OFFSET = (290, 187) + +SIZES = [ + ("16.png", 16), + ("16@2x.png", 32), + ("32.png", 32), + ("32@2x.png", 64), + ("128.png", 128), + ("128@2x.png", 256), + ("256.png", 256), + ("256@2x.png", 512), + ("512.png", 512), + ("512@2x.png", 1024), +] + + +def make_dark_from_figma(light_1024: Image.Image, chevron: Image.Image) -> Image.Image: + """Create dark icon by compositing Figma chevron over dark background. + + Uses the light icon's alpha channel for the squircle shape mask, + fills it with the dark background color, then composites the + chevron layer on top at the precomputed offset. + """ + size = 1024 + light = light_1024.convert("RGBA") + if light.size != (size, size): + light = light.resize((size, size), Image.LANCZOS) + + # Create dark background with the squircle shape from the light icon + dark_bg = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + light_px = light.load() + dark_px = dark_bg.load() + for y in range(size): + for x in range(size): + _, _, _, a = light_px[x, y] + if a > 0: + dark_px[x, y] = (DARK_BG[0], DARK_BG[1], DARK_BG[2], a) + + # Scale chevron + chev = chevron.convert("RGBA") + cw, ch = chev.size + scaled_w = int(cw * FIGMA_SCALE) + scaled_h = int(ch * FIGMA_SCALE) + chev = chev.resize((scaled_w, scaled_h), Image.LANCZOS) + ox, oy = FIGMA_OFFSET + + # Build enhanced glow: brighten the chevron, blur at two radii + glow_src = chev.copy() + glow_px = glow_src.load() + for y in range(scaled_h): + for x in range(scaled_w): + r, g, b, a = glow_px[x, y] + if a > 0: + glow_px[x, y] = ( + min(255, int(r * 1.2)), + min(255, int(g * 1.2)), + min(255, int(b * 1.2)), + min(255, int(a * 1.1)), + ) + + glow_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + glow_canvas.paste(glow_src, (ox, oy), glow_src) + + # Wide soft glow + tighter glow + glow_wide = glow_canvas.filter(ImageFilter.GaussianBlur(radius=25)) + glow_tight = glow_canvas.filter(ImageFilter.GaussianBlur(radius=12)) + + # Reduce glow opacity + for glow, factor in [(glow_wide, 0.45), (glow_tight, 0.35)]: + gpx = glow.load() + for y in range(size): + for x in range(size): + r, g, b, a = gpx[x, y] + gpx[x, y] = (r, g, b, int(a * factor)) + + # Composite: dark bg -> wide glow -> tight glow -> sharp chevron + result = Image.alpha_composite(dark_bg, glow_wide) + result = Image.alpha_composite(result, glow_tight) + chev_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + chev_canvas.paste(chev, (ox, oy), chev) + result = Image.alpha_composite(result, chev_canvas) + + return result + + +def make_dark_fallback(img: Image.Image) -> Image.Image: + """Fallback: mathematical recomposition when Figma layer is unavailable.""" + img = img.convert("RGBA") + w, h = img.size + pixels = img.load() + + for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if a == 0: + continue + max_dev = max(255 - r, 255 - g, 255 - b) + fg_alpha = min(max_dev / 60.0, 1.0) + bg_factor = 1.0 - fg_alpha + nr = max(0, int(r - bg_factor * (255 - DARK_BG[0]))) + ng = max(0, int(g - bg_factor * (255 - DARK_BG[1]))) + nb = max(0, int(b - bg_factor * (255 - DARK_BG[2]))) + pixels[x, y] = (nr, ng, nb, a) + + return img + + +def update_contents_json(icon_dir: str) -> None: + """Add dark appearance entries to Contents.json.""" + contents_path = os.path.join(icon_dir, "Contents.json") + with open(contents_path) as f: + contents = json.load(f) + + # Remove any existing dark entries to avoid duplicates + images = [ + img for img in contents["images"] + if not any( + ap.get("value") == "dark" + for ap in img.get("appearances", []) + ) + ] + + dark_images = [] + for img in images: + filename = img.get("filename", "") + if not filename: + continue + base, ext = os.path.splitext(filename) + dark_entry = { + "appearances": [ + {"appearance": "luminosity", "value": "dark"} + ], + "filename": f"{base}_dark{ext}", + "idiom": img["idiom"], + "scale": img["scale"], + "size": img["size"], + } + dark_images.append(dark_entry) + + # Interleave: light, dark, light, dark, ... + merged = [] + for i, img in enumerate(images): + merged.append(img) + if i < len(dark_images): + merged.append(dark_images[i]) + + contents["images"] = merged + with open(contents_path, "w") as f: + json.dump(contents, f, indent=2) + f.write("\n") + print(f" Updated {contents_path}") + + +def generate_dark_icons(icon_set: str) -> None: + """Generate dark variants for an icon set.""" + src_dir = os.path.join(REPO, "Assets.xcassets", f"{icon_set}.appiconset") + if not os.path.isdir(src_dir): + print(f"SKIP {icon_set} (not found)") + return + + use_figma = os.path.exists(FIGMA_CHEVRON) + if use_figma: + print(f"\n{icon_set} (using Figma chevron layer):") + chevron = Image.open(FIGMA_CHEVRON) + light_1024_path = os.path.join(src_dir, "512@2x.png") + light_1024 = Image.open(light_1024_path) + dark_1024 = make_dark_from_figma(light_1024, chevron) + else: + print(f"\n{icon_set} (fallback: mathematical recomposition):") + dark_1024 = None + + for filename, pixel_size in SIZES: + src_path = os.path.join(src_dir, filename) + if not os.path.exists(src_path): + print(f" SKIP {filename} (not found)") + continue + + base, ext = os.path.splitext(filename) + dst_path = os.path.join(src_dir, f"{base}_dark{ext}") + + if use_figma: + # Downscale the 1024x1024 Figma composite + dark_img = dark_1024.resize((pixel_size, pixel_size), Image.LANCZOS) + else: + img = Image.open(src_path) + if img.size != (pixel_size, pixel_size): + img = img.resize((pixel_size, pixel_size), Image.LANCZOS) + dark_img = make_dark_fallback(img) + + dark_img.save(dst_path, "PNG") + print(f" {base}_dark{ext} ({pixel_size}x{pixel_size})") + + update_contents_json(src_dir) + + +def main(): + generate_dark_icons("AppIcon") + + +if __name__ == "__main__": + main()