Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -505,6 +506,7 @@
A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = "<group>"; };
A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = "<group>"; };
A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeManager.swift; sourceTree = "<group>"; };
A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextPreview.swift; sourceTree = "<group>"; };
A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeModels.swift; sourceTree = "<group>"; };
A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = "<group>"; };
A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextCoordinator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -810,6 +812,7 @@
children = (
E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */,
E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */,
A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */,
19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */,
907549CB913B40C28B953A5D /* SettingsRowLabel.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -368,7 +369,8 @@ final class OverlayController: SuggestionOverlayControlling {
fieldMetrics: fieldMetrics,
fallbackRatio: Layout.fontToLineHeightRatio,
minimum: Layout.minimumGhostFontSize,
maximum: qualityCap
maximum: qualityCap,
sizeMultiplier: CGFloat(suggestionSettings.ghostTextSizeMultiplier)
)
}

Expand Down
19 changes: 16 additions & 3 deletions Cotabby/Support/GhostFontMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions Cotabby/Support/MirrorOverlayLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,20 @@ struct MirrorOverlayLayout: Equatable {
visibleFrame: CGRect,
showsAcceptanceHint: Bool,
autoAcceptTrailingPunctuation: Bool = true,
sizeMultiplier: CGFloat = 1,
reason: CompletionRenderMode.MirrorReason
) -> MirrorOverlayLayout {
let normalizedSuggestion = normalizedDisplayText(suggestion)
let highlightedPrefix = highlightedAcceptancePrefix(
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
Expand All @@ -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)
Expand Down Expand Up @@ -135,7 +141,7 @@ struct MirrorOverlayLayout: Equatable {

return MirrorOverlayLayout(
panelFrame: panelFrame,
fontSize: Metrics.fontSize,
fontSize: scaledFontSize,
suggestionText: normalizedSuggestion,
highlightedPrefix: highlightedPrefix,
isRightToLeft: geometry.isRightToLeft,
Expand Down
30 changes: 30 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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:))
Expand Down Expand Up @@ -305,6 +321,7 @@ struct SuggestionSettingsStore {
disabledAppRules: resolvedDisabledAppRules,
customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex,
ghostTextOpacity: resolvedGhostTextOpacity,
ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier,
selectedEngine: resolvedEngine,
selectedWordCountPreset: resolvedWordCountPreset,
isUsingCustomWordCountRange: resolvedUsingCustomWordCountRange,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading