diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 08921f58..611b440b 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -154,6 +154,7 @@ 6E01052209B73D7361C12CEF /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */; }; 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; + 76DFC829F2417FB048463285 /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; @@ -505,6 +506,7 @@ A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = ""; }; A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeManager.swift; sourceTree = ""; }; + A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextPreview.swift; sourceTree = ""; }; A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeModels.swift; sourceTree = ""; }; A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = ""; }; A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextCoordinator.swift; sourceTree = ""; }; @@ -810,6 +812,7 @@ children = ( E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */, E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */, + A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */, 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */, 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */, ); @@ -1350,6 +1353,7 @@ 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */, ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */, F4EEE6291095B0BF2D3FBA21 /* GhostTextColorPreset.swift in Sources */, + 76DFC829F2417FB048463285 /* GhostTextPreview.swift in Sources */, D90C889A623A928F4F5FDC7B /* HardwareCapabilityProbe.swift in Sources */, B65B49F24F59154A7611FD22 /* HomePaneView.swift in Sources */, 91C27021750AC03AA4A0115A /* HuggingFaceAPIClient.swift in Sources */, diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 021bf2ea..dee44036 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -17,6 +17,10 @@ struct SuggestionSettingsData: Equatable { var disabledAppRules: [DisabledApplicationRule] var customSuggestionTextColorHex: String? var ghostTextOpacity: Double + /// User multiplier applied on top of the caret-approximated ghost-text size. 1.0 keeps the + /// existing best-approximation; lower values shrink suggestions for users who find the auto-size + /// too large. See `SuggestionSettingsStore.clampedGhostTextSizeMultiplier` for the bounds. + var ghostTextSizeMultiplier: Double var selectedEngine: SuggestionEngineKind var selectedWordCountPreset: SuggestionWordCountPreset /// When true, generation uses `customWordCountLowWords...customWordCountHighWords` instead of diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index ab136cea..62e50432 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -40,6 +40,10 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var disabledAppRules: [DisabledApplicationRule] @Published private(set) var customSuggestionTextColorHex: String? @Published private(set) var ghostTextOpacity: Double + /// Multiplier the overlay applies on top of the caret-approximated ghost-text size. Read live by + /// `OverlayController` at present time (like `ghostTextOpacity`), so it is intentionally not part + /// of the generation-facing `SuggestionSettingsSnapshot` — it changes presentation, not requests. + @Published private(set) var ghostTextSizeMultiplier: Double @Published private(set) var selectedEngine: SuggestionEngineKind @Published private(set) var selectedWordCountPreset: SuggestionWordCountPreset /// When true, the active length budget reads `customWordCountLowWords...HighWords` and the @@ -118,6 +122,10 @@ final class SuggestionSettingsModel: ObservableObject { static let maximumGhostTextOpacity = SuggestionSettingsStore.maximumGhostTextOpacity static let defaultGhostTextOpacity = SuggestionSettingsStore.defaultGhostTextOpacity static let ghostTextOpacityStep = SuggestionSettingsStore.ghostTextOpacityStep + static let minimumGhostTextSizeMultiplier = SuggestionSettingsStore.minimumGhostTextSizeMultiplier + static let maximumGhostTextSizeMultiplier = SuggestionSettingsStore.maximumGhostTextSizeMultiplier + static let defaultGhostTextSizeMultiplier = SuggestionSettingsStore.defaultGhostTextSizeMultiplier + static let ghostTextSizeMultiplierStep = SuggestionSettingsStore.ghostTextSizeMultiplierStep static let maximumExtendedContextCharacters = SuggestionSettingsStore.maximumExtendedContextCharacters init( @@ -134,6 +142,7 @@ final class SuggestionSettingsModel: ObservableObject { disabledAppRules = data.disabledAppRules customSuggestionTextColorHex = data.customSuggestionTextColorHex ghostTextOpacity = data.ghostTextOpacity + ghostTextSizeMultiplier = data.ghostTextSizeMultiplier selectedEngine = data.selectedEngine selectedWordCountPreset = data.selectedWordCountPreset isUsingCustomWordCountRange = data.isUsingCustomWordCountRange @@ -611,6 +620,16 @@ final class SuggestionSettingsModel: ObservableObject { store.saveGhostTextOpacity(clamped) } + func setGhostTextSizeMultiplier(_ multiplier: Double) { + let clamped = SuggestionSettingsStore.clampedGhostTextSizeMultiplier(multiplier) + guard ghostTextSizeMultiplier != clamped else { + return + } + + ghostTextSizeMultiplier = clamped + store.saveGhostTextSizeMultiplier(clamped) + } + func setUserName(_ name: String) { guard userName != name else { return diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index 2dc14b46..d50411f3 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -303,6 +303,7 @@ final class OverlayController: SuggestionOverlayControlling { visibleFrame: visibleFrame, showsAcceptanceHint: acceptanceHintLabel != nil, autoAcceptTrailingPunctuation: suggestionSettings.autoAcceptTrailingPunctuation, + sizeMultiplier: CGFloat(suggestionSettings.ghostTextSizeMultiplier), reason: reason ) let customGhostColor = SuggestionTextColorCodec.color( @@ -368,7 +369,8 @@ final class OverlayController: SuggestionOverlayControlling { fieldMetrics: fieldMetrics, fallbackRatio: Layout.fontToLineHeightRatio, minimum: Layout.minimumGhostFontSize, - maximum: qualityCap + maximum: qualityCap, + sizeMultiplier: CGFloat(suggestionSettings.ghostTextSizeMultiplier) ) } diff --git a/Cotabby/Support/GhostFontMetrics.swift b/Cotabby/Support/GhostFontMetrics.swift index ddeae05b..8f7b605f 100644 --- a/Cotabby/Support/GhostFontMetrics.swift +++ b/Cotabby/Support/GhostFontMetrics.swift @@ -12,6 +12,12 @@ import Foundation /// Kept as a pure value helper (no AppKit) so the sizing math is unit-testable in isolation; callers /// extract the metrics from an `NSFont` and pass plain numbers. enum GhostFontMetrics { + /// Hard legibility floor applied after the user's size multiplier, below which ghost text would + /// read as broken rather than small. It sits under `minimum` on purpose so a "smaller" multiplier + /// still shrinks text that auto-sized to the floor; within the shipped multiplier range it never + /// binds, so it is purely a backstop against degenerate inputs (a non-positive or tiny multiplier). + static let absoluteMinimumPointSize: CGFloat = 9 + /// Glyph-box metrics of the host field's font. `ascender - descender` is the full glyph box /// height (`NSFont.descender` is negative). The derived ratio is scale-invariant, so callers may /// instantiate the reference font at any size. @@ -21,16 +27,23 @@ enum GhostFontMetrics { let descender: CGFloat } + /// `sizeMultiplier` is the user's Appearance "Ghost Text Size" knob. It scales the + /// caret-approximated size *after* the `[minimum, maximum]` clamp, so the knob reliably resizes + /// ghost text even for fields that auto-size onto those rails; applying it before the clamp would + /// make a "smaller" choice a no-op whenever the field already sits at `minimum`. Growth is bounded + /// by the caller's clamped multiplier rather than a second ceiling here; only the absolute floor + /// is re-applied so a low multiplier can never produce illegibly small text. static func pointSize( caretHeight: CGFloat, fieldMetrics: FieldFontMetrics?, fallbackRatio: CGFloat, minimum: CGFloat, - maximum: CGFloat + maximum: CGFloat, + sizeMultiplier: CGFloat = 1 ) -> CGFloat { let ratio = metricRatio(fieldMetrics) ?? fallbackRatio - let proposed = max(minimum, caretHeight * ratio) - return min(proposed, maximum) + let autoSize = min(max(minimum, caretHeight * ratio), maximum) + return max(absoluteMinimumPointSize, autoSize * sizeMultiplier) } /// `pointSize / (ascender - descender)` for the field font, or nil when the metrics are unusable. diff --git a/Cotabby/Support/MirrorOverlayLayout.swift b/Cotabby/Support/MirrorOverlayLayout.swift index bf9e1423..8e7a66c3 100644 --- a/Cotabby/Support/MirrorOverlayLayout.swift +++ b/Cotabby/Support/MirrorOverlayLayout.swift @@ -82,6 +82,7 @@ struct MirrorOverlayLayout: Equatable { visibleFrame: CGRect, showsAcceptanceHint: Bool, autoAcceptTrailingPunctuation: Bool = true, + sizeMultiplier: CGFloat = 1, reason: CompletionRenderMode.MirrorReason ) -> MirrorOverlayLayout { let normalizedSuggestion = normalizedDisplayText(suggestion) @@ -89,7 +90,12 @@ struct MirrorOverlayLayout: Equatable { in: normalizedSuggestion, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation ) - let measuredTextWidth = measuredWidth(of: normalizedSuggestion, fontSize: Metrics.fontSize) + // Mirror mode's font is fixed (the caret height is untrustworthy here), but the user's + // "Ghost Text Size" knob still scales it so suggestions stay one consistent size across both + // display modes. The shared legibility floor guards a low multiplier; the keycap pill keeps + // its own fixed size, so its width reservation below is intentionally left unscaled. + let scaledFontSize = max(GhostFontMetrics.absoluteMinimumPointSize, Metrics.fontSize * sizeMultiplier) + let measuredTextWidth = measuredWidth(of: normalizedSuggestion, fontSize: scaledFontSize) let keycapReservation = showsAcceptanceHint ? Metrics.keycapReservation : 0 // Reserve the keycap on top of the text width, not inside the min/max clamp. Otherwise a @@ -99,7 +105,7 @@ struct MirrorOverlayLayout: Equatable { let textContentWidth = min(textBudget, max(Metrics.minCardWidth, measuredTextWidth)) let contentWidth = textContentWidth + keycapReservation let cardWidth = contentWidth + (Metrics.horizontalPadding * 2) - let cardHeight = ceil(Metrics.fontSize * 1.6) + (Metrics.verticalPadding * 2) + let cardHeight = ceil(scaledFontSize * 1.6) + (Metrics.verticalPadding * 2) let anchorTopY = computeAnchorTopY(geometry: geometry, reason: reason) let anchorCenterX = computeAnchorCenterX(geometry: geometry) @@ -135,7 +141,7 @@ struct MirrorOverlayLayout: Equatable { return MirrorOverlayLayout( panelFrame: panelFrame, - fontSize: Metrics.fontSize, + fontSize: scaledFontSize, suggestionText: normalizedSuggestion, highlightedPrefix: highlightedPrefix, isRightToLeft: geometry.isRightToLeft, diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 7cd50423..1e5b636e 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -38,6 +38,15 @@ struct SuggestionSettingsStore { static let defaultGhostTextOpacity: Double = 1.0 static let ghostTextOpacityStep: Double = 0.1 + /// Multiplier the overlay applies on top of the caret-approximated ghost-text size. 1.0 is the + /// out-of-box default (the unchanged best-approximation). The band is symmetric around 1.0 with + /// real shrink room because "suggestions look too big" is the common complaint, and is kept + /// narrow on both ends so neither extreme renders ghost text illegibly small or comically large. + static let minimumGhostTextSizeMultiplier: Double = 0.7 + static let maximumGhostTextSizeMultiplier: Double = 1.3 + static let defaultGhostTextSizeMultiplier: Double = 1.0 + static let ghostTextSizeMultiplierStep: Double = 0.1 + /// Hard upper bound on the persisted Extended Context blob, in characters. Sized to match what the /// engines actually consume rather than what they can store: the OSS base path renders this as a /// budgeted "notes" section (`BaseCompletionPromptRenderer`, `maxChars` 1300) inside a 2400-char @@ -57,6 +66,7 @@ struct SuggestionSettingsStore { private static let showAcceptanceHintDefaultsKey = "cotabbyShowAcceptanceHint" private static let customSuggestionTextColorHexDefaultsKey = "cotabbyCustomSuggestionTextColorHex" private static let ghostTextOpacityDefaultsKey = "cotabbyGhostTextOpacity" + private static let ghostTextSizeMultiplierDefaultsKey = "cotabbyGhostTextSizeMultiplier" private static let selectedEngineDefaultsKey = "cotabbySelectedEngine" private static let selectedWordCountPresetDefaultsKey = "cotabbySelectedWordCountPreset" private static let usingCustomWordCountRangeDefaultsKey = "cotabbyUsingCustomWordCountRange" @@ -134,6 +144,12 @@ struct SuggestionSettingsStore { } else { Self.clampedGhostTextOpacity(userDefaults.double(forKey: Self.ghostTextOpacityDefaultsKey)) } + let resolvedGhostTextSizeMultiplier: Double = + if userDefaults.object(forKey: Self.ghostTextSizeMultiplierDefaultsKey) == nil { + Self.defaultGhostTextSizeMultiplier + } else { + Self.clampedGhostTextSizeMultiplier(userDefaults.double(forKey: Self.ghostTextSizeMultiplierDefaultsKey)) + } let resolvedEngine = userDefaults .string(forKey: Self.selectedEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) @@ -305,6 +321,7 @@ struct SuggestionSettingsStore { disabledAppRules: resolvedDisabledAppRules, customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, ghostTextOpacity: resolvedGhostTextOpacity, + ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, selectedEngine: resolvedEngine, selectedWordCountPreset: resolvedWordCountPreset, isUsingCustomWordCountRange: resolvedUsingCustomWordCountRange, @@ -354,6 +371,7 @@ struct SuggestionSettingsStore { saveShowAcceptanceHint(data.showAcceptanceHint) saveCustomSuggestionTextColorHex(data.customSuggestionTextColorHex) saveGhostTextOpacity(data.ghostTextOpacity) + saveGhostTextSizeMultiplier(data.ghostTextSizeMultiplier) saveSelectedEngine(data.selectedEngine) saveSelectedWordCountPreset(data.selectedWordCountPreset) saveUsingCustomWordCountRange(data.isUsingCustomWordCountRange) @@ -445,6 +463,10 @@ struct SuggestionSettingsStore { userDefaults.set(opacity, forKey: Self.ghostTextOpacityDefaultsKey) } + func saveGhostTextSizeMultiplier(_ multiplier: Double) { + userDefaults.set(multiplier, forKey: Self.ghostTextSizeMultiplierDefaultsKey) + } + func saveSelectedEngine(_ engine: SuggestionEngineKind) { userDefaults.set(engine.rawValue, forKey: Self.selectedEngineDefaultsKey) } @@ -659,6 +681,14 @@ struct SuggestionSettingsStore { return min(maximumGhostTextOpacity, max(minimumGhostTextOpacity, value)) } + static func clampedGhostTextSizeMultiplier(_ value: Double) -> Double { + guard value.isFinite else { + return defaultGhostTextSizeMultiplier + } + + return min(maximumGhostTextSizeMultiplier, max(minimumGhostTextSizeMultiplier, value)) + } + static func normalizedHexString(_ hex: String?) -> String? { guard let hex else { return nil diff --git a/Cotabby/UI/Settings/Components/GhostTextPreview.swift b/Cotabby/UI/Settings/Components/GhostTextPreview.swift new file mode 100644 index 00000000..e74426a4 --- /dev/null +++ b/Cotabby/UI/Settings/Components/GhostTextPreview.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// File overview: +/// A non-interactive sample that shows how an inline suggestion will look with the user's chosen +/// ghost-text color, opacity, and size. It is deliberately fake — there is no text field or model +/// behind it — so a user can judge their Appearance choices at a glance without switching to another +/// app and triggering a real suggestion. +/// +/// Fidelity: the typed prefix renders in the primary label color (like text the user already typed) +/// and the trailing run renders in the resolved ghost color at the chosen opacity (like a live +/// suggestion), matching how `GhostSuggestionView` splits already-typed text from the ghost. The two +/// runs are concatenated into one `Text` so the sample wraps as a single paragraph when a large size +/// multiplier makes it outgrow the row, instead of clipping the ghost half. +/// +/// Size: the real overlay derives its point size from the caret height, which does not exist here, so +/// a fixed mid-band base stands in for it and is scaled by the same multiplier the overlay applies. +/// The preview therefore communicates the *relative* effect of the size control, which is the choice +/// the user is actually making. +struct GhostTextPreview: View { + /// The resolved base ghost color, WITHOUT opacity applied — the view fades the ghost run itself so + /// the typed prefix stays at full strength, exactly like the overlay. + let ghostColor: Color + /// Ghost-run fade in `[0.3, 1.0]`, applied only to the suggestion half. + let opacity: Double + /// Final preview point size (the representative base already scaled by the user's multiplier). + let fontSize: CGFloat + + /// Representative base size the multiplier scales in the preview. Sits near the lower end of + /// the overlay's real `[14, 24]` caret-derived band, matching typical small-to-medium text + /// fields where most users encounter ghost text in practice. + static let baseFontSize: CGFloat = 16 + + /// The whole sentence reads naturally with the ghost half completing the typed half, and labels + /// itself as a sample so the user understands this is a preview rather than a real input. + private let typedText = "This is how your " + private let ghostText = "suggestions will look" + + var body: some View { + let sample = Text(typedText).foregroundStyle(.primary) + + Text(ghostText).foregroundStyle(ghostColor.opacity(opacity)) + + return sample + .font(.system(size: fontSize)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) + ) + // One decorative element: the configured values are what the user reads off the controls, + // so the sample text itself carries no extra information for VoiceOver. + .accessibilityElement(children: .ignore) + .accessibilityLabel("Preview of how suggestions will appear") + } +} diff --git a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift index f6f17b50..34badf0d 100644 --- a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift @@ -72,6 +72,12 @@ struct AppearancePaneView: View { } Section("Appearance") { + GhostTextPreview( + ghostColor: resolvedGhostTextColor, + opacity: suggestionSettings.ghostTextOpacity, + fontSize: GhostTextPreview.baseFontSize * CGFloat(suggestionSettings.ghostTextSizeMultiplier) + ) + LabeledContent { HStack(spacing: 8) { ForEach(GhostTextColorPreset.all) { preset in @@ -109,6 +115,30 @@ struct AppearancePaneView: View { systemImage: "circle.lefthalf.filled" ) } + + LabeledContent { + HStack(spacing: 10) { + TickMarkSlider( + value: ghostTextSizeBinding, + range: SuggestionSettingsModel.minimumGhostTextSizeMultiplier + ... SuggestionSettingsModel.maximumGhostTextSizeMultiplier, + step: SuggestionSettingsModel.ghostTextSizeMultiplierStep + ) + .frame(width: 180) + + Text(ghostTextSizeLabel) + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 42, alignment: .trailing) + } + } label: { + SettingsRowLabel( + title: "Ghost Text Size", + description: "Fine-tune how large suggestions appear. Lower it if the ghost text looks too big.", + systemImage: "textformat.size" + ) + } } } } @@ -150,6 +180,13 @@ struct AppearancePaneView: View { ) } + private var ghostTextSizeBinding: Binding { + Binding( + get: { suggestionSettings.ghostTextSizeMultiplier }, + set: { suggestionSettings.setGhostTextSizeMultiplier($0) } + ) + } + // MARK: - Ghost color swatch helpers /// Mirrors the overlay's automatic fallback (`GhostSuggestionView.ghostColor`) so the Automatic @@ -160,10 +197,23 @@ struct AppearancePaneView: View { : Color(red: 0.45, green: 0.45, blue: 0.45) } + /// Base color the live preview renders the ghost run in (before opacity): the user's custom pick, + /// or the same adaptive gray the overlay falls back to, so the sample matches the real suggestion. + private var resolvedGhostTextColor: Color { + SuggestionTextColorCodec.color(fromHex: suggestionSettings.customSuggestionTextColorHex) + ?? automaticGhostTextColor + } + private var ghostTextOpacityLabel: String { "\(Int((suggestionSettings.ghostTextOpacity * 100).rounded()))%" } + /// Multiplier shown as a scale factor (e.g. "1.0×") rather than a percentage, so it reads as a + /// size knob distinct from the opacity row's "%" right above it. + private var ghostTextSizeLabel: String { + String(format: "%.1f×", suggestionSettings.ghostTextSizeMultiplier) + } + @ViewBuilder private func ghostColorSwatch(for preset: GhostTextColorPreset) -> some View { let isSelected = GhostTextColorPreset.matching( diff --git a/CotabbyTests/GhostFontMetricsTests.swift b/CotabbyTests/GhostFontMetricsTests.swift index e1eec6a5..b9552fcd 100644 --- a/CotabbyTests/GhostFontMetricsTests.swift +++ b/CotabbyTests/GhostFontMetricsTests.swift @@ -102,4 +102,68 @@ final class GhostFontMetricsTests: XCTestCase { ) XCTAssertEqual(size, 20 * fallbackRatio, accuracy: 0.0001) } + + func testDefaultMultiplierLeavesAutoSizeUnchanged() { + // Omitting the multiplier must reproduce the pre-feature size exactly, so existing callers and + // the out-of-box default see no change. max(14, 20 * 0.78) = 15.6. + let size = GhostFontMetrics.pointSize( + caretHeight: 20, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum + ) + XCTAssertEqual(size, 15.6, accuracy: 0.0001) + } + + func testSizeMultiplierScalesResolvedSize() { + // The multiplier scales the auto-approximated 15.6 in both directions. + let smaller = GhostFontMetrics.pointSize( + caretHeight: 20, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum, + sizeMultiplier: 0.7 + ) + XCTAssertEqual(smaller, 15.6 * 0.7, accuracy: 0.0001) + + let larger = GhostFontMetrics.pointSize( + caretHeight: 20, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum, + sizeMultiplier: 1.3 + ) + XCTAssertEqual(larger, 15.6 * 1.3, accuracy: 0.0001) + } + + func testSizeMultiplierAppliesAfterTheMinimumClamp() { + // The multiplier scales the floored auto-size (not the raw caret math), so a field auto-sizing + // to the 14 floor still shrinks: 14 * 0.8 = 11.2, which is above the absolute floor. + let size = GhostFontMetrics.pointSize( + caretHeight: 5, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum, + sizeMultiplier: 0.8 + ) + XCTAssertEqual(size, minimum * 0.8, accuracy: 0.0001) + } + + func testSizeMultiplierRespectsAbsoluteFloor() { + // A degenerate multiplier far below the shipped range cannot push ghost text under the + // legibility floor: 14 * 0.5 = 7, clamped up to absoluteMinimumPointSize. + let size = GhostFontMetrics.pointSize( + caretHeight: 5, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum, + sizeMultiplier: 0.5 + ) + XCTAssertEqual(size, GhostFontMetrics.absoluteMinimumPointSize, accuracy: 0.0001) + } } diff --git a/CotabbyTests/ModelAndPresentationValueTests.swift b/CotabbyTests/ModelAndPresentationValueTests.swift index 2f7b3187..3c9528bf 100644 --- a/CotabbyTests/ModelAndPresentationValueTests.swift +++ b/CotabbyTests/ModelAndPresentationValueTests.swift @@ -328,3 +328,83 @@ final class GhostTextOpacitySettingsTests: XCTestCase { } } } + +final class GhostTextSizeSettingsTests: XCTestCase { + /// Same hosted-test deinit quarantine as `GhostTextOpacitySettingsTests`: retain the models for + /// the process lifetime and drive each test through `MainActor`. + private static var retainedModels: [SuggestionSettingsModel] = [] + + private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] + + override func tearDown() { + for suite in userDefaultsSuites { + suite.userDefaults.removePersistentDomain(forName: suite.suiteName) + } + userDefaultsSuites.removeAll() + super.tearDown() + } + + func test_defaultSizeMultiplierIsOneOnFreshInstall() { + runOnMainActor { + XCTAssertEqual( + makeModel().ghostTextSizeMultiplier, + SuggestionSettingsModel.defaultGhostTextSizeMultiplier + ) + } + } + + func test_setSizeMultiplierClampsBelowMinimumAndAboveMaximum() { + runOnMainActor { + let model = makeModel() + + model.setGhostTextSizeMultiplier(0.0) + XCTAssertEqual(model.ghostTextSizeMultiplier, SuggestionSettingsModel.minimumGhostTextSizeMultiplier) + + model.setGhostTextSizeMultiplier(5.0) + XCTAssertEqual(model.ghostTextSizeMultiplier, SuggestionSettingsModel.maximumGhostTextSizeMultiplier) + } + } + + func test_sizeMultiplierPersistsAcrossModelReload() { + runOnMainActor { + let userDefaults = makeUserDefaults() + makeModel(userDefaults: userDefaults).setGhostTextSizeMultiplier(0.8) + + XCTAssertEqual(makeModel(userDefaults: userDefaults).ghostTextSizeMultiplier, 0.8) + } + } + + @MainActor + private func makeModel(userDefaults: UserDefaults? = nil) -> SuggestionSettingsModel { + let model = SuggestionSettingsModel( + configuration: .standard, + userDefaults: userDefaults ?? makeUserDefaults() + ) + Self.retainedModels.append(model) + return model + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "GhostTextSizeSettingsTests-\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Expected an isolated UserDefaults suite") + return .standard + } + + userDefaults.removePersistentDomain(forName: suiteName) + userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) + return userDefaults + } + + private func runOnMainActor( + _ body: @MainActor () throws -> Result + ) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } + } +} diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index 8932ef9a..2010bdb4 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -147,6 +147,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveGloballyEnabled(false) store.saveUserName("Ada Lovelace") store.saveGhostTextOpacity(0.5) + store.saveGhostTextSizeMultiplier(0.8) store.saveFastModeEnabled(true) store.saveMenuBarWordCountVisible(false) @@ -155,6 +156,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertFalse(data.isGloballyEnabled) XCTAssertEqual(data.userName, "Ada Lovelace") XCTAssertEqual(data.ghostTextOpacity, 0.5, accuracy: 0.0001) + XCTAssertEqual(data.ghostTextSizeMultiplier, 0.8, accuracy: 0.0001) XCTAssertTrue(data.isFastModeEnabled) XCTAssertFalse(data.isMenuBarWordCountVisible) } @@ -182,6 +184,34 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertEqual(data.ghostTextOpacity, SuggestionSettingsStore.minimumGhostTextOpacity, accuracy: 0.0001) } + // MARK: - Ghost text size multiplier + + func test_load_defaultsGhostTextSizeMultiplierWhenUnset() async { + let defaults = makeIsolatedDefaults() + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertEqual( + data.ghostTextSizeMultiplier, + SuggestionSettingsStore.defaultGhostTextSizeMultiplier, + accuracy: 0.0001 + ) + } + + func test_load_clampsOutOfRangeGhostTextSizeMultiplier() async { + let defaults = makeIsolatedDefaults() + // Above the ceiling: must clamp down so a hand-edited default can't render giant ghost text. + defaults.set(5.0, forKey: "cotabbyGhostTextSizeMultiplier") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertEqual( + data.ghostTextSizeMultiplier, + SuggestionSettingsStore.maximumGhostTextSizeMultiplier, + accuracy: 0.0001 + ) + } + // MARK: - Power-based switching profiles func test_load_powerProfileEnginesDefaultToOpenSourceWhenAbsent() async {