From c653f7e733dd323aa17866e776fad6e5ba99563f Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:44:03 +0330 Subject: [PATCH] feat: add app theming engine with Look and Feel settings --- Sources/Firelink/AppSettings.swift | 8 +++ Sources/Firelink/ContentView.swift | 5 ++ Sources/Firelink/FirelinkApp.swift | 4 ++ Sources/Firelink/SettingsView.swift | 30 +++++++++ Sources/Firelink/Theme.swift | 96 +++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 Sources/Firelink/Theme.swift diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 26a6c44..c1e0d17 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -65,6 +65,10 @@ struct DownloadProxyConfiguration: Equatable, Sendable { @MainActor final class AppSettings: ObservableObject { + @Published var appTheme: AppTheme = .system { + didSet { save() } + } + @Published var perServerConnections: Int { didSet { let clamped = min(max(perServerConnections, 1), 16) @@ -129,6 +133,7 @@ final class AppSettings: ObservableObject { if let data = defaults.data(forKey: storageKey), let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) { + appTheme = stored.appTheme ?? .system perServerConnections = min(max(stored.perServerConnections, 1), 16) maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12) globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760) @@ -137,6 +142,7 @@ final class AppSettings: ObservableObject { siteLogins = stored.siteLogins downloadDirectories = Self.decodeDirectories(stored.downloadDirectories) } else { + appTheme = .system perServerConnections = 16 maxConcurrentDownloads = 3 globalSpeedLimitKiBPerSecond = 0 @@ -218,6 +224,7 @@ final class AppSettings: ObservableObject { private func save() { let stored = StoredSettings( + appTheme: appTheme, perServerConnections: perServerConnections, maxConcurrentDownloads: maxConcurrentDownloads, globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond, @@ -277,6 +284,7 @@ final class AppSettings: ObservableObject { } private struct StoredSettings: Codable { + var appTheme: AppTheme? var perServerConnections: Int var maxConcurrentDownloads: Int? var globalSpeedLimitKiBPerSecond: Int? diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index e3a5a8d..6c3376f 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject private var controller: DownloadController + @EnvironmentObject private var settings: AppSettings @Environment(\.openWindow) private var openWindow @State private var selection: Set = [] @State private var sidebarSelection: SidebarSelection = .downloads(.all) @@ -12,8 +13,12 @@ struct ContentView: View { NavigationSplitView { SidebarView(selection: $sidebarSelection) .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260) + .scrollContentBackground(.hidden) + .background(settings.appTheme.theme.secondaryBackground ?? Color(NSColor.windowBackgroundColor)) } detail: { detailView + .scrollContentBackground(.hidden) + .background(settings.appTheme.theme.background ?? Color(NSColor.windowBackgroundColor)) } } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index a457196..f326269 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -20,6 +20,7 @@ struct FirelinkApp: App { .environmentObject(controller) .environmentObject(settings) .environmentObject(schedulerController) + .modifier(AppThemeModifier(theme: settings.appTheme)) .frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760) } .windowStyle(.titleBar) @@ -28,6 +29,7 @@ struct FirelinkApp: App { AddDownloadsView() .environmentObject(controller) .environmentObject(settings) + .modifier(AppThemeModifier(theme: settings.appTheme)) } .windowResizability(.contentSize) @@ -36,8 +38,10 @@ struct FirelinkApp: App { DownloadPropertiesWindow(downloadID: downloadID) .environmentObject(controller) .environmentObject(settings) + .modifier(AppThemeModifier(theme: settings.appTheme)) } else { ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle") + .modifier(AppThemeModifier(theme: settings.appTheme)) } } .windowResizability(.contentSize) diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index fb9b857..af25030 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -4,6 +4,7 @@ import SwiftUI private enum SettingsSection: String, CaseIterable, Hashable { case downloads = "Downloads" case locations = "Locations" + case lookAndFeel = "Look and feel" case network = "Network" case siteLogins = "Site Logins" case power = "Power" @@ -13,6 +14,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { static let orderedCases: [SettingsSection] = [ .downloads, .locations, + .lookAndFeel, .network, .siteLogins, .power, @@ -23,6 +25,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { var symbolName: String { switch self { case .downloads: "arrow.down.circle" + case .lookAndFeel: "paintpalette" case .network: "network" case .locations: "folder" case .siteLogins: "key.fill" @@ -43,6 +46,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { } struct SettingsView: View { + @EnvironmentObject private var settings: AppSettings @State private var selection: SettingsSection = .downloads var body: some View { @@ -69,6 +73,7 @@ struct SettingsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(settings.appTheme.theme.background ?? Color(NSColor.windowBackgroundColor)) } private var settingsSidebar: some View { @@ -96,6 +101,8 @@ struct SettingsView: View { switch selection { case .downloads: DownloadSettingsPane() + case .lookAndFeel: + LookAndFeelSettingsPane() case .network: NetworkSettingsPane() case .locations: @@ -112,6 +119,29 @@ struct SettingsView: View { } } +private struct LookAndFeelSettingsPane: View { + @EnvironmentObject private var settings: AppSettings + + var body: some View { + Form { + Section("App Theme") { + Picker("Theme", selection: $settings.appTheme) { + ForEach(AppTheme.allCases) { theme in + Text(theme.rawValue) + .tag(theme) + } + } + .pickerStyle(.radioGroup) + + Text("Select a color palette for the app's user interface.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } +} + private struct AboutSettingsPane: View { @StateObject private var updateChecker = AppUpdateChecker() @State private var availableUpdate: AvailableUpdate? diff --git a/Sources/Firelink/Theme.swift b/Sources/Firelink/Theme.swift new file mode 100644 index 0000000..4813d6e --- /dev/null +++ b/Sources/Firelink/Theme.swift @@ -0,0 +1,96 @@ +import SwiftUI + +enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable { + case system = "System Default" + case light = "Light" + case dark = "Modern Dark" + case dracula = "Dracula" + case nord = "Nord" + + var id: String { rawValue } + + var theme: Theme { + switch self { + case .system: + return Theme.system + case .light: + return Theme.modernLight + case .dark: + return Theme.modernDark + case .dracula: + return Theme.dracula + case .nord: + return Theme.nord + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark, .dracula, .nord: return .dark + } + } +} + +struct Theme: Equatable, Sendable { + var background: Color? + var secondaryBackground: Color? + var text: Color? + var secondaryText: Color? + var accent: Color? + + static let system = Theme( + background: nil, + secondaryBackground: nil, + text: nil, + secondaryText: nil, + accent: nil + ) + + static let modernLight = Theme( + background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)), + secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)), + text: Color.primary, + secondaryText: Color.secondary, + accent: Color.accentColor + ) + + static let modernDark = Theme( + background: Color(nsColor: NSColor(calibratedRed: 0.08, green: 0.08, blue: 0.10, alpha: 1.0)), + secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.12, green: 0.12, blue: 0.14, alpha: 1.0)), + text: Color(nsColor: NSColor(calibratedRed: 0.9, green: 0.9, blue: 0.95, alpha: 1.0)), + secondaryText: Color(nsColor: NSColor(calibratedRed: 0.6, green: 0.6, blue: 0.65, alpha: 1.0)), + accent: Color(nsColor: NSColor(calibratedRed: 0.35, green: 0.55, blue: 0.95, alpha: 1.0)) + ) + + static let dracula = Theme( + background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)), + secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)), + text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)), + secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)), + accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink + ) + + static let nord = Theme( + background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0 + secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1 + text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4 + secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3 + accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8 + ) +} + +struct AppThemeModifier: ViewModifier { + let theme: AppTheme + + func body(content: Content) -> some View { + content + .preferredColorScheme(theme.colorScheme) + .tint(theme.theme.accent) + // Apply custom background only if it's not system. + // Using .background() on the root view might not cover the whole window. + // In macOS, it's often better to just set the color scheme and tint, + // and let specific views (like Sidebar, Content) use the theme colors explicitly. + } +}