diff --git a/swift/.changeset/theme-platform-colors.md b/swift/.changeset/theme-platform-colors.md new file mode 100644 index 00000000000..988fcfd4f2a --- /dev/null +++ b/swift/.changeset/theme-platform-colors.md @@ -0,0 +1,5 @@ +--- +'lexical-swift': minor +--- + +Themes take an app's own colors and change on a live view. `TextKitColor(uiColor:)` and `TextKitColor(nsColor:)` make a theme color from a `UIColor` or `NSColor`, keeping a dynamic color's light and dark variants (a high-contrast variant draws as its regular one), so `TextKitColor(uiColor: .label)` or a SwiftUI `Color` through `UIColor(_:)` follows the appearance. `LexicalUITextView` and `LexicalNSTextView` have a `theme` property that draws the text again when set to a different theme, as `LexicalEditor.theme(_:)` already did, which now sets it. To keep a theme's sizes fixed on iOS instead of scaling with Dynamic Type, pin the size category with `.dynamicTypeSize(.large)` on `LexicalEditor` or `traitOverrides.preferredContentSizeCategory = .large` on the view. diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 2f79d0d9d4c..30a826896f4 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -596,7 +596,11 @@ Reversible choices made porting `@lexical/extension` under Q18. as tall as its text; the cost is that a deep indent takes more of a narrow screen. A change of category renders the document again. A decorator's SwiftUI view scales only if it uses text styles or - `@ScaledMetric`. AppKit has no Dynamic Type. + `@ScaledMetric`. AppKit has no Dynamic Type. An app with fixed sizes + opts out with the platform's own trait, not a theme flag: + `.dynamicTypeSize(.large)` on `LexicalEditor`, or + `traitOverrides.preferredContentSizeCategory = .large` on the view, + where the theme's sizes are its points. - **A decorator's VoiceOver label is its view's, or else its text.** The attachment's `accessibilityLabel` (iOS), or its hosting view's `accessibilityLabel()` (macOS), is the labels of the SwiftUI view's diff --git a/swift/Sources/LexicalAppKit/LexicalNSTextView.swift b/swift/Sources/LexicalAppKit/LexicalNSTextView.swift index b71c5b63662..ddb6206ee9e 100644 --- a/swift/Sources/LexicalAppKit/LexicalNSTextView.swift +++ b/swift/Sources/LexicalAppKit/LexicalNSTextView.swift @@ -59,6 +59,17 @@ public var placeholder: String? { didSet { needsDisplay = true } } + /// The look of the rendered text and the placeholder, the controller's + /// `theme` (`TextKitTheme.default` until set). A different theme draws + /// the document again; an equal one leaves it as it is. + public var theme: TextKitTheme { + get { controller.theme } + set { + guard newValue != controller.theme else { return } + controller.theme = newValue + controller.report { try controller.renderAll(replacing: textStorage?.length ?? 0) } + } + } /// Whether the editor's state can show the placeholder, as of the last /// commit. public private(set) var canShowPlaceholder = false diff --git a/swift/Sources/LexicalAppKit/TextKitAttributes+AppKit.swift b/swift/Sources/LexicalAppKit/TextKitAttributes+AppKit.swift index 3dcba00b50e..dfc7a316bc5 100644 --- a/swift/Sources/LexicalAppKit/TextKitAttributes+AppKit.swift +++ b/swift/Sources/LexicalAppKit/TextKitAttributes+AppKit.swift @@ -5,6 +5,31 @@ // The theme's platform-neutral descriptors as AppKit attributes. extension TextKitColor { + /// `color` as it resolves in the light (aqua) appearance, with its dark + /// (dark aqua) resolution as the dark variant when that differs, so a + /// dynamic color, such as `.labelColor`, an asset catalog color, or a + /// SwiftUI `Color` through `NSColor(_:)`, still follows the appearance. + /// Only those two are kept: a high-contrast appearance draws the + /// regular variants. The components are sRGB; a color that has none, + /// such as a pattern, is clear. + public init(nsColor color: NSColor) { + func resolved(_ name: NSAppearance.Name) -> TextKitColor { + var result = TextKitColor(red: 0, green: 0, blue: 0, alpha: 0) + NSAppearance(named: name)?.performAsCurrentDrawingAppearance { + // Converting a dynamic color resolves it for the current + // drawing appearance. + guard let srgb = color.usingColorSpace(.sRGB) else { return } + result = TextKitColor( + red: srgb.redComponent, green: srgb.greenComponent, blue: srgb.blueComponent, + alpha: srgb.alphaComponent) + } + return result + } + let light = resolved(.aqua) + let dark = resolved(.darkAqua) + self = dark == light ? light : light.withDark(dark) + } + /// The color, or with a dark variant, a dynamic color that resolves to /// it in a dark appearance. var nsColor: NSColor { diff --git a/swift/Sources/LexicalSwiftUI/LexicalEditor.swift b/swift/Sources/LexicalSwiftUI/LexicalEditor.swift index 84a9700c7f9..5993ccc51a1 100644 --- a/swift/Sources/LexicalSwiftUI/LexicalEditor.swift +++ b/swift/Sources/LexicalSwiftUI/LexicalEditor.swift @@ -65,9 +65,12 @@ return editor } - /// Draws the text with `theme`, such as `TextKitTheme.default` with - /// styles for an app's own node types under their theme keys. The text - /// is drawn again when the theme changes. + /// Draws the text with `theme` (the text view's `theme`), such as + /// `TextKitTheme.default` with styles for an app's own node types under + /// their theme keys. The text is drawn again when the theme changes. + /// + /// On iOS the theme's sizes scale with Dynamic Type; to keep them at + /// the theme's points, pin the size with `.dynamicTypeSize(.large)`. public func theme(_ theme: TextKitTheme) -> Self { var editor = self editor.textKitTheme = theme @@ -110,15 +113,6 @@ let height = textView.contentHeight(atWidth: width) return min(max(height, heightRange.lowerBound), heightRange.upperBound) } - - /// Applies `textKitTheme` to the text view's controller, drawing the - /// text again when it changes. - fileprivate func applyTheme(_ controller: TextKitInputController, length: Int) { - let theme = textKitTheme ?? .default - guard controller.theme != theme else { return } - controller.theme = theme - controller.report { try controller.renderAll(replacing: length) } - } } extension LexicalEditor { @@ -155,7 +149,7 @@ textView.placeholder = placeholder textView.heightRange = heightRange textView.drawsBackground = drawsBackground - applyTheme(textView.controller, length: textView.textStorage?.length ?? 0) + textView.theme = textKitTheme ?? .default for configure in configurations { configure(textView) } } @@ -184,7 +178,7 @@ } else { textView.backgroundColor = .clear } - applyTheme(textView.controller, length: textView.textStorage.length) + textView.theme = textKitTheme ?? .default for configure in configurations { configure(textView) } } diff --git a/swift/Sources/LexicalTextKit/TextKitInput.swift b/swift/Sources/LexicalTextKit/TextKitInput.swift index 1a6cadf4706..6ea9c5aeae1 100644 --- a/swift/Sources/LexicalTextKit/TextKitInput.swift +++ b/swift/Sources/LexicalTextKit/TextKitInput.swift @@ -35,7 +35,9 @@ public final class TextKitInputController { public let movement = TextKitMovement() /// Keeps `document` in step with the editor's commits. public let reconciler = TextKitReconciler() - /// The look of the rendered text; set it before the view first renders. + /// The look of the rendered text. Text already drawn keeps the look it + /// was drawn with, so set this before the view first renders; on a live + /// view, set the view's `theme`, which draws the text again. public var theme: TextKitTheme = .default /// The document last rendered, which the view shows. public var document: TextKitDocument { reconciler.document } diff --git a/swift/Sources/LexicalUIKit/LexicalUITextView.swift b/swift/Sources/LexicalUIKit/LexicalUITextView.swift index 456fb5a6a87..b84feffaec5 100644 --- a/swift/Sources/LexicalUIKit/LexicalUITextView.swift +++ b/swift/Sources/LexicalUIKit/LexicalUITextView.swift @@ -71,6 +71,23 @@ setNeedsLayout() } } + /// The look of the rendered text and the placeholder, the controller's + /// `theme` (`TextKitTheme.default` until set). A different theme draws + /// the document again; an equal one leaves it as it is. + /// + /// The theme's sizes are its points at the default text size, scaled + /// with Dynamic Type for the view's `preferredContentSizeCategory`. To + /// keep them fixed, pin the category: + /// `traitOverrides.preferredContentSizeCategory = .large`, or + /// `.dynamicTypeSize(.large)` on a SwiftUI `LexicalEditor`. + public var theme: TextKitTheme { + get { controller.theme } + set { + guard newValue != controller.theme else { return } + controller.theme = newValue + controller.report { try controller.renderAll(replacing: textStorage.length) } + } + } /// When set, the view's intrinsic height is its content's /// (`contentHeight`) kept within the range, so it grows with its text /// up to the upper bound, as a chat composer does, and scrolls past it. diff --git a/swift/Sources/LexicalUIKit/TextKitAttributes+UIKit.swift b/swift/Sources/LexicalUIKit/TextKitAttributes+UIKit.swift index 762b54419f3..35b18b76a9c 100644 --- a/swift/Sources/LexicalUIKit/TextKitAttributes+UIKit.swift +++ b/swift/Sources/LexicalUIKit/TextKitAttributes+UIKit.swift @@ -5,6 +5,29 @@ // The theme's platform-neutral descriptors as UIKit attributes. extension TextKitColor { + /// `color` as it resolves in the light user interface style, with its + /// dark style's resolution as the dark variant when that differs, so a + /// dynamic color, such as `.label`, an asset catalog color, or a + /// SwiftUI `Color` through `UIColor(_:)`, still follows the appearance. + /// Only those two are kept: a high-contrast variant draws as its + /// regular one. The components are extended sRGB, as + /// `getRed(_:green:blue:alpha:)` gives them, so a Display P3 color keeps + /// its gamut. A pattern color has no components and is clear. + public init(uiColor color: UIColor) { + func resolved(_ style: UIUserInterfaceStyle) -> TextKitColor { + var (red, green, blue, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + if !color.resolvedColor(with: UITraitCollection(userInterfaceStyle: style)) + .getRed(&red, green: &green, blue: &blue, alpha: &alpha) + { + (red, green, blue, alpha) = (0, 0, 0, 0) + } + return TextKitColor(red: red, green: green, blue: blue, alpha: alpha) + } + let light = resolved(.light) + let dark = resolved(.dark) + self = dark == light ? light : light.withDark(dark) + } + /// The color, or with a dark variant, a dynamic color that resolves to /// it in the dark user interface style. var uiColor: UIColor { diff --git a/swift/Tests/LexicalAppKitTests/ThemeTests.swift b/swift/Tests/LexicalAppKitTests/ThemeTests.swift new file mode 100644 index 00000000000..5b3d3e3ebc8 --- /dev/null +++ b/swift/Tests/LexicalAppKitTests/ThemeTests.swift @@ -0,0 +1,93 @@ +#if os(macOS) + import AppKit + import Lexical + import LexicalAppKit + import LexicalExtension + import LexicalRichText + import LexicalTextKit + import Testing + + // Theming a live view: platform colors and swapping the theme. + + @MainActor + private func makeView() throws -> (LexicalNSTextView, NSWindow) { + let editor = try buildEditorFromExtensions( + defineExtension(name: "theme-test", dependencies: [RichTextExtension])) + try editor.update(discrete: true) { + try getRoot().clear().append(createParagraphNode().append(createTextNode("Body text"))) + } + let view = LexicalNSTextView(editor: editor, frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + let window = NSWindow( + contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.contentView = view + return (view, window) + } + + @MainActor + private func font(_ view: LexicalNSTextView) throws -> NSFont { + try #require(view.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) + } + + @MainActor + private func color(_ view: LexicalNSTextView, _ name: NSAppearance.Name) throws -> NSColor { + let color = try #require( + view.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor) + var resolved: NSColor? + NSAppearance(named: name)?.performAsCurrentDrawingAppearance { + resolved = color.usingColorSpace(.sRGB) + } + return try #require(resolved) + } + + /// Blue at half opacity in light, red in dark. + private let blueOrRed = NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + : NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.5) + } + + @MainActor + @Suite struct ThemeTests { + @Test func initializesAColorFromAnNSColorInLightAndDark() { + #expect( + TextKitColor(nsColor: blueOrRed) + == TextKitColor(red: 0, green: 0, blue: 1, alpha: 0.5) + .withDark(TextKitColor(red: 1, green: 0, blue: 0))) + // A color that is the same in both has no dark variant. + #expect( + TextKitColor(nsColor: NSColor(srgbRed: 0.25, green: 0.5, blue: 1, alpha: 1)).dark == nil) + // System colors are dynamic. + let text = TextKitColor(nsColor: .textColor) + #expect(text.red == 0 && text.alpha == 1) + #expect(text.darkAppearance.red == 1) + } + + /// A new theme draws the text again, and its dynamic colors still + /// follow the appearance; an equal theme edits nothing. + @Test func drawsAgainWhenTheThemeChanges() throws { + let (view, window) = try makeView() + #expect(try font(view).pointSize == 16) + var theme = TextKitTheme.default + theme["root"]?.fontSize = .points(20) + theme["root"]?.foregroundColor = TextKitColor(nsColor: blueOrRed) + view.theme = theme + #expect(view.controller.theme == theme) + #expect(try font(view).pointSize == 20) + #expect(try color(view, .aqua).blueComponent == 1) + #expect(try color(view, .aqua).alphaComponent == 0.5) + #expect(try color(view, .darkAqua).redComponent == 1) + + nonisolated(unsafe) var edits = 0 + let observer = NotificationCenter.default.addObserver( + forName: NSTextStorage.didProcessEditingNotification, object: view.textStorage, queue: nil + ) { _ in edits += 1 } + defer { NotificationCenter.default.removeObserver(observer) } + view.theme = theme + #expect(edits == 0) + view.theme = .default + #expect(edits > 0) + #expect(try font(view).pointSize == 16) + _ = window + } + } +#endif diff --git a/swift/Tests/LexicalUIKitTests/ThemeTests.swift b/swift/Tests/LexicalUIKitTests/ThemeTests.swift new file mode 100644 index 00000000000..5cb5b4aeb53 --- /dev/null +++ b/swift/Tests/LexicalUIKitTests/ThemeTests.swift @@ -0,0 +1,155 @@ +#if canImport(UIKit) + import Lexical + import LexicalExtension + import LexicalRichText + import LexicalSwiftUI + import LexicalTextKit + import LexicalUIKit + import SwiftUI + import Testing + import UIKit + + // Theming a live view: platform colors, swapping the theme, and pinning + // Dynamic Type with the platform's own traits. + + @MainActor + private func settle(_ view: UIView) { + for _ in 0..<5 { + view.window?.layoutIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + } + + @MainActor + private func makeEditor() throws -> Editor { + let editor = try buildEditorFromExtensions( + defineExtension(name: "theme-test", dependencies: [RichTextExtension])) + try editor.update(discrete: true) { + try getRoot().clear().append(createParagraphNode().append(createTextNode("Body text"))) + } + return editor + } + + @MainActor + private func font(_ textView: LexicalUITextView) throws -> UIFont { + try #require(textView.textStorage.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) + } + + @MainActor + private func color(_ textView: LexicalUITextView, _ style: UIUserInterfaceStyle) throws -> UIColor + { + let color = textView.textStorage.attribute(.foregroundColor, at: 0, effectiveRange: nil) + return try #require(color as? UIColor).resolvedColor( + with: UITraitCollection(userInterfaceStyle: style)) + } + + /// Blue at half opacity in light, red in dark. + private let blueOrRed = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 1, green: 0, blue: 0, alpha: 1) + : UIColor(red: 0, green: 0, blue: 1, alpha: 0.5) + } + + @MainActor + @Suite struct ThemeTests { + @Test func initializesAColorFromAUIColorInLightAndDark() { + #expect( + TextKitColor(uiColor: blueOrRed) + == TextKitColor(red: 0, green: 0, blue: 1, alpha: 0.5) + .withDark(TextKitColor(red: 1, green: 0, blue: 0))) + // A color that is the same in both has no dark variant. + #expect(TextKitColor(uiColor: UIColor(red: 0.25, green: 0.5, blue: 1, alpha: 1)).dark == nil) + // System colors are dynamic. + let label = TextKitColor(uiColor: .label) + #expect(label.red == 0 && label.alpha == 1) + // White in gray converted to RGB is a float's step past 1. + #expect(abs(label.darkAppearance.red - 1) < 1e-6) + } + + /// A new theme draws the text again, and its dynamic colors still + /// follow the appearance; an equal theme edits nothing. + @Test func drawsAgainWhenTheThemeChanges() throws { + let editor = try makeEditor() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 520)) + let view = LexicalUITextView(editor: editor, frame: window.bounds) + window.addSubview(view) + window.makeKeyAndVisible() + settle(view) + #expect(try font(view).pointSize == 16) + var theme = TextKitTheme.default + theme["root"]?.fontSize = .points(20) + theme["root"]?.foregroundColor = TextKitColor(uiColor: blueOrRed) + view.theme = theme + #expect(view.controller.theme == theme) + #expect(try font(view).pointSize == 20) + #expect(try color(view, .light) == UIColor(red: 0, green: 0, blue: 1, alpha: 0.5)) + #expect(try color(view, .dark) == UIColor(red: 1, green: 0, blue: 0, alpha: 1)) + + nonisolated(unsafe) var edits = 0 + let observer = NotificationCenter.default.addObserver( + forName: NSTextStorage.didProcessEditingNotification, object: view.textStorage, queue: nil + ) { _ in edits += 1 } + defer { NotificationCenter.default.removeObserver(observer) } + view.theme = theme + #expect(edits == 0) + view.theme = .default + #expect(edits > 0) + #expect(try font(view).pointSize == 16) + _ = window + } + + @Test func swiftUIThemeChangesTheLiveView() throws { + let editor = try makeEditor() + var large = TextKitTheme.default + large["root"]?.fontSize = .points(20) + let host = UIHostingController(rootView: LexicalEditor(editor).theme(large)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 520)) + window.rootViewController = host + window.makeKeyAndVisible() + settle(host.view) + let textView = try #require(editor.getRootElement() as? LexicalUITextView) + #expect(try font(textView).pointSize == 20) + host.rootView = LexicalEditor(editor) + settle(host.view) + #expect(textView.theme == .default) + #expect(try font(textView).pointSize == 16) + _ = window + } + + /// The documented Dynamic Type opt-out: SwiftUI's `dynamicTypeSize` + /// reaches the text view's traits, so the theme keeps its points while + /// the rest of the window is at the largest accessibility size. + @Test func swiftUIDynamicTypeSizeKeepsTheThemesSizes() throws { + func bodySize(_ content: some View, _ editor: Editor) throws -> CGFloat { + let host = UIHostingController(rootView: content) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 520)) + window.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge + window.rootViewController = host + window.makeKeyAndVisible() + settle(host.view) + defer { window.isHidden = true } + return try font(try #require(editor.getRootElement() as? LexicalUITextView)).pointSize + } + let scaled = try makeEditor() + #expect(try bodySize(LexicalEditor(scaled), scaled) > 40) + let pinned = try makeEditor() + #expect(try bodySize(LexicalEditor(pinned).dynamicTypeSize(.large), pinned) == 16) + } + + /// The UIKit form: the view's own trait override, set while the view + /// is in a window at the largest accessibility size. + @Test func traitOverridesKeepTheThemesSizes() throws { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 520)) + window.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge + let view = LexicalUITextView(editor: try makeEditor(), frame: window.bounds) + window.addSubview(view) + window.makeKeyAndVisible() + settle(view) + #expect(try font(view).pointSize > 40) + view.traitOverrides.preferredContentSizeCategory = .large + settle(view) + #expect(try font(view).pointSize == 16) + _ = window + } + } +#endif