From 154797555cb5dceed354ca312aaddb57ca5aa03e Mon Sep 17 00:00:00 2001 From: Tareq Ismail Date: Wed, 3 Jun 2026 14:05:37 -0700 Subject: [PATCH] Add idle fade writing prompt --- freewrite/ContentView.swift | 177 ++++++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 8 deletions(-) diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index c4c08cd..0b32f9d 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -92,6 +92,12 @@ struct ContentView: View { @State private var timeRemaining: Int = 900 // Changed to 900 seconds (15 minutes) @State private var timerIsRunning = false @State private var isHoveringTimer = false + @State private var fadeAfterSeconds: Int? = nil + @State private var secondsSinceTyping = 0 + @State private var idleFadeOverlayOpacity: Double = 0.0 + @State private var isIdleFading = false + @State private var idleFadeGeneration = UUID() + @State private var isHoveringIdleFade = false @State private var isHoveringFullscreen = false @State private var hoveredFont: String? = nil @State private var isHoveringSize = false @@ -143,6 +149,8 @@ struct ContentView: View { let availableFonts = NSFontManager.shared.availableFontFamilies let standardFonts = ["Lato-Regular", "Arial", ".AppleSystemUIFont", "Times New Roman"] let fontSizes: [CGFloat] = [16, 18, 20, 22, 24, 26] + let idleFadeOptions: [Int?] = [nil, 3, 7, 15, 30, 45] + let idleFadeDuration: Double = 7.0 let placeholderOptions = [ "Begin writing", "Pick a thought and go", @@ -841,6 +849,121 @@ struct ContentView: View { return isHoveringTimer ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8)) } } + + var idleFadeButtonTitle: String { + guard let fadeAfterSeconds else { + return "Don't fade" + } + return "Fade in \(fadeAfterSeconds)s" + } + + var idleFadeColor: Color { + if fadeAfterSeconds != nil { + return isHoveringIdleFade ? (colorScheme == .light ? .black : .white) : .gray.opacity(0.8) + } else { + return isHoveringIdleFade ? (colorScheme == .light ? .black : .white) : (colorScheme == .light ? .gray : .gray.opacity(0.8)) + } + } + + private func setIdleFadeSeconds(_ seconds: Int?) { + fadeAfterSeconds = seconds + resetIdleFadeProgress() + } + + private func adjustIdleFadeSeconds(by direction: Int) { + guard let currentIndex = idleFadeOptions.firstIndex(where: { $0 == fadeAfterSeconds }) else { + setIdleFadeSeconds(7) + return + } + + let nextIndex = min(max(currentIndex + direction, 0), idleFadeOptions.count - 1) + setIdleFadeSeconds(idleFadeOptions[nextIndex]) + } + + private func resetIdleFadeProgress() { + secondsSinceTyping = 0 + idleFadeGeneration = UUID() + + if isIdleFading || idleFadeOverlayOpacity > 0 { + withAnimation(.easeOut(duration: 0.2)) { + idleFadeOverlayOpacity = 0 + } + isIdleFading = false + } + } + + private func updateIdleFadeProgress() { + guard timerIsRunning, + fadeAfterSeconds != nil, + currentVideoURL == nil, + !isIdleFading else { + return + } + + secondsSinceTyping += 1 + if let fadeAfterSeconds, secondsSinceTyping >= fadeAfterSeconds { + startIdleFade() + } + } + + private func startIdleFade() { + guard timerIsRunning, + fadeAfterSeconds != nil, + currentVideoURL == nil, + !isIdleFading else { + return + } + + let fadeGeneration = UUID() + idleFadeGeneration = fadeGeneration + isIdleFading = true + + withAnimation(.linear(duration: idleFadeDuration)) { + idleFadeOverlayOpacity = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + idleFadeDuration) { + guard idleFadeGeneration == fadeGeneration, isIdleFading else { + return + } + + finishCurrentWritingSession(markTimerComplete: true) + withAnimation(.easeInOut(duration: 0.2)) { + showingSidebar = true + } + timeRemaining = 900 + createNewEntry() + secondsSinceTyping = 0 + + withAnimation(.easeOut(duration: 1.0)) { + idleFadeOverlayOpacity = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if idleFadeGeneration == fadeGeneration { + isIdleFading = false + } + } + } + } + + private func finishCurrentWritingSession(markTimerComplete: Bool = false) { + timerIsRunning = false + + if markTimerComplete { + timeRemaining = 0 + } + + if let currentId = selectedEntryId, + let currentEntry = entries.first(where: { $0.id == currentId }), + currentEntry.entryType == .text { + saveEntry(entry: currentEntry) + } + + withAnimation(.easeOut(duration: 1.0)) { + bottomNavOpacity = 1.0 + } + } var lineHeight: CGFloat { let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) @@ -1090,10 +1213,11 @@ struct ContentView: View { if let lastClick = lastClickTime, now.timeIntervalSince(lastClick) < 0.3 { timeRemaining = 900 - timerIsRunning = false + finishCurrentWritingSession() lastClickTime = nil } else { timerIsRunning.toggle() + resetIdleFadeProgress() lastClickTime = now } } @@ -1127,6 +1251,38 @@ struct ContentView: View { } } + Text("•") + .foregroundColor(.gray) + + Button(idleFadeButtonTitle) { + setIdleFadeSeconds(fadeAfterSeconds == nil ? 7 : nil) + } + .buttonStyle(.plain) + .foregroundColor(idleFadeColor) + .onHover { hovering in + isHoveringIdleFade = hovering + isHoveringBottomNav = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onAppear { + NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + if isHoveringIdleFade { + let scrollBuffer = event.deltaY * 0.25 + + if abs(scrollBuffer) >= 0.1 { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) + let direction = -scrollBuffer > 0 ? 1 : -1 + adjustIdleFadeSeconds(by: direction) + } + } + return event + } + } + Text("•") .foregroundColor(.gray) @@ -1502,6 +1658,11 @@ struct ContentView: View { } } } + + Color(colorScheme == .light ? .white : .black) + .opacity(idleFadeOverlayOpacity) + .ignoresSafeArea() + .allowsHitTesting(false) } // Right sidebar @@ -1716,7 +1877,9 @@ struct ContentView: View { clearVideoRecordingPreparationState() } } - .onChange(of: text) { _ in + .onChange(of: text) { _, _ in + resetIdleFadeProgress() + // Save current entry when text changes if let currentId = selectedEntryId, let currentEntry = entries.first(where: { $0.id == currentId }), @@ -1727,13 +1890,11 @@ struct ContentView: View { .onReceive(timer) { _ in if timerIsRunning && timeRemaining > 0 { timeRemaining -= 1 - } else if timeRemaining == 0 { - timerIsRunning = false - if !isHoveringBottomNav { - withAnimation(.easeOut(duration: 1.0)) { - bottomNavOpacity = 1.0 - } + if timeRemaining > 0 { + updateIdleFadeProgress() } + } else if timerIsRunning && timeRemaining == 0 { + finishCurrentWritingSession() } } .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in