From e9ee6dd39f2141bb9bfb5ed8a146d6ae41158da1 Mon Sep 17 00:00:00 2001 From: Sam Deane Date: Thu, 24 Oct 2024 16:16:53 +0100 Subject: [PATCH] Generic signal support Require macOS 14 for the generic pack support Would bumping the swift-tools-version to 5.10 be better? Use generic signal instead of generating helper Don't use SimpleSignal Moved GenericSignal to its own file. Removed some unused code, tidied comments. Fixed popping objects. Cleaner unpacking, without the need to copy the arguments. revert unrelated formatting changes put back SignalSupport to minimise changes Removed SimpleSignal completely tweaked names for a cleaner diff Added test for built in signals --- Generator/Generator/ClassGen.swift | 118 +++++------------- Package.swift | 4 +- Sources/SwiftGodot/Core/GenericSignal.swift | 99 +++++++++++++++ Sources/SwiftGodot/Core/SignalSupport.swift | 126 +++----------------- Tests/SwiftGodotTests/MarshalTests.swift | 12 +- 5 files changed, 153 insertions(+), 206 deletions(-) create mode 100644 Sources/SwiftGodot/Core/GenericSignal.swift diff --git a/Generator/Generator/ClassGen.swift b/Generator/Generator/ClassGen.swift index ce5822f00..05abc4d9d 100644 --- a/Generator/Generator/ClassGen.swift +++ b/Generator/Generator/ClassGen.swift @@ -518,98 +518,11 @@ func generateClasses (values: [JGodotExtensionAPIClass], outputDir: String?) asy } } -func generateSignalType (_ p: Printer, _ cdef: JGodotExtensionAPIClass, _ signal: JGodotSignal, _ name: String) -> String { - doc (p, cdef, "Signal support.\n") - doc (p, cdef, "Use the ``\(name)/connect(flags:_:)`` method to connect to the signal on the container object, and ``\(name)/disconnect(_:)`` to drop the connection.\nYou can also await the ``\(name)/emitted`` property for waiting for a single emission of the signal.") - - var lambdaFull = "" - p ("public class \(name)") { - p ("var target: Object") - p ("var signalName: StringName") - p ("init (target: Object, signalName: StringName)") { - p ("self.target = target") - p ("self.signalName = signalName") - } - doc (p, cdef, "Connects the signal to the specified callback\n\nTo disconnect, call the disconnect method, with the returned token on success\n - Parameters:\n - callback: the method to invoke when this signal is raised\n - flags: Optional, can be also added to configure the connection's behavior (see ``Object/ConnectFlags`` constants).\n - Returns: an object token that can be used to disconnect the object from the target on success, or the error produced by Godot.") - - p ("@discardableResult /* \(name) */") - var args = "" - var argUnwrap = "" - var callArgs = "" - var argIdx = 0 - var lambdaIgnore = "" - for arg in signal.arguments ?? [] { - if args != "" { - args += ", " - callArgs += ", " - lambdaIgnore += ", " - lambdaFull += ", " - } - args += getArgumentDeclaration(arg, omitLabel: true, isOptional: arg.type == "Variant") - let construct: String - - if let _ = classMap [arg.type] { - argUnwrap += "var ptr_\(argIdx): UnsafeMutableRawPointer?\n" - argUnwrap += "args [\(argIdx)]!.toType (Variant.GType.object, dest: &ptr_\(argIdx))\n" - let handleResolver: String - if hasSubclasses.contains(cdef.name) { - // If the type we are bubbling up has subclasses, we want to create the most - // derived type if possible, so we perform the longer lookup - handleResolver = "lookupObject (nativeHandle: ptr_\(argIdx)!) ?? " - } else { - handleResolver = "" - } - - construct = "lookupLiveObject (handleAddress: ptr_\(argIdx)!) as? \(arg.type) ?? \(handleResolver)\(arg.type) (nativeHandle: ptr_\(argIdx)!)" - } else if arg.type == "String" { - construct = "\(mapTypeName(arg.type)) (args [\(argIdx)]!)!.description" - } else if arg.type == "Variant" { - construct = "args [\(argIdx)]" - } else { - construct = "\(getGodotType(arg)) (args [\(argIdx)]!)!" - } - argUnwrap += "let arg_\(argIdx) = \(construct)\n" - callArgs += "arg_\(argIdx)" - lambdaIgnore += "_" - lambdaFull += escapeSwift (snakeToCamel (arg.name)) - argIdx += 1 - } - p ("public func connect (flags: Object.ConnectFlags = [], _ callback: @escaping (\(args)) -> ()) -> Object") { - p ("let signalProxy = SignalProxy()") - p ("signalProxy.proxy = ") { - p ("args in") - p (argUnwrap) - p ("callback (\(callArgs))") - } - p ("let callable = Callable(object: signalProxy, method: SignalProxy.proxyName)") - p ("let r = target.connect(signal: signalName, callable: callable, flags: UInt32 (flags.rawValue))") - p ("if r != .ok { print (\"Warning, error connecting to signal, code: \\(r)\") }") - p ("return signalProxy") - } - - doc (p, cdef, "Disconnects a signal that was previously connected, the return value from calling ``connect(flags:_:)``") - p ("public func disconnect (_ token: Object)") { - p ("target.disconnect(signal: signalName, callable: Callable (object: token, method: SignalProxy.proxyName))") - } - doc (p, cdef, "You can await this property to wait for the signal to be emitted once") - p ("public var emitted: Void "){ - p ("get async") { - p ("await withCheckedContinuation") { - p ("c in") - p ("connect (flags: .oneShot) { \(lambdaIgnore) in c.resume () }") - } - } - } - } - return lambdaFull -} - func generateSignals (_ p: Printer, cdef: JGodotExtensionAPIClass, signals: [JGodotSignal]) { p ("// Signals ") var parameterSignals: [JGodotSignal] = [] - var sidx = 0 for signal in signals { let signalProxyType: String @@ -617,11 +530,10 @@ func generateSignals (_ p: Printer, if signal.arguments != nil { parameterSignals.append (signal) - sidx += 1 - signalProxyType = "Signal\(sidx)" - lambdaSig = " " + generateSignalType (p, cdef, signal, signalProxyType) + " in" + signalProxyType = getGenericSignalType(signal) + lambdaSig = " \(getGenericSignalLambdaArgs(signal)) in" } else { - signalProxyType = "SimpleSignal" + signalProxyType = "GenericSignal< /* no args */ >" lambdaSig = "" } let signalName = godotMethodToSwift (signal.name) @@ -639,6 +551,30 @@ func generateSignals (_ p: Printer, } } +/// Return the type of a signal's parameters. +func getGenericSignalType(_ signal: JGodotSignal) -> String { + var argTypes: [String] = [] + for signalArgument in signal.arguments ?? [] { + let godotType = getGodotType(signalArgument) + if !godotType.isEmpty && godotType != "Variant" { + argTypes.append(godotType) + } + } + + return argTypes.isEmpty ? "GenericSignal< /* no args */ >" : "GenericSignal<\(argTypes.joined(separator: ", "))>" + } + +/// Return the names of a signal's parameters, +/// for use in documenting the corresponding lambda. +func getGenericSignalLambdaArgs(_ signal: JGodotSignal) -> String { + var argNames: [String] = [] + for signalArgument in signal.arguments ?? [] { + argNames.append(escapeSwift(snakeToCamel(signalArgument.name))) + } + + return argNames.joined(separator: ", ") +} + func generateSignalDocAppendix (_ p: Printer, cdef: JGodotExtensionAPIClass, signals: [JGodotSignal]?) { guard let signals = signals, signals.count > 0 else { return diff --git a/Package.swift b/Package.swift index 3313fa0e2..4d8034d8a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -231,7 +231,7 @@ targets.append(contentsOf: [ let package = Package( name: "SwiftGodot", platforms: [ - .macOS(.v13), + .macOS(.v14), .iOS (.v15) ], products: products, diff --git a/Sources/SwiftGodot/Core/GenericSignal.swift b/Sources/SwiftGodot/Core/GenericSignal.swift new file mode 100644 index 000000000..2309384a4 --- /dev/null +++ b/Sources/SwiftGodot/Core/GenericSignal.swift @@ -0,0 +1,99 @@ +// +// Created by Sam Deane on 25/10/2024. +// + +/// Signal support. +/// Use the ``GenericSignal/connect(flags:_:)`` method to connect to the signal on the container object, +/// and ``GenericSignal/disconnect(_:)`` to drop the connection. +/// +/// Use the ``GenericSignal/emit(...)`` method to emit a signal. +/// +/// You can also await the ``Signal1/emitted`` property for waiting for a single emission of the signal. +/// +public class GenericSignal { + var target: Object + var signalName: StringName + public init(target: Object, signalName: StringName) { + self.target = target + self.signalName = signalName + } + + /// Connects the signal to the specified callback + /// To disconnect, call the disconnect method, with the returned token on success + /// + /// - Parameters: + /// - callback: the method to invoke when this signal is raised + /// - flags: Optional, can be also added to configure the connection's behavior (see ``Object/ConnectFlags`` constants). + /// - Returns: an object token that can be used to disconnect the object from the target on success, or the error produced by Godot. + /// + @discardableResult /* Signal1 */ + public func connect(flags: Object.ConnectFlags = [], _ callback: @escaping (_ t: repeat each T) -> Void) -> Object { + let signalProxy = SignalProxy() + signalProxy.proxy = { args in + var index = 0 + do { + callback(repeat try args.unpack(as: (each T).self, index: &index)) + } catch { + print("Error unpacking signal arguments: \(error)") + } + } + + let callable = Callable(object: signalProxy, method: SignalProxy.proxyName) + let r = target.connect(signal: signalName, callable: callable, flags: UInt32(flags.rawValue)) + if r != .ok { print("Warning, error connecting to signal, code: \(r)") } + return signalProxy + } + + /// Disconnects a signal that was previously connected, the return value from calling + /// ``connect(flags:_:)`` + public func disconnect(_ token: Object) { + target.disconnect(signal: signalName, callable: Callable(object: token, method: SignalProxy.proxyName)) + } + + /// You can await this property to wait for the signal to be emitted once. + public var emitted: Void { + get async { + await withCheckedContinuation { c in + let signalProxy = SignalProxy() + signalProxy.proxy = { _ in c.resume() } + let callable = Callable(object: signalProxy, method: SignalProxy.proxyName) + let r = target.connect(signal: signalName, callable: callable, flags: UInt32(Object.ConnectFlags.oneShot.rawValue)) + if r != .ok { print("Warning, error connecting to signal, code: \(r)") } + } + + } + + } + +} + + +extension Arguments { + enum UnpackError: Error { + case typeMismatch + case missingArgument + } + + /// Unpack an argument as a specific type. + /// We throw a runtime error if the argument is not of the expected type, + /// or if there are not enough arguments to unpack. + func unpack(as type: T.Type, index: inout Int) throws -> T { + if index >= count { + throw UnpackError.missingArgument + } + let argument = self[index] + index += 1 + let value: T? + if argument.gtype == .object { + value = T.Representable.godotType == .object ? argument.asObject(Object.self) as? T : nil + } else { + value = T(argument) + } + + guard let value else { + throw UnpackError.typeMismatch + } + + return value + } +} diff --git a/Sources/SwiftGodot/Core/SignalSupport.swift b/Sources/SwiftGodot/Core/SignalSupport.swift index f5a0c7716..1408aa283 100644 --- a/Sources/SwiftGodot/Core/SignalSupport.swift +++ b/Sources/SwiftGodot/Core/SignalSupport.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Miguel de Icaza on 4/30/23. // @@ -18,129 +18,31 @@ /// invokeScript ("myDemo.proxy ()", params: ["myDemo", demo]) /// ``` public class SignalProxy: Object { - public static var proxyName = StringName ("proxy") + public static var proxyName = StringName("proxy") static var initClass: Bool = { register(type: SignalProxy.self) - + let s = ClassInfo(name: "SignalProxy") - + s.registerMethod(name: SignalProxy.proxyName, flags: .default, returnValue: nil, arguments: [], function: SignalProxy.proxyFunc) return true - } () - + }() + /// The code invoked when Godot invokes the `proxy` method on this object. public typealias Proxy = (borrowing Arguments) -> () public var proxy: Proxy? - - public required init () { + + public required init() { let _ = SignalProxy.initClass super.init() } - - public required init (nativeHandle: UnsafeRawPointer) { - super.init (nativeHandle: nativeHandle) - } - - func proxyFunc (args: borrowing Arguments) -> Variant? { - proxy? (args) - return nil - } -} -/// The simple signal is used to raise signals that take no arguments and return no values. -/// -/// To connect, you access the connect method, and pass you callback function, like this: -/// ``` -/// let myClass = MyClass () -/// let token = myClass.wakeup.connect { -/// print ("wakeup triggered") -/// } -/// ``` -/// -/// If you want to disconnect, you call: -/// ``` -/// myClass.wakeup.disconnect (token) -/// ``` -/// -/// To merely wait for one emission of the signal you can await the ``emitted`` property. -/// -/// Subclasses that use this, implement signals like this: -/// -/// ``` -/// class MyClass: Object { -/// // the `wakeup` signal -/// public var wakeup: SimpleSignal { SimpleSignal (self, "wakeup") } -/// } -/// ``` -/// -public class SimpleSignal { - var target: Object - var signalName: StringName - - /// - Parameters: - /// - target: the object where we will be operating on, to connect or disconnect the signal - /// - name: the name of the signal - public init (target: Object, signalName: StringName) { - self.target = target - self.signalName = signalName - } - - /// Connects the signal to the specified callback. - /// - /// To disconnect, call the disconnect method, with the returned token on success - /// - /// Example: - /// ```swift - /// node.ready.connect { - /// print ("Node is ready") - /// } - /// ``` - /// - /// - Parameters: - /// - callback: the method to invoke when the signal is raised - /// - flags: Optional, can be also added to configure the connection's behavior (see ``Object/ConnectFlags`` constants). - /// - Returns: an object token that can be used to disconnect the object from the target. - @discardableResult - public func connect (flags: Object.ConnectFlags = [], _ callback: @escaping () -> ()) -> Object { - let signalProxy = SignalProxy() - if flags.contains(.oneShot) { - signalProxy.proxy = { [weak signalProxy] args in - callback () - guard let signalProxy else { return } - signalProxy.proxy = nil - _ = signalProxy.callDeferred(method: "free") - } - } else { - signalProxy.proxy = { args in - callback () - } - } - - let callable = Callable(object: signalProxy, method: SignalProxy.proxyName) - let r = target.connect(signal: signalName, callable: callable, flags: UInt32 (flags.rawValue)) - if r != .ok { - print ("Warning, error connecting to signal \(signalName.description): \(r)") - } - return signalProxy + public required init(nativeHandle: UnsafeRawPointer) { + super.init(nativeHandle: nativeHandle) } - - /// Disconnects a signal that was previously connected, the return value from calling ``connect(flags:_:)`` - public func disconnect (_ token: Object) { - guard let signalProxy = token as? SignalProxy else { return } - signalProxy.proxy = nil - _ = signalProxy.callDeferred(method: "free") - target.disconnect(signal: signalName, callable: Callable (object: token, method: SignalProxy.proxyName)) - } - - /// You can await this property to wait for the signal to be emitted once - @MainActor - public var emitted: Void { - get async { - await withCheckedContinuation { c in - connect (flags: .oneShot) { - c.resume() - } - } - } + + func proxyFunc(args: borrowing Arguments) -> Variant? { + proxy?(args) + return nil } } diff --git a/Tests/SwiftGodotTests/MarshalTests.swift b/Tests/SwiftGodotTests/MarshalTests.swift index edc84fafb..2dfc682a8 100644 --- a/Tests/SwiftGodotTests/MarshalTests.swift +++ b/Tests/SwiftGodotTests/MarshalTests.swift @@ -33,7 +33,17 @@ final class MarshalTests: GodotTestCase { XCTAssertEqual (node.receivedInt, 22, "Integers should have been the same") XCTAssertEqual (node.receivedString, "Joey", "Strings should have been the same") } - + + func testBuiltInSignals() { + let node = TestNode() + var signalReceived = false + node.ready.connect { + signalReceived = true + } + node.emitSignal("ready") + XCTAssertTrue (signalReceived, "ready signal should have been received") + } + func testClassesMethodsPerformance() { let node = TestNode() let child = TestNode()