Skip to content

Commit 563a0fe

Browse files
committed
add automatic appearance and theme animation
1 parent 8836921 commit 563a0fe

File tree

2 files changed

+148
-39
lines changed

2 files changed

+148
-39
lines changed

freewrite/ContentView.swift

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,22 @@ struct ContentView: View {
8282
@State private var isHoveringHistoryText = false
8383
@State private var isHoveringHistoryPath = false
8484
@State private var isHoveringHistoryArrow = false
85-
@State private var colorScheme: ColorScheme = .light // Add state for color scheme
8685
@State private var isHoveringThemeToggle = false // Add state for theme toggle hover
8786
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
8887
let entryHeight: CGFloat = 40
8988

89+
@AppStorage("colorScheme") private var colorSchemeString: String = "auto"
90+
@Environment(\.colorScheme) private var systemColorScheme
91+
@State private var showingThemePopover = false
92+
@State private var refreshID = UUID()
93+
private var currentColorScheme: ColorScheme {
94+
switch colorSchemeString {
95+
case "light": return .light
96+
case "dark": return .dark
97+
default: return systemColorScheme
98+
}
99+
}
100+
90101
let availableFonts = NSFontManager.shared.availableFontFamilies
91102
let standardFonts = ["Lato-Regular", "Arial", ".AppleSystemUIFont", "Times New Roman"]
92103
let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26]
@@ -149,13 +160,6 @@ struct ContentView: View {
149160
Here's my journal entry:
150161
"""
151162

152-
// Initialize with saved theme preference if available
153-
init() {
154-
// Load saved color scheme preference
155-
let savedScheme = UserDefaults.standard.string(forKey: "colorScheme") ?? "light"
156-
_colorScheme = State(initialValue: savedScheme == "dark" ? .dark : .light)
157-
}
158-
159163
// Modify getDocumentsDirectory to use cached value
160164
private func getDocumentsDirectory() -> URL {
161165
return documentsDirectory
@@ -351,9 +355,9 @@ struct ContentView: View {
351355

352356
var timerColor: Color {
353357
if timerIsRunning {
354-
return isHoveringTimer ? (colorScheme == .light ? .black : .white) : .gray.opacity(0.8)
358+
return isHoveringTimer ? (currentColorScheme == .light ? .black : .white) : .gray.opacity(0.8)
355359
} else {
356-
return isHoveringTimer ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8))
360+
return isHoveringTimer ? (currentColorScheme == .light ? .black : .white) : (currentColorScheme == .light ? .gray : .gray.opacity(0.8))
357361
}
358362
}
359363

@@ -372,25 +376,24 @@ struct ContentView: View {
372376
return fontSize / 2
373377
}
374378

375-
// Add a color utility computed property
376379
var popoverBackgroundColor: Color {
377-
return colorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray)
380+
return currentColorScheme == .light ? Color(NSColor.controlBackgroundColor) : Color(NSColor.darkGray)
378381
}
379382

380383
var popoverTextColor: Color {
381-
return colorScheme == .light ? Color.primary : Color.white
384+
return currentColorScheme == .light ? Color.primary : Color.white
382385
}
383386

384387
var body: some View {
385-
let buttonBackground = colorScheme == .light ? Color.white : Color.black
388+
let buttonBackground = currentColorScheme == .light ? Color.white : Color.black
386389
let navHeight: CGFloat = 68
387-
let textColor = colorScheme == .light ? Color.gray : Color.gray.opacity(0.8)
388-
let textHoverColor = colorScheme == .light ? Color.black : Color.white
390+
let textColor = currentColorScheme == .light ? Color.gray : Color.gray.opacity(0.8)
391+
let textHoverColor = currentColorScheme == .light ? Color.black : Color.white
389392

390393
HStack(spacing: 0) {
391394
// Main content
392395
ZStack {
393-
Color(colorScheme == .light ? .white : .black)
396+
Color(currentColorScheme == .light ? .white : .black)
394397
.ignoresSafeArea()
395398

396399
TextEditor(text: Binding(
@@ -404,17 +407,16 @@ struct ContentView: View {
404407
}
405408
}
406409
))
407-
.background(Color(colorScheme == .light ? .white : .black))
408410
.font(.custom(selectedFont, size: fontSize))
409-
.foregroundColor(colorScheme == .light ? Color(red: 0.20, green: 0.20, blue: 0.20) : Color(red: 0.9, green: 0.9, blue: 0.9))
411+
.foregroundColor(currentColorScheme == .light ? Color(red: 0.20, green: 0.20, blue: 0.20) : Color(red: 0.9, green: 0.9, blue: 0.9))
410412
.scrollContentBackground(.hidden)
411413
.scrollIndicators(.never)
412414
.lineSpacing(lineHeight)
413415
.frame(maxWidth: 650)
414-
.id("\(selectedFont)-\(fontSize)-\(colorScheme)")
416+
.id("\(selectedFont)-\(fontSize)-\(currentColorScheme)")
415417
.padding(.bottom, bottomNavOpacity > 0 ? navHeight : 0)
416418
.ignoresSafeArea()
417-
.colorScheme(colorScheme)
419+
.colorScheme(currentColorScheme)
418420
.onAppear {
419421
placeholderText = placeholderOptions.randomElement() ?? "\n\nBegin writing"
420422
// Removed findSubview code which was causing errors
@@ -424,7 +426,7 @@ struct ContentView: View {
424426
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
425427
Text(placeholderText)
426428
.font(.custom(selectedFont, size: fontSize))
427-
.foregroundColor(colorScheme == .light ? .gray.opacity(0.5) : .gray.opacity(0.6))
429+
.foregroundColor(currentColorScheme == .light ? .gray.opacity(0.5) : .gray.opacity(0.6))
428430
// .padding(.top, 8)
429431
// .padding(.leading, 8)
430432
.allowsHitTesting(false)
@@ -630,7 +632,6 @@ struct ContentView: View {
630632
.frame(width: 250)
631633
.padding(.horizontal, 12)
632634
.padding(.vertical, 8)
633-
.background(popoverBackgroundColor)
634635
.cornerRadius(8)
635636
.shadow(color: Color.black.opacity(0.1), radius: 4, y: 2)
636637
} else if text.count < 350 {
@@ -640,7 +641,6 @@ struct ContentView: View {
640641
.frame(width: 250)
641642
.padding(.horizontal, 12)
642643
.padding(.vertical, 8)
643-
.background(popoverBackgroundColor)
644644
.cornerRadius(8)
645645
.shadow(color: Color.black.opacity(0.1), radius: 4, y: 2)
646646
} else {
@@ -672,7 +672,6 @@ struct ContentView: View {
672672
.foregroundColor(popoverTextColor)
673673
}
674674
.frame(width: 120)
675-
.background(popoverBackgroundColor)
676675
.cornerRadius(8)
677676
.shadow(color: Color.black.opacity(0.1), radius: 4, y: 2)
678677
}
@@ -724,12 +723,13 @@ struct ContentView: View {
724723

725724
// Theme toggle button
726725
Button(action: {
727-
colorScheme = colorScheme == .light ? .dark : .light
728-
// Save preference
729-
UserDefaults.standard.set(colorScheme == .light ? "light" : "dark", forKey: "colorScheme")
726+
showingThemePopover = true
730727
}) {
731-
Image(systemName: colorScheme == .light ? "moon.fill" : "sun.max.fill")
728+
let theme = AppColorScheme(rawValue: colorSchemeString) ?? .auto
729+
Image(systemName: theme.systemImage)
730+
.frame(width: 18) // If you remove this, there will be flickering in the width of the bar.
732731
.foregroundColor(isHoveringThemeToggle ? textHoverColor : textColor)
732+
.animation(.easeInOut(duration: 0.3), value: colorSchemeString)
733733
}
734734
.buttonStyle(.plain)
735735
.onHover { hovering in
@@ -741,6 +741,38 @@ struct ContentView: View {
741741
NSCursor.pop()
742742
}
743743
}
744+
.popover(isPresented: $showingThemePopover, attachmentAnchor: .point(UnitPoint(x: 0.5, y: 0)), arrowEdge: .top) {
745+
VStack(spacing: 0) {
746+
ForEach(["light", "dark", "auto"], id: \.self) { themeValue in
747+
let theme = AppColorScheme(rawValue: themeValue) ?? .auto
748+
Button(action: {
749+
withAnimation(.easeInOut(duration: 0.3)) {
750+
colorSchemeString = themeValue
751+
}
752+
showingThemePopover = false
753+
}) {
754+
HStack {
755+
Image(systemName: theme.systemImage)
756+
.frame(width: 20)
757+
Text(theme.displayName)
758+
.frame(maxWidth: .infinity, alignment: .leading)
759+
}
760+
.padding(.horizontal, 12)
761+
.padding(.vertical, 8)
762+
.cornerRadius(4)
763+
}
764+
.buttonStyle(.plain)
765+
.foregroundColor(popoverTextColor)
766+
767+
if themeValue != "auto" {
768+
Divider()
769+
}
770+
}
771+
}
772+
.background(popoverBackgroundColor)
773+
.cornerRadius(8)
774+
.shadow(color: Color.black.opacity(0.1), radius: 4, y: 2)
775+
}
744776

745777
Text("")
746778
.foregroundColor(.gray)
@@ -772,7 +804,6 @@ struct ContentView: View {
772804
}
773805
}
774806
.padding()
775-
.background(Color(colorScheme == .light ? .white : .black))
776807
.opacity(bottomNavOpacity)
777808
.onHover { hovering in
778809
isHoveringBottomNav = hovering
@@ -861,8 +892,8 @@ struct ContentView: View {
861892
Image(systemName: "arrow.down.circle")
862893
.font(.system(size: 11))
863894
.foregroundColor(hoveredExportId == entry.id ?
864-
(colorScheme == .light ? .black : .white) :
865-
(colorScheme == .light ? .gray : .gray.opacity(0.8)))
895+
(currentColorScheme == .light ? .black : .white) :
896+
(currentColorScheme == .light ? .gray : .gray.opacity(0.8)))
866897
}
867898
.buttonStyle(.plain)
868899
.help("Export entry as PDF")
@@ -934,12 +965,12 @@ struct ContentView: View {
934965
.scrollIndicators(.never)
935966
}
936967
.frame(width: 200)
937-
.background(Color(colorScheme == .light ? .white : NSColor.black))
938968
}
939969
}
940970
.frame(minWidth: 1100, minHeight: 600)
941971
.animation(.easeInOut(duration: 0.2), value: showingSidebar)
942-
.preferredColorScheme(colorScheme)
972+
.animation(.easeInOut(duration: 0.3), value: currentColorScheme) // MARK : this sets the light/dark mode transition time
973+
.preferredColorScheme(currentColorScheme)
943974
.onAppear {
944975
showingSidebar = false // Hide sidebar by default
945976
loadExistingEntries()
@@ -1305,4 +1336,4 @@ extension NSView {
13051336

13061337
#Preview {
13071338
ContentView()
1308-
}
1339+
}

freewrite/freewriteApp.swift

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,85 @@
66
//
77

88
import SwiftUI
9+
import AppKit
910

1011
@main
1112
struct freewriteApp: App {
1213
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
13-
@AppStorage("colorScheme") private var colorSchemeString: String = "light"
14+
@AppStorage("colorScheme") private var colorSchemeString: String = "auto"
15+
@StateObject private var appearanceManager = AppearanceManager()
1416

1517
init() {
1618
// Register Lato font
1719
if let fontURL = Bundle.main.url(forResource: "Lato-Regular", withExtension: "ttf") {
1820
CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, nil)
1921
}
2022
}
21-
23+
2224
var body: some Scene {
2325
WindowGroup {
2426
ContentView()
2527
.toolbar(.hidden, for: .windowToolbar)
26-
.preferredColorScheme(colorSchemeString == "dark" ? .dark : .light)
28+
.preferredColorScheme(getPreferredColorScheme())
29+
.environmentObject(appearanceManager)
2730
}
2831
.windowStyle(.hiddenTitleBar)
2932
.defaultSize(width: 1100, height: 600)
3033
.windowToolbarStyle(.unifiedCompact)
3134
.windowResizability(.contentSize)
3235
}
36+
37+
// Return desired appearance from user setting
38+
private func getPreferredColorScheme() -> ColorScheme? {
39+
switch colorSchemeString {
40+
case "light":
41+
return .light
42+
case "dark":
43+
return .dark
44+
default:
45+
return appearanceManager.colorScheme
46+
}
47+
}
48+
}
49+
50+
@MainActor
51+
class AppearanceManager: ObservableObject {
52+
@Published var colorScheme: ColorScheme = .light
53+
private var appearanceObserver: Any?
54+
55+
init() {
56+
setupAppearanceObserver()
57+
updateColorScheme()
58+
}
59+
60+
private func setupAppearanceObserver() {
61+
// Observe system appearance changes
62+
appearanceObserver = DistributedNotificationCenter.default.addObserver(
63+
forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
64+
object: nil,
65+
queue: .main
66+
) { [weak self] _ in
67+
self?.updateColorScheme()
68+
}
69+
}
70+
71+
func updateColorScheme() {
72+
let isDarkMode = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
73+
colorScheme = isDarkMode ? .dark : .light
74+
75+
// Update app's appearance
76+
DispatchQueue.main.async {
77+
NSApp.appearance = isDarkMode ?
78+
NSAppearance(named: .darkAqua) :
79+
NSAppearance(named: .aqua)
80+
}
81+
}
82+
83+
deinit {
84+
if let observer = appearanceObserver {
85+
DistributedNotificationCenter.default.removeObserver(observer)
86+
}
87+
}
3388
}
3489

3590
// Add AppDelegate to handle window configuration
@@ -45,4 +100,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
45100
window.center()
46101
}
47102
}
48-
}
103+
}
104+
105+
// Names and icons of the three appearance options
106+
enum AppColorScheme: String {
107+
case light = "light"
108+
case dark = "dark"
109+
case auto = "auto"
110+
111+
var displayName: String {
112+
switch self {
113+
case .light: return "Light"
114+
case .dark: return "Dark"
115+
case .auto: return "Auto"
116+
}
117+
}
118+
119+
var systemImage: String {
120+
switch self {
121+
case .light: return "sun.max.fill"
122+
case .dark: return "moon.fill"
123+
case .auto: return "sun.dust.fill"
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)