Skip to content

Commit 75ea263

Browse files
authored
feat(overlay): fade suggestions in with an opacity ramp, toggleable in Settings (#749)
1 parent 611c3c6 commit 75ea263

11 files changed

Lines changed: 379 additions & 2 deletions

Cotabby.xcodeproj/project.pbxproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
157A55EB796BEB7819B90D5D /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */; };
7272
15D4C75B6BA901E110AC929F /* InlinePreviewPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEFF8BAEC9168CADEDD4757 /* InlinePreviewPanelController.swift */; };
7373
15FA56CEF6FB5FF54C2FBA6F /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */; };
74+
1671E0A3C8D6DF30C5AD7F9F /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */; };
7475
1681C0F22323FB1156579D99 /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; };
7576
175C4FA56C29DEE58C2D4D7E /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; };
7677
18382D1919D90E3C1EE143C2 /* AppSurfaceClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C451E144D220D5C63372A8C0 /* AppSurfaceClassifierTests.swift */; };
@@ -295,6 +296,7 @@
295296
610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; };
296297
6106B16C0DBA94EBF838D93E /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */; };
297298
613C60F5D664655CB4D8CC27 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */; };
299+
614F45951D2BD56406E1F95E /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */; };
298300
61635150B8004F6CB2FACE65 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B0830FBE4F2E239F670DBA /* AppSurfaceClassifier.swift */; };
299301
61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; };
300302
62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; };
@@ -392,6 +394,7 @@
392394
88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */; };
393395
89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; };
394396
8A47597B097058832453C2FF /* LlamaSuggestionEvalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A691EB51A7B93D1FAC38657F /* LlamaSuggestionEvalTests.swift */; };
397+
8A88C3A598E65E4127FEEC3D /* SuggestionFadeInPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */; };
395398
8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */; };
396399
8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */; };
397400
8D0F66DD1E6C988368A4545D /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */; };
@@ -804,6 +807,7 @@
804807
421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretRunPlacementTests.swift; sourceTree = "<group>"; };
805808
43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGateTests.swift; sourceTree = "<group>"; };
806809
43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetectorTests.swift; sourceTree = "<group>"; };
810+
442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = "<group>"; };
807811
4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = "<group>"; };
808812
44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = "<group>"; };
809813
45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = "<group>"; };
@@ -875,6 +879,7 @@
875879
7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LGPL-3.0.txt"; sourceTree = "<group>"; };
876880
75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRunTests.swift; sourceTree = "<group>"; };
877881
764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = "<group>"; };
882+
764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicy.swift; sourceTree = "<group>"; };
878883
77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = "<group>"; };
879884
78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = "<group>"; };
880885
78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = "<group>"; };
@@ -1558,6 +1563,7 @@
15581563
FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */,
15591564
470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */,
15601565
3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */,
1566+
442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */,
15611567
45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */,
15621568
8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */,
15631569
CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */,
@@ -1772,6 +1778,7 @@
17721778
299BD7B741DA4AAE6A061BAD /* StreamedGhostTextPolicy.swift */,
17731779
E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */,
17741780
3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */,
1781+
764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */,
17751782
B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */,
17761783
DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */,
17771784
CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */,
@@ -2191,6 +2198,7 @@
21912198
094DE1CA4A821D722F173F9D /* SuggestionDebugLogger.swift in Sources */,
21922199
08A161D7775D09D116E48F6D /* SuggestionEngineModels.swift in Sources */,
21932200
9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */,
2201+
1671E0A3C8D6DF30C5AD7F9F /* SuggestionFadeInPolicy.swift in Sources */,
21942202
CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */,
21952203
F28FB178EC507C3D42A6F893 /* SuggestionInteractionState.swift in Sources */,
21962204
7DEFC57991AB0C5379AD9CBF /* SuggestionModels.swift in Sources */,
@@ -2437,6 +2445,7 @@
24372445
3C23336EE6F6559857DE92EE /* SuggestionDebugLogger.swift in Sources */,
24382446
A93A741C8C3973687D28F0B6 /* SuggestionEngineModels.swift in Sources */,
24392447
52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */,
2448+
614F45951D2BD56406E1F95E /* SuggestionFadeInPolicy.swift in Sources */,
24402449
2FC40D4BFDD05C2401D7A5E9 /* SuggestionInserter.swift in Sources */,
24412450
A982E243A4D8BC1BF7504B3E /* SuggestionInteractionState.swift in Sources */,
24422451
0AF568AB234033BA2DE4CAA7 /* SuggestionModels.swift in Sources */,
@@ -2612,6 +2621,7 @@
26122621
9938DE59D9E05BC51A5BA5B8 /* SuggestionDebugLoggerTests.swift in Sources */,
26132622
19CA1BF8B508E0E219EF4485 /* SuggestionEngineModelsTests.swift in Sources */,
26142623
449218D0646AB3745B7E4F30 /* SuggestionEngineRouterTests.swift in Sources */,
2624+
8A88C3A598E65E4127FEEC3D /* SuggestionFadeInPolicyTests.swift in Sources */,
26152625
5CED06E89FBEF557DCD6C684 /* SuggestionFocusFreshnessTests.swift in Sources */,
26162626
6CBEF02FCDFCF406E378C27C /* SuggestionInteractionStateTests.swift in Sources */,
26172627
4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */,

