HuggingChat-Mac/Settings/AppearanceSettings.swift (56 lines of code) (raw):
//
// AppearanceSettings.swift
// HuggingChat-Mac
//
// Created by Cyril Zakka on 9/2/24.
//
import SwiftUI
import Highlightr
import MarkdownView
enum Appearance: String {
case light, dark, auto
var iconName: String {
switch self {
case .light:
return "ThemeIconLight"
case .dark:
return "ThemeIconDark"
case .auto:
return "ThemeIconAuto"
}
}
}
enum AccentColorOption: String, CaseIterable {
case blue, purple, pink, red, orange, yellow, green, gray
var color: Color {
switch self {
case .blue: return .blue
case .purple: return .purple
case .pink: return .pink
case .red: return .red
case .orange: return .orange
case .yellow: return .yellow
case .green: return .green
case .gray: return .gray
}
}
}
struct AppearanceSettings: View {
@Environment(ModelManager.self) private var modelManager
@Environment(\.colorScheme) private var colorScheme
@AppStorage("appearance") private var appearance: Appearance = .auto
@AppStorage("inlineCodeHiglight") private var inlineCodeHiglight: AccentColorOption = .blue
@AppStorage("lightCodeBlockTheme") private var lightCodeBlockTheme: String = "xcode"
@AppStorage("darkCodeBlockTheme") private var darkCodeBlockTheme: String = "monokai-sublime"
// Themes
@AppStorage("isPixelPalsUnlocked") var isPixelPalsUnlocked: Bool = false
@AppStorage("isChromeDinoUnlocked") var isChromeDinoUnlocked: Bool = false
@AppStorage("isAppleClassicUnlocked") var isAppleClassicUnlocked: Bool = false
var sortedThemes: [ThemeNames] {
ThemeNames.allCases.sorted { firstTheme, secondTheme in
if isThemeUnlocked(firstTheme) == isThemeUnlocked(secondTheme) {
return firstTheme.rawValue < secondTheme.rawValue // Alphabetical order if unlock status is the same
}
return isThemeUnlocked(firstTheme) && !isThemeUnlocked(secondTheme)
}
}
// Code theme
@State private var isPreviewExpanded: Bool = false
@State var codeSample: String = """
Here's an `inline code sample`, followed by a code block:
```python
# This is a comment
def function():
return "Here's a string"
squares = [x**2 for x in range(5)]
```
"""
var lightThemes = [
"a11y-light",
"arduino-light", "ascetic", "atelier-cave-light",
"atelier-dune-light",
"atelier-estuary-light","atelier-forest-light",
"atelier-heath-light",
"atelier-lakeside-light","atelier-plateau-light",
"atelier-savanna-light",
"atelier-seaside-light", "atelier-sulphurpool-light", "atom-one-light", "brown-paper", "color-brewer", "default",
"docco", "foundation", "github-gist", "github",
"googlecode", "grayscale", "gruvbox-light",
"idea", "isbl-editor-light", "kimbie.light", "lightfair", "mono-blue",
"paraiso-light", "purebasic", "qtcreator_light", "routeros", "school-book",
"solarized-light",
"tomorrow", "vs", "xcode"
]
var darkThemes = [
"agate", "an-old-hope", "androidstudio",
"arta", "atelier-cave-dark",
"atelier-dune-dark", "atelier-estuary-dark",
"atelier-forest-dark",
"atelier-heath-dark", "atelier-lakeside-dark",
"atelier-plateau-dark",
"atelier-savanna-dark", "atelier-seaside-dark", "atelier-sulphurpool-dark",
"atom-one-dark-reasonable", "atom-one-dark",
"codepen-embed", "darcula", "dark", "darkula",
"docco", "dracula", "far", "gml",
"gruvbox-dark", "hopscotch",
"hybrid", "ir-black", "isbl-editor-dark",
"kimbie.dark", "magula",
"monokai-sublime", "monokai", "nord", "obsidian", "ocean", "paraiso-dark",
"pojoaque", "qtcreator_dark",
"railscasts", "rainbow", "shades-of-purple",
"solarized-dark", "sunburst", "tomorrow-night-blue",
"tomorrow-night-bright", "tomorrow-night-eighties", "tomorrow-night",
"vs2015", "xcode-dark", "xt256", "zenburn"
]
let columns = [GridItem(.adaptive(minimum: 80))]
var body: some View {
Form {
Section(content: {
LabeledContent("Appearance:", content: {
HStack(spacing: 12) {
AppearanceButton(title: "Light", isSelected: appearance == .light, icon: Appearance.light.iconName) {
appearance = .light
}
AppearanceButton(title: "Dark", isSelected: appearance == .dark, icon: Appearance.dark.iconName) {
appearance = .dark
}
AppearanceButton(title: "Auto", isSelected: appearance == .auto, icon: Appearance.auto.iconName) {
appearance = .auto
}
}
})
}, header: {
Text("General")
})
Section(content: {
// Code Accent color
LabeledContent("Inline Highlight:") {
HStack(spacing: 8) {
ForEach(AccentColorOption.allCases, id: \.self) { option in
ColorButton(color: option.color, isSelected: inlineCodeHiglight == option) {
inlineCodeHiglight = option
}
}
}
.padding(.vertical, 5)
}
LabeledContent("Syntax Highlight Theme") {
if colorScheme == .light {
Picker("", selection: $lightCodeBlockTheme) {
ForEach(lightThemes, id: \.self) { theme in
Text(formatTheme(themeName: theme))
.tag(theme)
}
}
.pickerStyle(MenuPickerStyle())
.labelsHidden()
} else if colorScheme == .dark {
Picker("", selection: $darkCodeBlockTheme) {
ForEach(darkThemes, id: \.self) { theme in
Text(formatTheme(themeName: theme))
.tag(theme)
}
}
.pickerStyle(MenuPickerStyle())
.labelsHidden()
}
}
DisclosureGroup(isExpanded: $isPreviewExpanded) {
MarkdownView(text: $codeSample)
.font(.system(.body).monospaced().weight(.medium), for: .codeBlock)
.tint(inlineCodeHiglight.color, for: .inlineCodeBlock)
.codeHighlighterTheme(CodeHighlighterTheme(lightModeThemeName: lightCodeBlockTheme, darkModeThemeName: darkCodeBlockTheme))
.padding(.top, 10)
} label: {
Text("Markdown Preview")
.fontWeight(.medium)
}
}, header: {
Text("Code")
})
Section(content: {
ScrollView {
HStack(alignment: .top) {
ForEach(sortedThemes, id: \.self) { themeName in
let theme = ThemingEngine.theme(for: themeName)
let isUnlocked = isThemeUnlocked(themeName)
ThemeThumbnailView(theme: theme, isSelected: ThemingEngine.shared.currentTheme.name == themeName, isUnlocked: isUnlocked)
.frame(width: 80)
.onTapGesture {
if isUnlocked {
ThemingEngine.shared.setTheme(themeName)
}
}
}
}
.padding()
}
}, header: {
Text("Theme")
})
}.formStyle(.grouped)
}
private func formatTheme(themeName: String) -> String {
return themeName
.replacingOccurrences(of: "[^a-zA-Z0-9]", with: " ", options: .regularExpression)
.split(separator: " ")
.map { $0.lowercased() }
.joined(separator: " ")
.capitalized
}
private func isThemeUnlocked(_ themeName: ThemeNames) -> Bool {
switch themeName {
case .pixelPals:
return isPixelPalsUnlocked
case .chromeDino:
return isChromeDinoUnlocked
case .appleClassic:
return isAppleClassicUnlocked
default:
return true
}
}
}
struct AppearanceButton: View {
let title: String
let isSelected: Bool
let icon: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack {
Image(icon)
.mask {
RoundedRectangle(cornerRadius: 7)
}
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected ? .blue: .clear, lineWidth: 3)
)
Text(title)
.font(.caption)
.foregroundColor(isSelected ? .primary : .secondary)
}
}
.buttonStyle(.borderless)
}
}
struct ColorButton: View {
let color: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 20, height: 20)
.overlay(
Circle()
.fill(isSelected ? .white:.clear)
.frame(width: 7, height: 10)
)
}
.buttonStyle(.borderless)
.help(color.description.capitalized)
}
}
struct ThemeThumbnailView: View {
let theme: Theme
let isSelected: Bool
var isUnlocked: Bool = false
var body: some View {
VStack(alignment: .center, spacing: 10) {
ZStack {
Image(theme.previewImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
.shadow(color: .primary.opacity(0.2), radius: 5)
.cornerRadius(10)
if !isUnlocked {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.black.gradient.opacity(0.8))
.frame(width: 50, height: 50)
Image(systemName: "lock.fill")
.font(.title)
.foregroundColor(.white)
}
}
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 3)
)
Text(theme.name.rawValue)
.font(.caption)
.foregroundColor(isSelected ? .primary : .secondary)
.multilineTextAlignment(.center)
// .lineLimit(1)
}
}
}
#Preview {
AppearanceSettings()
// .frame(width: 500, height: 500)
.environment(ModelManager())
}