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
68 changes: 54 additions & 14 deletions Cotabby/Services/Presentation/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,10 @@ final class OverlayController: SuggestionOverlayControlling {
default:
return resolution
}
let size = geometry.resolvedFieldStyle?.fontPointSize ?? resolution.font.pointSize
// `size` is the host's (the families are judged against the host's own widths); every
// resolution built from it below takes the user's multiplier, which `resolution` already has.
let multiplier = CGFloat(suggestionSettings.ghostTextSizeMultiplier)
let size = geometry.resolvedFieldStyle?.fontPointSize ?? resolution.font.pointSize / max(multiplier, 0.01)
markUnavailableNamedFace(resolution, for: geometry)
var evidence = typefaceEvidence[identity] ?? TypefaceEvidence()
let sample = Self.widthSample(of: geometry)
Expand All @@ -837,7 +840,7 @@ final class OverlayController: SuggestionOverlayControlling {
}
if unavailableNamedFaceFields.contains(identity) {
typefaceEvidence[identity] = evidence
return Self.scaledSystemFace(size: size, evidence: evidence)
return Self.scaledSystemFace(size: size, evidence: evidence, sizeMultiplier: multiplier)
}
// A system face scaled to a width sample is scaled ONCE per field, to the first sample long
// enough (`TypefaceEvidence.scalingAdoptionLength`): a sample that grows poll by poll (the
Expand All @@ -851,7 +854,11 @@ final class OverlayController: SuggestionOverlayControlling {
evidence.scalingSample != nil {
typefaceEvidence[identity] = evidence
return Self.scaledSystemFace(
size: size, evidence: evidence, reportedSize: zoomStep(for: geometry).reportedSize, zoomKind: zoomStep(for: geometry).kind
size: size,
evidence: evidence,
reportedSize: zoomStep(for: geometry).reportedSize,
zoomKind: zoomStep(for: geometry).kind,
sizeMultiplier: multiplier
)
}
guard let sample else {
Expand Down Expand Up @@ -879,7 +886,9 @@ final class OverlayController: SuggestionOverlayControlling {
"verdict": .string("\(verdict)")
]
CotabbyLogger.suggestion.debug("Typeface evidence", metadata: evidenceMetadata)
return Self.settledResolution(for: verdict, replacing: resolution, systemFamily: systemFamily, size: size, evidence: evidence)
return Self.settledResolution(
for: verdict, replacing: resolution, systemFamily: systemFamily, size: size, evidence: evidence, sizeMultiplier: multiplier
)
}

/// Marks a field whose face the ghost cannot load, which from then on takes the system face
Expand Down Expand Up @@ -919,28 +928,33 @@ final class OverlayController: SuggestionOverlayControlling {
/// The face a typeface verdict settles on, or `resolution` when it settles nothing new: the
/// system face or a matched family replaces it, and an undecidable field takes the system face
/// scaled to its own width.
private static func settledResolution(
static func settledResolution(
for verdict: TypefaceEvidence.Verdict,
replacing resolution: GhostFontResolver.Resolution,
systemFamily: String,
size: CGFloat,
evidence: TypefaceEvidence
evidence: TypefaceEvidence,
sizeMultiplier: CGFloat = 1
) -> GhostFontResolver.Resolution {
switch verdict {
case .singleSample:
return resolution
case .family(let family) where family == systemFamily:
guard resolution.font.familyName != systemFamily else { return resolution }
return GhostFontResolver.Resolution(font: NSFont.systemFont(ofSize: size), provenance: .hostSizeSystem, widthAgreement: 1)
return userScaled(
GhostFontResolver.Resolution(font: NSFont.systemFont(ofSize: size), provenance: .hostSizeSystem, widthAgreement: 1),
by: sizeMultiplier
)
case .family(let family):
guard resolution.font.familyName != family, let font = GhostFontResolver.familyFont(family, size: size) else {
return resolution
}
return GhostFontResolver.Resolution(
font: font, provenance: .hostSizeMatchedFamily, widthAgreement: resolution.widthAgreement
return userScaled(
GhostFontResolver.Resolution(font: font, provenance: .hostSizeMatchedFamily, widthAgreement: resolution.widthAgreement),
by: sizeMultiplier
)
case .undecidable:
return scaledSystemFace(size: size, evidence: evidence)
return scaledSystemFace(size: size, evidence: evidence, sizeMultiplier: sizeMultiplier)
}
}

Expand Down Expand Up @@ -985,23 +999,49 @@ final class OverlayController: SuggestionOverlayControlling {
/// (three presentations, 2026-09-11). As a stand-in for another face (one the host names but
/// this Mac lacks, or one no family fits) the scaled size is that face's advance, not a size the
/// host set, and it is kept as the sample gives it.
///
/// Every size here is the host's, measured from the host's own text, so the user's ghost size
/// multiplier is applied last, as `GhostFontResolver.resolve` applies it to the resolution this
/// replaces. Without it a field at a 60% multiplier rendered and remembered its ghost at the
/// host's full size (15.7 where 9.4 was asked for in Claude's composer, 2026-10-29).
static func scaledSystemFace(
size: CGFloat, evidence: TypefaceEvidence, reportedSize: CGFloat? = nil, zoomKind: HostZoomLadder.Kind? = nil
size: CGFloat,
evidence: TypefaceEvidence,
reportedSize: CGFloat? = nil,
zoomKind: HostZoomLadder.Kind? = nil,
sizeMultiplier: CGFloat = 1
) -> GhostFontResolver.Resolution {
guard let sample = evidence.scalingSample else {
return GhostFontResolver.Resolution(font: NSFont.systemFont(ofSize: size), provenance: .hostSizeSystem, widthAgreement: 1)
return userScaled(
GhostFontResolver.Resolution(font: NSFont.systemFont(ofSize: size), provenance: .hostSizeSystem, widthAgreement: 1),
by: sizeMultiplier
)
}
let scaled = GhostFontResolver.scaledSystemResolution(size: size, sample: sample.text, width: sample.width)
guard let reportedSize, let zoomKind,
let snapped = HostZoomLadder.snappedSize(measured: scaled.font.pointSize, reported: reportedSize, kind: zoomKind),
snapped != scaled.font.pointSize
else {
return scaled
return userScaled(scaled, by: sizeMultiplier)
}
let font = GhostFontResolver.resized(scaled.font, to: snapped)
let measured = GhostFontResolver.width(of: sample.text, font: font)
return userScaled(
GhostFontResolver.Resolution(
font: font, provenance: .hostSizeScaledSystem, widthAgreement: measured > 0 ? sample.width / measured : 1
),
by: sizeMultiplier
)
}

/// A resolution at the host's size, resized by the user's ghost size multiplier. The width
/// agreement is the host-size face's, as in `GhostFontResolver.resolve`.
static func userScaled(_ resolution: GhostFontResolver.Resolution, by sizeMultiplier: CGFloat) -> GhostFontResolver.Resolution {
guard sizeMultiplier > 0, abs(sizeMultiplier - 1) > 0.001 else { return resolution }
return GhostFontResolver.Resolution(
font: font, provenance: .hostSizeScaledSystem, widthAgreement: measured > 0 ? sample.width / measured : 1
font: GhostFontResolver.resized(resolution.font, to: resolution.font.pointSize * sizeMultiplier),
provenance: resolution.provenance,
widthAgreement: resolution.widthAgreement
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,37 @@ final class OverlayControllerTypefaceTests: XCTestCase {
XCTAssertEqual(standIn.font.pointSize, 15.45, accuracy: 0.01, "a stand-in three percent off the ladder keeps its sample's size")
}

/// Claude's composer at a 60% ghost size (2026-10-29): the system face scaled to the host's
/// 15.7 width rendered and was remembered at 15.7, not 9.4, and every later launch started there.
/// The width sample sizes the host's face; the user's multiplier sizes the ghost.
func testAScaledSystemFaceKeepsTheUsersSizeMultiplier() {
let text = "I was thinking that"
var evidence = TypefaceEvidence()
evidence.record(.init(text: text, width: GhostFontResolver.width(of: text, font: NSFont.systemFont(ofSize: 15.7))), resolverFamily: nil)
let ghost = OverlayController.scaledSystemFace(size: 15, evidence: evidence, sizeMultiplier: 0.6)
XCTAssertEqual(ghost.font.pointSize, 15.7 * 0.6, accuracy: 0.02)
XCTAssertEqual(ghost.provenance, .hostSizeScaledSystem)

// A sample within the ladder's tolerance of the reported 15 snaps to it, and the snapped
// size is scaled too.
var nearStep = TypefaceEvidence()
nearStep.record(.init(text: text, width: GhostFontResolver.width(of: text, font: NSFont.systemFont(ofSize: 15.0621))), resolverFamily: nil)
let snapped = OverlayController.scaledSystemFace(
size: 15, evidence: nearStep, reportedSize: 15, zoomKind: .chromeBrowser, sizeMultiplier: 0.6
)
XCTAssertEqual(snapped.font.pointSize, 15 * 0.6, accuracy: 0.001, "a zoom-step snap is the host's size, still scaled for the ghost")

let undecided = OverlayController.settledResolution(
for: .undecidable,
replacing: resolution(NSFont.systemFont(ofSize: 9), .hostSizeSystem),
systemFamily: NSFont.systemFont(ofSize: 15).familyName ?? "",
size: 15,
evidence: evidence,
sizeMultiplier: 0.6
)
XCTAssertEqual(undecided.font.pointSize, 15.7 * 0.6, accuracy: 0.02)
}

func testAMatchWhoseAdvanceDisagreesWithTheHostsSampleIsRefused() {
// Claude's composer: the caret travelled the width of Georgia 15.4 over this text; the
// system face at 16.15 scored well on the shapes but is five percent wider than that.
Expand Down