Cotabby/Models/SuggestionSettingsData.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ struct SuggestionSettingsData: Equatable {
7575
/// acceptable session the user can Tab into early. When off (the default), the suggestion appears
7676
/// once, fully formed, after generation finishes.
7777
var streamSuggestionsWhileGenerating: Bool
78+
/// When on (the default), a newly shown suggestion fades in over a short opacity ramp instead of
79+
/// snapping to full strength. Purely cosmetic and consumed only by the overlay renderer; the fade
80+
/// is suppressed automatically when the system "Reduce Motion" setting is on.
81+
var fadeInSuggestions: Bool
82+
/// Duration in seconds of that fade-in ramp. Read live by the overlay renderer and surfaced as a
83+
/// Slow-to-Fast speed slider in Settings; lower is a faster fade. See
84+
/// `SuggestionSettingsStore.clampedFadeInDuration` for the bounds.
85+
var fadeInDurationSeconds: Double
7886
var acceptanceKeyCode: CGKeyCode
7987
var acceptanceKeyModifiers: ShortcutModifierMask
8088
var acceptanceKeyLabel: String

Cotabby/Models/SuggestionSettingsModel.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ final class SuggestionSettingsModel: ObservableObject {
104104
@Published private(set) var autoAcceptTrailingPunctuation: Bool
105105
@Published private(set) var addSpaceAfterAccept: Bool
106106
@Published private(set) var streamSuggestionsWhileGenerating: Bool
107+
/// Whether a newly shown suggestion fades in. Read live by `OverlayController` at present time, so
108+
/// toggling it takes effect on the very next suggestion without any subscription bookkeeping. Not
109+
/// part of `snapshot`: it never reaches generation, only the overlay renderer.
110+
@Published private(set) var fadeInSuggestions: Bool
111+
/// Duration of the fade-in ramp in seconds. Read live by `OverlayController` alongside
112+
/// `fadeInSuggestions`, so dragging the speed slider takes effect on the next suggestion. Lower is
113+
/// a faster fade. Like `fadeInSuggestions`, it never reaches generation, only the overlay renderer.
114+
@Published private(set) var fadeInDurationSeconds: Double
107115
@Published private(set) var acceptanceKeyCode: CGKeyCode
108116
@Published private(set) var acceptanceKeyModifiers: ShortcutModifierMask
109117
@Published private(set) var acceptanceKeyLabel: String
@@ -146,6 +154,10 @@ final class SuggestionSettingsModel: ObservableObject {
146154
static let maximumGhostTextSizeMultiplier = SuggestionSettingsStore.maximumGhostTextSizeMultiplier
147155
static let defaultGhostTextSizeMultiplier = SuggestionSettingsStore.defaultGhostTextSizeMultiplier
148156
static let ghostTextSizeMultiplierStep = SuggestionSettingsStore.ghostTextSizeMultiplierStep
157+
static let minimumFadeInDuration = SuggestionSettingsStore.minimumFadeInDuration
158+
static let maximumFadeInDuration = SuggestionSettingsStore.maximumFadeInDuration
159+
static let defaultFadeInDuration = SuggestionSettingsStore.defaultFadeInDuration
160+
static let fadeInDurationStep = SuggestionSettingsStore.fadeInDurationStep
149161
static let maximumExtendedContextCharacters = SuggestionSettingsStore.maximumExtendedContextCharacters
150162

151163
init(
@@ -259,6 +271,8 @@ final class SuggestionSettingsModel: ObservableObject {
259271
autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation
260272
addSpaceAfterAccept = data.addSpaceAfterAccept
261273
streamSuggestionsWhileGenerating = data.streamSuggestionsWhileGenerating
274+
fadeInSuggestions = data.fadeInSuggestions
275+
fadeInDurationSeconds = data.fadeInDurationSeconds
262276
acceptanceKeyCode = data.acceptanceKeyCode
263277
acceptanceKeyModifiers = data.acceptanceKeyModifiers
264278
acceptanceKeyLabel = data.acceptanceKeyLabel
@@ -616,6 +630,23 @@ final class SuggestionSettingsModel: ObservableObject {
616630
store.saveStreamSuggestionsWhileGenerating(enabled)
617631
}
618632

633+
func setFadeInSuggestions(_ enabled: Bool) {
634+
guard fadeInSuggestions != enabled else {
635+
return
636+
}
637+
fadeInSuggestions = enabled
638+
store.saveFadeInSuggestions(enabled)
639+
}
640+
641+
func setFadeInDurationSeconds(_ seconds: Double) {
642+
let clamped = SuggestionSettingsStore.clampedFadeInDuration(seconds)
643+
guard fadeInDurationSeconds != clamped else {
644+
return
645+
}
646+
fadeInDurationSeconds = clamped
647+
store.saveFadeInDurationSeconds(clamped)
648+
}
649+
619650
func setAcceptanceGranularity(_ granularity: AcceptanceGranularity) {
620651
guard acceptanceGranularity != granularity else {
621652
return

Cotabby/Services/UI/OverlayController.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import AppKit
22
import Foundation
33
import Logging
4+
import QuartzCore
45
import SwiftUI
56

67
/// File overview:
@@ -105,13 +106,35 @@ final class OverlayController: SuggestionOverlayControlling {
105106
return
106107
}
107108

109+
// Decide on the fade using the panel state captured *before* `state` is reassigned below, so
110+
// the animation plays only on a genuine appearance. A reposition and a streamed-token
111+
// extension re-enter this path while the panel stays visible; restarting the opacity ramp on
112+
// either would make stable ghost text flicker. Note `advanceInline` calls `showInline`
113+
// directly and never routes through here, so it is exempt by construction without needing
114+
// the `overlayWasVisible` guard.
115+
let fadesIn = SuggestionFadeInPolicy.shouldFadeIn(
116+
isEnabled: suggestionSettings.fadeInSuggestions,
117+
overlayWasVisible: state.isVisible,
118+
reduceMotionEnabled: reduceMotionEnabled
119+
)
120+
108121
// Per-app render-mode overrides are not wired yet, so the policy always resolves without a
109122
// host bundle identifier; thread the focused app's id here when per-app overrides ship.
110123
let mode = currentRenderModePolicy.mode(
111124
for: geometry,
112125
bundleIdentifier: nil
113126
)
114127

128+
// Start fully transparent so the panel's first composited frame is invisible. Setting alpha
129+
// before the show paths call `orderFront` avoids a one-frame flash at full opacity. The else
130+
// branch resets the model value directly (off the animator), which cancels any stale mid-ramp
131+
// animation left paused by an order-out so a non-fading show can't resume semi-transparent.
132+
if fadesIn {
133+
panel.alphaValue = 0
134+
} else {
135+
panel.alphaValue = 1
136+
}
137+
115138
switch mode {
116139
case .inline:
117140
showInline(text: text, geometry: geometry)
@@ -120,6 +143,10 @@ final class OverlayController: SuggestionOverlayControlling {
120143
}
121144

122145
state = .visible(text: text, geometry: geometry, mode: mode)
146+
147+
if fadesIn {
148+
fadeInPanel()
149+
}
123150
}
124151

125152
/// Hides the floating panel and records why the overlay is no longer visible.
@@ -128,6 +155,26 @@ final class OverlayController: SuggestionOverlayControlling {
128155
state = .hidden(reason: reason)
129156
}
130157

158+
/// Mirrors the system Accessibility "Reduce Motion" preference. Read live so flipping it in
159+
/// System Settings suppresses the fade on the next suggestion without relaunching Cotabby.
160+
private var reduceMotionEnabled: Bool {
161+
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
162+
}
163+
164+
/// Ramps the panel from fully transparent to opaque over the user's configured fade duration.
165+
/// Driven through the AppKit animator proxy, which animates independently of
166+
/// `panel.animationBehavior` (kept `.none` so AppKit's own order-in spring stays off). Starting a
167+
/// fresh ramp supersedes any still-running one, so a rapid hide/show cannot strand the panel
168+
/// mid-fade. The duration is read live (the model keeps it clamped to a sane band), so the
169+
/// Settings speed slider takes effect on the very next suggestion.
170+
private func fadeInPanel() {
171+
NSAnimationContext.runAnimationGroup { context in
172+
context.duration = suggestionSettings.fadeInDurationSeconds
173+
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
174+
panel.animator().alphaValue = 1
175+
}
176+
}
177+
131178
/// Inline ghost text drawn next to the caret. This is the original rendering path; the body
132179
/// stays unchanged from the pre-mirror behavior aside from being extracted into its own method.
133180
///
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// Decides whether a freshly shown ghost-text overlay should fade in rather than appear instantly.
4+
///
5+
/// The hard rule this protects: a fade may only play on a *genuine appearance* of the overlay, never
6+
/// on an update to one that is already on screen. Caret repositions, streamed-token extensions, and
7+
/// word-by-word advances all re-invoke the show path while the panel stays visible; restarting the
8+
/// opacity animation on any of those would make stable ghost text flicker on every keystroke. The
9+
/// `overlayWasVisible` input is the panel state captured *before* the new content is applied, so a
10+
/// `false` value means the panel was hidden and this is a real first paint.
11+
///
12+
/// `reduceMotionEnabled` mirrors the system Accessibility "Reduce Motion" preference. Honoring it
13+
/// suppresses the animation even when the user has the Cotabby toggle on, because non-essential
14+
/// motion is exactly what that setting asks apps to drop; the suggestion still appears, just without
15+
/// the fade. Keeping the decision in a pure function lets all three inputs be exhaustively tested
16+
/// without standing up an `NSPanel` or AppKit animation.
17+
enum SuggestionFadeInPolicy {
18+
static func shouldFadeIn(
19+
isEnabled: Bool,
20+
overlayWasVisible: Bool,
21+
reduceMotionEnabled: Bool
22+
) -> Bool {
23+
guard isEnabled, !reduceMotionEnabled else {
24+
return false
25+
}
26+
return !overlayWasVisible
27+
}
28+
}

0 commit comments

Comments
 (0)