diff --git a/README.md b/README.md index 436f8d1a..339beda3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,47 @@ PartData( `content.text` returns `nil` for custom content, so check for it when you read the result of a spin. +### Styling + +By default a roulette is laid out the way a real wheel is: labels out at the rim, +arranged radially, and the separators stop short of the middle to leave a hub. +As on a real wheel, labels on the left half therefore sit upside down. + +Override any of that with `.rouletteStyle(_:)`: + +```swift +RouletteView(parts: partDatas) + .rouletteStyle( + RouletteStyle( + labelPosition: 0.8, // 0 = centre, 1 = outer edge + labelOrientation: .radial, // see the table below + innerRadiusRatio: 0.25 // size of the hub, 0 for a full pie + ) + ) +``` + +| `labelOrientation` | Effect | +|---|---| +| `.radial` | Reads outward from the hub, exactly like a real roulette. Labels on the left half sit upside down. **The default.** | +| `.radialUpright` | Same radial layout, but the left half is flipped so every label reads upright. Better when your labels are words rather than numbers. | +| `.tangential` | Runs across the wedge, perpendicular to the radius. The 1.x behaviour. | +| `.fixed` | Always upright, whatever angle the wedge sits at. | + +If the upside-down half does not suit your labels, one line switches it: + +```swift +RouletteView(parts: partDatas) + .rouletteStyle(RouletteStyle(labelOrientation: .radialUpright)) +``` + +`RouletteStyle.pie` restores the 1.x appearance — labels halfway out, laid +across the wedge, with no hub: + +```swift +RouletteView(parts: partDatas) + .rouletteStyle(.pie) +``` + ## RouletteModel `RouletteModel` uses the `@Observable` macro from the Observation framework. You can observe the roulette state directly via `model.state`. diff --git a/Sources/SimpleRoulette/Sources/Pie/RouletteInternalView.swift b/Sources/SimpleRoulette/Sources/Pie/RouletteInternalView.swift index f7c9d13f..2e9b768c 100644 --- a/Sources/SimpleRoulette/Sources/Pie/RouletteInternalView.swift +++ b/Sources/SimpleRoulette/Sources/Pie/RouletteInternalView.swift @@ -12,6 +12,8 @@ public struct RouletteInternalView: View { var model: RouletteModel + @Environment(\.rouletteStyle) private var style + @State private var currentAngle: Angle = .init() @State private var radius: CGFloat = 0 @State private var center: CGPoint = .zero @@ -54,23 +56,19 @@ public struct RouletteInternalView: View { RoulettePart( data: part, center: center, - radius: radius + radius: radius, + innerRadius: radius * style.innerRadiusRatio ) } ForEach(model.parts) { part in - let angle = ((part.endAngle + part.startAngle) / 2) + Angle(degrees: 90) + let mean = (part.startAngle + part.endAngle) / 2 + let distance = radius * style.labelPosition part.content.view - .rotationEffect(angle) + .rotationEffect(style.labelOrientation.rotation(midAngle: mean)) .offset( CGSize( - width: { () -> Double in - let mean = (part.startAngle + part.endAngle) / 2 - return radius * 0.5 * cos(mean.radians) - }(), - height: { () -> Double in - let mean = (part.startAngle + part.endAngle) / 2 - return radius * 0.5 * sin(mean.radians) - }() + width: distance * cos(mean.radians), + height: distance * sin(mean.radians) ) ) } diff --git a/Sources/SimpleRoulette/Sources/Pie/RoulettePart.swift b/Sources/SimpleRoulette/Sources/Pie/RoulettePart.swift index 1596315a..6d53332c 100644 --- a/Sources/SimpleRoulette/Sources/Pie/RoulettePart.swift +++ b/Sources/SimpleRoulette/Sources/Pie/RoulettePart.swift @@ -15,11 +15,13 @@ struct RoulettePart: View { init( data: PartData, center: CGPoint, - radius: Double + radius: Double, + innerRadius: Double = 0 ) { self.data = data self.center = center self.radius = radius + self.innerRadius = innerRadius } @@ -27,6 +29,8 @@ struct RoulettePart: View { var data: PartData var center: CGPoint var radius: Double + /// Radius of the hub left empty in the middle. `0` draws a full wedge. + var innerRadius: Double var body: some View { @@ -42,7 +46,20 @@ struct RoulettePart: View { private func path() -> Path { Path { path in - path.move(to: center) + guard innerRadius > 0 else { + path.move(to: center) + path.addArc( + center: center, + radius: radius, + startAngle: data.startAngle, + endAngle: data.endAngle, + clockwise: false, + transform: .identity + ) + path.closeSubpath() + return + } + // Annular sector: out along the rim, back along the hub. path.addArc( center: center, radius: radius, @@ -51,6 +68,14 @@ struct RoulettePart: View { clockwise: false, transform: .identity ) + path.addArc( + center: center, + radius: innerRadius, + startAngle: data.endAngle, + endAngle: data.startAngle, + clockwise: true, + transform: .identity + ) path.closeSubpath() } } diff --git a/Sources/SimpleRoulette/Sources/Types/RouletteStyle.swift b/Sources/SimpleRoulette/Sources/Types/RouletteStyle.swift new file mode 100644 index 00000000..04ac81e3 --- /dev/null +++ b/Sources/SimpleRoulette/Sources/Types/RouletteStyle.swift @@ -0,0 +1,135 @@ +// +// RouletteStyle.swift +// SimpleRoulette +// +// Created by Fumiya Tanaka on 2026/08/15. +// + +import Foundation +import SwiftUI + +/// How a part's label is rotated inside its wedge. +public enum RouletteLabelOrientation: Sendable, Hashable { + + /// Reads outward from the hub, following the wedge, the way a real roulette is + /// laid out. Labels on the left half of the wheel therefore sit upside down — + /// fine for numbers, harder to read for words. + case radial + + /// Follows the wedge like ``radial``, but labels on the left half of the wheel + /// are turned a further 180° so every one of them reads upright at rest. + case radialUpright + + /// Runs across the wedge, perpendicular to the radius. + case tangential + + /// Stays upright whatever angle the wedge sits at. + case fixed + + /// The rotation to apply to a label whose wedge is centred on `midAngle`. + func rotation(midAngle: Angle) -> Angle { + switch self { + case .radial: + return midAngle + case .radialUpright: + return isOnLeftHalf(midAngle) ? midAngle + .degrees(180) : midAngle + case .tangential: + return midAngle + .degrees(90) + case .fixed: + return .zero + } + } + + /// Whether a wedge centred on `angle` points into the half of the wheel where + /// radial text would read upside down. + private func isOnLeftHalf(_ angle: Angle) -> Bool { + let wrapped = angle.degrees.truncatingRemainder(dividingBy: 360) + let positive = wrapped < 0 ? wrapped + 360 : wrapped + return positive > 90 && positive < 270 + } +} + +/// Visual configuration shared by every part of a roulette. +/// +/// Apply one with ``SwiftUI/View/rouletteStyle(_:)``: +/// +/// ```swift +/// RouletteView(parts: parts) +/// .rouletteStyle(.pie) // the 1.x look +/// ``` +public struct RouletteStyle: Sendable, Hashable { + + /// Where a label sits between the centre (`0`) and the outer edge (`1`). + public var labelPosition: Double + + /// How a label is rotated within its wedge. + public var labelOrientation: RouletteLabelOrientation + + /// Radius of the empty hub in the middle, as a fraction of the outer radius. + /// + /// `0` draws a full pie, so every separator meets in the centre. + public var innerRadiusRatio: Double + + /// - Parameters: + /// - labelPosition: clamped to `0...1`. + /// - labelOrientation: how each label is rotated. + /// - innerRadiusRatio: clamped to `0...1`. + public init( + labelPosition: Double = 0.74, + labelOrientation: RouletteLabelOrientation = .radial, + innerRadiusRatio: Double = 0.22 + ) { + self.labelPosition = min(max(labelPosition, 0), 1) + self.labelOrientation = labelOrientation + self.innerRadiusRatio = min(max(innerRadiusRatio, 0), 1) + } + + /// Labels out at the rim, laid out radially, with a hub in the middle — the way + /// a real roulette wheel is arranged. Used when no style is applied. + /// + /// Being a faithful radial layout, labels on the left half of the wheel sit + /// upside down, exactly as the numbers on a real wheel do. If your labels are + /// words rather than numbers and you would rather every one of them stayed + /// readable, use ``RouletteLabelOrientation/radialUpright``: + /// + /// ```swift + /// RouletteView(parts: parts) + /// .rouletteStyle(RouletteStyle(labelOrientation: .radialUpright)) + /// ``` + public static let `default` = RouletteStyle() + + /// The 1.x look: labels halfway out, laid across the wedge, no hub. + public static let pie = RouletteStyle( + labelPosition: 0.5, + labelOrientation: .tangential, + innerRadiusRatio: 0 + ) +} + +// MARK: - Environment + +private struct RouletteStyleKey: EnvironmentKey { + static let defaultValue: RouletteStyle = .default +} + +extension EnvironmentValues { + /// The style applied to roulettes in this view hierarchy. + public var rouletteStyle: RouletteStyle { + get { self[RouletteStyleKey.self] } + set { self[RouletteStyleKey.self] = newValue } + } +} + +extension View { + /// Applies a visual style to every roulette in this view hierarchy. + /// + /// ```swift + /// RouletteView(parts: parts) + /// .rouletteStyle( + /// RouletteStyle(labelPosition: 0.8, innerRadiusRatio: 0.25) + /// ) + /// ``` + public func rouletteStyle(_ style: RouletteStyle) -> some View { + environment(\.rouletteStyle, style) + } +} diff --git a/Tests/SimpleRouletteTests/Types/RouletteStyleTests.swift b/Tests/SimpleRouletteTests/Types/RouletteStyleTests.swift new file mode 100644 index 00000000..e17bc8fd --- /dev/null +++ b/Tests/SimpleRouletteTests/Types/RouletteStyleTests.swift @@ -0,0 +1,108 @@ +// +// RouletteStyleTests.swift +// +// +// Created by Fumiya Tanaka on 2026/08/15. +// + +import Testing +import SwiftUI +@testable import SimpleRoulette + +@Suite("RouletteStyle Tests") +struct RouletteStyleTests { + + private let accuracy = 0.0001 + + @Test("Default style lays the wheel out like a real roulette") + func defaultStyle() { + let style = RouletteStyle.default + + // Faithful radial layout, labels out at the rim, hub in the middle. + #expect(style.labelOrientation == .radial) + #expect(style.labelPosition > 0.5) + #expect(style.innerRadiusRatio > 0) + } + + @Test("Every orientation stays selectable alongside the default") + func orientationsRemainAvailable() { + for orientation in [ + RouletteLabelOrientation.radial, + .radialUpright, + .tangential, + .fixed + ] { + let style = RouletteStyle(labelOrientation: orientation) + #expect(style.labelOrientation == orientation) + } + } + + @Test("Pie style reproduces the 1.x look") + func pieStyle() { + let style = RouletteStyle.pie + + #expect(style.labelOrientation == .tangential) + #expect(abs(style.labelPosition - 0.5) < accuracy) + #expect(style.innerRadiusRatio == 0) + } + + @Test("Ratios are clamped to 0...1") + func ratioClamping() { + let tooLarge = RouletteStyle(labelPosition: 4, innerRadiusRatio: 2.5) + #expect(tooLarge.labelPosition == 1) + #expect(tooLarge.innerRadiusRatio == 1) + + let negative = RouletteStyle(labelPosition: -1, innerRadiusRatio: -0.5) + #expect(negative.labelPosition == 0) + #expect(negative.innerRadiusRatio == 0) + } + + @Test("Radial labels rotate with their wedge") + func radialRotation() { + let mid = Angle(degrees: 45) + let rotation = RouletteLabelOrientation.radial.rotation(midAngle: mid) + #expect(abs(rotation.degrees - 45) < accuracy) + } + + @Test("Upright labels are flipped only on the half where text would be inverted") + func radialUprightRotation() { + let orientation = RouletteLabelOrientation.radialUpright + + // Right half reads outward already, so it is left alone. + #expect(abs(orientation.rotation(midAngle: .degrees(45)).degrees - 45) < accuracy) + #expect(abs(orientation.rotation(midAngle: .degrees(315)).degrees - 315) < accuracy) + + // Left half would be upside down, so it gets a further 180 degrees. + #expect(abs(orientation.rotation(midAngle: .degrees(135)).degrees - 315) < accuracy) + #expect(abs(orientation.rotation(midAngle: .degrees(180)).degrees - 360) < accuracy) + + // Boundaries stay unflipped. + #expect(abs(orientation.rotation(midAngle: .degrees(90)).degrees - 90) < accuracy) + #expect(abs(orientation.rotation(midAngle: .degrees(270)).degrees - 270) < accuracy) + } + + @Test("Upright flipping survives angles outside 0..<360") + func radialUprightWrapping() { + let orientation = RouletteLabelOrientation.radialUpright + + // 495 wraps to 135, which is on the flipped half. + #expect(abs(orientation.rotation(midAngle: .degrees(495)).degrees - 675) < accuracy) + // -225 wraps to 135 as well. + #expect(abs(orientation.rotation(midAngle: .degrees(-225)).degrees - (-45)) < accuracy) + } + + @Test("Tangential labels sit perpendicular to the radius") + func tangentialRotation() { + let mid = Angle(degrees: 45) + let rotation = RouletteLabelOrientation.tangential.rotation(midAngle: mid) + #expect(abs(rotation.degrees - 135) < accuracy) + } + + @Test("Fixed labels stay upright whatever the wedge angle") + func fixedRotation() { + for degrees in stride(from: 0.0, to: 360.0, by: 45.0) { + let rotation = RouletteLabelOrientation.fixed.rotation(midAngle: .degrees(degrees)) + #expect(rotation.degrees == 0) + } + } +}