From 5147d32d97d261f2bc7b793c3a715e2c116635df Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Fri, 17 Oct 2025 08:20:48 +1100 Subject: [PATCH 01/17] Vexillographer 3 initial update --- Package.swift | 14 +- Sources/Vexillographer/Bindings/Binding.swift | 82 -------- .../Bindings/EditableBoxedFlagValues.swift | 67 ------- .../Bindings/LosslessStringTransformer.swift | 34 ---- .../Bindings/OptionalTransformer.swift | 44 ----- .../Bindings/PassthroughTransformer.swift | 47 ----- Sources/Vexillographer/CopyButton.swift | 40 ---- Sources/Vexillographer/DetailButton.swift | 93 --------- .../Extensions/NSApplication+Sidebar.swift | 26 --- .../BooleanFlagControl.swift | 122 ------------ .../CaseIterableFlagControl.swift | 180 ----------------- .../OptionalCaseIterableFlagControl.swift | 184 ------------------ .../StringFlagControl.swift | 165 ---------------- .../FlagControl/FlagControl.swift | 94 +++++++++ .../FlagControlConfiguration.swift | 46 +++++ .../FlagControl/FlagDetail.swift | 93 +++++++++ .../FlagControl/FlagPicker+Bool.swift | 34 ++++ .../FlagControl/FlagPicker+CaseIterable.swift | 44 +++++ .../FlagControl/FlagPicker.swift | 74 +++++++ .../FlagTextField+FloatingPoint.swift | 77 ++++++++ .../FlagControl/FlagTextField+Integer.swift | 77 ++++++++ .../FlagControl/FlagTextField+String.swift | 61 ++++++ .../FlagControl/FlagTextField.swift | 93 +++++++++ .../FlagControl/FlagToggle.swift | 41 ++++ .../FlagControl/RowContent.swift | 27 +++ .../Vexillographer/FlagDetailSection.swift | 53 ----- Sources/Vexillographer/FlagDetailView.swift | 170 ---------------- .../Vexillographer/FlagDisplayValueView.swift | 72 ------- Sources/Vexillographer/FlagGroupView.swift | 107 ---------- .../FlagPole/FlagGroupItem.swift | 51 +++++ .../Vexillographer/FlagPole/FlagItem.swift | 123 ++++++++++++ .../FlagPole/FlagPoleContext.swift | 32 +++ .../FlagPole/FlagPoleItem.swift | 36 ++++ .../FlagPole/FlagPoleItemGroup.swift | 10 + .../FlagPole/FlagPoleVisitor.swift | 52 +++++ Sources/Vexillographer/FlagSectionView.swift | 77 -------- Sources/Vexillographer/FlagValueManager.swift | 113 ----------- Sources/Vexillographer/FlagView.swift | 111 ----------- .../Vexillographer/Unfurling/Unfurlable.swift | 57 ------ .../Unfurling/UnfurledFlag.swift | 69 ------- .../Unfurling/UnfurledFlagGroup.swift | 112 ----------- .../Unfurling/UnfurledFlagInfo.swift | 42 ---- .../Unfurling/UnfurledFlagItem.swift | 40 ---- .../Vexillographer/Utilities/AnyView.swift | 24 --- .../Utilities/DisplayName.swift | 100 ---------- .../Utilities/OptionalFlagValues.swift | 44 ----- .../Utilities/OptionalProtocol.swift | 11 ++ .../Vexillographer/Utilities/Pasteboard.swift | 34 ---- Sources/Vexillographer/Vexillographer.swift | 100 ++-------- .../View+FlagControlStyle.swift | 40 ++++ Sources/Vexillographer/View+FlagPole.swift | 31 +++ 51 files changed, 1175 insertions(+), 2395 deletions(-) delete mode 100644 Sources/Vexillographer/Bindings/Binding.swift delete mode 100644 Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift delete mode 100644 Sources/Vexillographer/Bindings/LosslessStringTransformer.swift delete mode 100644 Sources/Vexillographer/Bindings/OptionalTransformer.swift delete mode 100644 Sources/Vexillographer/Bindings/PassthroughTransformer.swift delete mode 100644 Sources/Vexillographer/CopyButton.swift delete mode 100644 Sources/Vexillographer/DetailButton.swift delete mode 100644 Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift delete mode 100644 Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift delete mode 100644 Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift delete mode 100644 Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift delete mode 100644 Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagControl.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagDetail.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagPicker.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagTextField+String.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagTextField.swift create mode 100644 Sources/Vexillographer/FlagControl/FlagToggle.swift create mode 100644 Sources/Vexillographer/FlagControl/RowContent.swift delete mode 100644 Sources/Vexillographer/FlagDetailSection.swift delete mode 100644 Sources/Vexillographer/FlagDetailView.swift delete mode 100644 Sources/Vexillographer/FlagDisplayValueView.swift delete mode 100644 Sources/Vexillographer/FlagGroupView.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagGroupItem.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagItem.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagPoleContext.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagPoleItem.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift create mode 100644 Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift delete mode 100644 Sources/Vexillographer/FlagSectionView.swift delete mode 100644 Sources/Vexillographer/FlagValueManager.swift delete mode 100644 Sources/Vexillographer/FlagView.swift delete mode 100644 Sources/Vexillographer/Unfurling/Unfurlable.swift delete mode 100644 Sources/Vexillographer/Unfurling/UnfurledFlag.swift delete mode 100644 Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift delete mode 100644 Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift delete mode 100644 Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift delete mode 100644 Sources/Vexillographer/Utilities/AnyView.swift delete mode 100644 Sources/Vexillographer/Utilities/DisplayName.swift delete mode 100644 Sources/Vexillographer/Utilities/OptionalFlagValues.swift create mode 100644 Sources/Vexillographer/Utilities/OptionalProtocol.swift delete mode 100644 Sources/Vexillographer/Utilities/Pasteboard.swift create mode 100644 Sources/Vexillographer/View+FlagControlStyle.swift create mode 100644 Sources/Vexillographer/View+FlagPole.swift diff --git a/Package.swift b/Package.swift index 9f4527dc..4234061a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( products: [ // Automatic .library(name: "Vexil", targets: [ "Vexil" ]), -// .library(name: "Vexillographer", targets: [ "Vexillographer" ]), + .library(name: "Vexillographer", targets: [ "Vexillographer" ]), ], dependencies: [ @@ -46,12 +46,12 @@ let package = Package( // Vexillographer -// .target( -// name: "Vexillographer", -// dependencies: [ -// .target(name: "Vexil"), -// ] -// ), + .target( + name: "Vexillographer", + dependencies: [ + .target(name: "Vexil"), + ] + ), // Macros diff --git a/Sources/Vexillographer/Bindings/Binding.swift b/Sources/Vexillographer/Bindings/Binding.swift deleted file mode 100644 index 921ca43d..00000000 --- a/Sources/Vexillographer/Bindings/Binding.swift +++ /dev/null @@ -1,82 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -extension Binding { - @available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) - init(key: String, manager: FlagValueManager, defaultValue: FValue, transformer: Transformer.Type) where Transformer: BoxedFlagValueTransformer, FValue: FlagValue, Transformer.EditingValue == Value, FValue.BoxedValueType == Transformer.OriginalValue { - self.init( - get: { - let value: FValue.BoxedValueType? = manager.boxedValue(key: key, type: FValue.self) ?? defaultValue.unwrappedBoxedValue() - return transformer.toEditingValue(value) - }, - set: { newValue in - do { - let value = transformer.toOriginalValue(newValue) - try manager.setBoxedValue(value, type: FValue.self, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ) - } - - @available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) - init(key: String, manager: FlagValueManager, defaultValue: Transformer.OriginalValue, transformer: Transformer.Type) where Transformer: FlagValueTransformer, Transformer.EditingValue == Value { - self.init( - get: { - let value: Transformer.OriginalValue = manager.flagValue(key: key) ?? defaultValue - return transformer.toEditingValue(value) - }, - set: { newValue in - do { - let value = transformer.toOriginalValue(newValue) - try manager.setFlagValue(value, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ) - } -} - - -// MARK: - Flag Value Transformers - -/// Describes a type that can be used to transform Boxed Flag Values for editing -/// -protocol BoxedFlagValueTransformer { - associatedtype OriginalValue - associatedtype EditingValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? -} - -/// Describes a type that can be used to transform Flag Values for editing -/// -protocol FlagValueTransformer { - associatedtype OriginalValue: FlagValue - associatedtype EditingValue: FlagValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? -} - -#endif diff --git a/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift b/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift deleted file mode 100644 index c03897ee..00000000 --- a/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift +++ /dev/null @@ -1,67 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -extension FlagValue { - - /// Casts a value to its BoxedValueType - /// - func unwrappedBoxedValue() -> BoxedValueType? { - let boxed = boxedFlagValue - - switch boxed { - case let .bool(value): return value as? BoxedValueType - case let .data(value): return value as? BoxedValueType - case let .double(value): return value as? BoxedValueType - case let .float(value): return value as? BoxedValueType - case let .integer(value): return value as? BoxedValueType - case let .string(value): return value as? BoxedValueType - case .none: return BoxedValueType?.none - // unsupported - case .array, .dictionary: return nil - } - } - - /// Initialises a FlagValue from its BoxedValueType - /// - init? (unwrapped value: BoxedValueType) { - - if BoxedValueType.self == Bool.self || BoxedValueType.self == Bool?.self, let wrapped = value as? Bool { - self.init(boxedFlagValue: .bool(wrapped)) - - } else if BoxedValueType.self == Data.self || BoxedValueType.self == Data?.self, let wrapped = value as? Data { - self.init(boxedFlagValue: .data(wrapped)) - - } else if BoxedValueType.self == Double.self || BoxedValueType.self == Double?.self, let wrapped = value as? Double { - self.init(boxedFlagValue: .double(wrapped)) - - } else if BoxedValueType.self == Float.self || BoxedValueType.self == Float?.self, let wrapped = value as? Float { - self.init(boxedFlagValue: .float(wrapped)) - - } else if BoxedValueType.self == Int.self || BoxedValueType.self == Int?.self, let wrapped = value as? Int { - self.init(boxedFlagValue: .integer(wrapped)) - - } else if BoxedValueType.self == String.self || BoxedValueType.self == String?.self, let wrapped = value as? String { - self.init(boxedFlagValue: .string(wrapped)) - - } else { - nil - } - } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift b/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift deleted file mode 100644 index 2bae3826..00000000 --- a/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift +++ /dev/null @@ -1,34 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// A simple transformer that converts a FlagValue into a string for editing with a TextField -/// -struct LosslessStringTransformer: BoxedFlagValueTransformer where Value: LosslessStringConvertible { - typealias OriginalValue = Value - typealias EditingValue = String - - static func toEditingValue(_ value: Value?) -> String { - value!.description - } - - static func toOriginalValue(_ value: String) -> Value? { - Value(value) - } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/OptionalTransformer.swift b/Sources/Vexillographer/Bindings/OptionalTransformer.swift deleted file mode 100644 index 07e83f86..00000000 --- a/Sources/Vexillographer/Bindings/OptionalTransformer.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -struct OptionalTransformer: BoxedFlagValueTransformer - where Value: OptionalFlagValue, Default: OptionalDefaultValue, Underlying: BoxedFlagValueTransformer, - Underlying.OriginalValue == Value.WrappedFlagValue, Default == Underlying.EditingValue -{ - typealias OriginalValue = Value - typealias EditingValue = Underlying.EditingValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue { - guard let wrapped = value?.wrapped else { - return Default.defaultValue - } - return Underlying.toEditingValue(wrapped) - } - - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? { - Value(Underlying.toOriginalValue(value)) - } -} - -// MARK: - Default Values - -protocol OptionalDefaultValue { - static var defaultValue: Self { get } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/PassthroughTransformer.swift b/Sources/Vexillographer/Bindings/PassthroughTransformer.swift deleted file mode 100644 index 37f506d0..00000000 --- a/Sources/Vexillographer/Bindings/PassthroughTransformer.swift +++ /dev/null @@ -1,47 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// A simple transformer that passes the value through as the same type -/// -struct BoxedPassthroughTransformer: BoxedFlagValueTransformer { - typealias OriginalValue = Value - typealias EditingValue = Value - - static func toEditingValue(_ value: OriginalValue?) -> Value { - value! - } - - static func toOriginalValue(_ value: Value) -> OriginalValue? { - value - } -} - -struct PassthroughTransformer: FlagValueTransformer where Value: FlagValue { - typealias OriginalValue = Value - typealias EditingValue = Value - - static func toEditingValue(_ value: OriginalValue?) -> Value { - value! - } - - static func toOriginalValue(_ value: Value) -> OriginalValue? { - value - } -} - -#endif diff --git a/Sources/Vexillographer/CopyButton.swift b/Sources/Vexillographer/CopyButton.swift deleted file mode 100644 index 1668d0cd..00000000 --- a/Sources/Vexillographer/CopyButton.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -struct CopyButton: View { - - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - var body: some View { -#if compiler(>=5.3.1) - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - return Button(action: self.action) { - Label("Copy", systemImage: "doc.on.doc") - }.eraseToAnyView() - } -#endif - return Button("Copy", action: action) - .eraseToAnyView() - } - -} - -#endif diff --git a/Sources/Vexillographer/DetailButton.swift b/Sources/Vexillographer/DetailButton.swift deleted file mode 100644 index 8fbea775..00000000 --- a/Sources/Vexillographer/DetailButton.swift +++ /dev/null @@ -1,93 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct DetailButton: View { - - // MARK: - Properties - - let hasChanges: Bool - - @Binding - var showDetail: Bool - - @State - private var size = CGSize.zero - - @State - private var isDraggingInside = false - - // MARK: - View - -#if os(iOS) - - var body: some View { - Image(systemName: hasChanges ? "info.circle.fill" : "info.circle") - .imageScale(.large) - .foregroundColor(.accentColor) - .opacity(isDraggingInside ? 0.3 : 1) - .animation(isDraggingInside ? .easeOut(duration: 0.15) : .easeIn(duration: 0.2), value: isDraggingInside) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: SizePreferenceKey.self, value: proxy.size) - } - ) - .onPreferenceChange(SizePreferenceKey.self) { size in - self.size = size - } - .gesture(selectionGesture) - } - - private var selectionGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { data in - isDraggingInside = CGRect(origin: .zero, size: size) - .insetBy(dx: -10, dy: -10) - .contains(data.location) - } - .onEnded { _ in - if isDraggingInside { - showDetail.toggle() - isDraggingInside = false - } - } - } - -#elseif os(macOS) - - var body: some View { - EmptyView() - } - -#endif - -} - -private struct SizePreferenceKey: PreferenceKey { - - typealias Value = CGSize - - static var defaultValue: Value = .zero - - static func reduce(value: inout Value, nextValue: () -> Value) { - value = nextValue() - } - -} - -#endif diff --git a/Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift b/Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift deleted file mode 100644 index 4304cb22..00000000 --- a/Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift +++ /dev/null @@ -1,26 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(macOS) - -import AppKit - -extension NSApplication { - - func toggleKeyWindowSidebar() { - keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) - } - -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift deleted file mode 100644 index 2a9f398b..00000000 --- a/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift +++ /dev/null @@ -1,122 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Boolean Flags -// -// Boolean flags are those those whose boxed type is `Bool`, or `Bool?` -// -// This includes `Bool` directly, but also `Optional` and -// `RawRepresentable where RawValue == Bool`. -// -// Plus any custom types that are boxed to a Bool. - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct BooleanFlagControl: View { - - // MARK: - Properties - - let label: String - @Binding - var value: Bool - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - - // MARK: - Views - - var body: some View { - HStack { - if isEditable { - Toggle(label, isOn: $value) - } else { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value) - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } -} - - -// MARK: - Boolean Flags - -/// Support for `UnfurledFlag` when `FlagValue.BoxedValueType == Bool` -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol BooleanEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: BooleanEditableFlag where Value.BoxedValueType == Bool { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - BooleanFlagControl( - label: label, - value: Binding( - key: info.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: BoxedPassthroughTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -// MARK: - Optional Boolean Flags - -/// Support for `UnfurledFlag` when `FlagValue.BoxedFlagValue == Bool?` -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalBooleanEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalBooleanEditableFlag where Value: FlagValue, Value.BoxedValueType: OptionalFlagValue, Value.BoxedValueType.WrappedFlagValue == Bool { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - BooleanFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: OptionalTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -extension Bool: OptionalDefaultValue { - static var defaultValue: Bool { - false - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift deleted file mode 100644 index c84ec5f3..00000000 --- a/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift +++ /dev/null @@ -1,180 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Case Iterable Flags -// -// Case Iterable flags are those those whose flag value conforms to `CaseIterable` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct CaseIterableFlagControl: View where Value: FlagValue, Value: CaseIterable, Value: Hashable, Value.AllCases: RandomAccessCollection { - - // MARK: - Properties - - let label: String - @Binding - var value: Value - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - // MARK: - View Body - - var content: some View { - HStack { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value) - } - } - -#if os(iOS) - - var body: some View { - HStack { - if isEditable { - NavigationLink(destination: selector) { - content - } - } else { - content - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } - - var selector: some View { - SelectorList(value: $value) - .navigationBarTitle(Text(label), displayMode: .inline) - } - -#elseif os(macOS) - - var body: some View { - Group { - if isEditable { - picker - } else { - content - } - } - } - - var picker: some View { - let picker = Picker( - selection: $value, - label: Text(label), - content: { - ForEach(Value.allCases, id: \.self) { value in - FlagDisplayValueView(value: value) - } - } - ) - -#if compiler(>=5.3.1) - - return picker - .pickerStyle(MenuPickerStyle()) - -#else - - return picker - -#endif - } - -#endif - - struct SelectorList: View { - @Binding - var value: Value - - @Environment(\.presentationMode) - private var presentationMode - - var body: some View { - Form { - ForEach(Value.allCases, id: \.self) { value in - Button( - action: { - self.value = value - presentationMode.wrappedValue.dismiss() - }, - label: { - HStack { - FlagDisplayValueView(value: value) - .foregroundColor(.primary) - Spacer() - - if value == self.value { - checkmark - } - } - } - ) - } - } - } - -#if os(macOS) - - var checkmark: some View { - Text("✓") - } - -#else - - var checkmark: some View { - Image(systemName: "checkmark") - } - -#endif - } -} - -// MARK: - Creating CaseIterableFlagControls - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol CaseIterableEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: CaseIterableEditableFlag - where Value: FlagValue, Value: CaseIterable, Value.AllCases: RandomAccessCollection, - Value: RawRepresentable, Value.RawValue: FlagValue, Value: Hashable -{ - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - CaseIterableFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: PassthroughTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift deleted file mode 100644 index b02e139e..00000000 --- a/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift +++ /dev/null @@ -1,184 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Optional Case Iterable Flags -// -// For those whose flag value is optional and conform to `CaseIterable` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct OptionalCaseIterableFlagControl: View - where Value: OptionalFlagValue, Value.WrappedFlagValue: CaseIterable, - Value.WrappedFlagValue: Hashable, Value.WrappedFlagValue.AllCases: RandomAccessCollection -{ - - // MARK: - Properties - - let label: String - @Binding - var value: Value - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - // MARK: - View Body - - var content: some View { - HStack { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value.wrapped) - } - } - - var body: some View { - HStack { - if isEditable { - NavigationLink(destination: selector) { - content - } - } else { - content - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } - -#if os(iOS) - - var selector: some View { - SelectorList(value: $value) - .navigationBarTitle(Text(label), displayMode: .inline) - } - -#else - - var selector: some View { - SelectorList(value: $value) - } - -#endif - - struct SelectorList: View { - @Binding - var value: Value - - @Environment(\.presentationMode) - private var presentationMode - - var body: some View { - Form { - Section { - Button( - action: { - valueSelected(nil) - }, - label: { - HStack { - Text("None") - .foregroundColor(.primary) - Spacer() - - if value.wrapped == nil { - checkmark - } - } - } - ) - } - - ForEach(Value.WrappedFlagValue.allCases, id: \.self) { value in - Button( - action: { - valueSelected(value) - }, - label: { - HStack { - FlagDisplayValueView(value: value) - .foregroundColor(.primary) - Spacer() - - if value == self.value.wrapped { - checkmark - } - } - } - ) - } - } - } - -#if os(macOS) - - var checkmark: some View { - Text("✓") - } - -#else - - var checkmark: some View { - Image(systemName: "checkmark") - } - -#endif - func valueSelected(_ value: Value.WrappedFlagValue?) { - self.value.wrapped = value - presentationMode.wrappedValue.dismiss() - } - } -} - -// MARK: - Creating CaseIterableFlagControls - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalCaseIterableEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalCaseIterableEditableFlag - where Value: OptionalFlagValue, Value.WrappedFlagValue: CaseIterable, - Value.WrappedFlagValue.AllCases: RandomAccessCollection, Value.WrappedFlagValue: RawRepresentable, - Value.WrappedFlagValue.RawValue: FlagValue, Value.WrappedFlagValue: Hashable -{ - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - let key = info.key - - return OptionalCaseIterableFlagControl( - label: label, - value: Binding( - get: { Value(manager.flagValue(key: key)) }, - set: { newValue in - do { - try manager.setFlagValue(newValue, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift deleted file mode 100644 index 4cf97e2d..00000000 --- a/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift +++ /dev/null @@ -1,165 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// String Flag Values -// -// String flag values are ones whose flag value conforms to `LosslessStringConvertible` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct StringFlagControl: View { - - // MARK: - Properties - - let label: String - @Binding - var value: String - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - - // MARK: - Views - - var body: some View { - HStack { - Text(label) - Spacer() - if isEditable { - TextField("", text: $value) - .multilineTextAlignment(.trailing) - } else { - FlagDisplayValueView(value: value) - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } -} - - -// MARK: - Lossless String Convertible Flags - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol StringEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: StringEditableFlag where Value.BoxedValueType: LosslessStringConvertible { - - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - StringFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: LosslessStringTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .flagValueKeyboard(type: Value.self) - .eraseToAnyView() - } - -} - - -// MARK: - Optional Lossless String Convertible Flags - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalStringEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalStringEditableFlag - where Value: FlagValue, Value.BoxedValueType: OptionalFlagValue, Value.BoxedValueType.WrappedFlagValue: LosslessStringConvertible -{ - - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - StringFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: OptionalTransformer>.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .flagValueKeyboard(type: Value.self) - .eraseToAnyView() - } - -} - -extension String: OptionalDefaultValue { - var unwrapped: String? { - self - } - - static var defaultValue: String { - "" - } -} - -#if os(iOS) - -private extension View { - func flagValueKeyboard(type: Value.Type) -> some View where Value: FlagValue { - keyboardType(Value.keyboardType) - } -} - -private extension FlagValue { - - /// Provides a hint as to what keyboard type to use for a given FlagValue - /// - static var keyboardType: UIKeyboardType { - if Self.self == Double.self || Self.self == Float.self { - return .decimalPad - - } else if Self.self == Int.self || Self.self == Int8.self || Self.self == Int16.self - || Self.self == Int32.self || Self.self == Int64.self || Self.self == UInt.self - || Self.self == UInt8.self || Self.self == UInt16.self || Self.self == UInt32.self - || Self.self == UInt64.self - { - return .numberPad - } - - return .default - } -} - -#else - -private extension View { - func flagValueKeyboard(type: (some FlagValue).Type) -> some View { - self - } -} - -#endif - -#endif diff --git a/Sources/Vexillographer/FlagControl/FlagControl.swift b/Sources/Vexillographer/FlagControl/FlagControl.swift new file mode 100644 index 00000000..fd142049 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagControl.swift @@ -0,0 +1,94 @@ +import SwiftUI +import Vexil + +public struct FlagControl: View { + + var wigwag: FlagWigwag + @ViewBuilder var content: (FlagControlConfiguration) -> Content + + @State private var cachedValue: Value? + @State private var seed = 0 + + @Environment(\.flagPoleContext) private var flagPoleContext + + public init( + _ wigwag: FlagWigwag, + @ViewBuilder content: @escaping (FlagControlConfiguration) -> Content + ) { + self.wigwag = wigwag + self.content = content + } + + public var body: some View { + content( + FlagControlConfiguration( + seed: seed, + name: wigwag.name, + description: wigwag.description, + keyPath: wigwag.keyPath, + isEditable: flagPoleContext.editableSource != nil, + hasValue: editableValue != nil, + defaultValue: wigwag.defaultValue, + value: Binding(get: getValue, set: setValue), + resetValue: resetValue + ) + ) + .task { + for await _ in wigwag.changes { + seed += 1 + cachedValue = resolvedValue + } + } + } + + var editableValue: Value? { + flagPoleContext.editableSource?.flagValue(key: wigwag.key) + } + + var nonEditableValue: Value { + let editableSourceID = flagPoleContext.editableSource?.flagValueSourceID + for source in flagPoleContext.sources where source.flagValueSourceID != editableSourceID { + if let value = source.flagValue(key: wigwag.key) as Value? { + return value + } + } + return wigwag.defaultValue + } + + var resolvedValue: Value { + editableValue ?? nonEditableValue + } + + func getValue() -> Value { + cachedValue ?? resolvedValue + } + + func setValue(_ newValue: Value, transaction: Transaction) { + guard let editableSource = flagPoleContext.editableSource else { + print("Trying to set a value that isn't editable. This will be ignored.") + return + } + + do { + $cachedValue.transaction(transaction).wrappedValue = newValue + try editableSource.setFlagValue(newValue, key: wigwag.key) + } catch { + print("Error trying to set value.") + } + } + + func resetValue() { + guard let editableSource = flagPoleContext.editableSource else { + print("Trying to set a value that isn't editable. This will be ignored.") + return + } + + do { + cachedValue = nonEditableValue + try editableSource.setFlagValue(nil as Value?, key: wigwag.key) + } catch { + print("Error trying to reset value.") + } + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift new file mode 100644 index 00000000..ce90b3c8 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift @@ -0,0 +1,46 @@ +import SwiftUI +import Vexil + +public struct FlagControlConfiguration { + + private let seed: Int + public let name: String + public let description: String? + public let keyPath: FlagKeyPath + public let isEditable: Bool + public let hasValue: Bool + public let defaultValue: Value + @Binding public var value: Value + private let _resetValue: () -> Void + + init( + seed: Int, + name: String, + description: String? = nil, + keyPath: FlagKeyPath, + isEditable: Bool, + hasValue: Bool, + defaultValue: Value, + value: Binding, + resetValue: @escaping () -> Void + ) { + self.seed = seed + self.name = name + self.description = description + self.keyPath = keyPath + self.isEditable = isEditable + self.hasValue = hasValue + self.defaultValue = defaultValue + _value = value + _resetValue = resetValue + } + + public var key: String { + keyPath.key + } + + public func resetValue() { + _resetValue() + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagDetail.swift b/Sources/Vexillographer/FlagControl/FlagDetail.swift new file mode 100644 index 00000000..4ffac1cd --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagDetail.swift @@ -0,0 +1,93 @@ +import SwiftUI +import Vexil + +struct FlagDetailView: View { + + var configuration: FlagControlConfiguration + + @Environment(\.dismiss) private var dismiss + @Environment(\.flagPoleContext) private var flagPoleContext + + var body: some View { + List { + Section { + if let description = configuration.description { + Text(description) + } + RowContent("Key", value: configuration.keyPath.key) + } + if let editableSource = flagPoleContext.editableSource { + let editableValue = editableSource.flagValue(key: configuration.key) as Value? + Section("Current Source") { + FlagValueRow(editableSource.flagValueSourceName, value: editableValue) + #if os(macOS) + RowContent("Clear Current Source") { + Button("Clear", role: .destructive) { + configuration.resetValue() + } + .disabled(editableValue == nil) + } + #else + Button("Clear Current Source", role: .destructive) { + configuration.resetValue() + } + .disabled(editableValue == nil) + #endif + } + } + Section("Flagpole Source Hierarchy") { + ForEach(flagPoleContext.sources, id: \.flagValueSourceID) { source in + let isEditableSource = source.flagValueSourceID == flagPoleContext.editableSource?.flagValueSourceID + let sourceValue = source.flagValue(key: configuration.key) as Value? + FlagValueRow(source.flagValueSourceName, value: sourceValue) + .font(isEditableSource ? .headline : nil) + } + FlagValueRow("Default Value", value: configuration.defaultValue) + } + } + .navigationTitle(configuration.name) + #if os(macOS) + .padding(0) // FIXME: Views for mac + #else + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem { + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + } + } + } +} + +struct FlagValueRow: View { + var label: String + var value: Value? + + init(_ label: String, value: Value?) { + self.label = label + self.value = value + } + + var body: some View { + RowContent(label) { + if let value { + if let value = value as? any OptionalProtocol { + if let wrapped = value.wrapped { + Text(String(describing: wrapped)) + } else { + Text("nil") + } + } else { + Text(String(describing: value)) + } + } else { + Text("not set") + .italic() + } + } + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift new file mode 100644 index 00000000..2ca3cb4f --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift @@ -0,0 +1,34 @@ +import SwiftUI +import Vexil + +public extension FlagPicker where Value.BoxedValueType == Bool?, SelectionValue == Bool?, Content == DefaultFlagPickerContent { + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration, selection: \.asOptionalBool) { + DefaultFlagPickerContent(Array([nil, true, false])) + } + } +} + +private extension FlagValue where BoxedValueType == Bool? { + + var asOptionalBool: Bool? { + get { + Bool(boxedFlagValue: boxedFlagValue) + } + set { + let boxedFlagValue = newValue.map(BoxedFlagValue.bool) ?? .none + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } + +} + +protocol OptionalBooleanFlagPickerRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalBooleanFlagPickerRepresentable where Value.BoxedValueType == Bool? { + func makeContent() -> any View { + FlagPicker(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift new file mode 100644 index 00000000..f3031e00 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift @@ -0,0 +1,44 @@ +import SwiftUI +import Vexil + +public extension FlagPicker where Value: CaseIterable, SelectionValue == Value, Content == DefaultFlagPickerContent { + + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration) { + DefaultFlagPickerContent(Array(Value.allCases)) + } + } +} + +public extension FlagPicker { + + init( + configuration: FlagControlConfiguration + ) where Value == Wrapped?, SelectionValue == Wrapped?, Content == DefaultFlagPickerContent { + self.init(configuration: configuration, selection: \.wrapped) { + DefaultFlagPickerContent([nil as Wrapped?] + Array(Wrapped.allCases)) + } + } +} + +protocol CaseIterableFlagPickerRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: CaseIterableFlagPickerRepresentable where Value: CaseIterable & Hashable { + func makeContent() -> any View { + FlagPicker(configuration: self) + } +} + +protocol OptionalCaseIterableFlagPickerRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalCaseIterableFlagPickerRepresentable where Value: OptionalProtocol, Value.Wrapped: CaseIterable & Hashable { + func makeContent() -> any View { + FlagPicker(configuration: self, selection: \.wrapped) { + DefaultFlagPickerContent([nil as Value.Wrapped?] + Array(Value.Wrapped.allCases)) + } + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker.swift b/Sources/Vexillographer/FlagControl/FlagPicker.swift new file mode 100644 index 00000000..470a37be --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Vexil + +public struct FlagPicker: View { + + private var name: String + @Binding private var value: Value + private var selection: WritableKeyPath + private var content: Content + + init( + configuration: FlagControlConfiguration, + selection: WritableKeyPath, + @ViewBuilder content: () -> Content + ) { + name = configuration.name + _value = configuration.$value + self.selection = selection + self.content = content() + } + + public var body: some View { + Picker(name, selection: $value[dynamicMember: selection]) { + content + } + } + +} + +public extension FlagPicker where SelectionValue == Value { + + init(configuration: FlagControlConfiguration, @ViewBuilder content: () -> Content) { + self.init(configuration: configuration, selection: \.asSelection, content: content) + } + +} + +private extension FlagValue { + + var asSelection: Self { + get { self } + set { self = newValue } + } + +} + + + + +public struct DefaultFlagPickerContent: View { + + private var options: [SelectionValue] + + init(_ options: [SelectionValue]) { + self.options = options + } + + public var body: some View { + ForEach(options, id: \.self) { option in + if let optional = option as? any OptionalProtocol { + if let wrapped = optional.wrapped { + Text(String(describing: wrapped)) + } else { + Section { + Text("None") + } + } + } else { + Text(String(describing: option)) + } + } + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift new file mode 100644 index 00000000..8b480bbd --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift @@ -0,0 +1,77 @@ +import SwiftUI +import Vexil + +extension FlagTextField where Value.BoxedValueType: BinaryFloatingPoint { + + init(configuration: FlagControlConfiguration) { + self.init( + configuration: configuration, + formatted: \.asString, + keyboardType: .decimalPad, + editingFormat: { $0 } + ) + } + +} + +private extension FlagValue where BoxedValueType: BinaryFloatingPoint { + var asString: String { + get { + Double(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.double(0) : .double(Double(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol FloatingPointTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: FloatingPointTextFieldRepresentable where Value.BoxedValueType: BinaryFloatingPoint { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryFloatingPoint { + self.init( + configuration: configuration, + formatted: \.asStringOrEmpty, + keyboardType: .decimalPad, + editingFormat: { $0 } + ) + } + +} + +private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueType.Wrapped: BinaryFloatingPoint { + var asStringOrEmpty: String { + get { + Double(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .double(Double(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalFloatingPointFlagTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalFloatingPointFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryFloatingPoint { + func makeContent() -> any View { + FlagTextField( + configuration: self, + formatted: \.asStringOrEmpty, + keyboardType: .decimalPad, + editingFormat: { $0 } + ) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift new file mode 100644 index 00000000..e34484ea --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift @@ -0,0 +1,77 @@ +import SwiftUI +import Vexil + +extension FlagTextField where Value.BoxedValueType: BinaryInteger { + + init(configuration: FlagControlConfiguration) { + self.init( + configuration: configuration, + formatted: \.asString, + keyboardType: .numberPad, + editingFormat: { $0.filter(\.isNumber) } + ) + } + +} + +private extension FlagValue where BoxedValueType: BinaryInteger { + var asString: String { + get { + Int(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.integer(0) : .integer(Int(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol IntegerFlagTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: IntegerFlagTextFieldRepresentable where Value.BoxedValueType: BinaryInteger { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryInteger { + self.init( + configuration: configuration, + formatted: \.asStringOrEmpty, + keyboardType: .numberPad, + editingFormat: { $0.filter(\.isNumber) } + ) + } + +} + +private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueType.Wrapped: BinaryInteger { + var asStringOrEmpty: String { + get { + Int(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .integer(Int(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalIntegerFlagTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalIntegerFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryInteger { + func makeContent() -> any View { + FlagTextField( + configuration: self, + formatted: \.asStringOrEmpty, + keyboardType: .numberPad, + editingFormat: { $0.filter(\.isNumber) } + ) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift new file mode 100644 index 00000000..e90fa076 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift @@ -0,0 +1,61 @@ +import SwiftUI +import Vexil + +extension FlagTextField where Value.BoxedValueType == String { + + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration, formatted: \.asString) + } + +} + +private extension FlagValue where BoxedValueType == String { + var asString: String { + get { + String(boxedFlagValue: boxedFlagValue) ?? "" + } + set { + self = Self(boxedFlagValue: .string(newValue)) ?? self + } + } +} + +protocol StringFlagTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: StringFlagTextFieldRepresentable where Value.BoxedValueType == String { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value.BoxedValueType == String? { + self.init(configuration: configuration, formatted: \.asStringOrEmpty, placeholder: "nil") + } + +} + +private extension FlagValue where BoxedValueType == String? { + var asStringOrEmpty: String { + get { + String(boxedFlagValue: boxedFlagValue) ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .string(newValue) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalStringFlagTextFieldRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalStringFlagTextFieldRepresentable where Value.BoxedValueType == String? { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift new file mode 100644 index 00000000..1b3be8a2 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -0,0 +1,93 @@ +import SwiftUI +import Vexil + +struct FlagTextField: View { + + private var name: String + @Binding private var value: Value + private var placeholder: String + private var formatted: WritableKeyPath + private var format: (String) -> String + private var editingFormat: (String) -> String + #if canImport(UIKit) + private var keyboardType: UIKeyboardType + #endif + + @State private var cachedText: String? + + @FocusState private var isFocused + + #if canImport(UIKit) + init( + configuration: FlagControlConfiguration, + formatted: WritableKeyPath, + keyboardType: UIKeyboardType = .default, + placeholder: String = "", + format: @escaping (String) -> String = { $0 }, + editingFormat: @escaping (String) -> String = { $0 } + ) { + self.name = configuration.name + self._value = configuration.$value + self.keyboardType = keyboardType + self.placeholder = placeholder + self.formatted = formatted + self.format = format + self.editingFormat = editingFormat + } + #else + init( + configuration: FlagControlConfiguration, + formatted: WritableKeyPath, + placeholder: String = "", + format: @escaping (String) -> String = { $0 }, + editingFormat: @escaping (String) -> String = { $0 } + ) { + self.name = configuration.name + self._value = configuration.$value + self.placeholder = placeholder + self.formatted = formatted + self.format = format + self.editingFormat = editingFormat + } + #endif + + var body: some View { + HStack { + Text(name) + .accessibilityHidden(true) + TextField(placeholder, text: text) + .multilineTextAlignment(.trailing) + .accessibilityLabel(name) + #if canImport(UIKit) + .keyboardType(keyboardType) + #endif + } + .onChange(of: value.boxedFlagValue) { _ in + cachedText = nil + } + .onChange(of: cachedText) { newText in + guard let newText else { + return + } + cachedText = editingFormat(newText) + } + .onChange(of: isFocused) { isFocused in + guard isFocused == false, let cachedText else { + return + } + let newText = format(cachedText) + self.cachedText = newText + value[keyPath: formatted] = newText + } + .focused($isFocused) + } + + var text: Binding { + Binding( + get: { cachedText ?? value[keyPath: formatted] }, + set: { cachedText = $0 } + ) + } + +} + diff --git a/Sources/Vexillographer/FlagControl/FlagToggle.swift b/Sources/Vexillographer/FlagControl/FlagToggle.swift new file mode 100644 index 00000000..66455637 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagToggle.swift @@ -0,0 +1,41 @@ +import SwiftUI +import Vexil + +public struct FlagToggle: View where Value.BoxedValueType == Bool { + + private var name: String + @Binding private var value: Value + + public init(configuration: FlagControlConfiguration) { + self.name = configuration.name + _value = configuration.$value + } + + public var body: some View { + Toggle(name, isOn: $value.asBool) + } + +} + +private extension FlagValue where BoxedValueType == Bool { + + var asBool: Bool { + get { + Bool(boxedFlagValue: boxedFlagValue) ?? false + } + set { + self = Self(boxedFlagValue: .bool(newValue)) ?? self + } + } + +} + +protocol FlagToggleRepresentable { + @MainActor func makeContent() -> any View +} + +extension FlagControlConfiguration: FlagToggleRepresentable where Value.BoxedValueType == Bool { + func makeContent() -> any View { + FlagToggle(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/RowContent.swift b/Sources/Vexillographer/FlagControl/RowContent.swift new file mode 100644 index 00000000..016fbb0d --- /dev/null +++ b/Sources/Vexillographer/FlagControl/RowContent.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct RowContent: View { + + var label: String + var content: Content + + init(_ label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + init(_ label: String, value: some Any) where Content == Text { + self.label = label + self.content = Text(String(describing: value)) + } + + var body: some View { + HStack(spacing: 0) { + Text(label) + Spacer() + content + .foregroundStyle(.secondary) + } + } + +} diff --git a/Sources/Vexillographer/FlagDetailSection.swift b/Sources/Vexillographer/FlagDetailSection.swift deleted file mode 100644 index de53bb16..00000000 --- a/Sources/Vexillographer/FlagDetailSection.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -struct FlagDetailSection: View where Header: View, Content: View { - - private let header: Header - - private let content: Content - - init(header: Header, @ViewBuilder content: () -> Content) { - self.header = header - self.content = content() - } - -#if os(macOS) - - var body: some View { - GroupBox(label: header) { - VStack(alignment: .leading, spacing: 8) { - content - } - .padding(EdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)) - .frame(maxWidth: .infinity, alignment: .leading) - }.padding(.bottom, 8) - } - -#else - - var body: some View { - Section(header: header) { - content - } - } - -#endif - -} - -#endif diff --git a/Sources/Vexillographer/FlagDetailView.swift b/Sources/Vexillographer/FlagDetailView.swift deleted file mode 100644 index d5f0b662..00000000 --- a/Sources/Vexillographer/FlagDetailView.swift +++ /dev/null @@ -1,170 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct FlagDetailView: View where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let flag: UnfurledFlag - let isEditable: Bool - - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(flag: UnfurledFlag, manager: FlagValueManager) { - self.flag = flag - self.manager = manager - self.isEditable = manager.isEditable - } - - - // MARK: - View Body - -#if os(iOS) - - var body: some View { - content - .navigationBarTitle(Text(flag.info.name), displayMode: .inline) - } - -#elseif os(macOS) - - var body: some View { - ScrollView { - content - } - .frame(minWidth: 300) - } - -#else - - var body: some View { - content - } - -#endif - - - var content: some View { - Form { - FlagDetailSection(header: Text("Flag Details")) { - flagKeyView - .contextMenu { - CopyButton(action: flag.info.key.copyToPasteboard) - } - - VStack(alignment: .leading) { - Text("Description:").font(.headline) - Text(flag.info.description) - } - .contextMenu { - CopyButton(action: flag.info.description.copyToPasteboard) - } - } - - if manager.source != nil { - FlagDetailSection(header: Text("Current Source")) { - HStack { - Text(manager.source!.flagValueSourceName) - .font(.headline) - Spacer() - description(source: manager.source!) - } - - Button(action: clearValue) { - Text("Clear Flag Value in Current Source") - } - .foregroundColor(.red) - .opacity(isCurrentSourceSet ? 1 : 0.3) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .disabled(isCurrentSourceSet == false) - .animation(.easeInOut, value: isCurrentSourceSet) - } - } - - FlagDetailSection(header: Text("FlagPole Source Hierarchy")) { - ForEach(manager.flagPole._sources, id: \.flagValueSourceName) { source in - HStack { - if (source as AnyObject) === (manager.source as AnyObject) { - Text(source.flagValueSourceName) - .font(.headline) - } else { - Text(source.flagValueSourceName) - } - Spacer() - description(source: source) - } - } - HStack { - Text("Default Value") - Spacer() - FlagDisplayValueView(value: flag.flag.defaultValue) - } - } - } - } - - func description(source: FlagValueSource) -> some View { - if let value = flagValue(source: source) { - FlagDisplayValueView(value: value).eraseToAnyView() - } else { - Text("not set").italic().eraseToAnyView() - } - } - - func flagValue(source: FlagValueSource) -> Value? { - source.flagValue(key: flag.flag.key) - } - - func clearValue() { - try? manager.source?.setFlagValue(Value?.none, key: flag.flag.key) // swiftlint:disable:this syntactic_sugar - } - - var isCurrentSourceSet: Bool { - guard let source = manager.source else { - return false - } - return flagValue(source: source) != nil - } - - private var flagKeyView: some View { -#if os(macOS) - - return VStack(alignment: .leading) { - Text("Key").font(.headline) - Text(flag.info.key) - } - -#else - - return HStack { - Text("Key").font(.headline) - Spacer() - Text(flag.info.key) - } - -#endif - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagDisplayValueView.swift b/Sources/Vexillographer/FlagDisplayValueView.swift deleted file mode 100644 index 35d21621..00000000 --- a/Sources/Vexillographer/FlagDisplayValueView.swift +++ /dev/null @@ -1,72 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct FlagDisplayValueView: View where Value: FlagValue { - - // MARK: - Properties - - let value: Value - - var string: String? { - if let value = value as? OptionalFlagDisplayValue { - return value.flagDisplayValue - } - if let displayValue = value as? FlagDisplayValue { - return displayValue.flagDisplayValue - } - return String(describing: value) - } - - // MARK: - Body - - var body: some View { - Group { - if string != nil { - Text(string!) - .contextMenu { - CopyButton(action: string!.copyToPasteboard) - } - - } else { - Text("nil").foregroundColor(.red) - } - } - } - -} - -private protocol OptionalFlagDisplayValue { - var flagDisplayValue: String? { get } -} - -extension Optional: OptionalFlagDisplayValue where Wrapped: FlagValue { - var flagDisplayValue: String? { - guard let value = self else { - return nil - } - - if let displayValue = value as? FlagDisplayValue { - return displayValue.flagDisplayValue - } - - return String(describing: value) - } -} - -#endif diff --git a/Sources/Vexillographer/FlagGroupView.swift b/Sources/Vexillographer/FlagGroupView.swift deleted file mode 100644 index 53a2f880..00000000 --- a/Sources/Vexillographer/FlagGroupView.swift +++ /dev/null @@ -1,107 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagGroupView: View where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let group: UnfurledFlagGroup - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(group: UnfurledFlagGroup, manager: FlagValueManager) { - self.group = group - self.manager = manager - } - - - // MARK: - View Body - -#if os(iOS) - - var body: some View { - Form { - Section { - description - } - .padding([.top, .bottom], 4) - flags - } - } - -#elseif os(macOS) && compiler(>=5.3.1) - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - description - .padding(.bottom, 8) - Divider() - } - .padding() - - Form { - Section { - // Filter out all links. They won't work on the mac flag group view. - ForEach(group.allItems().filter { $0.isLink == false }, id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - } - .padding([.leading, .trailing, .bottom], 30) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) - } - .navigationTitle(group.info.name) - } - -#else - - var body: some View { - Form { - description - Section { - flags - } - } - } - -#endif - - var description: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Description").font(.headline) - Text(group.info.description) - } - .contextMenu { - CopyButton(action: group.info.description.copyToPasteboard) - } - } - - var flags: some View { - ForEach(group.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift new file mode 100644 index 00000000..f06ccfb5 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift @@ -0,0 +1,51 @@ +import SwiftUI +import Vexil + +struct FlagGroupItem: FlagPoleItemGroup { + + var group: FlagGroupWigwag + var items = [any FlagPoleItem]() + + init(_ group: FlagGroupWigwag) { + self.group = group + } + + var isHidden: Bool { + group.displayOption == .hidden || visibleItems.isEmpty + } + + var keyPath: FlagKeyPath { + group.keyPath + } + + var name: String { + group.name + } + + var visibleItems: [any FlagPoleItem] { + items.filter { $0.isHidden == false } + } + + func makeContent() -> any View { + switch group.displayOption { + case .navigation, nil: + NavigationLink(group.name) { + List { + if let description = group.description { + Section { + Text(description) + } + } + ForEach(visibleItems, id: \.keyPath, content: \.content) + } + } + case .section: + Section(group.name) { + ForEach(visibleItems, id: \.keyPath, content: \.content) + } + case .hidden: + EmptyView() + } + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagItem.swift b/Sources/Vexillographer/FlagPole/FlagItem.swift new file mode 100644 index 00000000..425f9cee --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagItem.swift @@ -0,0 +1,123 @@ +import SwiftUI +import Vexil + +struct FlagItem: FlagPoleItem { + + var flag: FlagWigwag + + init(_ flag: FlagWigwag) { + self.flag = flag + } + + var isHidden: Bool { + flag.displayOption == .hidden + } + + var keyPath: FlagKeyPath { + flag.keyPath + } + + var name: String { flag.name } + + func makeContent() -> any View { + FlagItemContent(wigwag: flag) + } + +} + +struct FlagItemContent: View { + + var wigwag: FlagWigwag + + @State private var isShowingDetail = false + @FocusState private var isFocused + + @Environment(\.flagPoleContext) var flagPoleContext + + var body: some View { + FlagControl(wigwag) { configuration in + HStack { + if let styledControl = flagPoleContext.styledControl(configuration: configuration) { + styledControl + } else if configuration.isEditable { + DefaultFlagControl(configuration: configuration) + } else { + FlagValueRow(configuration.name, value: configuration.value) + } + Button { + isFocused = false + isShowingDetail = true + } label: { + Label("Info", systemImage: "info.circle") + .imageScale(.large) + .labelStyle(.iconOnly) + .foregroundStyle(.tint) + .symbolVariant(configuration.hasValue ? .fill : .none) + } + .buttonStyle(.plain) + } + .focused($isFocused) + .swipeActions(edge: .trailing) { + if configuration.hasValue { + Button { + configuration.resetValue() + } label: { + Label("Clear", systemImage: "trash.fill") + .imageScale(.large) + } + .tint(.red) + } + } + .sheet(isPresented: $isShowingDetail) { + NavigationView { + FlagDetailView(configuration: configuration) + } + } + } + } +} + +struct StyledFlagControl: View { + var configuration: FlagControlConfiguration + var style: any FlagControlStyle + + var body: some View { + AnyView(style.makeBody(configuration: configuration)) + } +} + +struct DefaultFlagControl: View { + var content: any View + + init(configuration: FlagControlConfiguration) { + switch configuration { + case let configuration as any FlagToggleRepresentable: + content = configuration.makeContent() + case let configuration as any OptionalBooleanFlagPickerRepresentable: + content = configuration.makeContent() + case let configuration as any CaseIterableFlagPickerRepresentable: + content = configuration.makeContent() + case let configuration as any OptionalCaseIterableFlagPickerRepresentable: + content = configuration.makeContent() + case let configuration as any IntegerFlagTextFieldRepresentable: + content = configuration.makeContent() + case let configuration as any OptionalIntegerFlagTextFieldRepresentable: + content = configuration.makeContent() + case let configuration as any FloatingPointTextFieldRepresentable: + content = configuration.makeContent() + case let configuration as any OptionalFloatingPointFlagTextFieldRepresentable: + content = configuration.makeContent() + case let configuration as any StringFlagTextFieldRepresentable: + content = configuration.makeContent() + case let configuration as any OptionalStringFlagTextFieldRepresentable: + content = configuration.makeContent() + default: + content = Text("Unimplemented \(configuration.name)").frame(maxWidth: .infinity) + } + } + + var body: some View { + AnyView(content) + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift new file mode 100644 index 00000000..293f0261 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Vexil + +struct FlagPoleContext { + + var items: [any FlagPoleItem] = [] + var editableSource: (any FlagValueSource)? + var sources: [any FlagValueSource] = [] + var keyPathByFlagKeyPath = [FlagKeyPath: AnyKeyPath]() + var styles = [AnyHashable: any FlagControlStyle]() + + func items(matching searchText: String) -> [any FlagPoleItem] { + items.flatMap { $0.items(matching: searchText) } + } + + @MainActor func styledControl(configuration: FlagControlConfiguration) -> AnyView? { + if let keyPath = keyPathByFlagKeyPath[configuration.keyPath], let style = styles[keyPath] { + style.control(configuration: configuration) + } else if let style = styles[ObjectIdentifier(Value.self)] { + style.control(configuration: configuration) + } else { + nil + } + } + +} + +extension EnvironmentValues { + + @Entry var flagPoleContext = FlagPoleContext() + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItem.swift b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift new file mode 100644 index 00000000..090634c9 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift @@ -0,0 +1,36 @@ +import SwiftUI +import Vexil + +protocol FlagPoleItem { + + var keyPath: FlagKeyPath { get } + var name: String { get } + var isHidden: Bool { get } + @MainActor func makeContent() -> any View + +} + +extension FlagPoleItem { + @MainActor var content: AnyView { AnyView(makeContent()) } +} + +extension FlagPoleItem { + + func matches(searchText: String) -> Bool { + searchText.isEmpty || name.localizedStandardContains(searchText) || keyPath.key.localizedStandardContains(searchText) + } + + func items(matching searchText: String) -> [any FlagPoleItem] { + guard isHidden == false else { + return [] + } + if let group = self as? FlagPoleItemGroup { + return group.items.flatMap { $0.items(matching: searchText) } + } else if matches(searchText: searchText) { + return [self] + } else { + return [] + } + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift new file mode 100644 index 00000000..f6e97743 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift @@ -0,0 +1,10 @@ +import SwiftUI +import Vexil + +protocol FlagPoleItemGroup: FlagPoleItem { + + var items: [any FlagPoleItem] { get set } + +} + + diff --git a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift new file mode 100644 index 00000000..af4868d4 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift @@ -0,0 +1,52 @@ +import SwiftUI +import Vexil + +extension FlagContainer { + var keyPathByFlagKeyPath: [FlagKeyPath: AnyKeyPath] { + _allFlagKeyPaths.reduce(into: [FlagKeyPath: AnyKeyPath]()) { $0[$1.value] = $1.key } + } +} + +class FlagPoleVisitor: FlagVisitor { + + var lookup: any FlagLookup + var items = [any FlagPoleItem]() + var groupStack = [any FlagPoleItemGroup]() + var keyPathStack = [AnyKeyPath]() + var keyPathByFlagKeyPath = [FlagKeyPath: AnyKeyPath]() + + init(lookup: any FlagLookup) { + self.lookup = lookup + } + + func beginContainer(keyPath: FlagKeyPath, containerType: any FlagContainer.Type) { + let container = containerType.init(_flagKeyPath: keyPath, _flagLookup: lookup) + keyPathByFlagKeyPath.merge(container.keyPathByFlagKeyPath, uniquingKeysWith: { $1 }) + } + + func beginGroup(keyPath: FlagKeyPath, wigwag: () -> FlagGroupWigwag) { + groupStack.append(FlagGroupItem(wigwag())) + } + + func visitFlag( + keyPath: FlagKeyPath, + value: () -> Value?, + defaultValue: Value, + wigwag: () -> FlagWigwag + ) { + appendToGroupOrRoot(FlagItem(wigwag())) + } + + func endGroup(keyPath: FlagKeyPath) { + appendToGroupOrRoot(groupStack.removeLast()) + } + + private func appendToGroupOrRoot(_ newItem: any FlagPoleItem) { + if groupStack.last != nil { + groupStack[groupStack.count - 1].items.append(newItem) + } else { + items.append(newItem) + } + } + +} diff --git a/Sources/Vexillographer/FlagSectionView.swift b/Sources/Vexillographer/FlagSectionView.swift deleted file mode 100644 index 1c1ff8f0..00000000 --- a/Sources/Vexillographer/FlagSectionView.swift +++ /dev/null @@ -1,77 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagSectionView: View where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let group: UnfurledFlagGroup - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(group: UnfurledFlagGroup, manager: FlagValueManager) { - self.group = group - self.manager = manager - } - - - // MARK: - View Body - -#if os(macOS) - - var body: some View { - GroupBox( - label: Text(group.info.name), - content: { - VStack(alignment: .leading) { - Text(group.info.description) - Divider() - content - }.padding(4) - } - ) - .padding([.top, .bottom]) - } - -#else - - var body: some View { - Section( - header: Text(group.info.name), - footer: Text(group.info.description), - content: { - content - } - ) - } - -#endif - - private var content: some View { - ForEach(group.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagValueManager.swift b/Sources/Vexillographer/FlagValueManager.swift deleted file mode 100644 index 90cdebbd..00000000 --- a/Sources/Vexillographer/FlagValueManager.swift +++ /dev/null @@ -1,113 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Combine -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -class FlagValueManager: ObservableObject where RootGroup: FlagContainer { - - // MARK: - Properties - - let flagPole: FlagPole - let source: FlagValueSource? - private var cancellables = Set() - - var isEditable: Bool { - source != nil - } - - - // MARK: - Initialisation - - init(flagPole: FlagPole, source: FlagValueSource?) { - self.flagPole = flagPole - self.source = source - - flagPole - .publisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - } - - - // MARK: - Flag Values - - func rawValue(key: String) -> Value? where Value: FlagValue { - source?.flagValue(key: key) - } - - func flagValue(key: String) -> Value? where Value: FlagValue { - let snapshot = flagPole.snapshot() - return snapshot.flagValue(key: key) - } - - func setFlagValue(_ value: (some FlagValue)?, key: String) throws { - guard let source else { - return - } - - let snapshot = flagPole.emptySnapshot() - try snapshot.setFlagValue(value, key: key) - try flagPole.save(snapshot: snapshot, to: source) - } - - func hasValueInSource(flag: Flag) -> Bool { - if let _: Value = source?.flagValue(key: flag.key) { - true - - } else { - false - } - } - - - // MARK: - Boxed Values - - func boxedValue(key: String, type: Value.Type) -> Value.BoxedValueType? where Value: FlagValue { - guard let value: Value = flagValue(key: key) else { - return nil - } - return value.unwrappedBoxedValue() - } - - func setBoxedValue(_ value: Value.BoxedValueType?, type: Value.Type, key: String) throws where Value: FlagValue { - let unboxed = value.flatMap(Value.init(unwrapped:)) - try setFlagValue(unboxed, key: key) - } - - - // MARK: - Displaying Flag Values - - func allItems() -> [UnfurledFlagItem] { - Mirror(reflecting: flagPole._rootGroup) - .children - .compactMap { child -> UnfurledFlagItem? in - guard let label = child.label, let unfurlable = child.value as? Unfurlable else { - return nil - } - let unfurled = unfurlable.unfurl(label: label, manager: self) - return unfurled - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagView.swift b/Sources/Vexillographer/FlagView.swift deleted file mode 100644 index d60670fc..00000000 --- a/Sources/Vexillographer/FlagView.swift +++ /dev/null @@ -1,111 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagView: View where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let flag: UnfurledFlag - - @ObservedObject - var manager: FlagValueManager - - @State - private var showDetail = false - - // MARK: - Initialisation - - init(flag: UnfurledFlag, manager: FlagValueManager) { - self.flag = flag - self.manager = manager - } - - - // MARK: - View Body - - var body: some View { - content - .contextMenu { - Button("Show Details") { showDetail = true } - } - .sheet( - isPresented: $showDetail, - content: { - detailView - } - ) - } - - var content: some View { - - if let flag = flag as? BooleanEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalBooleanEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? CaseIterableEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalCaseIterableEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? StringEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalStringEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - } - - return EmptyView().eraseToAnyView() - } - -#if os(iOS) - - var detailView: some View { - NavigationView { - FlagDetailView(flag: flag, manager: manager) - .navigationBarItems(trailing: detailDoneButton) - } - } - -#elseif os(macOS) - - var detailView: some View { - VStack { - FlagDetailView(flag: flag, manager: manager) - HStack { - Spacer() - detailDoneButton - } - } - .padding() - } - -#endif - - var detailDoneButton: some View { - Button("Close") { - showDetail = false - } - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/Unfurlable.swift b/Sources/Vexillographer/Unfurling/Unfurlable.swift deleted file mode 100644 index e9e1be2c..00000000 --- a/Sources/Vexillographer/Unfurling/Unfurlable.swift +++ /dev/null @@ -1,57 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// Describes a type that can "unfurl" itself. -/// -/// Basically this is used to provide the Flag and FlagGroups with a way to create a type-erased `UnfurledFlagItem` -/// that describes themelves. -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol Unfurlable { - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension Flag: Unfurlable where Value: FlagValue { - - /// Creates an `UnfurledFlag` from the receiver and returns it as a type-erased `UnfurledFlagItem` - /// - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? { - guard info.shouldDisplay == true else { - return nil - } - let unfurled = UnfurledFlag(name: info.flagValueSourceName ?? label.localizedDisplayName, flag: self, manager: manager) - return unfurled.isEditable ? unfurled : nil - } -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension FlagGroup: Unfurlable { - - /// Creates an `UnfurledFlagGroup` from the receiver and returns it as a type-erased `UnfurledFlagItem` - /// - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? { - guard info.shouldDisplay == true else { - return nil - } - let unfurled = UnfurledFlagGroup(name: info.flagValueSourceName ?? label.localizedDisplayName, group: self, manager: manager) - return unfurled.isEditable ? unfurled : nil - } -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlag.swift b/Sources/Vexillographer/Unfurling/UnfurledFlag.swift deleted file mode 100644 index ce57c603..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlag.swift +++ /dev/null @@ -1,69 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlag: UnfurledFlagItem, Identifiable where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let info: UnfurledFlagInfo - let flag: Flag - let hasChildren = false - - private let manager: FlagValueManager - - var id: UUID { - flag.id - } - - var isEditable: Bool { - self is BooleanEditableFlag - || self is CaseIterableEditableFlag - || self is StringEditableFlag - || self is OptionalBooleanEditableFlag - || self is OptionalCaseIterableEditableFlag - || self is OptionalStringEditableFlag - } - - var childLinks: [UnfurledFlagItem]? { - nil - } - - var isLink: Bool { - false - } - - // MARK: - Initialisation - - init(name: String, flag: Flag, manager: FlagValueManager) { - self.info = UnfurledFlagInfo(key: flag.key, info: flag.info, defaultName: name) - self.flag = flag - self.manager = manager - } - - - // MARK: - Unfurled Flag Item Conformance - - var unfurledView: AnyView { - AnyView(UnfurledFlagView(flag: self, manager: manager)) - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift deleted file mode 100644 index c23b4e8d..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift +++ /dev/null @@ -1,112 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagGroup: UnfurledFlagItem, Identifiable where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let info: UnfurledFlagInfo - let group: FlagGroup - let hasChildren = true - - private let manager: FlagValueManager - - var id: UUID { - group.id - } - - var isEditable: Bool { - allItems() - .isEmpty == false - } - - var isLink: Bool { - group.display == .navigation - } - - var childLinks: [UnfurledFlagItem]? { - let children = allItems().filter { $0.hasChildren == true && $0.isLink } - return children.isEmpty == false ? children : nil - } - - // MARK: - Initialisation - - init(name: String, group: FlagGroup, manager: FlagValueManager) { - self.info = UnfurledFlagInfo(key: "", info: group.info, defaultName: name) - self.group = group - self.manager = manager - } - - - // MARK: - Unfurled Flag Item Conformance - - func allItems() -> [UnfurledFlagItem] { - Mirror(reflecting: group.wrappedValue) - .children - .compactMap { child -> UnfurledFlagItem? in - guard let label = child.label, let unfurlable = child.value as? Unfurlable else { - return nil - } - guard let unfurled = unfurlable.unfurl(label: label, manager: manager) else { - return nil - } - return unfurled.isEditable ? unfurled : nil - } - } - - var unfurledView: AnyView { - switch group.display { - case .navigation: - unfurledNavigationLink - - case .section: - UnfurledFlagSectionView(group: self, manager: manager) - .eraseToAnyView() - } - } - - private var unfurledNavigationLink: AnyView { - var destination = UnfurledFlagGroupView(group: self, manager: manager).eraseToAnyView() - -#if os(iOS) - - destination = destination - .navigationBarTitle(Text(info.flagValueSourceName), displayMode: .inline) - .eraseToAnyView() - -#elseif compiler(>=5.3.1) - - destination = destination - .navigationTitle(info.flagValueSourceName) - .eraseToAnyView() - -#endif - - return NavigationLink(destination: destination) { - HStack { - Text(info.flagValueSourceName) - .font(.headline) - } - }.eraseToAnyView() - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift deleted file mode 100644 index 447d57e2..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift +++ /dev/null @@ -1,42 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagInfo { - - // MARK: - Properties - - /// The flag's key - let key: String - - /// The name of the unfurled flag or flag group - let name: String - - /// A brief description of the unfurled flag or flag group - let description: String - - - // MARK: - Initialisation - - init(key: String, info: FlagInfo, defaultName: String) { - self.key = key - self.name = info.flagValueSourceName ?? defaultName - self.description = info.description - } -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift deleted file mode 100644 index 4f5cdd67..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol UnfurledFlagItem { - var id: UUID { get } - var info: UnfurledFlagInfo { get } - var hasChildren: Bool { get } - var childLinks: [UnfurledFlagItem]? { get } - var unfurledView: AnyView { get } - var isEditable: Bool { get } - var isLink: Bool { get } -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagItemView: View { - var item: UnfurledFlagItem - - var body: some View { - item.unfurledView.id(item.id) - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/AnyView.swift b/Sources/Vexillographer/Utilities/AnyView.swift deleted file mode 100644 index 6820df46..00000000 --- a/Sources/Vexillographer/Utilities/AnyView.swift +++ /dev/null @@ -1,24 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -extension View { - func eraseToAnyView() -> AnyView { - AnyView(self) - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/DisplayName.swift b/Sources/Vexillographer/Utilities/DisplayName.swift deleted file mode 100644 index c6cb5b70..00000000 --- a/Sources/Vexillographer/Utilities/DisplayName.swift +++ /dev/null @@ -1,100 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation - -extension String { - var localizedDisplayName: String { - displayName(with: Locale.autoupdatingCurrent) - } - - var displayName: String { - self.displayName(with: nil) - } - - func displayName(with locale: Locale?) -> String { - let uppercased = CharacterSet.uppercaseLetters - return (hasPrefix("_") ? String(dropFirst()) : self) - .separatedAtWordBoundaries - .map { CharacterSet(charactersIn: $0).isStrictSubset(of: uppercased) ? $0 : $0.capitalized(with: locale) } - .joined(separator: " ") - } - - /// Separates a string at word boundaries, eg. `oneTwoThree` becomes `one Two Three` - /// - /// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` - /// and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt). - /// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means - /// the result is consistent regardless of the current user's locale and language preferences. - /// - /// Adapted from JSONEncoder's `toSnakeCase()` - /// - var separatedAtWordBoundaries: [String] { - guard !isEmpty else { - return [] - } - - let string = self - - var words: [Range] = [] - // The general idea of this algorithm is to split words on transition from lower to upper case, then on - // transition of >1 upper case characters to lowercase - // - // myProperty -> my_property - // myURLProperty -> my_url_property - // - // We assume, per Swift naming conventions, that the first character of the key is lowercase. - var wordStart = string.startIndex - var searchRange = string.index(after: wordStart) ..< string.endIndex - - let uppercase = CharacterSet.uppercaseLetters.union(CharacterSet.decimalDigits) - - // Find next uppercase character - while let upperCaseRange = string.rangeOfCharacter(from: uppercase, options: [], range: searchRange) { - let untilUpperCase = wordStart ..< upperCaseRange.lowerBound - words.append(untilUpperCase) - - // Find next lowercase character - searchRange = upperCaseRange.lowerBound ..< searchRange.upperBound - guard let lowerCaseRange = string.rangeOfCharacter(from: CharacterSet.lowercaseLetters, options: [], range: searchRange) else { - // There are no more lower case letters. Just end here. - wordStart = searchRange.lowerBound - break - } - - // Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase - // letters that we should treat as its own word - let nextCharacterAfterCapital = string.index(after: upperCaseRange.lowerBound) - if lowerCaseRange.lowerBound == nextCharacterAfterCapital { - // The next character after capital is a lower case character and therefore not a word boundary. - // Continue searching for the next upper case for the boundary. - wordStart = upperCaseRange.lowerBound - } else { - // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character. - let beforeLowerIndex = string.index(before: lowerCaseRange.lowerBound) - words.append(upperCaseRange.lowerBound ..< beforeLowerIndex) - - // Next word starts at the capital before the lowercase we just found - wordStart = beforeLowerIndex - } - searchRange = lowerCaseRange.upperBound ..< searchRange.upperBound - } - words.append(wordStart ..< searchRange.upperBound) - - return words.map { string[$0].lowercased() } - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/OptionalFlagValues.swift b/Sources/Vexillographer/Utilities/OptionalFlagValues.swift deleted file mode 100644 index ace14a5b..00000000 --- a/Sources/Vexillographer/Utilities/OptionalFlagValues.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -protocol OptionalFlagValue { - associatedtype WrappedFlagValue: FlagValue - - var wrapped: WrappedFlagValue? { get set } - - init(_ wrapped: WrappedFlagValue?) -} - -extension Optional: OptionalFlagValue where Wrapped: FlagValue { - typealias WrappedFlagValue = Wrapped - - var wrapped: Wrapped? { - get { - self - } - set { - self = newValue - } - } - - init(_ wrapped: Wrapped?) { - self = wrapped - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/OptionalProtocol.swift b/Sources/Vexillographer/Utilities/OptionalProtocol.swift new file mode 100644 index 00000000..ee0fe3d1 --- /dev/null +++ b/Sources/Vexillographer/Utilities/OptionalProtocol.swift @@ -0,0 +1,11 @@ +protocol OptionalProtocol { + associatedtype Wrapped + var wrapped: Wrapped? { get set } +} + +extension Optional: OptionalProtocol { + var wrapped: Wrapped? { + get { self } + set { self = newValue } + } +} diff --git a/Sources/Vexillographer/Utilities/Pasteboard.swift b/Sources/Vexillographer/Utilities/Pasteboard.swift deleted file mode 100644 index 540889ce..00000000 --- a/Sources/Vexillographer/Utilities/Pasteboard.swift +++ /dev/null @@ -1,34 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) - -import UIKit - -extension String { - func copyToPasteboard() { - UIPasteboard.general.string = self - } -} - -#elseif os(macOS) - -import Cocoa - -extension String { - func copyToPasteboard() { - NSPasteboard.general.setString(self, forType: .string) - } -} - -#endif diff --git a/Sources/Vexillographer/Vexillographer.swift b/Sources/Vexillographer/Vexillographer.swift index f247bfc2..728faf3b 100644 --- a/Sources/Vexillographer/Vexillographer.swift +++ b/Sources/Vexillographer/Vexillographer.swift @@ -1,94 +1,36 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - import SwiftUI import Vexil -#if os(macOS) && compiler(>=5.3.1) - -/// A SwiftUI View that allows you to easily edit the flag -/// structure in a provided FlagValueSource. -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -public struct Vexillographer: View where RootGroup: FlagContainer { +public struct Vexillographer: View { - // MARK: - Properties + @State private var searchText = "" - @ObservedObject - var manager: FlagValueManager - - // MARK: - Initialisation - - /// Initialises a new `Vexillographer` instance with the provided FlagPole and source - /// - /// - Parameters; - /// - flagPole: A `FlagPole` instance manages the flag and source hierarchy we want to display - /// - source: An optional `FlagValueSource` for editing the flag values in. If `nil` the flag values are displayed read-only - /// - public init(flagPole: FlagPole, source: FlagValueSource?) { - self._manager = ObservedObject(wrappedValue: FlagValueManager(flagPole: flagPole, source: source)) - } - - // MARK: - Body + public init() {} public var body: some View { - List(manager.allItems(), id: \.id, children: \.childLinks) { item in - UnfurledFlagItemView(item: item) - } - .listStyle(SidebarListStyle()) - .toolbar { - ToolbarItem(placement: .navigation) { - Button(action: NSApp.toggleKeyWindowSidebar) { - Image(systemName: "sidebar.left") - } - } - } + FlagList(searchText: searchText) + .searchable(text: $searchText) } -} -#else - -/// A SwiftUI View that allows you to easily edit the flag -/// structure in a provided FlagValueSource. -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -public struct Vexillographer: View where RootGroup: FlagContainer { - - // MARK: - Properties - - @State - var manager: FlagValueManager +} - // MARK: - Initialisation +private struct FlagList: View { - /// Initialises a new `Vexillographer` instance with the provided FlagPole and source - /// - /// - Parameters; - /// - flagPole: A `FlagPole` instance manages the flag and source hierarchy we want to display - /// - source: An optional `FlagValueSource` for editing the flag values in. If `nil` the flag values are displayed read-only - /// - public init(flagPole: FlagPole, source: FlagValueSource?) { - self._manager = State(wrappedValue: FlagValueManager(flagPole: flagPole, source: source)) - } + var searchText: String + @Environment(\.flagPoleContext) private var flagPoleContext + @Environment(\.isSearching) private var isSearching - public var body: some View { - ForEach(manager.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) + var body: some View { + List { + if isSearching { + let searchResult = flagPoleContext.items(matching: searchText) + ForEach(searchResult, id: \.keyPath, content: \.content) + } else { + let visibleItems = flagPoleContext.items.filter { $0.isHidden == false } + ForEach(visibleItems, id: \.keyPath, content: \.content) + } } - .environmentObject(manager) + .listStyle(.insetGrouped) } -} -#endif - -#endif +} diff --git a/Sources/Vexillographer/View+FlagControlStyle.swift b/Sources/Vexillographer/View+FlagControlStyle.swift new file mode 100644 index 00000000..24c139d0 --- /dev/null +++ b/Sources/Vexillographer/View+FlagControlStyle.swift @@ -0,0 +1,40 @@ +import SwiftUI +import Vexil + +public protocol FlagControlStyle: DynamicProperty { + associatedtype Value: FlagValue + associatedtype Body: View + @ViewBuilder @MainActor func makeBody(configuration: Configuration) -> Body + typealias Configuration = FlagControlConfiguration +} + +public extension View { + + func flagControlStyle(_ style: Style) -> some View { + modifier(FlagControlStyleModifier(style: style, key: ObjectIdentifier(Style.Value.self))) + } + + func flagControlStyle(_ style: Style, for keyPath: KeyPath) -> some View { + modifier(FlagControlStyleModifier(style: style, key: keyPath)) + } + +} + +private struct FlagControlStyleModifier: ViewModifier { + var style: Style + var key: AnyHashable + + func body(content: Content) -> some View { + content + .transformEnvironment(\.flagPoleContext) { + $0.styles[key] = style + } + } +} + +extension FlagControlStyle { + @MainActor func control(configuration: Configuration) -> AnyView? { + (configuration as? Configuration).map { AnyView(StyledFlagControl(configuration: $0, style: self)) } + } +} + diff --git a/Sources/Vexillographer/View+FlagPole.swift b/Sources/Vexillographer/View+FlagPole.swift new file mode 100644 index 00000000..d6dd7c05 --- /dev/null +++ b/Sources/Vexillographer/View+FlagPole.swift @@ -0,0 +1,31 @@ +import SwiftUI +import Vexil + +public extension View { + + func flagPole( + _ flagPole: FlagPole, + editableSource: (any FlagValueSource)? = nil + ) -> some View { + modifier(FlagPoleModifier(flagPole: flagPole, editableSource: editableSource)) + } + +} + +private struct FlagPoleModifier: ViewModifier { + var flagPole: FlagPole + var editableSource: (any FlagValueSource)? + + func body(content: Content) -> some View { + // TODO: Can cache this. + let visitor = FlagPoleVisitor(lookup: flagPole) + flagPole.walk(visitor: visitor) + return content + .transformEnvironment(\.flagPoleContext) { + $0.items = visitor.items + $0.keyPathByFlagKeyPath = visitor.keyPathByFlagKeyPath + $0.editableSource = editableSource + $0.sources = flagPole._sources + } + } +} From b327a949bdcc403fe874a9c088486497228907b8 Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Fri, 17 Oct 2025 08:23:23 +1100 Subject: [PATCH 02/17] Add examples project --- .../contents.xcworkspacedata | 7 + Examples/Examples.xcodeproj/project.pbxproj | 499 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 33 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Examples/Assets.xcassets/Contents.json | 6 + Examples/Examples/Dependencies.swift | 14 + Examples/Examples/ExamplesApp.swift | 10 + Examples/Examples/FeatureFlags.swift | 15 + Examples/Examples/RootView.swift | 27 + Examples/ExamplesTests/ExamplesTests.swift | 10 + Examples/Package.swift | 10 + 13 files changed, 684 insertions(+) create mode 100644 Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Examples.xcodeproj/project.pbxproj create mode 100644 Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/Examples/Assets.xcassets/Contents.json create mode 100644 Examples/Examples/Dependencies.swift create mode 100644 Examples/Examples/ExamplesApp.swift create mode 100644 Examples/Examples/FeatureFlags.swift create mode 100644 Examples/Examples/RootView.swift create mode 100644 Examples/ExamplesTests/ExamplesTests.swift create mode 100644 Examples/Package.swift diff --git a/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj new file mode 100644 index 00000000..5b3ab83b --- /dev/null +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -0,0 +1,499 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 436D05AB2EA194D30056498A /* Vexil in Frameworks */ = {isa = PBXBuildFile; productRef = 436D05AA2EA194D30056498A /* Vexil */; }; + 436D05AD2EA194D30056498A /* Vexillographer in Frameworks */ = {isa = PBXBuildFile; productRef = 436D05AC2EA194D30056498A /* Vexillographer */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 436D05732EA193620056498A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 436D055D2EA193610056498A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 436D05642EA193610056498A; + remoteInfo = Examples; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 436D05652EA193610056498A /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 436D05722EA193620056498A /* ExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 436D05672EA193610056498A /* Examples */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Examples; + sourceTree = ""; + }; + 436D05752EA193620056498A /* ExamplesTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ExamplesTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 436D05622EA193610056498A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 436D05AD2EA194D30056498A /* Vexillographer in Frameworks */, + 436D05AB2EA194D30056498A /* Vexil in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D056F2EA193620056498A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 436D055C2EA193610056498A = { + isa = PBXGroup; + children = ( + 436D05672EA193610056498A /* Examples */, + 436D05752EA193620056498A /* ExamplesTests */, + 436D05662EA193610056498A /* Products */, + ); + sourceTree = ""; + }; + 436D05662EA193610056498A /* Products */ = { + isa = PBXGroup; + children = ( + 436D05652EA193610056498A /* Examples.app */, + 436D05722EA193620056498A /* ExamplesTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 436D05642EA193610056498A /* Examples */ = { + isa = PBXNativeTarget; + buildConfigurationList = 436D05862EA193620056498A /* Build configuration list for PBXNativeTarget "Examples" */; + buildPhases = ( + 436D05612EA193610056498A /* Sources */, + 436D05622EA193610056498A /* Frameworks */, + 436D05632EA193610056498A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 436D05672EA193610056498A /* Examples */, + ); + name = Examples; + packageProductDependencies = ( + 436D05AA2EA194D30056498A /* Vexil */, + 436D05AC2EA194D30056498A /* Vexillographer */, + ); + productName = Examples; + productReference = 436D05652EA193610056498A /* Examples.app */; + productType = "com.apple.product-type.application"; + }; + 436D05712EA193620056498A /* ExamplesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 436D05892EA193620056498A /* Build configuration list for PBXNativeTarget "ExamplesTests" */; + buildPhases = ( + 436D056E2EA193620056498A /* Sources */, + 436D056F2EA193620056498A /* Frameworks */, + 436D05702EA193620056498A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 436D05742EA193620056498A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 436D05752EA193620056498A /* ExamplesTests */, + ); + name = ExamplesTests; + packageProductDependencies = ( + ); + productName = ExamplesTests; + productReference = 436D05722EA193620056498A /* ExamplesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 436D055D2EA193610056498A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 436D05642EA193610056498A = { + CreatedOnToolsVersion = 26.0; + }; + 436D05712EA193620056498A = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 436D05642EA193610056498A; + }; + }; + }; + buildConfigurationList = 436D05602EA193610056498A /* Build configuration list for PBXProject "Examples" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 436D055C2EA193610056498A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 436D05A92EA194D30056498A /* XCLocalSwiftPackageReference "../" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 436D05662EA193610056498A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 436D05642EA193610056498A /* Examples */, + 436D05712EA193620056498A /* ExamplesTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 436D05632EA193610056498A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D05702EA193620056498A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 436D05612EA193610056498A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D056E2EA193620056498A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 436D05742EA193620056498A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 436D05642EA193610056498A /* Examples */; + targetProxy = 436D05732EA193620056498A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 436D05842EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 436D05852EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 436D05872EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.Examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 436D05882EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.Examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 436D058A2EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; + }; + name = Debug; + }; + 436D058B2EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 436D05602EA193610056498A /* Build configuration list for PBXProject "Examples" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D05842EA193620056498A /* Debug */, + 436D05852EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 436D05862EA193620056498A /* Build configuration list for PBXNativeTarget "Examples" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D05872EA193620056498A /* Debug */, + 436D05882EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 436D05892EA193620056498A /* Build configuration list for PBXNativeTarget "ExamplesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D058A2EA193620056498A /* Debug */, + 436D058B2EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 436D05A92EA194D30056498A /* XCLocalSwiftPackageReference "../" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 436D05AA2EA194D30056498A /* Vexil */ = { + isa = XCSwiftPackageProductDependency; + productName = Vexil; + }; + 436D05AC2EA194D30056498A /* Vexillographer */ = { + isa = XCSwiftPackageProductDependency; + productName = Vexillographer; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 436D055D2EA193610056498A /* Project object */; +} diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..be939c45 --- /dev/null +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "7d18382a80812208b61dcb5af0b5caf1a54236a8ca18bf984fd71055ff6d89a2", + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Assets.xcassets/Contents.json b/Examples/Examples/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Dependencies.swift b/Examples/Examples/Dependencies.swift new file mode 100644 index 00000000..d4464f08 --- /dev/null +++ b/Examples/Examples/Dependencies.swift @@ -0,0 +1,14 @@ +import Vexil + +struct Dependencies { + var flags = FlagPole( + hoist: FeatureFlags.self, + sources: FlagPole.defaultSources + [RemoteFlags.values] + ) + + @TaskLocal static var current = Dependencies() +} + +enum RemoteFlags { + static let values = FlagValueDictionary() +} diff --git a/Examples/Examples/ExamplesApp.swift b/Examples/Examples/ExamplesApp.swift new file mode 100644 index 00000000..dcaa6979 --- /dev/null +++ b/Examples/Examples/ExamplesApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ExamplesApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/Examples/Examples/FeatureFlags.swift b/Examples/Examples/FeatureFlags.swift new file mode 100644 index 00000000..3c3fc373 --- /dev/null +++ b/Examples/Examples/FeatureFlags.swift @@ -0,0 +1,15 @@ +import Vexil + +@FlagContainer +struct FeatureFlags { + + @Flag("Whether to display the developer menu in the UI") + var developerMenuEnabled = false + + @Flag("A string flag") + var string = "Blob" + + @Flag("A number flag") + var number = 42 + +} diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift new file mode 100644 index 00000000..adecd670 --- /dev/null +++ b/Examples/Examples/RootView.swift @@ -0,0 +1,27 @@ +import SwiftUI +import Vexillographer +import Vexil + +struct RootView: View { + var body: some View { + NavigationView { + Vexillographer() + .flagPole( + Dependencies.current.flags, + editableSource: Dependencies.current.flags._sources.first + ) + } + .task { + do { + for value in 0... { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + try RemoteFlags.values.setFlagValue(value, key: "number") + } + } catch { } + } + } +} + +#Preview { + RootView() +} diff --git a/Examples/ExamplesTests/ExamplesTests.swift b/Examples/ExamplesTests/ExamplesTests.swift new file mode 100644 index 00000000..5aa5cdd3 --- /dev/null +++ b/Examples/ExamplesTests/ExamplesTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import Examples + +struct ExamplesTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/Examples/Package.swift b/Examples/Package.swift new file mode 100644 index 00000000..104d5ac9 --- /dev/null +++ b/Examples/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version:6.1 + +import PackageDescription + +let package = Package( + name: "", + products: [], + dependencies: [], + targets: [] +) From 48e3c202ade9a692e18d96f8688ab6bb8d0db44d Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Mon, 20 Oct 2025 08:05:00 +1100 Subject: [PATCH 03/17] Fix watchOS and mac builds --- .../FlagTextField+FloatingPoint.swift | 17 +++-- .../FlagControl/FlagTextField+Integer.swift | 16 +++-- .../FlagControl/FlagTextField.swift | 67 ++++++++----------- Sources/Vexillographer/Vexillographer.swift | 2 + 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift index 8b480bbd..e1d939a9 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift @@ -4,12 +4,14 @@ import Vexil extension FlagTextField where Value.BoxedValueType: BinaryFloatingPoint { init(configuration: FlagControlConfiguration) { - self.init( + self = Self( configuration: configuration, formatted: \.asString, - keyboardType: .decimalPad, editingFormat: { $0 } ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif } } @@ -39,12 +41,15 @@ extension FlagControlConfiguration: FloatingPointTextFieldRepresentable where Va extension FlagTextField { init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryFloatingPoint { - self.init( + self = Self( configuration: configuration, formatted: \.asStringOrEmpty, - keyboardType: .decimalPad, editingFormat: { $0 } ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif + } } @@ -70,8 +75,10 @@ extension FlagControlConfiguration: OptionalFloatingPointFlagTextFieldRepresenta FlagTextField( configuration: self, formatted: \.asStringOrEmpty, - keyboardType: .decimalPad, editingFormat: { $0 } ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif } } diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift index e34484ea..bf5d4138 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift @@ -4,12 +4,14 @@ import Vexil extension FlagTextField where Value.BoxedValueType: BinaryInteger { init(configuration: FlagControlConfiguration) { - self.init( + self = Self( configuration: configuration, formatted: \.asString, - keyboardType: .numberPad, editingFormat: { $0.filter(\.isNumber) } ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif } } @@ -39,12 +41,14 @@ extension FlagControlConfiguration: IntegerFlagTextFieldRepresentable where Valu extension FlagTextField { init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryInteger { - self.init( + self = Self( configuration: configuration, formatted: \.asStringOrEmpty, - keyboardType: .numberPad, editingFormat: { $0.filter(\.isNumber) } ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif } } @@ -70,8 +74,10 @@ extension FlagControlConfiguration: OptionalIntegerFlagTextFieldRepresentable wh FlagTextField( configuration: self, formatted: \.asStringOrEmpty, - keyboardType: .numberPad, editingFormat: { $0.filter(\.isNumber) } ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif } } diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift index 1b3be8a2..eb19fc12 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -9,47 +9,28 @@ struct FlagTextField: View { private var formatted: WritableKeyPath private var format: (String) -> String private var editingFormat: (String) -> String - #if canImport(UIKit) - private var keyboardType: UIKeyboardType - #endif +#if os(iOS) || os(tvOS) + private var keyboardType = UIKeyboardType.default +#endif @State private var cachedText: String? @FocusState private var isFocused - #if canImport(UIKit) - init( - configuration: FlagControlConfiguration, - formatted: WritableKeyPath, - keyboardType: UIKeyboardType = .default, - placeholder: String = "", - format: @escaping (String) -> String = { $0 }, - editingFormat: @escaping (String) -> String = { $0 } - ) { - self.name = configuration.name - self._value = configuration.$value - self.keyboardType = keyboardType - self.placeholder = placeholder - self.formatted = formatted - self.format = format - self.editingFormat = editingFormat - } - #else - init( - configuration: FlagControlConfiguration, - formatted: WritableKeyPath, - placeholder: String = "", - format: @escaping (String) -> String = { $0 }, - editingFormat: @escaping (String) -> String = { $0 } - ) { - self.name = configuration.name - self._value = configuration.$value - self.placeholder = placeholder - self.formatted = formatted - self.format = format - self.editingFormat = editingFormat - } - #endif + init( + configuration: FlagControlConfiguration, + formatted: WritableKeyPath, + placeholder: String = "", + format: @escaping (String) -> String = { $0 }, + editingFormat: @escaping (String) -> String = { $0 } + ) { + name = configuration.name + _value = configuration.$value + self.placeholder = placeholder + self.formatted = formatted + self.format = format + self.editingFormat = editingFormat + } var body: some View { HStack { @@ -58,9 +39,10 @@ struct FlagTextField: View { TextField(placeholder, text: text) .multilineTextAlignment(.trailing) .accessibilityLabel(name) - #if canImport(UIKit) + .submitLabel(.done) +#if os(iOS) || os(tvOS) .keyboardType(keyboardType) - #endif +#endif } .onChange(of: value.boxedFlagValue) { _ in cachedText = nil @@ -89,5 +71,12 @@ struct FlagTextField: View { ) } -} +#if os(iOS) || os(tvOS) + func keyboardType(_ type: UIKeyboardType) -> Self { + var copy = self + copy.keyboardType = type + return copy + } +#endif +} diff --git a/Sources/Vexillographer/Vexillographer.swift b/Sources/Vexillographer/Vexillographer.swift index 728faf3b..9db52f7a 100644 --- a/Sources/Vexillographer/Vexillographer.swift +++ b/Sources/Vexillographer/Vexillographer.swift @@ -30,7 +30,9 @@ private struct FlagList: View { ForEach(visibleItems, id: \.keyPath, content: \.content) } } +#if os(iOS) .listStyle(.insetGrouped) +#endif } } From b26dc71f3a3f6580e1dd279ee2d6da62ebc90110 Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Mon, 20 Oct 2025 09:24:17 +1100 Subject: [PATCH 04/17] Disable auto correction on text field --- Sources/Vexillographer/FlagControl/FlagTextField.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift index eb19fc12..9d1ef716 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -40,6 +40,8 @@ struct FlagTextField: View { .multilineTextAlignment(.trailing) .accessibilityLabel(name) .submitLabel(.done) + .autocorrectionDisabled() + .textContentType(nil) #if os(iOS) || os(tvOS) .keyboardType(keyboardType) #endif From e97f189e06b16fdae235702f88cf8b75917d3d5b Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Mon, 20 Oct 2025 09:24:33 +1100 Subject: [PATCH 05/17] Add some more example flags --- Examples/Examples/FeatureFlags.swift | 44 ++++++++++++++++++++++++++-- Examples/Examples/RootView.swift | 32 ++++++++++++-------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/Examples/Examples/FeatureFlags.swift b/Examples/Examples/FeatureFlags.swift index 3c3fc373..f19491d9 100644 --- a/Examples/Examples/FeatureFlags.swift +++ b/Examples/Examples/FeatureFlags.swift @@ -3,13 +3,51 @@ import Vexil @FlagContainer struct FeatureFlags { - @Flag("Whether to display the developer menu in the UI") + @Flag(description: "Whether to display the developer menu in the UI", display: .hidden) var developerMenuEnabled = false + @FlagGroup(description: "Builtin types", display: .section) + var builtinTypes: BuiltinTypes + +} + +@FlagContainer +struct BuiltinTypes { + + @Flag("A boolean flag") + var boolean = true + + @Flag("An optional boolean flag") + var optionalBoolean: Bool? + @Flag("A string flag") var string = "Blob" - @Flag("A number flag") - var number = 42 + @Flag("An optional string flag") + var optionalString: String? + + @Flag("An integer flag") + var integer = 42 + + @Flag("An optional integer flag") + var optionalInteger: Int? + + @Flag("A double flag") + var double = 1729.42 + + @Flag("An optional double flag") + var optionalDouble: Double? + + @Flag("An case iterable flag") + var caseIterable = Enum.foo + + @Flag("An optional case iterable flag") + var optionalCaseIterable: Enum? + + enum Enum: String, CaseIterable, FlagValue { + case foo + case bar + case baz + } } diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift index adecd670..991b6f48 100644 --- a/Examples/Examples/RootView.swift +++ b/Examples/Examples/RootView.swift @@ -1,25 +1,31 @@ import SwiftUI -import Vexillographer import Vexil +import Vexillographer struct RootView: View { + var body: some View { NavigationView { - Vexillographer() - .flagPole( - Dependencies.current.flags, - editableSource: Dependencies.current.flags._sources.first - ) - } - .task { - do { - for value in 0... { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - try RemoteFlags.values.setFlagValue(value, key: "number") + List { + FlagControl(Dependencies.current.flags.$developerMenuEnabled) { configuration in + Section { + FlagToggle(configuration: configuration) + } + if configuration.value { + NavigationLink("Developer Menu") { + Vexillographer() + } + } } - } catch { } + } } + .flagPole( + Dependencies.current.flags, + editableSource: Dependencies.current.flags._sources.first + ) + } + } #Preview { From fc27978cce173f2aad38a2f397ac39dae0f208ae Mon Sep 17 00:00:00 2001 From: Pat Brown Date: Tue, 21 Oct 2025 17:52:56 +1100 Subject: [PATCH 06/17] More updates and adding notes --- .../DoubleAndBooleanControlStyle.swift | 26 ++++++++++++++ Examples/Examples/FeatureFlags.swift | 22 ++++++++++++ Examples/Examples/RootView.swift | 1 + Sources/Vexil/Group.swift | 6 ++++ .../FlagControl/FlagControl.swift | 18 +++++----- .../FlagControlConfiguration.swift | 2 ++ .../FlagControl/FlagDetail.swift | 36 +++++++++++-------- .../FlagControl/FlagPicker+Bool.swift | 1 + .../FlagControl/FlagPicker+CaseIterable.swift | 1 + .../FlagControl/FlagPicker.swift | 1 + .../FlagTextField+FloatingPoint.swift | 1 + .../FlagControl/FlagTextField+Integer.swift | 1 + .../FlagControl/FlagTextField+String.swift | 1 + .../FlagControl/FlagTextField.swift | 4 +++ .../FlagControl/FlagToggle.swift | 1 + .../FlagControl/RowContent.swift | 1 + .../FlagPole/FlagGroupItem.swift | 2 +- .../Vexillographer/FlagPole/FlagItem.swift | 4 +-- .../FlagPole/FlagPoleContext.swift | 3 +- .../FlagPole/FlagPoleVisitor.swift | 16 +++++---- .../Utilities/OptionalProtocol.swift | 1 + Sources/Vexillographer/Vexillographer.swift | 3 +- .../View+FlagControlStyle.swift | 14 ++++++-- 23 files changed, 128 insertions(+), 38 deletions(-) create mode 100644 Examples/Examples/DoubleAndBooleanControlStyle.swift diff --git a/Examples/Examples/DoubleAndBooleanControlStyle.swift b/Examples/Examples/DoubleAndBooleanControlStyle.swift new file mode 100644 index 00000000..512356b9 --- /dev/null +++ b/Examples/Examples/DoubleAndBooleanControlStyle.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Vexillographer + +struct DoubleAndBooleanControlStyle: FlagControlStyle { + + func makeBody(configuration: Configuration) -> some View { + VStack { + Toggle(configuration.name, isOn: configuration.$value.isEnabled) + Slider(value: configuration.$value.percent, in: 0...1.0) { + Text("Percent \(configuration.value.percent)") + } minimumValueLabel: { + Text("0.0") + } maximumValueLabel: { + Text("1.0") + } + .disabled(!configuration.value.isEnabled) + } + } + +} + +extension FlagControlStyle where Self == DoubleAndBooleanControlStyle { + + static var doubleAndBoolean: Self { Self() } + +} diff --git a/Examples/Examples/FeatureFlags.swift b/Examples/Examples/FeatureFlags.swift index f19491d9..481dd507 100644 --- a/Examples/Examples/FeatureFlags.swift +++ b/Examples/Examples/FeatureFlags.swift @@ -9,6 +9,9 @@ struct FeatureFlags { @FlagGroup(description: "Builtin types", display: .section) var builtinTypes: BuiltinTypes + @FlagGroup("Custom flags") + var customFlags: CustomFlags + } @FlagContainer @@ -51,3 +54,22 @@ struct BuiltinTypes { } } + +@FlagContainer +struct CustomFlags { + + @Flag("A boolean flag") + var boolean = true + + @Flag("A double flag") + var double = 1729.42 + + @Flag("A double and boolaen flag") + var doubleAndBoolean = DoubleAndBoolean() + + struct DoubleAndBoolean: Codable, Equatable, FlagValue { + var percent = 0.5 + var isEnabled = false + } + +} diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift index 991b6f48..50608380 100644 --- a/Examples/Examples/RootView.swift +++ b/Examples/Examples/RootView.swift @@ -23,6 +23,7 @@ struct RootView: View { Dependencies.current.flags, editableSource: Dependencies.current.flags._sources.first ) + .flagControlStyle(.doubleAndBoolean) } diff --git a/Sources/Vexil/Group.swift b/Sources/Vexil/Group.swift index 1c8f8c22..fbf7d94e 100644 --- a/Sources/Vexil/Group.swift +++ b/Sources/Vexil/Group.swift @@ -11,6 +11,12 @@ // //===----------------------------------------------------------------------===// +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro FlagGroup( + _ description: StaticString +) = #externalMacro(module: "VexilMacros", type: "FlagGroupMacro") + @attached(accessor) @attached(peer, names: prefixed(`$`)) public macro FlagGroup( diff --git a/Sources/Vexillographer/FlagControl/FlagControl.swift b/Sources/Vexillographer/FlagControl/FlagControl.swift index fd142049..84e994ae 100644 --- a/Sources/Vexillographer/FlagControl/FlagControl.swift +++ b/Sources/Vexillographer/FlagControl/FlagControl.swift @@ -1,10 +1,11 @@ import SwiftUI import Vexil +// Public way to create single custom controls public struct FlagControl: View { - var wigwag: FlagWigwag - @ViewBuilder var content: (FlagControlConfiguration) -> Content + private var wigwag: FlagWigwag + private var content: (FlagControlConfiguration) -> Content @State private var cachedValue: Value? @State private var seed = 0 @@ -41,11 +42,11 @@ public struct FlagControl: View { } } - var editableValue: Value? { + private var editableValue: Value? { flagPoleContext.editableSource?.flagValue(key: wigwag.key) } - var nonEditableValue: Value { + private var nonEditableValue: Value { let editableSourceID = flagPoleContext.editableSource?.flagValueSourceID for source in flagPoleContext.sources where source.flagValueSourceID != editableSourceID { if let value = source.flagValue(key: wigwag.key) as Value? { @@ -55,15 +56,16 @@ public struct FlagControl: View { return wigwag.defaultValue } - var resolvedValue: Value { + private var resolvedValue: Value { editableValue ?? nonEditableValue } - func getValue() -> Value { + private func getValue() -> Value { cachedValue ?? resolvedValue } - func setValue(_ newValue: Value, transaction: Transaction) { + private func setValue(_ newValue: Value, transaction: Transaction) { + // TODO: logging guard let editableSource = flagPoleContext.editableSource else { print("Trying to set a value that isn't editable. This will be ignored.") return @@ -77,7 +79,7 @@ public struct FlagControl: View { } } - func resetValue() { + private func resetValue() { guard let editableSource = flagPoleContext.editableSource else { print("Trying to set a value that isn't editable. This will be ignored.") return diff --git a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift index ce90b3c8..ad64095e 100644 --- a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift +++ b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift @@ -1,6 +1,8 @@ import SwiftUI import Vexil +// Binding to a flag value could be a property wrapper but maybe best +// not to blur the lines public struct FlagControlConfiguration { private let seed: Int diff --git a/Sources/Vexillographer/FlagControl/FlagDetail.swift b/Sources/Vexillographer/FlagControl/FlagDetail.swift index 4ffac1cd..4dc76e4f 100644 --- a/Sources/Vexillographer/FlagControl/FlagDetail.swift +++ b/Sources/Vexillographer/FlagControl/FlagDetail.swift @@ -1,6 +1,9 @@ import SwiftUI import Vexil +// Sheet with flag info +// - can reset value +// - can see source hierarchy struct FlagDetailView: View { var configuration: FlagControlConfiguration @@ -20,19 +23,19 @@ struct FlagDetailView: View { let editableValue = editableSource.flagValue(key: configuration.key) as Value? Section("Current Source") { FlagValueRow(editableSource.flagValueSourceName, value: editableValue) - #if os(macOS) - RowContent("Clear Current Source") { - Button("Clear", role: .destructive) { - configuration.resetValue() - } - .disabled(editableValue == nil) - } - #else - Button("Clear Current Source", role: .destructive) { +#if os(macOS) + RowContent("Clear Current Source") { + Button("Clear", role: .destructive) { configuration.resetValue() } .disabled(editableValue == nil) - #endif + } +#else + Button("Clear Current Source", role: .destructive) { + configuration.resetValue() + } + .disabled(editableValue == nil) +#endif } } Section("Flagpole Source Hierarchy") { @@ -46,11 +49,11 @@ struct FlagDetailView: View { } } .navigationTitle(configuration.name) - #if os(macOS) +#if os(macOS) .padding(0) // FIXME: Views for mac - #else +#else .navigationBarTitleDisplayMode(.inline) - #endif +#endif .toolbar { ToolbarItem { Button { @@ -64,8 +67,9 @@ struct FlagDetailView: View { } struct FlagValueRow: View { - var label: String - var value: Value? + + private var label: String + private var value: Value? init(_ label: String, value: Value?) { self.label = label @@ -74,6 +78,7 @@ struct FlagValueRow: View { var body: some View { RowContent(label) { + // Clean this up if let value { if let value = value as? any OptionalProtocol { if let wrapped = value.wrapped { @@ -90,4 +95,5 @@ struct FlagValueRow: View { } } } + } diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift index 2ca3cb4f..b5bb80e0 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// Convenience for optional bool public extension FlagPicker where Value.BoxedValueType == Bool?, SelectionValue == Bool?, Content == DefaultFlagPickerContent { init(configuration: FlagControlConfiguration) { self.init(configuration: configuration, selection: \.asOptionalBool) { diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift index f3031e00..d48ef321 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// Convenience for case iterable public extension FlagPicker where Value: CaseIterable, SelectionValue == Value, Content == DefaultFlagPickerContent { init(configuration: FlagControlConfiguration) { diff --git a/Sources/Vexillographer/FlagControl/FlagPicker.swift b/Sources/Vexillographer/FlagControl/FlagPicker.swift index 470a37be..05cbfb50 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// A picker public struct FlagPicker: View { private var name: String diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift index e1d939a9..b54d4013 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// TextField convenience for floating point extension FlagTextField where Value.BoxedValueType: BinaryFloatingPoint { init(configuration: FlagControlConfiguration) { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift index bf5d4138..ab9b3d4a 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// TextField convenience for integer extension FlagTextField where Value.BoxedValueType: BinaryInteger { init(configuration: FlagControlConfiguration) { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift index e90fa076..61a5b381 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// TextField convenience for string extension FlagTextField where Value.BoxedValueType == String { init(configuration: FlagControlConfiguration) { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift index 9d1ef716..04e08962 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -1,6 +1,9 @@ import SwiftUI import Vexil +// A text field +// - want to dismiss on scroll? +// - want to have confirm/cancel? struct FlagTextField: View { private var name: String @@ -66,6 +69,7 @@ struct FlagTextField: View { .focused($isFocused) } + // Can this be computed key path? var text: Binding { Binding( get: { cachedText ?? value[keyPath: formatted] }, diff --git a/Sources/Vexillographer/FlagControl/FlagToggle.swift b/Sources/Vexillographer/FlagControl/FlagToggle.swift index 66455637..f0f30747 100644 --- a/Sources/Vexillographer/FlagControl/FlagToggle.swift +++ b/Sources/Vexillographer/FlagControl/FlagToggle.swift @@ -1,6 +1,7 @@ import SwiftUI import Vexil +// A toggle public struct FlagToggle: View where Value.BoxedValueType == Bool { private var name: String diff --git a/Sources/Vexillographer/FlagControl/RowContent.swift b/Sources/Vexillographer/FlagControl/RowContent.swift index 016fbb0d..0daa6ddc 100644 --- a/Sources/Vexillographer/FlagControl/RowContent.swift +++ b/Sources/Vexillographer/FlagControl/RowContent.swift @@ -1,5 +1,6 @@ import SwiftUI +// UI helper struct RowContent: View { var label: String diff --git a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift index f06ccfb5..63d5ce07 100644 --- a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift @@ -21,7 +21,7 @@ struct FlagGroupItem: FlagPoleItemGroup { var name: String { group.name } - + var visibleItems: [any FlagPoleItem] { items.filter { $0.isHidden == false } } diff --git a/Sources/Vexillographer/FlagPole/FlagItem.swift b/Sources/Vexillographer/FlagPole/FlagItem.swift index 425f9cee..28bbf399 100644 --- a/Sources/Vexillographer/FlagPole/FlagItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagItem.swift @@ -32,8 +32,8 @@ struct FlagItemContent: View { @State private var isShowingDetail = false @FocusState private var isFocused - @Environment(\.flagPoleContext) var flagPoleContext - + @Environment(\.flagPoleContext) private var flagPoleContext + var body: some View { FlagControl(wigwag) { configuration in HStack { diff --git a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift index 293f0261..b7169d5e 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift @@ -13,7 +13,8 @@ struct FlagPoleContext { items.flatMap { $0.items(matching: searchText) } } - @MainActor func styledControl(configuration: FlagControlConfiguration) -> AnyView? { + @MainActor + func styledControl(configuration: FlagControlConfiguration) -> AnyView? { if let keyPath = keyPathByFlagKeyPath[configuration.keyPath], let style = styles[keyPath] { style.control(configuration: configuration) } else if let style = styles[ObjectIdentifier(Value.self)] { diff --git a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift index af4868d4..69e42a56 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift @@ -1,18 +1,11 @@ import SwiftUI import Vexil -extension FlagContainer { - var keyPathByFlagKeyPath: [FlagKeyPath: AnyKeyPath] { - _allFlagKeyPaths.reduce(into: [FlagKeyPath: AnyKeyPath]()) { $0[$1.value] = $1.key } - } -} - class FlagPoleVisitor: FlagVisitor { var lookup: any FlagLookup var items = [any FlagPoleItem]() var groupStack = [any FlagPoleItemGroup]() - var keyPathStack = [AnyKeyPath]() var keyPathByFlagKeyPath = [FlagKeyPath: AnyKeyPath]() init(lookup: any FlagLookup) { @@ -50,3 +43,12 @@ class FlagPoleVisitor: FlagVisitor { } } + +private extension FlagContainer { + + /// A map of type-erased key paths by flag key path. + var keyPathByFlagKeyPath: [FlagKeyPath: AnyKeyPath] { + Dictionary(uniqueKeysWithValues: _allFlagKeyPaths.map { ($0.value, $0.key) }) + } + +} diff --git a/Sources/Vexillographer/Utilities/OptionalProtocol.swift b/Sources/Vexillographer/Utilities/OptionalProtocol.swift index ee0fe3d1..9343460b 100644 --- a/Sources/Vexillographer/Utilities/OptionalProtocol.swift +++ b/Sources/Vexillographer/Utilities/OptionalProtocol.swift @@ -1,3 +1,4 @@ +// Is this still needed protocol OptionalProtocol { associatedtype Wrapped var wrapped: Wrapped? { get set } diff --git a/Sources/Vexillographer/Vexillographer.swift b/Sources/Vexillographer/Vexillographer.swift index 9db52f7a..9efd9d07 100644 --- a/Sources/Vexillographer/Vexillographer.swift +++ b/Sources/Vexillographer/Vexillographer.swift @@ -5,10 +5,11 @@ public struct Vexillographer: View { @State private var searchText = "" - public init() {} + public init() { } public var body: some View { FlagList(searchText: searchText) + // Want to make this opt in? .searchable(text: $searchText) } diff --git a/Sources/Vexillographer/View+FlagControlStyle.swift b/Sources/Vexillographer/View+FlagControlStyle.swift index 24c139d0..047c79fc 100644 --- a/Sources/Vexillographer/View+FlagControlStyle.swift +++ b/Sources/Vexillographer/View+FlagControlStyle.swift @@ -2,10 +2,14 @@ import SwiftUI import Vexil public protocol FlagControlStyle: DynamicProperty { + associatedtype Value: FlagValue associatedtype Body: View - @ViewBuilder @MainActor func makeBody(configuration: Configuration) -> Body + typealias Configuration = FlagControlConfiguration + + @ViewBuilder @MainActor func makeBody(configuration: Configuration) -> Body + } public extension View { @@ -21,6 +25,7 @@ public extension View { } private struct FlagControlStyleModifier: ViewModifier { + var style: Style var key: AnyHashable @@ -30,11 +35,14 @@ private struct FlagControlStyleModifier: ViewModifier { $0.styles[key] = style } } + } extension FlagControlStyle { - @MainActor func control(configuration: Configuration) -> AnyView? { + + @MainActor + func control(configuration: Configuration) -> AnyView? { (configuration as? Configuration).map { AnyView(StyledFlagControl(configuration: $0, style: self)) } } -} +} From 62eac3921ac3010d53f3e0aabfb5f9b6740ed74f Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Tue, 4 Nov 2025 22:51:50 +1100 Subject: [PATCH 07/17] Fixed tvOS compilation issues --- Sources/Vexillographer/FlagControl/FlagDetail.swift | 2 +- Sources/Vexillographer/FlagPole/FlagItem.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Vexillographer/FlagControl/FlagDetail.swift b/Sources/Vexillographer/FlagControl/FlagDetail.swift index 4dc76e4f..edee2d0b 100644 --- a/Sources/Vexillographer/FlagControl/FlagDetail.swift +++ b/Sources/Vexillographer/FlagControl/FlagDetail.swift @@ -51,7 +51,7 @@ struct FlagDetailView: View { .navigationTitle(configuration.name) #if os(macOS) .padding(0) // FIXME: Views for mac -#else +#elseif !os(tvOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { diff --git a/Sources/Vexillographer/FlagPole/FlagItem.swift b/Sources/Vexillographer/FlagPole/FlagItem.swift index 28bbf399..98e6b12d 100644 --- a/Sources/Vexillographer/FlagPole/FlagItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagItem.swift @@ -57,6 +57,7 @@ struct FlagItemContent: View { .buttonStyle(.plain) } .focused($isFocused) +#if !os(tvOS) .swipeActions(edge: .trailing) { if configuration.hasValue { Button { @@ -68,6 +69,7 @@ struct FlagItemContent: View { .tint(.red) } } +#endif .sheet(isPresented: $isShowingDetail) { NavigationView { FlagDetailView(configuration: configuration) From 69a7fcdc0c48d38efba7c2fafc843b8d450bda62 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:27:36 +1100 Subject: [PATCH 08/17] Ignore the example project's swiftpm directory --- .gitignore | 1 + .../xcode/package.xcworkspace/contents.xcworkspacedata | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/.gitignore b/.gitignore index d9d8f012..d14d3685 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.build /build /.swiftpm +/Examples/.swiftpm /Packages /*.xcodeproj xcuserdata/ diff --git a/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - From ea8070e8e9aae2bc4d7ee7d64826ead532782406 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:40:19 +1100 Subject: [PATCH 09/17] Fully exclude Vexillographer on Linux --- Package.swift | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/Package.swift b/Package.swift index 4234061a..bc83b295 100644 --- a/Package.swift +++ b/Package.swift @@ -15,9 +15,7 @@ let package = Package( ], products: [ - // Automatic .library(name: "Vexil", targets: [ "Vexil" ]), - .library(name: "Vexillographer", targets: [ "Vexillographer" ]), ], dependencies: [ @@ -44,15 +42,6 @@ let package = Package( ] ), - // Vexillographer - - .target( - name: "Vexillographer", - dependencies: [ - .target(name: "Vexil"), - ] - ), - // Macros .macro( @@ -76,6 +65,25 @@ let package = Package( #if !os(Linux) +// MARK: - Vexillographer + +// Vexillographer is not supported on Linux + +package.products.append( + .library(name: "Vexillographer", targets: [ "Vexillographer" ]) +) + +package.targets.append( + .target( + name: "Vexillographer", + dependencies: [ + .target(name: "Vexil"), + ] + ) +) + +// MARK: - Macro Testing + // We can't disable macro validation using `swift test` so these are guaranteed to fail on Linux package.targets.append( .testTarget( From 1d87103911b58348a783d222b27004cebd8ccffd Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:40:44 +1100 Subject: [PATCH 10/17] GitHub have horribly broken their macOS runners in recent years, reset to support just Xcode 26.1 --- .github/workflows/ios-tests.yml | 4 ++-- .github/workflows/macos-tests.yml | 4 ++-- .github/workflows/tvos-tests.yml | 4 ++-- .github/workflows/visionos-tests.yml | 4 ++-- .github/workflows/watchos-tests.yml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 36d0d9d1..51cee90a 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index f6632342..234a8690 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4", "26.0" ] - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/tvos-tests.yml b/.github/workflows/tvos-tests.yml index f1be5ef1..1cf9136c 100644 --- a/.github/workflows/tvos-tests.yml +++ b/.github/workflows/tvos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index 86008ad7..d30b3669 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index b0eebe04..f827b60b 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: From c9d482bcde07be6d986d094b090ab5e8faad3759 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:44:29 +1100 Subject: [PATCH 11/17] Try the Vexil-Package scheme --- .github/workflows/ios-tests.yml | 2 +- .github/workflows/macos-tests.yml | 2 +- .github/workflows/tvos-tests.yml | 2 +- .github/workflows/visionos-tests.yml | 2 +- .github/workflows/watchos-tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 51cee90a..1f68eeae 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ | xcbeautify --renderer github-actions build-ios: diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 234a8690..62d30d53 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=macOS,name=My Mac" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=macOS,name=My Mac" \ | xcbeautify --renderer github-actions build-macos: diff --git a/.github/workflows/tvos-tests.yml b/.github/workflows/tvos-tests.yml index 1cf9136c..e0006bfa 100644 --- a/.github/workflows/tvos-tests.yml +++ b/.github/workflows/tvos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ | xcbeautify --renderer github-actions build-tvos: diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index d30b3669..d4a6d78a 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ | xcbeautify --renderer github-actions build-visionos: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index f827b60b..1c7b766e 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ | xcbeautify --renderer github-actions build-watchos: From f15796df3551c44d04d40656216bfb47bc8075f4 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:52:43 +1100 Subject: [PATCH 12/17] See what schemes are available --- .github/workflows/watchos-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index 1c7b766e..eaa19e13 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -40,6 +40,7 @@ jobs: uses: actions/checkout@v4 - name: Build and Test run: | + xcrun xcodebuild -list -workspace . set -o pipefail && \ NSUnbufferedIO=YES \ xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ From c96298c5e1128f6caff67a61c0edc4c57d1e9659 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:56:05 +1100 Subject: [PATCH 13/17] Testing with iOS --- .github/workflows/ios-tests.yml | 3 ++- .github/workflows/watchos-tests.yml | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 1f68eeae..4cf28f40 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -40,9 +40,10 @@ jobs: uses: actions/checkout@v4 - name: Build and Test run: | + xcrun xcodebuild -list -workspace . set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ + xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ | xcbeautify --renderer github-actions build-ios: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index eaa19e13..f827b60b 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -40,10 +40,9 @@ jobs: uses: actions/checkout@v4 - name: Build and Test run: | - xcrun xcodebuild -list -workspace . set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ + xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ | xcbeautify --renderer github-actions build-watchos: From c81c053c153dbd5dc70bedf636de276c03186e84 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 16:59:32 +1100 Subject: [PATCH 14/17] Go with build tests for non-macOS platforms on Xcode 26, sigh. --- .github/workflows/ios-tests.yml | 3 +-- .github/workflows/tvos-tests.yml | 2 +- .github/workflows/visionos-tests.yml | 2 +- .github/workflows/watchos-tests.yml | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 4cf28f40..ea4c24c5 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -40,10 +40,9 @@ jobs: uses: actions/checkout@v4 - name: Build and Test run: | - xcrun xcodebuild -list -workspace . set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ + xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ | xcbeautify --renderer github-actions build-ios: diff --git a/.github/workflows/tvos-tests.yml b/.github/workflows/tvos-tests.yml index e0006bfa..ff9d635e 100644 --- a/.github/workflows/tvos-tests.yml +++ b/.github/workflows/tvos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ | xcbeautify --renderer github-actions build-tvos: diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index d4a6d78a..723ecdd0 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ | xcbeautify --renderer github-actions build-visionos: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index f827b60b..d9a19dee 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ + xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ | xcbeautify --renderer github-actions build-watchos: From 97aa88670f95d57c873925a5d50cd6664961e62b Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 17:05:36 +1100 Subject: [PATCH 15/17] Update simulator devices --- .github/workflows/visionos-tests.yml | 2 +- .github/workflows/watchos-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index 723ecdd0..bec0c725 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=26.1" \ | xcbeautify --renderer github-actions build-visionos: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index d9a19dee..f03a337f 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ + xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Ultra 3 (49mm)" \ | xcbeautify --renderer github-actions build-watchos: From fe93222f3a95f4ee5b5510313a67cde46e5ea298 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 17:06:01 +1100 Subject: [PATCH 16/17] Formatting --- Examples/Examples/Dependencies.swift | 16 ++- .../DoubleAndBooleanControlStyle.swift | 15 ++- Examples/Examples/ExamplesApp.swift | 13 +++ Examples/Examples/FeatureFlags.swift | 13 +++ Examples/Examples/RootView.swift | 13 +++ Examples/ExamplesTests/ExamplesTests.swift | 18 +++- .../FlagControl/FlagControl.swift | 22 +++- .../FlagControlConfiguration.swift | 18 +++- .../FlagControl/FlagDetail.swift | 19 +++- .../FlagControl/FlagPicker+Bool.swift | 16 ++- .../FlagControl/FlagPicker+CaseIterable.swift | 19 +++- .../FlagControl/FlagPicker.swift | 18 +++- .../FlagTextField+FloatingPoint.swift | 19 +++- .../FlagControl/FlagTextField+Integer.swift | 19 +++- .../FlagControl/FlagTextField+String.swift | 19 +++- .../FlagControl/FlagTextField.swift | 24 ++++- .../FlagControl/FlagToggle.swift | 19 +++- .../FlagControl/RowContent.swift | 13 +++ .../FlagPole/FlagGroupItem.swift | 15 +++ .../Vexillographer/FlagPole/FlagItem.swift | 100 ++++++++++-------- .../FlagPole/FlagPoleContext.swift | 16 ++- .../FlagPole/FlagPoleItem.swift | 19 +++- .../FlagPole/FlagPoleItemGroup.swift | 17 ++- .../FlagPole/FlagPoleVisitor.swift | 13 +++ .../Utilities/OptionalProtocol.swift | 13 +++ Sources/Vexillographer/Vexillographer.swift | 24 ++++- .../View+FlagControlStyle.swift | 16 ++- Sources/Vexillographer/View+FlagPole.swift | 17 ++- 28 files changed, 481 insertions(+), 82 deletions(-) diff --git a/Examples/Examples/Dependencies.swift b/Examples/Examples/Dependencies.swift index d4464f08..739b4ddb 100644 --- a/Examples/Examples/Dependencies.swift +++ b/Examples/Examples/Dependencies.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import Vexil struct Dependencies { @@ -6,7 +19,8 @@ struct Dependencies { sources: FlagPole.defaultSources + [RemoteFlags.values] ) - @TaskLocal static var current = Dependencies() + @TaskLocal + static var current = Dependencies() } enum RemoteFlags { diff --git a/Examples/Examples/DoubleAndBooleanControlStyle.swift b/Examples/Examples/DoubleAndBooleanControlStyle.swift index 512356b9..84d7c37b 100644 --- a/Examples/Examples/DoubleAndBooleanControlStyle.swift +++ b/Examples/Examples/DoubleAndBooleanControlStyle.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexillographer @@ -6,7 +19,7 @@ struct DoubleAndBooleanControlStyle: FlagControlStyle { func makeBody(configuration: Configuration) -> some View { VStack { Toggle(configuration.name, isOn: configuration.$value.isEnabled) - Slider(value: configuration.$value.percent, in: 0...1.0) { + Slider(value: configuration.$value.percent, in: 0 ... 1.0) { Text("Percent \(configuration.value.percent)") } minimumValueLabel: { Text("0.0") diff --git a/Examples/Examples/ExamplesApp.swift b/Examples/Examples/ExamplesApp.swift index dcaa6979..d5f1bbee 100644 --- a/Examples/Examples/ExamplesApp.swift +++ b/Examples/Examples/ExamplesApp.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI @main diff --git a/Examples/Examples/FeatureFlags.swift b/Examples/Examples/FeatureFlags.swift index 481dd507..dc9e48e0 100644 --- a/Examples/Examples/FeatureFlags.swift +++ b/Examples/Examples/FeatureFlags.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import Vexil @FlagContainer diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift index 50608380..ce9c8b22 100644 --- a/Examples/Examples/RootView.swift +++ b/Examples/Examples/RootView.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil import Vexillographer diff --git a/Examples/ExamplesTests/ExamplesTests.swift b/Examples/ExamplesTests/ExamplesTests.swift index 5aa5cdd3..e728fe6a 100644 --- a/Examples/ExamplesTests/ExamplesTests.swift +++ b/Examples/ExamplesTests/ExamplesTests.swift @@ -1,9 +1,23 @@ -import Testing +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + @testable import Examples +import Testing struct ExamplesTests { - @Test func example() async throws { + @Test + func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. } diff --git a/Sources/Vexillographer/FlagControl/FlagControl.swift b/Sources/Vexillographer/FlagControl/FlagControl.swift index 84e994ae..b814e7e4 100644 --- a/Sources/Vexillographer/FlagControl/FlagControl.swift +++ b/Sources/Vexillographer/FlagControl/FlagControl.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -7,10 +20,13 @@ public struct FlagControl: View { private var wigwag: FlagWigwag private var content: (FlagControlConfiguration) -> Content - @State private var cachedValue: Value? - @State private var seed = 0 + @State + private var cachedValue: Value? + @State + private var seed = 0 - @Environment(\.flagPoleContext) private var flagPoleContext + @Environment(\.flagPoleContext) + private var flagPoleContext public init( _ wigwag: FlagWigwag, diff --git a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift index ad64095e..6872a835 100644 --- a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift +++ b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -12,7 +25,8 @@ public struct FlagControlConfiguration { public let isEditable: Bool public let hasValue: Bool public let defaultValue: Value - @Binding public var value: Value + @Binding + public var value: Value private let _resetValue: () -> Void init( @@ -34,7 +48,7 @@ public struct FlagControlConfiguration { self.hasValue = hasValue self.defaultValue = defaultValue _value = value - _resetValue = resetValue + self._resetValue = resetValue } public var key: String { diff --git a/Sources/Vexillographer/FlagControl/FlagDetail.swift b/Sources/Vexillographer/FlagControl/FlagDetail.swift index edee2d0b..65bc39fc 100644 --- a/Sources/Vexillographer/FlagControl/FlagDetail.swift +++ b/Sources/Vexillographer/FlagControl/FlagDetail.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -8,8 +21,10 @@ struct FlagDetailView: View { var configuration: FlagControlConfiguration - @Environment(\.dismiss) private var dismiss - @Environment(\.flagPoleContext) private var flagPoleContext + @Environment(\.dismiss) + private var dismiss + @Environment(\.flagPoleContext) + private var flagPoleContext var body: some View { List { diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift index b5bb80e0..f2ce9065 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -25,7 +38,8 @@ private extension FlagValue where BoxedValueType == Bool? { } protocol OptionalBooleanFlagPickerRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: OptionalBooleanFlagPickerRepresentable where Value.BoxedValueType == Bool? { diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift index d48ef321..054977cc 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -23,7 +36,8 @@ public extension FlagPicker { } protocol CaseIterableFlagPickerRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: CaseIterableFlagPickerRepresentable where Value: CaseIterable & Hashable { @@ -33,7 +47,8 @@ extension FlagControlConfiguration: CaseIterableFlagPickerRepresentable where Va } protocol OptionalCaseIterableFlagPickerRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: OptionalCaseIterableFlagPickerRepresentable where Value: OptionalProtocol, Value.Wrapped: CaseIterable & Hashable { diff --git a/Sources/Vexillographer/FlagControl/FlagPicker.swift b/Sources/Vexillographer/FlagControl/FlagPicker.swift index 05cbfb50..8f124199 100644 --- a/Sources/Vexillographer/FlagControl/FlagPicker.swift +++ b/Sources/Vexillographer/FlagControl/FlagPicker.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -5,7 +18,8 @@ import Vexil public struct FlagPicker: View { private var name: String - @Binding private var value: Value + @Binding + private var value: Value private var selection: WritableKeyPath private var content: Content @@ -14,7 +28,7 @@ public struct FlagPicker, @ViewBuilder content: () -> Content ) { - name = configuration.name + self.name = configuration.name _value = configuration.$value self.selection = selection self.content = content() diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift index b54d4013..d54e9481 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -30,7 +43,8 @@ private extension FlagValue where BoxedValueType: BinaryFloatingPoint { } protocol FloatingPointTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: FloatingPointTextFieldRepresentable where Value.BoxedValueType: BinaryFloatingPoint { @@ -68,7 +82,8 @@ private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueTy } protocol OptionalFloatingPointFlagTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: OptionalFloatingPointFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryFloatingPoint { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift index ab9b3d4a..2b77880c 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -30,7 +43,8 @@ private extension FlagValue where BoxedValueType: BinaryInteger { } protocol IntegerFlagTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: IntegerFlagTextFieldRepresentable where Value.BoxedValueType: BinaryInteger { @@ -67,7 +81,8 @@ private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueTy } protocol OptionalIntegerFlagTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: OptionalIntegerFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryInteger { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift index 61a5b381..97d5b88e 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -22,7 +35,8 @@ private extension FlagValue where BoxedValueType == String { } protocol StringFlagTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: StringFlagTextFieldRepresentable where Value.BoxedValueType == String { @@ -52,7 +66,8 @@ private extension FlagValue where BoxedValueType == String? { } protocol OptionalStringFlagTextFieldRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: OptionalStringFlagTextFieldRepresentable where Value.BoxedValueType == String? { diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift index 04e08962..4ac155a8 100644 --- a/Sources/Vexillographer/FlagControl/FlagTextField.swift +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -7,7 +20,8 @@ import Vexil struct FlagTextField: View { private var name: String - @Binding private var value: Value + @Binding + private var value: Value private var placeholder: String private var formatted: WritableKeyPath private var format: (String) -> String @@ -16,9 +30,11 @@ struct FlagTextField: View { private var keyboardType = UIKeyboardType.default #endif - @State private var cachedText: String? + @State + private var cachedText: String? - @FocusState private var isFocused + @FocusState + private var isFocused init( configuration: FlagControlConfiguration, @@ -27,7 +43,7 @@ struct FlagTextField: View { format: @escaping (String) -> String = { $0 }, editingFormat: @escaping (String) -> String = { $0 } ) { - name = configuration.name + self.name = configuration.name _value = configuration.$value self.placeholder = placeholder self.formatted = formatted diff --git a/Sources/Vexillographer/FlagControl/FlagToggle.swift b/Sources/Vexillographer/FlagControl/FlagToggle.swift index f0f30747..e00c02b1 100644 --- a/Sources/Vexillographer/FlagControl/FlagToggle.swift +++ b/Sources/Vexillographer/FlagControl/FlagToggle.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -5,7 +18,8 @@ import Vexil public struct FlagToggle: View where Value.BoxedValueType == Bool { private var name: String - @Binding private var value: Value + @Binding + private var value: Value public init(configuration: FlagControlConfiguration) { self.name = configuration.name @@ -32,7 +46,8 @@ private extension FlagValue where BoxedValueType == Bool { } protocol FlagToggleRepresentable { - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagControlConfiguration: FlagToggleRepresentable where Value.BoxedValueType == Bool { diff --git a/Sources/Vexillographer/FlagControl/RowContent.swift b/Sources/Vexillographer/FlagControl/RowContent.swift index 0daa6ddc..730a4930 100644 --- a/Sources/Vexillographer/FlagControl/RowContent.swift +++ b/Sources/Vexillographer/FlagControl/RowContent.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI // UI helper diff --git a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift index 63d5ce07..02722936 100644 --- a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -39,10 +52,12 @@ struct FlagGroupItem: FlagPoleItemGroup { ForEach(visibleItems, id: \.keyPath, content: \.content) } } + case .section: Section(group.name) { ForEach(visibleItems, id: \.keyPath, content: \.content) } + case .hidden: EmptyView() } diff --git a/Sources/Vexillographer/FlagPole/FlagItem.swift b/Sources/Vexillographer/FlagPole/FlagItem.swift index 98e6b12d..5a326a4f 100644 --- a/Sources/Vexillographer/FlagPole/FlagItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagItem.swift @@ -1,38 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil struct FlagItem: FlagPoleItem { - + var flag: FlagWigwag - + init(_ flag: FlagWigwag) { self.flag = flag } - + var isHidden: Bool { flag.displayOption == .hidden } - + var keyPath: FlagKeyPath { flag.keyPath } - + var name: String { flag.name } - + func makeContent() -> any View { FlagItemContent(wigwag: flag) } - + } struct FlagItemContent: View { - + var wigwag: FlagWigwag - - @State private var isShowingDetail = false - @FocusState private var isFocused - - @Environment(\.flagPoleContext) private var flagPoleContext + + @State + private var isShowingDetail = false + @FocusState + private var isFocused + + @Environment(\.flagPoleContext) + private var flagPoleContext var body: some View { FlagControl(wigwag) { configuration in @@ -58,23 +74,23 @@ struct FlagItemContent: View { } .focused($isFocused) #if !os(tvOS) - .swipeActions(edge: .trailing) { - if configuration.hasValue { - Button { - configuration.resetValue() - } label: { - Label("Clear", systemImage: "trash.fill") - .imageScale(.large) + .swipeActions(edge: .trailing) { + if configuration.hasValue { + Button { + configuration.resetValue() + } label: { + Label("Clear", systemImage: "trash.fill") + .imageScale(.large) + } + .tint(.red) } - .tint(.red) } - } #endif - .sheet(isPresented: $isShowingDetail) { - NavigationView { - FlagDetailView(configuration: configuration) + .sheet(isPresented: $isShowingDetail) { + NavigationView { + FlagDetailView(configuration: configuration) + } } - } } } } @@ -82,7 +98,7 @@ struct FlagItemContent: View { struct StyledFlagControl: View { var configuration: FlagControlConfiguration var style: any FlagControlStyle - + var body: some View { AnyView(style.makeBody(configuration: configuration)) } @@ -90,36 +106,36 @@ struct StyledFlagControl: View { struct DefaultFlagControl: View { var content: any View - - init(configuration: FlagControlConfiguration) { + + init(configuration: FlagControlConfiguration) { switch configuration { case let configuration as any FlagToggleRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any OptionalBooleanFlagPickerRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any CaseIterableFlagPickerRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any OptionalCaseIterableFlagPickerRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any IntegerFlagTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any OptionalIntegerFlagTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any FloatingPointTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any OptionalFloatingPointFlagTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any StringFlagTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() case let configuration as any OptionalStringFlagTextFieldRepresentable: - content = configuration.makeContent() + self.content = configuration.makeContent() default: - content = Text("Unimplemented \(configuration.name)").frame(maxWidth: .infinity) + self.content = Text("Unimplemented \(configuration.name)").frame(maxWidth: .infinity) } } - + var body: some View { AnyView(content) } - + } diff --git a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift index b7169d5e..15790f11 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -28,6 +41,7 @@ struct FlagPoleContext { extension EnvironmentValues { - @Entry var flagPoleContext = FlagPoleContext() + @Entry + var flagPoleContext = FlagPoleContext() } diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItem.swift b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift index 090634c9..f7b7effd 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleItem.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -6,12 +19,14 @@ protocol FlagPoleItem { var keyPath: FlagKeyPath { get } var name: String { get } var isHidden: Bool { get } - @MainActor func makeContent() -> any View + @MainActor + func makeContent() -> any View } extension FlagPoleItem { - @MainActor var content: AnyView { AnyView(makeContent()) } + @MainActor + var content: AnyView { AnyView(makeContent()) } } extension FlagPoleItem { diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift index f6e97743..6fdf9868 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift @@ -1,10 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil protocol FlagPoleItemGroup: FlagPoleItem { - + var items: [any FlagPoleItem] { get set } - + } diff --git a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift index 69e42a56..92588692 100644 --- a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift +++ b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil diff --git a/Sources/Vexillographer/Utilities/OptionalProtocol.swift b/Sources/Vexillographer/Utilities/OptionalProtocol.swift index 9343460b..b8838e82 100644 --- a/Sources/Vexillographer/Utilities/OptionalProtocol.swift +++ b/Sources/Vexillographer/Utilities/OptionalProtocol.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + // Is this still needed protocol OptionalProtocol { associatedtype Wrapped diff --git a/Sources/Vexillographer/Vexillographer.swift b/Sources/Vexillographer/Vexillographer.swift index 9efd9d07..78432715 100644 --- a/Sources/Vexillographer/Vexillographer.swift +++ b/Sources/Vexillographer/Vexillographer.swift @@ -1,11 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil public struct Vexillographer: View { - @State private var searchText = "" + @State + private var searchText = "" - public init() { } + public init() {} public var body: some View { FlagList(searchText: searchText) @@ -18,8 +32,10 @@ public struct Vexillographer: View { private struct FlagList: View { var searchText: String - @Environment(\.flagPoleContext) private var flagPoleContext - @Environment(\.isSearching) private var isSearching + @Environment(\.flagPoleContext) + private var flagPoleContext + @Environment(\.isSearching) + private var isSearching var body: some View { List { diff --git a/Sources/Vexillographer/View+FlagControlStyle.swift b/Sources/Vexillographer/View+FlagControlStyle.swift index 047c79fc..3fade9c6 100644 --- a/Sources/Vexillographer/View+FlagControlStyle.swift +++ b/Sources/Vexillographer/View+FlagControlStyle.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil @@ -8,7 +21,8 @@ public protocol FlagControlStyle: DynamicProperty { typealias Configuration = FlagControlConfiguration - @ViewBuilder @MainActor func makeBody(configuration: Configuration) -> Body + @ViewBuilder @MainActor + func makeBody(configuration: Configuration) -> Body } diff --git a/Sources/Vexillographer/View+FlagPole.swift b/Sources/Vexillographer/View+FlagPole.swift index d6dd7c05..68b206a5 100644 --- a/Sources/Vexillographer/View+FlagPole.swift +++ b/Sources/Vexillographer/View+FlagPole.swift @@ -1,10 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + import SwiftUI import Vexil public extension View { - func flagPole( - _ flagPole: FlagPole, + func flagPole( + _ flagPole: FlagPole, editableSource: (any FlagValueSource)? = nil ) -> some View { modifier(FlagPoleModifier(flagPole: flagPole, editableSource: editableSource)) From 8c1918dbc376b3f6083a846b033dd8aeb6da1439 Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 29 Nov 2025 17:13:01 +1100 Subject: [PATCH 17/17] Try generic visionOS simulator --- .github/workflows/visionos-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index bec0c725..51859479 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=26.1" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "generic/platform=visionOS Simulator" \ | xcbeautify --renderer github-actions build-visionos: