diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..dbca795 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,6 @@ +disabled_rules: + - nesting + - file_length + - function_body_length + - type_body_length + - large_tuple diff --git a/assets/LuminareButtonStyle.png b/Assets/LuminareButtonStyle.png similarity index 100% rename from assets/LuminareButtonStyle.png rename to Assets/LuminareButtonStyle.png diff --git a/assets/LuminareColorPicker.png b/Assets/LuminareColorPicker.png similarity index 100% rename from assets/LuminareColorPicker.png rename to Assets/LuminareColorPicker.png diff --git a/assets/LuminareCompactButtonStyle.png b/Assets/LuminareCompactButtonStyle.png similarity index 100% rename from assets/LuminareCompactButtonStyle.png rename to Assets/LuminareCompactButtonStyle.png diff --git a/assets/LuminareCosmeticButtonStyle.png b/Assets/LuminareCosmeticButtonStyle.png similarity index 100% rename from assets/LuminareCosmeticButtonStyle.png rename to Assets/LuminareCosmeticButtonStyle.png diff --git a/assets/LuminareDestructiveButtonStyle.png b/Assets/LuminareDestructiveButtonStyle.png similarity index 100% rename from assets/LuminareDestructiveButtonStyle.png rename to Assets/LuminareDestructiveButtonStyle.png diff --git a/assets/LuminareList.png b/Assets/LuminareList.png similarity index 100% rename from assets/LuminareList.png rename to Assets/LuminareList.png diff --git a/assets/LuminareModal.png b/Assets/LuminareModal.png similarity index 100% rename from assets/LuminareModal.png rename to Assets/LuminareModal.png diff --git a/assets/LuminarePicker.png b/Assets/LuminarePicker.png similarity index 100% rename from assets/LuminarePicker.png rename to Assets/LuminarePicker.png diff --git a/assets/LuminareSection.png b/Assets/LuminareSection.png similarity index 100% rename from assets/LuminareSection.png rename to Assets/LuminareSection.png diff --git a/assets/LuminareSliderPicker.png b/Assets/LuminareSliderPicker.png similarity index 100% rename from assets/LuminareSliderPicker.png rename to Assets/LuminareSliderPicker.png diff --git a/assets/LuminareTextField.png b/Assets/LuminareTextField.png similarity index 100% rename from assets/LuminareTextField.png rename to Assets/LuminareTextField.png diff --git a/assets/LuminareToggle.png b/Assets/LuminareToggle.png similarity index 100% rename from assets/LuminareToggle.png rename to Assets/LuminareToggle.png diff --git a/assets/LuminareValueAdjuster.png b/Assets/LuminareValueAdjuster.png similarity index 100% rename from assets/LuminareValueAdjuster.png rename to Assets/LuminareValueAdjuster.png diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..baafe41 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "ed31ca32dc2eaeeeac6cd0423ed78fa0a352bf7bc51e7550faa3ce07ce4c0d40", + "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" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 6f77c4b..d462a8d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,5 @@ // swift-tools-version: 5.10 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// the swift-tools-version declares the minimum version of Swift required to build this package import PackageDescription @@ -9,15 +9,18 @@ let package = Package( .macOS(.v13) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. + // 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") + ], 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. + // 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" ), 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/LuminareButtonStyle.swift b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyle.swift new file mode 100644 index 0000000..c999ea7 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyle.swift @@ -0,0 +1,513 @@ +// +// LuminareButtonStyle.swift +// +// +// Created by Kai Azim on 2024-04-02. +// + +import SwiftUI + +// MARK: - Button Style + +/// A stylized button style. +/// +/// ![LuminareButtonStyle](LuminareButtonStyle) +public struct LuminareButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.luminareAnimationFast) private var animationFast + + private let cornerRadius: CGFloat, minHeight: CGFloat + + @State private var isHovering: Bool + + /// Initializes a ``LuminareButtonStyle``. + /// + /// - Parameters: + /// - cornerRadius: the corner radius of the button. + /// - minHeight: the minimum height of the background. + public init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34 + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = false + } + + #if DEBUG + init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34, + isHovering: Bool = false + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = isHovering + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LuminareProminentButtonStyle.tintedBackgroundForState( + isPressed: configuration.isPressed, isEnabled: isEnabled, isHovering: isHovering, + styles: ( + .quaternary, .quaternary.opacity(0.7), .quinary + ) + ) + } + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .clipShape(.rect(cornerRadius: cornerRadius)) + .opacity(isEnabled ? 1 : 0.5) + } +} + +// MARK: - Button Style (Destructive) + +/// A stylized button style tinted in red, typically used for indicating a destructive action. +/// +/// ![LuminareDestructiveButtonStyle](LuminareDestructiveButtonStyle) +public struct LuminareDestructiveButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.luminareAnimationFast) private var animationFast + + private let cornerRadius: CGFloat, minHeight: CGFloat + + @State private var isHovering: Bool + + /// Initializes a ``LuminareDestructiveButtonStyle``. + /// + /// - Parameters: + /// - cornerRadius: the corner radius of the button. + /// - minHeight: the minimum height of the background. + public init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34 + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = false + } + + #if DEBUG + init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34, + isHovering: Bool = false + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = isHovering + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LuminareProminentButtonStyle.tintedBackgroundForState( + isPressed: configuration.isPressed, isEnabled: isEnabled, isHovering: isHovering, + layered: .red + ) + } + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .clipShape(.rect(cornerRadius: cornerRadius)) + .opacity(isEnabled ? 1 : 0.5) + } +} + +// 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 + + private let cornerRadius: CGFloat, minHeight: CGFloat + + @State private var isHovering: Bool + + /// Initializes a ``LuminareProminentButtonStyle``. + /// + /// - Parameters: + /// - cornerRadius: the corner radius of the button. + /// - minHeight: the minimum height of the background. + public init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34 + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = false + } + + #if DEBUG + init( + cornerRadius: CGFloat = 2, + minHeight: CGFloat = 34, + isHovering: Bool = false + ) { + self.cornerRadius = cornerRadius + self.minHeight = minHeight + self.isHovering = isHovering + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LuminareProminentButtonStyle.tintedBackgroundForState( + isPressed: configuration.isPressed, isEnabled: isEnabled, isHovering: isHovering, + layered: .tint + ) + } + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .clipShape(.rect(cornerRadius: cornerRadius)) + .opacity(isEnabled ? 1 : 0.5) + } + + @ViewBuilder static func tintedBackgroundForState( + isPressed: Bool, isEnabled: Bool, isHovering: Bool, + layered: some ShapeStyle + ) -> some View { + tintedBackgroundForState(isPressed: isPressed, isEnabled: isEnabled, isHovering: isHovering, styles: ( + layered.opacity(0.4), + layered.opacity(0.25), + layered.opacity(0.15) + )) + } + + @ViewBuilder static func tintedBackgroundForState( + isPressed: Bool, isEnabled: Bool, isHovering: Bool, + styles: (some ShapeStyle, some ShapeStyle, some ShapeStyle) + ) -> some View { + Group { + if isPressed, isEnabled { + Rectangle().foregroundStyle(styles.0) + } else if isHovering, isEnabled { + Rectangle().foregroundStyle(styles.1) + } else { + Rectangle().foregroundStyle(styles.2) + } + } + } +} + +// 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: Bool + @Environment(\.luminareAnimationFast) private var animationFast + + private let minHeight: CGFloat + private let cornerRadius: CGFloat + @ViewBuilder private let icon: () -> Image + + @State private var isHovering: Bool + + /// Initializes a ``LuminareCosmeticButtonStyle``. + /// + /// - Parameters: + /// - minHeight: the minimum height of the background. + /// - cornerRadius: the corner radius of the button. + /// - icon: the trailing aligned `Image` to display while hovering. + public init( + minHeight: CGFloat = 34, + cornerRadius: CGFloat = 2, + @ViewBuilder icon: @escaping () -> Image + ) { + self.minHeight = minHeight + self.cornerRadius = cornerRadius + self.icon = icon + self.isHovering = false + } + + #if DEBUG + init( + minHeight: CGFloat = 34, + cornerRadius: CGFloat = 2, + isHovering: Bool = false, + @ViewBuilder icon: @escaping () -> Image + ) { + self.minHeight = minHeight + self.cornerRadius = cornerRadius + self.icon = icon + self.isHovering = isHovering + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LuminareProminentButtonStyle.tintedBackgroundForState( + isPressed: configuration.isPressed, isEnabled: isEnabled, isHovering: isHovering, + styles: ( + .quaternary, .quaternary.opacity(0.7), .clear + ) + ) + } + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .clipShape(.rect(cornerRadius: cornerRadius)) + .opacity(isEnabled ? 1 : 0.5) + .overlay { + HStack { + Spacer() + + icon() + .opacity(isHovering ? 1 : 0) + } + .padding(24) + .allowsHitTesting(false) + } + } +} + +// 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: Bool + @Environment(\.luminareAnimationFast) private var animationFast + + private let extraCompact: Bool + private let minHeight: CGFloat + private let cornerRadius: CGFloat + + @State var isHovering: Bool + + /// Initializes a ``LuminareButtonStyle``. + /// + /// - Parameters: + /// - extraCompact: whether to eliminate the padding around the content. + /// - minHeight: the minimum height of the background. + /// - cornerRadius: the corner radius of the button. + public init( + extraCompact: Bool = false, + minHeight: CGFloat = 34, + cornerRadius: CGFloat = 8 + ) { + self.extraCompact = extraCompact + self.minHeight = minHeight + self.cornerRadius = cornerRadius + self.isHovering = false + } + + #if DEBUG + init( + extraCompact: Bool = false, + minHeight: CGFloat = 34, + cornerRadius: CGFloat = 8, + isHovering: Bool = false + ) { + self.extraCompact = extraCompact + self.minHeight = minHeight + self.cornerRadius = cornerRadius + self.isHovering = isHovering + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, extraCompact ? 0 : 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LuminareProminentButtonStyle.tintedBackgroundForState( + isPressed: configuration.isPressed, isEnabled: isEnabled, isHovering: isHovering, + styles: ( + .quaternary, .quaternary.opacity(0.7), .quinary + ) + ) + } + .background(border()) + .fixedSize(horizontal: extraCompact, vertical: extraCompact) + .clipShape(.rect(cornerRadius: cornerRadius)) + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .opacity(isEnabled ? 1 : 0.5) + } + + @ViewBuilder private func border() -> some View { + Group { + if isHovering { + RoundedRectangle(cornerRadius: cornerRadius).strokeBorder(.quaternary) + } else { + RoundedRectangle(cornerRadius: cornerRadius).strokeBorder(.quaternary.opacity(0.7)) + } + } + } +} + +// 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 { + private let isHighlighted: Bool + private let cornerRadius: CGFloat + + /// Initializes a ``LuminareBordered``. + /// + /// - Parameters: + /// - isHighlighted: whether to display a highlighted overlay. + /// - cornerRadius: the corner radius of the button. + public init( + isHighlighted: Bool = false, + cornerRadius: CGFloat = 8 + ) { + self.isHighlighted = isHighlighted + self.cornerRadius = cornerRadius + } + + public func body(content: Content) -> some View { + content + .background { + if isHighlighted { + Rectangle().foregroundStyle(.quaternary.opacity(0.7)) + } else { + Rectangle().foregroundStyle(.quinary) + } + } + .clipShape(.rect(cornerRadius: cornerRadius)) + .background { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(.quaternary) + } + } +} + +// 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(\.luminareAnimationFast) private var animationFast + + private let minHeight: CGFloat, horizontalPadding: CGFloat + private let cornerRadius: CGFloat + private let isBordered: Bool + + @State private var isHovering: Bool + + /// Initializes a ``LuminareHoverable``. + /// + /// - Parameters: + /// - minHeight: the minimum height of the background. + /// - horizontalPadding: the horizontal padding around the content. + /// - cornerRadius: the corner radius of the button. + /// - isBordered: whether to display a border while not hovering. + public init( + minHeight: CGFloat = 32, horizontalPadding: CGFloat = 8, + cornerRadius: CGFloat = 8, + isBordered: Bool = false + ) { + self.minHeight = minHeight + self.horizontalPadding = horizontalPadding + self.cornerRadius = cornerRadius + self.isBordered = isBordered + self.isHovering = false + } + + #if DEBUG + init( + minHeight: CGFloat = 32, horizontalPadding: CGFloat = 8, + cornerRadius: CGFloat = 8, + isBordered: Bool = false, + isHovering: Bool = false + ) { + self.minHeight = minHeight + self.horizontalPadding = horizontalPadding + self.cornerRadius = cornerRadius + self.isBordered = isBordered + self.isHovering = isHovering + } + #endif + + public func body(content: Content) -> some View { + content + .onHover { hover in + withAnimation(animationFast) { + isHovering = hover + } + } + .frame(minHeight: minHeight) + .padding(.horizontal, horizontalPadding) + .background { + if isHovering { + Rectangle() + .foregroundStyle(.quinary) + } else { + Rectangle() + .foregroundStyle(.clear) + } + } + .clipShape(.rect(cornerRadius: cornerRadius)) + .background { + if isHovering { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(.quaternary) + } else if isBordered { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(.quaternary.opacity(0.7)) + } + } + } +} diff --git a/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift b/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift new file mode 100644 index 0000000..f2cbb09 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/LuminareCroppedSectionItem.swift @@ -0,0 +1,81 @@ +// +// LuminareCroppedSectionItem.swift +// +// +// 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: Fields + + private let innerPadding: CGFloat + private let cornerRadius: CGFloat, buttonCornerRadius: CGFloat + private let isFirstChild: Bool, isLastChild: Bool + + // MARK: Initializers + + /// Initializes a ``LuminareCroppedItem``. + /// + /// - Parameters: + /// - innerPadding: the padding around the contents. + /// - cornerRadius: the radius of the corners. + /// - buttonCornerRadius: the corner radius of the button. + /// - 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, + cornerRadius: CGFloat = 12, buttonCornerRadius: CGFloat = 2, + isFirstChild: Bool, isLastChild: Bool + ) { + self.innerPadding = innerPadding + self.cornerRadius = cornerRadius + self.buttonCornerRadius = buttonCornerRadius + 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: cornerRadius - innerPadding, + bottomLeadingRadius: cornerRadius - innerPadding, + bottomTrailingRadius: cornerRadius - innerPadding, + topTrailingRadius: cornerRadius - innerPadding + ) + } else if isFirstChild { + UnevenRoundedRectangle( + topLeadingRadius: cornerRadius - innerPadding, + bottomLeadingRadius: buttonCornerRadius, + bottomTrailingRadius: buttonCornerRadius, + topTrailingRadius: cornerRadius - innerPadding + ) + } else if isLastChild { + UnevenRoundedRectangle( + topLeadingRadius: buttonCornerRadius, + bottomLeadingRadius: cornerRadius - innerPadding, + bottomTrailingRadius: cornerRadius - innerPadding, + topTrailingRadius: buttonCornerRadius + ) + } else { + UnevenRoundedRectangle( + topLeadingRadius: buttonCornerRadius, + bottomLeadingRadius: buttonCornerRadius, + bottomTrailingRadius: buttonCornerRadius, + topTrailingRadius: buttonCornerRadius + ) + } + } +} diff --git a/Sources/Luminare/Components/Auxiliary/LuminareSelectionData.swift b/Sources/Luminare/Components/Auxiliary/LuminareSelectionData.swift new file mode 100644 index 0000000..f9fa308 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/LuminareSelectionData.swift @@ -0,0 +1,30 @@ +// +// LuminareSelectionData.swift +// +// +// Created by KrLite on 2024/11/10. +// + +import SwiftUI + +/// The selection's behavior. +/// +/// Suitable for customizing selection appearance in certain views. +/// +/// - **Currently used in:** +/// - ``LuminareList`` +/// - ``LuminarePicker`` +public protocol LuminareSelectionData { + /// Whether this element is selectable. + var isSelectable: Bool { get } + + /// The selection color. + /// + /// If `nil`, the color will fallback to the `\.luminareTint` environment value. + var tint: Color? { get } +} + +public extension LuminareSelectionData { + var isSelectable: Bool { true } + var tint: Color? { nil } +} 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..d1eacc9 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/Scroll Views/AutoScrollView.swift @@ -0,0 +1,61 @@ +// +// AutoScrollView.swift +// +// +// 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 { + private let axes: Axis.Set + private let showsIndicators: Bool + @ViewBuilder private let 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(axes, showsIndicators: showsIndicators) { + content() + .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(isHorizontalScrollDisabled && isVerticalScrollDisabled) + } + + private var isHorizontalScrollDisabled: Bool { + guard axes.contains(.horizontal) else { return true } + return contentSize.width <= containerSize.width + } + + private var isVerticalScrollDisabled: Bool { + guard axes.contains(.vertical) else { return true } + return contentSize.height <= containerSize.height + } +} 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..506bdb0 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/Scroll Views/InfiniteScrollView.swift @@ -0,0 +1,579 @@ +// +// InfiniteScrollView.swift +// +// +// Created by KrLite on 2024/11/2. +// + +import AppKit +import SwiftUI + +/// The direction of an ``InfiniteScrollView``. +public enum InfiniteScrollViewDirection: Equatable { + /// The view can, and can only be scrolled horizontally. + case horizontal + /// The view can, and can only be scrolled vertically. + case vertical + + /// 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. + @Binding public var size: CGSize + /// the spacing between pages. + @Binding 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 when 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 monitor: Any? + private var draggingStage: DraggingStage = .invalid + + init(_ parent: InfiniteScrollView) { + self.parent = parent + } + + // swiftlint:disable:next cyclomatic_complexity + 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 { + // deduplicating + if let monitor { + NSEvent.removeMonitor(monitor) + } + + monitor = NSEvent.addLocalMonitorForEvents(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: .constant(size), + spacing: .constant(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..031c652 --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/Utility Views/DividedVStack.swift @@ -0,0 +1,126 @@ +// +// DividedVStack.swift +// +// +// Created by Kai Azim on 2024-04-02. +// +// Thanks to https://movingparts.io/variadic-views-in-swiftui + +import SwiftUI + +// 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 let 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 { + _VariadicView.Tree( + DividedVStackLayout( + spacing: isMasked ? spacing : 0, + isMasked: isMasked, + hasDividers: hasDividers + ) + ) { + content() + } + } +} + +// MARK: - Layouts + +struct DividedVStackLayout: _VariadicView_UnaryViewRoot { + let spacing: CGFloat + let innerPadding: CGFloat + let isMasked: Bool + let hasDividers: Bool + + init( + spacing: CGFloat?, + innerPadding: CGFloat = 4, + isMasked: Bool, + hasDividers: Bool + ) { + self.spacing = spacing ?? innerPadding + self.innerPadding = innerPadding + self.isMasked = isMasked + self.hasDividers = hasDividers + } + + @ViewBuilder + func body(children: _VariadicView.Children) -> 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..a50966f --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/Utility Views/ForceTouch.swift @@ -0,0 +1,270 @@ +// +// ForceTouch.swift +// +// +// 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 let content: () -> Content + + @State private var timestamp: Date? + @State private var state: NSPressGestureRecognizer.State = .ended + + @State private var longPressTimer: Timer? + @State private var monitor: Any? + + /// 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 + } + } + + monitor = NSEvent.addLocalMonitorForEvents(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 // enable 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..4f542a4 100644 --- a/Sources/Luminare/Utilities/ScreenView.swift +++ b/Sources/Luminare/Components/Auxiliary/Utility Views/ScreenView.swift @@ -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 100% rename from Sources/Luminare/Utilities/VisualEffectView.swift rename to Sources/Luminare/Components/Auxiliary/Utility Views/VisualEffectView.swift diff --git a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift index 27be997..388e3b6 100644 --- a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift @@ -7,14 +7,25 @@ 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..f5e62f5 100644 --- a/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift @@ -7,22 +7,49 @@ import SwiftUI -// View for the color popup as a whole -struct ColorPickerModalView: View { - @Binding var color: Color +public struct RGBColorNames where R: View, G: View, B: View { + @ViewBuilder public var red: () -> R + @ViewBuilder public var green: () -> G + @ViewBuilder public var blue: () -> B +} + +// MARK: - Color Picker (Modal) + +// view for the color popup as a whole +struct ColorPickerModalView: View where R: View, G: View, B: View, Done: View { + typealias ColorNames = RGBColorNames + + // MARK: Environments + + @Environment(\.dismiss) private var dismiss + @Environment(\.luminareAnimationFast) private var animationFast + + // MARK: Fields + + @Binding var selectedColor: HSBColor @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 colorNames: ColorNames + @ViewBuilder var done: () -> Done + + @State private var redComponent: Double = .zero + @State private var greenComponent: Double = .zero + @State private var blueComponent: Double = .zero - // Main view containing all components of the color picker + @State private var isRedStepperPresented: Bool = false + @State private var isGreenStepperPresented: Bool = false + @State private var isBlueStepperPresented: Bool = false + + 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, hasDividers: false) { VStack(spacing: 2) { - ColorSaturationBrightnessView(selectedColor: $color) + ColorSaturationBrightnessView(selectedColor: $selectedColor) .scaledToFill() .clipShape( UnevenRoundedRectangle( @@ -33,7 +60,7 @@ struct ColorPickerModalView: View { ) ) - ColorHueSliderView(selectedColor: $color) + ColorHueSliderView(selectedColor: $selectedColor, roundedBottom: true) .scaledToFill() .clipShape( UnevenRoundedRectangle( @@ -48,57 +75,130 @@ struct ColorPickerModalView: View { } RGBInputFields + + HStack { + Button { + colorSampler.show { nsColor in + if let nsColor { + selectedColor = Color(nsColor: nsColor).hsb + updateComponents(selectedColor) + } + } + } label: { + Image(systemName: "eyedropper.halffull") + .padding(-4) + } + .aspectRatio(1, contentMode: .fit) + .fixedSize() + .buttonStyle(LuminareCompactButtonStyle()) + + Button { + dismiss() + } label: { + done() + } + .buttonStyle(LuminareCompactButtonStyle()) + } } .onAppear { - updateComponents(newValue: color) + updateComponents(selectedColor) } - .onChange(of: color) { _ in - updateComponents(newValue: color) + .onChange(of: selectedColor) { color in + updateComponents(color) } + .onChange(of: internalColor) { color in + selectedColor = color + } + .animation(animationFast, value: selectedColor) } - // View for RGB input fields - private var RGBInputFields: some View { + // view for RGB input fields + @ViewBuilder private var RGBInputFields: some View { HStack(spacing: 8) { - RGBInputField(label: colorNames.red, value: $redComponent) - .onChange(of: redComponent) { _ in - setColor(updateColorFromRGB()) - } + RGBInputField(value: $redComponent) { + colorNames.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) { + colorNames.green() + } color: { value in + .init( + red: redComponent / 255.0, + green: value / 255.0, + blue: blueComponent / 255.0 + ) + } - RGBInputField(label: colorNames.blue, value: $blueComponent) - .onChange(of: blueComponent) { _ in - setColor(updateColorFromRGB()) - } + RGBInputField(value: $blueComponent) { + colorNames.blue() + } color: { value in + .init( + red: redComponent / 255.0, + green: greenComponent / 255.0, + blue: value / 255.0 + ) + } } } - // Set the color based on the source of change - private func setColor(_ newColor: Color) { - withAnimation(LuminareConstants.fastAnimation) { - color = newColor + private var internalColor: HSBColor { + let hsb = Color(red: redComponent / 255.0, green: greenComponent / 255.0, blue: blueComponent / 255.0).hsb + + return if hsb.saturation == 0 || hsb.brightness == 0 { + // preserve hue + .init(hue: selectedColor.hue, saturation: hsb.saturation, brightness: hsb.brightness) + } else { + hsb } } - // 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 + + // update components when the color changes + private func updateComponents(_ newValue: HSBColor) { + // check if changed externally + guard newValue != internalColor else { return } + + let rgb = newValue.rgb + hexColor = rgb.toHex() + + let components = rgb.components + redComponent = components.red * 255.0 + greenComponent = components.green * 255.0 + blueComponent = components.blue * 255.0 } +} - // 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 +// MARK: - Preview + +@available(macOS 15.0, *) +#Preview( + "ColorPickerModalView", + traits: .sizeThatFitsLayout +) { + @Previewable @State var color: HSBColor = Color.accentColor.hsb + @Previewable @State var hexColor = "" + + LuminareSection { + ColorPickerModalView( + selectedColor: $color, + hexColor: $hexColor, + colorNames: .init { + Text("Red") + } green: { + Text("Green") + } blue: { + Text("Blue") + } + ) { + Text("Done") + } } + .frame(width: 300) } diff --git a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift index f145bc7..b8d4d4e 100644 --- a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift @@ -7,22 +7,30 @@ import SwiftUI -// View for adjusting the lightness of a selected color +// MARK: - Color Saturation Brightness + +// view for adjusting the lightness of a selected color 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,88 +66,95 @@ 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) } } } } - // Update the position of the circle based on user interaction + // 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)) let adjustedY = max(0, min(location.y, viewSize.height)) - // Only adjust brightness if dragging, to avoid overwriting with white or black + // only adjust brightness if dragging, to avoid overwriting with white or black if isDragging { 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 + // 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..4145667 100644 --- a/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift +++ b/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift @@ -7,54 +7,211 @@ import SwiftUI -public struct LuminareColorPicker: View { +/// The style of a ``LuminareColorPicker``. +public struct LuminareColorPickerStyle + where F: ParseableFormatStyle, F.FormatInput == String, F.FormatOutput == String, + R: View, G: View, B: View, Done: View { + public typealias ColorNames = RGBColorNames + + struct ModalData { + var colorNames: ColorNames + @ViewBuilder var done: () -> Done + } + + let format: F? + let colorNamesAndDone: ModalData? + + /// Has a color well that can present a color picker modal. + /// + /// - Parameters: + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + /// - done: the **done** label inside the color picker modal. + public static func colorWell( + colorNames: ColorNames, + @ViewBuilder done: @escaping () -> Done + ) -> Self where F == StringFormatStyle { + .init(format: nil, colorNamesAndDone: .init(colorNames: colorNames, done: done)) + } + + /// Has a color well that can present a color picker modal, whose **done** label is a localized text. + /// + /// - Parameters: + /// - key: the `LocalizedStringKey` to look up the **done** label text. + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + public static func colorWell( + _ key: LocalizedStringKey, + colorNames: ColorNames + ) -> Self where F == StringFormatStyle, Done == Text { + .colorWell(colorNames: colorNames) { + Text(key) + } + } + + /// Has a text field with a custom format. + /// + /// - Parameters: + /// - format: the `ParseableFormatStyle` to parse the color string. + public static func textField(format: F) -> Self + where R == EmptyView, G == EmptyView, B == EmptyView, Done == EmptyView { + .init(format: format, colorNamesAndDone: nil) + } + + /// 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, R == EmptyView, G == EmptyView, B == EmptyView, Done == EmptyView { + .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. + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + /// - done: the **done** label inside the color picker modal. + public static func textFieldWithColorWell( + format: F, + colorNames: ColorNames, + @ViewBuilder done: @escaping () -> Done + ) -> Self { + .init(format: format, colorNamesAndDone: .init(colorNames: colorNames, done: done)) + } + + /// Has both a text field with a custom format and a color well, whose **done** label is a localized text. + /// + /// - Parameters: + /// - key: the `LocalizedStringKey` to look up the **done** label text. + /// - format: the `ParseableFormatStyle` to parse the color string. + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + public static func textFieldWithColorWell( + _ key: LocalizedStringKey, + format: F, + colorNames: ColorNames + ) -> Self where Done == Text { + .textFieldWithColorWell( + format: format, + colorNames: colorNames + ) { + Text(key) + } + } + + /// 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. + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + /// - done: the **done** label inside the color picker modal. + public static func textFieldWithColorWell( + parseStrategy: StringFormatStyle.Strategy = .hex(.lowercasedWithWell), + colorNames: ColorNames, + @ViewBuilder done: @escaping () -> Done + ) -> Self where F == StringFormatStyle { + .textFieldWithColorWell(format: .init(parseStrategy: parseStrategy), colorNames: colorNames, done: done) + } + + /// Has both a text field with a hex format strategy and a color well, whose **done** label is a localized text. + /// + /// - Parameters: + /// - key: the `LocalizedStringKey` to look up the **done** label text. + /// - parseStrategy: the ``StringFormatStyle/Strategy`` that specifies how the hex string will be formatted. + /// - colorNames: the names of the red, green, and blue color input fields inside the color picker modal. + public static func textFieldWithColorWell( + _ key: LocalizedStringKey, + parseStrategy: StringFormatStyle.Strategy = .hex(.lowercasedWithWell), + colorNames: ColorNames + ) -> Self where F == StringFormatStyle, Done == Text { + .textFieldWithColorWell( + parseStrategy: parseStrategy, + colorNames: colorNames + ) { + Text(key) + } + } +} + +// MARK: - Color Picker + +/// A stylized color picker. +public struct LuminareColorPicker: View + where F: ParseableFormatStyle, F.FormatInput == String, F.FormatOutput == String, + R: View, G: View, B: View, Done: View { + public typealias Style = LuminareColorPickerStyle + + // MARK: Fields + @Binding var currentColor: Color + private let isBordered: Bool + 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. + /// - isBordered: whether to display a border around the text field while not hovering. + /// - 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, + isBordered: Bool = true, + style: Style ) { self._currentColor = color self._text = State(initialValue: color.wrappedValue.toHex()) - self.colorNames = colorNames - self.formatStrategy = formatStrategy + self.isBordered = isBordered + 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, + isBordered: isBordered + ) + .onSubmit { if let newColor = Color(hex: text) { currentColor = newColor text = newColor.toHex() } else { - text = currentColor.toHex() // revert to last valid color + // revert to last valid color + text = currentColor.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) + + if let colorNamesAndDone = style.colorNamesAndDone { + Button { + isColorPickerPresented.toggle() + } label: { + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(currentColor) + .frame(width: 26, height: 26) + .padding(4) + .modifier(LuminareBordered()) + } + .buttonStyle(PlainButtonStyle()) + .luminareModal(isPresented: $isColorPickerPresented, closesOnDefocus: true, isCompact: true) { + ColorPickerModalView( + selectedColor: $currentColor.hsb, + hexColor: $text, + colorNames: colorNamesAndDone.colorNames, + done: colorNamesAndDone.done + ) .frame(width: 280) + } } } .onChange(of: currentColor) { _ in @@ -62,3 +219,30 @@ public struct LuminareColorPicker: View { } } } + +// MARK: - Previews + +// preview as app +@available(macOS 15.0, *) +#Preview( + "LuminareColorPicker", + traits: .sizeThatFitsLayout +) { + @Previewable @State var color: Color = .accentColor + + LuminareColorPicker( + color: $color, + style: .textFieldWithColorWell( + "Done", + colorNames: .init { + Text("Red") + } green: { + Text("Green") + } blue: { + Text("Blue") + } + ) + ) + .monospaced() + .frame(width: 300) +} diff --git a/Sources/Luminare/Components/Color Picker/RGBInputField.swift b/Sources/Luminare/Components/Color Picker/RGBInputField.swift index 5adb191..f631ae8 100644 --- a/Sources/Luminare/Components/Color Picker/RGBInputField.swift +++ b/Sources/Luminare/Components/Color Picker/RGBInputField.swift @@ -7,29 +7,68 @@ import SwiftUI -// Custom input field for RGB values -struct RGBInputField: View { - var label: LocalizedStringKey - @Binding var value: Double +// MARK: - RGB Inout Field + +// custom input field for RGB values +struct RGBInputField