From 0b7d28c46d14ce2db30d0526dd93462eec8d2703 Mon Sep 17 00:00:00 2001 From: stackotter Date: Mon, 16 Jun 2025 22:53:05 +1000 Subject: [PATCH 1/5] Implement dynamic text styles (and various font-related modifiers) --- .../Sources/CounterExample/CounterApp.swift | 20 +- Sources/AppKitBackend/AppKitBackend.swift | 60 ++-- Sources/Gtk/Utility/CSS/CSSProperty.swift | 2 +- Sources/Gtk3/Utility/CSS/CSSProperty.swift | 2 +- Sources/Gtk3Backend/Gtk3Backend.swift | 24 +- Sources/GtkBackend/GtkBackend.swift | 34 ++- Sources/SwiftCrossUI/Backend/AppBackend.swift | 22 ++ .../Environment/EnvironmentValues.swift | 30 +- Sources/SwiftCrossUI/Values/DeviceClass.swift | 21 ++ Sources/SwiftCrossUI/Values/Font.swift | 259 ++++++++++++++++- Sources/SwiftCrossUI/Values/TextStyle.swift | 270 ++++++++++++++++++ .../Views/Modifiers/Style/FontModifier.swift | 49 +++- Sources/UIKitBackend/Font+UIFont.swift | 36 +-- .../UIKitBackend/UIKitBackend+Control.swift | 2 +- .../UIKitBackend/UIKitBackend+Passive.swift | 10 +- Sources/UIKitBackend/UIKitBackend.swift | 25 +- Sources/WinUIBackend/WinUIBackend.swift | 71 +++-- 17 files changed, 784 insertions(+), 153 deletions(-) create mode 100644 Sources/SwiftCrossUI/Values/DeviceClass.swift create mode 100644 Sources/SwiftCrossUI/Values/TextStyle.swift diff --git a/Examples/Sources/CounterExample/CounterApp.swift b/Examples/Sources/CounterExample/CounterApp.swift index c580fa3a59..9ea07e0c78 100644 --- a/Examples/Sources/CounterExample/CounterApp.swift +++ b/Examples/Sources/CounterExample/CounterApp.swift @@ -13,18 +13,18 @@ struct CounterApp: App { var body: some Scene { WindowGroup("CounterExample: \(count)") { #hotReloadable { - VStack { - HStack(spacing: 20) { - Button("-") { - count -= 1 - } - Text("Count: \(count)") - Button("+") { - count += 1 - } + VStack(alignment: .leading, spacing: 1) { + ForEach(Font.TextStyle.allCases) { style in + Text("This is \(style)") + .font(.system(style)) } - .padding() } + // VStack(alignment: .leading, spacing: 1) { + // ForEach(Font.Weight.allCases) { weight in + // Text("This is \(weight) text") + // .fontWeight(weight) + // } + // } } } .defaultSize(width: 400, height: 200) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 5977e636a8..5a0bcfe90f 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -24,6 +24,7 @@ public final class AppKitBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let deviceClass = DeviceClass.desktop public var scrollBarWidth: Int { // We assume that all scrollers have their controlSize set to `.regular` by default. @@ -323,13 +324,9 @@ public final class AppKitBackend: AppBackend { public func computeRootEnvironment(defaultEnvironment: EnvironmentValues) -> EnvironmentValues { let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" - let font = Font.system( - size: Int(NSFont.systemFont(ofSize: 0.0).pointSize.rounded(.awayFromZero)) - ) return defaultEnvironment .with(\.colorScheme, isDark ? .dark : .light) - .with(\.font, font) } public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) { @@ -1112,50 +1109,55 @@ public final class AppKitBackend: AppBackend { case .trailing: .right } + + let resolvedFont = environment.resolvedFont + + // This is definitely what these properties were intended for + paragraphStyle.minimumLineHeight = CGFloat(resolvedFont.lineHeight) + paragraphStyle.maximumLineHeight = CGFloat(resolvedFont.lineHeight) + paragraphStyle.lineSpacing = 0 + return [ .foregroundColor: environment.suggestedForegroundColor.nsColor, - .font: font(for: environment), + .font: font(for: resolvedFont), .paragraphStyle: paragraphStyle, ] } - private static func font(for environment: EnvironmentValues) -> NSFont { - switch environment.font { - case .system(let size, let weight, let design): - switch design { - case .default, .none: - NSFont.systemFont( - ofSize: CGFloat(size), weight: weight.map(Self.weight(for:)) ?? .regular - ) + private static func font(for font: Font.Resolved) -> NSFont { + let size = CGFloat(font.pointSize) + let weight = weight(for: font.weight) + switch font.identifier.kind { + case .system: + switch font.design { + case .default: + return NSFont.systemFont(ofSize: size, weight: weight) case .monospaced: - NSFont.monospacedSystemFont( - ofSize: CGFloat(size), - weight: weight.map(Self.weight(for:)) ?? .regular - ) + return NSFont.monospacedSystemFont(ofSize: size, weight: weight) } } } private static func weight(for weight: Font.Weight) -> NSFont.Weight { switch weight { - case .black: - .black - case .bold: - .bold - case .heavy: - .heavy + case .thin: + .thin + case .ultraLight: + .ultraLight case .light: .light - case .medium: - .medium case .regular: .regular + case .medium: + .medium case .semibold: .semibold - case .thin: - .thin - case .ultraLight: - .ultraLight + case .bold: + .bold + case .black: + .black + case .heavy: + .heavy } } diff --git a/Sources/Gtk/Utility/CSS/CSSProperty.swift b/Sources/Gtk/Utility/CSS/CSSProperty.swift index 4bc2db5c4e..5d6bee3cff 100644 --- a/Sources/Gtk/Utility/CSS/CSSProperty.swift +++ b/Sources/Gtk/Utility/CSS/CSSProperty.swift @@ -58,7 +58,7 @@ public struct CSSProperty: Equatable { CSSProperty(key: "min-height", value: "\(height)px") } - public static func fontSize(_ size: Int) -> CSSProperty { + public static func fontSize(_ size: Double) -> CSSProperty { CSSProperty(key: "font-size", value: "\(size)px") } diff --git a/Sources/Gtk3/Utility/CSS/CSSProperty.swift b/Sources/Gtk3/Utility/CSS/CSSProperty.swift index bffe738388..19c90f6367 100644 --- a/Sources/Gtk3/Utility/CSS/CSSProperty.swift +++ b/Sources/Gtk3/Utility/CSS/CSSProperty.swift @@ -58,7 +58,7 @@ public struct CSSProperty: Equatable { CSSProperty(key: "min-height", value: "\(height)px") } - public static func fontSize(_ size: Int) -> CSSProperty { + public static func fontSize(_ size: Double) -> CSSProperty { CSSProperty(key: "font-size", value: "\(size)px") } diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index b8329bb0c3..9b8c6411d4 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -36,6 +36,7 @@ public final class Gtk3Backend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = true public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let deviceClass = DeviceClass.desktop var gtkApp: Application @@ -1433,18 +1434,19 @@ public final class Gtk3Backend: AppBackend { ) -> [CSSProperty] { var properties: [CSSProperty] = [] properties.append(.foregroundColor(environment.suggestedForegroundColor.gtkColor)) - switch environment.font { - case .system(let size, let weight, let design): - properties.append(.fontSize(size)) + let font = environment.resolvedFont + switch font.identifier.kind { + case .system: + properties.append(.fontSize(font.pointSize)) let weightNumber = - switch weight { - case .thin: - 100 + switch font.weight { case .ultraLight: + 100 + case .thin: 200 case .light: 300 - case .regular, .none: + case .regular: 400 case .medium: 500 @@ -1452,16 +1454,16 @@ public final class Gtk3Backend: AppBackend { 600 case .bold: 700 - case .black: - 900 case .heavy: + 800 + case .black: 900 } properties.append(.fontWeight(weightNumber)) - switch design { + switch font.design { case .monospaced: properties.append(.fontFamily("monospace")) - case .default, .none: + case .default: break } } diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index ce00bf1988..094186f916 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -35,6 +35,7 @@ public final class GtkBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let deviceClass = DeviceClass.desktop var gtkApp: Application @@ -1480,35 +1481,40 @@ public final class GtkBackend: AppBackend { ) -> [CSSProperty] { var properties: [CSSProperty] = [] properties.append(.foregroundColor(environment.suggestedForegroundColor.gtkColor)) - switch environment.font { - case .system(let size, let weight, let design): - properties.append(.fontSize(size)) + let font = environment.resolvedFont + switch font.identifier.kind { + case .system: + properties.append(.fontSize(font.pointSize)) + // For some reason I had to tweak these a bit to make them match + // up with AppKit's font weights. I didn't have to do that for + // Gtk3Backend (which matches SwiftUI's text layout and rendering + // remarkbly well). let weightNumber = - switch weight { - case .thin: - 100 + switch font.weight { case .ultraLight: 200 - case .light: + case .thin: 300 - case .regular, .none: + case .light: 400 - case .medium: + case .regular: 500 - case .semibold: + case .medium: 600 + case .semibold: + 700 case .bold: 700 - case .black: - 900 case .heavy: + 800 + case .black: 900 } properties.append(.fontWeight(weightNumber)) - switch design { + switch font.design { case .monospaced: properties.append(.fontFamily("monospace")) - case .default, .none: + case .default: break } } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index b9cdb233e1..634db2f9c4 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -88,6 +88,10 @@ public protocol AppBackend { /// are called. var menuImplementationStyle: MenuImplementationStyle { get } + /// The class of device that the backend is currently running on. Used to + /// determine text sizing and other adaptive properties. + var deviceClass: DeviceClass { get } + /// Whether the backend can reveal files in the system file manager or not. /// Mobile backends generally can't. var canRevealFiles: Bool { get } @@ -180,6 +184,17 @@ public protocol AppBackend { /// may or may not override the previous handler. func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) + /// Resolves the given text style to concrete font properties. + /// + /// This method doesn't take ``EnvironmentValues`` because its result + /// should be consistent when given the same text style twice. Font modifiers + /// take effect later in the font resolution process. + /// + /// A default implementation is provided. It uses the backend's reported + /// device class and looks up the text style in a lookup table derived + /// from Apple's typography guidelines. See ``TextStyle/resolve(for:)``. + @Sendable func resolveTextStyle(_ textStyle: Font.TextStyle) -> Font.TextStyle.Resolved + /// Computes a window's environment based off the root environment. This may involve /// updating ``EnvironmentValues/windowScaleFactor`` etc. func computeWindowEnvironment( @@ -668,6 +683,13 @@ public protocol AppBackend { } extension AppBackend { + @Sendable + public func resolveTextStyle( + _ textStyle: Font.TextStyle + ) -> Font.TextStyle.Resolved { + textStyle.resolve(for: deviceClass) + } + public func tag(widget: Widget, as tag: String) { // This is only really to assist contributors when debugging backends, // so it's safe enough to have a no-op default implementation. diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 2a5dcb2c1a..3d709f9aa2 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -13,8 +13,35 @@ public struct EnvironmentValues { /// The current stack spacing. Inherited by ``ForEach`` and ``Group`` so /// that they can be used without affecting layout. public var layoutSpacing: Int + /// The current font. public var font: Font + /// A font overlay storing font modifications. If these conflict with the + /// font's internal overlay, these win. + /// + /// We keep this separate overlay for modifiers because we want modifiers to + /// be persisted even if the developer sets a custom font further down the + /// view hierarchy. + var fontOverlay: Font.Overlay + + /// A font resolution context derived from the current environment. + /// + /// Essentially just a subset of the environment. + public var fontResolutionContext: Font.Context { + Font.Context( + overlay: fontOverlay, + deviceClass: backend.deviceClass, + resolveTextStyle: backend.resolveTextStyle(_:) + ) + } + + /// The current font resolved to a form suitable for rendering. Just a + /// helper method for our own backends. We haven't made this public because + /// it would be weird to have two pretty equivalent ways of resolving fonts. + package var resolvedFont: Font.Resolved { + font.resolve(in: fontResolutionContext) + } + /// How lines should be aligned relative to each other when line wrapped. public var multilineTextAlignment: HorizontalAlignment @@ -158,7 +185,8 @@ public struct EnvironmentValues { layoutAlignment = .center layoutSpacing = 10 foregroundColor = nil - font = .system(size: 12) + font = .body + fontOverlay = Font.Overlay() multilineTextAlignment = .leading colorScheme = .light windowScaleFactor = 1 diff --git a/Sources/SwiftCrossUI/Values/DeviceClass.swift b/Sources/SwiftCrossUI/Values/DeviceClass.swift new file mode 100644 index 0000000000..44ee850584 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/DeviceClass.swift @@ -0,0 +1,21 @@ +/// A class of devices. Used to determine adaptive sizing behaviour such as +/// the sizes of the various dynamic ``Font/TextStyle``s. +public struct DeviceClass: Hashable, Sendable { + package enum Kind { + case desktop + case phone + case tablet + case tv + } + + package var kind: Kind + + /// The device class for laptops and desktops. + public static let desktop = Self(kind: .desktop) + /// The device class for smartphones. + public static let phone = Self(kind: .phone) + /// The device class for tablets (e.g. iPads). + public static let tablet = Self(kind: .tablet) + /// The device class for smart TVs (e.g. Apple TVs). + public static let tv = Self(kind: .tv) +} diff --git a/Sources/SwiftCrossUI/Values/Font.swift b/Sources/SwiftCrossUI/Values/Font.swift index 053d7a06f0..8fb907d2c9 100644 --- a/Sources/SwiftCrossUI/Values/Font.swift +++ b/Sources/SwiftCrossUI/Values/Font.swift @@ -1,20 +1,259 @@ -public enum Font { - case system(size: Int, weight: Weight? = nil, design: Design? = nil) +/// A font that can dynamically adapt to the environment. +public struct Font: Hashable, Sendable { + /// Gets a system font to use with the specified size, weight, and design. + public static func system( + size: Double, + weight: Weight? = nil, + design: Design? = nil + ) -> Font { + let kind = Kind.concrete( + identifier: .system, + size: size, + weight: weight, + design: design + ) + return Font(kind: kind) + } - public enum Weight { - case black - case bold - case heavy + /// Gets a system font that uses the specified style, weight, and design. + public static func system( + _ style: Font.TextStyle, + weight: Weight? = nil, + design: Design? = nil + ) -> Font { + return Font(kind: .dynamic(style)) + .weight(weight) + .design(design) + } + + /// The font style for large titles. + public static let largeTitle = Font(dynamic: .largeTitle) + /// The font used for first level hierarchical headings. + public static let title = Font(dynamic: .title) + /// The font used for second level hierarchical headings. + public static let title2 = Font(dynamic: .title2) + /// The font used for third level hierarchical headings. + public static let title3 = Font(dynamic: .title3) + /// The font used for headings. + public static let headline = Font(dynamic: .headline) + /// The font used for subheadings. + public static let subheadline = Font(dynamic: .subheadline) + /// The font used for body text. + public static let body = Font(dynamic: .body) + /// The font used for callouts. + public static let callout = Font(dynamic: .callout) + /// The font used for standard captions. + public static let caption = Font(dynamic: .caption) + /// The font used for alternate captions. + public static let caption2 = Font(dynamic: .caption2) + /// The font used in footnotes. + public static let footnote = Font(dynamic: .footnote) + + /// Selects whether or not to use the font's emphasized variant. + public func emphasized(_ emphasized: Bool = true) -> Font { + var font = self + font.overlay.emphasize = emphasized + return font + } + + /// Overrides the font's weight. Takes an optional for convenience. Does + /// nothing if given `nil`. + public func weight(_ weight: Weight?) -> Font { + var font = self + if let weight { + font.overlay.weight = weight + } + return font + } + + /// Overrides the font's design. Takes an optional for convenience. Does + /// nothing if given `nil`. + public func design(_ design: Design?) -> Font { + var font = self + if let design { + font.overlay.design = design + } + return font + } + + /// Overrides the font's point size. + public func pointSize(_ pointSize: Double) -> Font { + var font = self + font.overlay.pointSize = pointSize + font.overlay.pointSizeScaleFactor = 1 + return font + } + + /// Scales the font's point size and line height by a given factor. + public func scaled(by factor: Double) -> Font { + var font = self + font.overlay.pointSizeScaleFactor *= factor + font.overlay.lineHeightScaleFactor *= factor + return font + } + + /// Selects whether or not to use the font's monospaced variant. + public func monospaced(_ monospaced: Bool) -> Font { + var font = self + if monospaced { + font.overlay.design = .monospaced + } else if font.overlay.design == .monospaced { + font.overlay.design = .default + } + return font + } + + private var kind: Kind + private var overlay = Overlay() + + private init(kind: Kind) { + self.kind = kind + } + + private init(dynamic textStyle: TextStyle) { + self.kind = .dynamic(textStyle) + } + + /// Internal storage enum to hide away Font's implementation. + private enum Kind: Hashable, Sendable { + case concrete( + identifier: Resolved.Identifier, + size: Double, + weight: Weight? = nil, + design: Design? = nil + ) + case dynamic(TextStyle) + } + + /// A font weight. + /// + /// The cases are in order of increasing weight. + public enum Weight: Hashable, Sendable, CaseIterable, Codable { + case ultraLight + case thin case light - case medium case regular + case medium case semibold - case thin - case ultraLight + case bold + case heavy + case black } - public enum Design { + /// A font's design. + public enum Design: Hashable, Sendable, CaseIterable, Codable { case `default` case monospaced } + + /// An overlay applied to a font after resolving its concrete properties. + struct Overlay: Hashable, Sendable { + /// Overrides the font's base size. Applied before scaling. + var pointSize: Double? + /// Overrides the font's line height. Applied before scaling. + var lineHeight: Double? + /// Applied to the font's point size (after applying the ``pointSize`` + /// overlay if present). + var pointSizeScaleFactor: Double = 1 + /// Applied to the font's line height (after applying the ``lineHeight`` + /// overlay if present). + var lineHeightScaleFactor: Double = 1 + + /// Overrides the font's weight. Applied before (i.e. overridden by) + /// ``Self/isEmphasized``. + var weight: Weight? + /// If `true`, overrides the font's weight with the font's emphasized + /// weight. If `false`, does nothing. Applied after the ``weight`` + /// overlay has been applied if one is present. + var emphasize: Bool = false + + /// Overrides the font's design. + var design: Design? + + /// Applies an overlay to a resolved font. Requires an emphasized weight + /// for the resolved font. + func apply( + to resolvedFont: inout Font.Resolved, + emphasizedWeight: Weight + ) { + if let weight { + resolvedFont.weight = weight + } + if let design { + resolvedFont.design = design + } + if emphasize { + resolvedFont.weight = emphasizedWeight + } + if let pointSize { + resolvedFont.pointSize = pointSize + } + if let lineHeight { + resolvedFont.lineHeight = lineHeight + } + resolvedFont.pointSize *= pointSizeScaleFactor + resolvedFont.lineHeight *= lineHeightScaleFactor + } + } + + public struct Resolved: Hashable, Sendable { + public struct Identifier: Hashable, Sendable { + package var kind: Kind + + public static let system = Self(kind: .system) + + package enum Kind: Hashable { + case system + } + } + + public var identifier: Identifier + public var pointSize: Double + public var lineHeight: Double + public var weight: Weight + public var design: Design + } + + public struct Context: Sendable { + var overlay: Font.Overlay + var deviceClass: DeviceClass + var resolveTextStyle: @Sendable (TextStyle) -> TextStyle.Resolved + } + + package func resolve(in context: Context) -> Resolved { + let emphasizedWeight: Weight + var resolved: Resolved + switch kind { + case .concrete(let identifier, let size, let weight, let design): + switch identifier.kind { + case .system: + emphasizedWeight = .bold + resolved = Resolved( + identifier: .system, + pointSize: size, + // TODO: Research which line height ratio would be + // the best default (or any alternatives to a + // constant ratio). + lineHeight: (size * 1.25).rounded(.awayFromZero), + weight: weight ?? .regular, + design: design ?? .default + ) + } + case .dynamic(let textStyle): + let resolvedTextStyle = context.resolveTextStyle(textStyle) + emphasizedWeight = resolvedTextStyle.emphasizedWeight + resolved = Resolved( + identifier: .system, + pointSize: resolvedTextStyle.pointSize, + lineHeight: resolvedTextStyle.lineHeight, + weight: resolvedTextStyle.weight, + design: .default + ) + } + + overlay.apply(to: &resolved, emphasizedWeight: emphasizedWeight) + context.overlay.apply(to: &resolved, emphasizedWeight: emphasizedWeight) + + return resolved + } } diff --git a/Sources/SwiftCrossUI/Values/TextStyle.swift b/Sources/SwiftCrossUI/Values/TextStyle.swift new file mode 100644 index 0000000000..42f17bafbb --- /dev/null +++ b/Sources/SwiftCrossUI/Values/TextStyle.swift @@ -0,0 +1,270 @@ +extension Font { + /// A dynamic text style based off [Apple's typography guidelines](https://developer.apple.com/design/human-interface-guidelines/typography). + public enum TextStyle: Hashable, Sendable, CaseIterable, Codable { + /// The font style for large titles. + case largeTitle + /// The font used for first level hierarchical headings. + case title + /// The font used for second level hierarchical headings. + case title2 + /// The font used for third level hierarchical headings. + case title3 + /// The font used for headings. + case headline + /// The font used for subheadings. + case subheadline + /// The font used for body text. + case body + /// The font used for callouts. + case callout + /// The font used for standard captions. + case caption + /// The font used for alternate captions. + case caption2 + /// The font used in footnotes. + case footnote + } +} + +extension Font.TextStyle { + /// A text style's resolved properties. + public struct Resolved { + public var pointSize: Double + public var weight: Font.Weight = .regular + public var emphasizedWeight: Font.Weight + public var lineHeight: Double + + /// Fallback to macOS's body text style. This isn't expected to ever + /// get used because it's only reachable if we forgot to supply text + /// styles for a new device class or miss a text style in a device + /// class' text style lookup table. + static let fallback = Self( + pointSize: 13, + weight: .regular, + emphasizedWeight: .semibold, + lineHeight: 16 + ) + } + + /// Resolves the text style's concrete text properties for the given + /// device class. Generally follows [Apple's typography guidelines](https://developer.apple.com/design/human-interface-guidelines/typography). + /// Our styles only differ from Apple's where Apple decided not to + /// specify a text style for a specific platform. + public func resolve(for deviceClass: DeviceClass) -> Resolved { + guard let textStyles = Self.resolvedTextStyles[deviceClass] else { + print("warning: Missing text styles for device class \(deviceClass)") + return .fallback + } + + guard let textStyle = textStyles[self] else { + print( + """ + warning: Missing \(self) text style for device class \ + \(deviceClass) + """ + ) + return .fallback + } + + return textStyle + } + + private static let resolvedTextStyles: [DeviceClass: [Self: Resolved]] = [ + .desktop: desktopTextStyles, + .phone: mobileTextStyles, + .tablet: mobileTextStyles, + .tv: tvTextStyles + ] + + private static let desktopTextStyles: [Self: Resolved] = [ + .largeTitle: Resolved( + pointSize: 26, + emphasizedWeight: .bold, + lineHeight: 32 + ), + .title: Resolved( + pointSize: 22, + emphasizedWeight: .bold, + lineHeight: 26 + ), + .title2: Resolved( + pointSize: 17, + emphasizedWeight: .bold, + lineHeight: 22 + ), + .title3: Resolved( + pointSize: 15, + emphasizedWeight: .semibold, + lineHeight: 20 + ), + .headline: Resolved( + pointSize: 13, + weight: .bold, + emphasizedWeight: .heavy, + lineHeight: 16 + ), + .body: Resolved( + pointSize: 13, + emphasizedWeight: .semibold, + lineHeight: 16 + ), + .callout: Resolved( + pointSize: 12, + emphasizedWeight: .semibold, + lineHeight: 15 + ), + .subheadline: Resolved( + pointSize: 11, + emphasizedWeight: .semibold, + lineHeight: 14 + ), + .footnote: Resolved( + pointSize: 10, + emphasizedWeight: .semibold, + lineHeight: 13 + ), + .caption: Resolved( + pointSize: 10, + emphasizedWeight: .medium, + lineHeight: 13 + ), + .caption2: Resolved( + pointSize: 10, + weight: .medium, + emphasizedWeight: .semibold, + lineHeight: 13 + ), + ] + + private static let mobileTextStyles: [Self: Resolved] = [ + .largeTitle: Resolved( + pointSize: 34, + emphasizedWeight: .semibold, + lineHeight: 41 + ), + .title: Resolved( + pointSize: 28, + emphasizedWeight: .semibold, + lineHeight: 34 + ), + .title2: Resolved( + pointSize: 22, + emphasizedWeight: .semibold, + lineHeight: 28 + ), + .title3: Resolved( + pointSize: 20, + emphasizedWeight: .semibold, + lineHeight: 25 + ), + .headline: Resolved( + pointSize: 17, + weight: .semibold, + emphasizedWeight: .semibold, + lineHeight: 22 + ), + .body: Resolved( + pointSize: 17, + emphasizedWeight: .semibold, + lineHeight: 22 + ), + .callout: Resolved( + pointSize: 16, + emphasizedWeight: .semibold, + lineHeight: 21 + ), + .subheadline: Resolved( + pointSize: 15, + emphasizedWeight: .semibold, + lineHeight: 20 + ), + .footnote: Resolved( + pointSize: 13, + emphasizedWeight: .semibold, + lineHeight: 18 + ), + .caption: Resolved( + pointSize: 12, + emphasizedWeight: .semibold, + lineHeight: 16 + ), + .caption2: Resolved( + pointSize: 11, + emphasizedWeight: .semibold, + lineHeight: 13 + ), + ] + + // The tvOS large title and footnote styles are the only ones not from the + // Apple typography guidelines. I've just made it up based off the ratios + // used on other platforms to provide a consistent set of text styles + // across all platforms. + private static let tvTextStyles: [Self: Resolved] = [ + .largeTitle: Resolved( + pointSize: 91, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 80 + ), + .title: Resolved( + pointSize: 76, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 96 + ), + .title2: Resolved( + pointSize: 57, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 66 + ), + .title3: Resolved( + pointSize: 48, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 56 + ), + .headline: Resolved( + pointSize: 38, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 46 + ), + .body: Resolved( + pointSize: 29, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 36 + ), + .callout: Resolved( + pointSize: 31, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 38 + ), + .subheadline: Resolved( + pointSize: 38, + weight: .regular, + emphasizedWeight: .medium, + lineHeight: 46 + ), + .footnote: Resolved( + pointSize: 27, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 33 + ), + .caption: Resolved( + pointSize: 25, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 32 + ), + .caption2: Resolved( + pointSize: 23, + weight: .medium, + emphasizedWeight: .bold, + lineHeight: 30 + ), + ] +} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/FontModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/FontModifier.swift index 3963941273..aa0f8ce34a 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/FontModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/FontModifier.swift @@ -1,21 +1,54 @@ extension View { + /// Sets the font of contained text. Can be overridden by other font + /// modifiers within the contained view, unlike other font-related + /// modifiers such as ``View/fontWeight(_:)`` and ``View/emphasized()`` + /// which override the font properties of all contained text. public func font(_ font: Font) -> some View { EnvironmentModifier(self) { environment in environment.with(\.font, font) } } - public func bold() -> some View { + /// Overrides the font weight of any contained text. Optional for + /// convenience. If given `nil`, does nothing. + public func fontWeight(_ weight: Font.Weight?) -> some View { EnvironmentModifier(self) { environment in - let font = - switch environment.font { - case let .system(size, _, design): - Font.system(size: size, weight: .bold, design: design) - } + environment.with(\.fontOverlay.weight, weight) + } + } + /// Overrides the font design of any contained text. Optional for + /// convenience. If given `nil`, does nothing. + public func fontDesign(_ design: Font.Design?) -> some View { + EnvironmentModifier(self) { environment in + environment.with(\.fontOverlay.design, design) + } + } + + /// Forces any contained text to be bold, or if the a contained font is + /// a ``Font/TextStyle``, forces the style's emphasized weight to be + /// used. + /// + /// Deprecated and renamed for clarity. Use ``View.fontWeight(_:)`` + /// to make text bold. + @available( + *, deprecated, + message: "Use View.emphasized() instead", + renamed: "View.emphasized()" + ) + public func bold() -> some View { + emphasized() + } + + /// Forces any contained text to become emphasized. For text that uses + /// ``Font/TextStyle``-based fonts, this means using the text style's + /// emphasized weight. For all other text, this means using + /// ``Font/Weight/bold``. + public func emphasized() -> some View { + EnvironmentModifier(self) { environment in return environment.with( - \.font, - font + \.fontOverlay.emphasize, + true ) } } diff --git a/Sources/UIKitBackend/Font+UIFont.swift b/Sources/UIKitBackend/Font+UIFont.swift index 586430a049..ddd4066c5e 100644 --- a/Sources/UIKitBackend/Font+UIFont.swift +++ b/Sources/UIKitBackend/Font+UIFont.swift @@ -1,37 +1,37 @@ import SwiftCrossUI import UIKit -extension Font { +extension Font.Resolved { var uiFont: UIFont { - switch self { - case .system(let size, let weight, let design): + switch identifier.kind { + case .system: let weight: UIFont.Weight = switch weight { - case .black: - .black - case .bold: - .bold - case .heavy: - .heavy + case .ultraLight: + .ultraLight + case .thin: + .thin case .light: .light + case .regular: + .regular case .medium: .medium - case .regular, nil: - .regular case .semibold: .semibold - case .thin: - .thin - case .ultraLight: - .ultraLight + case .bold: + .bold + case .heavy: + .heavy + case .black: + .black } switch design { case .monospaced: - return .monospacedSystemFont(ofSize: CGFloat(size), weight: weight) - default: - return .systemFont(ofSize: CGFloat(size), weight: weight) + return .monospacedSystemFont(ofSize: CGFloat(pointSize), weight: weight) + case .default: + return .systemFont(ofSize: CGFloat(pointSize), weight: weight) } } } diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index ef9cf43f65..272f42cd11 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -251,7 +251,7 @@ extension UIKitBackend { textFieldWidget.child.isEnabled = environment.isEnabled textFieldWidget.child.placeholder = placeholder - textFieldWidget.child.font = environment.font.uiFont + textFieldWidget.child.font = environment.resolvedFont.uiFont textFieldWidget.child.textColor = UIColor(color: environment.suggestedForegroundColor) textFieldWidget.onChange = onChange textFieldWidget.onSubmit = onSubmit diff --git a/Sources/UIKitBackend/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index 0abb07cc11..235e7b893c 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -19,10 +19,16 @@ extension UIKitBackend { } paragraphStyle.lineBreakMode = .byWordWrapping + // This is definitely what these properties were intended for + let resolvedFont = environment.resolvedFont + paragraphStyle.minimumLineHeight = CGFloat(resolvedFont.lineHeight) + paragraphStyle.maximumLineHeight = CGFloat(resolvedFont.lineHeight) + paragraphStyle.lineSpacing = 0 + return NSAttributedString( string: text, attributes: [ - .font: environment.font.uiFont, + .font: resolvedFont.uiFont, .foregroundColor: environment.foregroundColor?.uiColor ?? defaultForegroundColor, .paragraphStyle: paragraphStyle, ] @@ -59,7 +65,7 @@ extension UIKitBackend { if let proposedFrame { CGSize(width: CGFloat(proposedFrame.x), height: .greatestFiniteMagnitude) } else { - CGSize(width: .greatestFiniteMagnitude, height: environment.font.uiFont.lineHeight) + CGSize(width: .greatestFiniteMagnitude, height: environment.resolvedFont.lineHeight) } let size = attributedString.boundingRect( with: boundingSize, diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index 393fc53d08..ddfb73dbe7 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -23,6 +23,23 @@ public final class UIKitBackend: AppBackend { public let canRevealFiles = false + public var deviceClass: DeviceClass { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + .phone + case .pad: + .tablet + case .tv: + .tv + case .mac: + .desktop + case .unspecified, .carPlay, .vision: + // Seems like the safest fallback for now given that we don't + // explicitly support these devices. + .tablet + } + } + var onTraitCollectionChange: (() -> Void)? private let appDelegateClass: ApplicationDelegate.Type @@ -67,14 +84,6 @@ public final class UIKitBackend: AppBackend { public func computeRootEnvironment(defaultEnvironment: EnvironmentValues) -> EnvironmentValues { var environment = defaultEnvironment - environment.font = .system( - size: Int( - UIFont.preferredFont(forTextStyle: .body).pointSize.rounded( - .toNearestOrAwayFromZero)), - weight: .regular, - design: .default - ) - environment.toggleStyle = .switch switch UITraitCollection.current.userInterfaceStyle { diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index bfe806a5e1..af3ed53145 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -42,6 +42,7 @@ public final class WinUIBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = false + public let deviceClass = DeviceClass.desktop public var scrollBarWidth: Int { 12 @@ -300,7 +301,6 @@ public final class WinUIBackend: AppBackend { return defaultEnvironment - .with(\.font, .system(size: 14)) .with(\.colorScheme, isLight ? .light : .dark) } @@ -1737,40 +1737,6 @@ extension SwiftCrossUI.Color { } extension EnvironmentValues { - var winUIFontSize: Double { - switch font { - case .system(let size, _, _): - Double(size) - } - } - - var winUIFontWeight: UInt16 { - switch font { - case .system(_, let weight, _): - switch weight { - case .thin: - 100 - case .ultraLight: - 200 - case .light: - 300 - case .regular, .none: - 400 - case .medium: - 500 - case .semibold: - 600 - case .bold: - 700 - case .black: - 900 - case .heavy: - 900 - } - } - - } - var winUIForegroundBrush: WinUI.Brush { let brush = SolidColorBrush() brush.color = suggestedForegroundColor.uwpColor @@ -1778,8 +1744,9 @@ extension EnvironmentValues { } func apply(to control: WinUI.Control) { - control.fontSize = winUIFontSize - control.fontWeight.weight = winUIFontWeight + let resolvedFont = resolvedFont + control.fontSize = resolvedFont.pointSize + control.fontWeight.weight = resolvedFont.winUIFontWeight control.foreground = winUIForegroundBrush control.isEnabled = isEnabled switch colorScheme { @@ -1791,12 +1758,38 @@ extension EnvironmentValues { } func apply(to textBlock: WinUI.TextBlock) { - textBlock.fontSize = winUIFontSize - textBlock.fontWeight.weight = winUIFontWeight + let resolvedFont = resolvedFont + textBlock.fontSize = resolvedFont.pointSize + textBlock.fontWeight.weight = resolvedFont.winUIFontWeight textBlock.foreground = winUIForegroundBrush } } +extension Font.Resolved { + var winUIFontWeight: UInt16 { + switch weight { + case .ultraLight: + 100 + case .thin: + 200 + case .light: + 300 + case .regular: + 400 + case .medium: + 500 + case .semibold: + 600 + case .bold: + 700 + case .heavy: + 800 + case .black: + 900 + } + } +} + final class CustomComboBox: ComboBox { var options: [String] = [] var onChangeSelection: ((Int?) -> Void)? From d6700f52a36c594babab51bf6bc2c2966900872a Mon Sep 17 00:00:00 2001 From: stackotter Date: Mon, 16 Jun 2025 23:14:08 +1000 Subject: [PATCH 2/5] Fix AppKitBackend tests (broke due to changed default font size) --- Tests/SwiftCrossUITests/SwiftCrossUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SwiftCrossUITests/SwiftCrossUITests.swift b/Tests/SwiftCrossUITests/SwiftCrossUITests.swift index 990dc23639..94f95652e6 100644 --- a/Tests/SwiftCrossUITests/SwiftCrossUITests.swift +++ b/Tests/SwiftCrossUITests/SwiftCrossUITests.swift @@ -94,7 +94,7 @@ final class SwiftCrossUITests: XCTestCase { XCTAssertEqual( result.size, - ViewSize(fixedSize: SIMD2(88, 95)), + ViewSize(fixedSize: SIMD2(92, 96)), "View update result mismatch" ) From 52e07383bc9c3619b839ba7a5f391558c3859e3e Mon Sep 17 00:00:00 2001 From: stackotter Date: Tue, 17 Jun 2025 11:08:04 +1000 Subject: [PATCH 3/5] Update Font.monospaced(_:) modifier with default parameter value --- Sources/SwiftCrossUI/Values/Font.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftCrossUI/Values/Font.swift b/Sources/SwiftCrossUI/Values/Font.swift index 8fb907d2c9..8667fd2063 100644 --- a/Sources/SwiftCrossUI/Values/Font.swift +++ b/Sources/SwiftCrossUI/Values/Font.swift @@ -93,7 +93,7 @@ public struct Font: Hashable, Sendable { } /// Selects whether or not to use the font's monospaced variant. - public func monospaced(_ monospaced: Bool) -> Font { + public func monospaced(_ monospaced: Bool = true) -> Font { var font = self if monospaced { font.overlay.design = .monospaced From 875e1d7dfaf08c1ae9b427e8254047f8ea43a41b Mon Sep 17 00:00:00 2001 From: stackotter Date: Tue, 17 Jun 2025 22:58:39 +1000 Subject: [PATCH 4/5] Fix AppKitBackend post-rebase and revert CounterApp --- .../Sources/CounterExample/CounterApp.swift | 18 ++++++++---------- Sources/AppKitBackend/AppKitBackend.swift | 10 ++++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Examples/Sources/CounterExample/CounterApp.swift b/Examples/Sources/CounterExample/CounterApp.swift index 9ea07e0c78..264ff9e095 100644 --- a/Examples/Sources/CounterExample/CounterApp.swift +++ b/Examples/Sources/CounterExample/CounterApp.swift @@ -13,18 +13,16 @@ struct CounterApp: App { var body: some Scene { WindowGroup("CounterExample: \(count)") { #hotReloadable { - VStack(alignment: .leading, spacing: 1) { - ForEach(Font.TextStyle.allCases) { style in - Text("This is \(style)") - .font(.system(style)) + HStack(spacing: 20) { + Button("-") { + count -= 1 + } + Text("Count: \(count)") + Button("+") { + count += 1 } } - // VStack(alignment: .leading, spacing: 1) { - // ForEach(Font.Weight.allCases) { weight in - // Text("This is \(weight) text") - // .fontWeight(weight) - // } - // } + .padding() } } .defaultSize(width: 400, height: 200) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 5a0bcfe90f..62f9e4b46d 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -749,8 +749,9 @@ public final class AppKitBackend: AppBackend { textField.isEnabled = environment.isEnabled textField.placeholderString = placeholder textField.appearance = environment.colorScheme.nsAppearance - if textField.font != Self.font(for: environment) { - textField.font = Self.font(for: environment) + let resolvedFont = environment.resolvedFont + if textField.font != Self.font(for: resolvedFont) { + textField.font = Self.font(for: resolvedFont) } textField.onEdit = { textField in onChange(textField.stringValue) @@ -803,8 +804,9 @@ public final class AppKitBackend: AppBackend { textEditor.onEdit = { textView in onChange(self.getContent(ofTextEditor: textView)) } - if textEditor.font != Self.font(for: environment) { - textEditor.font = Self.font(for: environment) + let resolvedFont = environment.resolvedFont + if textEditor.font != Self.font(for: resolvedFont) { + textEditor.font = Self.font(for: resolvedFont) } textEditor.appearance = environment.colorScheme.nsAppearance textEditor.isEditable = environment.isEnabled From b80640af49aa6abcd5beb240e6a8bee652c25959 Mon Sep 17 00:00:00 2001 From: stackotter Date: Mon, 23 Jun 2025 22:31:39 +1000 Subject: [PATCH 5/5] Fix UIKitBackend compilation error (post TextEditor rebase) --- Sources/UIKitBackend/UIKitBackend+Control.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index 272f42cd11..0b813a8dc8 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -299,7 +299,7 @@ extension UIKitBackend { let textEditorWidget = textEditor as! TextEditorWidget textEditorWidget.child.isEditable = environment.isEnabled - textEditorWidget.child.font = environment.font.uiFont + textEditorWidget.child.font = environment.resolvedFont.uiFont textEditorWidget.child.textColor = UIColor(color: environment.suggestedForegroundColor) textEditorWidget.onChange = onChange