Skip to content

Commit cf73fbb

Browse files
authored
Merge pull request #168 from benjaminbelaga/feature/terminal-theme-import
feat: import terminal color schemes from .itermcolors files
2 parents 2c5c2ef + ade4321 commit cf73fbb

15 files changed

Lines changed: 1067 additions & 37 deletions

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ A macOS menu bar application that monitors AI coding assistant usage quotas. Kee
2626
- **Multi-Provider Support** - Monitor Claude, Codex, Gemini, GitHub Copilot, Antigravity, Z.ai, Kimi, Kiro, and Amp quotas in one place
2727
- **Provider Enable/Disable** - Toggle individual providers on/off from Settings to customize your monitoring
2828
- **Real-Time Quota Tracking** - View Session, Weekly, and Model-specific usage percentages
29-
- **Multiple Themes** - Light, Dark, CLI (terminal-style), and festive Christmas themes
29+
- **Multiple Themes** - Light, Dark, CLI, Christmas, and [imported terminal themes](#import-terminal-theme) (.itermcolors)
3030
- **Automatic Adaptation** - System theme follows your macOS appearance; Christmas auto-enables during the holiday season
3131
- **Visual Status Indicators** - Color-coded progress bars (green/yellow/red) show quota health
3232
- **System Notifications** - Get alerted when quota status changes to warning or critical
@@ -182,6 +182,18 @@ ClaudeBar uses a **layered architecture** with `QuotaMonitor` as the single sour
182182
- **Chicago School TDD** - Tests verify state changes, not method calls
183183
- **No ViewModel/AppState** - Views consume domain directly
184184

185+
## Import Terminal Theme
186+
187+
Match ClaudeBar's appearance to your terminal. Import any `.itermcolors` file:
188+
189+
1. Open **Settings** (gear icon)
190+
2. Click **Import .itermcolors**
191+
3. Select your file (export from iTerm2: Preferences > Profiles > Colors > Color Presets > Export)
192+
193+
450+ pre-made schemes available at [iTerm2-Color-Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/schemes).
194+
195+
Imported themes are saved in `~/.claudebar/themes/` and persist across restarts.
196+
185197
## Contributing
186198

187199
### Adding a New AI Provider
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import SwiftUI
2+
import Infrastructure
3+
import UniformTypeIdentifiers
4+
5+
/// Import button for terminal color scheme files.
6+
///
7+
/// Currently supports `.itermcolors` (iTerm2 export format, compatible with 450+ schemes
8+
/// from [iTerm2-Color-Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes)).
9+
/// The architecture supports adding more formats via new parsers.
10+
struct ThemeImportButton: View {
11+
@Environment(\.appTheme) private var theme
12+
@State private var isImporting = false
13+
@State private var importError: String?
14+
@State private var importedThemeName: String?
15+
16+
var body: some View {
17+
VStack(spacing: 6) {
18+
Button {
19+
isImporting = true
20+
} label: {
21+
HStack(spacing: 6) {
22+
Image(systemName: "square.and.arrow.down")
23+
.font(.system(size: 10, weight: .semibold))
24+
Text("Import Theme")
25+
.font(theme.font(size: 11, weight: .medium))
26+
}
27+
.foregroundStyle(theme.accentPrimary)
28+
.padding(.horizontal, 12)
29+
.padding(.vertical, 6)
30+
.background(
31+
RoundedRectangle(cornerRadius: 8)
32+
.fill(theme.accentPrimary.opacity(0.1))
33+
.overlay(
34+
RoundedRectangle(cornerRadius: 8)
35+
.stroke(theme.accentPrimary.opacity(0.3), lineWidth: 1)
36+
)
37+
)
38+
}
39+
.buttonStyle(.plain)
40+
.fileImporter(
41+
isPresented: $isImporting,
42+
allowedContentTypes: [UTType(filenameExtension: "itermcolors") ?? .propertyList],
43+
allowsMultipleSelection: false
44+
) { result in
45+
handleImport(result)
46+
}
47+
48+
if let error = importError {
49+
Text(error)
50+
.font(theme.font(size: 9))
51+
.foregroundStyle(theme.statusCritical)
52+
}
53+
54+
if let name = importedThemeName {
55+
Text("Imported: \(name)")
56+
.font(theme.font(size: 9))
57+
.foregroundStyle(theme.statusHealthy)
58+
}
59+
}
60+
}
61+
62+
@MainActor private func handleImport(_ result: Result<[URL], Error>) {
63+
importError = nil
64+
importedThemeName = nil
65+
66+
switch result {
67+
case .success(let urls):
68+
guard let url = urls.first else { return }
69+
guard url.startAccessingSecurityScopedResource() else {
70+
importError = "Cannot access file"
71+
return
72+
}
73+
defer { url.stopAccessingSecurityScopedResource() }
74+
75+
do {
76+
let importedTheme = try ThemeRegistry.shared.importItermcolors(from: url)
77+
importedThemeName = importedTheme.displayName
78+
} catch {
79+
importError = "Import failed: \(error.localizedDescription)"
80+
}
81+
82+
case .failure(let error):
83+
importError = error.localizedDescription
84+
}
85+
}
86+
}

Sources/App/Theme/AppThemeProvider.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ public protocol AppThemeProvider {
8383
/// Font design (default, rounded, monospaced, serif)
8484
var fontDesign: Font.Design { get }
8585

86+
/// Custom font family name (e.g., "IBM Plex Mono"). When set, views use this instead of system font.
87+
var customFontName: String? { get }
88+
8689
// MARK: - Status Colors
8790

8891
/// Healthy status color (>50% remaining)
@@ -145,6 +148,9 @@ public extension AppThemeProvider {
145148
/// Default status bar icon is nil (uses status-based icons)
146149
var statusBarIconName: String? { nil }
147150

151+
/// Default custom font is nil (uses system font with fontDesign)
152+
var customFontName: String? { nil }
153+
148154
/// Default overlay is nil
149155
@MainActor var overlayView: AnyView? { nil }
150156

@@ -169,6 +175,25 @@ public extension AppThemeProvider {
169175
}
170176
}
171177

178+
// MARK: - Theme Font Helper
179+
180+
public extension AppThemeProvider {
181+
/// Returns the appropriate font for this theme — custom font if set, otherwise system font with fontDesign.
182+
func font(size: CGFloat, weight: Font.Weight = .regular) -> Font {
183+
if let name = customFontName {
184+
let suffix: String = switch weight {
185+
case .bold, .heavy, .black: "-Bold"
186+
case .semibold: "-SemiBold"
187+
case .medium: "-Medium"
188+
case .light, .ultraLight, .thin: "-Light"
189+
default: "-Regular"
190+
}
191+
return .custom("\(name)\(suffix)", size: size)
192+
}
193+
return .system(size: size, weight: weight, design: fontDesign)
194+
}
195+
}
196+
172197
// MARK: - Base Theme
173198

174199
/// Base theme providing common defaults that other themes can inherit from.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import SwiftUI
2+
import Infrastructure
3+
import Domain
4+
5+
// MARK: - RGBColor → SwiftUI Color
6+
7+
public extension TerminalColorScheme.RGBColor {
8+
/// Convert to SwiftUI `Color`.
9+
var color: Color {
10+
Color(red: red, green: green, blue: blue).opacity(alpha)
11+
}
12+
}
13+
14+
// MARK: - ImportedTerminalTheme
15+
16+
/// An ``AppThemeProvider`` implementation generated from an imported terminal color scheme.
17+
///
18+
/// Created by ``TerminalThemeGenerator`` and backed by pre-computed ``GeneratedThemeProperties``.
19+
/// All SwiftUI colors are derived from the canonical ``TerminalColorScheme/RGBColor`` values.
20+
public struct ImportedTerminalTheme: AppThemeProvider {
21+
private let props: GeneratedThemeProperties
22+
private let scheme: TerminalColorScheme
23+
24+
/// Create a theme from generated properties and the original color scheme.
25+
public init(props: GeneratedThemeProperties, scheme: TerminalColorScheme) {
26+
self.props = props
27+
self.scheme = scheme
28+
}
29+
30+
// MARK: - Identity
31+
32+
public var id: String { props.id }
33+
public var displayName: String { props.displayName }
34+
public var icon: String { "terminal.fill" }
35+
public var subtitle: String? { "Imported" }
36+
public var statusBarIconName: String? { nil }
37+
38+
// MARK: - Color Scheme Preference
39+
40+
/// Whether this imported theme prefers a dark color scheme (derived from background luminance).
41+
public var prefersDarkColorScheme: Bool { props.isDark }
42+
43+
// MARK: - Background
44+
45+
public var backgroundGradient: LinearGradient {
46+
LinearGradient(
47+
colors: [props.background.color, props.cardBackground.color],
48+
startPoint: .topLeading,
49+
endPoint: .bottomTrailing
50+
)
51+
}
52+
53+
public var showBackgroundOrbs: Bool { false }
54+
55+
// MARK: - Cards & Glass
56+
57+
public var cardGradient: LinearGradient {
58+
LinearGradient(
59+
colors: [props.cardBackground.color, props.cardBackground.color.opacity(0.95)],
60+
startPoint: .topLeading,
61+
endPoint: .bottomTrailing
62+
)
63+
}
64+
65+
public var glassBackground: Color { props.glassBackground.color }
66+
public var glassBorder: Color { props.glassBorder.color }
67+
public var glassHighlight: Color { props.glassHighlight.color }
68+
public var cardCornerRadius: CGFloat { 10 }
69+
public var pillCornerRadius: CGFloat { 12 }
70+
71+
// MARK: - Typography
72+
73+
public var textPrimary: Color { props.textPrimary.color }
74+
public var textSecondary: Color { props.textSecondary.color }
75+
public var textTertiary: Color { props.textTertiary.color }
76+
public var fontDesign: Font.Design { .monospaced }
77+
public var customFontName: String? { nil }
78+
79+
// MARK: - Status Colors
80+
81+
public var statusHealthy: Color { props.statusHealthy.color }
82+
public var statusWarning: Color { props.statusWarning.color }
83+
public var statusCritical: Color { props.statusCritical.color }
84+
public var statusDepleted: Color { props.statusDepleted.color }
85+
86+
// MARK: - Accents
87+
88+
public var accentPrimary: Color { props.accentPrimary.color }
89+
public var accentSecondary: Color { props.accentSecondary.color }
90+
91+
public var accentGradient: LinearGradient {
92+
LinearGradient(
93+
colors: [accentPrimary, accentSecondary],
94+
startPoint: .leading,
95+
endPoint: .trailing
96+
)
97+
}
98+
99+
public var pillGradient: LinearGradient {
100+
LinearGradient(
101+
colors: [accentPrimary.opacity(0.25), accentSecondary.opacity(0.15)],
102+
startPoint: .topLeading,
103+
endPoint: .bottomTrailing
104+
)
105+
}
106+
107+
public var shareGradient: LinearGradient {
108+
LinearGradient(
109+
colors: [scheme.yellow.color, scheme.brightYellow.color],
110+
startPoint: .leading,
111+
endPoint: .trailing
112+
)
113+
}
114+
115+
// MARK: - Interactive States
116+
117+
public var hoverOverlay: Color { accentPrimary.opacity(0.1) }
118+
public var pressedOverlay: Color { accentPrimary.opacity(0.15) }
119+
120+
// MARK: - Progress Bar
121+
122+
public var progressTrack: Color { props.progressTrack.color }
123+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
import Infrastructure
3+
4+
/// Persists imported terminal color schemes as JSON files in `~/.claudebar/themes/`.
5+
///
6+
/// On load, the stored ``TerminalColorScheme`` values are re-generated into themes
7+
/// by ``TerminalThemeGenerator``, ensuring imported themes benefit from future mapping improvements.
8+
@MainActor
9+
public final class ImportedThemeStore {
10+
11+
private let themesDirectory: URL
12+
13+
/// Create a store backed by a directory.
14+
/// - Parameter directory: Override for the themes directory. Defaults to `~/.claudebar/themes/`.
15+
public init(directory: URL? = nil) {
16+
self.themesDirectory = directory ?? Self.defaultDirectory()
17+
}
18+
19+
/// Load all imported color schemes from disk, sorted by import date.
20+
public func loadAll() -> [(TerminalColorScheme, Date)] {
21+
guard let files = try? FileManager.default.contentsOfDirectory(
22+
at: themesDirectory, includingPropertiesForKeys: nil
23+
) else { return [] }
24+
25+
return files
26+
.filter { $0.pathExtension == "json" }
27+
.compactMap { url -> (TerminalColorScheme, Date)? in
28+
guard let data = try? Data(contentsOf: url),
29+
let entry = try? JSONDecoder().decode(StoredTheme.self, from: data)
30+
else { return nil }
31+
return (entry.scheme, entry.importedAt)
32+
}
33+
}
34+
35+
/// Save a color scheme to disk as JSON.
36+
public func save(_ scheme: TerminalColorScheme) throws {
37+
try FileManager.default.createDirectory(at: themesDirectory, withIntermediateDirectories: true)
38+
let entry = StoredTheme(scheme: scheme, importedAt: Date())
39+
let data = try JSONEncoder().encode(entry)
40+
let filename = Self.sanitize(scheme.name)
41+
let fileURL = themesDirectory.appendingPathComponent("\(filename).json")
42+
try data.write(to: fileURL, options: .atomic)
43+
}
44+
45+
/// Delete a stored theme by its scheme name.
46+
public func delete(name: String) throws {
47+
let filename = Self.sanitize(name)
48+
let fileURL = themesDirectory.appendingPathComponent("\(filename).json")
49+
try FileManager.default.removeItem(at: fileURL)
50+
}
51+
52+
private static func defaultDirectory() -> URL {
53+
FileManager.default.homeDirectoryForCurrentUser
54+
.appendingPathComponent(".claudebar")
55+
.appendingPathComponent("themes")
56+
}
57+
58+
private static func sanitize(_ name: String) -> String {
59+
name.lowercased()
60+
.replacingOccurrences(of: " ", with: "-")
61+
.replacingOccurrences(of: "[^a-z0-9\\-]", with: "", options: .regularExpression)
62+
}
63+
64+
private struct StoredTheme: Codable {
65+
let scheme: TerminalColorScheme
66+
let importedAt: Date
67+
}
68+
}

Sources/App/Theme/ThemeEnvironment.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ public struct AppThemeProviderModifier: ViewModifier {
5454
switch mode {
5555
case .light: return .light
5656
case .dark, .cli, .christmas: return .dark
57-
case .system, .none: return systemColorScheme
57+
case .system: return systemColorScheme
58+
case .none:
59+
// Imported theme — check dark preference
60+
if let imported = ThemeRegistry.shared.theme(for: themeModeId) as? ImportedTerminalTheme {
61+
return imported.prefersDarkColorScheme ? .dark : .light
62+
}
63+
return systemColorScheme
5864
}
5965
}
6066

0 commit comments

Comments
 (0)