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
22 changes: 21 additions & 1 deletion Sources/PalmierPro/Inspector/Tabs/TextTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct TextTab: View {
contentField
InspectorSection("Typography") {
fontRow
weightSlider
sizeSlider
}
InspectorSection("Appearance") {
Expand Down Expand Up @@ -55,7 +56,7 @@ struct TextTab: View {
}

private var fontRow: some View {
InspectorRow(icon: "character", label: "Font") {
return InspectorRow(icon: "character", label: "Font") {
FontPickerField(
current: style.fontName,
onPreview: { name in
Expand All @@ -72,6 +73,25 @@ struct TextTab: View {
}
}

private var weightSlider: some View {
InspectorRow(icon: "bold", label: "Weight") {
ScrubbableNumberField(
value: style.fontWeight,
range: 100...900,
format: "%.0f",
valueSuffix: "",
fieldWidth: 50,
onChanged: { newVal in
editor.applyTextStyle(clipId: clip.id) { $0.fontWeight = newVal }
}
) { newVal in
editor.commitTextStyle(clipId: clip.id) { $0.fontWeight = newVal }
editor.fitTextClipToContent(clipId: clip.id)
}
.disabled(!style.fontSupportsWeightAxis)
.opacity(style.fontSupportsWeightAxis ? 1.0 : 0.4)
}
}
private var sizeSlider: some View {
InspectorRow(icon: "textformat.size", label: "Size") {
ScrubbableNumberField(
Expand Down
54 changes: 47 additions & 7 deletions Sources/PalmierPro/Models/TextStyle.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import AppKit
import SwiftUI
import Foundation
import CoreText

struct TextStyle: Codable, Sendable, Equatable {
var fontName: String = "Helvetica-Bold"
var fontSize: Double = 96
var fontScale: Double = 1.0
var fontWeight: Double = 400
var color: RGBA = RGBA()
var alignment: Alignment = .center
var shadow: Shadow = Shadow()
Expand Down Expand Up @@ -41,7 +44,7 @@ struct TextStyle: Codable, Sendable, Equatable {
}

private enum CodingKeys: String, CodingKey {
case fontName, fontSize, fontScale, color, alignment, shadow, background, border
case fontName, fontSize, fontScale, fontWeight, color, alignment, shadow, background, border
}
}

Expand All @@ -53,6 +56,7 @@ extension TextStyle {
fontName: (try? c.decode(String.self, forKey: .fontName)) ?? "Helvetica-Bold",
fontSize: (try? c.decode(Double.self, forKey: .fontSize)) ?? 96,
fontScale: (try? c.decode(Double.self, forKey: .fontScale)) ?? 1.0,
fontWeight: (try? c.decode(Double.self, forKey: .fontWeight)) ?? 400,
color: (try? c.decode(RGBA.self, forKey: .color)) ?? RGBA(),
alignment: (try? c.decode(Alignment.self, forKey: .alignment)) ?? .center,
shadow: (try? c.decode(Shadow.self, forKey: .shadow)) ?? Shadow(),
Expand Down Expand Up @@ -118,7 +122,43 @@ extension TextStyle.RGBA {

extension TextStyle {
func resolvedFont(size: CGFloat) -> NSFont {
NSFont(name: fontName, size: size) ?? NSFont.boldSystemFont(ofSize: size)
// 1. Load the base font, falling back to bold system font if missing
let baseFont = NSFont(name: self.fontName, size: size) ?? .boldSystemFont(ofSize: size)

// 2. Sanitize the weight to protect against Infinity math crashes
var safeWeight = self.fontWeight
if safeWeight.isNaN || safeWeight.isInfinite { safeWeight = 400.0 }
safeWeight = max(1.0, min(1000.0, safeWeight))

// 3. AppKit strictly requires NSNumber for variation tags and values
let variation: [NSNumber: NSNumber] = [
NSNumber(value: 0x77676874): NSNumber(value: safeWeight) // 'wght' axis
]

// 4. Inject the variation directly into the font's descriptor
let descriptor = baseFont.fontDescriptor.addingAttributes([
.variation: variation
])

// 5. Ask AppKit to resolve a completely fresh font instance
if let variableFont = NSFont(descriptor: descriptor, size: size) {
// Safety net: ensure the font resolved with valid geometry
let height = variableFont.boundingRectForFont.height
if height > 0 && !height.isNaN && !height.isInfinite {
return variableFont
}
}

// Fallback if the font doesn't support the variation
return baseFont
}
/// True if `fontName` resolves to a font with a `wght` variation axis.
var fontSupportsWeightAxis: Bool {
guard let base = NSFont(name: fontName, size: 12) else { return false }
guard let axes = CTFontCopyVariationAxes(base) as? [[String: Any]] else { return false }
return axes.contains { axis in
(axis[kCTFontVariationAxisIdentifierKey as String] as? NSNumber)?.intValue == 0x77676874
}
}

var nsColor: NSColor { color.nsColor }
Expand All @@ -130,17 +170,17 @@ extension TextStyle {
case .center: p.alignment = .center
case .right: p.alignment = .right
}
p.lineBreakMode = .byWordWrapping
return p
}

/// `includeColor: false` for bounding measurement (color doesn't affect size).
func attributes(size: CGFloat, includeColor: Bool = true) -> [NSAttributedString.Key: Any] {
var attrs: [NSAttributedString.Key: Any] = [
.font: resolvedFont(size: size),
.paragraphStyle: paragraphStyle,
.paragraphStyle: paragraphStyle
]
if includeColor { attrs[.foregroundColor] = nsColor }
if includeColor {
attrs[.foregroundColor] = nsColor
}
return attrs
}
}
Expand All @@ -153,4 +193,4 @@ extension TextStyle.Alignment {
case .right: .right
}
}
}
}
17 changes: 17 additions & 0 deletions Tests/PalmierProTests/TextStyleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import XCTest
import Foundation
@testable import PalmierPro

final class TextStyleTests: XCTestCase {

func testFontWeightDefaultsAndRoundTrips() throws {
var style = TextStyle()
XCTAssertEqual(style.fontWeight, 400)

style.fontWeight = 700
let data = try JSONEncoder().encode(style)
let decoded = try JSONDecoder().decode(TextStyle.self, from: data)
XCTAssertEqual(decoded.fontWeight, 700)
}

}
Loading