diff --git a/Sources/OpenSwiftUI/App/Scene/SceneList.swift b/Sources/OpenSwiftUI/App/Scene/SceneList.swift index 6d53e618d..ff6b8a511 100644 --- a/Sources/OpenSwiftUI/App/Scene/SceneList.swift +++ b/Sources/OpenSwiftUI/App/Scene/SceneList.swift @@ -48,6 +48,7 @@ extension SceneList { var connectionOptionPayloadStorage: ConnectionOptionPayloadStorage = .init() #elseif os(macOS) // TODO: macOS specific properties + var keyboardShortcut: KeyboardShortcut? #endif // MARK: - SceneList.Item.Summary diff --git a/Sources/OpenSwiftUI/Event/Event/KeyEvent.swift b/Sources/OpenSwiftUI/Event/Event/KeyEvent.swift index 1abc36212..fa2aab0ac 100644 --- a/Sources/OpenSwiftUI/Event/Event/KeyEvent.swift +++ b/Sources/OpenSwiftUI/Event/Event/KeyEvent.swift @@ -5,7 +5,6 @@ // Audited for 6.5.4 // Status: Complete -package import Foundation @_spi(ForOpenSwiftUIOnly) package import OpenSwiftUICore @@ -68,41 +67,3 @@ package struct KeyEvent: NonGestureEventType, ModifiersEventType, Equatable { } } } - -// MARK: - TransformEvent - -package struct TransformEvent: HitTestableEventType, SpatialEventType, Equatable { - package var timestamp: Time - package var phase: EventPhase - package var binding: EventBinding? - package var globalLocation: CGPoint - package var location: CGPoint - package var initialScale: CGFloat - package var scaleDelta: CGFloat - package var initialAngle: Angle - package var angleDelta: Angle - - package init( - timestamp: Time, - phase: EventPhase, - binding: EventBinding? = nil, - globalLocation: CGPoint, - location: CGPoint, - initialScale: CGFloat, - scaleDelta: CGFloat, - initialAngle: Angle, - angleDelta: Angle - ) { - self.timestamp = timestamp - self.phase = phase - self.binding = binding - self.globalLocation = globalLocation - self.location = location - self.initialScale = initialScale - self.scaleDelta = scaleDelta - self.initialAngle = initialAngle - self.angleDelta = angleDelta - } - - package var radius: CGFloat { .zero } -} diff --git a/Sources/OpenSwiftUI/Event/Event/TransformEvent.swift b/Sources/OpenSwiftUI/Event/Event/TransformEvent.swift new file mode 100644 index 000000000..da05b281f --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Event/TransformEvent.swift @@ -0,0 +1,48 @@ +// +// TransformEvent.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +package import Foundation +@_spi(ForOpenSwiftUIOnly) +package import OpenSwiftUICore + +// MARK: - TransformEvent + +package struct TransformEvent: HitTestableEventType, SpatialEventType, Equatable { + package var timestamp: Time + package var phase: EventPhase + package var binding: EventBinding? + package var globalLocation: CGPoint + package var location: CGPoint + package var initialScale: CGFloat + package var scaleDelta: CGFloat + package var initialAngle: Angle + package var angleDelta: Angle + + package init( + timestamp: Time, + phase: EventPhase, + binding: EventBinding? = nil, + globalLocation: CGPoint, + location: CGPoint, + initialScale: CGFloat, + scaleDelta: CGFloat, + initialAngle: Angle, + angleDelta: Angle + ) { + self.timestamp = timestamp + self.phase = phase + self.binding = binding + self.globalLocation = globalLocation + self.location = location + self.initialScale = initialScale + self.scaleDelta = scaleDelta + self.initialAngle = initialAngle + self.angleDelta = angleDelta + } + + package var radius: CGFloat { .zero } +} diff --git a/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyPressModifier.swift b/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyPressModifier.swift new file mode 100644 index 000000000..0ee54cfaa --- /dev/null +++ b/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyPressModifier.swift @@ -0,0 +1,275 @@ +// +// KeyPressModifier.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: C97FB0E5EC0789E5A42E597830BFC1D5 (SwiftUI) + +public import Foundation +import OpenAttributeGraphShims +public import OpenSwiftUICore + +// MARK: - View + onKeyPress + +@available(OpenSwiftUI_v5_0, *) +@available(watchOS, unavailable) +extension View { + /// Performs an action if the user presses a key on a hardware keyboard + /// while the view has focus. + /// + /// OpenSwiftUI performs the action for key-down and key-repeat events. + /// + /// - Parameters: + /// - key: The key to match against incoming hardware keyboard events. + /// - action: The action to perform. Return `.handled` to consume the + /// event and prevent further dispatch, or `.ignored` to allow dispatch + /// to continue. + /// - Returns: A modified view that binds hardware keyboard input + /// when focused. + nonisolated public func onKeyPress( + _ key: KeyEquivalent, + action: @escaping () -> KeyPress.Result + ) -> some View { + onKeyPress(subject: .keys([key]), phases: [.down, .repeat]) { _ in + action() + } + } + + /// Performs an action if the user presses a key on a hardware keyboard + /// while the view has focus. + /// + /// OpenSwiftUI performs the action for the specified event phases. + /// + /// - Parameters: + /// - key: The key to match against incoming hardware keyboard events. + /// - phases: The key-press phases to match (`.down`, `.up`, + /// and `.repeat`). + /// - action: The action to perform. The action receives a value + /// describing the matched key event. Return `.handled` to consume the + /// event and prevent further dispatch, or `.ignored` to allow dispatch + /// to continue. + /// - Returns: A modified view that binds hardware keyboard input + /// when focused. + nonisolated public func onKeyPress( + _ key: KeyEquivalent, + phases: KeyPress.Phases, + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(subject: .keys([key]), phases: phases, action: action) + } + + /// Performs an action if the user presses one or more keys on a hardware + /// keyboard while the view has focus. + /// + /// - Parameters: + /// - keys: A set of keys to match against incoming hardware + /// keyboard events. + /// - phases: The key-press phases to match (`.down`, `.repeat`, and + /// `.up`). The default value is `[.down, .repeat]`. + /// - action: The action to perform. The action receives a value + /// describing the matched key event. Return `.handled` to consume the + /// event and prevent further dispatch, or `.ignored` to allow dispatch + /// to continue. + /// - Returns: A modified view that binds keyboard input when focused. + nonisolated public func onKeyPress( + keys: Set, + phases: KeyPress.Phases = [.down, .repeat], + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(subject: .keys(keys), phases: phases, action: action) + } + + /// Performs an action if the user presses one or more keys on a hardware + /// keyboard while the view has focus. + /// + /// - Parameters: + /// - characters: The set of characters to match against incoming + /// hardware keyboard events. + /// - phases: The key-press phases to match (`.down`, `.repeat`, and + /// `.up`). The default value is `[.down, .repeat]`. + /// - action: The action to perform. The action receives a value + /// describing the matched key event. Return `.handled` to consume the + /// event and prevent further dispatch, or `.ignored` to allow dispatch + /// to continue. + /// - Returns: A modified view that binds hardware keyboard input + /// when focused. + nonisolated public func onKeyPress( + characters: CharacterSet, + phases: KeyPress.Phases = [.down, .repeat], + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(subject: .characters(characters), phases: phases, action: action) + } + + /// Performs an action if the user presses any key on a hardware keyboard + /// while the view has focus. + /// + /// - Parameters: + /// - phases: The key-press phases to match (`.down`, `.repeat`, and + /// `.up`). The default value is `[.down, .repeat]`. + /// - action: The action to perform. The action receives a value + /// describing the matched key event. Return `.handled` to consume the + /// event and prevent further dispatch, or `.ignored` to allow dispatch + /// to continue. + /// - Returns: A modified view that binds hardware keyboard input + /// when focused. + nonisolated public func onKeyPress( + phases: KeyPress.Phases = [.down, .repeat], + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(subject: .all, phases: phases, action: action) + } + + nonisolated func onKeyPress( + subject: KeyPress.Handler.Subject, + phases: KeyPress.Phases, + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + modifier(KeyPressModifier(handler: .init(subject: subject, phases: phases, action: action))) + } +} + +// MARK: - KeyPress + +@available(OpenSwiftUI_v5_0, *) +@available(watchOS, unavailable) +public struct KeyPress: Sendable { + /// The phase of the key-press event (`.down`, `.repeat`, or `.up`). + public let phase: Phases + + /// The key equivalent value for the pressed key. + public let key: KeyEquivalent + + /// The characters generated by the pressed key as if no modifier + /// key applies. + public let characters: String + + /// The set of modifier keys the user held in addition to the + /// pressed key. + public let modifiers: EventModifiers +} + +@available(OpenSwiftUI_v5_0, *) +@available(watchOS, unavailable) +extension KeyPress: CustomDebugStringConvertible { + /// Options for matching different phases of a key-press event. + public struct Phases: OptionSet, Sendable, CustomDebugStringConvertible { + /// The user pressed down on a key. + public static let down: Phases = .init(rawValue: 1 << 0) + + /// The user held a key down to issue a sequence of repeating events. + public static let `repeat`: Phases = .init(rawValue: 1 << 1) + + /// The user released a key. + public static let up: Phases = .init(rawValue: 1 << 2) + + /// A value that matches all key press phases. + public static let all: Phases = .init(rawValue: .max) + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public var debugDescription: String { + var names: [String] = [] + if self == .all { + names.append(".all") + } else { + if contains(.down) { names.append(".down") } + if contains(.repeat) { names.append(".repeat") } + if contains(.up) { names.append(".up") } + } + return names.count == 1 ? names[0] : "[\(names.joined(separator: ", "))]" + } + } + + /// A result value returned from a key-press action that indicates whether + /// the action consumed the event. + public enum Result: Sendable { + /// The action consumed the event, preventing dispatch from continuing. + case handled + + /// The action ignored the event, allowing dispatch to continue. + case ignored + } + + public var debugDescription: String { + "KeyPress(\(phase), \"\(characters)\")" + } +} + +// MARK: - Deprecated API + +@_spi(_) +extension View { + @available(OpenSwiftUI_v5_0, *) + @available(*, deprecated, renamed: "onKeyPress(keys:phases:action:)") + @available(watchOS, unavailable) + nonisolated public func onKeyPress( + keysIn keys: Set, + phases: KeyPress.Phases = [.down, .repeat], + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(keys: keys, phases: phases, action: action) + } + + @available(OpenSwiftUI_v5_0, *) + @available(*, deprecated, renamed: "onKeyPress(keys:phases:action:)") + @available(watchOS, unavailable) + nonisolated public func onKeyPress( + charactersIn characters: CharacterSet, + phases: KeyPress.Phases = [.down, .repeat], + action: @escaping (KeyPress) -> KeyPress.Result + ) -> some View { + onKeyPress(characters: characters, phases: phases, action: action) + } +} + +// MARK: - KeyPressModifier + +private struct KeyPressModifier: EnvironmentModifier, PrimitiveViewModifier { + var handler: KeyPress.Handler + + static func makeEnvironment(modifier: Attribute, environment: inout EnvironmentValues) { + let handler = modifier.value.handler + environment.keyPressHandlers.append(handler) + } +} + +extension KeyPress { + struct Handler { + enum Subject { + case keys(Set) + case characters(CharacterSet) + case all + } + + var subject: Subject + var phases: Phases + var action: (KeyPress) -> Result + } +} + +extension EnvironmentValues { + var keyPressHandlers: [KeyPress.Handler] { + get { self[KeyPressHandlersKey.self] } + set { self[KeyPressHandlersKey.self] = newValue } + } + + private struct KeyPressHandlersKey: EnvironmentKey { + static var defaultValue: [KeyPress.Handler] { [] } + } +} + +extension CachedEnvironment.ID { + static let keyPressHandlers: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var keyPressHandlers: Attribute<[KeyPress.Handler]> { + mapEnvironment(id: .keyPressHandlers) { $0.keyPressHandlers } + } +} diff --git a/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyboardShortcut.swift b/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyboardShortcut.swift new file mode 100644 index 000000000..be1996198 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/InputEvent/Keyboard/KeyboardShortcut.swift @@ -0,0 +1,660 @@ +// +// KeyboardShortcut.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 254C3FE5924A018B482F2F0C0D49154F (SwiftUI) + +import Foundation +import OpenAttributeGraphShims +@_spi(Private) +public import OpenSwiftUICore + +// MARK: - View + keyboardShortcut + +@available(OpenSwiftUI_v2_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension View { + /// Defines a keyboard shortcut and assigns it to the modified control. + /// + /// Pressing the control's shortcut while the control is anywhere in the + /// frontmost window or scene, or anywhere in the macOS main menu, is + /// equivalent to direct interaction with the control to perform its primary + /// action. + /// + /// The target of a keyboard shortcut is resolved in a leading-to-trailing, + /// depth-first traversal of one or more view hierarchies. On macOS, the + /// system looks in the key window first, then the main window, and then the + /// command groups; on other platforms, the system looks in the active + /// scene, and then the command groups. + /// + /// If multiple controls are associated with the same shortcut, the first + /// one found is used. + /// + /// The default localization configuration is set to + /// ``KeyboardShortcut/Localization-swift.struct/automatic``. + nonisolated public func keyboardShortcut( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command + ) -> some View { + keyboardShortcut(KeyboardShortcut(key, modifiers: modifiers)) + } + + /// Assigns a keyboard shortcut to the modified control. + /// + /// Pressing the control's shortcut while the control is anywhere in the + /// frontmost window or scene, or anywhere in the macOS main menu, is + /// equivalent to direct interaction with the control to perform its primary + /// action. + /// + /// The target of a keyboard shortcut is resolved in a leading-to-trailing + /// traversal of one or more view hierarchies. On macOS, the system looks in + /// the key window first, then the main window, and then the command groups; + /// on other platforms, the system looks in the active scene, and then the + /// command groups. + /// + /// If multiple controls are associated with the same shortcut, the first + /// one found is used. + nonisolated public func keyboardShortcut(_ shortcut: KeyboardShortcut) -> some View { + keyboardShortcut(Optional(shortcut)) + } + + /// Assigns an optional keyboard shortcut to the modified control. + /// + /// Pressing the control's shortcut while the control is anywhere in the + /// frontmost window or scene, or anywhere in the macOS main menu, is + /// equivalent to direct interaction with the control to perform its primary + /// action. + /// + /// The target of a keyboard shortcut is resolved in a leading-to-trailing + /// traversal of one or more view hierarchies. On macOS, the system looks in + /// the key window first, then the main window, and then the command groups; + /// on other platforms, the system looks in the active scene, and then the + /// command groups. + /// + /// If multiple controls are associated with the same shortcut, the first + /// one found is used. If the provided shortcut is `nil`, the modifier will + /// have no effect. + @available(OpenSwiftUI_v3_4, *) + nonisolated public func keyboardShortcut(_ shortcut: KeyboardShortcut?) -> some View { + environment(\.keyboardShortcut, shortcut) + .input(HasKeyboardShortcut.self) + ._trait(KeyboardShortcutPickerOptionTraitKey.self, shortcut) + } +} + +@available(OpenSwiftUI_v3_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension View { + /// Defines a keyboard shortcut and assigns it to the modified control. + /// + /// Pressing the control's shortcut while the control is anywhere in the + /// frontmost window or scene, or anywhere in the macOS main menu, is + /// equivalent to direct interaction with the control to perform its primary + /// action. + /// + /// The target of a keyboard shortcut is resolved in a leading-to-trailing, + /// depth-first traversal of one or more view hierarchies. On macOS, the + /// system looks in the key window first, then the main window, and then the + /// command groups; on other platforms, the system looks in the active + /// scene, and then the command groups. + /// + /// If multiple controls are associated with the same shortcut, the first + /// one found is used. + /// + /// ### Localization + /// + /// Provide a `localization` value to specify how this shortcut + /// should be localized. + /// Given that `key` is always defined in relation to the US-English + /// keyboard layout, it might be hard to reach on different international + /// layouts. For example the shortcut `⌘[` works well for the + /// US layout but is hard to reach for German users, where + /// `[` is available by pressing `⌥5`, making users type `⌥⌘5`. + /// The automatic keyboard shortcut remapping re-assigns the shortcut to + /// an appropriate replacement, `⌘Ö` in this case. + /// + /// Certain shortcuts carry information about directionality. For instance, + /// `⌘[` can reveal a previous view. Following the layout direction of + /// the UI, this shortcut will be automatically mirrored to `⌘]`. + /// However, this does not apply to items such as "Align Left `⌘{`", + /// which will be "left" independently of the layout direction. + /// When the shortcut shouldn't follow the directionality of the UI, but rather + /// be the same in both right-to-left and left-to-right directions, using + /// ``KeyboardShortcut/Localization-swift.struct/withoutMirroring`` + /// will prevent the system from flipping it. + /// + /// var body: some Commands { + /// CommandMenu("Card") { + /// Button("Align Left") { ... } + /// .keyboardShortcut("{", + /// modifiers: .option, + /// localization: .withoutMirroring) + /// Button("Align Right") { ... } + /// .keyboardShortcut("}", + /// modifiers: .option, + /// localization: .withoutMirroring) + /// } + /// } + /// + /// Lastly, providing the option + /// ``KeyboardShortcut/Localization-swift.struct/custom`` + /// disables + /// the automatic localization for this shortcut to tell the system that + /// internationalization is taken care of in a different way. + nonisolated public func keyboardShortcut( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command, + localization: KeyboardShortcut.Localization + ) -> some View { + keyboardShortcut(KeyboardShortcut(key, modifiers: modifiers, localization: localization)) + } +} + +// MARK: - Scene + keyboardShortcut + +@available(OpenSwiftUI_v4_0, *) +@available(iOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension Scene { + /// Defines a keyboard shortcut for opening new scene windows. + /// + /// A scene's keyboard shortcut is bound to the command it adds for creating + /// new windows (in the case of `WindowGroup` and `DocumentGroup`) or + /// bringing a singleton window forward (in the case of `Window` and, on + /// macOS, `Settings`). Pressing the keyboard shortcut is equivalent to + /// selecting the menu command. + /// + /// In cases where a command already has a keyboard shortcut, the scene's + /// keyboard shortcut is used instead. For example, `WindowGroup` normally + /// creates a File > New Window menu command whose keyboard shortcut is + /// `⌘N`. The following code changes it to `⌥⌘N`: + /// + /// WindowGroup { + /// ContentView() + /// } + /// .keyboardShortcut("n", modifiers: [.option, .command]) + /// + /// ### Localization + /// + /// Provide a `localization` value to specify how this shortcut + /// should be localized. + /// + /// Given that `key` is always defined in relation to the US-English + /// keyboard layout, it might be hard to reach on different international + /// layouts. For example the shortcut `⌘[` works well for the + /// US layout but is hard to reach for German users, where + /// `[` is available by pressing `⌥5`, making users type `⌥⌘5`. + /// The automatic keyboard shortcut remapping re-assigns the shortcut to + /// an appropriate replacement, `⌘Ö` in this case. + /// + /// Providing the option + /// ``KeyboardShortcut/Localization-swift.struct/custom`` + /// disables the automatic localization for this shortcut to tell the system + /// that internationalization is taken care of in a different way. + /// + /// - Parameters: + /// - key: The key equivalent the user presses to present the scene. + /// - modifiers: The modifier keys required to perform the shortcut. + /// - localization: The localization style to apply to the shortcut. + /// - Returns: A scene that can be presented with a keyboard shortcut. + nonisolated public func keyboardShortcut( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command, + localization: KeyboardShortcut.Localization = .automatic + ) -> some Scene { + keyboardShortcut(KeyboardShortcut(key, modifiers: modifiers, localization: localization)) + } + + /// Defines a keyboard shortcut for opening new scene windows. + /// + /// A scene's keyboard shortcut is bound to the command it adds for creating + /// new windows (in the case of `WindowGroup` and `DocumentGroup`) or + /// bringing a singleton window forward (in the case of `Window` and, on + /// macOS, `Settings` and `UtilityWindow`). Pressing the keyboard shortcut + /// is equivalent to selecting the menu command. + /// + /// In cases where a command already has a keyboard shortcut, the scene's + /// keyboard shortcut is used instead. For example, `WindowGroup` normally + /// creates a File > New Window menu command whose keyboard shortcut is + /// `⌘N`. The following code changes it to something based on dynamic state: + /// + /// @main + /// struct Notes: App { + /// @State private var newWindowShortcut: KeyboardShortcut? = ... + /// + /// var body: some Scene { + /// WindowGroup { + /// ContentView($newWindowShortcut) + /// } + /// .keyboardShortcut(newWindowShortcut) + /// } + /// } + /// + /// If `shortcut` is `nil`, the scene's presentation command will not be + /// associated with a keyboard shortcut, even if OpenSwiftUI normally assigns + /// one automatically. + /// + /// - Parameters: + /// - shortcut: The keyboard shortcut for presenting the scene, or `nil`. + /// - Returns: A scene that can be presented with a keyboard shortcut. + nonisolated public func keyboardShortcut(_ shortcut: KeyboardShortcut?) -> some Scene { + #if os(macOS) + transformPreference(SceneList.Key.self) { list in + var items: [SceneList.Item] = [] + for item in list.items { + var item = item + // TODO: Verify the nil behavior + item.keyboardShortcut = item.keyboardShortcut ?? shortcut + items.append(item) + } + list.items = items + } + #else + _openSwiftUIUnreachableCode() + #endif + } +} + +// MARK: - KeyboardShortcut + +/// Keyboard shortcuts describe combinations of keys on a keyboard that the user +/// can press in order to activate a button or toggle. +@available(OpenSwiftUI_v2_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public struct KeyboardShortcut: Sendable { + /// Options for how a keyboard shortcut participates in automatic localization. + /// + /// A shortcut's `key` that is defined on an US-English keyboard + /// layout might not be reachable on international layouts. + /// For example the shortcut `⌘[` works well for the US layout but is + /// hard to reach for German users. + /// On the German keyboard layout, pressing `⌥5` will produce + /// `[`, which causes the shortcut to become `⌥⌘5`. + /// If configured, which is the default behavior, automatic shortcut + /// remapping will convert it to `⌘Ö`. + /// + /// In addition to that, some keyboard shortcuts carry information + /// about directionality. + /// Right-aligning a block of text or seeking forward in context of music + /// playback are such examples. These kinds of shortcuts benefit from the option + /// ``KeyboardShortcut/Localization-swift.struct/withoutMirroring`` + /// to tell the system that they won't be flipped when running in a + /// right-to-left context. + @available(OpenSwiftUI_v3_0, *) + public struct Localization: Sendable { + /// Remap shortcuts to their international counterparts, mirrored for + /// right-to-left usage if appropriate. + /// + /// This is the default configuration. + public static let automatic = Localization(style: .automatic) + + /// Don't mirror shortcuts. + /// + /// Use this for shortcuts that always have a specific directionality, like + /// aligning something on the right. + /// + /// Don't use this option for navigational shortcuts like "Go Back" because navigation + /// is flipped in right-to-left contexts. + public static let withoutMirroring = Localization(style: .withoutMirroring) + + /// Don't use automatic shortcut remapping. + /// + /// When you use this mode, you have to take care of international use-cases separately. + public static let custom = Localization(style: .custom) + + enum Style: Hashable { + case automatic + case withoutMirroring + case custom + } + + let style: Style + } + + /// The standard keyboard shortcut for the default button, consisting of + /// the Return (↩) key and no modifiers. + /// + /// On macOS, the default button is designated with special coloration. If + /// more than one control is assigned this shortcut, only the first one is + /// emphasized. + public static let defaultAction = KeyboardShortcut(.return, modifiers: []) + + /// The standard keyboard shortcut for cancelling the in-progress action + /// or dismissing a prompt, consisting of the Escape (⎋) key and no + /// modifiers. + public static let cancelAction = KeyboardShortcut(.escape, modifiers: []) + + /// The key equivalent that the user presses in conjunction with any + /// specified modifier keys to activate the shortcut. + public var key: KeyEquivalent + + /// The modifier keys that the user presses in conjunction with a key + /// equivalent to activate the shortcut. + public var modifiers: EventModifiers + + /// The localization strategy to apply to this shortcut. + @available(OpenSwiftUI_v3_0, *) + public var localization: Localization + + /// Creates a new keyboard shortcut with the given key equivalent and set of + /// modifier keys. + /// + /// The localization configuration defaults to + /// ``KeyboardShortcut/Localization-swift.struct/automatic``. + public init( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command + ) { + self.key = key + self.modifiers = modifiers + self.localization = .automatic + } + + /// Creates a new keyboard shortcut with the given key equivalent and set of + /// modifier keys. + /// + /// Use the `localization` parameter to specify a localization strategy + /// for this shortcut. + @available(OpenSwiftUI_v3_0, *) + public init( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command, + localization: Localization + ) { + self.key = key + self.modifiers = modifiers + self.localization = localization + } +} + +// MARK: - KeyEquivalent + +/// Key equivalents consist of a letter, punctuation, or function key that can +/// be combined with an optional set of modifier keys to specify a keyboard +/// shortcut. +/// +/// Key equivalents are used to establish keyboard shortcuts to app +/// functionality. Any key can be used as a key equivalent as long as pressing +/// it produces a single character value. Key equivalents are typically +/// initialized using a single-character string literal, with constants for +/// unprintable or hard-to-type values. +/// +/// The modifier keys necessary to type a key equivalent are factored in to the +/// resulting keyboard shortcut. That is, a key equivalent whose raw value is +/// the capitalized string "A" corresponds with the keyboard shortcut +/// Command-Shift-A. The exact mapping may depend on the keyboard layout—for +/// example, a key equivalent with the character value "}" produces a shortcut +/// equivalent to Command-Shift-] on ANSI keyboards, but would produce a +/// different shortcut for keyboard layouts where punctuation characters are in +/// different locations. +@available(OpenSwiftUI_v2_0, *) +// @available(tvOS 17.0, *) +@available(watchOS, unavailable) +public struct KeyEquivalent: Sendable { + /// Up Arrow (U+F700) + public static let upArrow: KeyEquivalent = "\u{F700}" + + /// Down Arrow (U+F701) + public static let downArrow: KeyEquivalent = "\u{F701}" + + /// Left Arrow (U+F702) + public static let leftArrow: KeyEquivalent = "\u{F702}" + + /// Right Arrow (U+F703) + public static let rightArrow: KeyEquivalent = "\u{F703}" + + /// Escape (U+001B) + public static let escape: KeyEquivalent = "\u{001B}" + + /// Delete (U+0008) + public static let delete: KeyEquivalent = "\u{0008}" + + /// Delete Forward (U+F728) + public static let deleteForward: KeyEquivalent = "\u{F728}" + + /// Home (U+F729) + public static let home: KeyEquivalent = "\u{F729}" + + /// End (U+F72B) + public static let end: KeyEquivalent = "\u{F72B}" + + /// Page Up (U+F72C) + public static let pageUp: KeyEquivalent = "\u{F72C}" + + /// Page Down (U+F72D) + public static let pageDown: KeyEquivalent = "\u{F72D}" + + /// Clear (U+F739) + public static let clear: KeyEquivalent = "\u{F739}" + + /// Tab (U+0009) + public static let tab: KeyEquivalent = "\u{0009}" + + /// Space (U+0020) + public static let space: KeyEquivalent = "\u{0020}" + + /// Return (U+000D) + public static let `return`: KeyEquivalent = "\u{000D}" + + /// The character value that the key equivalent represents. + public var character: Character + + /// Creates a new key equivalent from the given character value. + public init(_ character: Character) { + self.character = character + } +} + +@available(OpenSwiftUI_v5_0, *) +@available(watchOS, unavailable) +extension KeyEquivalent: Hashable {} + +@available(OpenSwiftUI_v2_0, *) +// @available(tvOS 17.0, *) +@available(watchOS, unavailable) +extension KeyEquivalent: ExpressibleByExtendedGraphemeClusterLiteral { + public init(extendedGraphemeClusterLiteral: Character) { + character = extendedGraphemeClusterLiteral + } + + public typealias ExtendedGraphemeClusterLiteralType = Character + public typealias UnicodeScalarLiteralType = Character +} + +// MARK: - EnvironmentValues + keyboardShortcut + +@available(OpenSwiftUI_v3_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension EnvironmentValues { + /// The keyboard shortcut that buttons in this environment will be triggered + /// with. + /// + /// This is particularly useful in button styles when a button's appearance + /// depends on the shortcut associated with it. On macOS, for example, when + /// a button is bound to the Return key, it is typically drawn with a + /// special emphasis. This happens automatically when using the built-in + /// button styles, and can be implemented manually in custom styles using + /// this environment key: + /// + /// private struct MyButtonStyle: ButtonStyle { + /// @Environment(\.keyboardShortcut) + /// private var shortcut: KeyboardShortcut? + /// + /// func makeBody(configuration: Configuration) -> some View { + /// let labelFont = Font.body + /// .weight(shortcut == .defaultAction ? .bold : .regular) + /// configuration.label + /// .font(labelFont) + /// } + /// } + /// + /// If no keyboard shortcut has been applied to the view or its ancestor, + /// then the environment value will be `nil`. + public internal(set) var keyboardShortcut: KeyboardShortcut? { + get { self[ButtonKeyboardShortcutKey.self] } + set { self[ButtonKeyboardShortcutKey.self] = newValue } + } +} + +// MARK: - ButtonKeyboardShortcutKey + +private struct ButtonKeyboardShortcutKey: EnvironmentKey { + static var defaultValue: KeyboardShortcut? { nil } +} + +extension CachedEnvironment.ID { + static let keyboardShortcut: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var keyboardShortcut: Attribute { + mapEnvironment(id: .keyboardShortcut) { $0.keyboardShortcut } + } +} + +// MARK: - EnvironmentValues + sceneKeyboardShortcuts + +extension EnvironmentValues { + private struct SceneKeyboardShortcutsKey: EnvironmentKey { + static var defaultValue: [SceneID: KeyboardShortcut] { [:] } + } + + @inline(__always) + var sceneKeyboardShortcuts: [SceneID: KeyboardShortcut] { + get { self[SceneKeyboardShortcutsKey.self] } + set { self[SceneKeyboardShortcutsKey.self] = newValue } + } +} + +// MARK: - View + KeyboardShortcutBindingBehavior + +extension View { + nonisolated func keyboardShortcutBindingBehavior( + action: @escaping () -> Void, + label: () -> V + ) -> some View where V: View { + modifier(KeyboardShortcutBindingBehavior(action: action, label: label())) + } +} + +// MARK: - KeyboardShortcutBinding + +struct KeyboardShortcutBinding { + var shortcut: KeyboardShortcut + var action: () -> () + var title: String? +} + +// MARK: - KeyboardShortcutBindingBehavior + +struct KeyboardShortcutBindingBehavior: MultiViewModifier, PrimitiveViewModifier where V: View { + var action: () -> () + var label: V + + nonisolated static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var outputs = body(_Graph(), inputs) + if inputs[HasKeyboardShortcut.self] { + let listGenerator = PlatformItemListGenerator( + flags: TextPlatformItemListFlags.self, + content: modifier.value[offset: { .of(&$0.label) }], + inputs: inputs, + inputsIncludeGeometry: true + ) + outputs.preferences.makePreferenceWriter( + inputs: inputs.preferences, + key: KeyboardShortcutBindingsKey.self, + value: Attribute(BindKeyboardShortcutItems( + modifier: modifier.value, + listGenerator: listGenerator, + shortcut: inputs.base.keyboardShortcut, + isEnabled: inputs.isEnabled, + hostKeys: inputs.preferences.hostKeys, + itemList: OptionalAttribute() + )) + ) + } + return outputs + } +} + +// MARK: - KeyboardShortcutBindingsKey + +struct KeyboardShortcutBindingsKey: HostPreferenceKey { + static var defaultValue: [KeyboardShortcutBinding] { [] } + + static func reduce(value: inout Value, nextValue: () -> Value) { + value.append(contentsOf: nextValue()) + } +} + +// MARK: - BindKeyboardShortcutItems + +private struct BindKeyboardShortcutItems: StatefulRule where V: View { + @Attribute var modifier: KeyboardShortcutBindingBehavior + var listGenerator: PlatformItemListGenerator + @Attribute var shortcut: KeyboardShortcut? + @Attribute var isEnabled: Bool + @Attribute var hostKeys: PreferenceKeys + var itemList: OptionalAttribute + + typealias Value = [KeyboardShortcutBinding] + + mutating func updateValue() { + guard hostKeys.contains(KeyboardShortcutBindingsKey.self), + isEnabled, + let shortcut else { + value = [] + return + } + let list: Attribute + if let attribute = itemList.attribute { + list = attribute + } else { + let oldSubgraph = Subgraph.current + Subgraph.current = attribute.subgraph + list = Attribute(listGenerator) + Subgraph.current = oldSubgraph + itemList = OptionalAttribute(list) + } + value = [KeyboardShortcutBinding( + shortcut: shortcut, + action: modifier.action, + title: list.value.mergedContentItem.text?.string + )] + } +} + +// MARK: - HasKeyboardShortcut + +struct HasKeyboardShortcut: ViewInputBoolFlag {} + +// MARK: - KeyboardShortcut + Hashable + +@available(OpenSwiftUI_v3_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension KeyboardShortcut: Hashable { + public static func == (lhs: KeyboardShortcut, rhs: KeyboardShortcut) -> Bool { + lhs.key.character == rhs.key.character && + lhs.modifiers == rhs.modifiers && + lhs.localization.style == rhs.localization.style + } + + public func hash(into hasher: inout Hasher) { + key.character.hash(into: &hasher) + hasher.combine(modifiers.rawValue) + hasher.combine(localization.style) + } +} diff --git a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyEquivalent.swift b/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyEquivalent.swift deleted file mode 100644 index e1b488881..000000000 --- a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyEquivalent.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// KeyEquivalent.swift -// OpenSwiftUI -// -// Audited for 3.5.2 -// Status: Complete - -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public struct KeyEquivalent { - public static let upArrow: KeyEquivalent = "\u{F700}" - public static let downArrow: KeyEquivalent = "\u{F701}" - public static let leftArrow: KeyEquivalent = "\u{F702}" - public static let rightArrow: KeyEquivalent = "\u{F703}" - public static let escape: KeyEquivalent = "\u{001B}" - public static let delete: KeyEquivalent = "\u{0008}" - public static let deleteForward: KeyEquivalent = "\u{F728}" - public static let home: KeyEquivalent = "\u{F729}" - public static let end: KeyEquivalent = "\u{F72B}" - public static let pageUp: KeyEquivalent = "\u{F72C}" - public static let pageDown: KeyEquivalent = "\u{F72D}" - public static let clear: KeyEquivalent = "\u{F739}" - public static let tab: KeyEquivalent = "\u{0009}" - public static let space: KeyEquivalent = "\u{0020}" - public static let `return`: KeyEquivalent = "\u{000D}" - - public var character: Character - public init(_ character: Character) { - self.character = character - } -} - -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension KeyEquivalent: ExpressibleByExtendedGraphemeClusterLiteral { - public init(extendedGraphemeClusterLiteral: Character) { - character = extendedGraphemeClusterLiteral - } - - public typealias ExtendedGraphemeClusterLiteralType = Character - public typealias UnicodeScalarLiteralType = Character -} diff --git a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyboardShortcutBinding.swift b/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyboardShortcutBinding.swift deleted file mode 100644 index ac5f4afbe..000000000 --- a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/KeyboardShortcutBinding.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// KeyboardShortcutBinding.swift -// OpenSwiftUI - -struct KeyboardShortcutBinding { - var shortcut: KeyboardShortcut - var action: ()->Void - var title: String? -} diff --git a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/TODO/KeyboardShortcut.swift b/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/TODO/KeyboardShortcut.swift deleted file mode 100644 index 8825817c0..000000000 --- a/Sources/OpenSwiftUI/Event/InputEvent/KeyboardShortcut/TODO/KeyboardShortcut.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// KeyboardShortcut.swift -// OpenSwiftUI -// -// Audited for 3.5.2 -// Status: Blocked by EnvironmentValues -// ID: 254C3FE5924A018B482F2F0C0D49154 (SwiftUI) - -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public struct KeyboardShortcut { - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public struct Localization { - public static let automatic = Localization(style: .automatic) - public static let withoutMirroring = Localization(style: .withoutMirroring) - public static let custom = Localization(style: .custom) - - enum Style: Hashable { - case automatic - case withoutMirroring - case custom - } - - let style: Style - } - - public static let defaultAction = KeyboardShortcut(.return, modifiers: []) - public static let cancelAction = KeyboardShortcut(.escape, modifiers: []) - public var key: KeyEquivalent - public var modifiers: EventModifiers - public var localization: Localization - - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public init(_ key: KeyEquivalent, modifiers: EventModifiers = .command) { - self.key = key - self.modifiers = modifiers - self.localization = .automatic - } - - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public init(_ key: KeyEquivalent, modifiers: EventModifiers = .command, localization: Localization) { - self.key = key - self.modifiers = modifiers - self.localization = localization - } -} - -// @available(tvOS, unavailable) -// @available(watchOS, unavailable) -// extension View { -// public func keyboardShortcut(_: KeyEquivalent, modifiers _: EventModifiers = .command) -> some View {} -// -// public func keyboardShortcut(_: KeyEquivalent, modifiers _: EventModifiers = .command, localization _: KeyboardShortcut.Localization) -> some View {} -// -// public func keyboardShortcut(_: KeyboardShortcut) -> some View {} -// -// public func keyboardShortcut(_: KeyboardShortcut?) -> some View {} -// } - -// @available(tvOS, unavailable) -// @available(watchOS, unavailable) -// extension EnvironmentValues { -// public var keyboardShortcut: KeyboardShortcut? { -// get -// } -// } - -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension KeyboardShortcut: Hashable { - public static func == (lhs: KeyboardShortcut, rhs: KeyboardShortcut) -> Bool { - lhs.key.character == rhs.key.character && - lhs.modifiers == rhs.modifiers && - lhs.localization.style == rhs.localization.style - } - - public func hash(into hasher: inout Hasher) { - key.character.hash(into: &hasher) - hasher.combine(modifiers.rawValue) - hasher.combine(localization.style) - } -} - -// MARK: EnvironmentValues + sceneKeyboardShortcuts - -extension EnvironmentValues { - private struct SceneKeyboardShortcutsKey: EnvironmentKey { - static var defaultValue: [SceneID: KeyboardShortcut] { [:] } - } - - @inline(__always) - var sceneKeyboardShortcuts: [SceneID: KeyboardShortcut] { - get { self[SceneKeyboardShortcutsKey] } - set { self[SceneKeyboardShortcutsKey] = newValue } - } -} diff --git a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift index aaa662cda..4e4dffd9a 100644 --- a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift +++ b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift @@ -29,12 +29,25 @@ package struct PlatformItemList { var namedResolvedImage: Image.NamedResolved? var systemItem: SystemItem? var selectionBehavior: SelectionBehavior? + var keyboardShortcut: KeyboardShortcut? + var onHover: ((Bool) -> ())? + var buttonRole: ButtonRole? var accessibility: Accessibility? + var secondaryNavigationBehavior: SecondaryNavigationBehavior? var label: NSAttributedString? var tooltip: String? var badge: String? + var children: PlatformItemList? + var labelGroupChildren: PlatformItemList? + var menuIndicatorVisibility: Visibility? + var controlSize: ControlSize? + var toggleState: ToggleState? + var commandOperation: CommandOperation? + var scaleDownMenuImage: Bool = false + var keepsMenuPresented: Bool = false + var isPopUpButton: Bool? + // var menuOrder: MenuOrder = .automatic var tint: Color? - // TODO init( text: NSAttributedString? = nil, @@ -58,6 +71,8 @@ package struct PlatformItemList { struct SelectionBehavior {} + struct SecondaryNavigationBehavior {} + struct Accessibility {} struct ImageColorResolver { @@ -207,7 +222,7 @@ struct PlatformItemListHiddenRepresentable: PlatformHiddenRepresentable { } } -// MARK: - PlatformItemListViewThatFitsRepresentable [WIP] +// MARK: - PlatformItemListViewThatFitsRepresentable struct PlatformItemListViewThatFitsRepresentable: PlatformViewThatFitsRepresentable { static func shouldMakeRepresentation(inputs: _ViewInputs) -> Bool { @@ -231,7 +246,19 @@ struct PlatformItemListViewThatFitsRepresentable: PlatformViewThatFitsRepresenta let state: SizeFittingState var value: (inout PlatformItemList) -> Void { - _openSwiftUIUnimplementedFailure() + { list in + list.items = [] + var children = PlatformItemList(items: []) + state.applyChildren(selectLast: false) { outputs, _ in + if let childList = outputs.preferences.platformItemList { + children.items.append(childList.value.mergedContentItem) + } + return false + } + var item = PlatformItemList.Item() + item.children = children + list.items = [item] + } } } } @@ -388,3 +415,9 @@ struct PlatformItemListTextRepresentable: PlatformTextRepresentable { } } } + +// MARK: - IsPlatformItemListSourceInput + +struct IsPlatformItemListSourceInput: ViewInput { + static var defaultValue: Bool { false } +} diff --git a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListFlag.swift b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListFlag.swift index c2ef1339e..58ae07fbc 100644 --- a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListFlag.swift +++ b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListFlag.swift @@ -25,8 +25,7 @@ struct PlatformItemListFlagsSet: OptionSet, Hashable { static var layout: PlatformItemListFlagsSet { .init(rawValue: 1 << 3) } - // FIXME: Infer the semantic name from a concrete consumer. - static var _4: PlatformItemListFlagsSet { .init(rawValue: 1 << 4) } + static var accessibility: PlatformItemListFlagsSet { .init(rawValue: 1 << 4) } static var namedImage: PlatformItemListFlagsSet { .init(rawValue: 1 << 5) } @@ -34,13 +33,13 @@ struct PlatformItemListFlagsSet: OptionSet, Hashable { static var action: PlatformItemListFlagsSet { [.selection, .text, .layout] } - static var label: PlatformItemListFlagsSet { [.image, .text, ._4] } + static var label: PlatformItemListFlagsSet { [.image, .text, .accessibility] } static var toolbar: PlatformItemListFlagsSet { .label } static var searchToken: PlatformItemListFlagsSet { [.selection, .image, .text, .layout] } - static var widgetMetadata: PlatformItemListFlagsSet { [.image, .text, ._4, .namedImage, .viewThatFits] } + static var widgetMetadata: PlatformItemListFlagsSet { [.image, .text, .accessibility, .namedImage, .viewThatFits] } static var all: PlatformItemListFlagsSet { .init(rawValue: .max) } diff --git a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListGenerator.swift b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListGenerator.swift new file mode 100644 index 000000000..a52b299a4 --- /dev/null +++ b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemListGenerator.swift @@ -0,0 +1,79 @@ +// +// PlatformItemListGenerator.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 4CA94EFFBA1A33DEA2B0583B3783C90F (SwiftUI) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - PlatformItemListGenerator + +struct PlatformItemListGenerator: StatefulRule where Flags: PlatformItemListFlags, Content: View { + var subgraph: Subgraph + @Attribute var content: Content + let inputs: _ViewInputs + let inputsIncludeGeometry: Bool + @OptionalAttribute var itemList: PlatformItemList? + + init( + flags: Flags.Type, + content: Attribute, + inputs: _ViewInputs, + inputsIncludeGeometry: Bool + ) { + self.subgraph = Subgraph.current! + self._content = content + self.inputs = inputs + self.inputsIncludeGeometry = inputsIncludeGeometry + self._itemList = OptionalAttribute() + } + + typealias Value = PlatformItemList + + mutating func updateValue() { + if !hasValue { + _itemList = subgraph.apply { + makeItemList() + } + } + value = itemList ?? PlatformItemList(items: []) + } + + private func makeItemList() -> OptionalAttribute { + let flags = Flags.flags + var newInputs = inputs + if inputsIncludeGeometry { + newInputs = newInputs.withoutGeometryDependencies + newInputs.preferences = PreferencesInputs( + hostKeys: inputs.intern(PreferenceKeys(), id: .defaultValue) + ) + } + newInputs.addPlatformItemListKey(flags: Flags.self, editOperation: .replace) + newInputs[IsPlatformItemListSourceInput.self] = true + if flags.contains(.accessibility), + inputs.preferences.contains(AccessibilityNodesKey.self) { + newInputs.preferences.add(AccessibilityAttachment.Key.self) + } + let outputs = Content._makeView(view: _GraphValue($content), inputs: newInputs) + return OptionalAttribute(outputs.preferences.platformItemList) + } +} + +extension PlatformItemListGenerator where Flags == AllPlatformItemListFlags { + init( + content: Attribute, + inputs: _ViewInputs, + inputsIncludeGeometry: Bool + ) { + self.init( + flags: AllPlatformItemListFlags.self, + content: content, + inputs: inputs, + inputsIncludeGeometry: inputsIncludeGeometry + ) + } +} diff --git a/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift b/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift index 693229516..83987e773 100644 --- a/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift +++ b/Sources/OpenSwiftUI/Modifier/SceneModifier/PreferenceSceneModifier.swift @@ -60,7 +60,7 @@ extension _PreferenceTransformModifier: _SceneModifier { @available(OpenSwiftUI_v4_0, *) extension Scene { @inlinable - func transformPreference( + nonisolated func transformPreference( _ key: K.Type = K.self, _ callback: @escaping (inout K.Value) -> Void ) -> some Scene where K: PreferenceKey { diff --git a/Sources/OpenSwiftUI/View/Control/Picker/KeyboardShortcutPickerOptionTraitKey.swift b/Sources/OpenSwiftUI/View/Control/Picker/KeyboardShortcutPickerOptionTraitKey.swift new file mode 100644 index 000000000..953060b85 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Picker/KeyboardShortcutPickerOptionTraitKey.swift @@ -0,0 +1,52 @@ +// +// KeyboardShortcutPickerContent.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +import OpenSwiftUICore + +// TODO: _KeyboardShortcutPickerContent + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension View { + @inlinable + nonisolated public func pickerKeyboardShortcut( + _ shortcut: KeyboardShortcut? + ) -> some View { + _trait(KeyboardShortcutPickerOptionTraitKey.self, shortcut) + } + + nonisolated public func pickerKeyboardShortcut( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command + ) -> some View { + _trait(KeyboardShortcutPickerOptionTraitKey.self, .init(key, modifiers: modifiers)) + } + + nonisolated public func pickerKeyboardShortcut( + _ key: KeyEquivalent, + modifiers: EventModifiers = .command, + localization: KeyboardShortcut.Localization + ) -> some View { + _trait(KeyboardShortcutPickerOptionTraitKey.self, .init(key, modifiers: modifiers, localization: localization)) + } +} + +// MARK: - KeyboardShortcutPickerOptionTraitKey + +@available(OpenSwiftUI_v2_0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@usableFromInline +struct KeyboardShortcutPickerOptionTraitKey: _ViewTraitKey { + @inlinable + static var defaultValue: KeyboardShortcut? { nil } +} + +@available(*, unavailable) +extension KeyboardShortcutPickerOptionTraitKey: Sendable {} diff --git a/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift index cc4afe8ab..a581cb096 100644 --- a/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift +++ b/Tests/OpenSwiftUICoreTests/View/Text/AttributedString/NSAttributedStringTests.swift @@ -111,8 +111,8 @@ private func expectApproximatelyEqual( } } -// Semantic version overrides are process wide, so the tests below must not run -// concurrently with each other. +// Serialize this suite's reads of process-wide semantic overrides. +// Override tests also use the main actor to exclude graph tests in other suites. @Suite(.serialized) struct NSAttributedStringTests { // MARK: - Max font metrics @@ -132,6 +132,7 @@ struct NSAttributedStringTests { #expect(metrics.outsets == .zero) } + @MainActor @Test(arguments: [true, false]) func maxFontMetricsOutsetsFollowTextRenderingMetrics(isTextRenderingMetricsEnabled: Bool) { let semantics = isTextRenderingMetricsEnabled @@ -305,6 +306,7 @@ struct NSAttributedStringTests { ) } + @MainActor @Test(arguments: [true, false]) func textSpacingWithStandardSizing(isTextSpacingV2Enabled: Bool) { let semantics = isTextSpacingV2Enabled @@ -372,6 +374,7 @@ struct NSAttributedStringTests { ) } + @MainActor @Test func textSpacingWithVerticalWritingModeUsesSideEdges() { Semantics.TextSpacingUIKit0059v2.introduced.test(as: \.sdk) { diff --git a/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift b/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift new file mode 100644 index 000000000..ec4fbfc32 --- /dev/null +++ b/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift @@ -0,0 +1,207 @@ +// +// KeyPressTests.swift +// OpenSwiftUITests + +import Foundation +import OpenAttributeGraphShims +@_spi(_) +@testable import OpenSwiftUI +@_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore +import Testing + +@MainActor +@Suite(.disabled(if: attributeGraphVendor == .oag)) +struct KeyPressTests { + @Test(arguments: [ + (KeyPress.Phases(), 0, "[]"), + (.down, 1, ".down"), + (.repeat, 2, ".repeat"), + (.up, 4, ".up"), + ([.down, .repeat], 3, "[.down, .repeat]"), + ([.down, .repeat, .up], 7, "[.down, .repeat, .up]"), + (.init(rawValue: 8), 8, "[]"), + (.init(rawValue: 9), 9, ".down"), + (.all, Int.max, ".all"), + (.init(rawValue: -1), -1, "[.down, .repeat, .up]"), + ] as [(KeyPress.Phases, Int, String)]) + func phases(phase: KeyPress.Phases, rawValue: Int, description: String) { + #expect(phase.rawValue == rawValue) + #expect(phase.debugDescription == description) + } + + @Test(arguments: [ + (.began, .down), + (.active, .repeat), + (.ended, .up), + (.failed, .up), + ] as [(EventPhase, KeyPress.Phases)]) + func eventConversion(phase: EventPhase, expected: KeyPress.Phases) throws { + let press = try #require(KeyPress(for: event(phase: phase, keys: "ab", string: "AB"))) + #expect(press.phase == expected) + #expect(press.key == "a") + #expect(press.characters == "ab") + #expect(press.modifiers == [.shift, .command]) + } + + @Test + func emptyAndNonKeyEvents() { + #expect(KeyPress(for: event(keys: "", string: "a")) == nil) + #expect(KeyPress(for: OtherEvent()) == nil) + } + + @Test(arguments: [ + ("👨‍👩‍👧‍👦x", Character("👨‍👩‍👧‍👦"), "KeyPress(.down, \"👨‍👩‍👧‍👦x\")"), + ("\"\n", "\"", "KeyPress(.down, \"\"\n\")"), + ]) + func keyAndDescription(keys: String, key: Character, description: String) throws { + let press = try #require(KeyPress(for: event(keys: keys, string: "ignored"))) + #expect(press.key.character == key) + #expect(press.characters == keys) + #expect(press.debugDescription == description) + } + + @Test(arguments: [KeyPress.Result.handled, .ignored]) + func singleKeyActionAndDefaultPhases(result: KeyPress.Result) throws { + var calls = 0 + let view = EmptyView().onKeyPress("a") { + calls += 1 + return result + } + let handlers = try environment(for: view).keyPressHandlers + let handler = try #require(handlers.first) + #expect(calls == 0) + #expect(handler.phases == [.down, .repeat]) + guard case let .keys(keys) = handler.subject else { + Issue.record("Expected a key subject") + return + } + #expect(keys == ["a"]) + let press = try #require(KeyPress(for: event(keys: "a", string: "A"))) + #expect(handler.action(press) == result) + #expect(calls == 1) + } + + @Test(arguments: [ + (KeyOverload.single, Set(["a"]), KeyPress.Phases.up), + (.keys, ["a", "b"], [.down, .repeat]), + (.keysIn, ["x"], .up), + ] as [(KeyOverload, Set, KeyPress.Phases)]) + func keySubjects(overload: KeyOverload, keys: Set, phases: KeyPress.Phases) throws { + let values: EnvironmentValues + switch overload { + case .single: + let key = try #require(keys.first) + values = try environment(for: EmptyView().onKeyPress(key, phases: phases) { _ in .ignored }) + case .keys: + values = try environment(for: EmptyView().onKeyPress(keys: keys) { _ in .ignored }) + case .keysIn: + values = try environment(for: EmptyView().onKeyPress(keysIn: keys, phases: phases) { _ in .ignored }) + } + let handler = try #require(values.keyPressHandlers.first) + guard case let .keys(subject) = handler.subject else { + Issue.record("Expected a key subject") + return + } + #expect(subject == keys) + #expect(handler.phases == phases) + } + + @Test(arguments: [ + (false, CharacterSet.letters, KeyPress.Phases.up), + (true, .decimalDigits, [.down, .repeat]), + ] as [(Bool, CharacterSet, KeyPress.Phases)]) + func characterSubjects(deprecated: Bool, characters: CharacterSet, phases: KeyPress.Phases) throws { + let values = try deprecated + ? environment(for: EmptyView().onKeyPress(charactersIn: characters) { _ in .ignored }) + : environment(for: EmptyView().onKeyPress(characters: characters, phases: phases) { _ in .ignored }) + let handler = try #require(values.keyPressHandlers.first) + guard case let .characters(subject) = handler.subject else { + Issue.record("Expected a character-set subject") + return + } + #expect(subject == characters) + #expect(handler.phases == phases) + } + + @Test + func allKeysAndDefaultPhases() throws { + let all = try environment(for: EmptyView().onKeyPress { _ in .ignored }) + guard case .all = try #require(all.keyPressHandlers.first).subject else { + Issue.record("Expected an all-keys subject") + return + } + #expect(all.keyPressHandlers.first?.phases == [.down, .repeat]) + } + + @Test + func environmentPreservesHandlerOrderAndActions() throws { + let first = try environment(for: EmptyView().onKeyPress(phases: .up) { _ in .handled }) + let second = try environment(for: EmptyView().onKeyPress(phases: .down) { _ in .ignored }, initial: first) + #expect(second.keyPressHandlers.map(\.phases) == [.up, .down]) + #expect(first.keyPressHandlers.count == 1) + let press = try #require(KeyPress(for: event(keys: "a", string: "A"))) + #expect(second.keyPressHandlers.map { $0.action(press) } == [.handled, .ignored]) + } + + @Test + func keyHashingUsesCharacterEquality() { + let keys: Set = [KeyEquivalent("é"), KeyEquivalent("e\u{301}")] + #expect(keys.count == 1) + #expect(Set([KeyPress.Result.handled, .ignored]).count == 2) + } + + private func event(phase: EventPhase = .began, keys: String, string: String) -> KeyEvent { + KeyEvent( + phase: phase, + timestamp: .init(seconds: 0), + modifiers: [.shift, .command], + keys: keys, + stringValue: string, + keyID: 1 + ) + } + + enum KeyOverload: Sendable { + case single, keys, keysIn + } + + private func environment(for view: V, initial: EnvironmentValues = .init()) throws -> EnvironmentValues { + let modifier = try #require(Mirror(reflecting: view).descendant("modifier") as? any EnvironmentModifier) + return applying(modifier, to: initial) + } + + private func applying(_ modifier: M, to initial: EnvironmentValues) -> EnvironmentValues { + let graph = ViewGraph(rootViewType: EmptyView.self) + return graph.globalSubgraph.apply { + var environment = initial + M.makeEnvironment(modifier: Attribute(value: modifier), environment: &environment) + return environment + } + } + + private struct OtherEvent: EventType { + var phase: EventPhase = .began + var timestamp: Time = .init(seconds: 0) + var binding: EventBinding? + } +} + +extension KeyPress { + fileprivate init?(for event: any EventType) { + guard let event = event as? KeyEvent, + let character = event.keys.first else { + return nil + } + let phase: Phases = switch event.phase { + case .began: .down + case .active: .repeat + case .ended, .failed: .up + } + self.init( + phase: phase, + key: KeyEquivalent(character), + characters: event.keys, + modifiers: event.modifiers + ) + } +}