Skip to content
Merged
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
5 changes: 5 additions & 0 deletions swift/.changeset/theme-platform-colors.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion swift/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions swift/Sources/LexicalAppKit/LexicalNSTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions swift/Sources/LexicalAppKit/TextKitAttributes+AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 8 additions & 14 deletions swift/Sources/LexicalSwiftUI/LexicalEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) }
}

Expand Down Expand Up @@ -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) }
}

Expand Down
4 changes: 3 additions & 1 deletion swift/Sources/LexicalTextKit/TextKitInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
17 changes: 17 additions & 0 deletions swift/Sources/LexicalUIKit/LexicalUITextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions swift/Sources/LexicalUIKit/TextKitAttributes+UIKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions swift/Tests/LexicalAppKitTests/ThemeTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading