diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index ae3931dd6..09c467fd5 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -440,6 +440,7 @@ B61A606129F188AB009B43F9 /* ExternalLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61A606029F188AB009B43F9 /* ExternalLink.swift */; }; B61A606929F4481A009B43F9 /* MonospacedFontPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61A606829F4481A009B43F9 /* MonospacedFontPicker.swift */; }; B61DA9DF29D929E100BF4A43 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61DA9DE29D929E100BF4A43 /* GeneralSettingsView.swift */; }; + B62423302C21EE280096668B /* ThemeModel+CRUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B624232F2C21EE280096668B /* ThemeModel+CRUD.swift */; }; B628B7932B18369800F9775A /* GitClient+Validate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B628B7922B18369800F9775A /* GitClient+Validate.swift */; }; B628B7B72B223BAD00F9775A /* FindModePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B628B7B62B223BAD00F9775A /* FindModePicker.swift */; }; B62AEDAA2A1FCBE5009A9F52 /* AreaTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62AEDA92A1FCBE5009A9F52 /* AreaTabBar.swift */; }; @@ -519,6 +520,7 @@ B6F0517929D9E3C900D72287 /* SourceControlGitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F0517829D9E3C900D72287 /* SourceControlGitView.swift */; }; B6F0517B29D9E46400D72287 /* SourceControlSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F0517A29D9E46400D72287 /* SourceControlSettingsView.swift */; }; B6F0517D29D9E4B100D72287 /* TerminalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F0517C29D9E4B100D72287 /* TerminalSettingsView.swift */; }; + B6FA3F882BF41C940023DE9C /* ThemeSettingsThemeToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA3F872BF41C940023DE9C /* ThemeSettingsThemeToken.swift */; }; B6FF04782B6C08AC002C2C78 /* DefaultThemes in Resources */ = {isa = PBXBuildFile; fileRef = B6FF04772B6C08AC002C2C78 /* DefaultThemes */; }; D7012EE827E757850001E1EF /* FindNavigatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7012EE727E757850001E1EF /* FindNavigatorView.swift */; }; D7211D4327E066CE008F2ED7 /* Localized+Ex.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7211D4227E066CE008F2ED7 /* Localized+Ex.swift */; }; @@ -689,7 +691,7 @@ 5878DA81291863F900DD95A3 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; 5878DA832918642000DD95A3 /* ParsePackagesResolved.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParsePackagesResolved.swift; sourceTree = ""; }; 5878DA862918642F00DD95A3 /* AcknowledgementsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowledgementsViewModel.swift; sourceTree = ""; }; - 5878DAA1291AE76700DD95A3 /* QuickOpenView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenView.swift; sourceTree = ""; }; + 5878DAA1291AE76700DD95A3 /* QuickOpenView.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenView.swift; sourceTree = ""; wrapsLines = 1; }; 5878DAA2291AE76700DD95A3 /* QuickOpenPreviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenPreviewView.swift; sourceTree = ""; }; 5878DAA3291AE76700DD95A3 /* QuickOpenViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenViewModel.swift; sourceTree = ""; }; 5878DAA4291AE76700DD95A3 /* QuickOpenItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenItem.swift; sourceTree = ""; }; @@ -1012,6 +1014,7 @@ B61A606029F188AB009B43F9 /* ExternalLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLink.swift; sourceTree = ""; }; B61A606829F4481A009B43F9 /* MonospacedFontPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospacedFontPicker.swift; sourceTree = ""; }; B61DA9DE29D929E100BF4A43 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + B624232F2C21EE280096668B /* ThemeModel+CRUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeModel+CRUD.swift"; sourceTree = ""; }; B628B7922B18369800F9775A /* GitClient+Validate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Validate.swift"; sourceTree = ""; }; B628B7B62B223BAD00F9775A /* FindModePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindModePicker.swift; sourceTree = ""; }; B62AEDA92A1FCBE5009A9F52 /* AreaTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaTabBar.swift; sourceTree = ""; }; @@ -1096,6 +1099,7 @@ B6F0517829D9E3C900D72287 /* SourceControlGitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlGitView.swift; sourceTree = ""; }; B6F0517A29D9E46400D72287 /* SourceControlSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlSettingsView.swift; sourceTree = ""; }; B6F0517C29D9E4B100D72287 /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = ""; }; + B6FA3F872BF41C940023DE9C /* ThemeSettingsThemeToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingsThemeToken.swift; sourceTree = ""; }; B6FF04772B6C08AC002C2C78 /* DefaultThemes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DefaultThemes; sourceTree = ""; }; D7012EE727E757850001E1EF /* FindNavigatorView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = FindNavigatorView.swift; sourceTree = ""; tabWidth = 4; }; D7211D4227E066CE008F2ED7 /* Localized+Ex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localized+Ex.swift"; sourceTree = ""; }; @@ -2285,6 +2289,7 @@ B6EA1FF729DB78DB001BF195 /* ThemeSettingThemeRow.swift */, B6EA1FFA29DB78F6001BF195 /* ThemeSettingsThemeDetails.swift */, B6EA1FFC29DB792C001BF195 /* ThemeSettingsColorPreview.swift */, + B6FA3F872BF41C940023DE9C /* ThemeSettingsThemeToken.swift */, ); path = ThemeSettings; sourceTree = ""; @@ -3100,6 +3105,7 @@ children = ( 58F2EAE0292FB2B0004A9BDE /* ThemeSettings.swift */, B6EA1FE429DA33DB001BF195 /* ThemeModel.swift */, + B624232F2C21EE280096668B /* ThemeModel+CRUD.swift */, B6EA1FE629DA341D001BF195 /* Theme.swift */, ); path = Models; @@ -3568,6 +3574,7 @@ 58798219292D92370085B254 /* SearchModeModel.swift in Sources */, 6C5C891B2A3F736500A94FE1 /* FocusedValues.swift in Sources */, 611192062B08CCF600D4459B /* SearchIndexer+Add.swift in Sources */, + B62423302C21EE280096668B /* ThemeModel+CRUD.swift in Sources */, B62AEDD72A27B3D0009A9F52 /* UtilityAreaTabViewModel.swift in Sources */, 85773E1E2A3E0A1F00C5D926 /* SettingsSearchResult.swift in Sources */, B66A4E4F29C917B8004573B4 /* WelcomeWindow.swift in Sources */, @@ -3814,6 +3821,7 @@ 58798236292E30B90085B254 /* FeedbackType.swift in Sources */, 587B9E6D29301D8F00AC7927 /* GitLabEventNote.swift in Sources */, 587B9E9129301D8F00AC7927 /* BitBucketOAuthRouter.swift in Sources */, + B6FA3F882BF41C940023DE9C /* ThemeSettingsThemeToken.swift in Sources */, B6E41C7429DD40010088F9F4 /* View+HideSidebarToggle.swift in Sources */, 611191FA2B08CC9000D4459B /* SearchIndexer.swift in Sources */, 58822532292C280D00E83CDE /* UtilityAreaViewModel.swift in Sources */, diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 6cd441d62..3f3003d58 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -46,7 +46,7 @@ struct CodeFileView: View { @EnvironmentObject private var editorManager: EditorManager - @StateObject private var themeModel: ThemeModel = .shared + @ObservedObject private var themeModel: ThemeModel = .shared private var cancellables = Set() @@ -77,7 +77,9 @@ struct CodeFileView: View { codeFile.undoManager = self.undoManager.manager } - @State private var selectedTheme = ThemeModel.shared.selectedTheme ?? ThemeModel.shared.themes.first! + private var currentTheme: Theme { + themeModel.selectedTheme ?? themeModel.themes.first! + } @State private var font: NSFont = Settings[\.textEditing].font.current @@ -107,7 +109,7 @@ struct CodeFileView: View { CodeEditSourceEditor( codeFile.content ?? NSTextStorage(), language: getLanguage(), - theme: selectedTheme.editor.editorTheme, + theme: currentTheme.editor.editorTheme, font: font, tabWidth: codeFile.defaultTabWidth ?? defaultTabWidth, indentOption: (codeFile.indentOption ?? indentOption).textViewOption(), @@ -131,17 +133,9 @@ struct CodeFileView: View { EffectView(.contentBackground) } } - .colorScheme( - selectedTheme.appearance == .dark - ? .dark - : .light - ) + .colorScheme(currentTheme.appearance == .dark ? .dark : .light) // minHeight zero fixes a bug where the app would freeze if the contents of the file are empty. .frame(minHeight: .zero, maxHeight: .infinity) - .onChange(of: themeModel.selectedTheme) { newValue in - guard let theme = newValue else { return } - self.selectedTheme = theme - } .onChange(of: settingsFont) { newFontSetting in font = newFontSetting.current } @@ -162,10 +156,12 @@ struct CodeFileView: View { } private func getBracketPairHighlight() -> BracketPairHighlight? { - let theme = ThemeModel.shared.selectedTheme ?? ThemeModel.shared.themes.first! - let color = Settings[\.textEditing].bracketHighlight.useCustomColor - ? Settings[\.textEditing].bracketHighlight.color.nsColor - : theme.editor.text.nsColor.withAlphaComponent(0.8) + let color = if Settings[\.textEditing].bracketHighlight.useCustomColor { + Settings[\.textEditing].bracketHighlight.color.nsColor + } else { + currentTheme.editor.text.nsColor.withAlphaComponent(0.8) + } + switch Settings[\.textEditing].bracketHighlight.highlightType { case .disabled: return nil diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift index cbb131999..a71492015 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift @@ -39,6 +39,12 @@ struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable { /// An URL for reference var distributionURL: String + /// If the theme is bundled with CodeEdit or not + var isBundled: Bool = false + + /// The URL for the theme file + var fileURL: URL? + /// The `unique name` of the theme var name: String @@ -66,6 +72,7 @@ struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable { license: String, metadataDescription: String, distributionURL: String, + isBundled: Bool, name: String, displayName: String, appearance: ThemeType, @@ -75,6 +82,7 @@ struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable { self.license = license self.metadataDescription = metadataDescription self.distributionURL = distributionURL + self.isBundled = isBundled self.name = name self.displayName = displayName self.appearance = appearance diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift new file mode 100644 index 000000000..74090f65b --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift @@ -0,0 +1,292 @@ +// +// ThemeModel+CRUD.swift +// CodeEdit +// +// Created by Austin Condiff on 6/18/24. +// + +import SwiftUI +import UniformTypeIdentifiers + +extension ThemeModel { + /// Loads a theme from a given url and appends it to ``themes``. + /// - Parameter url: The URL of the theme + /// - Returns: A ``Theme`` + private func load(from url: URL) throws -> Theme? { + do { + // get the data from the provided file + let json = try Data(contentsOf: url) + // decode the json into ``Theme`` + let theme = try JSONDecoder().decode(Theme.self, from: json) + return theme + } catch { + print(error) + return nil + } + } + + /// Loads all available themes from `~/Library/Application Support/CodeEdit/Themes/` + /// + /// If no themes are available, it will create a default theme and save + /// it to the location mentioned above. + /// + /// When overrides are found in `~/Library/Application Support/CodeEdit/settings.json` + /// they are applied to the loaded themes without altering the original + /// the files in `~/Library/Application Support/CodeEdit/Themes/`. + func loadThemes() throws { // swiftlint:disable:this function_body_length + if let bundledThemesURL = bundledThemesURL { + // remove all themes from memory + themes.removeAll() + + var isDir: ObjCBool = false + + // check if a themes directory exists, otherwise create one + if !filemanager.fileExists(atPath: themesURL.path, isDirectory: &isDir) { + try filemanager.createDirectory(at: themesURL, withIntermediateDirectories: true) + } + + // get all URLs in users themes folder that end with `.cetheme` + let userDefinedThemeFilenames = try filemanager.contentsOfDirectory(atPath: themesURL.path).filter { + $0.contains(".cetheme") + } + let userDefinedThemeURLs = userDefinedThemeFilenames.map { + themesURL.appendingPathComponent($0) + } + + // get all bundled theme URLs + let bundledThemeFilenames = try filemanager.contentsOfDirectory(atPath: bundledThemesURL.path).filter { + $0.contains(".cetheme") + } + let bundledThemeURLs = bundledThemeFilenames.map { + bundledThemesURL.appendingPathComponent($0) + } + + // combine user theme URLs with bundled theme URLs + let themeURLs = userDefinedThemeURLs + bundledThemeURLs + + let prefs = Settings.shared.preferences + + // load each theme from disk and store in memory + try themeURLs.forEach { fileURL in + if var theme = try load(from: fileURL) { + + // get all properties of terminal and editor colors + guard let terminalColors = try theme.terminal.allProperties() as? [String: Theme.Attributes], + let editorColors = try theme.editor.allProperties() as? [String: Theme.Attributes] + else { + print("error") + // TODO: Throw a proper error + throw NSError() // swiftlint:disable:this discouraged_direct_init + } + + // check if there are any overrides in `settings.json` + if let overrides = prefs.theme.overrides[theme.name]?["terminal"] { + terminalColors.forEach { (key, _) in + if let attributes = overrides[key] { + theme.terminal[key] = attributes + } + } + } + + if let overrides = prefs.theme.overrides[theme.name]?["editor"] { + editorColors.forEach { (key, _) in + if let attributes = overrides[key] { + theme.editor[key] = attributes + } + } + } + + theme.isBundled = fileURL.path.contains(bundledThemesURL.path) + + theme.fileURL = fileURL + + // add the theme to themes array + self.themes.append(theme) + + // if there already is a selected theme in `settings.json` select this theme + // otherwise take the first in the list + self.selectedDarkTheme = self.darkThemes.first { + $0.name == prefs.theme.selectedDarkTheme + } ?? self.darkThemes.first + + self.selectedLightTheme = self.lightThemes.first { + $0.name == prefs.theme.selectedLightTheme + } ?? self.lightThemes.first + + // For selecting the default theme, doing it correctly on startup requires some more logic + let userSelectedTheme = self.themes.first { $0.name == prefs.theme.selectedTheme } + let systemAppearance = NSAppearance.currentDrawing().name + + if userSelectedTheme != nil { + self.selectedTheme = userSelectedTheme + } else { + if systemAppearance == .darkAqua { + self.selectedTheme = self.selectedDarkTheme + } else { + self.selectedTheme = self.selectedLightTheme + } + } + } + } + } + } + + func importTheme() { + let openPanel = NSOpenPanel() + let allowedTypes = [UTType(filenameExtension: "cetheme")!] + + openPanel.prompt = "Import" + openPanel.allowedContentTypes = allowedTypes + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + + openPanel.begin { result in + if result.rawValue == NSApplication.ModalResponse.OK.rawValue { + if let url = openPanel.urls.first { + self.duplicate(url) + } + } + } + } + + func duplicate(_ url: URL) { + do { + self.isAdding = true + // Construct the destination file URL + var destinationFileURL = self.themesURL.appendingPathComponent(url.lastPathComponent) + + // Extract the base filename and extension + let fileExtension = destinationFileURL.pathExtension + + var fileName = destinationFileURL.deletingPathExtension().lastPathComponent + var newFileName = fileName + + var iterator = 1 + + let isBundled = url.absoluteString.hasPrefix(bundledThemesURL?.absoluteString ?? "") + let isImporting = + !url.absoluteString.hasPrefix(bundledThemesURL?.absoluteString ?? "") + && !url.absoluteString.hasPrefix(themesURL.absoluteString) + + if isBundled { + newFileName = "\(fileName) \(iterator)" + destinationFileURL = self.themesURL + .appendingPathComponent(newFileName) + .appendingPathExtension(fileExtension) + } + + // Check if the file already exists + while FileManager.default.fileExists(atPath: destinationFileURL.path) { + fileName = destinationFileURL.deletingPathExtension().lastPathComponent + + // Remove any existing iterator + if let range = fileName.range(of: " \\d+$", options: .regularExpression) { + fileName = String(fileName[.. Theme? { - do { - // get the data from the provided file - let json = try Data(contentsOf: url) - // decode the json into ``Theme`` - let theme = try JSONDecoder().decode(Theme.self, from: json) - return theme - } catch { - print(error) - return nil - } - } - - /// Loads all available themes from `~/Library/Application Support/CodeEdit/Themes/` - /// - /// If no themes are available, it will create a default theme and save - /// it to the location mentioned above. - /// - /// When overrides are found in `~/Library/Application Support/CodeEdit/settings.json` - /// they are applied to the loaded themes without altering the original - /// the files in `~/Library/Application Support/CodeEdit/Themes/`. - func loadThemes() throws { // swiftlint:disable:this function_body_length - if let bundledThemesURL = bundledThemesURL { - // remove all themes from memory - themes.removeAll() - - var isDir: ObjCBool = false - - // check if a themes directory exists, otherwise create one - if !filemanager.fileExists(atPath: themesURL.path, isDirectory: &isDir) { - try filemanager.createDirectory(at: themesURL, withIntermediateDirectories: true) - } - - // get all URLs in users themes folder that end with `.cetheme` - let userDefinedThemeFilenames = try filemanager.contentsOfDirectory(atPath: themesURL.path).filter { - $0.contains(".cetheme") - } - let userDefinedThemeURLs = userDefinedThemeFilenames.map { - themesURL.appendingPathComponent($0) - } - - // get all bundled theme URLs - let bundledThemeFilenames = try filemanager.contentsOfDirectory(atPath: bundledThemesURL.path).filter { - $0.contains(".cetheme") - } - let bundledThemeURLs = bundledThemeFilenames.map { - bundledThemesURL.appendingPathComponent($0) - } - - // combine user theme URLs with bundled theme URLs - let themeURLs = userDefinedThemeURLs + bundledThemeURLs - - let prefs = Settings.shared.preferences - - // load each theme from disk and store in memory - try themeURLs.forEach { fileURL in - if var theme = try load(from: fileURL) { - - // get all properties of terminal and editor colors - guard let terminalColors = try theme.terminal.allProperties() as? [String: Theme.Attributes], - let editorColors = try theme.editor.allProperties() as? [String: Theme.Attributes] - else { - print("error") - // TODO: Throw a proper error - throw NSError() // swiftlint:disable:this discouraged_direct_init - } - - // check if there are any overrides in `settings.json` - if let overrides = prefs.theme.overrides[theme.name]?["terminal"] { - terminalColors.forEach { (key, _) in - if let attributes = overrides[key] { - theme.terminal[key] = attributes - } - } - } - - if let overrides = prefs.theme.overrides[theme.name]?["editor"] { - editorColors.forEach { (key, _) in - if let attributes = overrides[key] { - theme.editor[key] = attributes - } - } - } - - // add the theme to themes array - self.themes.append(theme) - - // if there already is a selected theme in `settings.json` select this theme - // otherwise take the first in the list - self.selectedDarkTheme = self.darkThemes.first { - $0.name == prefs.theme.selectedDarkTheme - } ?? self.darkThemes.first - - self.selectedLightTheme = self.lightThemes.first { - $0.name == prefs.theme.selectedLightTheme - } ?? self.lightThemes.first - - // For selecting the default theme, doing it correctly on startup requires some more logic - let userSelectedTheme = self.themes.first { $0.name == prefs.theme.selectedTheme } - let systemAppearance = NSAppearance.currentDrawing().name - - if userSelectedTheme != nil { - self.selectedTheme = userSelectedTheme - } else { - if systemAppearance == .darkAqua { - self.selectedTheme = self.selectedDarkTheme - } else { - self.selectedTheme = self.selectedLightTheme - } - } - } - } - } - } - /// This function stores 'dark' and 'light' themes into `ThemePreferences` if user happens to select a theme - private func updateAppearanceTheme() { + func updateAppearanceTheme() { if self.selectedTheme?.appearance == .dark { self.selectedDarkTheme = self.selectedTheme } else if self.selectedTheme?.appearance == .light { @@ -239,89 +118,56 @@ final class ThemeModel: ObservableObject { } } - /// Removes all overrides of the given theme in - /// `~/Library/Application Support/CodeEdit/settings.json` - /// - /// After removing overrides, themes are reloaded - /// from `~/Library/Application Support/CodeEdit/Themes`. See ``loadThemes()`` - /// for more information. - /// - /// - Parameter theme: The theme to reset - func reset(_ theme: Theme) { - Settings.shared.preferences.theme.overrides[theme.name] = [:] - do { - try self.loadThemes() - } catch { - print(error) + func cancelDetails(_ theme: Theme) { + if let index = themes.firstIndex(where: { $0.fileURL == theme.fileURL }), + let detailsTheme = self.detailsTheme { + self.themes[index] = detailsTheme + self.save(self.themes[index]) } } - /// Removes the given theme from `–/Library/Application Support/CodeEdit/themes` - /// - /// After removing the theme, themes are reloaded - /// from `~/Library/Application Support/CodeEdit/Themes`. See ``loadThemes()`` - /// for more information. - /// - /// - Parameter theme: The theme to delete - func delete(_ theme: Theme) { - let url = themesURL - .appendingPathComponent(theme.name) - .appendingPathExtension("cetheme") - do { - // remove the theme from the list - try filemanager.removeItem(at: url) - - // remove from overrides in `settings.json` - Settings.shared.preferences.theme.overrides.removeValue(forKey: theme.name) + /// Initialize to the app's current appearance. + @Published var selectedAppearance: ThemeSettingsAppearances = { + NSApp.effectiveAppearance.name == .darkAqua ? .dark : .light + }() - // reload themes - try self.loadThemes() - } catch { - print(error) - } + enum ThemeSettingsAppearances: String, CaseIterable { + case light = "Light Appearance" + case dark = "Dark Appearance" } - /// Saves changes on theme properties to `overrides` - /// in `~/Library/Application Support/CodeEdit/settings.json`. - private func saveThemes() { - let url = themesURL - themes.forEach { theme in - do { - // load the original theme from `~/Library/Application Support/CodeEdit/Themes/` - let originalUrl = url.appendingPathComponent(theme.name).appendingPathExtension("cetheme") - let originalData = try Data(contentsOf: originalUrl) - let originalTheme = try JSONDecoder().decode(Theme.self, from: originalData) - - // get properties of the current theme as well as the original - guard let terminalColors = try theme.terminal.allProperties() as? [String: Theme.Attributes], - let editorColors = try theme.editor.allProperties() as? [String: Theme.Attributes], - let oTermColors = try originalTheme.terminal.allProperties() as? [String: Theme.Attributes], - let oEditColors = try originalTheme.editor.allProperties() as? [String: Theme.Attributes] - else { - // TODO: Throw a proper error - throw NSError() // swiftlint:disable:this discouraged_direct_init - } - - // compare the properties and if there are differences, save to overrides - // in `settings.json - var newAttr: [String: [String: Theme.Attributes]] = ["terminal": [:], "editor": [:]] - terminalColors.forEach { (key, value) in - if value != oTermColors[key] { - newAttr["terminal"]?[key] = value - } - } - - editorColors.forEach { (key, value) in - if value != oEditColors[key] { - newAttr["editor"]?[key] = value - } - } - DispatchQueue.main.async { - Settings.shared.preferences.theme.overrides[theme.name] = newAttr - } - - } catch { - print(error) + func getThemeActive(_ theme: Theme) -> Bool { + if settings.matchAppearance { + return selectedAppearance == .dark + ? selectedDarkTheme == theme + : selectedAppearance == .light + ? selectedLightTheme == theme + : selectedTheme == theme + } + return selectedTheme == theme + } + + /// Activates the current theme, setting ``selectedTheme`` and ``selectedLightTheme``/``selectedDarkTheme`` as + /// necessary. + /// - Parameter theme: The theme to activate. + func activateTheme(_ theme: Theme) { + if settings.matchAppearance { + if selectedAppearance == .dark { + selectedDarkTheme = theme + } else if selectedAppearance == .light { + selectedLightTheme = theme + } + if (selectedAppearance == .dark && colorScheme == .dark) + || (selectedAppearance == .light && colorScheme == .light) { + selectedTheme = theme + } + } else { + selectedTheme = theme + if colorScheme == .light { + selectedLightTheme = theme + } + if colorScheme == .dark { + selectedDarkTheme = theme } } } diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift index 2e1522f3a..54a2f3217 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift @@ -10,7 +10,8 @@ import SwiftUI struct ThemeSettingsThemeRow: View { @Binding var theme: Theme var active: Bool - var action: (Theme) -> Void + + @ObservedObject private var themeModel: ThemeModel = .shared @State private var presentingDetails: Bool = false @@ -28,24 +29,39 @@ struct ThemeSettingsThemeRow: View { .font(.footnote) } .frame(maxWidth: .infinity, alignment: .leading) - Button { - presentingDetails = true - } label: { - Text("Details...") + if !active { + Button { + themeModel.activateTheme(theme) + } label: { + Text("Choose") + } + .buttonStyle(.bordered) + .opacity(isHovering ? 1 : 0) } - .buttonStyle(.bordered) - .opacity(isHovering ? 1 : 0) ThemeSettingsColorPreview(theme) + Menu { + Button("Details...") { + themeModel.detailsTheme = theme + } + Button("Duplicate") { + if let fileURL = theme.fileURL { + themeModel.duplicate(fileURL) + } + } + Divider() + Button("Delete") { + themeModel.delete(theme) + } + .disabled(theme.isBundled) + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16)) + } + .buttonStyle(.icon) } .padding(10) .onHover { hovering in isHovering = hovering } - .onTapGesture { - action(theme) - } - .sheet(isPresented: $presentingDetails) { - ThemeSettingsThemeDetails($theme) - } } } diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift index 77ebfa777..9ec3bb5f4 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift @@ -11,108 +11,192 @@ struct ThemeSettingsThemeDetails: View { @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) + var colorScheme + @Binding var theme: Theme - @State private var initialTheme: Theme + var originalTheme: Theme @StateObject private var themeModel: ThemeModel = .shared - init(_ theme: Binding) { + init(theme: Binding) { _theme = theme - _initialTheme = State(initialValue: theme.wrappedValue) + originalTheme = theme.wrappedValue } var body: some View { VStack(spacing: 0) { Form { - Section { - TextField("Name", text: $theme.displayName) - } - Section { - SettingsColorPicker( - "Text", - color: $theme.editor.text.swiftColor - ) - SettingsColorPicker( - "Cursor", - color: $theme.editor.insertionPoint.swiftColor - ) - SettingsColorPicker( - "Invisibles", - color: $theme.editor.invisibles.swiftColor - ) - } - Section { - SettingsColorPicker( - "Background", - color: $theme.editor.background.swiftColor - ) - SettingsColorPicker( - "Current Line", - color: $theme.editor.lineHighlight.swiftColor - ) - SettingsColorPicker( - "Selection", - color: $theme.editor.selection.swiftColor - ) + Group { + Section { + TextField("Name", text: $theme.displayName) + TextField("Author", text: $theme.author) + Picker("Type", selection: $theme.appearance) { + Text("Light") + .tag(Theme.ThemeType.light) + Text("Dark") + .tag(Theme.ThemeType.dark) + } + } + Section("Text") { + SettingsColorPicker( + "Text", + color: $theme.editor.text.swiftColor + ) + SettingsColorPicker( + "Cursor", + color: $theme.editor.insertionPoint.swiftColor + ) + SettingsColorPicker( + "Invisibles", + color: $theme.editor.invisibles.swiftColor + ) + } + Section("Background") { + SettingsColorPicker( + "Background", + color: $theme.editor.background.swiftColor + ) + SettingsColorPicker( + "Current Line", + color: $theme.editor.lineHighlight.swiftColor + ) + SettingsColorPicker( + "Selection", + color: $theme.editor.selection.swiftColor + ) + } + Section("Tokens") { + VStack(spacing: 0) { + ThemeSettingsThemeToken( + "Keywords", + color: $theme.editor.keywords.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Commands", + color: $theme.editor.commands.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Types", + color: $theme.editor.types.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Attributes", + color: $theme.editor.attributes.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Variables", + color: $theme.editor.variables.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Values", + color: $theme.editor.values.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Numbers", + color: $theme.editor.numbers.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Strings", + color: $theme.editor.strings.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Characters", + color: $theme.editor.characters.swiftColor + ) + Divider().padding(.horizontal, 10) + ThemeSettingsThemeToken( + "Comments", + color: $theme.editor.comments.swiftColor + ) + } + .background(theme.editor.background.swiftColor) + .padding(-10) + .colorScheme( + theme.appearance == .dark + ? .dark + : theme.appearance == .light + ? .light : colorScheme + ) + } } - Section { - SettingsColorPicker( - "Keywords", - color: $theme.editor.keywords.swiftColor - ) - SettingsColorPicker( - "Commands", - color: $theme.editor.commands.swiftColor - ) - SettingsColorPicker( - "Types", - color: $theme.editor.types.swiftColor - ) - SettingsColorPicker( - "Attributes", - color: $theme.editor.attributes.swiftColor - ) - SettingsColorPicker( - "Variables", - color: $theme.editor.variables.swiftColor - ) - SettingsColorPicker( - "Values", - color: $theme.editor.values.swiftColor - ) - SettingsColorPicker( - "Numbers", - color: $theme.editor.numbers.swiftColor - ) - SettingsColorPicker( - "Strings", - color: $theme.editor.strings.swiftColor - ) - SettingsColorPicker( - "Characters", - color: $theme.editor.characters.swiftColor - ) - SettingsColorPicker( - "Comments", - color: $theme.editor.comments.swiftColor - ) - } - }.formStyle(.grouped) + .disabled(theme.isBundled) + } + .formStyle(.grouped) Divider() HStack { + if theme.isBundled { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .font(.body) + .foregroundStyle(Color.yellow) + Text("Duplicate this theme to make changes.") + .font(.subheadline) + .lineLimit(2) + } + .help("Bundled themes must be duplicated to make changes.") + .accessibilityElement(children: .combine) + .accessibilityLabel("Warning: Duplicate this theme to make changes.") + } else if !themeModel.isAdding { + Button(role: .destructive) { + themeModel.delete(theme) + dismiss() + } label: { + Text("Delete") + .foregroundStyle(.red) + .frame(minWidth: 56) + } + Button { + if let fileURL = theme.fileURL { + themeModel.duplicate(fileURL) + } + } label: { + Text("Duplicate") + .frame(minWidth: 56) + } + } Spacer() - Button { - theme = initialTheme - dismiss() - } label: { - Text("Cancel") + if !themeModel.isAdding && theme.isBundled { + Button { + if let fileURL = theme.fileURL { + themeModel.duplicate(fileURL) + } + } label: { + Text("Duplicate") + .frame(minWidth: 56) + } + } else { + Button { + if themeModel.isAdding { + themeModel.delete(theme) + } else { + themeModel.cancelDetails(theme) + } + + dismiss() + } label: { + Text("Cancel") + .frame(minWidth: 56) + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) Button { + if !theme.isBundled { + themeModel.rename(to: theme.displayName, theme: theme) + } dismiss() } label: { Text("Done") + .frame(minWidth: 56) } .buttonStyle(.borderedProminent) } diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift new file mode 100644 index 000000000..06564fc7b --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift @@ -0,0 +1,37 @@ +// +// ThemeSettingsThemeToken.swift +// CodeEdit +// +// Created by Austin Condiff on 5/14/24. +// + +import SwiftUI + +struct ThemeSettingsThemeToken: View { + var label: String + + @Binding var color: Color + + @State private var selectedColor: Color + + init(_ label: String, color: Binding) { + self.label = label + self._color = color + self._selectedColor = State(initialValue: color.wrappedValue) + } + + var body: some View { + LabeledContent { + ColorPicker(selection: $selectedColor, supportsOpacity: false) { } + .labelsHidden() + } label: { + Text(label) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(color) + } + .padding(10) + .onChange(of: selectedColor) { newValue in + color = newValue + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index b3e44793c..3f327b43e 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -18,45 +18,6 @@ struct ThemeSettingsView: View { var useDarkTerminalAppearance @State private var listView: Bool = false - @State private var selectedAppearance: ThemeSettingsAppearances = .dark - - enum ThemeSettingsAppearances: String, CaseIterable { - case light = "Light Appearance" - case dark = "Dark Appearance" - } - - func getThemeActive (_ theme: Theme) -> Bool { - if settings.matchAppearance { - return selectedAppearance == .dark - ? themeModel.selectedDarkTheme == theme - : selectedAppearance == .light - ? themeModel.selectedLightTheme == theme - : themeModel.selectedTheme == theme - } - return themeModel.selectedTheme == theme - } - - func activateTheme (_ theme: Theme) { - if settings.matchAppearance { - if selectedAppearance == .dark { - themeModel.selectedDarkTheme = theme - } else if selectedAppearance == .light { - themeModel.selectedLightTheme = theme - } - if (selectedAppearance == .dark && colorScheme == .dark) - || (selectedAppearance == .light && colorScheme == .light) { - themeModel.selectedTheme = theme - } - } else { - themeModel.selectedTheme = theme - if colorScheme == .light { - themeModel.selectedLightTheme = theme - } - if colorScheme == .dark { - themeModel.selectedDarkTheme = theme - } - } - } var body: some View { SettingsForm { @@ -70,8 +31,8 @@ struct ThemeSettingsView: View { Section { VStack(spacing: 0) { if settings.matchAppearance { - Picker("", selection: $selectedAppearance) { - ForEach(ThemeSettingsAppearances.allCases, id: \.self) { tab in + Picker("", selection: $themeModel.selectedAppearance) { + ForEach(ThemeModel.ThemeSettingsAppearances.allCases, id: \.self) { tab in Text(tab.rawValue) .tag(tab) } @@ -81,27 +42,60 @@ struct ThemeSettingsView: View { .padding(10) } VStack(spacing: 0) { - ForEach(selectedAppearance == .dark ? themeModel.darkThemes : themeModel.lightThemes) { theme in - Divider() + ForEach( + themeModel.selectedAppearance == .dark + ? themeModel.darkThemes + : themeModel.lightThemes + ) { theme in + Divider().padding(.horizontal, 10) ThemeSettingsThemeRow( theme: $themeModel.themes[themeModel.themes.firstIndex(of: theme)!], - active: getThemeActive(theme), - action: activateTheme + active: themeModel.getThemeActive(theme) ).id(theme) } - ForEach(selectedAppearance == .dark ? themeModel.lightThemes : themeModel.darkThemes) { theme in - Divider() + ForEach( + themeModel.selectedAppearance == .dark + ? themeModel.lightThemes + : themeModel.darkThemes + ) { theme in + Divider().padding(.horizontal, 10) ThemeSettingsThemeRow( theme: $themeModel.themes[themeModel.themes.firstIndex(of: theme)!], - active: getThemeActive(theme), - action: activateTheme + active: themeModel.getThemeActive(theme) ).id(theme) } } } .padding(-10) + } footer: { + HStack { + Spacer() + Button("Import...") { + themeModel.importTheme() + } + } + .padding(.top, 10) } } + .sheet(item: $themeModel.detailsTheme) { + themeModel.isAdding = false + } content: { theme in + if let index = themeModel.themes.firstIndex(where: { + $0.fileURL?.absoluteString == theme.fileURL?.absoluteString + }) { + ThemeSettingsThemeDetails(theme: Binding( + get: { themeModel.themes[index] }, + set: { newValue in + themeModel.themes[index] = newValue + themeModel.save(newValue) + if settings.selectedTheme == theme.name { + themeModel.activateTheme(newValue) + } + } + )) + } + + } } } diff --git a/CodeEdit/Features/Settings/Views/SettingsColorPicker.swift b/CodeEdit/Features/Settings/Views/SettingsColorPicker.swift index 13b751f6a..453d2347d 100644 --- a/CodeEdit/Features/Settings/Views/SettingsColorPicker.swift +++ b/CodeEdit/Features/Settings/Views/SettingsColorPicker.swift @@ -7,7 +7,7 @@ import SwiftUI -struct SettingsColorPicker: View { +struct SettingsColorPicker: View where Content: View { /// Color modified elsewhere in user theme @Binding var color: Color @@ -17,17 +17,28 @@ struct SettingsColorPicker: View { @State private var selectedColor: Color private let label: String + private let content: Content? - init(_ label: String, color: Binding) { + init(_ label: String, color: Binding, @ViewBuilder content: @escaping () -> Content) { self._color = color self.label = label self._selectedColor = State(initialValue: color.wrappedValue) + self.content = content() + } + + init(_ label: String, color: Binding) where Content == EmptyView { + self.init(label, color: color) { + EmptyView() + } } var body: some View { LabeledContent(label) { - ColorPicker(selection: $selectedColor, supportsOpacity: false) { } - .labelsHidden() + HStack(spacing: 16) { + content + ColorPicker(selection: $selectedColor, supportsOpacity: false) { } + .labelsHidden() + } } .onChange(of: selectedColor) { newValue in color = newValue diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index b2649facc..8c4dcb35d 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -69,7 +69,11 @@ struct WorkspaceView: View { focusedEditor = newValue } } + .task { + themeModel.colorScheme = colorScheme + } .onChange(of: colorScheme) { newValue in + themeModel.colorScheme = newValue if matchAppearance { themeModel.selectedTheme = newValue == .dark ? themeModel.selectedDarkTheme