diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index f77ff76409..6b63ea9e00 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0485EB2527E7B9C800138301 /* Overlays in Frameworks */ = {isa = PBXBuildFile; productRef = 0485EB2427E7B9C800138301 /* Overlays */; }; 20D2A95527F72E6800E7ECF6 /* Accounts in Frameworks */ = {isa = PBXBuildFile; productRef = 20D2A95427F72E6800E7ECF6 /* Accounts */; }; 2803257127F3CF1F009C7DC2 /* AppPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = 2803257027F3CF1F009C7DC2 /* AppPreferences */; }; + 28069DA427F5BCF70016BC47 /* default-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 28069DA327F5BCDD0016BC47 /* default-dark.json */; }; 2806CBD427EE9FC0003E5523 /* ProjectNavigatorContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2806CBD327EE9FC0003E5523 /* ProjectNavigatorContextMenu.swift */; }; 2813F93827ECC4AA00E305E4 /* FindNavigatorResultList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201B327E9989900CB86D0 /* FindNavigatorResultList.swift */; }; 2813F93927ECC4C300E305E4 /* NavigatorSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287776E627E3413200D46668 /* NavigatorSidebar.swift */; }; @@ -87,6 +88,7 @@ 0485EB1C27E7338100138301 /* QuickOpenItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickOpenItem.swift; sourceTree = ""; }; 0485EB1E27E7458B00138301 /* WorkspaceCodeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCodeFileView.swift; sourceTree = ""; }; 0485EB2227E7791400138301 /* QuickOpenPreviewView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = QuickOpenPreviewView.swift; sourceTree = ""; tabWidth = 4; }; + 28069DA327F5BCDD0016BC47 /* default-dark.json */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = text.json; path = "default-dark.json"; sourceTree = ""; tabWidth = 2; }; 2806CBD327EE9FC0003E5523 /* ProjectNavigatorContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorContextMenu.swift; sourceTree = ""; }; 286471AA27ED51FD0039369D /* ProjectNavigator.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = ProjectNavigator.swift; sourceTree = ""; tabWidth = 4; }; 286620A427E4AB6900E18C2B /* BreadcrumbsComponent.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = BreadcrumbsComponent.swift; sourceTree = ""; tabWidth = 4; }; @@ -181,6 +183,14 @@ path = "Quick Open"; sourceTree = ""; }; + 28069DA527F5BD320016BC47 /* DefaultThemes */ = { + isa = PBXGroup; + children = ( + 28069DA327F5BCDD0016BC47 /* default-dark.json */, + ); + path = DefaultThemes; + sourceTree = ""; + }; 286471AC27ED52950039369D /* ProjectNavigator */ = { isa = PBXGroup; children = ( @@ -278,6 +288,7 @@ 043C321927E32295006AE443 /* MainMenu.xib */, B673FDAC27E8296A00795864 /* PressActionsModifier.swift */, B658FB3127DA9E0F00EA4DBD /* WorkspaceView.swift */, + 28069DA527F5BD320016BC47 /* DefaultThemes */, ); path = CodeEdit; sourceTree = ""; @@ -454,6 +465,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 28069DA427F5BCF70016BC47 /* default-dark.json in Resources */, B658FB3727DA9E1000EA4DBD /* Preview Assets.xcassets in Resources */, D7211D4727E06BFE008F2ED7 /* Localizable.strings in Resources */, B658FB3427DA9E1000EA4DBD /* Assets.xcassets in Resources */, diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 101b77ea84..9add396af2 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -31,9 +31,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationDidFinishLaunching(_ notification: Notification) { - if let appearanceString = UserDefaults.standard.string(forKey: Appearances.storageKey) { - Appearances(rawValue: appearanceString)?.applyAppearance() - } + AppPreferencesModel.shared.preferences.general.appAppearance.applyAppearance() DispatchQueue.main.async { if NSApp.windows.isEmpty { @@ -77,8 +75,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func handleOpen() { - let behavior = ReopenBehavior(rawValue: UserDefaults.standard.string(forKey: ReopenBehavior.storageKey) - ?? ReopenBehavior.default.rawValue) ?? ReopenBehavior.default + let behavior = AppPreferencesModel.shared.preferences.general.reopenBehavior switch behavior { case .welcome: @@ -174,7 +171,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { title: "Themes", toolbarIcon: NSImage(systemSymbolName: "paintbrush", accessibilityDescription: nil)! ) { - PreferencesThemeView() + ThemePreferencesView() }, Preferences.Pane( identifier: Preferences.PaneIdentifier("TextEditing"), @@ -183,6 +180,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ) { PreferencesTextEditingView() }, + Preferences.Pane( + identifier: Preferences.PaneIdentifier("Terminal"), + title: "Terminal", + toolbarIcon: NSImage(systemSymbolName: "terminal", accessibilityDescription: nil)! + ) { + PreferencesTerminalView() + }, Preferences.Pane( identifier: Preferences.PaneIdentifier("KeyBindings"), title: "Key Bindings", diff --git a/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift b/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift index 44d17ce125..539d39666d 100644 --- a/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift +++ b/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift @@ -10,8 +10,9 @@ import WorkspaceClient import AppPreferences struct BreadcrumbsComponent: View { - @AppStorage(FileIconStyle.storageKey) - var iconStyle: FileIconStyle = .default + + @StateObject + private var prefs: AppPreferencesModel = .shared @ObservedObject var workspace: WorkspaceDocument @@ -51,7 +52,7 @@ struct BreadcrumbsComponent: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 12) - .foregroundStyle(iconStyle == .color ? color : .secondary) + .foregroundStyle(prefs.preferences.general.fileIconStyle == .color ? color : .secondary) .onAppear { self.position = NSPoint( x: geometry.frame(in: .global).minX, diff --git a/CodeEdit/DefaultThemes/default-dark.json b/CodeEdit/DefaultThemes/default-dark.json new file mode 100644 index 0000000000..fa28e625e5 --- /dev/null +++ b/CodeEdit/DefaultThemes/default-dark.json @@ -0,0 +1,124 @@ +{ + "type" : "dark", + "license" : "MIT", + "author" : "CodeEdit", + "description" : "CodeEdit default dark theme.", + "name" : "Default (Dark)", + "distributionURL" : "https:\/\/github.com\/CodeEditApp\/CodeEdit", + "version" : "0.0.1", + "editor" : { + "strings" : { + "color" : "#f0907f" + }, + "comments" : { + "color" : "#97be71" + }, + "numbers" : { + "color" : "#d6c775" + }, + "commands" : { + "color" : "#c6a3f9" + }, + "lineHighlight" : { + "color" : "#303030" + }, + "values" : { + "color" : "#70c1e2" + }, + "background" : { + "color" : "#1e1e1e" + }, + "keywords" : { + "color" : "#ef8bb6" + }, + "text" : { + "color" : "#DDDDDD" + }, + "insertionPoint" : { + "color" : "#DDDDDD" + }, + "selection" : { + "color" : "#8b8b8b" + }, + "types" : { + "color" : "#93c7bc" + }, + "variables" : { + "color" : "#70c1e2" + }, + "attributes" : { + "color" : "#93c7bc" + }, + "characters" : { + "color" : "#93c7bc" + }, + "invisibles" : { + "color" : "#636363" + } + }, + "terminal" : { + "text" : { + "color" : "#ffffff" + }, + "boldText" : { + "color" : "#ffffff" + }, + "cursor" : { + "color" : "#ffffff" + }, + "background" : { + "color" : "#1e1e1e" + }, + "selection" : { + "color" : "#8b8b8b" + }, + "black" : { + "color" : "#000000" + }, + "red" : { + "color" : "#990000" + }, + "green" : { + "color" : "#00a600" + }, + "yellow" : { + "color" : "#999900" + }, + "blue" : { + "color" : "#0000b2" + }, + "magenta" : { + "color" : "#b200b2" + }, + "cyan" : { + "color" : "#00a6b2" + }, + "white" : { + "color" : "#bfbfbf" + }, + "brightBlack" : { + "color" : "#666666" + }, + "brightRed" : { + "color" : "#e50000" + }, + "brightGreen" : { + "color" : "#00d900" + }, + "brightYellow" : { + "color" : "#e5e500" + }, + "brightBlue" : { + "color" : "#0000ff" + }, + "brightMagenta" : { + "color" : "#e500e5" + }, + "brightCyan" : { + "color" : "#00e5e5" + }, + "brightWhite" : { + "color" : "#e5e5e5" + } + } +} diff --git a/CodeEdit/Info.plist b/CodeEdit/Info.plist index cf7b78b9ab..1302ee1c7b 100644 --- a/CodeEdit/Info.plist +++ b/CodeEdit/Info.plist @@ -45,6 +45,6 @@ GitHash - aec92088b4c2e9e20f29a7592e6e787cd72f3b8f + e3a67545e65e3719f05ef880787360c7551564e9 diff --git a/CodeEdit/NavigatorSidebar/ProjectNavigator/ProjectNavigatorItem.swift b/CodeEdit/NavigatorSidebar/ProjectNavigator/ProjectNavigatorItem.swift index e33e2f3f38..8cf13dfa41 100644 --- a/CodeEdit/NavigatorSidebar/ProjectNavigator/ProjectNavigatorItem.swift +++ b/CodeEdit/NavigatorSidebar/ProjectNavigator/ProjectNavigatorItem.swift @@ -16,8 +16,9 @@ import AppPreferences /// Displays a File or Folder in the ``ProjectNavigator``. /// struct ProjectNavigatorItem: View { - @AppStorage(FileIconStyle.storageKey) - var iconStyle: FileIconStyle = .default + + @StateObject + private var prefs: AppPreferencesModel = .shared /// The `FileItem` for this view var item: WorkspaceClient.FileItem @@ -93,6 +94,6 @@ struct ProjectNavigatorItem: View { /// Returns a color depending on the set icon color style in preferences private var iconColor: Color { - return iconStyle == .color ? item.iconColor : .secondary + return prefs.preferences.general.fileIconStyle == .color ? item.iconColor : .secondary } } diff --git a/CodeEdit/TabBar/TabBarItem.swift b/CodeEdit/TabBar/TabBarItem.swift index 42bb8a623b..6dce45bc37 100644 --- a/CodeEdit/TabBar/TabBarItem.swift +++ b/CodeEdit/TabBar/TabBarItem.swift @@ -30,8 +30,8 @@ struct TabBarItem: View { @Environment(\.colorScheme) var colorScheme - @AppStorage(FileIconStyle.storageKey) - private var iconStyle: FileIconStyle = .default + @StateObject + private var prefs: AppPreferencesModel = .shared @State var isHovering: Bool = false @@ -106,7 +106,7 @@ struct TabBarItem: View { .resizable() .aspectRatio(contentMode: .fit) .foregroundColor( - iconStyle == .color ? item.iconColor : .secondary + prefs.preferences.general.fileIconStyle == .color ? item.iconColor : .secondary ) .frame(width: 12, height: 12) Text(item.url.lastPathComponent) diff --git a/CodeEdit/Welcome/WelcomeView.swift b/CodeEdit/Welcome/WelcomeView.swift index bfb7409bf6..587de81cb8 100644 --- a/CodeEdit/Welcome/WelcomeView.swift +++ b/CodeEdit/Welcome/WelcomeView.swift @@ -21,8 +21,8 @@ struct WelcomeView: View { @State var isHoveringClose: Bool = false - @AppStorage(ReopenBehavior.storageKey) - var behavior: ReopenBehavior = .welcome + @StateObject + private var prefs: AppPreferencesModel = .shared var dismissWindow: () -> Void @@ -187,9 +187,9 @@ struct WelcomeView: View { HStack { Spacer() Toggle("Show this window when CodeEdit launches", isOn: .init(get: { - return self.behavior == .welcome + return prefs.preferences.general.reopenBehavior == .welcome }, set: { new in - self.behavior = new ? .welcome : .openPanel + prefs.preferences.general.reopenBehavior = new ? .welcome : .openPanel })) .toggleStyle(.checkbox) Spacer() diff --git a/CodeEditModules/Modules/AppPreferences/src/Extensions/Color+HEX.swift b/CodeEditModules/Modules/AppPreferences/src/Extensions/Color+HEX.swift new file mode 100644 index 0000000000..5f3dac5a08 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/Extensions/Color+HEX.swift @@ -0,0 +1,41 @@ +// +// Color+HEX.swift +// +// +// Created by Lukas Pistrol on 23.03.22. +// + +import SwiftUI + +public extension Color { + + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + self.init(hex: Int(int)) + } + + init(hex: Int) { + let red = (hex >> 16) & 0xFF + let green = (hex >> 8) & 0xFF + let blue = hex & 0xFF + self.init(.sRGB, red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, opacity: 1) + } + + var hex: Int { + guard let components = cgColor?.components, components.count >= 3 else { return 0 } + + let red = lround((Double(components[0]) * 255.0)) << 16 + let green = lround((Double(components[1]) * 255.0)) << 8 + let blue = lround((Double(components[2]) * 255.0)) + + return red | green | blue + } + + var hexString: String { + let color = self.hex + + return "#" + String(format: "%06x", color) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/GeneralPreferences/PreferencesGeneralView.swift b/CodeEditModules/Modules/AppPreferences/src/GeneralPreferences/PreferencesGeneralView.swift new file mode 100644 index 0000000000..06b53cb924 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/GeneralPreferences/PreferencesGeneralView.swift @@ -0,0 +1,56 @@ +// +// PreferencesGeneralView.swift +// +// +// Created by Lukas Pistrol on 30.03.22. +// + +import SwiftUI +import Preferences + +public struct PreferencesGeneralView: View { + + @StateObject + private var prefs: AppPreferencesModel = .shared + + public init() {} + + public var body: some View { + Form { + Picker("Appearance:", selection: $prefs.preferences.general.appAppearance) { + Text("System") + .tag(AppPreferences.Appearances.system) + Divider() + Text("Light") + .tag(AppPreferences.Appearances.light) + Text("Dark") + .tag(AppPreferences.Appearances.dark) + } + .onChange(of: prefs.preferences.general.appAppearance) { tag in + tag.applyAppearance() + } + .fixedSize() + + Picker("File Icon Style:", selection: $prefs.preferences.general.fileIconStyle) { + Text("Color") + .tag(AppPreferences.FileIconStyle.color) + Text("Monochrome") + .tag(AppPreferences.FileIconStyle.monochrome) + } + .pickerStyle(.radioGroup) + + Picker("Reopen Behavior:", selection: $prefs.preferences.general.reopenBehavior) { + Text("Welcome Screen") + .tag(AppPreferences.ReopenBehavior.welcome) + Divider() + Text("Open Panel") + .tag(AppPreferences.ReopenBehavior.openPanel) + Text("New Document") + .tag(AppPreferences.ReopenBehavior.newDocument) + } + .fixedSize() + } + .frame(width: 844) + .padding(30) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/HelperViews/HelpButton.swift b/CodeEditModules/Modules/AppPreferences/src/HelperViews/HelpButton.swift new file mode 100644 index 0000000000..0186ef1a10 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/HelperViews/HelpButton.swift @@ -0,0 +1,29 @@ +// +// HelpButton.swift +// +// +// Created by Lukas Pistrol on 30.03.22. +// + +import SwiftUI + +struct HelpButton: View { + var action : () -> Void + + var body: some View { + Button(action: action, label: { + ZStack { + Circle() + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 0.5) + .background(Circle().foregroundColor(Color(NSColor.controlColor))) + .shadow(color: Color(NSColor.separatorColor).opacity(0.3), radius: 0.5) + .shadow(color: Color(NSColor.shadowColor).opacity(0.3), radius: 1, y: 0.5) + .frame(width: 20, height: 20) +// Text("?").font(.system(size: 15, weight: .medium )) + Image(systemName: "questionmark") + .font(.system(size: 12.5, weight: .medium)) + } + }) + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/HelperViews/PreferencesColorPicker.swift b/CodeEditModules/Modules/AppPreferences/src/HelperViews/PreferencesColorPicker.swift new file mode 100644 index 0000000000..bfd537c1d5 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/HelperViews/PreferencesColorPicker.swift @@ -0,0 +1,31 @@ +// +// PreferencesColorPicker.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +struct PreferencesColorPicker: View { + + @Binding + var color: Color + + private var label: String? + + init(_ color: Binding, label: String? = nil) { + self._color = color + self.label = label + } + + var body: some View { + HStack { + ColorPicker(selection: $color, supportsOpacity: false) { } + .labelsHidden() + if let label = label { + Text(label) + } + } + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/HelperViews/SegmentedControl.swift b/CodeEditModules/Modules/AppPreferences/src/HelperViews/SegmentedControl.swift new file mode 100644 index 0000000000..982d3592ba --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/HelperViews/SegmentedControl.swift @@ -0,0 +1,44 @@ +// +// SegmentedControl.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +@available(macOS 12, *) +struct SegmentedControl: View { + + init(_ selection: Binding, options: [String]) { + self._preselectedIndex = selection + self.options = options + } + + @Binding var preselectedIndex: Int + var options: [String] + let color = Color.accentColor + var body: some View { + HStack(spacing: 0) { + ForEach(options.indices, id: \.self) { index in + Text(options[index]) + .font(.subheadline) + .foregroundColor(preselectedIndex == index ? .white : .primary) + .frame(height: 16) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background { + Rectangle() + .fill(color) + .cornerRadius(5) + .padding(2) + .opacity(preselectedIndex == index ? 1 : 0.01) + } + .onTapGesture { + preselectedIndex = index + } + } + } + .frame(height: 20) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferences.swift b/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferences.swift new file mode 100644 index 0000000000..ffe3935683 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferences.swift @@ -0,0 +1,211 @@ +// +// AppPreferences.swift +// +// +// Created by Lukas Pistrol on 01.04.22. +// + +import SwiftUI + +/// # AppPreferences +/// +/// The model structure of settings for `CodeEdit` +/// +/// A `JSON` representation is persisted in `~/.codeedit/preference.json`. +/// - Attention: Don't use `UserDefaults` for persisting user accessible settings. +/// If a further setting is needed, extend the struct like ``GeneralPreferences``, +/// ``ThemePreferences``, or ``TerminalPreferences`` does. +public struct AppPreferences: Codable { + + /// The general global setting + public var general: GeneralPreferences = .init() + + /// The global settings for themes + public var theme: ThemePreferences = .init() + + /// The global settings for the terminal emulator + public var terminal: TerminalPreferences = .init() + + /// The global settings for text editing + public var textEditing: TextEditingPreferences = .init() + +} + +public extension AppPreferences { + + /// The general global setting + struct GeneralPreferences: Codable { + + /// The appearance of the app + public var appAppearance: Appearances = .system + + /// The style for file icons + public var fileIconStyle: FileIconStyle = .color + + /// The reopen behavior of the app + public var reopenBehavior: ReopenBehavior = .welcome + } + + /// The appearance of the app + /// - **system**: uses the system appearance + /// - **dark**: always uses dark appearance + /// - **light**: always uses light appearance + enum Appearances: String, Codable { + case system + case light + case dark + + /// Applies the selected appearance + public func applyAppearance() { + switch self { + case .system: + NSApp.appearance = nil + + case .dark: + NSApp.appearance = .init(named: .darkAqua) + + case .light: + NSApp.appearance = .init(named: .aqua) + } + } + } + + /// The style for file icons + /// - **color**: File icons appear in their default colors + /// - **monochrome**: File icons appear monochromatic + enum FileIconStyle: String, Codable { + case color + case monochrome + } + + /// The reopen behavior of the app + /// - **welcome**: On restart the app will show the welcome screen + /// - **openPanel**: On restart the app will show an open panel + /// - **newDocument**: On restart a new empty document will be created + enum ReopenBehavior: String, Codable { + case welcome + case openPanel + case newDocument + } + +} + +public extension AppPreferences { + + /// A dictionary containing the keys and associated ``Theme/Attributes`` of overridden properties + /// + /// ```json + /// { + /// "editor" : { + /// "background" : { + /// "color" : "#123456" + /// }, + /// ... + /// }, + /// "terminal" : { + /// "blue" : { + /// "color" : "#1100FF" + /// }, + /// ... + /// } + /// } + /// ``` + typealias ThemeOverrides = [String: [String: Theme.Attributes]] + + /// The global settings for themes + struct ThemePreferences: Codable { + + /// The name of the currently selected theme + public var selectedTheme: String? + + /// Use the system background that matches the appearance setting + public var useThemeBackground: Bool = false + + /// Dictionary of themes containing overrides + /// + /// ```json + /// { + /// "overrides" : { + /// "DefaultDark" : { + /// "editor" : { + /// "background" : { + /// "color" : "#123456" + /// }, + /// ... + /// }, + /// "terminal" : { + /// "blue" : { + /// "color" : "#1100FF" + /// }, + /// ... + /// } + /// ... + /// }, + /// ... + /// }, + /// ... + /// } + /// ``` + public var overrides: [String: ThemeOverrides] = [:] + } + +} + +public extension AppPreferences { + + /// The global settings for the terminal emulator + struct TerminalPreferences: Codable { + /// If true terminal appearance will always be `dark`. Otherwise it adapts to the system setting. + public var darkAppearance: Bool = false + + /// The selected shell to use. + public var shell: TerminalShell = .system + + /// The font to use in terminal. + public var font: TerminalFont = .init() + } + + /// The shell options. + /// - **bash**: uses the default bash shell + /// - **zsh**: uses the ZSH shell + /// - **system**: uses the system default shell (most likely ZSH) + enum TerminalShell: String, Codable { + case bash + case zsh + case system + } + + struct TerminalFont: Codable { + /// Indicates whether or not to use a custom font + public var customFont: Bool = false + + /// The font size for the custom font + public var size: Int = 11 + + /// The name of the custom font + public var name: String = "SF-MonoMedium" + } +} + +public extension AppPreferences { + + /// The global settings for text editing + struct TextEditingPreferences: Codable { + /// An integer indicating how many spaces a `tab` will generate + public var defaultTabWidth: Int = 4 + + /// The font to use in editor. + public var font: EditorFont = .init() + } + + struct EditorFont: Codable { + /// Indicates whether or not to use a custom font + public var customFont: Bool = false + + /// The font size for the custom font + public var size: Int = 11 + + /// The name of the custom font + public var name: String = "SF-MonoMedium" + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferencesModel.swift b/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferencesModel.swift new file mode 100644 index 0000000000..d57b5c1543 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/Model/AppPreferencesModel.swift @@ -0,0 +1,79 @@ +// +// AppPreferencesModel.swift +// +// +// Created by Lukas Pistrol on 01.04.22. +// + +import Foundation +import SwiftUI + +/// The Preferences View Model. Accessible via the singleton "``AppPreferencesModel/shared``". +/// +/// **Usage:** +/// ```swift +/// @StateObject +/// private var prefs: AppPreferencesModel = .shared +/// ``` +public class AppPreferencesModel: ObservableObject { + + /// The publicly available singleton instance of ``AppPreferencesModel`` + public static let shared: AppPreferencesModel = .init() + + private init() { + self.preferences = loadPreferences() + } + + /// Published instance of the ``AppPreferences`` model. + /// + /// Changes are saved automatically. + @Published + public var preferences: AppPreferences! { + didSet { + try? savePreferences() + objectWillChange.send() + } + } + + /// Load and construct ``AppPreferences`` model from + /// `~/.codeedit/preferences.json` + private func loadPreferences() -> AppPreferences { + if !filemanager.fileExists(atPath: baseURL.path) { + let codeEditURL = filemanager + .homeDirectoryForCurrentUser + .appendingPathComponent(".codeedit", isDirectory: true) + try? filemanager.createDirectory(at: codeEditURL, withIntermediateDirectories: false) + return .init() + } + + guard let json = try? Data(contentsOf: baseURL), + let prefs = try? JSONDecoder().decode(AppPreferences.self, from: json) + else { + return .init() + } + return prefs + } + + /// Save``AppPreferences`` model to + /// `~/.codeedit/preferences.json` + private func savePreferences() throws { + let data = try JSONEncoder().encode(preferences) + let json = try JSONSerialization.jsonObject(with: data) + let prettyJSON = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) + try prettyJSON.write(to: baseURL, options: .atomic) + } + + /// Default instance of the `FileManager` + private let filemanager = FileManager.default + + /// The URL of the `preferences.json` settings file. + /// + /// `~/.codeedit/preferences.json` + private var baseURL: URL { + return filemanager + .homeDirectoryForCurrentUser + .appendingPathComponent(".codeedit", isDirectory: true) + .appendingPathComponent("preferences") + .appendingPathExtension("json") + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/Model/Appearances.swift b/CodeEditModules/Modules/AppPreferences/src/Model/Appearances.swift deleted file mode 100644 index 035e5c8c06..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/Model/Appearances.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Appearances.swift -// CodeEdit -// -// Created by Pavel Kasila on 17.03.22. -// - -import SwiftUI - -public enum Appearances: String, CaseIterable, Hashable { - case system - case light - case dark - - public func applyAppearance() { - switch self { - case .system: - NSApp.appearance = nil - - case .dark: - NSApp.appearance = .init(named: .darkAqua) - - case .light: - NSApp.appearance = .init(named: .aqua) - } - } - - public static let `default` = Appearances.system - public static let storageKey = "appearance" -} diff --git a/CodeEditModules/Modules/AppPreferences/src/Model/FileIconStyle.swift b/CodeEditModules/Modules/AppPreferences/src/Model/FileIconStyle.swift deleted file mode 100644 index b72c081c3f..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/Model/FileIconStyle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// FileIconStyle.swift -// CodeEdit -// -// Created by Lukas Pistrol on 18.03.22. -// - -import Foundation - -public enum FileIconStyle: String, CaseIterable, Hashable { - case color - case monochrome - - public static let `default` = FileIconStyle.color - public static let storageKey = "fileIconStyle" -} diff --git a/CodeEditModules/Modules/AppPreferences/src/Model/ReopenBehavior.swift b/CodeEditModules/Modules/AppPreferences/src/Model/ReopenBehavior.swift deleted file mode 100644 index 93f1283d19..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/Model/ReopenBehavior.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ReopenBehavior.swift -// CodeEdit -// -// Created by Pavel Kasila on 17.03.22. -// - -import Foundation - -public enum ReopenBehavior: String, CaseIterable, Hashable { - case welcome - case openPanel - case newDocument - - public static let `default` = ReopenBehavior.welcome - public static let storageKey = "behavior" -} diff --git a/CodeEditModules/Modules/AppPreferences/src/PreferencesGeneralView.swift b/CodeEditModules/Modules/AppPreferences/src/PreferencesGeneralView.swift deleted file mode 100644 index ea96370cc3..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/PreferencesGeneralView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 30.03.22. -// - -import SwiftUI -import Preferences - -public struct PreferencesGeneralView: View { - - @AppStorage(Appearances.storageKey) - private var appearance: Appearances = .default - - @AppStorage(ReopenBehavior.storageKey) - private var reopenBehavior: ReopenBehavior = .default - - @AppStorage(FileIconStyle.storageKey) - private var fileIconStyle: FileIconStyle = .default - - public init() {} - - public var body: some View { - Form { - Picker("Appearance:", selection: $appearance) { - Text("System") - .tag(Appearances.system) - Divider() - Text("Light") - .tag(Appearances.light) - Text("Dark") - .tag(Appearances.dark) - } - .onChange(of: appearance) { tag in - tag.applyAppearance() - } - .fixedSize() - - Picker("File Icon Style:", selection: $fileIconStyle) { - Text("Color") - .tag(FileIconStyle.color) - Text("Monochrome") - .tag(FileIconStyle.monochrome) - } - .pickerStyle(.radioGroup) - - Picker("Reopen Behavior:", selection: $reopenBehavior) { - Text("Welcome Screen") - .tag(ReopenBehavior.welcome) - Divider() - Text("Open Panel") - .tag(ReopenBehavior.openPanel) - Text("New Document") - .tag(ReopenBehavior.newDocument) - } - .fixedSize() - } - .frame(width: 844) - .padding(30) - } -} diff --git a/CodeEditModules/Modules/AppPreferences/src/PreferencesTextEditingView.swift b/CodeEditModules/Modules/AppPreferences/src/PreferencesTextEditingView.swift deleted file mode 100644 index e31890782b..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/PreferencesTextEditingView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 30.03.22. -// - -import SwiftUI -import CodeFile - -public struct PreferencesTextEditingView: View { - - @AppStorage(EditorTabWidth.storageKey) - var defaultTabWidth: Int = EditorTabWidth.default - - public init() {} - - public var body: some View { - Form { - HStack { - Stepper("Default Tab Width:", value: $defaultTabWidth, in: 2...8) - Text(String(defaultTabWidth)) - } - } - .frame(width: 844) - .padding(30) - } -} diff --git a/CodeEditModules/Modules/AppPreferences/src/PreferencesThemeView.swift b/CodeEditModules/Modules/AppPreferences/src/PreferencesThemeView.swift deleted file mode 100644 index 8313a0a0f0..0000000000 --- a/CodeEditModules/Modules/AppPreferences/src/PreferencesThemeView.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 30.03.22. -// - -import CodeFile -import FontPicker -import SwiftUI -import Preferences -import TerminalEmulator - -public struct PreferencesThemeView: View { - - @AppStorage(CodeFileView.Theme.storageKey) - var editorTheme: CodeFileView.Theme = .atelierSavannaAuto - - @AppStorage(TerminalShellType.storageKey) - var shellType: TerminalShellType = .default - - @AppStorage(TerminalFont.storageKey) - var terminalFontSelection: TerminalFont = .default - - @AppStorage(TerminalFontName.storageKey) - var terminalFontName: String = TerminalFontName.default - - @AppStorage(TerminalFontSize.storageKey) - var terminalFontSize: Int = TerminalFontSize.default - - @AppStorage(TerminalColorScheme.storageKey) - var terminalColorSchmeme: TerminalColorScheme = .default - - @StateObject - private var colors = AnsiColors.shared - - public init() {} - - public var body: some View { - TabView { - editor - .padding() - .tabItem { - Text("Editor") - } - terminal - .padding() - .tabItem { - Text("Terminal") - } - } - .frame(width: 844) - .padding(30) - } - - private var editor: some View { - Form { - Picker("Editor Theme", selection: $editorTheme) { - Text("Atelier Savanna (Auto)") - .tag(CodeFileView.Theme.atelierSavannaAuto) - Text("Atelier Savanna Dark") - .tag(CodeFileView.Theme.atelierSavannaDark) - Text("Atelier Savanna Light") - .tag(CodeFileView.Theme.atelierSavannaLight) - Text("Agate") - .tag(CodeFileView.Theme.agate) - Text("Ocean") - .tag(CodeFileView.Theme.ocean) - } - .fixedSize() - } - } - - private var terminal: some View { - Form { - Picker("Terminal Shell", selection: $shellType) { - Text("System Default") - .tag(TerminalShellType.auto) - Divider() - Text("ZSH") - .tag(TerminalShellType.zsh) - Text("Bash") - .tag(TerminalShellType.bash) - } - .fixedSize() - - Picker("Terminal Appearance", selection: $terminalColorSchmeme) { - Text("App Default") - .tag(TerminalColorScheme.auto) - Divider() - Text("Light") - .tag(TerminalColorScheme.light) - Text("Dark") - .tag(TerminalColorScheme.dark) - } - .fixedSize() - - Picker("Terminal Font", selection: $terminalFontSelection) { - Text("System Font") - .tag(TerminalFont.systemFont) - Divider() - Text("Custom") - .tag(TerminalFont.custom) - } - .fixedSize() - if terminalFontSelection == .custom { - FontPicker( - "\(terminalFontName) \(terminalFontSize)", - name: $terminalFontName, - size: $terminalFontSize - ) - } - Divider() - .frame(maxWidth: 400) - Section { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 0) { - ansiColorPicker($colors.colors[0]) - ansiColorPicker($colors.colors[1]) - ansiColorPicker($colors.colors[2]) - ansiColorPicker($colors.colors[3]) - ansiColorPicker($colors.colors[4]) - ansiColorPicker($colors.colors[5]) - ansiColorPicker($colors.colors[6]) - ansiColorPicker($colors.colors[7]) - Text("Normal").padding(.leading, 4) - } - HStack(spacing: 0) { - ansiColorPicker($colors.colors[8]) - ansiColorPicker($colors.colors[9]) - ansiColorPicker($colors.colors[10]) - ansiColorPicker($colors.colors[11]) - ansiColorPicker($colors.colors[12]) - ansiColorPicker($colors.colors[13]) - ansiColorPicker($colors.colors[14]) - ansiColorPicker($colors.colors[15]) - Text("Bright").padding(.leading, 4) - } - } - } - Button("Restore Defaults") { - AnsiColors.shared.resetDefault() - } - } - } - - private func ansiColorPicker(_ color: Binding) -> some View { - ColorPicker(selection: color, supportsOpacity: false) { } - .labelsHidden() - } -} diff --git a/CodeEditModules/Modules/AppPreferences/src/TerminalPreferences/PreferencesTerminalView.swift b/CodeEditModules/Modules/AppPreferences/src/TerminalPreferences/PreferencesTerminalView.swift new file mode 100644 index 0000000000..50798f55c6 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/TerminalPreferences/PreferencesTerminalView.swift @@ -0,0 +1,57 @@ +// +// PreferencesTerminalView.swift +// +// +// Created by Lukas Pistrol on 02.04.22. +// + +import SwiftUI +import Preferences +import FontPicker + +public struct PreferencesTerminalView: View { + + @StateObject + private var prefs: AppPreferencesModel = .shared + + public init() {} + + public var body: some View { + Form { + shellSelector + fontSelector + } + .frame(width: 844) + .padding(30) + } + + private var shellSelector: some View { + Picker("Shell:", selection: $prefs.preferences.terminal.shell) { + Text("System Default") + .tag(AppPreferences.TerminalShell.system) + Divider() + Text("ZSH") + .tag(AppPreferences.TerminalShell.zsh) + Text("Bash") + .tag(AppPreferences.TerminalShell.bash) + } + .fixedSize() + } + + @ViewBuilder + private var fontSelector: some View { + Picker("Font:", selection: $prefs.preferences.terminal.font.customFont) { + Text("System Font") + .tag(false) + Text("Custom") + .tag(true) + } + .fixedSize() + if prefs.preferences.terminal.font.customFont { + FontPicker( + "\(prefs.preferences.terminal.font.name) \(prefs.preferences.terminal.font.size)", + name: $prefs.preferences.terminal.font.name, size: $prefs.preferences.terminal.font.size + ) + } + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/TextEditingPreferences/PreferencesTextEditingView.swift b/CodeEditModules/Modules/AppPreferences/src/TextEditingPreferences/PreferencesTextEditingView.swift new file mode 100644 index 0000000000..39ffbb1e65 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/TextEditingPreferences/PreferencesTextEditingView.swift @@ -0,0 +1,48 @@ +// +// PreferencesTextEditingView.swift +// +// +// Created by Lukas Pistrol on 30.03.22. +// + +import SwiftUI +import FontPicker + +public struct PreferencesTextEditingView: View { + + @StateObject + private var prefs: AppPreferencesModel = .shared + + public init() {} + + public var body: some View { + Form { + fontSelector + HStack { + Stepper("Default Tab Width:", + value: $prefs.preferences.textEditing.defaultTabWidth, + in: 2...8) + Text(String(prefs.preferences.textEditing.defaultTabWidth)) + } + } + .frame(width: 844) + .padding(30) + } + + @ViewBuilder + private var fontSelector: some View { + Picker("Font:", selection: $prefs.preferences.textEditing.font.customFont) { + Text("System Font") + .tag(false) + Text("Custom") + .tag(true) + } + .fixedSize() + if prefs.preferences.textEditing.font.customFont { + FontPicker( + "\(prefs.preferences.textEditing.font.name) \(prefs.preferences.textEditing.font.size)", + name: $prefs.preferences.textEditing.font.name, size: $prefs.preferences.textEditing.font.size + ) + } + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/EditorThemeView.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/EditorThemeView.swift new file mode 100644 index 0000000000..69a3c904f3 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/EditorThemeView.swift @@ -0,0 +1,95 @@ +// +// EditorThemeView.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +struct EditorThemeView: View { + + @StateObject + private var themeModel: ThemeModel = .shared + + @StateObject + private var prefs: AppPreferencesModel = .shared + + var body: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .foregroundColor(Color(NSColor.controlBackgroundColor)) + if let selectedTheme = themeModel.selectedTheme, + let index = themeModel.themes.firstIndex(of: selectedTheme) { + VStack(alignment: .leading, spacing: 0) { + Toggle("Use theme background ", isOn: $prefs.preferences.theme.useThemeBackground) + .padding(.bottom, 20) + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].editor.text.swiftColor, + label: "Text") + PreferencesColorPicker($themeModel.themes[index].editor.insertionPoint.swiftColor, + label: "Cursor") + PreferencesColorPicker($themeModel.themes[index].editor.invisibles.swiftColor, + label: "Invisibles") + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].editor.background.swiftColor, + label: "Background") + PreferencesColorPicker($themeModel.themes[index].editor.lineHighlight.swiftColor, + label: "Current Line") + PreferencesColorPicker($themeModel.themes[index].editor.selection.swiftColor, + label: "Selection") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.bottom, 20) + Text("Syntax") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.bottom, 10) + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].editor.keywords.swiftColor, + label: "Keywords") + PreferencesColorPicker($themeModel.themes[index].editor.commands.swiftColor, + label: "Commands") + PreferencesColorPicker($themeModel.themes[index].editor.types.swiftColor, + label: "Types") + PreferencesColorPicker($themeModel.themes[index].editor.attributes.swiftColor, + label: "Attributes") + PreferencesColorPicker($themeModel.themes[index].editor.variables.swiftColor, + label: "Variables") + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].editor.values.swiftColor, + label: "Values") + PreferencesColorPicker($themeModel.themes[index].editor.numbers.swiftColor, + label: "Numbers") + PreferencesColorPicker($themeModel.themes[index].editor.strings.swiftColor, + label: "Strings") + PreferencesColorPicker($themeModel.themes[index].editor.characters.swiftColor, + label: "Characters") + PreferencesColorPicker($themeModel.themes[index].editor.comments.swiftColor, + label: "Comments") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(20) + } else { + Text("Select a Theme") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } +} + +struct EditorThemeView_Previews: PreviewProvider { + static var previews: some View { + EditorThemeView() + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/Theme.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/Theme.swift new file mode 100644 index 0000000000..0e289f789a --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/Theme.swift @@ -0,0 +1,384 @@ +// +// Theme.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +/// # Theme +/// +/// The model structure of themes for the editor & terminal emulator +public struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable { + + enum CodingKeys: String, CodingKey { + case author, license, distributionURL, name, editor, terminal, version + case appearance = "type" + case metadataDescription = "description" + } + + public static func == (lhs: Theme, rhs: Theme) -> Bool { + lhs.id == rhs.id + } + + /// The `id` of the theme + public var id: String { self.name } + + /// The `author` of the theme + public var author: String + + /// The `licence` of the theme + public var license: String + + /// A short `description` of the theme + public var metadataDescription: String + + /// An URL for reference + public var distributionURL: String + + /// The `display name` of the theme + public var name: String + + /// The `version` of the theme + public var version: String + + /// The ``ThemeType`` of the theme + /// + /// Appears as `"type"` in the `preferences.json` + public var appearance: ThemeType + + /// Editor colors of the theme + public var editor: EditorColors + + /// Terminal colors of the theme + public var terminal: TerminalColors + + public init( + editor: EditorColors, + terminal: TerminalColors, + author: String, + license: String, + metadataDescription: String, + distributionURL: String, + name: String, + appearance: ThemeType, + version: String + ) { + self.author = author + self.license = license + self.metadataDescription = metadataDescription + self.distributionURL = distributionURL + self.name = name + self.appearance = appearance + self.version = version + self.editor = editor + self.terminal = terminal + } +} + +public extension Theme { + /// The type of the theme + /// - **dark**: this is a theme for dark system appearance + /// - **light**: this is a theme for light system appearance + enum ThemeType: String, Codable, Hashable { + case dark + case light + } +} + +// MARK: - Attributes +public extension Theme { + /// Attributes of a certain field + /// + /// As of now it only includes the colors `hex` string and + /// an accessor for a `SwiftUI` `Color`. + struct Attributes: Codable, Equatable, Hashable, Loopable { + + /// The 24-bit hex string of the color (e.g. #123456) + public var color: String + + public init(color: String) { + self.color = color + } + + /// The `SwiftUI` color + public internal(set) var swiftColor: Color { + get { + Color(hex: color) + } + set { + self.color = newValue.hexString + } + } + } +} + +public extension Theme { + /// The editor colors of the theme + struct EditorColors: Codable, Hashable, Loopable { + public var text: Attributes + public var insertionPoint: Attributes + public var invisibles: Attributes + public var background: Attributes + public var lineHighlight: Attributes + public var selection: Attributes + public var keywords: Attributes + public var commands: Attributes + public var types: Attributes + public var attributes: Attributes + public var variables: Attributes + public var values: Attributes + public var numbers: Attributes + public var strings: Attributes + public var characters: Attributes + public var comments: Attributes + + /// Allows to look up properties by their name + /// + /// **Example:** + /// ```swift + /// editor["text"] + /// // equal to calling + /// editor.text + /// ``` + subscript(key: String) -> Attributes { + get { + switch key { + case "text": return self.text + case "insertionPoint": return self.insertionPoint + case "invisibles": return self.invisibles + case "background": return self.background + case "lineHighlight": return self.lineHighlight + case "selection": return self.selection + case "keywords": return self.keywords + case "commands": return self.commands + case "types": return self.types + case "attributes": return self.attributes + case "variables": return self.variables + case "values": return self.values + case "numbers": return self.numbers + case "strings": return self.strings + case "characters": return self.characters + case "comments": return self.comments + default: fatalError("Invalid key") + } + } + set { + switch key { + case "text": self.text = newValue + case "insertionPoint": self.insertionPoint = newValue + case "invisibles": self.invisibles = newValue + case "background": self.background = newValue + case "lineHighlight": self.lineHighlight = newValue + case "selection": self.selection = newValue + case "keywords": self.keywords = newValue + case "commands": self.commands = newValue + case "types": self.types = newValue + case "attributes": self.attributes = newValue + case "variables": self.variables = newValue + case "values": self.values = newValue + case "numbers": self.numbers = newValue + case "strings": self.strings = newValue + case "characters": self.characters = newValue + case "comments": self.comments = newValue + default: fatalError("Invalid key") + } + } + } + + public init( + text: Attributes, + insertionPoint: Attributes, + invisibles: Attributes, + background: Attributes, + lineHighlight: Attributes, + selection: Attributes, + keywords: Attributes, + commands: Attributes, + types: Attributes, + attributes: Attributes, + variables: Attributes, + values: Attributes, + numbers: Attributes, + strings: Attributes, + characters: Attributes, + comments: Attributes + ) { + self.text = text + self.insertionPoint = insertionPoint + self.invisibles = invisibles + self.background = background + self.lineHighlight = lineHighlight + self.selection = selection + self.keywords = keywords + self.commands = commands + self.types = types + self.attributes = attributes + self.variables = variables + self.values = values + self.numbers = numbers + self.strings = strings + self.characters = characters + self.comments = comments + } + } +} + +public extension Theme { + /// The terminal emulator colors of the theme + struct TerminalColors: Codable, Hashable, Loopable { + public var text: Attributes + public var boldText: Attributes + public var cursor: Attributes + public var background: Attributes + public var selection: Attributes + public var black: Attributes + public var red: Attributes + public var green: Attributes + public var yellow: Attributes + public var blue: Attributes + public var magenta: Attributes + public var cyan: Attributes + public var white: Attributes + public var brightBlack: Attributes + public var brightRed: Attributes + public var brightGreen: Attributes + public var brightYellow: Attributes + public var brightBlue: Attributes + public var brightMagenta: Attributes + public var brightCyan: Attributes + public var brightWhite: Attributes + + public var ansiColors: [String] { + [ + black.color, + red.color, + green.color, + yellow.color, + blue.color, + magenta.color, + cyan.color, + white.color, + brightBlack.color, + brightRed.color, + brightGreen.color, + brightYellow.color, + brightBlue.color, + brightMagenta.color, + brightCyan.color, + brightWhite.color, + ] + } + + // swiftlint:disable function_body_length + /// Allows to look up properties by their name + /// + /// **Example:** + /// ```swift + /// terminal["text"] + /// // equal to calling + /// terminal.text + /// ``` + subscript(key: String) -> Attributes { + get { + switch key { + case "text": return self.text + case "boldText": return self.boldText + case "cursor": return self.cursor + case "background": return self.background + case "selection": return self.selection + case "black": return self.black + case "red": return self.red + case "green": return self.green + case "yellow": return self.yellow + case "blue": return self.blue + case "magenta": return self.magenta + case "cyan": return self.cyan + case "white": return self.white + case "brightBlack": return self.brightBlack + case "brightRed": return self.brightRed + case "brightGreen": return self.brightGreen + case "brightYellow": return self.brightYellow + case "brightBlue": return self.brightBlue + case "brightMagenta": return self.brightMagenta + case "brightCyan": return self.brightCyan + case "brightWhite": return self.brightWhite + default: fatalError("Invalid key") + } + } + set { + switch key { + case "text": self.text = newValue + case "boldText": self.boldText = newValue + case "cursor": self.cursor = newValue + case "background": self.background = newValue + case "selection": self.selection = newValue + case "black": self.black = newValue + case "red": self.red = newValue + case "green": self.green = newValue + case "yellow": self.yellow = newValue + case "blue": self.blue = newValue + case "magenta": self.magenta = newValue + case "cyan": self.cyan = newValue + case "white": self.white = newValue + case "brightBlack": self.brightBlack = newValue + case "brightRed": self.brightRed = newValue + case "brightGreen": self.brightGreen = newValue + case "brightYellow": self.brightYellow = newValue + case "brightBlue": self.brightBlue = newValue + case "brightMagenta": self.brightMagenta = newValue + case "brightCyan": self.brightCyan = newValue + case "brightWhite": self.brightWhite = newValue + default: fatalError("Invalid key") + } + } + } + + init( + text: Attributes, + boldText: Attributes, + cursor: Attributes, + background: Attributes, + selection: Attributes, + black: Attributes, + red: Attributes, + green: Attributes, + yellow: Attributes, + blue: Attributes, + magenta: Attributes, + cyan: Attributes, + white: Attributes, + brightBlack: Attributes, + brightRed: Attributes, + brightGreen: Attributes, + brightYellow: Attributes, + brightBlue: Attributes, + brightMagenta: Attributes, + brightCyan: Attributes, + brightWhite: Attributes + ) { + self.text = text + self.boldText = boldText + self.cursor = cursor + self.background = background + self.selection = selection + self.black = black + self.red = red + self.green = green + self.yellow = yellow + self.blue = blue + self.magenta = magenta + self.cyan = cyan + self.white = white + self.brightBlack = brightBlack + self.brightRed = brightRed + self.brightGreen = brightGreen + self.brightYellow = brightYellow + self.brightBlue = brightBlue + self.brightMagenta = brightMagenta + self.brightCyan = brightCyan + self.brightWhite = brightWhite + } + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/ThemeModel.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/ThemeModel.swift new file mode 100644 index 0000000000..f9a52126b3 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/Model/ThemeModel.swift @@ -0,0 +1,273 @@ +// +// ThemeModel.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +/// The Theme View Model. Accessible via the singleton "``ThemeModel/shared``". +/// +/// **Usage:** +/// ```swift +/// @StateObject +/// private var themeModel: ThemeModel = .shared +/// ``` +public class ThemeModel: ObservableObject { + + public static let shared: ThemeModel = .init() + + /// The selected appearance in the sidebar. + /// - **0**: dark mode themes + /// - **1**: light mode themes + @Published + var selectedAppearance: Int = 0 + + /// The selected tab in the main section. + /// - **0**: Preview + /// - **1**: Editor + /// - **2**: Terminal + @Published + var selectedTab: Int = 1 + + /// An array of loaded ``Theme``. + @Published + public var themes: [Theme] = [] { + didSet { + saveThemes() + objectWillChange.send() + } + } + + /// The currently selected ``Theme``. + @Published + public var selectedTheme: Theme? { + didSet { + AppPreferencesModel.shared.preferences.theme.selectedTheme = selectedTheme?.name + } + } + + /// Only themes where ``Theme/appearance`` == ``Theme/ThemeType/dark`` + public var darkThemes: [Theme] { + themes.filter { $0.appearance == .dark } + } + + /// Only themes where ``Theme/appearance`` == ``Theme/ThemeType/light`` + public var lightThemes: [Theme] { + themes.filter { $0.appearance == .light } + } + + private init() { + do { + try loadThemes() + } catch { + print(error) + } + } + + /// 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 { + // 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 + } + + /// Loads all available themes from `~/.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 `~/.codeedit/.preferences.json` + /// they are applied to the loaded themes without altering the original + /// the files in `~/.codeedit/themes/`. + public func loadThemes() throws { + themes.removeAll() + let url = baseURL.appendingPathComponent("themes") + + var isDir: ObjCBool = false + + // check if a themes directory exists, otherwise create one + if !filemanager.fileExists(atPath: url.path, isDirectory: &isDir) { + try filemanager.createDirectory(at: url, withIntermediateDirectories: true) + } + + // get all filenames in themes folder that end with `.json` + let content = try filemanager.contentsOfDirectory(atPath: url.path).filter { $0.contains(".json") } + + // if the folder does not contain any themes create a default bundled theme and return + if content.isEmpty { + guard let defaultUrl = Bundle.main.url(forResource: "default-dark", withExtension: "json") else { + return + } + let json = try Data(contentsOf: defaultUrl) + let jsonObject = try JSONSerialization.jsonObject(with: json) + let prettyJSON = try JSONSerialization.data(withJSONObject: jsonObject, + options: .prettyPrinted) + + try prettyJSON.write(to: url.appendingPathComponent("Default (Dark).json")) + + return + } + + let prefs = AppPreferencesModel.shared.preferences + // load each theme from disk + try content.forEach { file in + let fileURL = url.appendingPathComponent(file) + var theme = try load(from: fileURL) + guard let terminalColors = try theme.terminal.allProperties() as? [String: Theme.Attributes], + let editorColors = try theme.editor.allProperties() as? [String: Theme.Attributes] + else { + print("error") + throw NSError() + } + 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 + } + } + } + self.themes.append(theme) + self.selectedTheme = self.themes.first { $0.name == prefs?.theme.selectedTheme } ?? self.themes.first + print("loaded themes") + } + } + + /// Removes all overrides of the given theme in + /// `~/.codeedit/preferences.json` + /// + /// After removing overrides, themes are reloaded + /// from `~/.codeedit/themes`. See ``loadThemes()`` + /// for more information. + /// + /// - Parameter theme: The theme to reset + public func reset(_ theme: Theme) { + AppPreferencesModel.shared.preferences.theme.overrides[theme.name] = [:] + do { + try self.loadThemes() + } catch { + print(error) + } + } + + /// Removes the given theme from `–/.codeedit/themes` + /// + /// After removing the theme, themes are reloaded + /// from `~/.codeedit/themes`. See ``loadThemes()`` + /// for more information. + /// + /// - Parameter theme: The theme to delete + public func delete(_ theme: Theme) { + let url = baseURL + .appendingPathComponent("themes") + .appendingPathComponent(theme.name) + .appendingPathExtension("json") + do { + try filemanager.removeItem(at: url) + AppPreferencesModel.shared.preferences.theme.overrides.removeValue(forKey: theme.name) + try self.loadThemes() + } catch { + print(error) + } + } + + /// Saves changes on theme properties to `overrides` + /// in `~/.codeedit/preferences.json`. + private func saveThemes() { + let url = baseURL.appendingPathComponent("themes") + themes.forEach { theme in + do { + let originalUrl = url.appendingPathComponent(theme.name).appendingPathExtension("json") + let originalData = try Data(contentsOf: originalUrl) + let originalTheme = try JSONDecoder().decode(Theme.self, from: originalData) + 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 { + throw NSError() + } + 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 + } + } + AppPreferencesModel.shared.preferences.theme.overrides[theme.name] = newAttr + + } catch { + print(error) + } + } + } + + /// Default instance of the `FileManager` + private let filemanager = FileManager.default + + /// The base folder url `~/.codeedit/` + private var baseURL: URL { + filemanager.homeDirectoryForCurrentUser.appendingPathComponent(".codeedit") + } +} + +/// Loopable protocol implements a method that will return all child +/// properties and their associated values of a `Type` +protocol Loopable { + func allProperties() throws -> [String: Any] +} + +extension Loopable { + + /// returns all child properties and their associated values of `self` + /// + /// **Example:** + /// ```swift + /// struct Author: Loopable { + /// var name: String = "Steve" + /// var books: Int = 4 + /// } + /// + /// let author = Author() + /// print(author.allProperties()) + /// + /// // returns + /// ["name": "Steve", "books": 4] + /// ``` + func allProperties() throws -> [String: Any] { + var result: [String: Any] = [:] + + let mirror = Mirror(reflecting: self) + + guard let style = mirror.displayStyle, style == .struct || style == .class else { + throw NSError() + } + + for (property, value) in mirror.children { + guard let property = property else { + continue + } + + result[property] = value + } + + return result + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/PreviewThemeView.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/PreviewThemeView.swift new file mode 100644 index 0000000000..d8f7b8a466 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/PreviewThemeView.swift @@ -0,0 +1,36 @@ +// +// PreviewThemeView.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI + +struct PreviewThemeView: View { + + @StateObject + private var themeModel: ThemeModel = .shared + + var body: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .foregroundColor(Color(NSColor.controlBackgroundColor)) + if themeModel.selectedTheme == nil { + Text("Select a Theme") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + Text("Preview is not yet implemented") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } +} + +struct PreviewThemeView_Previews: PreviewProvider { + static var previews: some View { + PreviewThemeView() + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/TerminalThemeView.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/TerminalThemeView.swift new file mode 100644 index 0000000000..32e5bb4f32 --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/TerminalThemeView.swift @@ -0,0 +1,116 @@ +// +// TerminalThemeView.swift +// +// +// Created by Lukas Pistrol on 31.03.22. +// + +import SwiftUI +import FontPicker + +struct TerminalThemeView: View { + @StateObject + private var prefs: AppPreferencesModel = .shared + + @StateObject + private var themeModel: ThemeModel = .shared + + init() {} + + var body: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .foregroundColor(Color(NSColor.controlBackgroundColor)) + if themeModel.selectedTheme == nil { + Text("Select a Theme") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + VStack(alignment: .leading, spacing: 15) { + topToggles + colorSelector + ansiColorSelector + } + .padding(20) + } + } + } + + private var topToggles: some View { + VStack(alignment: .leading) { + Toggle("Always use dark terminal appearance", isOn: $prefs.preferences.terminal.darkAppearance) + } + } + + private var colorSelector: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Background & Text") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.bottom, 10) + HStack(alignment: .top, spacing: 0) { + if let selectedTheme = themeModel.selectedTheme, + let index = themeModel.themes.firstIndex(of: selectedTheme) { + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].terminal.text.swiftColor, + label: "Text") + PreferencesColorPicker($themeModel.themes[index].terminal.boldText.swiftColor, + label: "Bold Text") + PreferencesColorPicker($themeModel.themes[index].terminal.cursor.swiftColor, + label: "Cursor") + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + PreferencesColorPicker($themeModel.themes[index].terminal.background.swiftColor, + label: "Background") + PreferencesColorPicker($themeModel.themes[index].terminal.selection.swiftColor, + label: "Selection") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var ansiColorSelector: some View { + VStack(alignment: .leading, spacing: 5) { + if let selectedTheme = themeModel.selectedTheme, + let index = themeModel.themes.firstIndex(of: selectedTheme) { + Text("ANSI Colors") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.bottom, 5) + HStack(spacing: 5) { + PreferencesColorPicker($themeModel.themes[index].terminal.black.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.red.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.green.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.yellow.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.blue.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.magenta.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.cyan.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.white.swiftColor) + Text("Normal").padding(.leading, 4) + } + HStack(spacing: 5) { + PreferencesColorPicker($themeModel.themes[index].terminal.brightBlack.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightRed.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightGreen.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightYellow.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightBlue.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightMagenta.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightCyan.swiftColor) + PreferencesColorPicker($themeModel.themes[index].terminal.brightWhite.swiftColor) + Text("Bright").padding(.leading, 4) + } + .padding(.top, 5) + } + } + } +} + +struct TerminalThemeView_Previews: PreviewProvider { + static var previews: some View { + TerminalThemeView() + .preferredColorScheme(.dark) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreferencesView.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreferencesView.swift new file mode 100644 index 0000000000..2caad2e58e --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreferencesView.swift @@ -0,0 +1,228 @@ +// +// ThemePreferencesView.swift +// +// +// Created by Lukas Pistrol on 30.03.22. +// + +import FontPicker +import SwiftUI +import Preferences + +@available(macOS 12, *) +public struct ThemePreferencesView: View { + @Environment(\.colorScheme) + var colorScheme + + @ObservedObject + private var themeModel: ThemeModel = .shared + + @State + private var listView: Bool = false + + public init() {} + + public var body: some View { + VStack(spacing: 20) { + frame + HStack(alignment: .center) { + Toggle("Automatically change theme based on system appearance", isOn: .constant(false)) + .disabled(true) + .help("Not yet implemented") + Spacer() + Button("Get More Themes...") {} + .disabled(true) + .help("Not yet implemented") + HelpButton {} + .disabled(true) + .help("Not yet implemented") + } + } + .frame(width: 872) + .padding() + .onAppear { + try? themeModel.loadThemes() + } + } + + private var frame: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 1) { + sidebar + settingsContent + } + .padding(1) + .background(Rectangle().foregroundColor(Color(NSColor.separatorColor))) + .frame(height: 468) + } + } + + private var sidebar: some View { + VStack(spacing: 1) { + toolbar { + let options = [ + "Dark Mode", + "Light Mode" + ] + SegmentedControl($themeModel.selectedAppearance, options: options) + } + if listView { + sidebarLisView + } else { + sidebarScrollView + } + toolbar { + sidebarBottomToolbar + } + .frame(height: 27) + } + .frame(width: 320) + } + + private var sidebarLisView: some View { + List(selection: $themeModel.selectedTheme) { + ForEach(themeModel.selectedAppearance == 0 ? themeModel.darkThemes : themeModel.lightThemes) { theme in + Button(theme.name) { themeModel.selectedTheme = theme } + .buttonStyle(.plain) + .tag(theme) + .contextMenu { + Button("Reset Theme") { + themeModel.reset(theme) + } + Divider() + Button("Delete Theme", role: .destructive) { + themeModel.delete(theme) + } + .disabled(themeModel.themes.count <= 1) + } + } + } + } + + private var sidebarScrollView: some View { + ScrollView { + let grid: [GridItem] = .init( + repeating: .init(.fixed(130), spacing: 20, alignment: .center), + count: 2 + ) + LazyVGrid(columns: grid, + alignment: .center, + spacing: 20) { + ForEach(themeModel.selectedAppearance == 0 ? themeModel.darkThemes : themeModel.lightThemes) { theme in + ThemePreviewIcon( + theme, + selection: $themeModel.selectedTheme, + colorScheme: themeModel.selectedAppearance == 0 ? .dark : .light + ) + .transition(.opacity) + .contextMenu { + Button("Reset Theme") { + themeModel.reset(theme) + } + Divider() + Button("Delete Theme", role: .destructive) { + themeModel.delete(theme) + } + .disabled(themeModel.themes.count <= 1) + } + } + } + .padding(.vertical, 20) + } + .background(Color(NSColor.controlBackgroundColor)) + } + + private var sidebarBottomToolbar: some View { + HStack { + Button {} label: { + Image(systemName: "plus") + } + .disabled(true) + .help("Not yet implemented") + .buttonStyle(.plain) + Button { + themeModel.delete(themeModel.selectedTheme!) + } label: { + Image(systemName: "minus") + } + .disabled(themeModel.selectedTheme == nil || themeModel.themes.count <= 1) + .help("Delete selected theme") + .buttonStyle(.plain) + Divider() + Button { try? themeModel.loadThemes() } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + Spacer() + Button { + listView = true + } label: { + Image(systemName: "list.dash") + .foregroundColor(listView ? .accentColor : .primary) + } + .buttonStyle(.plain) + Button { + listView = false + } label: { + Image(systemName: "square.grid.2x2") + .symbolVariant(listView ? .none : .fill) + .foregroundColor(listView ? .primary : .accentColor) + } + .buttonStyle(.plain) + } + } + + private var settingsContent: some View { + VStack(spacing: 1) { + let options = [ + "Preview", + "Editor", + "Terminal" + ] + toolbar { + SegmentedControl($themeModel.selectedTab, options: options) + } + switch themeModel.selectedTab { + case 1: + EditorThemeView() + case 2: + TerminalThemeView() + default: + PreviewThemeView() + } + toolbar { + HStack { + Spacer() + Button {} label: { + Image(systemName: "info.circle") + } + .buttonStyle(.plain) + } + } + } + } + + private func toolbar( + height: Double = 27, + bgColor: Color = Color(NSColor.controlBackgroundColor), + @ViewBuilder content: @escaping () -> T + ) -> some View { + ZStack { + Rectangle() + .foregroundColor(bgColor) + HStack { + content() + .padding(.horizontal, 8) + } + } + .frame(height: height) + } +} + +@available(macOS 12, *) +struct PrefsThemes_Previews: PreviewProvider { + static var previews: some View { + ThemePreferencesView() + .preferredColorScheme(.light) + } +} diff --git a/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreviewIcon.swift b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreviewIcon.swift new file mode 100644 index 0000000000..907f6f06be --- /dev/null +++ b/CodeEditModules/Modules/AppPreferences/src/ThemePreferences/ThemePreviewIcon.swift @@ -0,0 +1,277 @@ +// +// ThemePreviewIcon.swift +// +// +// Created by Lukas Pistrol on 30.03.22. +// + +import SwiftUI + +@available(macOS 12, *) +struct ThemePreviewIcon: View { + init(_ theme: Theme, selection: Binding, colorScheme: ColorScheme) { + self.theme = theme + self._selection = selection + self.colorScheme = colorScheme + } + + var theme: Theme + + @Binding + var selection: Theme? + + var colorScheme: ColorScheme + + var body: some View { + VStack { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 3) + .foregroundColor(Color(hex: colorScheme == .dark ? 0x4c4c4c : 0xbbbbbb)) + + HStack(spacing: 1) { + sidebar + content + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + .padding(1) + } + .padding(1) + .frame(width: 130, height: 88) + .shadow(color: Color(NSColor.shadowColor).opacity(0.1), radius: 8, x: 0, y: 2) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(lineWidth: 2) + .foregroundColor(selection == theme ? .accentColor : .clear) + } + Text(theme.name) + .font(.subheadline) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .foregroundColor(selection == theme ? .white : .primary) + .background(Capsule().foregroundColor(selection == theme ? .accentColor : .clear)) + } + .help(theme.metadataDescription) + .onTapGesture { + withAnimation(.interactiveSpring()) { + self.selection = theme + } + } + } + + private var sidebar: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .foregroundColor(Color(hex: colorScheme == .dark ? 0x383838 : 0xd0d0d0)) + .frame(width: 36) + + HStack(spacing: 1.5) { + Circle().foregroundColor(.red) + Circle().foregroundColor(Color(hex: 0xf9b82d)) + Circle().foregroundColor(.green) + } + .frame(width: 12, height: 3) + .padding(4) + } + } + + private var content: some View { + VStack(spacing: 0) { + Rectangle() + .foregroundColor(Color(hex: colorScheme == .dark ? 0x2b2b2b : 0xe0e0e0)) + .frame(height: 10) + Rectangle() + .foregroundColor(theme.editor.background.swiftColor) + .overlay(alignment: .topLeading) { + codeWindow + } + } + } + + private var codeWindow: some View { + VStack(alignment: .leading, spacing: 4) { + block1 + block2 + block3 + block4 + block5 + } + .padding(.top, 6) + .padding(.leading, 6) + } + + private var block1: some View { + codeStatement(theme.editor.comments.color, length: 25) + } + + private var block2: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 1) { + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.variables.color, length: 6) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.values.color, length: 8) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.values.color, length: 8) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.strings.color, length: 7) + } + HStack(spacing: 1) { + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.variables.color, length: 8) + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.strings.color, length: 12) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.strings.color, length: 14) + codeStatement(theme.editor.text.color, length: 1) + } + } + } + + private var block3: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 1) { + codeStatement(theme.editor.keywords.color, length: 4) + codeStatement(theme.editor.variables.color, length: 8) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(3) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(5) + codeStatement(theme.editor.text.color, length: 3) + codeStatement(theme.editor.numbers.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(5) + codeStatement(theme.editor.text.color, length: 6) + codeStatement(theme.editor.strings.color, length: 7) + codeStatement(theme.editor.strings.color, length: 5) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(5) + codeStatement(theme.editor.text.color, length: 5) + codeStatement(theme.editor.keywords.color, length: 5) + } + HStack(spacing: 1) { + codeSpace(3) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeStatement(theme.editor.text.color, length: 2) + } + } + } + + private var block4: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 1) { + codeStatement(theme.editor.keywords.color, length: 6) + codeStatement(theme.editor.keywords.color, length: 7) + codeStatement(theme.editor.commands.color, length: 8) + codeStatement(theme.editor.values.color, length: 3) + codeStatement(theme.editor.text.color, length: 2) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(3) + codeStatement(theme.editor.keywords.color, length: 4) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.variables.color, length: 5) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.values.color, length: 8) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.types.color, length: 8) + codeStatement(theme.editor.text.color, length: 2) + } + } + } + + private var block5: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 1) { + codeSpace(3) + codeStatement(theme.editor.keywords.color, length: 4) + codeStatement(theme.editor.variables.color, length: 10) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.types.color, length: 11) + codeStatement(theme.editor.text.color, length: 3) + codeStatement(theme.editor.keywords.color, length: 2) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(5) + codeStatement(theme.editor.attributes.color, length: 8) + codeStatement(theme.editor.text.color, length: 2) + codeStatement(theme.editor.variables.color, length: 5) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.keywords.color, length: 2) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(7) + codeStatement(theme.editor.keywords.color, length: 3) + codeStatement(theme.editor.variables.color, length: 12) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(9) + codeStatement(theme.editor.text.color, length: 3) + codeStatement(theme.editor.characters.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(9) + codeStatement(theme.editor.text.color, length: 1) + } + HStack(spacing: 1) { + codeSpace(9) + codeStatement(theme.editor.text.color, length: 3) + codeStatement(theme.editor.attributes.color, length: 5) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.types.color, length: 6) + codeStatement(theme.editor.text.color, length: 1) + codeStatement(theme.editor.numbers.color, length: 1) + codeStatement(theme.editor.text.color, length: 1) + } + } + } + + private func codeStatement(_ color: String, length: Double) -> some View { + Rectangle() + .foregroundColor(Color(hex: color)) + .frame(width: length, height: 2) + } + + private func codeSpace(_ length: Double) -> some View { + Rectangle() + .foregroundColor(.clear) + .frame(width: length-1, height: 2) + } +} + + @available(macOS 12, *) + struct ThemePreviewIcon_Previews: PreviewProvider { + static var previews: some View { + ThemePreviewIcon(ThemeModel.shared.themes.first!, + selection: .constant(ThemeModel.shared.themes.first), + colorScheme: .light) + .preferredColorScheme(.light) + + ThemePreviewIcon(ThemeModel.shared.themes.last!, + selection: .constant(nil), + colorScheme: .dark) + .preferredColorScheme(.dark) + } + } diff --git a/CodeEditModules/Modules/CodeFile/src/Model/EditorTabWidth.swift b/CodeEditModules/Modules/CodeFile/src/Model/EditorTabWidth.swift deleted file mode 100644 index de10a136ed..0000000000 --- a/CodeEditModules/Modules/CodeFile/src/Model/EditorTabWidth.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -public enum EditorTabWidth { - static public let `default`: Int = 4 - static public let storageKey = "defaultTabWidth" -} diff --git a/CodeEditModules/Modules/CodeFile/src/TextView/CodeEditorTextView.swift b/CodeEditModules/Modules/CodeFile/src/TextView/CodeEditorTextView.swift index f805527c3a..fe4ef91155 100644 --- a/CodeEditModules/Modules/CodeFile/src/TextView/CodeEditorTextView.swift +++ b/CodeEditModules/Modules/CodeFile/src/TextView/CodeEditorTextView.swift @@ -7,10 +7,12 @@ import Cocoa import SwiftUI +import AppPreferences public class CodeEditorTextView: NSTextView { - @AppStorage(EditorTabWidth.storageKey) - private var tabWidth: Int = EditorTabWidth.default + + @StateObject + private var prefs: AppPreferencesModel = .shared init( textContainer container: NSTextContainer? @@ -84,7 +86,7 @@ public class CodeEditorTextView: NSTextView { super.insertText( String( repeating: " ", - count: tabWidth + count: prefs.preferences.textEditing.defaultTabWidth ), replacementRange: selectedRange() ) diff --git a/CodeEditModules/Modules/FontPicker/src/FontPickerView.swift b/CodeEditModules/Modules/FontPicker/src/FontPickerView.swift index adf9039ae8..ec3d792c4c 100644 --- a/CodeEditModules/Modules/FontPicker/src/FontPickerView.swift +++ b/CodeEditModules/Modules/FontPicker/src/FontPickerView.swift @@ -66,8 +66,7 @@ public struct FontPicker: View { NSFontPanel.shared.setPanelFont(self.font, isMultiple: false) NSFontPanel.shared.orderBack(nil) } label: { - Image(systemName: "textformat") - .imageScale(.large) + Text("Select...") } .fixedSize() } diff --git a/CodeEditModules/Modules/StatusBar/src/StatusBarItems/StatusBarIndentSelector.swift b/CodeEditModules/Modules/StatusBar/src/StatusBarItems/StatusBarIndentSelector.swift index b9b9c1bd9a..5529fa322a 100644 --- a/CodeEditModules/Modules/StatusBar/src/StatusBarItems/StatusBarIndentSelector.swift +++ b/CodeEditModules/Modules/StatusBar/src/StatusBarItems/StatusBarIndentSelector.swift @@ -5,6 +5,7 @@ // Created by Lukas Pistrol on 22.03.22. // +import AppPreferences import SwiftUI import CodeFile @@ -13,8 +14,8 @@ internal struct StatusBarIndentSelector: View { @ObservedObject private var model: StatusBarModel - @AppStorage(EditorTabWidth.storageKey) - private var tabWidth: Int = EditorTabWidth.default + @StateObject + private var prefs: AppPreferencesModel = .shared internal init(model: StatusBarModel) { self.model = model @@ -32,14 +33,14 @@ internal struct StatusBarIndentSelector: View { Divider() - Picker("Tab Width", selection: $tabWidth) { + Picker("Tab Width", selection: $prefs.preferences.textEditing.defaultTabWidth) { ForEach(2..<9) { index in Text("\(index) Spaces") .tag(index) } } } label: { - Text("\(tabWidth) Spaces") + Text("\(prefs.preferences.textEditing.defaultTabWidth) Spaces") .font(model.toolbarFont) } .menuStyle(.borderlessButton) diff --git a/CodeEditModules/Modules/TerminalEmulator/src/Extensions/SwiftTerm+Color+Init.swift b/CodeEditModules/Modules/TerminalEmulator/src/Extensions/SwiftTerm+Color+Init.swift index 9eb40ffdb8..625ea22ea5 100644 --- a/CodeEditModules/Modules/TerminalEmulator/src/Extensions/SwiftTerm+Color+Init.swift +++ b/CodeEditModules/Modules/TerminalEmulator/src/Extensions/SwiftTerm+Color+Init.swift @@ -5,6 +5,7 @@ // Created by Lukas Pistrol on 24.03.22. // +import Foundation import SwiftTerm internal extension SwiftTerm.Color { @@ -31,4 +32,12 @@ internal extension SwiftTerm.Color { let blue = UInt8(hex & 0xFF) self.init(iRed: red, green: green, blue: blue) } + + /// 0x000000 - 0xFFFFFF + convenience init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + self.init(hex: Int(int)) + } } diff --git a/CodeEditModules/Modules/TerminalEmulator/src/Model/AnsiColors.swift b/CodeEditModules/Modules/TerminalEmulator/src/Model/AnsiColors.swift deleted file mode 100644 index e123725b4f..0000000000 --- a/CodeEditModules/Modules/TerminalEmulator/src/Model/AnsiColors.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 23.03.22. -// - -import SwiftUI - -/// Wrapper for storing `Color` objects as 24-bit integer in UserDefaults -public class AnsiColors: ObservableObject { - // public static let `default` = - public static let storageKey: String = "ANSIColors" - public static let shared: AnsiColors = .init() - - public init() { - guard let loadColors = UserDefaults.standard.object(forKey: Self.storageKey) as? [Int] else { - resetDefault() - return - } - self.mappedColors = loadColors - } - - public func resetDefault() { - colors.removeAll() - colors.append(Color(red: 0.000, green: 0.000, blue: 0.000)) - colors.append(Color(red: 0.600, green: 0.000, blue: 0.000)) - colors.append(Color(red: 0.000, green: 0.651, blue: 0.004)) - colors.append(Color(red: 0.600, green: 0.600, blue: 0.000)) - colors.append(Color(red: 0.000, green: 0.031, blue: 0.702)) - colors.append(Color(red: 0.702, green: 0.020, blue: 0.702)) - colors.append(Color(red: 0.000, green: 0.647, blue: 0.702)) - colors.append(Color(red: 0.749, green: 0.749, blue: 0.749)) - colors.append(Color(red: 0.400, green: 0.400, blue: 0.400)) - colors.append(Color(red: 0.902, green: 0.000, blue: 0.004)) - colors.append(Color(red: 0.004, green: 0.851, blue: 0.000)) - colors.append(Color(red: 0.902, green: 0.898, blue: 0.012)) - colors.append(Color(red: 0.000, green: 0.063, blue: 1.000)) - colors.append(Color(red: 0.902, green: 0.035, blue: 0.902)) - colors.append(Color(red: 0.008, green: 0.902, blue: 0.898)) - colors.append(Color(red: 0.902, green: 0.902, blue: 0.902)) - } - - @Published - public var mappedColors: [Int] = [] { - didSet { - UserDefaults.standard.set(mappedColors, forKey: AnsiColors.storageKey) - } - } - - public var colors: [Color] { - get { - let mapped = self.mappedColors.map { Color(hex: $0) } - return mapped - } - set { - self.mappedColors = newValue.map { $0.hex } - } - - } -} - -public extension Color { - init(hex: Int) { - let red = (hex >> 16) & 0xFF - let green = (hex >> 8) & 0xFF - let blue = hex & 0xFF - self.init(.sRGB, red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, opacity: 1) - } - - var hex: Int { - guard let components = cgColor?.components, components.count >= 3 else { return 0 } - - let red = Int(components[0] * 255) << 16 - let green = Int(components[1] * 255) << 8 - let blue = Int(components[2] * 255) - - return red | green | blue - } -} diff --git a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalColorScheme.swift b/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalColorScheme.swift deleted file mode 100644 index c4b10e1ac8..0000000000 --- a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalColorScheme.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 26.03.22. -// - -import Foundation - -public enum TerminalColorScheme: String, CaseIterable, Hashable { - case auto - case light - case dark - - static public let `default` = TerminalColorScheme.auto - static public let storageKey = "terminalColorScheme" -} diff --git a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalFont.swift b/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalFont.swift deleted file mode 100644 index 78a19d10d1..0000000000 --- a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalFont.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// File.swift -// -// -// Created by Lukas Pistrol on 23.03.22. -// - -import Foundation - -public enum TerminalFont: String, CaseIterable, Hashable { - case systemFont - case custom - - static public let `default`: TerminalFont = .systemFont - static public let storageKey = "terminalFontSelection" -} - -public enum TerminalFontName { - static public let `default`: String = "SFMono-Medium" - static public let storageKey = "terminalFontName" -} - -public enum TerminalFontSize { - static public let `default`: Int = 11 - static public let storageKey = "terminalFontSize" -} diff --git a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalShellType.swift b/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalShellType.swift deleted file mode 100644 index 940732056b..0000000000 --- a/CodeEditModules/Modules/TerminalEmulator/src/Model/TerminalShellType.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// TerminalShellType.swift -// CodeEdit -// -// Created by Lukas Pistrol on 22.03.22. -// - -import Foundation - -public enum TerminalShellType: String, CaseIterable, Hashable { - case bash - case zsh - case auto - - static public let `default` = TerminalShellType.auto - static public let storageKey = "terminalShellType" -} diff --git a/CodeEditModules/Modules/TerminalEmulator/src/TerminalEmulatorView.swift b/CodeEditModules/Modules/TerminalEmulator/src/TerminalEmulatorView.swift index 49566bc5ae..8435002430 100644 --- a/CodeEditModules/Modules/TerminalEmulator/src/TerminalEmulatorView.swift +++ b/CodeEditModules/Modules/TerminalEmulator/src/TerminalEmulatorView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftTerm +import AppPreferences /// # TerminalEmulatorView /// @@ -16,23 +17,11 @@ import SwiftTerm /// for use in SwiftUI. /// public struct TerminalEmulatorView: NSViewRepresentable { - @AppStorage(TerminalShellType.storageKey) - var shellType: TerminalShellType = .default - - @AppStorage(TerminalFont.storageKey) - var terminalFontSelection: TerminalFont = .default - - @AppStorage(TerminalFontName.storageKey) - var terminalFontName: String = TerminalFontName.default - - @AppStorage(TerminalFontSize.storageKey) - var terminalFontSize: Int = TerminalFontSize.default - - @AppStorage(TerminalColorScheme.storageKey) - var terminalColorSchmeme: TerminalColorScheme = .default + @StateObject + private var prefs: AppPreferencesModel = .shared @StateObject - private var ansiColors: AnsiColors = .shared + private var themeModel: ThemeModel = .shared internal static var lastTerminal: LocalProcessTerminalView? @@ -42,10 +31,13 @@ public struct TerminalEmulatorView: NSViewRepresentable { private let systemFont: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) private var font: NSFont { - if terminalFontSelection == .systemFont { + if !prefs.preferences.terminal.font.customFont { return systemFont } - return NSFont(name: terminalFontName, size: CGFloat(terminalFontSize)) ?? systemFont + return NSFont( + name: prefs.preferences.terminal.font.name, + size: CGFloat(prefs.preferences.terminal.font.size) + ) ?? systemFont } private var url: URL @@ -72,8 +64,8 @@ public struct TerminalEmulatorView: NSViewRepresentable { /// return String(cString: pwd.pw_shell) /// ``` private func getShell() -> String { - switch shellType { - case .auto: + switch prefs.preferences.terminal.shell { + case .system: return autoDetectDefaultShell() case .bash: return "/bin/bash" @@ -99,17 +91,22 @@ public struct TerminalEmulatorView: NSViewRepresentable { /// Returns the mapped array of `SwiftTerm.Color` objects of ANSI Colors private var colors: [SwiftTerm.Color] { - return ansiColors.mappedColors.map { SwiftTerm.Color(hex: $0) } + if let selectedTheme = themeModel.selectedTheme, + let index = themeModel.themes.firstIndex(of: selectedTheme) { + return themeModel.themes[index].terminal.ansiColors.map { color in + SwiftTerm.Color(hex: color) + } + } + return [] } /// returns a `NSAppearance` based on the user setting of the terminal appearance, /// `nil` if app default is not overriden private var colorAppearance: NSAppearance? { - switch terminalColorSchmeme { - case .auto: return nil - case .light: return .init(named: .aqua) - case .dark: return .init(named: .darkAqua) + if prefs.preferences.terminal.darkAppearance { + return .init(named: .darkAqua) } + return nil } /// Inherited from NSViewRepresentable.makeNSView(context:). diff --git a/CodeEditModules/Package.swift b/CodeEditModules/Package.swift index 1518bf9ae3..7ed0d74948 100644 --- a/CodeEditModules/Package.swift +++ b/CodeEditModules/Package.swift @@ -100,6 +100,7 @@ let package = Package( name: "CodeFile", dependencies: [ "Highlightr", + "AppPreferences" ], path: "Modules/CodeFile/src" ), @@ -155,7 +156,10 @@ let package = Package( ), .target( name: "TerminalEmulator", - dependencies: ["SwiftTerm"], + dependencies: [ + "SwiftTerm", + "AppPreferences" + ], path: "Modules/TerminalEmulator/src" ), .target( @@ -177,9 +181,7 @@ let package = Package( name: "AppPreferences", dependencies: [ "Preferences", - "CodeFile", "FontPicker", - "TerminalEmulator", ], path: "Modules/AppPreferences/src" ),