diff --git a/AGENTS.md b/AGENTS.md index 9e839c3..6462402 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,11 @@ the pages. The step's primary action must `flow.record(...)` then call Theme comes from the environment (`.onboardingTheme(_:)`), set once at the root. Never pass a theme into a view; never hard-code brand colors in a step. +The kit balances header titles and headlines itself. A step's own message +paragraph is app content, so apply +`Text(message).multilineTextAlignment(.center).balancedText()` to it; do not +reach for `lineBreakStrategy`, which leaves a one-word last line alone. + ## 5. Reconcile system permissions A permission the OS has already granted must not get a page. Pass diff --git a/README.md b/README.md index d01108a..639ef1f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,13 @@ layer never compiles SwiftUI it doesn't use. `.onboardingTheme(_:)` at the root. A brand (custom font, custom background) and a plain look are the same view with different themes, not different views. -- **`OnboardingHeader`**: icon (optional), title, headline (optional). +- **`OnboardingHeader`**: icon (optional), title, headline (optional). The + title and headline are balanced (even lines, no one-word last line). +- **`.balancedText()`**: the same balancing for any `Text`, for a step's own + paragraph: `Text(message).multilineTextAlignment(.center).balancedText()`. + `lineBreakStrategy = .pushOut` does not fix a one-word last line; this + does, by laying the text out at the narrowest width that keeps its line + count. - **`OnboardingAction`**: a title and a handler, not a state machine. A permission step's primary button changes label as its state changes (Allow Location → Open Settings → Next); that's the app's own logic recomputing diff --git a/Sources/HowdyKitUI/BalancedText.swift b/Sources/HowdyKitUI/BalancedText.swift new file mode 100644 index 0000000..cdf73a9 --- /dev/null +++ b/Sources/HowdyKitUI/BalancedText.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// The narrowest width that lays text out in the same number of lines as it +/// has at `maxWidth`. Wrapping text greedily leaves one long line and a stub +/// ("taken." alone); at the narrowest width that still fits the same line +/// count the lines come out even, which is what CSS `text-wrap: balance` +/// does. `measure` returns the text's height at a width and must not grow +/// as the width grows (greedy wrapping satisfies that). +enum BalancedWidth { + static func narrowest(maxWidth: CGFloat, measure: (CGFloat) -> CGFloat) -> CGFloat { + let fullHeight = measure(maxWidth) + var low: CGFloat = 0 + var high = maxWidth + while high - low > 1 { + let mid = (low + high) / 2 + if measure(mid) <= fullHeight + 0.5 { + high = mid + } else { + low = mid + } + } + return high + } +} + +struct BalancedTextLayout: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let text = subviews.first else { return .zero } + // No finite width to balance against (an unconstrained measure). + guard let maxWidth = proposal.width, maxWidth.isFinite, maxWidth > 0 else { + return text.sizeThatFits(proposal) + } + let width = BalancedWidth.narrowest(maxWidth: maxWidth) { + text.sizeThatFits(ProposedViewSize(width: $0, height: nil)).height + } + return text.sizeThatFits(ProposedViewSize(width: width, height: nil)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews.first?.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) + } +} + +public extension View { + /// Wraps a multi-line `Text` so its lines come out even instead of + /// leaving a one-word last line. Apply it to the text itself, after + /// `multilineTextAlignment`, so the narrower block is centered by its + /// parent and each line is centered inside it: + /// + /// Text(message).multilineTextAlignment(.center).balancedText() + /// + /// The kit applies it to every step header's title and headline. + func balancedText() -> some View { + BalancedTextLayout { self } + } +} diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index 828d2b8..da2a71f 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -79,10 +79,12 @@ public struct OnboardingStepView: View { .font(theme.titleFont) .foregroundStyle(theme.primaryColor) .multilineTextAlignment(.center) + .balancedText() if let headline = header.headline { Text(headline) .font(theme.headlineFont) .multilineTextAlignment(.center) + .balancedText() } } } diff --git a/Tests/HowdyKitUITests/BalancedTextTests.swift b/Tests/HowdyKitUITests/BalancedTextTests.swift new file mode 100644 index 0000000..21b9c46 --- /dev/null +++ b/Tests/HowdyKitUITests/BalancedTextTests.swift @@ -0,0 +1,74 @@ +import CoreGraphics +import Testing +@testable import HowdyKitUI + +@Suite("BalancedWidth") +struct BalancedTextTests { + private let charWidth: CGFloat = 10 + private let lineHeight: CGFloat = 20 + + /// Greedy word wrap with fixed-width characters, the same shape of + /// problem a real text layout solves. + private func lines(_ text: String, at width: CGFloat) -> [String] { + let perLine = max(1, Int(width / charWidth)) + var result: [String] = [] + var current = "" + for word in text.split(separator: " ") { + let candidate = current.isEmpty ? String(word) : current + " " + word + if candidate.count <= perLine || current.isEmpty { + current = candidate + } else { + result.append(current) + current = String(word) + } + } + if !current.isEmpty { result.append(current) } + return result + } + + private func measure(_ text: String) -> (CGFloat) -> CGFloat { + { width in CGFloat(lines(text, at: width).count) * lineHeight } + } + + private let sentence = "Dolly lines up your location history with each photo's timestamp and suggests where it was taken." + + /// At 49 characters per line the sentence wraps to three lines with + /// "taken." alone on the last (at 50 it fits on two). + private let orphanWidth: CGFloat = 490 + + @Test("Keeps the line count the text has at full width") + func keepsLineCount() { + let full = lines(sentence, at: orphanWidth) + let width = BalancedWidth.narrowest(maxWidth: orphanWidth, measure: measure(sentence)) + #expect(lines(sentence, at: width).count == full.count) + #expect(width <= orphanWidth) + } + + @Test("Pulls a one-word last line up into an even block") + func fixesOrphan() throws { + let greedy = lines(sentence, at: orphanWidth) + #expect(greedy.count == 3) + #expect(greedy.last == "taken.") + + let width = BalancedWidth.narrowest(maxWidth: orphanWidth, measure: measure(sentence)) + let balanced = lines(sentence, at: width) + let last = try #require(balanced.last) + #expect(last.split(separator: " ").count > 1) + #expect(last.count * 2 >= (balanced.map(\.count).max() ?? 0)) + } + + @Test("A single line shrinks to its own width, no narrower") + func singleLine() { + let text = "Photo Library" + let width = BalancedWidth.narrowest(maxWidth: 340, measure: measure(text)) + #expect(lines(text, at: width).count == 1) + #expect(width >= CGFloat(text.count) * charWidth - 1) + #expect(width < 340) + } + + @Test("A width too small for any wrap change returns it unchanged") + func tinyWidth() { + let width = BalancedWidth.narrowest(maxWidth: 1, measure: measure(sentence)) + #expect(width == 1) + } +}