Skip to content
Open
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
6 changes: 6 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@
7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; };
7BE110312F7E8E845763D6A5 /* InsertionSafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */; };
7BEA76E69707BC760B0D2394 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45161160AC94229A630FC3A /* LlamaRuntimeManager.swift */; };
7C03CF993AFF3B1BCD61F31C /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; };
7C5BD8FDACC491EF62665FB2 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; };
7CD9B73CE933F15B490B3605 /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB1F528070F8F9108720DB /* SettingsSearchResultRow.swift */; };
7D87C0AEF85FA0653C9C6031 /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */; };
Expand Down Expand Up @@ -522,6 +523,7 @@
A8854697A9EB29DB737C4A26 /* SelfCaptureGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571B221ACAFED86DABB10F48 /* SelfCaptureGateTests.swift */; };
A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; };
A8DCC8CFAD1B698A32E1B077 /* SuggestionClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 743B7207A91EB1BFADD0C5E7 /* SuggestionClientError.swift */; };
A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; };
AA00D42DFD1EE094E01A7EEA /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; };
AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; };
AAC519AC668EF430F68B06CA /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D68B5ABA427D3E87000E78 /* InlineCommandCoordinator.swift */; };
Expand Down Expand Up @@ -818,6 +820,7 @@
0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = "<group>"; };
0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = "<group>"; };
0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = "<group>"; };
0F328B43743D803A16045110 /* HostFontRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFontRegistry.swift; sourceTree = "<group>"; };
110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = "<group>"; };
11CF768650A90705FC0D2730 /* SuggestionTextColorCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodec.swift; sourceTree = "<group>"; };
12082948AEBC0DFE5ADC6961 /* SuggestionAvailabilityEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2559,6 +2562,7 @@
2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */,
872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */,
8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */,
0F328B43743D803A16045110 /* HostFontRegistry.swift */,
924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */,
9A3C5A66AA93E50E4A64ED46 /* OverlayController.swift */,
);
Expand Down Expand Up @@ -3338,6 +3342,7 @@
9210DC383F2D181F617A2D74 /* GhostTextPreview.swift in Sources */,
31FE2E49FD1491E90B32B956 /* HardwareCapabilityProbe.swift in Sources */,
507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */,
7C03CF993AFF3B1BCD61F31C /* HostFontRegistry.swift in Sources */,
09B092E8A682D127FC9872A7 /* HuggingFaceAPIClient.swift in Sources */,
7E11BEAD32E9FF170C92000A /* HuggingFaceModelBrowserView.swift in Sources */,
FF46903861BA67AED8C24EF8 /* HuggingFaceModels.swift in Sources */,
Expand Down Expand Up @@ -3619,6 +3624,7 @@
C657A9C35E432D0A21D96F9F /* GhostTextPreview.swift in Sources */,
8D380BEC82C2969F3ED2161A /* HardwareCapabilityProbe.swift in Sources */,
B65B49F24F59154A7611FD22 /* HomePaneView.swift in Sources */,
A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */,
0B7C1A4F515F63462CCCE9EA /* HuggingFaceAPIClient.swift in Sources */,
F0DCEFED640B453A5ECEB810 /* HuggingFaceModelBrowserView.swift in Sources */,
AB1B25E213AE0A79C6993239 /* HuggingFaceModels.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,8 @@ extension SuggestionCoordinator {
focusChangeSequence: context.focusChangeSequence,
focusedInputIdentityKey: context.focusedInputIdentityKey,
isCorrection: isCorrection,
resolvedFieldStyle: context.resolvedFieldStyle
resolvedFieldStyle: context.resolvedFieldStyle,
observedContentEdges: context.observedContentEdges
)
_ = overlayPresenter.present(
text: text,
Expand Down Expand Up @@ -812,8 +813,11 @@ extension SuggestionCoordinator {
)
}
// Run-measured derived rects are kept unconditionally: run frames carry the host's
// real line positions, including blank lines some hosts omit from the AX text.
if context.observedContentEdges != nil {
// real line positions, including blank lines some hosts omit from the AX text. The
// provenance check matters because content edges can now also come from the host's
// line-query attributes, which describe a left margin but carry no line information —
// letting those skip the repair would leave a wrong-line web caret uncorrected.
if context.observedContentEdges?.isRunMeasured == true {
return LayoutRepairedAnchor(
rect: fallbackRect, quality: .derived, outcome: nil, skipReason: .runMeasuredGeometry
)
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Models/Focus/FocusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ nonisolated struct ObservedContentEdges: Equatable, Sendable {
let leftX: CGFloat
/// Global Cocoa-coordinate top edge (maxY) of the topmost text run.
let topY: CGFloat
/// True only when these edges came from walking the host's child text-run frames. Those frames
/// carry the host's real line positions, which is why `layoutRepairedAnchor` lets them outrank
/// its own layout estimate for a web field. Edges obtained any other way — the host's line-query
/// attributes, for instance — describe a margin but say nothing about which visual line the
/// caret is on, so they must not buy that same trust. Defaults to `false` so a future source has
/// to opt in deliberately rather than inherit an exemption it did not earn.
let isRunMeasured: Bool

init(leftX: CGFloat, topY: CGFloat, isRunMeasured: Bool = false) {
self.leftX = leftX
self.topY = topY
self.isRunMeasured = isRunMeasured
}
}

/// This snapshot is the future handoff point into suggestion generation.
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Models/Settings/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ struct SuggestionPresentationSettings: Equatable {
var customSuggestionTextColorHex: String?
var ghostTextOpacity: Double
var ghostTextSizeMultiplier: Double
/// Point-size clamps applied to the caret-approximated ghost size before the multiplier.
var ghostFontSizeFloor: Double
var ghostFontSizeCeiling: Double
var isMenuBarIconVisible: Bool
var isMenuBarWordCountVisible: Bool
var mirrorPreference: MirrorPreference
Expand Down Expand Up @@ -339,6 +342,16 @@ extension SuggestionSettingsData {
set { presentation.ghostTextSizeMultiplier = newValue }
}

var ghostFontSizeFloor: Double {
get { presentation.ghostFontSizeFloor }
set { presentation.ghostFontSizeFloor = newValue }
}

var ghostFontSizeCeiling: Double {
get { presentation.ghostFontSizeCeiling }
set { presentation.ghostFontSizeCeiling = newValue }
}

var isMenuBarIconVisible: Bool {
get { presentation.isMenuBarIconVisible }
set { presentation.isMenuBarIconVisible = newValue }
Expand Down
50 changes: 50 additions & 0 deletions Cotabby/Models/Settings/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ final class SuggestionSettingsModel: ObservableObject {
/// `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
/// Point-size floor and ceiling for the caret-approximated ghost size, applied before
/// `ghostTextSizeMultiplier`. Read live by `OverlayController` for the same reason the
/// multiplier is: they change presentation, not the generation request.
@Published private(set) var ghostFontSizeFloor: Double
@Published private(set) var ghostFontSizeCeiling: Double
@Published private(set) var selectedEngine: SuggestionEngineKind
@Published private(set) var openAICompatibleBaseURL: String
@Published private(set) var openAICompatibleModelName: String
Expand Down Expand Up @@ -173,6 +178,13 @@ final class SuggestionSettingsModel: ObservableObject {
static let minimumGhostTextSizeMultiplier = SuggestionSettingsStore.minimumGhostTextSizeMultiplier
static let maximumGhostTextSizeMultiplier = SuggestionSettingsStore.maximumGhostTextSizeMultiplier
static let ghostTextSizeMultiplierStep = SuggestionSettingsStore.ghostTextSizeMultiplierStep
static let defaultGhostFontSizeFloor = SuggestionSettingsStore.defaultGhostFontSizeFloor
static let minimumGhostFontSizeFloor = SuggestionSettingsStore.minimumGhostFontSizeFloor
static let maximumGhostFontSizeFloor = SuggestionSettingsStore.maximumGhostFontSizeFloor
static let defaultGhostFontSizeCeiling = SuggestionSettingsStore.defaultGhostFontSizeCeiling
static let minimumGhostFontSizeCeiling = SuggestionSettingsStore.minimumGhostFontSizeCeiling
static let maximumGhostFontSizeCeiling = SuggestionSettingsStore.maximumGhostFontSizeCeiling
static let ghostFontSizeStep = SuggestionSettingsStore.ghostFontSizeStep
static let minimumFadeInDuration = SuggestionSettingsStore.minimumFadeInDuration
static let maximumFadeInDuration = SuggestionSettingsStore.maximumFadeInDuration
static let fadeInDurationStep = SuggestionSettingsStore.fadeInDurationStep
Expand Down Expand Up @@ -209,6 +221,8 @@ final class SuggestionSettingsModel: ObservableObject {
customSuggestionTextColorHex = data.customSuggestionTextColorHex
ghostTextOpacity = data.ghostTextOpacity
ghostTextSizeMultiplier = data.ghostTextSizeMultiplier
ghostFontSizeFloor = data.ghostFontSizeFloor
ghostFontSizeCeiling = data.ghostFontSizeCeiling
selectedEngine = data.selectedEngine
openAICompatibleBaseURL = data.openAICompatibleBaseURL
openAICompatibleModelName = data.openAICompatibleModelName
Expand Down Expand Up @@ -286,6 +300,8 @@ final class SuggestionSettingsModel: ObservableObject {
customSuggestionTextColorHex = data.customSuggestionTextColorHex
ghostTextOpacity = data.ghostTextOpacity
ghostTextSizeMultiplier = data.ghostTextSizeMultiplier
ghostFontSizeFloor = data.ghostFontSizeFloor
ghostFontSizeCeiling = data.ghostFontSizeCeiling
selectedEngine = data.selectedEngine
openAICompatibleBaseURL = data.openAICompatibleBaseURL
openAICompatibleModelName = data.openAICompatibleModelName
Expand Down Expand Up @@ -411,6 +427,8 @@ final class SuggestionSettingsModel: ObservableObject {
customSuggestionTextColorHex: customSuggestionTextColorHex,
ghostTextOpacity: ghostTextOpacity,
ghostTextSizeMultiplier: ghostTextSizeMultiplier,
ghostFontSizeFloor: ghostFontSizeFloor,
ghostFontSizeCeiling: ghostFontSizeCeiling,
isMenuBarIconVisible: isMenuBarIconVisible,
isMenuBarWordCountVisible: isMenuBarWordCountVisible,
mirrorPreference: mirrorPreference,
Expand Down Expand Up @@ -1128,6 +1146,38 @@ final class SuggestionSettingsModel: ObservableObject {
store.saveGhostTextSizeMultiplier(clamped)
}

/// Raising the floor past the ceiling (or lowering the ceiling past the floor) would describe an
/// empty range, which `GhostFontMetrics` would resolve by letting the ceiling win — silently
/// ignoring the control the user just moved. Pushing the other value along keeps both controls
/// honest and the range non-empty, and it matches how paired min/max controls behave elsewhere.
func setGhostFontSizeFloor(_ points: Double) {
let clamped = SuggestionSettingsStore.clampedGhostFontSizeFloor(points)
guard ghostFontSizeFloor != clamped else {
return
}

ghostFontSizeFloor = clamped
store.saveGhostFontSizeFloor(clamped)

if ghostFontSizeCeiling < clamped {
setGhostFontSizeCeiling(clamped)
}
}

func setGhostFontSizeCeiling(_ points: Double) {
let clamped = SuggestionSettingsStore.clampedGhostFontSizeCeiling(points)
guard ghostFontSizeCeiling != clamped else {
return
}

ghostFontSizeCeiling = clamped
store.saveGhostFontSizeCeiling(clamped)

if ghostFontSizeFloor > clamped {
setGhostFontSizeFloor(clamped)
}
}

func setUserName(_ name: String) {
guard userName != name else {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
/// The host field's own text font/color, so the overlay can render ghost text that matches the
/// field instead of always using the system font and a fixed gray. Nil falls back to defaults.
let resolvedFieldStyle: ResolvedFieldStyle?
/// Where the host actually starts drawing text, when it could be measured. A field's `AXFrame`
/// is not its text area — Word publishes the whole page, so its left edge is the paper's edge
/// rather than the document's margin. Ghost text that wraps onto another line aligns to this
/// instead of the frame, so overflow lines land on the host's margin like its own text does.
let observedContentEdges: ObservedContentEdges?

init(
caretRect: CGRect,
Expand All @@ -66,7 +71,8 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
focusChangeSequence: UInt64 = 0,
focusedInputIdentityKey: UInt64 = 0,
isCorrection: Bool = false,
resolvedFieldStyle: ResolvedFieldStyle? = nil
resolvedFieldStyle: ResolvedFieldStyle? = nil,
observedContentEdges: ObservedContentEdges? = nil
) {
self.caretRect = caretRect
self.inputFrameRect = inputFrameRect
Expand All @@ -79,6 +85,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
self.focusedInputIdentityKey = focusedInputIdentityKey
self.isCorrection = isCorrection
self.resolvedFieldStyle = resolvedFieldStyle
self.observedContentEdges = observedContentEdges
}

/// Returns a copy with only `caretRect` replaced. Used to advance the ghost by an exact measured
Expand All @@ -94,7 +101,8 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
isRightToLeft: isRightToLeft,
focusChangeSequence: focusChangeSequence,
focusedInputIdentityKey: focusedInputIdentityKey,
resolvedFieldStyle: resolvedFieldStyle
resolvedFieldStyle: resolvedFieldStyle,
observedContentEdges: observedContentEdges
)
}
}
Expand Down
Loading