diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..80295b5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,156 @@ +name: Tests + +# Run once per change, not twice. `on: [push, pull_request]` would fire +# both events whenever a PR branch is pushed (push → `refs/heads/`, +# pull_request → `refs/pull//merge`), doubling CI minutes. Restricting +# `push` to `main` means branch pushes only run under the PR; `main` still +# gets CI on direct pushes / merges. +on: + pull_request: + push: + branches: [main] + +# Cancel superseded runs on the same branch so stale pushes don't burn billing. +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Style and formatting gate. Cheap and runs in parallel with the test job, + # so a style violation surfaces immediately instead of after the suite. + # Configs live in `.swiftlint.yml` and `.swiftformat`; both run with + # --strict so warnings are treated as errors. + lint: + runs-on: macos-26 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + timeout-minutes: 2 + + - name: Install swiftlint and swiftformat + timeout-minutes: 5 + run: | + brew update >/dev/null + brew install swiftlint swiftformat + swiftlint --version + swiftformat --version + + # Scoped to the library and its tests. `Demo/` is sample code carrying + # deliberately long marquee strings, and is excluded in `.swiftlint.yml`. + - name: SwiftLint + timeout-minutes: 2 + run: swiftlint lint --strict --quiet Sources Tests + + - name: SwiftFormat + timeout-minutes: 2 + run: swiftformat Sources Tests --lint --strict + + # Test + coverage gate. `swift test` on the macOS host is what produces the + # coverage profile; the platform build matrix below covers the slices this + # job cannot compile. + test: + # Xcode's package-graph resolver checks Package.swift's tools-version with + # Xcode's built-in SwiftPM, not a separately installed toolchain. This + # manifest requires Swift 6.2, so the image needs Xcode 26+. + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + timeout-minutes: 2 + + - uses: maxim-lobanov/setup-xcode@v1 + timeout-minutes: 5 + with: + xcode-version: latest-stable + + - name: Print toolchain + timeout-minutes: 1 + run: | + xcodebuild -version + swift --version + + - name: Run tests with coverage + timeout-minutes: 10 + run: swift test --enable-code-coverage + + # SwiftPM emits an indexed `.profdata`, which Codecov cannot read, so it + # has to be converted to LCOV. Paths are derived from + # `swift build --show-bin-path` rather than hard-coded, because the build + # directory is architecture-specific (e.g. `arm64-apple-macosx/debug`). + - name: Export coverage report + timeout-minutes: 2 + run: | + set -euo pipefail + bin_dir="$(swift build --show-bin-path)" + profdata="$bin_dir/codecov/default.profdata" + test_bundle="$(find "$bin_dir" -maxdepth 1 -name '*.xctest' -type d -print | head -n 1)" + + if [[ ! -f "$profdata" || -z "$test_bundle" ]]; then + echo "::error::Could not find coverage profile or test bundle under $bin_dir." >&2 + exit 1 + fi + + test_binary="$test_bundle/Contents/MacOS/$(basename "$test_bundle" .xctest)" + + xcrun llvm-cov export \ + -format=lcov \ + "$test_binary" \ + -instr-profile "$profdata" \ + --ignore-filename-regex='(/\.build/|/Tests/)' \ + > coverage.lcov + + # An empty or headerless report uploads "successfully" and silently + # reports zero coverage, so fail loudly here instead. + if [[ ! -s coverage.lcov ]] || ! grep -q '^SF:' coverage.lcov; then + echo "::error::Generated LCOV report is empty or invalid." >&2 + exit 1 + fi + + echo "Covered files:" + grep '^SF:' coverage.lcov + + # The repository is public and the organisation allows tokenless uploads, + # so `CODECOV_TOKEN` is optional; the step still passes it when present. + # `fail_ci_if_error` stays false deliberately: a Codecov outage should not + # turn the Tests badge red when the tests themselves passed. + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + timeout-minutes: 5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.lcov + fail_ci_if_error: false + + # Cross-platform compile gate. `swift test` above only ever compiles the + # macOS slice, so a break confined to another platform's SDK would otherwise + # reach main unseen. Building every declared platform is what keeps the + # `platforms:` list in Package.swift honest. + build: + name: Build (${{ matrix.platform }}) + runs-on: macos-26 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + platform: [iOS, macOS, tvOS, visionOS, watchOS] + steps: + - uses: actions/checkout@v6 + timeout-minutes: 2 + + - uses: maxim-lobanov/setup-xcode@v1 + timeout-minutes: 5 + with: + xcode-version: latest-stable + + - name: Build for ${{ matrix.platform }} + timeout-minutes: 15 + run: | + xcodebuild build \ + -scheme MarqueeText \ + -destination "generic/platform=${{ matrix.platform }}" \ + -configuration Debug \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 52fe2f7..c3eedae 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ playground.xcworkspace # .swiftpm .build/ +.swiftpm/ # CocoaPods # diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..868f3df --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,49 @@ +included: + - Sources + - Tests + +excluded: + - .build + - .swiftpm + +opt_in_rules: + - array_init + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - flatmap_over_map_reduce + - identical_operands + - joined_default_parameter + - last_where + - legacy_multiple + - literal_expression_end_indentation + - modifier_order + - operator_usage_whitespace + - overridden_super_call + - prefer_self_type_over_type_of_self + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - unneeded_parentheses_in_closure_argument + - yoda_condition + +line_length: + warning: 120 + error: 200 + ignores_comments: false + ignores_urls: true + +identifier_name: + excluded: + - id + - x + - y diff --git a/Package.swift b/Package.swift index 30de67b..a15e836 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,8 @@ let package = Package( .iOS(.v16), .macOS(.v13), .tvOS(.v16), - .visionOS(.v1) + .visionOS(.v1), + .watchOS(.v9) ], products: [ .library( diff --git a/README.md b/README.md index 0b64190..b8797cb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # MarqueeText +[![Tests](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml/badge.svg)](https://github.com/harflabs/MarqueeText/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/harflabs/MarqueeText/branch/main/graph/badge.svg)](https://codecov.io/gh/harflabs/MarqueeText) +[![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fharflabs%2FMarqueeText%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/harflabs/MarqueeText) +[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fharflabs%2FMarqueeText%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/harflabs/MarqueeText) +[![License](https://img.shields.io/github/license/harflabs/MarqueeText)](LICENSE) + A lightweight SwiftUI component that automatically creates marquee scrolling animations when text overflows its container. Perfect for music players, news tickers, status displays, and more. ## Features @@ -9,7 +15,7 @@ A lightweight SwiftUI component that automatically creates marquee scrolling ani - 🎨 **SwiftUI Native** - Built with pure SwiftUI - ♿️ **Accessible** - VoiceOver-friendly labels with Reduce Motion support - ↔️ **Localizable** - Supports `LocalizedStringResource` and right-to-left layouts -- 📱 **Multi-Platform** - iOS, macOS, tvOS, and visionOS +- 📱 **Multi-Platform** - iOS, macOS, tvOS, visionOS, and watchOS ## Requirements @@ -17,6 +23,7 @@ A lightweight SwiftUI component that automatically creates marquee scrolling ani - macOS 13.0+ - tvOS 16.0+ - visionOS 1.0+ +- watchOS 9.0+ ## Installation @@ -26,7 +33,7 @@ Add the following to your `Package.swift` file: ```swift dependencies: [ - .package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.1.0") + .package(url: "https://github.com/harflabs/MarqueeText.git", from: "1.2.0") ] ``` @@ -64,6 +71,34 @@ the marquee responsive in lists, stacks, compact controls, and during device rot Right-to-left layout direction mirrors the marquee alignment and scroll direction. +`MarqueeText` is layout-interchangeable with a single line `Text`: + +- It reports the same size for every proposal, **including on the first layout pass**, so dropping it into a `List`, + `LazyVStack`, or toolbar never shifts surrounding layout on the following frame. +- A generous height proposal does not stretch it, so it behaves like `Text` — not like `Color` — inside `ZStack`, + overlays, and stacks with taller siblings. +- Text baselines are forwarded, so it lines up in `HStack(alignment: .firstTextBaseline)`. +- Only the horizontal axis is clipped. Glyphs that legitimately paint outside the line box — Arabic diacritics, + emoji, tall accents — render exactly as `Text` renders them. + +These guarantees are covered by tests that compare `MarqueeText` against a real `Text` in a hosting view. + +### Performance + +`MarqueeText` is built to survive long lists. + +- **Text that fits costs nothing at rest.** Only overflowing text starts a timeline, so a list of mostly + short labels does no per-frame work at all. +- **Offscreen rows stop animating.** In a `List` or `LazyVStack`, per-frame work stays flat whether the + collection holds 25 rows or 2,500 — only the rows on screen tick. +- **The view measures itself once.** Both the text width and the container width are produced by the layout + and reported through a single weightless probe, so there is no hidden duplicate of the text to lay out and + no second geometry reader. + +One caveat worth knowing: a plain `VStack` inside a `ScrollView` builds *every* row, so every overflowing +marquee animates even while scrolled out of sight. Use `List` or `LazyVStack` for long collections, as you +would for any non-trivial row content. + ### Custom Timing ```swift @@ -93,8 +128,11 @@ MarqueeText("Styled marquee text") ### Accessibility -`MarqueeText` exposes a single accessibility label for the full text. When Reduce Motion is enabled, overflowing -text is shown without the continuous marquee animation. +`MarqueeText` exposes a single accessibility label for the full text, carrying the static text trait. + +When Reduce Motion is enabled, overflowing text does not scroll. It truncates with an ellipsis exactly like `Text`, +so the label still reads as deliberately shortened rather than being cut off mid glyph. The full string remains +available to VoiceOver through the accessibility label. ## Testing @@ -105,7 +143,8 @@ swift test --enable-code-coverage ``` The test suite covers overflow detection, text and layout updates, right-to-left layout, Reduce Motion, invalid sizing -inputs, and redraw-heavy animation timing. +inputs, redraw-heavy animation timing, seamless loop continuity, and size and baseline parity with `Text` measured +through a real hosting view. ## Examples @@ -142,7 +181,6 @@ inputs, and redraw-heavy animation timing. ## Apps Using MarqueeText -- [Casti - Your Personalized Podcasts, Powered by AI](https://apps.apple.com/app/id6746376736) - [Tilfaz - Live & On-Demand TV](https://apps.apple.com/app/id1668359578) *Add your app here! Submit a pull request to include your app.* @@ -159,4 +197,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file This library is built by [Harf Labs](https://harflabs.com), a software development company that creates solutions for real problems. -If you like this project and need help with your own software projects, we'd love to hear from you! [Get in touch](https://harflabs.com/en/contact) and let's build something amazing together. +If you like this project and need help with your own software projects, we'd love to hear from you! [Get in touch](https://harflabs.com/en/#contact) and let's build something amazing together. diff --git a/Sources/MarqueeText/MarqueeConfiguration.swift b/Sources/MarqueeText/MarqueeConfiguration.swift new file mode 100644 index 0000000..5c3afcd --- /dev/null +++ b/Sources/MarqueeText/MarqueeConfiguration.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftUI + +struct MarqueeConfiguration: Equatable { + static let defaultDelay: TimeInterval = 1 + static let defaultDuration: TimeInterval = 8 + static let defaultSpacing: CGFloat = 50 + + var delay: TimeInterval + var duration: TimeInterval + var spacing: CGFloat + + init( + duration: TimeInterval = Self.defaultDuration, + delay: TimeInterval = Self.defaultDelay, + spacing: CGFloat = Self.defaultSpacing + ) { + self.duration = duration.marqueePositive(or: Self.defaultDuration) + self.delay = delay.marqueeNonNegative + self.spacing = spacing.marqueeNonNegative + } +} diff --git a/Sources/MarqueeText/MarqueeContent.swift b/Sources/MarqueeText/MarqueeContent.swift new file mode 100644 index 0000000..ddfa2d0 --- /dev/null +++ b/Sources/MarqueeText/MarqueeContent.swift @@ -0,0 +1,21 @@ +import SwiftUI + +enum MarqueeContent: Equatable { + case localized(LocalizedStringResource) + case verbatim(String) + + var text: Text { + switch self { + case .localized(let text): + Text(text) + case .verbatim(let text): + Text(verbatim: text) + } + } +} + +// The two widths the marquee needs in view state in order to decide whether to scroll. +// +// Both are already known to ``MarqueeSizingLayout``, which hands them back by proposing them as the size +// of a weightless probe subview: width carries the natural text width, height carries the container width. +// Only the widths matter — the view's height comes from the layout, not from view state. diff --git a/Sources/MarqueeText/MarqueeHorizontalClipShape.swift b/Sources/MarqueeText/MarqueeHorizontalClipShape.swift new file mode 100644 index 0000000..0d50548 --- /dev/null +++ b/Sources/MarqueeText/MarqueeHorizontalClipShape.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct MarqueeHorizontalClipShape: Shape { + static let minimumVerticalOverhang: CGFloat = 64 + static let verticalOverhangMultiplier: CGFloat = 4 + + static func verticalOverhang(forHeight height: CGFloat) -> CGFloat { + Swift.max(height.marqueeNonNegative * verticalOverhangMultiplier, minimumVerticalOverhang) + } + + static func clipRect(in rect: CGRect) -> CGRect { + let width = rect.width.marqueeNonNegative + let height = rect.height.marqueeNonNegative + let overhang = verticalOverhang(forHeight: height) + let originX = rect.origin.x.isFinite ? rect.origin.x : 0 + let originY = rect.origin.y.isFinite ? rect.origin.y : 0 + + return CGRect( + x: originX, + y: originY - overhang, + width: width, + height: height + overhang * 2 + ) + } + + func path(in rect: CGRect) -> Path { + Path(Self.clipRect(in: rect)) + } +} diff --git a/Sources/MarqueeText/MarqueeMeasurement.swift b/Sources/MarqueeText/MarqueeMeasurement.swift new file mode 100644 index 0000000..7e84862 --- /dev/null +++ b/Sources/MarqueeText/MarqueeMeasurement.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct MarqueeMeasurement: Equatable { + static let zero = MarqueeMeasurement(textWidth: 0, containerWidth: 0) + + var containerWidth: CGFloat + var textWidth: CGFloat + + init(textWidth: CGFloat, containerWidth: CGFloat) { + self.containerWidth = containerWidth.marqueeNonNegative + self.textWidth = textWidth.marqueeNonNegative + } + + /// Rebuilds the measurement from the size the probe actually resolved to. + init(probeSize: CGSize) { + self.init(textWidth: probeSize.width, containerWidth: probeSize.height) + } + + /// The proposal that makes a probe resolve to this measurement. + var probeProposal: ProposedViewSize { + ProposedViewSize(width: textWidth, height: containerWidth) + } +} diff --git a/Sources/MarqueeText/MarqueeMeasurementReader.swift b/Sources/MarqueeText/MarqueeMeasurementReader.swift new file mode 100644 index 0000000..cd29b6a --- /dev/null +++ b/Sources/MarqueeText/MarqueeMeasurementReader.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// Carries the probe's measurement, using `nil` to mean "this subtree did not measure anything". +/// +/// SwiftUI folds every child of a container into the preference, including children that never write one +/// and therefore contribute `defaultValue`. Those must not clear a real measurement. Distinguishing "no +/// contribution" (`nil`) from "measured, and the answer is zero" (`.some(.zero)`) is what lets a genuinely +/// empty measurement still overwrite an earlier one, instead of leaving stale widths behind. +struct MarqueeMeasurementPreferenceKey: PreferenceKey { + static var defaultValue: MarqueeMeasurement? { + nil + } + + static func reduce(value: inout MarqueeMeasurement?, nextValue: () -> MarqueeMeasurement?) { + guard let next = nextValue() else { return } + + value = next + } +} + +/// Reports back whatever size the layout proposes to it, which is how both widths reach view state. +struct MarqueeMeasurementReader: View { + var body: some View { + GeometryReader { geometry in + Color.clear + .allowsHitTesting(false) + .preference( + key: MarqueeMeasurementPreferenceKey.self, + value: MarqueeMeasurement(probeSize: geometry.size) as MarqueeMeasurement? + ) + } + } +} + +// A rectangle that spans the view horizontally but extends well past it vertically. +// +// Clipping horizontally is what hides the off screen marquee copies. Clipping vertically is not wanted: +// glyphs such as Arabic diacritics, emoji, and tall accents legitimately paint outside the line box and +// `Text` renders them, so the marquee must not cut them off. diff --git a/Sources/MarqueeText/MarqueeNumericSupport.swift b/Sources/MarqueeText/MarqueeNumericSupport.swift new file mode 100644 index 0000000..596904f --- /dev/null +++ b/Sources/MarqueeText/MarqueeNumericSupport.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftUI + +extension CGSize { + var marqueeSanitized: CGSize { + CGSize( + width: width.marqueeNonNegative, + height: height.marqueeNonNegative + ) + } +} + +extension CGFloat { + var marqueeNonNegative: CGFloat { + isFinite && self > 0 ? self : 0 + } + + func marqueeClamped(to range: ClosedRange) -> CGFloat { + guard !isNaN else { return range.lowerBound } + guard isFinite else { + return self > 0 ? range.upperBound : range.lowerBound + } + + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + +extension TimeInterval { + var marqueeNonNegative: TimeInterval { + isFinite && self > 0 ? self : 0 + } + + func marqueePositive(or fallback: TimeInterval) -> TimeInterval { + isFinite && self > 0 ? self : fallback + } +} diff --git a/Sources/MarqueeText/MarqueeResolvedLayout.swift b/Sources/MarqueeText/MarqueeResolvedLayout.swift new file mode 100644 index 0000000..953cd53 --- /dev/null +++ b/Sources/MarqueeText/MarqueeResolvedLayout.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftUI + +struct MarqueeResolvedLayout: Equatable { + static let overflowTolerance: CGFloat = 0.5 + + var configuration: MarqueeConfiguration + var content: MarqueeContent + var layoutDirection: LayoutDirection + var localeIdentifier: String + var measurement: MarqueeMeasurement + var reduceMotion: Bool + + var alignment: Alignment { + isRightToLeft ? .trailing : .leading + } + + var animationIdentity: MarqueeAnimationIdentity { + MarqueeAnimationIdentity( + containerWidth: measurement.containerWidth, + content: content, + delay: configuration.delay, + duration: configuration.duration, + isRightToLeft: isRightToLeft, + localeIdentifier: localeIdentifier, + reduceMotion: reduceMotion, + shouldScroll: shouldScroll, + spacing: configuration.spacing, + textWidth: measurement.textWidth + ) + } + + var hasMeasuredContainer: Bool { + measurement.containerWidth > 0 + } + + var hasMeasuredText: Bool { + measurement.textWidth > 0 + } + + var isRightToLeft: Bool { + layoutDirection == .rightToLeft + } + + var offset: CGFloat { + offset(progress: 1) + } + + var overflows: Bool { + guard hasMeasuredContainer, hasMeasuredText else { return false } + + return measurement.textWidth - measurement.containerWidth > Self.overflowTolerance + } + + var scrollDistance: CGFloat { + measurement.textWidth + configuration.spacing + } + + var shouldScroll: Bool { + overflows && !reduceMotion + } + + func offset( + at date: Date, + startDate: Date + ) -> CGFloat { + offset(progress: progress(at: date, startDate: startDate)) + } + + func offset(progress: CGFloat) -> CGFloat { + guard shouldScroll else { return 0 } + + let distance = scrollDistance * progress.marqueeClamped(to: 0...1) + return isRightToLeft ? distance : -distance + } + + func progress( + at date: Date, + startDate: Date + ) -> CGFloat { + guard shouldScroll else { return 0 } + + let elapsed = max(0, date.timeIntervalSince(startDate)) + let cycleDuration = configuration.delay + configuration.duration + guard cycleDuration.isFinite, cycleDuration > 0 else { return 0 } + + let cycleElapsed = elapsed.truncatingRemainder(dividingBy: cycleDuration) + + guard cycleElapsed > configuration.delay else { return 0 } + + return CGFloat((cycleElapsed - configuration.delay) / configuration.duration) + .marqueeClamped(to: 0...1) + } +} + +struct MarqueeAnimationIdentity: Equatable { + var containerWidth: CGFloat + var content: MarqueeContent + var delay: TimeInterval + var duration: TimeInterval + var isRightToLeft: Bool + var localeIdentifier: String + var reduceMotion: Bool + var shouldScroll: Bool + var spacing: CGFloat + var textWidth: CGFloat +} diff --git a/Sources/MarqueeText/MarqueeSizingLayout.swift b/Sources/MarqueeText/MarqueeSizingLayout.swift new file mode 100644 index 0000000..815c53d --- /dev/null +++ b/Sources/MarqueeText/MarqueeSizingLayout.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct MarqueeSizingLayout: Layout { + var alignment: Alignment + var isScrolling: Bool + var spacing: CGFloat + + var placementAnchor: UnitPoint { + alignment == .trailing ? .trailing : .leading + } + + /// Recovers the size of a single text run from the ideal size of the displayed content. + /// + /// While scrolling, the content is two copies separated by `spacing`, so one run is half of what is left + /// after the gap. Otherwise the content is a single run already. Deriving it here is what lets the view + /// avoid laying the text out a second time purely to measure it. + static func singleTextSize( + contentIdealSize: CGSize, + isScrolling: Bool, + spacing: CGFloat + ) -> CGSize { + let contentIdealSize = contentIdealSize.marqueeSanitized + + guard isScrolling else { return contentIdealSize } + + return CGSize( + width: ((contentIdealSize.width - spacing.marqueeNonNegative) / 2).marqueeNonNegative, + height: contentIdealSize.height + ) + } + + /// Resolves the size the marquee reports to its parent. + /// + /// This mirrors a single line `Text`: the natural size, capped by whatever the parent proposes on each + /// axis. It depends only on the content's ideal size, so it is already correct on the first layout pass + /// and never changes as internal measurement state settles. + static func resolvedSize( + intrinsicSize: CGSize, + proposedWidth: CGFloat?, + proposedHeight: CGFloat? + ) -> CGSize { + let intrinsicSize = intrinsicSize.marqueeSanitized + let width = proposedWidth + .flatMap { $0.isFinite ? Swift.min($0.marqueeNonNegative, intrinsicSize.width) : nil } + ?? intrinsicSize.width + let height = proposedHeight + .flatMap { $0.isFinite ? Swift.min($0.marqueeNonNegative, intrinsicSize.height) : nil } + ?? intrinsicSize.height + + return CGSize(width: width, height: height) + .marqueeSanitized + } + + /// The natural size of one text run, derived from the displayed content. + func intrinsicTextSize(subviews: Subviews) -> CGSize { + guard let content = subviews.first else { return .zero } + + return Self.singleTextSize( + contentIdealSize: content.sizeThatFits(.unspecified), + isScrolling: isScrolling, + spacing: spacing + ) + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) -> CGSize { + Self.resolvedSize( + intrinsicSize: intrinsicTextSize(subviews: subviews), + proposedWidth: proposal.width, + proposedHeight: proposal.height + ) + } + + /// Forwards the text baselines so the marquee aligns with `Text` in `firstTextBaseline` stacks. + func explicitAlignment( + of guide: VerticalAlignment, + in bounds: CGRect, + proposal _: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) -> CGFloat? { + guard guide == .firstTextBaseline || guide == .lastTextBaseline else { return nil } + guard let content = subviews.first else { return nil } + + let dimensions = content.dimensions(in: .unspecified) + let baseline = dimensions[guide] + + guard baseline.isFinite, dimensions.height.isFinite, bounds.height.isFinite else { return nil } + + return (bounds.height - dimensions.height) / 2 + baseline + } + + func placementPoint(in bounds: CGRect) -> CGPoint { + alignment == .trailing + ? CGPoint(x: bounds.maxX, y: bounds.midY) + : CGPoint(x: bounds.minX, y: bounds.midY) + } + + func placeSubviews( + in bounds: CGRect, + proposal _: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) { + guard let content = subviews.first else { return } + + content.place( + at: placementPoint(in: bounds), + anchor: placementAnchor, + proposal: ProposedViewSize( + width: bounds.width, + height: bounds.height + ) + ) + + // Any remaining subview is the measurement probe. Proposing the two widths makes it report them + // back through a preference, so neither the text nor the container needs its own geometry reader. + guard subviews.count > 1 else { return } + + let measurement = MarqueeMeasurement( + textWidth: intrinsicTextSize(subviews: subviews).width, + containerWidth: bounds.width + ) + + for index in 1.. some View { - TimelineView(.animation(paused: !layout.shouldAnimate)) { timeline in + TimelineView(.animation) { timeline in HStack(spacing: configuration.spacing) { - displayText - displayText + intrinsicText + intrinsicText } .offset( x: layout.offset( @@ -170,354 +181,9 @@ public struct MarqueeText: View { } } - func updateContainerSize(_ newValue: CGSize) { - updateSize(&containerSize, to: newValue) - } - - func updateTextSize(_ newValue: CGSize) { - updateSize(&textSize, to: newValue) - } - - func updateSize(_ size: inout CGSize, to newValue: CGSize) { - let sanitizedValue = newValue.marqueeSanitized - - guard size.isMeaningfullyDifferent(from: sanitizedValue) else { return } - - size = sanitizedValue - } -} - -enum MarqueeContent { - case localized(LocalizedStringResource) - case verbatim(String) - - var text: Text { - switch self { - case .localized(let text): - Text(text) - case .verbatim(let text): - Text(verbatim: text) - } - } - - var animationIdentity: String { - switch self { - case .localized(let text): - "localized:\(String(describing: text))" - case .verbatim(let text): - "verbatim:\(text)" - } - } -} - -struct MarqueeConfiguration: Equatable { - static let defaultDelay: TimeInterval = 1 - static let defaultDuration: TimeInterval = 8 - static let defaultSpacing: CGFloat = 50 - - var delay: TimeInterval - var duration: TimeInterval - var spacing: CGFloat - - init( - duration: TimeInterval = Self.defaultDuration, - delay: TimeInterval = Self.defaultDelay, - spacing: CGFloat = Self.defaultSpacing - ) { - self.duration = duration.marqueePositive(or: Self.defaultDuration) - self.delay = delay.marqueeNonNegative - self.spacing = spacing.marqueeNonNegative - } -} - -struct MarqueeResolvedLayout: Equatable { - static let defaultHeight: CGFloat = 20 - static let overflowTolerance: CGFloat = 0.5 - - var configuration: MarqueeConfiguration - var containerSize: CGSize - var contentIdentity: String - var layoutDirection: LayoutDirection - var localeIdentifier: String - var reduceMotion: Bool - var textSize: CGSize - - init( - textSize: CGSize, - containerSize: CGSize, - configuration: MarqueeConfiguration, - contentIdentity: String, - layoutDirection: LayoutDirection, - localeIdentifier: String, - reduceMotion: Bool - ) { - self.configuration = configuration - self.containerSize = containerSize.marqueeSanitized - self.contentIdentity = contentIdentity - self.layoutDirection = layoutDirection - self.localeIdentifier = localeIdentifier - self.reduceMotion = reduceMotion - self.textSize = textSize.marqueeSanitized - } - - var alignment: Alignment { - isRightToLeft ? .trailing : .leading - } - - var animationIdentity: MarqueeAnimationIdentity { - MarqueeAnimationIdentity( - containerWidth: containerSize.width, - contentIdentity: contentIdentity, - delay: configuration.delay, - duration: configuration.duration, - isRightToLeft: isRightToLeft, - localeIdentifier: localeIdentifier, - reduceMotion: reduceMotion, - shouldScroll: shouldScroll, - spacing: configuration.spacing, - textWidth: textSize.width - ) - } - - var hasAnimation: Bool { - shouldAnimate - } - - var hasMeasuredContainer: Bool { - containerSize.width > 0 - } - - var hasMeasuredText: Bool { - textSize.width > 0 && textSize.height > 0 - } - - var height: CGFloat { - textSize.height > 0 ? textSize.height : Self.defaultHeight - } - - var isRightToLeft: Bool { - layoutDirection == .rightToLeft - } - - var offset: CGFloat { - offset(progress: 1) - } - - var overflows: Bool { - guard hasMeasuredContainer, hasMeasuredText else { return false } - - return textSize.width - containerSize.width > Self.overflowTolerance - } - - var scrollDistance: CGFloat { - textSize.width + configuration.spacing - } - - var shouldAnimate: Bool { - shouldScroll - } - - var shouldScroll: Bool { - overflows && !reduceMotion - } - - func offset( - at date: Date, - startDate: Date - ) -> CGFloat { - offset(progress: progress(at: date, startDate: startDate)) - } - - func offset(progress: CGFloat) -> CGFloat { - guard shouldScroll else { return 0 } - - let distance = scrollDistance * progress.marqueeClamped(to: 0...1) - return isRightToLeft ? distance : -distance - } - - func progress( - at date: Date, - startDate: Date - ) -> CGFloat { - guard shouldScroll else { return 0 } - - let elapsed = max(0, date.timeIntervalSince(startDate)) - let cycleDuration = configuration.delay + configuration.duration - guard cycleDuration.isFinite, cycleDuration > 0 else { return 0 } - - let cycleElapsed = elapsed.truncatingRemainder(dividingBy: cycleDuration) - - guard cycleElapsed > configuration.delay else { return 0 } - - return CGFloat((cycleElapsed - configuration.delay) / configuration.duration) - .marqueeClamped(to: 0...1) - } -} - -struct MarqueeAnimationIdentity: Equatable, Hashable { - var containerWidth: CGFloat - var contentIdentity: String - var delay: TimeInterval - var duration: TimeInterval - var isRightToLeft: Bool - var localeIdentifier: String - var reduceMotion: Bool - var shouldScroll: Bool - var spacing: CGFloat - var textWidth: CGFloat -} - -struct MarqueeContainerSizePreferenceKey: PreferenceKey { - static var defaultValue: CGSize { - .zero - } - - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue().marqueeSanitized - } -} - -struct MarqueeTextSizePreferenceKey: PreferenceKey { - static var defaultValue: CGSize { - .zero - } - - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue().marqueeSanitized - } -} - -struct MarqueeContainerSizeReader: View { - var body: some View { - GeometryReader { geometry in - Color.clear - .allowsHitTesting(false) - .preference( - key: MarqueeContainerSizePreferenceKey.self, - value: geometry.size - ) - } - } -} - -struct MarqueeTextSizeReader: View { - var body: some View { - GeometryReader { geometry in - Color.clear - .allowsHitTesting(false) - .preference( - key: MarqueeTextSizePreferenceKey.self, - value: geometry.size - ) - } - } -} - -struct MarqueeSizingLayout: Layout { - var alignment: Alignment - - var placementAnchor: UnitPoint { - alignment == .trailing ? .trailing : .leading - } - - static func resolvedSize( - intrinsicSize: CGSize, - proposedWidth: CGFloat?, - proposedHeight: CGFloat? - ) -> CGSize { - let intrinsicSize = intrinsicSize.marqueeSanitized - let width = proposedWidth - .flatMap { $0.isFinite ? min($0.marqueeNonNegative, intrinsicSize.width) : nil } - ?? intrinsicSize.width - let height = proposedHeight - .flatMap { $0.isFinite ? $0.marqueeNonNegative : nil } - ?? intrinsicSize.height - - return CGSize(width: width, height: height) - .marqueeSanitized - } - - func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache _: inout () - ) -> CGSize { - let intrinsicSize = subviews[0].sizeThatFits(.unspecified) - - return Self.resolvedSize( - intrinsicSize: intrinsicSize, - proposedWidth: proposal.width, - proposedHeight: proposal.height - ) - } - - func placementPoint(in bounds: CGRect) -> CGPoint { - alignment == .trailing - ? CGPoint(x: bounds.maxX, y: bounds.midY) - : CGPoint(x: bounds.minX, y: bounds.midY) - } - - func placeSubviews( - in bounds: CGRect, - proposal _: ProposedViewSize, - subviews: Subviews, - cache _: inout () - ) { - for subview in subviews { - subview.place( - at: placementPoint(in: bounds), - anchor: placementAnchor, - proposal: ProposedViewSize( - width: bounds.width, - height: bounds.height - ) - ) - } - } -} - -extension CGSize { - var marqueeSanitized: CGSize { - CGSize( - width: width.marqueeNonNegative, - height: height.marqueeNonNegative - ) - } - - func isMeaningfullyDifferent( - from other: CGSize, - tolerance: CGFloat = 0 - ) -> Bool { - guard width.isFinite, height.isFinite, other.width.isFinite, other.height.isFinite else { - return true - } - - return abs(width - other.width) > tolerance - || abs(height - other.height) > tolerance - } -} - -extension CGFloat { - var marqueeNonNegative: CGFloat { - isFinite && self > 0 ? self : 0 - } - - func marqueeClamped(to range: ClosedRange) -> CGFloat { - guard !isNaN else { return range.lowerBound } - guard isFinite else { - return self > 0 ? range.upperBound : range.lowerBound - } - - return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } -} - -extension TimeInterval { - var marqueeNonNegative: TimeInterval { - isFinite && self > 0 ? self : 0 - } + func updateMeasurement(_ newValue: MarqueeMeasurement?) { + guard let newValue, measurement != newValue else { return } - func marqueePositive(or fallback: TimeInterval) -> TimeInterval { - isFinite && self > 0 ? self : fallback + measurement = newValue } } diff --git a/Tests/MarqueeTextTests/MarqueeConfigurationTests.swift b/Tests/MarqueeTextTests/MarqueeConfigurationTests.swift new file mode 100644 index 0000000..5779857 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeConfigurationTests.swift @@ -0,0 +1,67 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueeConfigurationTests { + @Test + func validConfigurationIsPreserved() { + let configuration = MarqueeConfiguration( + duration: 4.5, + delay: 0.25, + spacing: 12 + ) + + #expect( + configuration == MarqueeConfiguration( + duration: 4.5, + delay: 0.25, + spacing: 12 + ) + ) + } + + @Test + func invalidConfigurationIsClamped() { + #expect( + MarqueeConfiguration( + duration: -1, + delay: -.infinity, + spacing: .nan + ) == MarqueeConfiguration( + duration: MarqueeConfiguration.defaultDuration, + delay: 0, + spacing: 0 + ) + ) + } + + @Test + func zeroDurationUsesDefaultButZeroDelayAndSpacingAreAllowed() { + #expect( + MarqueeConfiguration( + duration: 0, + delay: 0, + spacing: 0 + ) == MarqueeConfiguration( + duration: MarqueeConfiguration.defaultDuration, + delay: 0, + spacing: 0 + ) + ) + } + + @Test + func positiveInfinityConfigurationValuesAreClamped() { + #expect( + MarqueeConfiguration( + duration: .infinity, + delay: .infinity, + spacing: .infinity + ) == MarqueeConfiguration( + duration: MarqueeConfiguration.defaultDuration, + delay: 0, + spacing: 0 + ) + ) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeContentTests.swift b/Tests/MarqueeTextTests/MarqueeContentTests.swift new file mode 100644 index 0000000..2559f26 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeContentTests.swift @@ -0,0 +1,148 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +@MainActor +struct MarqueeContentTests { + @Test + func stringLiteralsUseLocalizedContent() { + let view = MarqueeText("Localized title") + + switch view.content { + case .localized: + break + case .verbatim: + Issue.record("String literals should keep using LocalizedStringResource.") + } + } + + @Test + func runtimeStringsUseVerbatimContent() { + let title = String("Runtime title") + let view = MarqueeText(title) + + switch view.content { + case .localized: + Issue.record("Runtime strings should use verbatim text.") + case .verbatim(let text): + #expect(text == "Runtime title") + } + } + + @Test + func explicitVerbatimInitializerUsesVerbatimContent() { + let view = MarqueeText(verbatim: "Exact title") + + switch view.content { + case .localized: + Issue.record("Verbatim initializer should keep the exact string.") + case .verbatim(let text): + #expect(text == "Exact title") + } + } + + @Test + func textViewsCanBeCreatedForBothContentKinds() { + let localizedText: Text = MarqueeContent.localized("Title").text + let verbatimText: Text = MarqueeContent.verbatim("Title").text + + _ = localizedText + _ = verbatimText + } + + @Test + func contentComparesByValueWithoutReflection() { + // Comparing equal-looking operands is the point here: it proves equality is by value, not by identity. + // swiftlint:disable identical_operands + #expect(MarqueeContent.verbatim("Title A") == MarqueeContent.verbatim("Title A")) + #expect(MarqueeContent.verbatim("Title A") != MarqueeContent.verbatim("Title B")) + #expect(MarqueeContent.localized("Title A") == MarqueeContent.localized("Title A")) + // swiftlint:enable identical_operands + #expect(MarqueeContent.localized("Title A") != MarqueeContent.localized("Title B")) + #expect(MarqueeContent.localized("Title A") != MarqueeContent.verbatim("Title A")) + } + + @Test + func bodiesCanBeCreatedForLocalizedAndVerbatimContent() { + _ = MarqueeText("Localized title").body + _ = MarqueeText(verbatim: "Runtime title").body + _ = MarqueeText( + content: .verbatim("Overflowing runtime title"), + configuration: MarqueeConfiguration(duration: 2, delay: 0, spacing: 12), + measurement: MarqueeMeasurement(textWidth: 180, containerWidth: 80) + ) + .body + } + + #if os(macOS) + @Test + func staticTextCanBeRenderedToAnImage() { + let renderer = ImageRenderer( + content: MarqueeText("Rendered title") + .frame(width: 240, height: 44) + ) + + #expect(renderer.nsImage != nil) + } + + @Test + func overflowingTextCanBeRenderedToAnImage() { + let renderer = ImageRenderer( + content: MarqueeText( + content: .verbatim("Rendered overflowing title"), + configuration: MarqueeConfiguration(duration: 2, delay: 0, spacing: 12), + measurement: MarqueeMeasurement(textWidth: 220, containerWidth: 80), + animationStartDate: Date(timeIntervalSince1970: 0) + ) + .frame(width: 80, height: 44) + ) + + #expect(renderer.nsImage != nil) + } + #endif + + @Test + func internalViewHelpersCanBeCreatedAndAnimationCanRestart() { + let view = MarqueeText(verbatim: "A long runtime title") + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 80 + ) + + _ = view.intrinsicText + _ = view.truncatingText + _ = view.scrollingText(layout: layout) + _ = MarqueeMeasurementReader().body + + view.restartAnimation(shouldAnimate: false) + view.restartAnimation(shouldAnimate: true) + } + + @Test + func measurementUpdatesAreSanitizedAndSubPointChangesStillApply() { + let view = MarqueeText(verbatim: "Runtime title") + + // Sub-point changes matter: they decide which side of the overflow threshold the text lands on. + #expect( + MarqueeMeasurement(textWidth: 100.4, containerWidth: 100) + != MarqueeMeasurement(textWidth: 100.8, containerWidth: 100) + ) + #expect( + MarqueeMeasurement(textWidth: .nan, containerWidth: -.infinity) == .zero + ) + + view.updateMeasurement(MarqueeMeasurement(textWidth: 80, containerWidth: 40)) + // An absent contribution must be ignored rather than treated as a measurement of zero. + view.updateMeasurement(nil) + } + + @Test + func probeProposalRoundTripsThroughTheProbeSize() { + let measurement = MarqueeMeasurement(textWidth: 180, containerWidth: 100) + let proposal = measurement.probeProposal + + #expect(proposal.width == 180) + #expect(proposal.height == 100) + #expect(MarqueeMeasurement(probeSize: CGSize(width: 180, height: 100)) == measurement) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeHorizontalClipShapeTests.swift b/Tests/MarqueeTextTests/MarqueeHorizontalClipShapeTests.swift new file mode 100644 index 0000000..765d8a6 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeHorizontalClipShapeTests.swift @@ -0,0 +1,41 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueeHorizontalClipShapeTests { + @Test + func clipMatchesTheViewHorizontallyAndExtendsPastItVertically() { + let rect = CGRect(x: 0, y: 0, width: 120, height: 18) + let clip = MarqueeHorizontalClipShape.clipRect(in: rect) + + #expect(clip.minX == rect.minX) + #expect(clip.width == rect.width) + #expect(clip.minY < rect.minY) + #expect(clip.maxY > rect.maxY) + } + + @Test + func verticalOverhangComfortablyExceedsGlyphOverhang() { + // Diacritics, emoji, and tall accents paint outside the line box; the clip must never cut them. + #expect(MarqueeHorizontalClipShape.verticalOverhang(forHeight: 18) >= 18) + #expect(MarqueeHorizontalClipShape.verticalOverhang(forHeight: 100) == 400) + #expect( + MarqueeHorizontalClipShape.verticalOverhang(forHeight: 0) + == MarqueeHorizontalClipShape.minimumVerticalOverhang + ) + } + + @Test + func invalidRectsProduceAFiniteClip() { + let clip = MarqueeHorizontalClipShape.clipRect( + in: CGRect(x: CGFloat.nan, y: .infinity, width: .nan, height: .infinity) + ) + + #expect(clip.origin.x.isFinite) + #expect(clip.origin.y.isFinite) + #expect(clip.width == 0) + #expect(clip.height.isFinite) + + _ = MarqueeHorizontalClipShape().path(in: CGRect(x: 0, y: 0, width: 120, height: 18)) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeLayoutTests.swift b/Tests/MarqueeTextTests/MarqueeLayoutTests.swift new file mode 100644 index 0000000..6124a8d --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeLayoutTests.swift @@ -0,0 +1,276 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueeLayoutTests { + @Test + func unmeasuredTextDoesNotScroll() { + let layout = resolvedLayout( + textWidth: 0, + containerWidth: 100 + ) + + #expect(!layout.hasMeasuredText) + #expect(layout.hasMeasuredContainer) + #expect(!layout.overflows) + #expect(!layout.shouldScroll) + #expect(layout.offset == 0) + } + + @Test + func unmeasuredContainerDoesNotScroll() { + let layout = resolvedLayout( + textWidth: 120, + containerWidth: 0 + ) + + #expect(layout.hasMeasuredText) + #expect(!layout.hasMeasuredContainer) + #expect(!layout.overflows) + #expect(!layout.shouldScroll) + } + + @Test + func textWithinOverflowToleranceDoesNotScroll() { + let layout = resolvedLayout( + textWidth: 100.4, + containerWidth: 100 + ) + + #expect(!layout.overflows) + #expect(!layout.shouldScroll) + } + + @Test + func textJustBeyondOverflowToleranceScrolls() { + let layout = resolvedLayout( + textWidth: 100.6, + containerWidth: 100 + ) + + #expect(layout.overflows) + #expect(layout.shouldScroll) + } + + @Test + func unmeasuredTextNeverScrollsEvenWhenTheContainerIsKnown() { + // Until the probe reports back, the text width is zero. Treating that as "narrower than the + // container" would be wrong in the other direction, so it must simply not scroll yet. + let layout = resolvedLayout( + textWidth: 0, + containerWidth: 100 + ) + + #expect(!layout.hasMeasuredText) + #expect(!layout.overflows) + #expect(!layout.shouldScroll) + } + + @Test + func overflowingTextScrollsWhenMotionIsAllowed() { + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24) + ) + + #expect(layout.overflows) + #expect(layout.shouldScroll) + #expect(layout.scrollDistance == 204) + #expect(layout.offset == -204) + } + + @Test + func timelineProgressWaitsDuringDelayAndMovesLinearly() { + let startDate = Date(timeIntervalSince1970: 100) + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 4, delay: 1, spacing: 20) + ) + + #expect(layout.shouldScroll) + #expect(layout.progress(at: startDate, startDate: startDate) == 0) + #expect(layout.progress(at: startDate.addingTimeInterval(0.5), startDate: startDate) == 0) + #expect(layout.progress(at: startDate.addingTimeInterval(3), startDate: startDate) == 0.5) + #expect(layout.offset(at: startDate, startDate: startDate) == 0) + #expect(layout.offset(at: startDate.addingTimeInterval(3), startDate: startDate) == -100) + #expect(layout.progress(at: startDate.addingTimeInterval(5.5), startDate: startDate) == 0) + } + + @Test + func scrollLoopIsSeamlessBecauseOnePassEqualsTheCopySpacing() { + let startDate = Date(timeIntervalSince1970: 0) + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 4, delay: 0, spacing: 20) + ) + + // A full pass must travel exactly one text width plus the spacing, which puts the second copy + // precisely where the first started. Anything else makes the loop visibly jump. + #expect(layout.scrollDistance == 200) + #expect(layout.offset(progress: 1) == -200) + #expect(layout.offset(at: startDate.addingTimeInterval(4), startDate: startDate) == 0) + } + + @Test + func timelineProgressDoesNotMoveBeforeStartDate() { + let startDate = Date(timeIntervalSince1970: 100) + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 4, delay: 1, spacing: 20) + ) + + #expect(layout.progress(at: startDate.addingTimeInterval(-10), startDate: startDate) == 0) + #expect(layout.offset(at: startDate.addingTimeInterval(-10), startDate: startDate) == 0) + } + + @Test + func timelineProgressHandlesOverflowingCycleDuration() { + let startDate = Date(timeIntervalSince1970: 100) + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration( + duration: .greatestFiniteMagnitude, + delay: .greatestFiniteMagnitude, + spacing: 20 + ) + ) + + #expect(layout.progress(at: startDate.addingTimeInterval(1), startDate: startDate) == 0) + #expect(layout.offset(at: startDate.addingTimeInterval(1), startDate: startDate) == 0) + } + + @Test + func repeatedGeometryChangesDoNotAccelerateTimelineMotion() { + let startDate = Date(timeIntervalSince1970: 200) + let date = startDate.addingTimeInterval(2) + let configuration = MarqueeConfiguration(duration: 4, delay: 0, spacing: 20) + let offsets = [80, 120, 160, 80, 120, 160].map { width in + resolvedLayout( + textWidth: 300, + containerWidth: width, + configuration: configuration + ) + .offset(at: date, startDate: startDate) + } + + #expect(offsets.allSatisfy { $0 == -160 }) + } + + @Test + func repeatedTimelineCyclesDoNotAccumulateExtraDistance() { + let startDate = Date(timeIntervalSince1970: 300) + let layout = resolvedLayout( + textWidth: 300, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 4, delay: 0, spacing: 20) + ) + + #expect(layout.offset(at: startDate.addingTimeInterval(2), startDate: startDate) == -160) + #expect(layout.offset(at: startDate.addingTimeInterval(6), startDate: startDate) == -160) + #expect(layout.offset(at: startDate.addingTimeInterval(10), startDate: startDate) == -160) + } + + @Test + func reducedMotionStopsScrollingEvenWhenTextOverflows() { + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + reduceMotion: true + ) + + #expect(layout.overflows) + #expect(!layout.shouldScroll) + #expect(layout.offset == 0) + #expect(layout.progress(at: Date(), startDate: Date(timeIntervalSince1970: 0)) == 0) + } + + @Test + func rightToLeftLayoutMirrorsAlignmentAndOffset() { + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24), + layoutDirection: .rightToLeft + ) + + #expect(layout.isRightToLeft) + #expect(layout.alignment == .trailing) + #expect(layout.offset == 204) + #expect(layout.offset(progress: 0.5) == 102) + } + + @Test + func leftToRightLayoutUsesLeadingAlignment() { + let layout = resolvedLayout(layoutDirection: .leftToRight) + + #expect(!layout.isRightToLeft) + #expect(layout.alignment == .leading) + } + + @Test + func animationIdentityCapturesLayoutInputs() { + let layout = resolvedLayout( + textWidth: 180, + containerWidth: 100, + configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24), + content: .verbatim("title"), + layoutDirection: .rightToLeft + ) + + #expect( + layout.animationIdentity == MarqueeAnimationIdentity( + containerWidth: 100, + content: .verbatim("title"), + delay: 0.5, + duration: 3, + isRightToLeft: true, + localeIdentifier: "en", + reduceMotion: false, + shouldScroll: true, + spacing: 24, + textWidth: 180 + ) + ) + } + + @Test + func animationIdentityChangesForContentAndLocaleChangesWithTheSameMeasurements() { + let english = resolvedLayout( + textWidth: 180, + containerWidth: 100, + content: .verbatim("Title A"), + localeIdentifier: "en" + ) + let changedContent = resolvedLayout( + textWidth: 180, + containerWidth: 100, + content: .verbatim("Title B"), + localeIdentifier: "en" + ) + let changedLocale = resolvedLayout( + textWidth: 180, + containerWidth: 100, + content: .verbatim("Title A"), + localeIdentifier: "ar" + ) + + #expect(english.animationIdentity != changedContent.animationIdentity) + #expect(english.animationIdentity != changedLocale.animationIdentity) + } + + @Test + func layoutSanitizesIncomingMeasurements() { + let layout = resolvedLayout( + textWidth: -.infinity, + containerWidth: .nan + ) + + #expect(layout.measurement == .zero) + #expect(!layout.shouldScroll) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeNumericSupportTests.swift b/Tests/MarqueeTextTests/MarqueeNumericSupportTests.swift new file mode 100644 index 0000000..0817311 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeNumericSupportTests.swift @@ -0,0 +1,35 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueeSizeHelperTests { + @Test + func sanitizesInvalidSizes() { + #expect(CGSize(width: -.infinity, height: .nan).marqueeSanitized == .zero) + #expect(CGSize(width: 44, height: 12).marqueeSanitized == CGSize(width: 44, height: 12)) + } + + @Test + func scalarSanitizersHandleFiniteAndInvalidValues() { + #expect(CGFloat(12).marqueeNonNegative == 12) + #expect(CGFloat(-1).marqueeNonNegative == 0) + #expect(CGFloat.nan.marqueeNonNegative == 0) + #expect(CGFloat.infinity.marqueeNonNegative == 0) + + #expect(TimeInterval(2).marqueeNonNegative == 2) + #expect(TimeInterval(-2).marqueeNonNegative == 0) + #expect(TimeInterval.nan.marqueeNonNegative == 0) + #expect(TimeInterval.infinity.marqueeNonNegative == 0) + + #expect(TimeInterval(2).marqueePositive(or: 8) == 2) + #expect(TimeInterval(0).marqueePositive(or: 8) == 8) + #expect(TimeInterval.infinity.marqueePositive(or: 8) == 8) + + #expect(CGFloat(-0.5).marqueeClamped(to: 0...1) == 0) + #expect(CGFloat(0.5).marqueeClamped(to: 0...1) == 0.5) + #expect(CGFloat(1.5).marqueeClamped(to: 0...1) == 1) + #expect(CGFloat.nan.marqueeClamped(to: 0...1) == 0) + #expect(CGFloat.infinity.marqueeClamped(to: 0...1) == 1) + #expect(CGFloat(-CGFloat.infinity).marqueeClamped(to: 0...1) == 0) + } +} diff --git a/Tests/MarqueeTextTests/MarqueePreferenceKeyTests.swift b/Tests/MarqueeTextTests/MarqueePreferenceKeyTests.swift new file mode 100644 index 0000000..6758fd3 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueePreferenceKeyTests.swift @@ -0,0 +1,62 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueePreferenceKeyTests { + @Test + func measurementPreferenceStartsWithNothingMeasured() { + #expect(MarqueeMeasurementPreferenceKey.defaultValue == nil) + + var value: MarqueeMeasurement? + MarqueeMeasurementPreferenceKey.reduce(value: &value) { + MarqueeMeasurement(textWidth: 180, containerWidth: 100) + } + + #expect(value == MarqueeMeasurement(textWidth: 180, containerWidth: 100)) + } + + @Test + func nonMeasuringSiblingsCannotEraseAMeasurement() { + // SwiftUI folds every child of a container into the preference, including children that never write + // one and therefore contribute `defaultValue`. Those must not clear a real measurement. + var value: MarqueeMeasurement? + + MarqueeMeasurementPreferenceKey.reduce(value: &value) { MarqueeMeasurement(textWidth: 180, containerWidth: 100) } + MarqueeMeasurementPreferenceKey.reduce(value: &value) { nil } + + #expect(value == MarqueeMeasurement(textWidth: 180, containerWidth: 100)) + } + + @Test + func aGenuinelyEmptyMeasurementStillClearsAnEarlierOne() { + // Emptying the text is a real measurement of zero, not an absent one. Rejecting it would leave the + // view believing it still has wide text in a wide container. + var value: MarqueeMeasurement? + + MarqueeMeasurementPreferenceKey.reduce(value: &value) { MarqueeMeasurement(textWidth: 180, containerWidth: 100) } + MarqueeMeasurementPreferenceKey.reduce(value: &value) { .zero } + + #expect(value == .zero) + } + + @Test + func realMeasurementsStillOverwriteEarlierOnes() { + var value: MarqueeMeasurement? + + MarqueeMeasurementPreferenceKey.reduce(value: &value) { MarqueeMeasurement(textWidth: 180, containerWidth: 100) } + MarqueeMeasurementPreferenceKey.reduce(value: &value) { MarqueeMeasurement(textWidth: 220, containerWidth: 100) } + + #expect(value == MarqueeMeasurement(textWidth: 220, containerWidth: 100)) + } + + @Test + func invalidContributionsAreSanitizedToZero() { + var value: MarqueeMeasurement? + + MarqueeMeasurementPreferenceKey.reduce(value: &value) { + MarqueeMeasurement(textWidth: .nan, containerWidth: -.infinity) + } + + #expect(value == .zero) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeSizingLayoutTests.swift b/Tests/MarqueeTextTests/MarqueeSizingLayoutTests.swift new file mode 100644 index 0000000..af2b5a9 --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeSizingLayoutTests.swift @@ -0,0 +1,109 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +struct MarqueeSizingLayoutTests { + @Test + func placementHonorsLayoutAlignment() { + let bounds = CGRect(x: 10, y: 20, width: 80, height: 30) + let leadingLayout = MarqueeSizingLayout(alignment: .leading, isScrolling: false, spacing: 0) + let trailingLayout = MarqueeSizingLayout(alignment: .trailing, isScrolling: false, spacing: 0) + + #expect(leadingLayout.placementAnchor == .leading) + #expect(leadingLayout.placementPoint(in: bounds) == CGPoint(x: 10, y: 35)) + #expect(trailingLayout.placementAnchor == .trailing) + #expect(trailingLayout.placementPoint(in: bounds) == CGPoint(x: 90, y: 35)) + } + + @Test + func unspecifiedProposalUsesIntrinsicSize() { + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: nil, + proposedHeight: nil + ) == CGSize(width: 120, height: 18) + ) + } + + @Test + func finiteWidthIsCappedAtIntrinsicWidth() { + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: 80, + proposedHeight: nil + ) == CGSize(width: 80, height: 18) + ) + + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: 200, + proposedHeight: nil + ) == CGSize(width: 120, height: 18) + ) + } + + @Test + func generousHeightProposalsNeverStretchTheMarquee() { + // A single line `Text` keeps its own height no matter how much room it is offered. Growing to fill + // would make the marquee behave like `Color` inside stacks, overlays, and z-stacks. + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: 80, + proposedHeight: 400 + ) == CGSize(width: 80, height: 18) + ) + } + + @Test + func tightHeightProposalsAreHonoredLikeText() { + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: nil, + proposedHeight: 12 + ) == CGSize(width: 120, height: 12) + ) + } + + @Test + func invalidProposalsFallBackOrClampSafely() { + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: .infinity, + proposedHeight: .nan + ) == CGSize(width: 120, height: 18) + ) + + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: 120, height: 18), + proposedWidth: -10, + proposedHeight: -4 + ) == .zero + ) + } + + @Test + func invalidIntrinsicSizeIsSanitizedBeforeResolvingProposal() { + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: CGFloat.nan, height: CGFloat.infinity), + proposedWidth: nil, + proposedHeight: nil + ) == .zero + ) + + #expect( + MarqueeSizingLayout.resolvedSize( + intrinsicSize: CGSize(width: CGFloat.infinity, height: CGFloat.nan), + proposedWidth: 40, + proposedHeight: 12 + ) == .zero + ) + } +} diff --git a/Tests/MarqueeTextTests/MarqueeTestSupport.swift b/Tests/MarqueeTextTests/MarqueeTestSupport.swift new file mode 100644 index 0000000..d1019ee --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeTestSupport.swift @@ -0,0 +1,22 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +func resolvedLayout( + textWidth: CGFloat = 120, + containerWidth: CGFloat = 100, + configuration: MarqueeConfiguration = MarqueeConfiguration(), + content: MarqueeContent = .verbatim("Title"), + layoutDirection: LayoutDirection = .leftToRight, + localeIdentifier: String = "en", + reduceMotion: Bool = false +) -> MarqueeResolvedLayout { + MarqueeResolvedLayout( + configuration: configuration, + content: content, + layoutDirection: layoutDirection, + localeIdentifier: localeIdentifier, + measurement: MarqueeMeasurement(textWidth: textWidth, containerWidth: containerWidth), + reduceMotion: reduceMotion + ) +} diff --git a/Tests/MarqueeTextTests/MarqueeTextLayoutParityTests.swift b/Tests/MarqueeTextTests/MarqueeTextLayoutParityTests.swift new file mode 100644 index 0000000..80b199b --- /dev/null +++ b/Tests/MarqueeTextTests/MarqueeTextLayoutParityTests.swift @@ -0,0 +1,124 @@ +@testable import MarqueeText +import SwiftUI +import Testing + +#if canImport(AppKit) +import AppKit +#endif + +#if os(macOS) +/// Locks in the promise that `MarqueeText` is layout-interchangeable with a single line `Text`. +/// +/// These assertions run against a real hosting view, so they cover the very first layout pass — the pass +/// that used to report a hard-coded 20pt height and shift surrounding layout on the next frame. +@MainActor +struct MarqueeTextLayoutParityTests { + @Test(arguments: [ + "Hi", + "A long headline that comfortably overflows any reasonable container", + "", + "نص عربي طويل جدا يتجاوز عرض الحاوية" + ]) + func marqueeReportsTheSameSizeAsTextForEveryProposal(_ string: String) { + for font in [Font.caption, .body, .largeTitle] { + let probes = LayoutProbe.probes( + for: MarqueeText(verbatim: string).font(font), + reference: Text(verbatim: string).lineLimit(1).font(font) + ) + + #expect(probes.marquee.ideal == probes.reference.ideal) + #expect(probes.marquee.zeroProposal == probes.reference.zeroProposal) + #expect(probes.marquee.infiniteProposal == probes.reference.infiniteProposal) + #expect(probes.marquee.firstBaseline == probes.reference.firstBaseline) + #expect(probes.marquee.lastBaseline == probes.reference.lastBaseline) + } + } + + @Test + func marqueeReportsItsFinalSizeOnTheFirstLayoutPass() { + let passes = LayoutProbe.allPasses(for: MarqueeText(verbatim: "Hi").font(.largeTitle)) + + #expect(passes.count >= 1) + #expect(Set(passes.map(\.ideal.height)).count == 1, "Height must not change once measurement settles.") + #expect(passes.allSatisfy { $0.ideal.height == passes[0].ideal.height }) + } + + @Test + func aGenerousHeightProposalDoesNotStretchTheMarquee() { + let probes = LayoutProbe.probes( + for: MarqueeText(verbatim: "Hi"), + reference: Text(verbatim: "Hi").lineLimit(1) + ) + + #expect(probes.marquee.tallProposal == probes.reference.tallProposal) + } +} + +struct LayoutProbe { + var firstBaseline: CGFloat + var ideal: CGSize + var infiniteProposal: CGSize + var lastBaseline: CGFloat + var tallProposal: CGSize + var zeroProposal: CGSize + + struct Pair { + var marquee: LayoutProbe + var reference: LayoutProbe + } + + private struct Recorder: Layout { + var record: @MainActor (LayoutProbe) -> Void + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + guard let subview = subviews.first else { return .zero } + let dimensions = subview.dimensions(in: .unspecified) + let probe = LayoutProbe( + firstBaseline: dimensions[.firstTextBaseline], + ideal: subview.sizeThatFits(.unspecified), + infiniteProposal: subview.sizeThatFits(.infinity), + lastBaseline: dimensions[.lastTextBaseline], + tallProposal: subview.sizeThatFits(ProposedViewSize(width: 400, height: 400)), + zeroProposal: subview.sizeThatFits(.zero) + ) + + MainActor.assumeIsolated { record(probe) } + + return subview.sizeThatFits(proposal) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + subviews.first?.place(at: bounds.origin, proposal: proposal) + } + } + + @MainActor + static func allPasses(for view: some View) -> [LayoutProbe] { + let box = Box() + let record: @MainActor (LayoutProbe) -> Void = { box.probes.append($0) } + let host = NSHostingView(rootView: Recorder(record: record) { view }.frame(width: 320, height: 60)) + + host.layoutSubtreeIfNeeded() + _ = host.fittingSize + + return box.probes + } + + @MainActor + static func probes(for marquee: some View, reference: some View) -> Pair { + // The last pass is the settled one; comparing it and the first pass is what the callers assert on. + let marqueePasses = allPasses(for: marquee) + let referencePasses = allPasses(for: reference) + + return Pair( + marquee: marqueePasses[marqueePasses.count - 1], + reference: referencePasses[referencePasses.count - 1] + ) + } + + @MainActor + private final class Box { + var probes: [LayoutProbe] = [] + } +} +#endif diff --git a/Tests/MarqueeTextTests/MarqueeTextTests.swift b/Tests/MarqueeTextTests/MarqueeTextTests.swift deleted file mode 100644 index 50a6933..0000000 --- a/Tests/MarqueeTextTests/MarqueeTextTests.swift +++ /dev/null @@ -1,667 +0,0 @@ -@testable import MarqueeText -import SwiftUI -import Testing - -struct MarqueeConfigurationTests { - @Test - func validConfigurationIsPreserved() { - let configuration = MarqueeConfiguration( - duration: 4.5, - delay: 0.25, - spacing: 12 - ) - - #expect( - configuration == MarqueeConfiguration( - duration: 4.5, - delay: 0.25, - spacing: 12 - ) - ) - } - - @Test - func invalidConfigurationIsClamped() { - #expect( - MarqueeConfiguration( - duration: -1, - delay: -.infinity, - spacing: .nan - ) == MarqueeConfiguration( - duration: MarqueeConfiguration.defaultDuration, - delay: 0, - spacing: 0 - ) - ) - } - - @Test - func zeroDurationUsesDefaultButZeroDelayAndSpacingAreAllowed() { - #expect( - MarqueeConfiguration( - duration: 0, - delay: 0, - spacing: 0 - ) == MarqueeConfiguration( - duration: MarqueeConfiguration.defaultDuration, - delay: 0, - spacing: 0 - ) - ) - } - - @Test - func positiveInfinityConfigurationValuesAreClamped() { - #expect( - MarqueeConfiguration( - duration: .infinity, - delay: .infinity, - spacing: .infinity - ) == MarqueeConfiguration( - duration: MarqueeConfiguration.defaultDuration, - delay: 0, - spacing: 0 - ) - ) - } -} - -@MainActor -struct MarqueeContentTests { - @Test - func stringLiteralsUseLocalizedContent() { - let view = MarqueeText("Localized title") - - switch view.content { - case .localized: - break - case .verbatim: - Issue.record("String literals should keep using LocalizedStringResource.") - } - } - - @Test - func runtimeStringsUseVerbatimContent() { - let title = String("Runtime title") - let view = MarqueeText(title) - - switch view.content { - case .localized: - Issue.record("Runtime strings should use verbatim text.") - case .verbatim(let text): - #expect(text == "Runtime title") - } - } - - @Test - func explicitVerbatimInitializerUsesVerbatimContent() { - let view = MarqueeText(verbatim: "Exact title") - - switch view.content { - case .localized: - Issue.record("Verbatim initializer should keep the exact string.") - case .verbatim(let text): - #expect(text == "Exact title") - } - } - - @Test - func textViewsCanBeCreatedForBothContentKinds() { - let localizedText: Text = MarqueeContent.localized("Title").text - let verbatimText: Text = MarqueeContent.verbatim("Title").text - - _ = localizedText - _ = verbatimText - } - - @Test - func contentIdentityChangesWhenTextChanges() { - #expect(MarqueeContent.verbatim("Title A").animationIdentity == "verbatim:Title A") - #expect(MarqueeContent.verbatim("Title A").animationIdentity != MarqueeContent.verbatim("Title B").animationIdentity) - #expect(MarqueeContent.localized("Title A").animationIdentity != MarqueeContent.localized("Title B").animationIdentity) - } - - @Test - func bodiesCanBeCreatedForLocalizedAndVerbatimContent() { - _ = MarqueeText("Localized title").body - _ = MarqueeText(verbatim: "Runtime title").body - _ = MarqueeText( - content: .verbatim("Overflowing runtime title"), - configuration: MarqueeConfiguration(duration: 2, delay: 0, spacing: 12), - containerSize: CGSize(width: 80, height: 20), - textSize: CGSize(width: 180, height: 18) - ) - .body - } - - #if os(macOS) - @Test - func staticTextCanBeRenderedToAnImage() { - if #available(macOS 13.0, *) { - let renderer = ImageRenderer( - content: MarqueeText("Rendered title") - .frame(width: 240, height: 44) - ) - - #expect(renderer.nsImage != nil) - } - } - - @Test - func overflowingTextCanBeRenderedToAnImage() { - if #available(macOS 13.0, *) { - let renderer = ImageRenderer( - content: MarqueeText( - content: .verbatim("Rendered overflowing title"), - configuration: MarqueeConfiguration(duration: 2, delay: 0, spacing: 12), - containerSize: CGSize(width: 80, height: 18), - textSize: CGSize(width: 220, height: 18), - animationStartDate: Date(timeIntervalSince1970: 0) - ) - .frame(width: 80, height: 44) - ) - - #expect(renderer.nsImage != nil) - } - } - #endif - - @Test - func internalViewHelpersCanBeCreatedAndAnimationCanRestart() { - let view = MarqueeText(verbatim: "A long runtime title") - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 80, height: 20) - ) - - _ = view.measuredText - _ = view.displayText - _ = view.scrollingText(layout: layout) - _ = MarqueeContainerSizeReader().body - _ = MarqueeTextSizeReader().body - - view.restartAnimation(shouldAnimate: false) - view.restartAnimation(shouldAnimate: true) - } - - @Test - func sizeUpdatesApplySubPointMeaningfulAndSanitizedChanges() { - let view = MarqueeText(verbatim: "Runtime title") - var size = CGSize(width: 10, height: 10) - - view.updateSize(&size, to: CGSize(width: 10.4, height: 10.4)) - #expect(size == CGSize(width: 10.4, height: 10.4)) - - view.updateSize(&size, to: CGSize(width: 12, height: 10)) - #expect(size == CGSize(width: 12, height: 10)) - - view.updateSize(&size, to: CGSize(width: .nan, height: -.infinity)) - #expect(size == .zero) - - view.updateContainerSize(CGSize(width: 40, height: 20)) - view.updateTextSize(CGSize(width: 80, height: 20)) - } - - @Test - func subPointSizeChangesAreAppliedSoOverflowCanRecompute() { - let view = MarqueeText(verbatim: "Runtime title") - var size = CGSize(width: 100.4, height: 18) - - view.updateSize(&size, to: CGSize(width: 100.8, height: 18)) - - #expect(size == CGSize(width: 100.8, height: 18)) - } - - @Test - func sizeUpdatesRepairInvalidCurrentValues() { - let view = MarqueeText(verbatim: "Runtime title") - var size = CGSize(width: CGFloat.nan, height: CGFloat.infinity) - - view.updateSize(&size, to: .zero) - - #expect(size == .zero) - } -} - -struct MarqueeLayoutTests { - @Test - func unmeasuredTextDoesNotScroll() { - let layout = resolvedLayout( - textSize: .zero, - containerSize: CGSize(width: 100, height: 20) - ) - - #expect(!layout.hasMeasuredText) - #expect(layout.hasMeasuredContainer) - #expect(!layout.overflows) - #expect(!layout.shouldScroll) - #expect(!layout.shouldAnimate) - #expect(!layout.hasAnimation) - #expect(layout.height == MarqueeResolvedLayout.defaultHeight) - #expect(layout.offset == 0) - } - - @Test - func unmeasuredContainerDoesNotScroll() { - let layout = resolvedLayout( - textSize: CGSize(width: 120, height: 18), - containerSize: .zero - ) - - #expect(layout.hasMeasuredText) - #expect(!layout.hasMeasuredContainer) - #expect(!layout.overflows) - #expect(!layout.shouldScroll) - #expect(layout.height == 18) - } - - @Test - func textWithinOverflowToleranceDoesNotScroll() { - let layout = resolvedLayout( - textSize: CGSize(width: 100.4, height: 18), - containerSize: CGSize(width: 100, height: 20) - ) - - #expect(!layout.overflows) - #expect(!layout.shouldScroll) - } - - @Test - func textJustBeyondOverflowToleranceScrolls() { - let layout = resolvedLayout( - textSize: CGSize(width: 100.6, height: 18), - containerSize: CGSize(width: 100, height: 20) - ) - - #expect(layout.overflows) - #expect(layout.shouldScroll) - } - - @Test - func textWithZeroHeightDoesNotScrollEvenWhenWidthOverflows() { - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 0), - containerSize: CGSize(width: 100, height: 20) - ) - - #expect(!layout.hasMeasuredText) - #expect(!layout.overflows) - #expect(!layout.shouldScroll) - } - - @Test - func overflowingTextScrollsWhenMotionIsAllowed() { - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24) - ) - - #expect(layout.overflows) - #expect(layout.shouldScroll) - #expect(layout.shouldAnimate) - #expect(layout.hasAnimation) - #expect(layout.scrollDistance == 204) - #expect(layout.offset == -204) - } - - @Test - func timelineProgressWaitsDuringDelayAndMovesLinearly() { - let startDate = Date(timeIntervalSince1970: 100) - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 4, delay: 1, spacing: 20) - ) - - #expect(layout.shouldScroll) - #expect(layout.shouldAnimate) - #expect(layout.hasAnimation) - #expect(layout.progress(at: startDate, startDate: startDate) == 0) - #expect(layout.progress(at: startDate.addingTimeInterval(0.5), startDate: startDate) == 0) - #expect(layout.progress(at: startDate.addingTimeInterval(3), startDate: startDate) == 0.5) - #expect(layout.offset(at: startDate, startDate: startDate) == 0) - #expect(layout.offset(at: startDate.addingTimeInterval(3), startDate: startDate) == -100) - #expect(layout.progress(at: startDate.addingTimeInterval(5.5), startDate: startDate) == 0) - } - - @Test - func timelineProgressDoesNotMoveBeforeStartDate() { - let startDate = Date(timeIntervalSince1970: 100) - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 4, delay: 1, spacing: 20) - ) - - #expect(layout.progress(at: startDate.addingTimeInterval(-10), startDate: startDate) == 0) - #expect(layout.offset(at: startDate.addingTimeInterval(-10), startDate: startDate) == 0) - } - - @Test - func timelineProgressHandlesOverflowingCycleDuration() { - let startDate = Date(timeIntervalSince1970: 100) - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration( - duration: .greatestFiniteMagnitude, - delay: .greatestFiniteMagnitude, - spacing: 20 - ) - ) - - #expect(layout.progress(at: startDate.addingTimeInterval(1), startDate: startDate) == 0) - #expect(layout.offset(at: startDate.addingTimeInterval(1), startDate: startDate) == 0) - } - - @Test - func repeatedGeometryChangesDoNotAccelerateTimelineMotion() { - let startDate = Date(timeIntervalSince1970: 200) - let date = startDate.addingTimeInterval(2) - let configuration = MarqueeConfiguration(duration: 4, delay: 0, spacing: 20) - let offsets = [80, 120, 160, 80, 120, 160].map { width in - resolvedLayout( - textSize: CGSize(width: 300, height: 18), - containerSize: CGSize(width: width, height: 20), - configuration: configuration - ) - .offset(at: date, startDate: startDate) - } - - #expect(offsets.allSatisfy { $0 == -160 }) - } - - @Test - func repeatedTimelineCyclesDoNotAccumulateExtraDistance() { - let startDate = Date(timeIntervalSince1970: 300) - let layout = resolvedLayout( - textSize: CGSize(width: 300, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 4, delay: 0, spacing: 20) - ) - - #expect(layout.offset(at: startDate.addingTimeInterval(2), startDate: startDate) == -160) - #expect(layout.offset(at: startDate.addingTimeInterval(6), startDate: startDate) == -160) - #expect(layout.offset(at: startDate.addingTimeInterval(10), startDate: startDate) == -160) - } - - @Test - func reducedMotionStopsScrollingEvenWhenTextOverflows() { - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - reduceMotion: true - ) - - #expect(layout.overflows) - #expect(!layout.shouldScroll) - #expect(!layout.shouldAnimate) - #expect(!layout.hasAnimation) - #expect(layout.offset == 0) - #expect(layout.progress(at: Date(), startDate: Date(timeIntervalSince1970: 0)) == 0) - } - - @Test - func rightToLeftLayoutMirrorsAlignmentAndOffset() { - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24), - layoutDirection: .rightToLeft - ) - - #expect(layout.isRightToLeft) - #expect(layout.alignment == .trailing) - #expect(layout.offset == 204) - #expect(layout.offset(progress: 0.5) == 102) - } - - @Test - func leftToRightLayoutUsesLeadingAlignment() { - let layout = resolvedLayout(layoutDirection: .leftToRight) - - #expect(!layout.isRightToLeft) - #expect(layout.alignment == .leading) - } - - @Test - func animationIdentityCapturesLayoutInputs() { - let layout = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration(duration: 3, delay: 0.5, spacing: 24), - contentIdentity: "verbatim:title", - layoutDirection: .rightToLeft - ) - - #expect( - layout.animationIdentity == MarqueeAnimationIdentity( - containerWidth: 100, - contentIdentity: "verbatim:title", - delay: 0.5, - duration: 3, - isRightToLeft: true, - localeIdentifier: "en", - reduceMotion: false, - shouldScroll: true, - spacing: 24, - textWidth: 180 - ) - ) - #expect(Set([layout.animationIdentity]).contains(layout.animationIdentity)) - } - - @Test - func animationIdentityChangesForContentAndLocaleChangesWithTheSameMeasurements() { - let english = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - contentIdentity: "verbatim:Title A", - localeIdentifier: "en" - ) - let changedContent = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - contentIdentity: "verbatim:Title B", - localeIdentifier: "en" - ) - let changedLocale = resolvedLayout( - textSize: CGSize(width: 180, height: 18), - containerSize: CGSize(width: 100, height: 20), - contentIdentity: "verbatim:Title A", - localeIdentifier: "ar" - ) - - #expect(english.animationIdentity != changedContent.animationIdentity) - #expect(english.animationIdentity != changedLocale.animationIdentity) - } - - @Test - func layoutSanitizesIncomingSizes() { - let layout = resolvedLayout( - textSize: CGSize(width: -.infinity, height: .nan), - containerSize: CGSize(width: .nan, height: -.infinity) - ) - - #expect(layout.textSize == .zero) - #expect(layout.containerSize == .zero) - } -} - -struct MarqueeSizeHelperTests { - @Test - func sanitizesInvalidSizes() { - #expect(CGSize(width: -.infinity, height: .nan).marqueeSanitized == .zero) - #expect(CGSize(width: 44, height: 12).marqueeSanitized == CGSize(width: 44, height: 12)) - } - - @Test - func detectsMeaningfulDifferences() { - #expect(CGSize(width: 10, height: 10).isMeaningfullyDifferent(from: CGSize(width: 10.4, height: 10.4))) - #expect(!CGSize(width: 10, height: 10).isMeaningfullyDifferent(from: CGSize(width: 10.4, height: 10.4), tolerance: 0.5)) - #expect(CGSize(width: 10, height: 10).isMeaningfullyDifferent(from: CGSize(width: 11, height: 10))) - #expect(CGSize(width: 10, height: 10).isMeaningfullyDifferent(from: CGSize(width: 10, height: 11))) - #expect(CGSize(width: CGFloat.nan, height: 10).isMeaningfullyDifferent(from: CGSize(width: 0, height: 10))) - } - - @Test - func scalarSanitizersHandleFiniteAndInvalidValues() { - #expect(CGFloat(12).marqueeNonNegative == 12) - #expect(CGFloat(-1).marqueeNonNegative == 0) - #expect(CGFloat.nan.marqueeNonNegative == 0) - #expect(CGFloat.infinity.marqueeNonNegative == 0) - - #expect(TimeInterval(2).marqueeNonNegative == 2) - #expect(TimeInterval(-2).marqueeNonNegative == 0) - #expect(TimeInterval.nan.marqueeNonNegative == 0) - #expect(TimeInterval.infinity.marqueeNonNegative == 0) - - #expect(TimeInterval(2).marqueePositive(or: 8) == 2) - #expect(TimeInterval(0).marqueePositive(or: 8) == 8) - #expect(TimeInterval.infinity.marqueePositive(or: 8) == 8) - - #expect(CGFloat(-0.5).marqueeClamped(to: 0...1) == 0) - #expect(CGFloat(0.5).marqueeClamped(to: 0...1) == 0.5) - #expect(CGFloat(1.5).marqueeClamped(to: 0...1) == 1) - #expect(CGFloat.nan.marqueeClamped(to: 0...1) == 0) - #expect(CGFloat.infinity.marqueeClamped(to: 0...1) == 1) - #expect(CGFloat(-CGFloat.infinity).marqueeClamped(to: 0...1) == 0) - } -} - -struct MarqueePreferenceKeyTests { - @Test - func containerSizePreferenceDefaultsAndReduces() { - #expect(MarqueeContainerSizePreferenceKey.defaultValue == .zero) - - var value = CGSize(width: 1, height: 1) - MarqueeContainerSizePreferenceKey.reduce(value: &value) { - CGSize(width: 20, height: 10) - } - #expect(value == CGSize(width: 20, height: 10)) - - MarqueeContainerSizePreferenceKey.reduce(value: &value) { - CGSize(width: -.infinity, height: .nan) - } - #expect(value == .zero) - } - - @Test - func textSizePreferenceDefaultsAndReduces() { - #expect(MarqueeTextSizePreferenceKey.defaultValue == .zero) - - var value = CGSize(width: 1, height: 1) - MarqueeTextSizePreferenceKey.reduce(value: &value) { - CGSize(width: 30, height: 12) - } - #expect(value == CGSize(width: 30, height: 12)) - - MarqueeTextSizePreferenceKey.reduce(value: &value) { - CGSize(width: .nan, height: -.infinity) - } - #expect(value == .zero) - } -} - -struct MarqueeSizingLayoutTests { - @Test - func placementHonorsLayoutAlignment() { - let bounds = CGRect(x: 10, y: 20, width: 80, height: 30) - let leadingLayout = MarqueeSizingLayout(alignment: .leading) - let trailingLayout = MarqueeSizingLayout(alignment: .trailing) - - #expect(leadingLayout.placementAnchor == .leading) - #expect(leadingLayout.placementPoint(in: bounds) == CGPoint(x: 10, y: 35)) - #expect(trailingLayout.placementAnchor == .trailing) - #expect(trailingLayout.placementPoint(in: bounds) == CGPoint(x: 90, y: 35)) - } - - @Test - func unspecifiedWidthUsesIntrinsicSize() { - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: 120, height: 18), - proposedWidth: nil, - proposedHeight: nil - ) == CGSize(width: 120, height: 18) - ) - } - - @Test - func finiteWidthIsCappedAtIntrinsicWidth() { - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: 120, height: 18), - proposedWidth: 80, - proposedHeight: nil - ) == CGSize(width: 80, height: 18) - ) - - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: 120, height: 18), - proposedWidth: 200, - proposedHeight: nil - ) == CGSize(width: 120, height: 18) - ) - } - - @Test - func invalidProposalsFallBackOrClampSafely() { - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: 120, height: 18), - proposedWidth: .infinity, - proposedHeight: .nan - ) == CGSize(width: 120, height: 18) - ) - - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: 120, height: 18), - proposedWidth: -10, - proposedHeight: -4 - ) == .zero - ) - } - - @Test - func invalidIntrinsicSizeIsSanitizedBeforeResolvingProposal() { - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: CGFloat.nan, height: CGFloat.infinity), - proposedWidth: nil, - proposedHeight: nil - ) == .zero - ) - - #expect( - MarqueeSizingLayout.resolvedSize( - intrinsicSize: CGSize(width: CGFloat.infinity, height: CGFloat.nan), - proposedWidth: 40, - proposedHeight: 12 - ) == CGSize(width: 0, height: 12) - ) - } -} - -private func resolvedLayout( - textSize: CGSize = CGSize(width: 120, height: 18), - containerSize: CGSize = CGSize(width: 100, height: 20), - configuration: MarqueeConfiguration = MarqueeConfiguration(), - contentIdentity: String = "verbatim:Title", - layoutDirection: LayoutDirection = .leftToRight, - localeIdentifier: String = "en", - reduceMotion: Bool = false -) -> MarqueeResolvedLayout { - MarqueeResolvedLayout( - textSize: textSize, - containerSize: containerSize, - configuration: configuration, - contentIdentity: contentIdentity, - layoutDirection: layoutDirection, - localeIdentifier: localeIdentifier, - reduceMotion: reduceMotion - ) -} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..955612b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,35 @@ +# Codecov configuration for MarqueeText. +# +# Coverage comes from a single `swift test --enable-code-coverage` run on a +# headless macOS CI host (see .github/workflows/test.yml → the `test` job). +# Everything in Sources/ is measurable there: the package has no system or +# device dependencies, and the SwiftUI layout logic is exercised either +# directly or through a real hosting view. + +coverage: + status: + # Overall coverage is an enforced, blocking check. The suite currently + # covers ~95% of regions, so a regression should fail rather than drift. + project: + default: + target: auto + # Tolerate sub-percent noise from non-deterministic line attribution + # between runs; a real drop trips the check. + threshold: 1% + + # New executable lines must stay well tested. The gap below 100% + # accommodates SwiftUI `body` builders, whose branches are not all + # reachable from a headless test process. + patch: + default: + target: 85% + threshold: 0% + +# Belt-and-suspenders: the LCOV export in the test job already strips Tests/ +# and .build/, and Demo/ builds from a separate Xcode project that never feeds +# coverage. Listing them keeps Codecov's bookkeeping aligned with what is +# actually uploaded. +ignore: + - "Tests" + - "Demo" + - ".build"