Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify Swift and Godot Signal Syntax #584

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
53966d1
suppress formatting for now
samdeane Nov 7, 2024
a646da7
Generic signal support
samdeane Oct 24, 2024
c8ce59a
Require macOS 14 for the generic pack support
samdeane Oct 24, 2024
20025c5
Use generic signal instead of generating helper
samdeane Oct 24, 2024
3f19b01
Don't use SimpleSignal
samdeane Oct 25, 2024
8a9ffda
Moved GenericSignal to its own file. Removed some unused code, tidied…
samdeane Oct 25, 2024
32ecb25
Fixed popping objects.
samdeane Oct 26, 2024
cd72ae3
Cleaner unpacking, without the need to copy the arguments.
samdeane Oct 29, 2024
a55d63b
revert unrelated formatting changes
samdeane Oct 31, 2024
7cc7603
put back SignalSupport to minimise changes
samdeane Oct 31, 2024
7eae04f
Removed SimpleSignal completely
samdeane Oct 31, 2024
81f13a0
tweaked names for a cleaner diff
samdeane Oct 31, 2024
2be97a8
Added test for built in signals
samdeane Nov 1, 2024
af36fd8
Declare local computed property for each signal.
samdeane Oct 23, 2024
4b75787
Added emit method.
samdeane Oct 25, 2024
ed702db
Added #nusignal macro to avoid breaking #signal
samdeane Oct 25, 2024
14e2273
Implemented emit() for GenericSignal
samdeane Oct 25, 2024
97854d4
removed unused adaptor class
samdeane Oct 26, 2024
3a2a8fe
removed SimpleSignal
samdeane Oct 26, 2024
f2e86c6
Call registration method for `nusignal` macro
samdeane Oct 28, 2024
653f270
Signals registering correctly.
samdeane Oct 28, 2024
3ba66f6
cleaned up
samdeane Oct 28, 2024
ce226d0
reverted unrelated formatting changes
samdeane Oct 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .no-swift-format
Empty file.
118 changes: 27 additions & 91 deletions Generator/Generator/ClassGen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -511,110 +511,22 @@ 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: false)
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
let lambdaSig: String
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)
Expand All @@ -632,6 +544,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
Expand Down
6 changes: 5 additions & 1 deletion Generator/Generator/MethodGen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ enum GeneratedMethodKind {
case utilityFunction
}

/// The list of static method names to make public.
/// Most of the names are kept fileprivate.
let publicMethodNames = ["method_get_class", "method_emit_signal"]

