diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..0390a3b
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,42 @@
+{
+ "originHash" : "ff1eab428ce698b2ba4197094f0d0f7530bfb7aea8c9f2c9e812a1bdcea27c19",
+ "pins" : [
+ {
+ "identity" : "swift-docc-plugin",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-docc-plugin",
+ "state" : {
+ "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
+ "version" : "1.4.3"
+ }
+ },
+ {
+ "identity" : "swift-docc-symbolkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+ "state" : {
+ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swiftui-introspect",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/siteline/swiftui-introspect",
+ "state" : {
+ "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swiftui-variadic-views",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lorenzofiamingo/swiftui-variadic-views",
+ "state" : {
+ "revision" : "af67d0e85bd2b499fbfb4cda834117e7c52b12b0",
+ "version" : "1.0.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 6f77c4b..19537bc 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,5 +1,4 @@
// swift-tools-version: 5.10
-// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -9,17 +8,23 @@ let package = Package(
.macOS(.v13)
],
products: [
- // Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Luminare",
targets: ["Luminare"]
)
],
+ dependencies: [
+ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
+ .package(url: "https://github.com/lorenzofiamingo/swiftui-variadic-views", from: "1.0.0"),
+ .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.0.0")
+ ],
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
.target(
- name: "Luminare"
+ name: "Luminare",
+ dependencies: [
+ .product(name: "VariadicViews", package: "swiftui-variadic-views"),
+ .product(name: "SwiftUIIntrospect", package: "swiftui-introspect")
+ ]
),
.testTarget(
name: "LuminareTests",
diff --git a/README.md b/README.md
index b9c52cf..a10c4f5 100644
--- a/README.md
+++ b/README.md
@@ -21,84 +21,7 @@ To add Luminare to your Xcode project, you can use Swift Package Manager (SPM).
Luminare offers a variety of components, organized for easy reference:
-
-
-
- Component Type |
- Component |
- Preview |
-
-
-
- Buttons |
- LuminareButtonStyle |
- |
-
-
- LuminareDestructiveButtonStyle |
- |
-
-
- LuminareCompactButtonStyle |
- |
-
-
- LuminareCosmeticButtonStyle |
- |
-
-
-
- Toggle Buttons |
- LuminareToggle |
- |
-
-
-
- Value Adjusters |
- LuminareValueAdjuster |
- |
-
-
-
- Pickers |
- LuminarePicker |
- |
-
-
- LuminareSliderPicker |
- |
-
-
-
- Color Pickers |
- LuminareColorPicker |
- |
-
-
-
- Text Fields |
- LuminareTextField |
- |
-
-
-
- Modal Views |
- .luminareModal(...) |
- |
-
-
-
- Sections |
- LuminareSection |
- |
-
-
-
- Lists |
- LuminareList |
- |
-
-
+*TODO: Add the table back.*
## Example Usage
@@ -106,4 +29,4 @@ Luminare can be used pretty much exactly like how you would use SwiftUI. For a p
## License
-Luminare is released under **GNU General Public License v3.0.** See the [LICENSE](LICENSE) file in the repository for the full license text.
+Luminare is released under **BSD 3-Clause License.** See the [LICENSE](LICENSE) file in the repository for the full license text.
diff --git a/Sources/Luminare/Utilities/LuminareBackgroundEffect.swift b/Sources/Luminare/Components/Auxiliary/LuminareBackgroundEffect.swift
similarity index 78%
rename from Sources/Luminare/Utilities/LuminareBackgroundEffect.swift
rename to Sources/Luminare/Components/Auxiliary/LuminareBackgroundEffect.swift
index a1303e4..d9b8348 100644
--- a/Sources/Luminare/Utilities/LuminareBackgroundEffect.swift
+++ b/Sources/Luminare/Components/Auxiliary/LuminareBackgroundEffect.swift
@@ -7,6 +7,7 @@
import SwiftUI
+/// A background effect that matches ``Luminare``.
public struct LuminareBackgroundEffect: ViewModifier {
public func body(content: Content) -> some View {
content
@@ -17,9 +18,3 @@ public struct LuminareBackgroundEffect: ViewModifier {
}
}
}
-
-public extension View {
- func luminareBackground() -> some View {
- modifier(LuminareBackgroundEffect())
- }
-}
diff --git a/Sources/Luminare/Components/Auxiliary/LuminareButtonStyles.swift b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyles.swift
new file mode 100644
index 0000000..d38a69a
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyles.swift
@@ -0,0 +1,578 @@
+//
+// LuminareButtonStyles.swift
+// Luminare
+//
+// Created by Kai Azim on 2024-04-02.
+//
+
+import SwiftUI
+
+struct AspectRatioModifier: ViewModifier {
+ @Environment(\.luminareMinHeight) private var minHeight
+ @Environment(\.luminareAspectRatio) private var aspectRatio
+ @Environment(\.luminareAspectRatioContentMode) private var contentMode
+ @Environment(\.luminareAspectRatioHasFixedHeight) private var hasFixedHeight
+
+ @ViewBuilder func body(content: Content) -> some View {
+ if let contentMode {
+ Group {
+ if isConstrained {
+ content
+ .frame(
+ minWidth: minWidth, maxWidth: .infinity,
+ minHeight: minHeight,
+ maxHeight: hasFixedHeight ? nil : .infinity
+ )
+ .aspectRatio(
+ aspectRatio,
+ contentMode: contentMode
+ )
+ } else {
+ content
+ .frame(
+ maxWidth: .infinity, minHeight: minHeight,
+ maxHeight: .infinity
+ )
+ }
+ }
+ .fixedSize(
+ horizontal: contentMode == .fit,
+ vertical: hasFixedHeight
+ )
+ } else {
+ content
+ }
+ }
+
+ private var isConstrained: Bool {
+ guard let contentMode else { return false }
+ return contentMode == .fit || hasFixedHeight
+ }
+
+ private var minWidth: CGFloat? {
+ if hasFixedHeight, let aspectRatio {
+ minHeight * aspectRatio
+ } else {
+ nil
+ }
+ }
+}
+
+// MARK: - Button Styles
+
+/// A stylized button style.
+///
+/// ![LuminareButtonStyle](LuminareButtonStyle)
+public struct LuminareButtonStyle: ButtonStyle {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareMinHeight) private var minHeight
+ @Environment(\.luminareButtonMaterial) private var material
+ @Environment(\.luminareButtonCornerRadii) private var cornerRadii
+ @Environment(\.luminareButtonHighlightOnHover) private var highlightOnHover
+
+ @State private var isHovering: Bool
+
+ public init() {
+ self.isHovering = false
+ }
+
+ #if DEBUG
+ init(
+ isHovering: Bool = false
+ ) {
+ self.isHovering = isHovering
+ }
+ #endif
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onHover { hover in
+ withAnimation(animationFast) {
+ isHovering = hover
+ }
+ }
+ .frame(minHeight: minHeight)
+ .opacity(isEnabled ? 1 : 0.5)
+ .modifier(
+ LuminareFilled(
+ isHovering: isHovering, isPressed: configuration.isPressed,
+ fill: .quinary, hovering: .quaternary.opacity(0.7),
+ pressed: .quaternary
+ )
+ )
+ .clipShape(.rect(cornerRadii: cornerRadii))
+ }
+}
+
+// MARK: - Button Style (Prominent)
+
+/// A stylized button style that can be tinted.
+///
+/// To tint the button, use the `.tint()` or `.overrideTint()` modifier.
+///
+/// ![LuminareProminentButtonStyle](LuminareProminentButtonStyle)
+public struct LuminareProminentButtonStyle: ButtonStyle {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareMinHeight) private var minHeight
+ @Environment(\.luminareButtonMaterial) private var material
+ @Environment(\.luminareButtonCornerRadii) private var cornerRadii
+ @Environment(\.luminareButtonHighlightOnHover) private var highlightOnHover
+
+ @State private var isHovering: Bool
+
+ public init() {
+ self.isHovering = false
+ }
+
+ #if DEBUG
+ init(
+ isHovering: Bool = false
+ ) {
+ self.isHovering = isHovering
+ }
+ #endif
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onHover { hover in
+ withAnimation(animationFast) {
+ isHovering = hover
+ }
+ }
+ .frame(minHeight: minHeight)
+ .opacity(isEnabled ? 1 : 0.5)
+ .modifier(
+ LuminareFilled(
+ isHovering: isHovering, isPressed: configuration.isPressed,
+ cascading: tint(configuration: configuration)
+ )
+ )
+ .clipShape(.rect(cornerRadii: cornerRadii))
+ }
+
+ private func tint(configuration: Configuration) -> AnyShapeStyle {
+ if let role = configuration.role {
+ switch role {
+ case .cancel, .destructive:
+ AnyShapeStyle(.red)
+ default:
+ AnyShapeStyle(.tint)
+ }
+ } else {
+ AnyShapeStyle(.tint)
+ }
+ }
+}
+
+// MARK: - Button Style (Cosmetic)
+
+/// A stylized button style that accepts an additional image for hovering.
+///
+/// Typically used for complex layouts with a custom avatar.
+/// However, the content is not constrained in any specific format.
+///
+/// ![LuminareCosmeticButtonStyle](LuminareCosmeticButtonStyle)
+public struct LuminareCosmeticButtonStyle: ButtonStyle {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareMinHeight) private var minHeight
+ @Environment(\.luminareButtonMaterial) private var material
+ @Environment(\.luminareButtonCornerRadii) private var cornerRadii
+ @Environment(\.luminareButtonHighlightOnHover) private var highlightOnHover
+
+ @ViewBuilder private var icon: () -> Image
+
+ @State private var isHovering: Bool
+
+ /// Initializes a ``LuminareCosmeticButtonStyle``.
+ ///
+ /// - Parameters:
+ /// - icon: the trailing aligned `Image` to display while hovering.
+ public init(
+ @ViewBuilder icon: @escaping () -> Image
+ ) {
+ self.icon = icon
+ self.isHovering = false
+ }
+
+ #if DEBUG
+ init(
+ isHovering: Bool = false,
+ @ViewBuilder icon: @escaping () -> Image
+ ) {
+ self.icon = icon
+ self.isHovering = isHovering
+ }
+ #endif
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onHover { hover in
+ withAnimation(animationFast) {
+ isHovering = hover
+ }
+ }
+ .frame(minHeight: minHeight)
+ .opacity(isEnabled ? 1 : 0.5)
+ .modifier(
+ LuminareFilled(
+ isHovering: isHovering, isPressed: configuration.isPressed
+ )
+ )
+ .overlay {
+ HStack {
+ Spacer()
+
+ icon()
+ .opacity(isHovering ? 1 : 0)
+ }
+ .padding(24)
+ .allowsHitTesting(false)
+ }
+ .clipShape(.rect(cornerRadii: cornerRadii))
+ }
+}
+
+// MARK: - Button Style (Compact)
+
+/// A stylized button style with a border.
+///
+/// Can be configured to disable padding when `extraCompact` is set to `true`.
+///
+/// ![LuminareCompactButtonStyle](LuminareCompactButtonStyle)
+public struct LuminareCompactButtonStyle: ButtonStyle {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareHorizontalPadding) private var horizontalPadding
+
+ @State private var isHovering: Bool
+
+ public init() {
+ self.isHovering = false
+ }
+
+ #if DEBUG
+ init(
+ isHovering: Bool = false
+ ) {
+ self.isHovering = isHovering
+ }
+ #endif
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .padding(.horizontal, horizontalPadding)
+ .modifier(AspectRatioModifier())
+ .opacity(isEnabled ? 1 : 0.5)
+ .modifier(
+ LuminareFilled(
+ isHovering: isHovering, isPressed: configuration.isPressed,
+ fill: .quinary, hovering: .quaternary.opacity(0.7),
+ pressed: .quaternary
+ )
+ )
+ .modifier(LuminareBordered(isHovering: isHovering))
+ .onHover { hover in
+ withAnimation(animationFast) {
+ isHovering = hover
+ }
+ }
+ }
+}
+
+// MARK: - Filled
+
+public struct LuminareFilled: ViewModifier {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareHasBackground) private var hasBackground
+ @Environment(\.luminareButtonMaterial) private var material
+ @Environment(\.luminareButtonHighlightOnHover) private var highlightOnHover
+
+ private let isHovering: Bool, isPressed: Bool
+ private let fill: AnyShapeStyle, hovering: AnyShapeStyle,
+ pressed: AnyShapeStyle
+
+ public init(
+ isHovering: Bool = false, isPressed: Bool = false,
+ fill: some ShapeStyle, hovering: some ShapeStyle,
+ pressed: some ShapeStyle
+ ) {
+ self.isHovering = isHovering
+ self.isPressed = isPressed
+ self.fill = .init(fill)
+ self.hovering = .init(hovering)
+ self.pressed = .init(pressed)
+ }
+
+ public init(
+ isHovering: Bool = false, isPressed: Bool = false,
+ cascading: some ShapeStyle
+ ) {
+ self.init(
+ isHovering: isHovering, isPressed: isPressed,
+ fill: cascading.opacity(0.15),
+ hovering: cascading.opacity(0.25),
+ pressed: cascading.opacity(0.4)
+ )
+ }
+
+ public init(
+ isHovering: Bool = false, isPressed: Bool = false,
+ pressed: some ShapeStyle
+ ) {
+ self.init(
+ isHovering: isHovering, isPressed: isPressed,
+ fill: .clear, hovering: pressed, pressed: pressed
+ )
+ }
+
+ public init(
+ isHovering: Bool = false, isPressed: Bool = false
+ ) {
+ self.init(
+ isHovering: isHovering, isPressed: isPressed,
+ pressed: .quinary
+ )
+ }
+
+ public func body(content: Content) -> some View {
+ if hasBackground {
+ content
+ .background(with: material) {
+ Group {
+ if isEnabled {
+ if isPressed {
+ Rectangle()
+ .foregroundStyle(pressed)
+ } else if highlightOnHover, isHovering {
+ Rectangle()
+ .foregroundStyle(hovering)
+ } else {
+ Rectangle()
+ .foregroundStyle(fill)
+ }
+ } else {
+ Rectangle()
+ .foregroundStyle(fill)
+ }
+ }
+ .opacity(isEnabled ? 1 : 0.5)
+ }
+ } else {
+ content
+ }
+ }
+}
+
+// MARK: - Bordered
+
+/// A stylized modifier that constructs a bordered appearance.
+///
+/// @Row {
+/// @Column(size: 2) {
+/// This looks like a ``LuminareCompactButtonStyle``, but is not limited to buttons.
+/// }
+///
+/// @Column {
+/// ![LuminareButtonStyle](LuminareBordered)
+/// }
+/// }
+public struct LuminareBordered: ViewModifier {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareIsBordered) private var isBordered
+ @Environment(\.luminareHasBackground) private var hasBackground
+ @Environment(\.luminareCompactButtonCornerRadii) private var cornerRadii
+
+ private let isHovering: Bool
+ private let fill: AnyShapeStyle, hovering: AnyShapeStyle
+
+ public init(
+ isHovering: Bool = false,
+ fill: some ShapeStyle, hovering: some ShapeStyle
+ ) {
+ self.isHovering = isHovering
+ self.fill = .init(fill)
+ self.hovering = .init(hovering)
+ }
+
+ public init(
+ isHovering: Bool = false,
+ cascading: some ShapeStyle
+ ) {
+ self.init(
+ isHovering: isHovering,
+ fill: cascading.opacity(0.7),
+ hovering: cascading
+ )
+ }
+
+ public init(
+ isHovering: Bool = false,
+ hovering: some ShapeStyle
+ ) {
+ self.init(
+ isHovering: isHovering,
+ fill: .clear, hovering: hovering
+ )
+ }
+
+ public init(
+ isHovering: Bool = false
+ ) {
+ self.init(
+ isHovering: isHovering,
+ cascading: .quaternary
+ )
+ }
+
+ public func body(content: Content) -> some View {
+ content
+ .clipShape(.rect(cornerRadii: cornerRadii))
+ .background {
+ if isHovering, hasBackground {
+ UnevenRoundedRectangle(cornerRadii: cornerRadii)
+ .strokeBorder(fill)
+ } else if isBordered {
+ UnevenRoundedRectangle(cornerRadii: cornerRadii)
+ .strokeBorder(hovering)
+ }
+ }
+ }
+}
+
+// MARK: - Hoverable
+
+/// A stylized modifier that constructs a bordered appearance while hovering.
+///
+/// @Row {
+/// @Column(size: 2) {
+/// While not hovering, the visibility of the border can be configured through `isBordered`.
+/// }
+///
+/// @Column {
+/// ![LuminareHoverable](LuminareHoverable)
+/// }
+/// }
+public struct LuminareHoverable: ViewModifier {
+ @Environment(\.isEnabled) private var isEnabled
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareHorizontalPadding) private var horizontalPadding
+
+ private let isPressed: Bool
+ private let fill: AnyShapeStyle, hovering: AnyShapeStyle,
+ pressed: AnyShapeStyle
+
+ @State private var isHovering: Bool
+
+ public init(
+ isPressed: Bool = false,
+ fill: some ShapeStyle, hovering: some ShapeStyle,
+ pressed: some ShapeStyle
+ ) {
+ self.isPressed = isPressed
+ self.fill = .init(fill)
+ self.hovering = .init(hovering)
+ self.pressed = .init(pressed)
+ self.isHovering = false
+ }
+
+ public init(
+ isPressed: Bool = false,
+ cascading: some ShapeStyle
+ ) {
+ self.init(
+ isPressed: isPressed,
+ fill: cascading.opacity(0.15),
+ hovering: cascading.opacity(0.25),
+ pressed: cascading.opacity(0.4)
+ )
+ }
+
+ public init(
+ isPressed: Bool = false,
+ pressed: some ShapeStyle
+ ) {
+ self.init(
+ isPressed: isPressed,
+ fill: .clear, hovering: pressed, pressed: pressed
+ )
+ }
+
+ public init(
+ isPressed: Bool = false
+ ) {
+ self.init(
+ isPressed: isPressed,
+ pressed: .quinary
+ )
+ }
+
+ #if DEBUG
+ init(
+ isPressed: Bool = false, isHovering: Bool = false,
+ fill: some ShapeStyle, hovering: some ShapeStyle,
+ pressed: some ShapeStyle
+ ) {
+ self.isPressed = isPressed
+ self.fill = .init(fill)
+ self.hovering = .init(hovering)
+ self.pressed = .init(pressed)
+ self.isHovering = isHovering
+ }
+
+ init(
+ isPressed: Bool = false, isHovering: Bool = false,
+ cascading: some ShapeStyle
+ ) {
+ self.init(
+ isPressed: isPressed, isHovering: isHovering,
+ fill: cascading.opacity(0.15),
+ hovering: cascading.opacity(0.25),
+ pressed: cascading.opacity(0.4)
+ )
+ }
+
+ init(
+ isPressed: Bool = false, isHovering: Bool = false,
+ pressed: some ShapeStyle
+ ) {
+ self.init(
+ isPressed: isPressed, isHovering: isHovering,
+ fill: .clear, hovering: pressed, pressed: pressed
+ )
+ }
+
+ init(
+ isPressed: Bool = false, isHovering: Bool = false
+ ) {
+ self.init(
+ isPressed: isPressed, isHovering: isHovering,
+ pressed: .quinary
+ )
+ }
+ #endif
+
+ public func body(content: Content) -> some View {
+ content
+ .padding(.horizontal, horizontalPadding)
+ .modifier(AspectRatioModifier())
+ .opacity(isEnabled ? 1 : 0.5)
+ .modifier(
+ LuminareFilled(
+ isHovering: isHovering, isPressed: isPressed,
+ fill: fill, hovering: hovering, pressed: pressed
+ )
+ )
+ .modifier(LuminareBordered(isHovering: isHovering))
+ .onHover { hover in
+ withAnimation(animationFast) {
+ isHovering = hover
+ }
+ }
+ }
+}
diff --git a/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift b/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift
new file mode 100644
index 0000000..98ccaf1
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift
@@ -0,0 +1,75 @@
+//
+// LuminareCroppedSectionItem.swift
+// Luminare
+//
+// Created by KrLite on 2024/11/15.
+//
+
+import SwiftUI
+
+// MARK: - Cropped Section Item
+
+/// An item with a cropped appearance, typically used in sections.
+public struct LuminareCroppedSectionItem: ViewModifier {
+ // MARK: Environments
+
+ @Environment(\.luminareCornerRadii) private var cornerRadii
+ @Environment(\.luminareButtonCornerRadii) private var buttonCornerRadii
+
+ // MARK: Fields
+
+ private let innerPadding: CGFloat
+ private let isFirstChild: Bool, isLastChild: Bool
+
+ // MARK: Initializers
+
+ /// Initializes a ``LuminareCroppedItem``.
+ ///
+ /// - Parameters:
+ /// - innerPadding: the padding around the contents.
+ /// - isFirstChild: whether this item is the first of the section.
+ /// - isLastChild: whether this item is the last of the section.
+ public init(
+ innerPadding: CGFloat = 4,
+ isFirstChild: Bool, isLastChild: Bool
+ ) {
+ self.innerPadding = innerPadding
+ self.isFirstChild = isFirstChild
+ self.isLastChild = isLastChild
+ }
+
+ // MARK: Body
+
+ public func body(content: Content) -> some View {
+ content
+ .mask(mask())
+ .padding(.horizontal, innerPadding)
+ }
+
+ @ViewBuilder private func mask() -> some View {
+ if isFirstChild, isLastChild {
+ UnevenRoundedRectangle(
+ topLeadingRadius: cornerRadii.topLeading - innerPadding,
+ bottomLeadingRadius: cornerRadii.bottomLeading - innerPadding,
+ bottomTrailingRadius: cornerRadii.bottomTrailing - innerPadding,
+ topTrailingRadius: cornerRadii.topTrailing - innerPadding
+ )
+ } else if isFirstChild {
+ UnevenRoundedRectangle(
+ topLeadingRadius: cornerRadii.topLeading - innerPadding,
+ bottomLeadingRadius: buttonCornerRadii.bottomLeading,
+ bottomTrailingRadius: buttonCornerRadii.bottomTrailing,
+ topTrailingRadius: cornerRadii.topTrailing - innerPadding
+ )
+ } else if isLastChild {
+ UnevenRoundedRectangle(
+ topLeadingRadius: buttonCornerRadii.topLeading,
+ bottomLeadingRadius: cornerRadii.bottomLeading - innerPadding,
+ bottomTrailingRadius: cornerRadii.bottomTrailing - innerPadding,
+ topTrailingRadius: buttonCornerRadii.topTrailing
+ )
+ } else {
+ Rectangle()
+ }
+ }
+}
diff --git a/Sources/Luminare/Components/Auxiliary/Scroll Views/AutoScrollView.swift b/Sources/Luminare/Components/Auxiliary/Scroll Views/AutoScrollView.swift
new file mode 100644
index 0000000..1c393c1
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/Scroll Views/AutoScrollView.swift
@@ -0,0 +1,105 @@
+//
+// AutoScrollView.swift
+// Luminare
+//
+// Created by KrLite on 2024/11/5.
+//
+
+import SwiftUI
+
+/// A simple scroll view that enables scrolling only if the content is large enough to scroll.
+public struct AutoScrollView: View where Content: View {
+ @Environment(\.luminareContentMarginsTop) private var contentMarginsTop
+ @Environment(\.luminareContentMarginsLeading) private var contentMarginsLeading
+ @Environment(\.luminareContentMarginsBottom) private var contentMarginsBottom
+ @Environment(\.luminareContentMarginsTrailing) private var contentMarginsTrailing
+
+ private let axes: Axis.Set
+ private let showsIndicators: Bool
+ @ViewBuilder private var content: () -> Content
+
+ @State private var contentSize: CGSize = .zero
+ @State private var containerSize: CGSize = .zero
+
+ /// Initializes a ``AutoScrollView``.
+ ///
+ /// - Parameters:
+ /// - axes: the axes of the scroll view.
+ /// - showsIndicators: whether to show the scroll indicators.
+ /// - content: the content to scroll.
+ public init(
+ _ axes: Axis.Set = .vertical,
+ showsIndicators: Bool = true,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.axes = axes
+ self.showsIndicators = showsIndicators
+ self.content = content
+ }
+
+ public var body: some View {
+ ScrollView(allowedAxes, showsIndicators: showsIndicators) {
+ VStack(spacing: 0) {
+ if contentMarginsTop > 0 {
+ Spacer()
+ .frame(height: contentMarginsTop)
+ }
+
+ content()
+ .padding(.leading, contentMarginsLeading)
+ .padding(.trailing, contentMarginsTrailing)
+
+ if contentMarginsBottom > 0 {
+ Spacer()
+ .frame(height: contentMarginsBottom)
+ }
+ }
+ .onGeometryChange(for: CGSize.self) { proxy in
+ proxy.size
+ } action: { size in
+ contentSize = size
+ }
+ }
+ .onGeometryChange(for: CGSize.self) { proxy in
+ proxy.size
+ } action: { size in
+ containerSize = size
+ }
+ .scrollDisabled(isHorizontalScrollingDisabled && isVerticalScrollingDisabled)
+ }
+
+ private var allowedAxes: Axis.Set {
+ if isHorizontalScrollingDisabled, isVerticalScrollingDisabled {
+ axes
+ } else if isHorizontalScrollingDisabled {
+ axes.intersection(.vertical)
+ } else if isVerticalScrollingDisabled {
+ axes.intersection(.horizontal)
+ } else {
+ axes
+ }
+ }
+
+ private var isHorizontalScrollingDisabled: Bool {
+ guard axes.contains(.horizontal) else { return true }
+ return contentSize.width <= containerSize.width
+ }
+
+ private var isVerticalScrollingDisabled: Bool {
+ guard axes.contains(.vertical) else { return true }
+ return contentSize.height <= containerSize.height
+ }
+}
+
+@available(macOS 15.0, *)
+#Preview(
+ "AutoScrollView",
+ traits: .sizeThatFitsLayout
+) {
+ AutoScrollView {
+ Color.red
+ .frame(height: 300)
+ }
+ .luminareContentMargins(.vertical, 50)
+ .frame(width: 100, height: 300)
+}
diff --git a/Sources/Luminare/Components/Auxiliary/Scroll Views/InfiniteScrollView.swift b/Sources/Luminare/Components/Auxiliary/Scroll Views/InfiniteScrollView.swift
new file mode 100644
index 0000000..7c00c0f
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/Scroll Views/InfiniteScrollView.swift
@@ -0,0 +1,577 @@
+//
+// InfiniteScrollView.swift
+// Luminare
+//
+// Created by KrLite on 2024/11/2.
+//
+
+import AppKit
+import SwiftUI
+
+/// The direction of an ``InfiniteScrollView``.
+public enum InfiniteScrollViewDirection: String, Equatable, Hashable, Identifiable, CaseIterable, Codable, Sendable {
+ /// The view can, and can only be scrolled horizontally.
+ case horizontal
+ /// The view can, and can only be scrolled vertically.
+ case vertical
+
+ public var id: Self { self }
+
+ /// Initializes an ``InfiniteScrollViewDirection`` from an `Axis`.
+ public init(axis: Axis) {
+ switch axis {
+ case .horizontal:
+ self = .horizontal
+ case .vertical:
+ self = .vertical
+ }
+ }
+
+ /// The scrolling `Axis` of the ``InfiniteScrollView``.
+ public var axis: Axis {
+ switch self {
+ case .horizontal:
+ .horizontal
+ case .vertical:
+ .vertical
+ }
+ }
+
+ // Stacks the given elements according to the direction
+ @ViewBuilder func stack(spacing: CGFloat, @ViewBuilder content: @escaping () -> some View) -> some View {
+ switch self {
+ case .horizontal:
+ HStack(alignment: .center, spacing: spacing, content: content)
+ case .vertical:
+ VStack(alignment: .center, spacing: spacing, content: content)
+ }
+ }
+
+ // Gets the length from the given 2D size according to the direction
+ func length(of size: CGSize) -> CGFloat {
+ switch self {
+ case .horizontal:
+ size.width
+ case .vertical:
+ size.height
+ }
+ }
+
+ // Gets the offset from the given 2D point according to the direction
+ func offset(of point: CGPoint) -> CGFloat {
+ switch self {
+ case .horizontal:
+ point.x
+ case .vertical:
+ point.y
+ }
+ }
+
+ // Forms a point from the given offset according to the direction
+ func point(from offset: CGFloat) -> CGPoint {
+ switch self {
+ case .horizontal:
+ .init(x: offset, y: 0)
+ case .vertical:
+ .init(x: 0, y: offset)
+ }
+ }
+
+ // Forms a size from the given length according to the direction
+ func size(from length: CGFloat, fallback: CGFloat) -> CGSize {
+ switch self {
+ case .horizontal:
+ .init(width: length, height: fallback)
+ case .vertical:
+ .init(width: fallback, height: length)
+ }
+ }
+}
+
+// MARK: - Infinite Scroll
+
+/// An auxiliary view that handles infinite scrolling with conditional wrapping and snapping support.
+///
+/// The fundamental effect is achieved through resetting the scrolling position after every scroll event that reaches
+/// the specified page length.
+///
+/// The scrolling result can be listened through ``InfiniteScrollView/offset`` and ``InfiniteScrollView/page``,
+/// respectively representing the offset from the page and the scrolled page count.
+public struct InfiniteScrollView: NSViewRepresentable {
+ public typealias Direction = InfiniteScrollViewDirection
+
+ @Environment(\.luminareAnimationFast) private var animationFast
+
+ var debug: Bool = false
+ /// The ``InfiniteScrollViewDirection`` that defines the scrolling direction.
+ public var direction: Direction
+ /// Whether mouse dragging is allowed as an alternative of scrolling.
+ /// Overscrolling is not allowed when dragging.
+ public var allowsDragging: Bool = true
+
+ /// The explicit size of the scroll view.
+ public var size: CGSize
+ /// the spacing between pages.
+ public var spacing: CGFloat
+ /// Whether snapping is enabled.
+ ///
+ /// If snapping is enabled, the view will automatically snaps to the nearest available page anchor with animation.
+ /// Otherwise, scrolling can stop at arbitrary midpoints.
+ @Binding public var snapping: Bool
+ /// Whether wrapping is enabled.
+ ///
+ /// If wrapping is enabled, the view will always allow infinite scrolling by constantly resetting the scrolling
+ /// position.
+ /// Otherwise, the view won't lock the scrollable region and allows overscrolling to happen.
+ @Binding public var wrapping: Bool
+ /// The initial offset of the scroll view.
+ ///
+ /// Can be useful when arbitrary initialization points are required.
+ @Binding public var initialOffset: CGFloat
+
+ /// Whether the scroll view should be resetted.
+ ///
+ /// This will automatically be set to `false` after a valid reset happens.
+ @Binding public var shouldReset: Bool
+ /// The offset from the nearest page.
+ ///
+ /// This binding is get-only.
+ @Binding public var offset: CGFloat
+ /// The scrolled page count.
+ ///
+ /// This binding is get-only.
+ @Binding public var page: Int
+
+ var length: CGFloat {
+ direction.length(of: size)
+ }
+
+ var scrollableLength: CGFloat {
+ length + spacing * 2
+ }
+
+ var centerRect: CGRect {
+ .init(origin: direction.point(from: (scrollableLength - length) / 2), size: size)
+ }
+
+ @ViewBuilder private func sideView() -> some View {
+ let size = direction.size(from: spacing, fallback: direction.length(of: size))
+
+ Group {
+ if debug {
+ Color.red
+ } else {
+ Color.clear
+ }
+ }
+ .frame(width: size.width, height: size.height)
+ }
+
+ @ViewBuilder private func centerView() -> some View {
+ Color.clear
+ .frame(width: size.width, height: size.height)
+ }
+
+ func onBoundsChange(_ bounds: CGRect, animate: Bool = false) {
+ let offset = direction.offset(of: bounds.origin) - direction.offset(of: centerRect.origin)
+ if animate {
+ withAnimation(animationFast) {
+ self.offset = offset
+ }
+ } else {
+ self.offset = offset
+ }
+ }
+
+ public func makeNSView(context: Context) -> NSScrollView {
+ let scrollView = NSScrollView()
+ scrollView.drawsBackground = false
+ scrollView.hasVerticalScroller = false
+ scrollView.hasHorizontalScroller = false
+
+ // Allocate the scrollable area
+ let documentView = NSHostingView(
+ rootView: direction.stack(spacing: 0) {
+ sideView()
+ centerView()
+ sideView()
+ }
+ )
+ scrollView.documentView = documentView
+
+ documentView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.contentView.translatesAutoresizingMaskIntoConstraints = false
+
+ // Observe scrolls
+ NotificationCenter.default.addObserver(
+ context.coordinator,
+ selector: #selector(context.coordinator.didLiveScroll(_:)),
+ name: NSScrollView.didLiveScrollNotification,
+ object: scrollView
+ )
+
+ // Observe when scrolling starts
+ NotificationCenter.default.addObserver(
+ context.coordinator,
+ selector: #selector(context.coordinator.willStartLiveScroll(_:)),
+ name: NSScrollView.willStartLiveScrollNotification,
+ object: scrollView
+ )
+
+ // Observe when scrolling ends
+ NotificationCenter.default.addObserver(
+ context.coordinator,
+ selector: #selector(context.coordinator.didEndLiveScroll(_:)),
+ name: NSScrollView.didEndLiveScrollNotification,
+ object: scrollView
+ )
+
+ return scrollView
+ }
+
+ public func updateNSView(_ nsView: NSScrollView, context: Context) {
+ DispatchQueue.main.async {
+ context.coordinator.initializeScroll(nsView)
+ }
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ // MARK: - Coordinator
+
+ public class Coordinator: NSObject {
+ private enum DraggingStage: Equatable {
+ case invalid
+ case preparing
+ case dragging
+ }
+
+ var parent: InfiniteScrollView
+
+ private var offsetOrigin: CGFloat = .zero
+ private var pageOrigin: Int = .zero
+
+ private var lastOffset: CGFloat = .zero
+ private var lastPageOffset: Int = .zero
+
+ private var draggingStage: DraggingStage = .invalid
+
+ private let id = UUID()
+
+ init(_ parent: InfiniteScrollView) {
+ self.parent = parent
+ }
+
+ func initializeScroll(_ scrollView: NSScrollView) {
+ let clipView = scrollView.contentView
+
+ // Reset if required
+ if parent.shouldReset {
+ resetScrollViewPosition(clipView, offset: parent.direction.point(from: parent.initialOffset))
+ pageOrigin = parent.page
+ }
+
+ // Set dragging monitor if required
+ if parent.allowsDragging {
+ // Deduplicate
+ EventMonitorManager.shared.addLocalMonitor(
+ for: id,
+ matching: [
+ .leftMouseDown, .leftMouseUp, .leftMouseDragged
+ ]
+ ) { [weak self] event in
+ let location = clipView.convert(event.locationInWindow, from: nil)
+ guard let self else { return event }
+
+ // ensure the dragging *happens* inside the view and can *continue* anywhere else
+ let canIgnoreBounds = draggingStage == .dragging
+ guard canIgnoreBounds || clipView.bounds.contains(location) else { return event }
+
+ switch event.type {
+ case .leftMouseDown:
+ // Indicates dragging might start in the future
+ draggingStage = .preparing
+ case .leftMouseUp:
+ switch draggingStage {
+ case .invalid:
+ break
+ case .preparing:
+ // invalidates dragging
+ draggingStage = .invalid
+ case .dragging:
+ // ends dragging
+ draggingStage = .invalid
+ didEndLiveScroll(.init(
+ name: NSScrollView.didEndLiveScrollNotification,
+ object: scrollView
+ )
+ )
+ }
+ case .leftMouseDragged:
+ // Always update view bounds first
+ clipView.setBoundsOrigin(clipView.bounds.origin.applying(
+ .init(translationX: -event.deltaX, y: -event.deltaY)
+ ))
+
+ switch draggingStage {
+ case .invalid:
+ break
+ case .preparing:
+ // Starts dragging
+ draggingStage = .dragging
+ willStartLiveScroll(.init(
+ name: NSScrollView.willStartLiveScrollNotification,
+ object: scrollView
+ ))
+
+ // Emits dragging
+ didLiveScroll(.init(
+ name: NSScrollView.didLiveScrollNotification,
+ object: scrollView
+ ))
+ case .dragging:
+ // Emits dragging
+ didLiveScroll(.init(
+ name: NSScrollView.didLiveScrollNotification,
+ object: scrollView
+ ))
+ }
+ default:
+ break
+ }
+
+ return event
+ }
+ }
+ }
+
+ // Should be called whenever a scroll happens.
+ @objc func didLiveScroll(_ notification: Notification) {
+ guard let scrollView = notification.object as? NSScrollView else { return }
+
+ let center = parent.direction.offset(of: parent.centerRect.origin)
+ let offset = parent.direction.offset(of: scrollView.contentView.bounds.origin)
+ let relativeOffset = offset - center
+
+ // Handles wrapping case
+ if parent.wrapping {
+ lastOffset = offset
+ lastPageOffset = 0
+
+ // Check if reaches next page
+ if abs(relativeOffset) >= parent.spacing {
+ resetScrollViewPosition(scrollView.contentView)
+
+ let pageOffset: Int = if relativeOffset >= parent.spacing {
+ +1
+ } else if relativeOffset <= -parent.spacing {
+ -1
+ } else { 0 }
+
+ accumulatePage(pageOffset)
+ }
+ }
+
+ // Handles non-wrapping case
+ else {
+ let offset = max(0, min(2 * parent.spacing, offset))
+ let relativeOffset = offset - offsetOrigin
+
+ // Arithmetic approach to achieve a undirectional paging effect
+ let isIncremental = offset - lastOffset > 0
+ let comparation: (Int, Int) -> Int = isIncremental ? max : min
+ let pageOffset = comparation(
+ lastPageOffset,
+ Int((relativeOffset / parent.spacing).rounded(isIncremental ? .down : .up))
+ )
+
+ lastOffset = offset
+ lastPageOffset = pageOffset
+
+ overridePage(pageOffset)
+ }
+
+ updateBounds(scrollView.contentView)
+ }
+
+ // Should be called whenever a scroll starts.
+ @objc func willStartLiveScroll(_ notification: Notification) {
+ guard let scrollView = notification.object as? NSScrollView else { return }
+
+ offsetOrigin = parent.direction.offset(of: scrollView.contentView.bounds.origin)
+ pageOrigin = parent.page
+
+ lastOffset = offsetOrigin
+
+ updateBounds(scrollView.contentView)
+ }
+
+ // Should be called whenever a scroll ends.
+ @objc func didEndLiveScroll(_ notification: Notification) {
+ guard let scrollView = notification.object as? NSScrollView else { return }
+
+ // Snaps if required
+ if parent.snapping {
+ NSAnimationContext.runAnimationGroup { context in
+ context.allowsImplicitAnimation = true
+ self.snapScrollViewPosition(scrollView.contentView)
+ }
+ }
+
+ updateBounds(scrollView.contentView)
+ }
+
+ private func updateBounds(_ clipView: NSClipView, animate: Bool = false) {
+ parent.onBoundsChange(clipView.bounds, animate: animate)
+ }
+
+ // Accumulates the page for wrapping
+ private func accumulatePage(_ offset: Int) {
+ parent.page += offset
+ pageOrigin = parent.page
+ }
+
+ // Overrides the page, not for wrapping
+ private func overridePage(_ offset: Int) {
+ parent.page = pageOrigin + offset
+ }
+
+ private func resetScrollViewPosition(_ clipView: NSClipView, offset: CGPoint = .zero, animate: Bool = false) {
+ clipView.setBoundsOrigin(parent.centerRect.origin.applying(.init(translationX: offset.x, y: offset.y)))
+
+ parent.shouldReset = false
+ offsetOrigin = parent.direction.offset(of: clipView.bounds.origin)
+
+ updateBounds(clipView, animate: animate)
+ }
+
+ // Snaps to the nearest available page anchor
+ private func snapScrollViewPosition(_ clipView: NSClipView) {
+ let center = parent.direction.offset(of: parent.centerRect.origin)
+ let offset = parent.direction.offset(of: clipView.bounds.origin)
+
+ let relativeOffset = offset - center
+
+ let snapsToNext = relativeOffset >= parent.spacing / 2
+ let snapsToPrevious = relativeOffset <= -parent.spacing / 2
+ let localOffset: CGFloat = if snapsToNext {
+ parent.spacing
+ } else if snapsToPrevious {
+ -parent.spacing
+ } else { 0 }
+
+ // - Paging logic
+
+ // Handles wrapping case
+ if parent.wrapping {
+ let pageOffset: Int = if snapsToNext {
+ +1
+ } else if snapsToPrevious {
+ -1
+ } else { 0 }
+
+ accumulatePage(pageOffset)
+ }
+
+ // Handles non-wrapping case
+ else {
+ // Simply rounds the page toward zero to find the nearest page
+ let relativeOffsetOrigin = offsetOrigin - center
+ let relativeOffset = localOffset - relativeOffsetOrigin
+ let pageOffset = Int((relativeOffset / parent.spacing).rounded(.towardZero))
+
+ overridePage(pageOffset)
+ }
+
+ // - Animation logic (required for correctly presenting directional snapping animations)
+
+ // Handles wrapping case
+ if parent.wrapping {
+ // Overflow to corresponding edge in advance to correct the animation origin
+ if localOffset != 0 {
+ resetScrollViewPosition(
+ clipView,
+ offset: parent.direction.point(from: relativeOffset - localOffset)
+ )
+ }
+
+ resetScrollViewPosition(clipView, animate: true)
+ }
+
+ // Handles non-wrapping case
+ else {
+ resetScrollViewPosition(
+ clipView,
+ offset: parent.direction.point(from: localOffset),
+ animate: true
+ )
+ }
+ }
+ }
+}
+
+// MARK: - Preview
+
+private struct InfiniteScrollPreview: View {
+ var direction: InfiniteScrollViewDirection = .horizontal
+ var size: CGSize = .init(width: 500, height: 100)
+
+ @State private var offset: CGFloat = 0
+ @State private var page: Int = 0
+ @State private var shouldReset: Bool = true
+ @State private var wrapping: Bool = true
+
+ var body: some View {
+ InfiniteScrollView(
+ debug: true,
+ direction: direction,
+
+ size: size,
+ spacing: 50,
+ snapping: .constant(true),
+ wrapping: $wrapping,
+ initialOffset: .constant(0),
+
+ shouldReset: $shouldReset,
+ offset: $offset,
+ page: $page
+ )
+ .frame(width: size.width, height: size.height)
+ .border(.red)
+
+ HStack {
+ Button("Reset Offset") {
+ shouldReset = true
+ }
+
+ Button(wrapping ? "Disable Wrapping" : "Enable Wrapping") {
+ wrapping.toggle()
+ }
+ }
+ .frame(maxWidth: .infinity)
+
+ HStack {
+ Text(String(format: "Offset: %.1f", offset))
+
+ Text("Page: \(page)")
+ .foregroundStyle(.tint)
+ }
+ .monospaced()
+ .frame(height: 12)
+ }
+}
+
+#Preview {
+ VStack {
+ InfiniteScrollPreview()
+
+ Divider()
+
+ InfiniteScrollPreview(direction: .vertical, size: .init(width: 100, height: 500))
+ }
+ .padding()
+ .contentTransition(.numericText())
+}
diff --git a/Sources/Luminare/Components/Auxiliary/Utility Views/DividedVStack.swift b/Sources/Luminare/Components/Auxiliary/Utility Views/DividedVStack.swift
new file mode 100644
index 0000000..1616835
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/Utility Views/DividedVStack.swift
@@ -0,0 +1,128 @@
+//
+// DividedVStack.swift
+// Luminare
+//
+// Created by Kai Azim on 2024-04-02.
+//
+// Thanks to https://movingparts.io/variadic-views-in-swiftui and https://github.com/lorenzofiamingo/swiftui-variadic-views
+
+import SwiftUI
+import VariadicViews
+
+// MARK: - Divided Vertical Stack
+
+/// A vertical stack with optional dividers between elements.
+public struct DividedVStack: View where Content: View {
+ // MARK: Fields
+
+ private let spacing: CGFloat?
+ private let isMasked: Bool
+ private let hasDividers: Bool
+
+ @ViewBuilder private var content: () -> Content
+
+ // MARK: Initializers
+
+ /// Initializes a ``DividedVStack``.
+ ///
+ /// - Parameters:
+ /// - spacing: the spacing between elements.
+ /// - isMasked: whether the elements are masked to match their borders.
+ /// - hasDividers: whether to show the dividers between elements.
+ /// - content: the content.
+ public init(
+ spacing: CGFloat? = nil,
+ isMasked: Bool = true,
+ hasDividers: Bool = true,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.spacing = spacing
+ self.isMasked = isMasked
+ self.hasDividers = hasDividers
+ self.content = content
+ }
+
+ // MARK: Body
+
+ public var body: some View {
+ UnaryVariadicView(content()) { children in
+ DividedVStackVariadic(
+ children: children,
+ spacing: isMasked ? spacing : 0,
+ isMasked: isMasked,
+ hasDividers: hasDividers
+ )
+ }
+ }
+}
+
+// MARK: - Layouts
+
+struct DividedVStackVariadic: View {
+ let children: VariadicViewChildren
+ let spacing: CGFloat
+ let innerPadding: CGFloat
+ let isMasked: Bool
+ let hasDividers: Bool
+
+ init(
+ children: VariadicViewChildren,
+ spacing: CGFloat?,
+ innerPadding: CGFloat = 4,
+ isMasked: Bool,
+ hasDividers: Bool
+ ) {
+ self.children = children
+ self.spacing = spacing ?? innerPadding
+ self.innerPadding = innerPadding
+ self.isMasked = isMasked
+ self.hasDividers = hasDividers
+ }
+
+ var body: some View {
+ let first = children.first?.id
+ let last = children.last?.id
+
+ VStack(spacing: hasDividers ? spacing : spacing / 2) {
+ ForEach(children) { child in
+ Group {
+ if isMasked {
+ child
+ .modifier(
+ LuminareCroppedSectionItem(
+ isFirstChild: child.id == first,
+ isLastChild: child.id == last
+ )
+ )
+ .padding(.top, child.id == first ? 1 : 0)
+ .padding(.bottom, child.id == last ? 1 : 0)
+ .padding(.horizontal, 1)
+ } else {
+ child
+ .mask(Rectangle()) // fixes hover areas for some reason
+ .padding(.vertical, -4)
+ }
+ }
+
+ if hasDividers, child.id != last {
+ Divider()
+ .padding(.horizontal, 1)
+ }
+ }
+ }
+ .padding(.vertical, innerPadding)
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ LuminareSection {
+ DividedVStack {
+ ForEach(37 ..< 43) { num in
+ Text("\(num)")
+ }
+ }
+ }
+ .padding()
+}
diff --git a/Sources/Luminare/Components/Auxiliary/Utility Views/ForceTouch.swift b/Sources/Luminare/Components/Auxiliary/Utility Views/ForceTouch.swift
new file mode 100644
index 0000000..c9f726c
--- /dev/null
+++ b/Sources/Luminare/Components/Auxiliary/Utility Views/ForceTouch.swift
@@ -0,0 +1,274 @@
+//
+// ForceTouch.swift
+// Luminare
+//
+// Created by KrLite on 2024/10/29.
+//
+
+import AppKit
+import SwiftUI
+
+/// The gesture state of a ``ForceTouch``.
+public enum ForceTouchGesture: Equatable {
+ /// An inactive gesture.
+ case inactive
+ /// An active gesture with a ``Event``.
+ case active(Event)
+
+ /// The event context of a ``ForceTouchGesture``.
+ public struct Event: Equatable {
+ public var state: NSPressGestureRecognizer.State
+ public var stage: Int
+ public var stageTransition: CGFloat
+ public var pressure: CGFloat
+ public var pressureBehavior: NSEvent.PressureBehavior
+ public var modifierFlags: NSEvent.ModifierFlags
+
+ public init(
+ state: NSPressGestureRecognizer.State,
+ stage: Int,
+ stageTransition: CGFloat,
+ pressure: CGFloat,
+ pressureBehavior: NSEvent.PressureBehavior,
+ modifierFlags: NSEvent.ModifierFlags
+ ) {
+ self.state = state
+ self.stage = stage
+ self.stageTransition = stageTransition
+ self.pressure = pressure
+ self.pressureBehavior = pressureBehavior
+ self.modifierFlags = modifierFlags
+ }
+
+ public init(_ state: NSPressGestureRecognizer.State, event: NSEvent) {
+ self.init(
+ state: state,
+ stage: event.stage,
+ stageTransition: event.stageTransition,
+ pressure: CGFloat(event.pressure),
+ pressureBehavior: event.pressureBehavior,
+ modifierFlags: event.modifierFlags
+ )
+ }
+
+ public init() {
+ self.init(
+ state: .ended,
+ stage: 0,
+ stageTransition: 0.0,
+ pressure: 0.0,
+ pressureBehavior: .primaryDefault,
+ modifierFlags: []
+ )
+ }
+ }
+}
+
+// MARK: - Force Touch
+
+/// A force touch recognizer.
+///
+/// On devices with force touch trackpads (e.g., MacBook Pros), this view can be regularly triggered by force touch
+/// gestures.
+/// As an alternative for devices without force touch support, this view can also be triggered through long press
+/// gestures.
+///
+/// However, the delegation of long press can automatically happen after failing to receive a force touch event after
+/// a delay of **`threshold + 0.1` seconds,** even on devices that support force touch.
+///
+/// While long pressing, the ``ForceTouchGesture/Event/pressure`` will be increased by `0.1` every `0.1`
+/// seconds, and the ``ForceTouchGesture/Event/stage`` will be increased by `1` every time the
+/// ``ForceTouchGesture/Event/pressure`` overflows.
+public struct ForceTouch: NSViewRepresentable where Content: View {
+ private let configuration: NSPressureConfiguration
+ private let threshold: CGFloat
+ @Binding private var gesture: ForceTouchGesture
+
+ @ViewBuilder private var content: () -> Content
+
+ @State private var timestamp: Date?
+ @State private var state: NSPressGestureRecognizer.State = .ended
+
+ @State private var longPressTimer: Timer?
+
+ private let id = UUID()
+
+ /// Initializes a ``ForceTouch``.
+ ///
+ /// - Parameters:
+ /// - configuration: the `NSPressureConfiguration` that configures the force touch behavior.
+ /// - threshold: the minimum threshold before emitting the first gesture event.
+ /// As force touch gestures have many stages, this only applies to the first stage.
+ /// - gesture: the binding for the emitted ``ForceTouchGesture``.
+ /// This binding is get-only.
+ /// - content: the content to be force touched.
+ public init(
+ configuration: NSPressureConfiguration = .init(pressureBehavior: .primaryDefault),
+ threshold: CGFloat = 0.5,
+ gesture: Binding,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.configuration = configuration
+ self.threshold = threshold
+ self._gesture = gesture
+ self.content = content
+ }
+
+ public func makeNSView(context _: Context) -> NSView {
+ let view = NSHostingView(
+ rootView: content()
+ )
+ view.translatesAutoresizingMaskIntoConstraints = false
+
+ let recognizer = ForceTouchGestureRecognizer(
+ configuration
+ ) { state in
+ self.state = state
+
+ switch state {
+ case .began:
+ timestamp = .now
+ case .ended, .cancelled, .failed:
+ timestamp = nil
+ gesture = .inactive
+ default:
+ break
+ }
+ } onPressureChange: { event in
+ terminateLongPressDelegate()
+
+ let isValid = event.stage > 0
+ let isFirstStage = event.stage == 1
+ let isOverThreshold = CGFloat(event.pressure) >= threshold
+
+ gesture = if isValid, !isFirstStage || isOverThreshold {
+ .active(ForceTouchGesture.Event(state, event: event))
+ } else {
+ .inactive
+ }
+ }
+
+ EventMonitorManager.shared.addLocalMonitor(
+ for: id,
+ matching: [
+ .leftMouseDown,
+ .leftMouseUp,
+ .mouseMoved,
+ .mouseExited
+ ]
+ ) { event in
+ let locationInView = view.convert(event.locationInWindow, from: nil)
+ guard view.bounds.contains(locationInView) else { return event }
+
+ switch event.type {
+ case .leftMouseDown:
+ prepareLongPressDelegate(event)
+ case .leftMouseUp, .mouseMoved, .mouseExited:
+ terminateLongPressDelegate()
+ timestamp = nil
+ gesture = .inactive
+ default:
+ break
+ }
+ return event
+ }
+
+ recognizer.allowedTouchTypes = .direct // Enables pressure-sensitive events
+ view.addGestureRecognizer(recognizer)
+ return view
+ }
+
+ public func updateNSView(_: NSView, context _: Context) {}
+
+ private func prepareLongPressDelegate(_ event: NSEvent) {
+ let modifierFlags = event.modifierFlags
+ var event = ForceTouchGesture.Event()
+ event.modifierFlags = modifierFlags
+
+ longPressTimer = .scheduledTimer(withTimeInterval: threshold + 0.1, repeats: false) { _ in
+ timestamp = .now
+ event.stage = 1
+
+ longPressTimer = .scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
+ let pressure = event.pressure + 0.1
+ let isOverflowing = pressure > 1
+
+ event.pressure = pressure.truncatingRemainder(dividingBy: 1)
+ if isOverflowing {
+ event.stage += 1
+ }
+
+ gesture = .active(event)
+ }
+ }
+ }
+
+ private func terminateLongPressDelegate() {
+ longPressTimer?.invalidate()
+ longPressTimer = nil
+ }
+}
+
+// MARK: - Force Touch Gesture Recognizer
+
+class ForceTouchGestureRecognizer: NSPressGestureRecognizer {
+ private let onStateChange: (NSPressGestureRecognizer.State) -> ()
+ private let onPressureChange: (NSEvent) -> ()
+
+ init(
+ _ configuration: NSPressureConfiguration,
+ onStateChange: @escaping (NSPressGestureRecognizer.State) -> (),
+ onPressureChange: @escaping (NSEvent) -> ()
+ ) {
+ self.onStateChange = onStateChange
+ self.onPressureChange = onPressureChange
+
+ super.init(target: nil, action: nil)
+ self.pressureConfiguration = configuration
+ self.target = self
+ self.action = #selector(handlePressureChange)
+ }
+
+ @available(*, unavailable)
+ required init?(coder _: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @objc private func handlePressureChange(_ gesture: NSPressGestureRecognizer) {
+ onStateChange(gesture.state)
+ }
+
+ override func pressureChange(with event: NSEvent) {
+ onPressureChange(event)
+ }
+}
+
+// MARK: - Preview
+
+private struct ForceTouchPreview: View where Content: View {
+ let threshold: CGFloat = 0.5
+ @State var gesture: ForceTouchGesture = .inactive
+ @ViewBuilder let content: () -> Content
+
+ var body: some View {
+ ForceTouch(threshold: threshold, gesture: $gesture, content: content)
+ .onChange(of: gesture) { gesture in
+ print(gesture)
+ }
+ .background {
+ switch gesture {
+ case .inactive:
+ Color.clear
+ case let .active(event):
+ Color.red.opacity(event.pressure)
+ }
+ }
+ }
+}
+
+#Preview {
+ ForceTouchPreview {
+ Text("Touch me!")
+ .padding()
+ }
+}
diff --git a/Sources/Luminare/Utilities/ScreenView.swift b/Sources/Luminare/Components/Auxiliary/Utility Views/ScreenView.swift
similarity index 57%
rename from Sources/Luminare/Utilities/ScreenView.swift
rename to Sources/Luminare/Components/Auxiliary/Utility Views/ScreenView.swift
index 443e0b5..9c96468 100644
--- a/Sources/Luminare/Utilities/ScreenView.swift
+++ b/Sources/Luminare/Components/Auxiliary/Utility Views/ScreenView.swift
@@ -1,6 +1,6 @@
//
// ScreenView.swift
-// Luminare Tester
+// Luminare
//
// Created by Kai Azim on 2024-04-14.
//
@@ -8,8 +8,12 @@
import SwiftUI
public struct ScreenView: View where Content: View {
- @Binding var blurred: Bool
- let screenContent: () -> Content
+ @Environment(\.luminareTint) private var tint
+ @Environment(\.luminareAnimationFast) private var animationFast
+
+ @Binding var isBlurred: Bool
+ let content: () -> Content
+
@State private var image: NSImage?
private let screenShape = UnevenRoundedRectangle(
@@ -19,23 +23,26 @@ public struct ScreenView: View where Content: View {
topTrailingRadius: 12
)
- public init(blurred: Binding = .constant(false), @ViewBuilder _ screenContent: @escaping () -> Content) {
- self._blurred = blurred
- self.screenContent = screenContent
+ public init(
+ isBlurred: Binding = .constant(false),
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self._isBlurred = isBlurred
+ self.content = content
}
public var body: some View {
ZStack {
- GeometryReader { geo in
+ GeometryReader { proxy in
if let image {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
- .frame(width: geo.size.width, height: geo.size.height)
- .blur(radius: blurred ? 10 : 0)
- .opacity(blurred ? 0.5 : 1)
+ .frame(width: proxy.size.width, height: proxy.size.height)
+ .blur(radius: isBlurred ? 10 : 0)
+ .opacity(isBlurred ? 0.5 : 1)
} else {
- LuminareConstants.tint()
+ tint
.opacity(0.1)
}
}
@@ -48,7 +55,7 @@ public struct ScreenView: View where Content: View {
}
}
.overlay {
- screenContent()
+ content()
.padding(5)
}
.clipShape(screenShape)
@@ -77,32 +84,9 @@ public struct ScreenView: View where Content: View {
}
if let newImage = NSImage.resize(url, width: 300) {
- await withAnimation(LuminareConstants.fastAnimation) {
+ withAnimation(animationFast) {
image = newImage
}
}
}
}
-
-extension NSImage {
- static func resize(_ url: URL, width: CGFloat) -> NSImage? {
- guard let inputImage = NSImage(contentsOf: url) else { return nil }
- let aspectRatio = inputImage.size.width / inputImage.size.height
- let thumbSize = NSSize(
- width: width,
- height: width / aspectRatio
- )
-
- let outputImage = NSImage(size: thumbSize)
- outputImage.lockFocus()
- inputImage.draw(
- in: NSRect(origin: .zero, size: thumbSize),
- from: .zero,
- operation: .sourceOver,
- fraction: 1
- )
- outputImage.unlockFocus()
-
- return outputImage
- }
-}
diff --git a/Sources/Luminare/Utilities/VisualEffectView.swift b/Sources/Luminare/Components/Auxiliary/Utility Views/VisualEffectView.swift
similarity index 98%
rename from Sources/Luminare/Utilities/VisualEffectView.swift
rename to Sources/Luminare/Components/Auxiliary/Utility Views/VisualEffectView.swift
index bdbb779..54ae172 100644
--- a/Sources/Luminare/Utilities/VisualEffectView.swift
+++ b/Sources/Luminare/Components/Auxiliary/Utility Views/VisualEffectView.swift
@@ -1,6 +1,6 @@
//
// VisualEffectView.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-04-01.
//
diff --git a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift
index 27be997..188f540 100644
--- a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift
+++ b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift
@@ -1,20 +1,31 @@
//
// ColorHueSliderView.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-05-15.
//
import SwiftUI
+// MARK: - Color Hue Slider
+
struct ColorHueSliderView: View {
- @Binding var selectedColor: Color
+ // MARK: Environments
+
+ @Environment(\.luminareAnimation) private var animation
+
+ // MARK: Fields
+
+ @Binding var selectedColor: HSBColor
+ var roundedTop: Bool = false
+ var roundedBottom: Bool = false
+
@State private var selectionPosition: CGFloat = 0
@State private var selectionOffset: CGFloat = 0
@State private var selectionCornerRadius: CGFloat = 0
@State private var selectionWidth: CGFloat = 0
- // Gradient for the color spectrum slider
+ // gradient for the color spectrum slider
private let colorSpectrumGradient = Gradient(
colors: stride(from: 0.0, through: 1.0, by: 0.01)
.map {
@@ -22,9 +33,7 @@ struct ColorHueSliderView: View {
}
)
- init(selectedColor: Binding) {
- self._selectedColor = selectedColor
- }
+ // MARK: Body
var body: some View {
GeometryReader { geo in
@@ -35,22 +44,28 @@ struct ColorHueSliderView: View {
endPoint: .trailing
)
+ let leadingCornerRadius = selectionOffset < (geo.size.width / 2) ? selectionCornerRadius : 2
+ let trailingCornerRadius = selectionOffset > (geo.size.width / 2) ? selectionCornerRadius : 2
+
UnevenRoundedRectangle(
- topLeadingRadius: 2,
- bottomLeadingRadius: selectionOffset < (geo.size.width / 2) ? selectionCornerRadius : 2,
- bottomTrailingRadius: selectionOffset > (geo.size.width / 2) ? selectionCornerRadius : 2,
- topTrailingRadius: 2
+ topLeadingRadius: roundedTop ? leadingCornerRadius : 2,
+ bottomLeadingRadius: roundedBottom ? leadingCornerRadius : 2,
+ bottomTrailingRadius: roundedBottom ? trailingCornerRadius : 2,
+ topTrailingRadius: roundedTop ? trailingCornerRadius : 2
)
.frame(width: selectionWidth, height: 12.5)
.padding(.bottom, 0.5)
.offset(x: selectionOffset, y: 0)
.foregroundColor(.white)
.shadow(radius: 3)
- .onChange(of: selectionPosition) { _ in
- withAnimation(LuminareConstants.animation) {
- selectionOffset = calculateOffset(handleWidth: handleWidth(at: selectionPosition, geo.size.width), geo.size.width)
- selectionWidth = handleWidth(at: selectionPosition, geo.size.width)
- selectionCornerRadius = handleCornerRadius(at: selectionPosition, geo.size.width)
+ .onChange(of: selectionPosition) { position in
+ withAnimation(animation) {
+ selectionOffset = calculateOffset(
+ handleWidth: handleWidth(at: position, geo.size.width),
+ geo.size.width
+ )
+ selectionWidth = handleWidth(at: position, geo.size.width)
+ selectionCornerRadius = handleCornerRadius(at: position, geo.size.width)
}
}
}
@@ -61,33 +76,36 @@ struct ColorHueSliderView: View {
}
)
.onAppear {
- selectionPosition = selectedColor.toHSB().hue * geo.size.width
- selectionOffset = calculateOffset(handleWidth: handleWidth(at: selectionPosition, geo.size.width), geo.size.width)
+ selectionPosition = selectedColor.hue * geo.size.width
+ selectionOffset = calculateOffset(
+ handleWidth: handleWidth(at: selectionPosition, geo.size.width),
+ geo.size.width
+ )
selectionWidth = handleWidth(at: selectionPosition, geo.size.width)
selectionCornerRadius = handleCornerRadius(at: selectionPosition, geo.size.width)
}
+ .onChange(of: selectedColor) { color in
+ selectionPosition = color.hue * geo.size.width
+ }
}
.frame(height: 16)
}
+ // MARK: Functions
+
private func handleDragChange(_ value: DragGesture.Value, _ viewSize: CGFloat) {
let lastPercentage = selectionPosition / viewSize
let clampedX = max(5.5, min(value.location.x, viewSize - 5.5))
selectionPosition = clampedX
let percentage = selectionPosition / viewSize
- let currenthsb = selectedColor.toHSB()
if percentage != lastPercentage, percentage == 5.5 / viewSize || percentage == (viewSize - 5.5) / viewSize {
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
}
- withAnimation(LuminareConstants.animation) {
- selectedColor = Color(
- hue: percentage,
- saturation: max(0.0001, currenthsb.saturation),
- brightness: currenthsb.brightness
- )
+ withAnimation(animation) {
+ selectedColor.hue = percentage
}
}
@@ -109,3 +127,15 @@ struct ColorHueSliderView: View {
return 15 * edgeFactor
}
}
+
+// MARK: - Preview
+
+@available(macOS 15.0, *)
+#Preview("ColorHueSliderView") {
+ @Previewable @State var color: HSBColor = Color.accentColor.hsb
+
+ LuminareSection {
+ ColorHueSliderView(selectedColor: $color, roundedTop: true, roundedBottom: true)
+ }
+ .padding()
+}
diff --git a/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift b/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift
index 1146637..7f0fb50 100644
--- a/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift
+++ b/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift
@@ -1,28 +1,58 @@
//
// ColorPickerModalView.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-05-15.
//
import SwiftUI
-// View for the color popup as a whole
+// MARK: - Color Picker (Modal)
+
struct ColorPickerModalView: View {
- @Binding var color: Color
+ // MARK: Environments
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.luminareAnimationFast) private var animationFast
+ @Environment(\.luminareColorPickerHasCancel) private var hasCancel
+ @Environment(\.luminareColorPickerHasDone) private var hasDone
+
+ // MARK: Fields
+
+ @Binding var selectedColor: Color
@Binding var hexColor: String
- @State private var redComponent: Double = 0
- @State private var greenComponent: Double = 0
- @State private var blueComponent: Double = 0
- let colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey)
+ var hasColorPicker: Bool = true
+
+ @State private var initialColor: Color = .black
+
+ @State private var redComponent: Double = .zero
+ @State private var greenComponent: Double = .zero
+ @State private var blueComponent: Double = .zero
+
+ @State private var isRedStepperPresented: Bool = false
+ @State private var isGreenStepperPresented: Bool = false
+ @State private var isBlueStepperPresented: Bool = false
+
+ @State private var hueFallback: Double = .zero
+
+ private let colorSampler = NSColorSampler()
+
+ // MARK: Body
- // Main view containing all components of the color picker
var body: some View {
Group {
- LuminareSection(disablePadding: true, showDividers: false) {
+ LuminareSection(hasPadding: false) {
VStack(spacing: 2) {
- ColorSaturationBrightnessView(selectedColor: $color)
+ let color = Binding {
+ internalHSBColor
+ } set: { newValue in
+ hueFallback = newValue.hue
+ updateComponents(newValue.rgb)
+ selectedColor = newValue.rgb
+ }
+
+ ColorSaturationBrightnessView(selectedColor: color)
.scaledToFill()
.clipShape(
UnevenRoundedRectangle(
@@ -33,7 +63,7 @@ struct ColorPickerModalView: View {
)
)
- ColorHueSliderView(selectedColor: $color)
+ ColorHueSliderView(selectedColor: color, roundedBottom: true)
.scaledToFill()
.clipShape(
UnevenRoundedRectangle(
@@ -47,58 +77,176 @@ struct ColorPickerModalView: View {
.padding(4)
}
- RGBInputFields
+ rgbInputFields()
+
+ controls()
}
.onAppear {
- updateComponents(newValue: color)
+ updateComponents(selectedColor)
+ initialColor = selectedColor
+ }
+ .onChange(of: selectedColor) { color in
+ updateComponents(color)
}
- .onChange(of: color) { _ in
- updateComponents(newValue: color)
+ .onChange(of: internalHSBColor) { newValue in
+ selectedColor = newValue.rgb
}
+ .animation(animationFast, value: internalHSBColor)
}
- // View for RGB input fields
- private var RGBInputFields: some View {
- HStack(spacing: 8) {
- RGBInputField(label: colorNames.red, value: $redComponent)
- .onChange(of: redComponent) { _ in
- setColor(updateColorFromRGB())
- }
+ private var internalHSBColor: HSBColor {
+ let hsb = Color(red: redComponent / 255.0, green: greenComponent / 255.0, blue: blueComponent / 255.0).hsb
+
+ if hsb.saturation == 0 || hsb.brightness == 0 {
+ // Preserve hue
+ return .init(
+ hue: hueFallback,
+ saturation: hsb.saturation,
+ brightness: hsb.brightness
+ )
+ } else {
+ hueFallback = hsb.hue
+ return hsb
+ }
+ }
+
+ private var hasControls: Bool {
+ hasCancel || hasDone
+ }
+
+ private var hasCancelAndDone: Bool {
+ hasCancel && hasDone
+ }
+
+ @ViewBuilder private func rgbInputFields() -> some View {
+ HStack(alignment: .bottom, spacing: 4) {
+ RGBInputField(value: $redComponent) {
+ Text("Red")
+ } color: { value in
+ .init(
+ red: value / 255.0,
+ green: greenComponent / 255.0,
+ blue: blueComponent / 255.0
+ )
+ }
- RGBInputField(label: colorNames.green, value: $greenComponent)
- .onChange(of: greenComponent) { _ in
- setColor(updateColorFromRGB())
+ RGBInputField(value: $greenComponent) {
+ Text("Blue")
+ } color: { value in
+ .init(
+ red: redComponent / 255.0,
+ green: value / 255.0,
+ blue: blueComponent / 255.0
+ )
+ }
+
+ RGBInputField(value: $blueComponent) {
+ Text("Green")
+ } color: { value in
+ .init(
+ red: redComponent / 255.0,
+ green: greenComponent / 255.0,
+ blue: value / 255.0
+ )
+ }
+
+ // Display color picker inline with RGB input fields
+ if (!hasControls && hasColorPicker) || hasCancelAndDone {
+ colorPicker()
+ }
+ }
+ .luminareAspectRatio(contentMode: .fill)
+ }
+
+ @ViewBuilder private func controls() -> some View {
+ if hasControls {
+ HStack(spacing: 4) {
+ // Display color picker inline with controls
+ if !hasCancelAndDone, hasColorPicker {
+ colorPicker()
}
- RGBInputField(label: colorNames.blue, value: $blueComponent)
- .onChange(of: blueComponent) { _ in
- setColor(updateColorFromRGB())
+ Group {
+ if hasCancel {
+ Button("Cancel") {
+ // Revert selected color
+ selectedColor = initialColor
+ dismiss()
+ }
+ .foregroundStyle(.red)
+ }
+
+ if hasDone {
+ Button("Done") {
+ selectedColor = internalHSBColor.rgb
+ initialColor = selectedColor
+ dismiss()
+ }
+ }
}
+ .buttonStyle(.luminareCompact)
+ .luminareAspectRatio(contentMode: .fill)
+ }
}
}
- // Set the color based on the source of change
- private func setColor(_ newColor: Color) {
- withAnimation(LuminareConstants.fastAnimation) {
- color = newColor
+ @ViewBuilder private func colorPicker() -> some View {
+ Button {
+ colorSampler.show { nsColor in
+ if let nsColor {
+ updateComponents(Color(nsColor: nsColor))
+ }
+ }
+ } label: {
+ Image(systemName: "eyedropper.halffull")
}
+ .luminareAspectRatio(1 / 1, contentMode: .fit)
+ .buttonStyle(.luminareCompact)
}
- // Update the color from RGB components
- private func updateColorFromRGB() -> Color {
- Color(
- red: redComponent / 255.0,
- green: greenComponent / 255.0,
- blue: blueComponent / 255.0
- )
+ // MARK: Functions
+
+ private func updateComponents(_ color: Color) {
+ // Check if changed externally
+ guard color != .init(hsb: internalHSBColor) else { return }
+
+ hexColor = color.toHex()
+
+ let components = color.components
+ redComponent = components.red * 255.0
+ greenComponent = components.green * 255.0
+ blueComponent = components.blue * 255.0
}
+}
+
+// MARK: - Preview
- // Update components when the color changes
- private func updateComponents(newValue: Color) {
- hexColor = newValue.toHex()
- let rgb = newValue.toRGB()
- redComponent = rgb.red
- greenComponent = rgb.green
- blueComponent = rgb.blue
+@available(macOS 15.0, *)
+#Preview(
+ "ColorPickerModalView",
+ traits: .sizeThatFitsLayout
+) {
+ @Previewable @FocusState var isFocused: Bool
+
+ @Previewable @State var color = Color.accentColor
+ @Previewable @State var hexColor = ""
+
+// color.frame(width: 50, height: 50)
+
+ VStack {
+ ColorPickerModalView(
+ selectedColor: $color,
+ hexColor: $hexColor
+ )
+ .luminareColorPickerControls(hasDone: true)
+ .focusable()
+ .focusEffectDisabled()
+ .focused($isFocused)
+ .onAppear {
+ isFocused = true
+ }
}
+ .frame(width: 260)
+ .fixedSize()
+// .foregroundStyle(color)
}
diff --git a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift
index f145bc7..873d25b 100644
--- a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift
+++ b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift
@@ -1,28 +1,35 @@
//
// ColorSaturationBrightnessView.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-05-15.
//
import SwiftUI
-// View for adjusting the lightness of a selected color
+// MARK: - Color Saturation Brightness
+
struct ColorSaturationBrightnessView: View {
- @Binding var selectedColor: Color
+ // MARK: Environments
+
+ @Environment(\.luminareAnimation) private var animation
+
+ // MARK: Fields
+
+ @Binding var selectedColor: HSBColor
@State private var circlePosition: CGPoint = .zero
- @State private var originalHue: CGFloat = 0
- @State private var originalSaturation: CGFloat = 0
@State private var isDragging: Bool = false
private let circleSize: CGFloat = 12
+ // MARK: Body
+
var body: some View {
GeometryReader { geo in
ZStack {
Color(
- hue: originalHue,
+ hue: selectedColor.hue,
saturation: 1,
brightness: 1
)
@@ -58,22 +65,18 @@ struct ColorSaturationBrightnessView: View {
)
.frame(width: geo.size.width, height: geo.size.width)
.onAppear {
- let hsb = selectedColor.toHSB()
- originalHue = hsb.hue
- originalSaturation = hsb.saturation
updateCirclePosition(geo.size)
}
.onChange(of: selectedColor) { _ in
if !isDragging {
- let hsb = selectedColor.toHSB()
- originalHue = hsb.hue
- originalSaturation = hsb.saturation
updateCirclePosition(geo.size)
}
}
}
}
+ // MARK: Functions
+
// Update the position of the circle based on user interaction
private func updateColor(_ location: CGPoint, _ viewSize: CGSize) {
let adjustedX = max(0, min(location.x, viewSize.width))
@@ -84,62 +87,73 @@ struct ColorSaturationBrightnessView: View {
let saturation = (adjustedX / viewSize.width)
let brightness = 1 - (adjustedY / viewSize.height)
- selectedColor = Color(
- hue: Double(originalHue),
- saturation: Double(saturation),
- brightness: Double(max(0.0001, brightness))
- )
+ selectedColor.saturation = Double(saturation)
+ selectedColor.brightness = Double(brightness)
}
- withAnimation(LuminareConstants.animation) {
+ withAnimation(animation) {
updateCirclePosition(viewSize)
}
}
// Initialize the position of the circle based on the current color
private func updateCirclePosition(_ viewSize: CGSize) {
- let hsb = selectedColor.toHSB()
-
- if hsb.saturation <= 0.0001 {
+ if selectedColor.saturation <= 0.0001 {
circlePosition = CGPoint(
x: .zero,
- y: (1 - CGFloat(hsb.brightness)) * viewSize.height
+ y: (1 - CGFloat(selectedColor.brightness)) * viewSize.height
)
} else {
circlePosition = CGPoint(
- x: CGFloat(hsb.saturation) * viewSize.width,
- y: (1 - CGFloat(hsb.brightness)) * viewSize.height
+ x: CGFloat(selectedColor.saturation) * viewSize.width,
+ y: (1 - CGFloat(selectedColor.brightness)) * viewSize.height
)
}
}
}
+// MARK: - Color Picker Circle
+
struct ColorPickerCircle: View {
- @Binding var selectedColor: Color
+ // MARK: Environments
+
+ @Environment(\.luminareAnimation) private var animation
+
+ // MARK: Fields
+
+ @Binding var selectedColor: HSBColor
@Binding var isDragging: Bool
+ var circleSize: CGFloat
@State private var isHovering: Bool = false
- private let circleSize: CGFloat
- init(selectedColor: Binding, isDragging: Binding, circleSize: CGFloat) {
- self._selectedColor = selectedColor
- self._isDragging = isDragging
- self.circleSize = circleSize
- }
+ // MARK: Body
var body: some View {
Circle()
.frame(width: circleSize, height: circleSize)
- .foregroundColor(selectedColor)
+ .foregroundColor(selectedColor.rgb)
.background {
Circle()
.stroke(.white, lineWidth: 6)
}
.shadow(radius: 3)
- .scaleEffect((isHovering && !isDragging) ? 1.25 : 1.0)
+ .scaleEffect((isHovering || isDragging) ? 1.25 : 1.0)
.onHover { hovering in
isHovering = hovering
}
- .animation(LuminareConstants.animation, value: [isHovering, isDragging])
+ .animation(animation, value: [isHovering, isDragging])
+ }
+}
+
+// MARK: - Preview
+
+@available(macOS 15.0, *)
+#Preview("ColorSaturationBrightnessView") {
+ @Previewable @State var color: HSBColor = Color.accentColor.hsb
+
+ LuminareSection {
+ ColorSaturationBrightnessView(selectedColor: $color)
}
+ .padding()
}
diff --git a/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift b/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift
index fb1aa1c..3e5ab68 100644
--- a/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift
+++ b/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift
@@ -1,64 +1,177 @@
//
// LuminareColorPicker.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-05-13.
//
import SwiftUI
-public struct LuminareColorPicker: View {
- @Binding var currentColor: Color
+/// The style of a ``LuminareColorPicker``.
+public struct LuminareColorPickerStyle
+ where F: ParseableFormatStyle, F.FormatInput == String, F.FormatOutput == String {
+ let format: F?
+ let hasColorWell: Bool
+
+ /// Has a color well that can present a color picker modal.
+ public static func colorWell() -> Self where F == StringFormatStyle {
+ .init(format: nil, hasColorWell: true)
+ }
+
+ /// Has a text field with a custom format.
+ ///
+ /// - Parameters:
+ /// - parseStrategy: the ``StringFormatStyle/Strategy`` that specifies how the hex string will be formatted.
+ public static func textField(
+ format: F
+ ) -> Self where F == StringFormatStyle {
+ .init(format: format, hasColorWell: false)
+ }
+
+ /// Has a text field with a hex format strategy.
+ ///
+ /// - Parameters:
+ /// - parseStrategy: the ``StringFormatStyle/Strategy`` that specifies how the hex string will be formatted.
+ public static func textField(
+ parseStrategy: StringFormatStyle.Strategy = .hex(.lowercasedWithWell)
+ ) -> Self where F == StringFormatStyle {
+ .textField(format: .init(parseStrategy: parseStrategy))
+ }
+
+ /// Has both a text field with a custom format and a color well.
+ ///
+ /// - Parameters:
+ /// - format: the `ParseableFormatStyle` to parse the color string.
+ public static func textFieldWithColorWell(
+ format: F
+ ) -> Self {
+ .init(format: format, hasColorWell: true)
+ }
+
+ /// Has both a text field with a hex format strategy and a color well.
+ ///
+ /// - Parameters:
+ /// - parseStrategy: the ``StringFormatStyle/Strategy`` that specifies how the hex string will be formatted.
+ public static func textFieldWithColorWell(
+ parseStrategy: StringFormatStyle.Strategy = .hex(.lowercasedWithWell)
+ ) -> Self where F == StringFormatStyle {
+ .textFieldWithColorWell(format: .init(parseStrategy: parseStrategy))
+ }
+}
+
+// MARK: - Color Picker
+
+/// A stylized color picker.
+public struct LuminareColorPicker: View
+ where F: ParseableFormatStyle, F.FormatInput == String, F.FormatOutput == String {
+ public typealias Style = LuminareColorPickerStyle
+
+ // MARK: Environments
+
+ @Environment(\.luminareCompactButtonCornerRadii) private var cornerRadii
+
+ // MARK: Fields
+
+ @Binding var color: Color
+
+ private let style: Style
@State private var text: String
- @State private var showColorPicker = false
- let colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey)
- let formatStrategy: StringFormatStyle.HexStrategy
+ @State private var isColorPickerPresented = false
+
+ // MARK: Initializers
+ /// Initializes a ``LuminareColorPicker``.
+ ///
+ /// - Parameters:
+ /// - color: the color to be edited.
+ /// - style: the ``LuminareColorPickerStyle`` that defines the style of the color picker.
public init(
- color: Binding, colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey),
- formatStrategy: StringFormatStyle.HexStrategy = .uppercasedWithWell
+ color: Binding,
+ style: Style
) {
- self._currentColor = color
+ self._color = color
self._text = State(initialValue: color.wrappedValue.toHex())
- self.colorNames = colorNames
- self.formatStrategy = formatStrategy
+ self.style = style
}
+ // MARK: Body
+
public var body: some View {
HStack {
- LuminareTextField(
- "Hex Color",
- value: .init($text),
- format: StringFormatStyle(parseStrategy: .hex(formatStrategy)),
- onSubmit: {
+ if let format = style.format {
+ LuminareTextField(
+ "Hex Color",
+ value: .init($text),
+ format: format
+ )
+ .onSubmit {
if let newColor = Color(hex: text) {
- currentColor = newColor
+ color = newColor
text = newColor.toHex()
} else {
- text = currentColor.toHex() // revert to last valid color
+ // Revert to last valid color
+ text = color.toHex()
}
}
- )
- .modifier(LuminareBordered())
-
- Button {
- showColorPicker.toggle()
- } label: {
- RoundedRectangle(cornerRadius: 4)
- .foregroundStyle(currentColor)
- .frame(width: 26, height: 26)
- .padding(4)
- .modifier(LuminareBordered())
}
- .buttonStyle(PlainButtonStyle())
- .luminareModal(isPresented: $showColorPicker, closeOnDefocus: true, isCompact: true) {
- ColorPickerModalView(color: $currentColor, hexColor: $text, colorNames: colorNames)
- .frame(width: 280)
+
+ if style.hasColorWell {
+ Button {
+ isColorPickerPresented.toggle()
+ } label: {
+ UnevenRoundedRectangle(cornerRadii: cornerRadii.map { max(0, $0 - 4) })
+ .foregroundStyle(color)
+ .padding(4)
+ }
+ .buttonStyle(.luminareCompact)
+ .luminareHorizontalPadding(0)
+ .luminareAspectRatio(1 / 1, contentMode: .fit)
+ .luminareModalWithPredefinedSheetStyle(isPresented: $isColorPickerPresented) {
+ VStack {
+ ColorPickerModalView(
+ selectedColor: $color,
+ hexColor: $text
+ )
+ }
+ .frame(width: 260)
+ }
}
}
- .onChange(of: currentColor) { _ in
- text = currentColor.toHex()
+ .onChange(of: color) { _ in
+ text = color.toHex()
+ }
+ }
+}
+
+// MARK: - Previews
+
+// Preview as app
+@available(macOS 15.0, *)
+#Preview(
+ "LuminareColorPicker",
+ traits: .sizeThatFitsLayout
+) {
+ @Previewable @State var color: Color = .accentColor
+
+ VStack {
+ LuminareColorPicker(
+ color: $color,
+ style: .textFieldWithColorWell()
+ )
+
+ LuminareColorPicker(
+ color: $color,
+ style: .textFieldWithColorWell()
+ )
+ .luminareModalStyle(.popover)
+ .luminareModalContentWrapper { view in
+ view
+ .luminareAspectRatio(contentMode: .fit)
+ .monospaced(false)
}
}
+ .luminareAspectRatio(contentMode: .fill)
+ .monospaced()
+ .frame(width: 300)
}
diff --git a/Sources/Luminare/Components/Color Picker/RGBInputField.swift b/Sources/Luminare/Components/Color Picker/RGBInputField.swift
index 5adb191..4e5835f 100644
--- a/Sources/Luminare/Components/Color Picker/RGBInputField.swift
+++ b/Sources/Luminare/Components/Color Picker/RGBInputField.swift
@@ -1,35 +1,74 @@
//
// RGBInputField.swift
-//
+// Luminare
//
// Created by Kai Azim on 2024-05-15.
//
import SwiftUI
-// Custom input field for RGB values
-struct RGBInputField: View {
- var label: LocalizedStringKey
- @Binding var value: Double
+// MARK: - RGB Input Field
+
+struct RGBInputField