feat: add app theming engine with Look and Feel settings

This commit is contained in:
nimbold
2026-06-03 04:44:03 +03:30
parent 992d448e60
commit c653f7e733
5 changed files with 143 additions and 0 deletions
+8
View File
@@ -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?
+5
View File
@@ -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<DownloadItem.ID> = []
@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))
}
}
+4
View File
@@ -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)
+30
View File
@@ -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?
+96
View File
@@ -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.
}
}