// To test the design, will use an external file later
// determines whether the className/method returns an optional reference type
func isReturnOptional (className: String, method: String) -> Bool {
Expand Down Expand Up @@ -498,7 +502,7 @@ func generateMethod(_ p: Printer, method: MethodDefinition, className: String, c
let inlineAttribute: String?
let documentationVisibilityAttribute: String?
if let methodHash = method.optionalHash {
let staticVarVisibility = if bindName != "method_get_class" { "fileprivate " } else { "" }
let staticVarVisibility = publicMethodNames.contains(bindName) ? "" : "fileprivate "
assert (!method.isVirtual)
switch generatedMethodKind {
case .classMethod:
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -231,7 +231,7 @@ targets.append(contentsOf: [
let package = Package(
name: "SwiftGodot",
platforms: [
.macOS(.v13),
.macOS(.v14),
.iOS (.v15)
],
products: products,
Expand Down
147 changes: 147 additions & 0 deletions Sources/SwiftGodot/Core/GenericSignal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// Created by Sam Deane on 25/10/2024.
//

extension VariantStorable {
static func propInfo(name: String) -> PropInfo {
let gType = Self.Representable.godotType
return PropInfo(
propertyType: gType,
propertyName: StringName(name),
className: gType == .object ? StringName(String(describing: Self.self)) : "",
hint: .none,
hintStr: "",
usage: .default
)
}
}

/// 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<each T: VariantStorable> {
var target: Object
var signalName: StringName
public init(target: Object, signalName: String) {
self.target = target
self.signalName = StringName(signalName)
}

/// Register this signal with the Godot runtime.
// TODO: the signal macro could probably pass in the argument names, so that we could register them as well
public static func register<C: Object>(_ signalName: String, info: ClassInfo<C>) {
let arguments = expandArguments(repeat (each T).self)
info.registerSignal(name: StringName(signalName), arguments: arguments)
}

/// Expand a list of argument types into a list of PropInfo objects
/// Note: it doesn't currently seem to be possible to constrain
/// the type of the pack expansion to be ``VariantStorable.Type``, but
/// we know that it always will be, so we can force cast it.
static func expandArguments<each ArgType>(_ type: repeat each ArgType) -> [PropInfo] {
var args = [PropInfo]()
var argC = 1
for arg in repeat each type {
let a = arg as! any VariantStorable.Type
args.append(a.propInfo(name: "arg\(argC)"))
argC += 1

}
return args
}

/// 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
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))
}

/// Emit the signal (with required arguments, if there are any)
@discardableResult /* discardable per discardableList: Object, emit_signal */
public func emit(_ t: repeat each T) -> GodotError {
var args = [Variant(signalName)]
for arg in repeat each t {
args.append(Variant(arg))
}
let result = target.emitSignalWithArguments(args)
return GodotError(rawValue: Int64(result)!)!
}

/// 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<T: VariantStorable>(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
}
}
45 changes: 45 additions & 0 deletions Sources/SwiftGodot/Core/RawCall.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@_implementationOnly import GDExtension

extension Object {

/// Make a raw call to a method bind.
/// All input arguments must be marshaled into `Variant`s.
/// The result is a `Variant` that must be unmarshaled into the expected type.
@discardableResult /* discardable per discardableList: Object, emit_signal */
final func rawCall(_ p_method_bind: GDExtensionMethodBindPtr, arguments: [Variant]) -> Variant {
var _result: Variant.ContentType = Variant.zero
// A temporary allocation containing pointers to `Variant.ContentType` of marshaled arguments
withUnsafeTemporaryAllocation(of: UnsafeRawPointer?.self, capacity: arguments.count) { pArgsBuffer in
defer { pArgsBuffer.deinitialize() }
guard let pArgs = pArgsBuffer.baseAddress else {
fatalError("pArgsBuffer.baseAddress is nil")
}

// A temporary allocation containing `Variant.ContentType` of marshaled arguments
withUnsafeTemporaryAllocation(of: Variant.ContentType.self, capacity: arguments.count) { contentsBuffer in
defer { contentsBuffer.deinitialize() }
guard let contentsPtr = contentsBuffer.baseAddress else {
fatalError("contentsBuffer.baseAddress is nil")
}

for i in 0..<arguments.count {
// Copy `content`s of the variadic `Variant`s into `contentBuffer`
contentsBuffer.initializeElement(at: i, to: arguments[i].content)
// Initialize `pArgs` elements following mandatory arguments to point at respective contents of `contentsBuffer`
pArgsBuffer.initializeElement(at: i, to: contentsPtr + i)
}

gi.object_method_bind_call(p_method_bind, UnsafeMutableRawPointer(mutating: handle), pArgs, Int64(arguments.count), &_result, nil)
}
}

return Variant(copying: _result)
}

/// Non-variadic variation on the emitSignal method.
/// Used by GenericSignal.
public final func emitSignalWithArguments(_ arguments: [Variant]) -> Variant {
return rawCall(Object.method_emit_signal, arguments: arguments)
}

}
4 changes: 2 additions & 2 deletions Sources/SwiftGodot/Core/SignalRegistration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,8 @@ public extension Object {
}
}

private extension PropInfo {
init(
extension PropInfo {
fileprivate init(
propertyType: (some VariantStorable).Type,
propertyName: StringName
) {
Expand Down
Loading
Loading