Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

240 lines
10 KiB
Swift

import AppKit
/// All user-configurable visual settings for the editor.
///
/// Stored as simple types (String, CGFloat) so it serializes cleanly to
/// UserDefaults. Computed properties provide the `NSFont` / `NSColor`
/// equivalents for rendering.
public struct EditorTheme: Equatable, Sendable {
// MARK: - Font
public var fontName: String
public var fontSize: CGFloat
/// Monospaced font for code (inline, blocks, tables). An empty name means the
/// system monospaced font.
public var monospaceFontName: String
public var monospaceFontSize: CGFloat
/// Whether ligatures are enabled for the standard (body) and monospaced fonts.
public var standardLigatures: Bool
public var monospaceLigatures: Bool
/// Whether editor text is antialiased (a single editor-wide setting).
public var antialias: Bool
// MARK: - Colors (hex strings, e.g. "#3366E6")
public var linkBlueHex: String
public var codeHex: String
/// Color for LaTeX operators/commands (`_`, `^`, `\sum`, …) in raw math.
public var mathOperatorHex: String
/// Color for numbers in raw math.
public var mathNumberHex: String
// MARK: - Spacing
public var lineSpacing: CGFloat
public var paragraphSpacingBefore: CGFloat
public init(fontName: String, fontSize: CGFloat, linkBlueHex: String, codeHex: String,
lineSpacing: CGFloat, paragraphSpacingBefore: CGFloat,
mathOperatorHex: String = "#D70015", mathNumberHex: String = "#C77800",
monospaceFontName: String = "", monospaceFontSize: CGFloat = 14,
standardLigatures: Bool = true, monospaceLigatures: Bool = false,
antialias: Bool = true) {
self.fontName = fontName
self.fontSize = fontSize
self.linkBlueHex = linkBlueHex
self.codeHex = codeHex
self.lineSpacing = lineSpacing
self.paragraphSpacingBefore = paragraphSpacingBefore
self.mathOperatorHex = mathOperatorHex
self.mathNumberHex = mathNumberHex
self.monospaceFontName = monospaceFontName
self.monospaceFontSize = monospaceFontSize
self.standardLigatures = standardLigatures
self.monospaceLigatures = monospaceLigatures
self.antialias = antialias
}
// MARK: - Defaults
public static let `default` = EditorTheme(
fontName: "Iowan Old Style",
fontSize: 16,
linkBlueHex: "#3366E6",
codeHex: "#8A2425",
lineSpacing: 4,
paragraphSpacingBefore: 2
)
// MARK: - Derived Properties
@MainActor public var bodyFont: NSFont {
let base = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize)
return Self.applyingLigatures(standardLigatures, to: base)
}
/// The monospaced font, at `size` (default: the theme's monospace size).
/// Falls back to the system monospaced font when no family is set or it can't
/// be loaded.
@MainActor public func monospaceFont(ofSize size: CGFloat? = nil) -> NSFont {
let resolved = size ?? monospaceFontSize
let base: NSFont = {
if !monospaceFontName.isEmpty, let font = NSFont(name: monospaceFontName, size: resolved) {
return font
}
// Default when no family is chosen: Input Mono Narrow, then Input Mono,
// then the system monospaced font — all Regular.
for name in ["InputMonoNarrow-Regular", "InputMono-Regular"] {
if let font = NSFont(name: name, size: resolved) { return font }
}
return .monospacedSystemFont(ofSize: resolved, weight: .regular)
}()
return Self.applyingLigatures(monospaceLigatures, to: base)
}
/// Returns `font` with ligatures disabled (when `on` is false) by turning off
/// both common ligatures and contextual alternates in its descriptor — the
/// latter is what drives programming ligatures like Fira Code's `=>`/`==`.
/// Baking it into the font (rather than the `.ligature` attribute) is what the
/// editor's TextKit 2 pipeline reliably honors.
private static func applyingLigatures(_ on: Bool, to font: NSFont) -> NSFont {
guard !on else { return font }
let kContextualAlternatesType = 36
let kContextualAlternatesOffSelector = 1
let settings: [[NSFontDescriptor.FeatureKey: Int]] = [
[.typeIdentifier: kLigaturesType, .selectorIdentifier: kCommonLigaturesOffSelector],
[.typeIdentifier: kContextualAlternatesType, .selectorIdentifier: kContextualAlternatesOffSelector],
]
let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: settings])
return NSFont(descriptor: descriptor, size: font.pointSize) ?? font
}
@MainActor public var linkBlueColor: NSColor {
NSColor(hex: linkBlueHex) ?? .systemBlue
}
@MainActor public var codeColor: NSColor {
NSColor(hex: codeHex) ?? .systemRed
}
@MainActor public var mathOperatorColor: NSColor {
NSColor(hex: mathOperatorHex) ?? .systemRed
}
@MainActor public var mathNumberColor: NSColor {
NSColor(hex: mathNumberHex) ?? .systemOrange
}
// MARK: - UserDefaults Persistence
private enum Keys {
static let fontName = "EditorFontName"
static let fontSize = "EditorFontSize"
static let monospaceFontName = "EditorMonospaceFontName"
static let monospaceFontSize = "EditorMonospaceFontSize"
static let standardLigatures = "EditorStandardLigatures"
static let monospaceLigatures = "EditorMonospaceLigatures"
static let antialias = "EditorAntialias"
static let linkBlueHex = "EditorLinkBlueHex"
static let codeHex = "EditorCodeHex"
static let mathOperatorHex = "EditorMathOperatorHex"
static let mathNumberHex = "EditorMathNumberHex"
static let lineSpacing = "EditorLineSpacing"
static let paragraphSpacingBefore = "EditorParagraphSpacingBefore"
}
public static func load(from defaults: UserDefaults = .standard) -> EditorTheme {
let d = defaults
let def = EditorTheme.default
let fontName = d.string(forKey: Keys.fontName) ?? def.fontName
let fontSize: CGFloat = {
let v = CGFloat(d.float(forKey: Keys.fontSize))
return v > 0 ? v : def.fontSize
}()
// The accent color is not user-customizable; always use the default so a
// stale persisted value (e.g. left over from the removed in-app accent
// picker) can't leak in and recolor links.
let linkBlueHex = def.linkBlueHex
let monospaceFontName = d.string(forKey: Keys.monospaceFontName) ?? def.monospaceFontName
let monospaceFontSize: CGFloat = {
let v = CGFloat(d.float(forKey: Keys.monospaceFontSize))
return v > 0 ? v : def.monospaceFontSize
}()
let standardLigatures = d.object(forKey: Keys.standardLigatures) as? Bool ?? def.standardLigatures
let monospaceLigatures = d.object(forKey: Keys.monospaceLigatures) as? Bool ?? def.monospaceLigatures
let antialias = d.object(forKey: Keys.antialias) as? Bool ?? def.antialias
let codeHex = d.string(forKey: Keys.codeHex) ?? def.codeHex
let mathOperatorHex = d.string(forKey: Keys.mathOperatorHex) ?? def.mathOperatorHex
let mathNumberHex = d.string(forKey: Keys.mathNumberHex) ?? def.mathNumberHex
let lineSpacing: CGFloat = d.object(forKey: Keys.lineSpacing) != nil
? CGFloat(d.float(forKey: Keys.lineSpacing))
: def.lineSpacing
let paragraphSpacingBefore: CGFloat = d.object(forKey: Keys.paragraphSpacingBefore) != nil
? CGFloat(d.float(forKey: Keys.paragraphSpacingBefore))
: def.paragraphSpacingBefore
return EditorTheme(
fontName: fontName,
fontSize: fontSize,
linkBlueHex: linkBlueHex,
codeHex: codeHex,
lineSpacing: lineSpacing,
paragraphSpacingBefore: paragraphSpacingBefore,
mathOperatorHex: mathOperatorHex,
mathNumberHex: mathNumberHex,
monospaceFontName: monospaceFontName,
monospaceFontSize: monospaceFontSize,
standardLigatures: standardLigatures,
monospaceLigatures: monospaceLigatures,
antialias: antialias
)
}
public func save(to defaults: UserDefaults = .standard) {
let d = defaults
d.set(fontName, forKey: Keys.fontName)
d.set(Float(fontSize), forKey: Keys.fontSize)
d.set(monospaceFontName, forKey: Keys.monospaceFontName)
d.set(Float(monospaceFontSize), forKey: Keys.monospaceFontSize)
d.set(standardLigatures, forKey: Keys.standardLigatures)
d.set(monospaceLigatures, forKey: Keys.monospaceLigatures)
d.set(antialias, forKey: Keys.antialias)
d.set(linkBlueHex, forKey: Keys.linkBlueHex)
d.set(codeHex, forKey: Keys.codeHex)
d.set(mathOperatorHex, forKey: Keys.mathOperatorHex)
d.set(mathNumberHex, forKey: Keys.mathNumberHex)
d.set(Float(lineSpacing), forKey: Keys.lineSpacing)
d.set(Float(paragraphSpacingBefore), forKey: Keys.paragraphSpacingBefore)
}
}
// MARK: - NSColor Hex Helpers
extension NSColor {
/// Create a color from a hex string like "#3366E6" or "3366E6".
public convenience init?(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h.removeFirst() }
guard h.count == 6, let rgb = UInt64(h, radix: 16) else { return nil }
let r = CGFloat((rgb >> 16) & 0xFF) / 255.0
let g = CGFloat((rgb >> 8) & 0xFF) / 255.0
let b = CGFloat(rgb & 0xFF) / 255.0
self.init(calibratedRed: r, green: g, blue: b, alpha: 1.0)
}
/// Returns the hex string representation (e.g. "#3366E6").
public var hexString: String {
guard let rgb = usingColorSpace(.deviceRGB) else { return "#000000" }
let r = Int(round(rgb.redComponent * 255))
let g = Int(round(rgb.greenComponent * 255))
let b = Int(round(rgb.blueComponent * 255))
return String(format: "#%02X%02X%02X", r, g, b)
}
}