portico/Sources/PorticoGen/Generator.swift

1235 lines
57 KiB
Swift

/// Emits the import block shared by every generated file.
/// - Parameter isPorticoGtk: When `true`, adds `@_spi(Portico) import Portico`
/// because the file lives in the separate `PorticoGtk` module.
func generateHeader(isPorticoGtk: Bool) -> String {
var out = "// Generated by PorticoGen. DO NOT EDIT. See Sources/PorticoGen to make changes.\n\n"
out += "import Adw\nimport Gtk\nimport Gio\nimport Gdk\n"
if isPorticoGtk { out += "@_spi(Portico) import Portico\n" }
return out + "\n"
}
/// Converts a Swift wrapper property name to the most likely canonical GObject
/// property name, such as `useMarkup` to `use-markup`.
///
/// Backticks are dropped because Swift keyword properties are emitted as
/// `` `open` ``, while the corresponding GObject property is named `open`.
/// The result is only a candidate; the runtime verifies it against the widget
/// class before using it as a `notify` detail.
func gobjectPropertyName(_ swiftName: String) -> String {
var out = ""
for character in swiftName where character != "`" {
if character.isUppercase {
if !out.isEmpty { out.append("-") }
out.append(contentsOf: character.lowercased())
} else {
out.append(character)
}
}
return out
}
// MARK: - Doc emission
/// Renders a non-DocC provenance marker plus a DocC comment block.
///
/// The marker is a plain `//` line placed above the `///` block, so the Swift
/// compiler's doc extraction (and therefore DocC's rendered HTML/PDF) ignores it
/// while it stays greppable in the generated source.
///
/// - Parameters:
/// - indent: Leading whitespace for every emitted line (`" "` for members, `""` for top level).
/// - chain: PorticoGen function names, outermost first; joined with `" -> "`.
/// - sources: Upstream symbols the prose came from; joined with `", "`.
/// Emits `| source: synthesized` when empty.
/// - summary: Carried-over doc lines, verbatim.
/// - notes: Portico-specific paragraphs appended after `summary`, one line each.
/// - parameters: `- Parameter` entries, in declaration order.
/// - returns: `- Returns:` text, or `nil` for initializers.
func docBlock(
indent: String,
chain: [String],
sources: [String],
summary: [String],
notes: [String] = [],
parameters: [(name: String, text: String)] = [],
returns: String? = nil
) -> String {
var out = ""
let chainStr = chain.joined(separator: " -> ")
let srcStr = sources.isEmpty ? "synthesized" : sources.joined(separator: ", ")
out += "\(indent)// PorticoGen: \(chainStr) | source: \(srcStr)\n"
func emitDoc(_ lines: [String]) {
for line in lines {
if line.isEmpty {
out += "\(indent)///\n"
} else {
out += "\(indent)/// \(line)\n"
}
}
}
emitDoc(summary)
if !notes.isEmpty {
// Blank separator between summary and notes.
if !summary.isEmpty { out += "\(indent)///\n" }
emitDoc(notes)
}
if !parameters.isEmpty || returns != nil {
// Blank separator before parameter/returns group.
out += "\(indent)///\n"
for (name, text) in parameters {
out += "\(indent)/// - Parameter \(name): \(text)\n"
}
if let r = returns {
out += "\(indent)/// - Returns: \(r)\n"
}
}
return out
}
/// Best available one-line description for a value parameter named `label` on `widget`.
///
/// Lookup order: the same-named property's doc, then `set<Label>`, then `get<Label>`,
/// each reduced by `firstParagraph(of:)`. Falls back to
/// `"The \`<label>\` value forwarded to \`<Module>.<Class>\`."`
func parameterDoc(_ label: String, widget: WidgetModel) -> String {
let propertyName = label
let setterName = "set" + label.prefix(1).uppercased() + label.dropFirst()
let getterName = "get" + label.prefix(1).uppercased() + label.dropFirst()
if let lines = widget.memberDocs[propertyName], let fp = firstParagraph(of: lines) {
return fp
}
if let lines = widget.memberDocs[setterName], let fp = firstParagraph(of: lines) {
return fp
}
if let lines = widget.memberDocs[getterName], let fp = firstParagraph(of: lines) {
return fp
}
return "The `\(label)` value forwarded to `\(widget.module).\(widget.className)`."
}
/// Emits the widget struct, its `WidgetView` conformance and its `Mountable`
/// extension. No imports, so the block can be concatenated into a shared file.
func generateStruct(widget: WidgetModel, structName: String) -> String {
var out = ""
let qualifiedType = "\(widget.module).\(widget.className)"
// Struct doc
if widget.docLines.isEmpty {
out += docBlock(
indent: "",
chain: ["generateStruct"],
sources: ["\(widget.module).\(widget.className)"],
summary: ["A \(structName) widget backed by `\(qualifiedType)`."]
)
} else {
out += docBlock(
indent: "",
chain: ["generateStruct"],
sources: ["\(widget.module).\(widget.className)"],
summary: widget.docLines,
notes: ["A Portico view that mounts a `\(qualifiedType)`."]
)
}
// Struct declaration
out += "@MainActor public struct \(structName): View {\n"
out += " private let make: (MountContext) -> \(qualifiedType)\n"
out += " private var configure: [(\(qualifiedType), MountContext) -> Void] = []\n"
out += "\n"
out += " public var body: Never { fatalError() }\n"
out += "\n"
// Inits
if let noArg = widget.noArgInit {
out += generateInits(widget: widget, initModel: noArg)
}
for initModel in widget.inits {
out += generateInits(widget: widget, initModel: initModel)
}
out += "}\n"
out += "\n"
// WidgetView conformance
out += "extension \(structName): WidgetView {\n"
out += " public typealias Target = \(qualifiedType)\n"
out += "\n"
out += " @_spi(Portico) public func appending(\n"
out += " _ step: @escaping (\(qualifiedType), MountContext) -> Void\n"
out += " ) -> Self {\n"
out += " var c = self\n"
out += " c.configure.append(step)\n"
out += " return c\n"
out += " }\n"
out += "}\n"
out += "\n"
// Mountable extension
out += "@_spi(Portico) extension \(structName): Mountable {\n"
out += " @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {\n"
out += " let w = make(ctx)\n"
out += " for step in configure { step(w, ctx) }\n"
out += " return w\n"
out += " }\n"
out += "}\n"
return out
}
// MARK: - Type qualification
/// Standard library types that should not be module-qualified.
private let stdlibTypes: Set<String> = [
"String", "Int", "Int8", "Int16", "Int32", "Int64",
"UInt", "UInt8", "UInt16", "UInt32", "UInt64",
"Bool", "Double", "Float", "Float80", "Void", "Never",
"Any", "Self", "Character", "StaticString", "NSObject",
]
/// How a generated modifier wraps its configuration step.
enum ModifierStyle {
/// `WidgetView` extension: `appending { }`, returns `Self`.
case widgetView
/// `View` extension: builds an `AnyView` mount thunk, returns `AnyView`.
case erasingView
}
/// Qualifies a type string with the widget's module if it is an unqualified
/// GTK/Adw type reference.
func qualifyType(_ type: String, module: String) -> String {
if type.contains(".") { return type }
if type.hasSuffix("?") {
let base = String(type.dropLast())
return qualifyType(base, module: module) + "?"
}
if type.hasSuffix("!") {
let base = String(type.dropLast())
return qualifyType(base, module: module) + "!"
}
if stdlibTypes.contains(type) { return type }
if type.hasPrefix("[") && type.hasSuffix("]") {
let inner = String(type.dropFirst().dropLast())
return "[\(qualifyType(inner, module: module))]"
}
if type.hasPrefix("(") { return type }
if let first = type.first, first.isLowercase { return type }
return "\(module).\(type)"
}
/// True when `type` denotes a GTK widget slot; i.e. `Widget`, `Widget?`, `Gtk.Widget?`,
/// `Adw` re-exports `Gtk`, so `Adw.Widget` and `Gtk.Widget` are the same type.
func isWidgetType(_ type: String, module: String) -> Bool {
var t = qualifyType(type, module: module)
while t.hasSuffix("?") || t.hasSuffix("!") { t = String(t.dropLast()) }
return t == "Gtk.Widget" || t == "Adw.Widget"
}
/// Every parsed class that descends from `Gtk.Widget`, keyed `"Module.ClassName"`.
///
/// Populated once by `main()` after the inheritance walk, before any generation runs.
/// ``isWidgetType(_:module:)`` recognizes only the exact base class, which is the right
/// test for a mountable slot; this set additionally recognizes subclass-typed properties
/// such as `Gtk.StackSidebar.stack` (typed `Gtk.Stack`).
var widgetClassKeys: Set<String> = []
/// Every parsed class/interface model, keyed "Module.ClassName".
///
/// Populated once by `main()` before any generation runs.
var widgetModelMap: [String: WidgetModel] = [:]
/// Raw parent-class string for every parsed class, keyed "Module.ClassName".
///
/// Populated once by `main()`; mirrors the parent registry used by widget-set resolution.
var widgetParentMap: [String: String] = [:]
/// Resolves a raw parent-class string to a fully-qualified `Module.Class` key.
///
/// The lookup mirrors `main()`'s widget inheritance walk, including same-name
/// GTK/Adwaita resolution and module-qualified fallbacks.
func resolveParentKey(currentKey: String, parentRaw: String) -> String? {
var parent = parentRaw
if let idx = parent.firstIndex(where: { $0 == " " || $0 == "," }) {
parent = String(parent[..<idx])
}
guard !parent.isEmpty else { return nil }
if parent.contains(".") { return parent }
let components = currentKey.split(separator: ".")
guard components.count == 2 else { return nil }
let module = String(components[0])
let className = String(components[1])
if parent == className {
// Adw.Window : Gtk.Window style: try the other GTK module first.
let other = (module == "Adw") ? "Gtk" : "Adw"
if widgetModelMap["\(other).\(parent)"] != nil { return "\(other).\(parent)" }
}
if widgetModelMap["\(module).\(parent)"] != nil { return "\(module).\(parent)" }
if widgetModelMap["Gtk.\(parent)"] != nil { return "Gtk.\(parent)" }
if widgetModelMap["Adw.\(parent)"] != nil { return "Adw.\(parent)" }
return nil
}
/// Returns nearest-first widget-class ancestors, excluding `Gtk.Widget` and
/// `Adw.Widget`. The walk stops when it reaches an unresolvable or non-widget
/// parent and guards against malformed cyclic model data.
func ancestorModels(for widget: WidgetModel) -> [WidgetModel] {
let stop = Set(["Gtk.Widget", "Adw.Widget"])
var result: [WidgetModel] = []
var current = "\(widget.module).\(widget.className)"
var visited: Set<String> = [current]
while let raw = widgetParentMap[current],
let parentKey = resolveParentKey(currentKey: current, parentRaw: raw) {
if stop.contains(parentKey) { break }
guard let model = widgetModelMap[parentKey], model.kind == .widgetClass else { break }
if !visited.insert(parentKey).inserted { break }
result.append(model)
current = parentKey
}
return result
}
/// Returns own properties followed by nearest-first ancestor properties,
/// retaining the first definition for each property name.
func effectiveProperties(for widget: WidgetModel) -> [(prop: PropertyModel, owner: WidgetModel)] {
var seen = Set<String>()
var result: [(prop: PropertyModel, owner: WidgetModel)] = []
for prop in widget.properties where seen.insert(prop.name).inserted {
result.append((prop, widget))
}
for ancestor in ancestorModels(for: widget) {
for prop in ancestor.properties where seen.insert(prop.name).inserted {
result.append((prop, ancestor))
}
}
return result
}
/// Returns own signals followed by nearest-first ancestor signals, retaining
/// the first definition for each signal base name.
func effectiveSignals(for widget: WidgetModel) -> [(signal: SignalModel, owner: WidgetModel)] {
var seen = Set<String>()
var result: [(signal: SignalModel, owner: WidgetModel)] = []
for signal in widget.signals where seen.insert(signal.baseName).inserted {
result.append((signal, widget))
}
for ancestor in ancestorModels(for: widget) {
for signal in ancestor.signals where seen.insert(signal.baseName).inserted {
result.append((signal, ancestor))
}
}
return result
}
/// The qualified widget class a `Binding` overload for `type` can be made generic over,
/// or `nil` when `type` is not a widget class.
///
/// `Binding` is invariant, so an overload fixed at the property's declared type rejects
/// the `Binding<Subclass?>` that a `WidgetRef<Subclass>` projects. Constraining a generic
/// parameter to the returned class restores the subtype relationship the property's own
/// type already has, and every such value upcasts to the setter's parameter type.
func widgetClassConstraint(_ type: String, module: String) -> String? {
var t = qualifyType(type, module: module)
while t.hasSuffix("?") || t.hasSuffix("!") { t = String(t.dropLast()) }
// `Adw` re-exports `Gtk`, so both spellings name the same base class.
if t == "Gtk.Widget" || t == "Adw.Widget" { return "Gtk.Widget" }
return widgetClassKeys.contains(t) ? t : nil
}
/// True when `qualifiedType` is `String` or `String?`.
func isStringType(_ qualifiedType: String) -> Bool {
return qualifiedType == "String" || qualifiedType == "String?"
}
/// Returns the safe expression used to read a property from a mounted widget.
///
/// A `String` property without a generated getter has no safe computed-property read:
/// the wrapper's C-string conversion traps when the underlying GObject value is NULL.
func readBackExpr(_ prop: PropertyModel, module: String) -> String? {
if let g = prop.getterName { return "w.\(g)()" }
if qualifyType(prop.type, module: module) == "String" { return nil }
return "w.\(prop.name)"
}
/// The single `String`/`String?` parameter of `params` that can be lifted to
/// `Portico.InterpolatedText`, plus the setter used to push later values.
struct InterpolationSlot {
let param: Param
let optional: Bool
let setterName: String
let setterLabel: String
}
/// Returns the lone interpolatable parameter of `params`, or `nil`.
///
/// Eligible only when exactly one parameter's qualified type is `String` or `String?`
/// **and** its label matches a settable property of the same type. Two or more `String`
/// parameters are rejected on purpose: Swift then ranks the all-`String` overload above
/// the interpolated one even for interpolated literals, which would silently drop
/// reactivity.
func interpolationSlot(params: [Param], widget: WidgetModel) -> InterpolationSlot? {
let stringParams = params.filter { isStringType(qualifyType($0.type, module: widget.module)) }
guard stringParams.count == 1, let p = stringParams.first else { return nil }
let qt = qualifyType(p.type, module: widget.module)
let propOwner = Dictionary(
effectiveProperties(for: widget).map { ($0.prop.name, ($0.prop, $0.owner.module)) },
uniquingKeysWith: { a, _ in a }
)
guard let (prop, ownerModule) = propOwner[p.label],
qualifyType(prop.type, module: ownerModule) == qt else { return nil }
let isOpt = qt.hasSuffix("?")
return InterpolationSlot(param: p, optional: isOpt, setterName: prop.setterName, setterLabel: prop.setterLabel)
}
// MARK: - Init generation
/// Orders widget slots so the main content regions receive trailing-closure priority.
func slotRank(_ name: String) -> Int {
switch name {
case "content": return 0
case "child": return 1
default: return 2
}
}
/// Categories 2, 3, 4 and 6 of an emitted initializer.
struct InitExtras {
var paramDecls: [String] = []
var docs: [(name: String, text: String)] = []
var preludeLines: [String] = []
var makeArgs: [String: String] = [:]
var configureLines: [String] = []
var makeUsesCtx = false
var configureUsesCtx = false
var sources: [String] = []
}
/// Returns the documentation shared by signal initializers and modifiers.
func signalHandlerDoc(_ signal: SignalModel) -> String {
var text = "Invoked when the widget emits the `\(signal.signalName)` signal."
if !signal.argTypes.isEmpty {
text += " The closure receives the signal's arguments in order."
}
if signal.returnType != "Void" {
text += " Its return value is forwarded to GTK as the signal's result."
}
return text
}
/// Builds the defaulted properties, slots, child builder and signal parameters shared by
/// every initializer flavor for one wrapper initializer.
func initExtras(widget: WidgetModel, initModel: InitModel, bindingValues: Bool) -> InitExtras {
var extras = InitExtras()
let effProps = effectiveProperties(for: widget)
// Optional value properties, including inherited members.
for entry in effProps where
!isWidgetType(entry.prop.type, module: entry.owner.module) &&
!widget.reservedInitLabels.contains(entry.prop.name) {
let prop = entry.prop
let owner = entry.owner
let name = prop.name
let qt = qualifyType(prop.type, module: owner.module)
if bindingValues {
extras.paramDecls.append("\(name): Portico.Binding<\(qt)>? = nil")
if let read = readBackExpr(prop, module: owner.module) {
extras.configureLines += [
" if let \(name) {",
" Portico.bindProperty(w, \(name), registry: ctx.registry, notifyDetail: \"\(gobjectPropertyName(name))\", read: { [w] in \(read) }, write: { [w] v in w.\(prop.setterName)(\(prop.setterLabel): v) })",
" }",
]
} else {
extras.configureLines += [
" if let \(name) {",
" w.\(prop.setterName)(\(prop.setterLabel): \(name).untrackedValue)",
" ctx.registry.add(\(name).subscribe { [w] v in w.\(prop.setterName)(\(prop.setterLabel): v) })",
" }",
]
}
extras.configureUsesCtx = true
} else {
let parameterType = qt.hasSuffix("?") ? qt : qt + "?"
extras.paramDecls.append("\(name): \(parameterType) = nil")
extras.configureLines.append(" if let \(name) { w.\(prop.setterName)(\(prop.setterLabel): \(name)) }")
}
extras.docs.append((name, parameterDoc(name, widget: owner)))
}
// Required widget parameters become strict single-view builders.
for param in initModel.params where isWidgetType(param.type, module: widget.module) {
let label = param.label
extras.paramDecls.append("@SingleViewBuilder \(label): () -> AnyView")
extras.preludeLines.append(" let \(label)View = \(label)()")
extras.makeArgs[label] = "\(label)View.makeWidget(ctx)"
extras.makeUsesCtx = true
extras.docs.append((label, "A closure supplying the single view passed to `\(widget.module).\(widget.className).init` as `\(label)`. Exactly one view is required; an empty closure is a compile-time error."))
}
// Optional generic child builder.
if let primary = widget.primaryAdder {
extras.paramDecls.append("@ViewBuilder children: () -> [AnyView] = { [] }")
extras.preludeLines.append(" let childrenViews = children()")
let discard = primary.returnsValue ? "_ = " : ""
extras.configureLines.append(" Portico.mountChildren(childrenViews, into: w, ctx) { c in \(discard)w.\(primary.methodName)(\(primary.label): c) }")
extras.configureUsesCtx = true
extras.sources.append("\(widget.module).\(widget.className).\(primary.methodName)(\(primary.label):)")
extras.docs.append(("children", "A `ViewBuilder` closure whose views are added in order."))
}
// Optional widget slots, ordered by semantic region and declaration order.
let matchedSlots = effProps.enumerated().filter { _, entry in
isWidgetType(entry.prop.type, module: entry.owner.module) &&
!nonSlotWidgetProperties.contains("\(entry.owner.module).\(entry.owner.className).\(entry.prop.name)") &&
!widget.reservedInitLabels.contains(entry.prop.name)
}.sorted {
let leftRank = slotRank($0.element.prop.name)
let rightRank = slotRank($1.element.prop.name)
return leftRank == rightRank ? $0.offset < $1.offset : leftRank < rightRank
}.map(\.element)
for entry in matchedSlots {
let prop = entry.prop
let owner = entry.owner
let name = prop.name
extras.paramDecls.append("@ViewBuilder \(name): () -> [AnyView] = { [] }")
extras.preludeLines.append(" let \(name)Views = \(name)()")
extras.configureLines.append(" if let v = \(name)Views.first { w.\(prop.setterName)(\(prop.setterLabel): v.makeWidget(ctx)) }")
extras.configureUsesCtx = true
extras.sources.append("\(owner.module).\(owner.className).\(prop.setterName)(\(prop.setterLabel):)")
extras.docs.append((name, "A `ViewBuilder` closure whose first view is mounted into the `\(name)` slot."))
}
// Optional signal handlers, including inherited signals.
for entry in effectiveSignals(for: widget) {
let signal = entry.signal
let args = signal.argTypes.map { qualifyType($0, module: entry.owner.module) }
let returnType = qualifyType(signal.returnType, module: entry.owner.module)
let handlerType = args.isEmpty
? "() -> \(returnType)"
: "(\(args.joined(separator: ", "))) -> \(returnType)"
let label = "on\(signal.baseName)"
extras.paramDecls.append("\(label): (\(handlerType))? = nil")
if args.isEmpty {
extras.configureLines.append(" if let \(label) { ctx.registry.add(w.connect\(signal.baseName) { _ in \(label)() }) }")
} else {
let names = args.indices.map { "a\($0)" }.joined(separator: ", ")
extras.configureLines.append(" if let \(label) { ctx.registry.add(w.connect\(signal.baseName) { _, \(names) in \(label)(\(names)) }) }")
}
extras.configureUsesCtx = true
extras.docs.append((label, signalHandlerDoc(signal)))
}
return extras
}
func generateInits(widget: WidgetModel, initModel: InitModel) -> String {
let qualifiedType = "\(widget.module).\(widget.className)"
let params = initModel.params
let valueParams = params.filter { !isWidgetType($0.type, module: widget.module) }
var out = ""
let effPropMap = Dictionary(
effectiveProperties(for: widget).map { ($0.prop.name, ($0.prop, $0.owner.module)) },
uniquingKeysWith: { a, _ in a }
)
let qualifiedValueTypes = valueParams.map { qualifyType($0.type, module: widget.module) }
let reactiveParams: [(Param, String)] = valueParams.compactMap { p in
guard let (prop, ownerModule) = effPropMap[p.label] else { return nil }
let propType = qualifyType(prop.type, module: ownerModule)
let paramType = qualifyType(p.type, module: widget.module)
guard propType == paramType else { return nil }
return (p, paramType)
}
let allReactive = !valueParams.isEmpty && reactiveParams.count == valueParams.count
let interpSlot = interpolationSlot(params: valueParams, widget: widget)
let initLabels = params.map { "\($0.label):" }.joined()
let baseDocs = valueParams.map { (name: $0.label, text: parameterDoc($0.label, widget: widget)) }
func notes(_ base: [String], extras: InitExtras, binding: Bool) -> [String] {
var result = base
if !extras.paramDecls.filter({ $0.hasPrefix("@") }).isEmpty {
result.append("Each closure is evaluated once; children are added in order and slot closures mount their first view.")
result.append("An empty closure adds no children and leaves slots unset.")
if widget.primaryAdder != nil {
result.append("A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.")
}
}
result.append("Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that.")
if binding {
result.append("Optional `Binding` parameters bind through `Portico.bindProperty`, so they are two-way wherever the wrapper exposes a safe getter.")
}
return result
}
func argumentExpressions(_ valueExpression: (Param) -> String, extras: InitExtras) -> String {
params.map { param in
"\(param.label): \(extras.makeArgs[param.label] ?? valueExpression(param))"
}.joined(separator: ", ")
}
func emitFlavor(
chain: String,
sources: [String],
summary: [String],
flavorNotes: [String],
parameterDecls: [String],
parameterDocs: [(name: String, text: String)],
arguments: String,
prelude: [String],
flavorConfigure: [String],
extras: InitExtras,
genericPrefix: String = "",
disfavored: Bool = false
) {
out += docBlock(
indent: " ",
chain: [chain],
sources: sources,
summary: summary,
notes: notes(flavorNotes, extras: extras, binding: chain.contains("binding")),
parameters: parameterDocs
)
if disfavored { out += " @_disfavoredOverload\n" }
out += " public init\(genericPrefix)(\(parameterDecls.joined(separator: ", "))) {\n"
out += prelude.map { "\($0)\n" }.joined()
let makeContext = extras.makeUsesCtx ? "ctx" : "_"
out += " make = { \(makeContext) in \(qualifiedType)(\(arguments)) }\n"
let configureLines = flavorConfigure + extras.configureLines
if !configureLines.isEmpty {
let configureContext = extras.configureUsesCtx || chain.contains("binding") || chain.contains("closure") || chain.contains("interpolation") ? "ctx" : "_"
out += " configure.append { w, \(configureContext) in\n"
out += configureLines.map { "\($0)\n" }.joined()
out += " }\n"
}
out += " }\n\n"
}
// --- Static init ---
let staticExtras = initExtras(widget: widget, initModel: initModel, bindingValues: false)
let staticArgs = argumentExpressions({ "\($0.label)" }, extras: staticExtras)
if let slot = interpSlot {
let slotLabel = slot.param.label
let typeParam = slot.optional ? "S?" : "S"
let valueDecls = zip(valueParams, qualifiedValueTypes).map { p, type in
p.label == slotLabel ? "\(p.label): \(typeParam)" : "\(p.label): \(type)"
}
let staticArgsWithCast = argumentExpressions({ param in
if param.label == slotLabel {
return slot.optional ? "\(param.label).map { String($0) }" : "String(\(param.label))"
}
return param.label
}, extras: staticExtras)
emitFlavor(
chain: "generateInits(static)",
sources: ["\(widget.module).\(widget.className).init(\(initLabels))"],
summary: initModel.doc,
flavorNotes: ["Applied once at mount; use the `Binding`, closure, or `InterpolatedText` overload for values that change.", "A string literal containing interpolation selects the `InterpolatedText` overload instead, which updates live."],
parameterDecls: valueDecls + staticExtras.paramDecls,
parameterDocs: baseDocs + staticExtras.docs,
arguments: staticArgsWithCast,
prelude: staticExtras.preludeLines,
flavorConfigure: [],
extras: staticExtras,
genericPrefix: "<S: StringProtocol>",
disfavored: true
)
} else {
emitFlavor(
chain: "generateInits(static)",
sources: ["\(widget.module).\(widget.className).init(\(initLabels))"],
summary: initModel.doc,
flavorNotes: ["Applied once at mount; use the `Binding` or closure overload for values that change."],
parameterDecls: zip(valueParams, qualifiedValueTypes).map { "\($0.label): \($1)" } + staticExtras.paramDecls,
parameterDocs: baseDocs + staticExtras.docs,
arguments: staticArgs,
prelude: staticExtras.preludeLines,
flavorConfigure: [],
extras: staticExtras
)
}
if allReactive {
let bindingExtras = initExtras(widget: widget, initModel: initModel, bindingValues: true)
let bindingParams = valueParams.map { "\($0.label): Portico.Binding<\(qualifyType($0.type, module: widget.module))>" } + bindingExtras.paramDecls
let bindingArgs = argumentExpressions({ "\($0.label).wrappedValue" }, extras: bindingExtras)
let setters = reactiveParams.map { rp in
let setter = effPropMap[rp.0.label]!.0
return "\(qualifiedType).\(setter.setterName)(\(setter.setterLabel):)"
}.joined(separator: ", ")
var bindSources = ["\(widget.module).\(widget.className).init(\(initLabels))"]
bindSources += reactiveParams.map { rp in
let setter = effPropMap[rp.0.label]!.0
return "\(widget.module).\(widget.className).\(setter.setterName)(\(setter.setterLabel):)"
}
let bindingConfigure = reactiveParams.map { p, _ -> String in
let setter = effPropMap[p.label]!.0
return " ctx.registry.add(\(p.label).subscribe { [w] v in w.\(setter.setterName)(\(setter.setterLabel): v) })"
}
emitFlavor(
chain: "generateInits(binding)",
sources: bindSources,
summary: initModel.doc,
flavorNotes: ["The initial value is the binding's current value; every later change is pushed into the widget through `\(setters)` without rebuilding the view."],
parameterDecls: bindingParams,
parameterDocs: baseDocs + bindingExtras.docs,
arguments: bindingArgs,
prelude: bindingExtras.preludeLines,
flavorConfigure: bindingConfigure,
extras: bindingExtras
)
let closureExtras = staticExtras
let closureParams = valueParams.map { "\($0.label): @escaping () -> \(qualifyType($0.type, module: widget.module))" } + closureExtras.paramDecls
let closureArgs = argumentExpressions({ "\($0.label)()" }, extras: closureExtras)
let closureConfigure = reactiveParams.enumerated().flatMap { i, rp -> [String] in
let setter = effPropMap[rp.0.label]!.0
return [
" let t\(i) = DependencyTracker { [w] in w.\(setter.setterName)(\(setter.setterLabel): \(rp.0.label)()) }",
" t\(i).run()",
" ctx.registry.add(t\(i))",
]
}
emitFlavor(
chain: "generateInits(closure)",
sources: bindSources,
summary: initModel.doc,
flavorNotes: ["Each closure runs inside a `DependencyTracker`, so any `@State` it reads re-runs it and pushes the new value through `\(setters)`."],
parameterDecls: closureParams,
parameterDocs: baseDocs + closureExtras.docs,
arguments: closureArgs,
prelude: closureExtras.preludeLines,
flavorConfigure: closureConfigure,
extras: closureExtras
)
}
// --- Interpolated init ---
if let slot = interpSlot {
let interpExtras = staticExtras
let slotLabel = slot.param.label
let interpType = slot.optional ? "Portico.InterpolatedText?" : "Portico.InterpolatedText"
let interpParams = valueParams.map { param in
"\(param.label): \(param.label == slotLabel ? interpType : qualifyType(param.type, module: widget.module))"
} + interpExtras.paramDecls
let interpArgs = argumentExpressions({ param in
param.label == slotLabel
? (slot.optional ? "\(param.label)?.untrackedText" : "\(param.label).untrackedText")
: param.label
}, extras: interpExtras)
let bindFn = slot.optional ? "Portico.bindOptionalInterpolation" : "Portico.bindInterpolation"
let setter = "\(qualifiedType).\(slot.setterName)(\(slot.setterLabel):)"
let interpConfigure = [" \(bindFn)(\(slotLabel), registry: ctx.registry) { [w] v in w.\(slot.setterName)(\(slot.setterLabel): v) }"]
emitFlavor(
chain: "generateInits(interpolation)",
sources: ["\(widget.module).\(widget.className).init(\(initLabels))", setter],
summary: initModel.doc,
flavorNotes: ["Interpolated segments are captured unevaluated and re-read inside a `DependencyTracker`, so any `@State` they read pushes a new value through `\(setter)`. A literal with no interpolation is applied once, with no subscription."],
parameterDecls: interpParams,
parameterDocs: baseDocs + interpExtras.docs,
arguments: interpArgs,
prelude: interpExtras.preludeLines,
flavorConfigure: interpConfigure,
extras: interpExtras
)
}
return out
}
/// `Module.Class.property` names that must never become a `@ViewBuilder` slot.
///
/// `setVisibleChild` requires a widget that is already a child of the container, so a slot
/// that mounts a fresh view would always violate the GTK precondition. The leading
/// `children:` builder covers these classes: GTK makes the first added child visible.
let nonSlotWidgetProperties: Set<String> = [
"Adw.Leaflet.visibleChild",
"Adw.ViewStack.visibleChild",
"Gtk.Stack.visibleChild",
]
/// Generates static and ViewBuilder modifiers for child-adder methods.
func generateChildAdderModifiers(widget: WidgetModel) -> String {
var out = ""
for adder in widget.childAdders {
let source = "\(widget.module).\(widget.className).\(adder.methodName)(\(adder.label):)"
let summary = widget.memberDocs[adder.methodName] ?? ["Adds a child to `\(widget.module).\(widget.className)`." ]
let discard = adder.returnsValue ? "_ = " : ""
out += docBlock(indent: " ", chain: ["generateModifierExtension", "generateChildAdderModifiers(static)"], sources: [source], summary: summary, notes: ["Applied once at mount; use the `@ViewBuilder` overload for multiple children."], parameters: [(name: "child", text: "A child widget to add.")], returns: "A copy of this view with the modifier applied.")
out += " public func \(adder.methodName)(_ child: Gtk.Widget) -> Self {\n"
emitWrapperStart(&out, style: .widgetView, usesCtx: false)
out += " \(discard)w.\(adder.methodName)(\(adder.label): child)\n"
emitWrapperEnd(&out, style: .widgetView)
out += " }\n\n"
out += docBlock(indent: " ", chain: ["generateModifierExtension", "generateChildAdderModifiers(viewBuilder)"], sources: [source], summary: summary, notes: ["Every view the closure produces is added at mount, in order."], parameters: [(name: "child", text: "A closure producing child views.")], returns: "A copy of this view with the modifier applied.")
out += " public func \(adder.methodName)(@ViewBuilder _ child: () -> [AnyView]) -> Self {\n"
out += " let childViews = child()\n"
emitWrapperStart(&out, style: .widgetView, lead: "return ")
out += " for v in childViews { \(discard)w.\(adder.methodName)(\(adder.label): v.makeWidget(ctx)) }\n"
emitWrapperEnd(&out, style: .widgetView)
out += " }\n\n"
}
return out
}
// MARK: - Property modifier generation
/// Generates the modifier overload set for a settable property.
///
/// - Parameters:
/// - owner: The ``WidgetModel`` that **owns** this modifier extension (used for
/// two-way binding key lookup).
/// - prop: The property to generate modifiers for.
/// - style: Whether to emit `WidgetView`-constrained (`Self`) or erasing
/// `View` (`AnyView`) modifiers.
///
/// Every property gets a static modifier. Widget-typed properties additionally get a
/// ``ViewBuilder`` slot modifier; everything else gets a `Binding` and a tracked-closure
/// modifier. Nullable properties get a second `Binding` overload taking the non-optional
/// value type, because `Binding` is invariant. Properties typed as a widget class emit
/// their `Binding` overloads generically over that class, so a `Binding` of any subclass
/// is accepted; see ``widgetClassConstraint(_:module:)``.
func generatePropertyModifiers(owner: WidgetModel, prop: PropertyModel, style: ModifierStyle) -> String {
var out = ""
let qualifiedType = qualifyType(prop.type, module: owner.module)
let ret = style == .widgetView ? "Self" : "AnyView"
let interpolatable = !isWidgetType(prop.type, module: owner.module) && isStringType(qualifiedType)
let root = style == .widgetView ? "generateModifierExtension" : "generateViewExtension"
let retStr = style == .widgetView
? "A copy of this view with the modifier applied."
: "An `AnyView` wrapping this view with the modifier applied."
let summaryLines = owner.memberDocs[prop.setterName] ?? owner.memberDocs[prop.name] ?? ["Sets `\(prop.name)`."]
let sources = ["\(owner.module).\(owner.className).\(prop.setterName)(\(prop.setterLabel):)"]
let paramText: String
if let pl = owner.memberDocs[prop.name], let fp = firstParagraph(of: pl) {
paramText = fp
} else if let sl = owner.memberDocs[prop.setterName], let fp = firstParagraph(of: sl) {
paramText = fp
} else {
paramText = "The new `\(prop.name)` value."
}
// --- Static modifier ---
if interpolatable {
let staticNotes = ["Applied once at mount; use the `Binding`, closure, or `InterpolatedText` overload for a value that changes.",
"A string literal containing interpolation selects the `InterpolatedText` overload instead, which updates live."]
out += docBlock(
indent: " ",
chain: [root, "generatePropertyModifiers(static)"],
sources: sources,
summary: summaryLines,
notes: staticNotes,
parameters: [(name: prop.name, text: paramText)],
returns: retStr
)
let typeParam = qualifiedType.hasSuffix("?") ? "S?" : "S"
let argExpr = qualifiedType.hasSuffix("?")
? "\(prop.name).map { String($0) }"
: "String(\(prop.name))"
out += " @_disfavoredOverload\n"
out += " public func \(prop.name)<S: StringProtocol>(_ \(prop.name): \(typeParam)) -> \(ret) {\n"
emitWrapperStart(&out, style: style, usesCtx: false)
out += " w.\(prop.setterName)(\(prop.setterLabel): \(argExpr))\n"
emitWrapperEnd(&out, style: style)
out += " }\n"
} else {
out += docBlock(
indent: " ",
chain: [root, "generatePropertyModifiers(static)"],
sources: sources,
summary: summaryLines,
notes: ["Applied once at mount; use the `Binding` or closure overload for a value that changes."],
parameters: [(name: prop.name, text: paramText)],
returns: retStr
)
out += " public func \(prop.name)(_ \(prop.name): \(qualifiedType)) -> \(ret) {\n"
emitWrapperStart(&out, style: style, usesCtx: false)
out += " w.\(prop.setterName)(\(prop.setterLabel): \(prop.name))\n"
emitWrapperEnd(&out, style: style)
out += " }\n"
}
out += "\n"
if isWidgetType(prop.type, module: owner.module) && style == .widgetView {
// --- ViewBuilder modifier ---
out += docBlock(
indent: " ",
chain: [root, "generatePropertyModifiers(viewBuilder)"],
sources: sources,
summary: summaryLines,
notes: [
"The closure is evaluated once when the modifier is applied. Its first view is mounted into the slot.",
"Additional views are ignored; an empty closure leaves the slot unset.",
],
parameters: [(name: prop.name, text: paramText)],
returns: retStr
)
out += " public func \(prop.name)(@ViewBuilder _ \(prop.name): () -> [AnyView]) -> \(ret) {\n"
out += " let \(prop.name)Views = \(prop.name)()\n"
emitWrapperStart(&out, style: style, lead: "return ")
out += " guard let v = \(prop.name)Views.first else { return }\n"
out += " w.\(prop.setterName)(\(prop.setterLabel): v.makeWidget(ctx))\n"
emitWrapperEnd(&out, style: style)
out += " }\n"
}
// --- Binding modifier ---
out += bindingModifier(owner: owner, prop: prop, valueType: qualifiedType, variance: .exact, style: style)
out += "\n"
if qualifiedType.hasSuffix("?") {
out += bindingModifier(owner: owner, prop: prop, valueType: String(qualifiedType.dropLast()), variance: .lifted, style: style)
out += "\n"
} else if prop.getterIsOptional && !isStringType(qualifiedType) {
out += bindingModifier(owner: owner, prop: prop, valueType: qualifiedType + "?", variance: .lowered, style: style)
out += "\n"
}
// --- Closure modifier ---
out += docBlock(
indent: " ",
chain: [root, "generatePropertyModifiers(closure)"],
sources: sources,
summary: summaryLines,
notes: ["The closure runs inside a `DependencyTracker`, so any `@State` it reads re-runs it and pushes the new value through `\(owner.module).\(owner.className).\(prop.setterName)(\(prop.setterLabel):)`."],
parameters: [(name: prop.name, text: paramText)],
returns: retStr
)
out += " public func \(prop.name)(_ \(prop.name): @escaping () -> \(qualifiedType)) -> \(ret) {\n"
emitWrapperStart(&out, style: style)
out += " let tracker = DependencyTracker { [w] in w.\(prop.setterName)(\(prop.setterLabel): \(prop.name)()) }\n"
out += " tracker.run()\n"
out += " ctx.registry.add(tracker)\n"
emitWrapperEnd(&out, style: style)
out += " }\n"
// --- Interpolated modifier ---
if interpolatable {
out += docBlock(
indent: " ",
chain: [root, "generatePropertyModifiers(interpolation)"],
sources: sources,
summary: summaryLines,
notes: ["Interpolated segments are captured unevaluated and re-read inside a `DependencyTracker`, so any `@State` they read pushes a new value through `\(owner.module).\(owner.className).\(prop.setterName)(\(prop.setterLabel):)`. A literal with no interpolation is applied once, with no subscription."],
parameters: [(name: prop.name, text: paramText)],
returns: retStr
)
let interpType = qualifiedType.hasSuffix("?") ? "Portico.InterpolatedText?" : "Portico.InterpolatedText"
let bindFn = qualifiedType.hasSuffix("?") ? "Portico.bindOptionalInterpolation" : "Portico.bindInterpolation"
out += " public func \(prop.name)(_ \(prop.name): \(interpType)) -> \(ret) {\n"
emitWrapperStart(&out, style: style)
out += " \(bindFn)(\(prop.name), registry: ctx.registry) { [w] v in w.\(prop.setterName)(\(prop.setterLabel): v) }\n"
emitWrapperEnd(&out, style: style)
out += " }\n"
}
out += "\n"
return out
}
/// Emits one `Binding`-taking reactive modifier for `prop`.
///
/// Uses `Portico.bindProperty` to push the binding's value into the widget at
/// mount and keep them in sync. When the property has a safe read-back path and
/// the value type is `Equatable`, the generated code calls the two-way overload;
/// otherwise it calls the one-way overload.
///
/// How a `Binding`'s value type relates to the property's declared type.
enum BindingVariance { case exact, lifted, lowered }
func bindingModifier(owner: WidgetModel, prop: PropertyModel, valueType: String, variance: BindingVariance, style: ModifierStyle) -> String {
var out = ""
let ret = style == .widgetView ? "Self" : "AnyView"
let root = style == .widgetView ? "generateModifierExtension" : "generateViewExtension"
let retStr = style == .widgetView
? "A copy of this view with the modifier applied."
: "An `AnyView` wrapping this view with the modifier applied."
// The expression the write-back reads the widget's current value through.
// `nil` means there is no safe read path, so the modifier stays one-way.
let readExpr = readBackExpr(prop, module: owner.module)
let isLowered: Bool
if case .lowered = variance { isLowered = true } else { isLowered = false }
let canReadBack = !isLowered && readExpr != nil
// Widget-class properties are emitted generically over `W` so a `Binding` of any
// subclass - what a `WidgetRef<Subclass>` projects - is accepted directly. An
// implicitly-unwrapped declared type is left concrete; replacing `T!` with `W`
// would silently drop its optionality.
let genericBase = valueType.hasSuffix("!")
? nil
: widgetClassConstraint(prop.type, module: owner.module)
let genericParam = genericBase.map { "<W: \($0)>" } ?? ""
let boundType = genericBase == nil
? valueType
: (valueType.hasSuffix("?") ? "W?" : "W")
// The read-back closure must yield the bound type, not the property's declared type.
let readCast = genericBase == nil ? "" : " as? W"
let summaryLines = owner.memberDocs[prop.setterName] ?? owner.memberDocs[prop.name] ?? ["Sets `\(prop.name)`."]
var sources = ["\(owner.module).\(owner.className).\(prop.setterName)(\(prop.setterLabel):)"]
let paramText: String
if let pl = owner.memberDocs[prop.name], let fp = firstParagraph(of: pl) {
paramText = fp
} else if let sl = owner.memberDocs[prop.setterName], let fp = firstParagraph(of: sl) {
paramText = fp
} else {
paramText = "The new `\(prop.name)` value."
}
let chain: [String]
var notes: [String]
if canReadBack {
sources.append("GObject.Object.connectNotify(detail:_:)")
if let g = prop.getterName {
sources.append("\(owner.module).\(owner.className).\(g)()")
}
}
switch variance {
case .exact:
if canReadBack {
chain = [root, "generatePropertyModifiers", "bindingModifier(twoWay)"]
notes = [
"Applied at mount and re-applied on every change the binding publishes.",
"""
When `\(valueType)` conforms to `Equatable` this binds in both directions: \
the widget's `notify` signal writes its current value back into the binding, \
so changes made in the UI propagate to the bound state. Each direction \
compares before writing, which terminates the echo after one hop. A value type \
that is not `Equatable` binds one way only, because the echo cannot be \
broken.
""",
]
} else {
chain = [root, "generatePropertyModifiers", "bindingModifier(oneWay)"]
notes = [
"Applied at mount and re-applied on every change the binding publishes.",
"Binds one way only (binding to widget): `\(owner.module).\(owner.className)` exposes no getter for `\(prop.name)` that is safe to read back, so changes made in the UI do not propagate to the binding.",
]
}
case .lifted:
let marker = canReadBack ? "twoWay" : "oneWay"
chain = [root, "generatePropertyModifiers", "bindingModifier(lifted,\(marker))"]
notes = [
"Applied at mount and re-applied on every change the binding publishes.",
"`Binding` is invariant, so a `Binding<\(valueType)>` is not accepted by the nullable overload; this one takes it and promotes each value. Pass a `Binding<\(valueType)?>` to be able to clear the property.",
]
if canReadBack {
notes.append("""
When `\(valueType)` conforms to `Equatable` this binds in both directions: \
the widget's `notify` signal writes its current value back into the binding, \
so changes made in the UI propagate to the bound state. Each direction \
compares before writing, which terminates the echo after one hop. A value type \
that is not `Equatable` binds one way only, because the echo cannot be \
broken.
""")
notes.append("A `nil` widget value is never written back into the binding.")
}
case .lowered:
chain = [root, "generatePropertyModifiers", "bindingModifier(lowered)"]
let nullableSource = genericBase == nil
? "this overload accepts a nullable binding"
: "this overload accepts a nullable binding (what a `WidgetRef` projects)"
notes = [
"Applied at mount and re-applied on every change the binding publishes.",
"`Binding` is invariant and this property has no unset state; \(nullableSource) and ignores `nil` values. Binds one way only.",
]
}
out += docBlock(
indent: " ",
chain: chain,
sources: sources,
summary: summaryLines,
notes: notes,
returns: retStr
)
out += " public func \(prop.name)\(genericParam)(_ \(prop.name): Portico.Binding<\(boundType)>) -> \(ret) {\n"
if canReadBack, let re = readExpr {
// Two-way capable: call Portico.bindProperty with read/write closures.
let setterCall = "w.\(prop.setterName)(\(prop.setterLabel): v)"
if style == .widgetView {
out += " appending { w, ctx in\n"
out += " Portico.bindProperty(\n"
out += " w, \(prop.name), registry: ctx.registry, notifyDetail: \"\(gobjectPropertyName(prop.name))\",\n"
out += " read: { [w] in \(re)\(readCast) },\n"
out += " write: { [w] v in \(setterCall) }\n"
out += " )\n"
out += " }\n"
} else {
out += " AnyView(makeWidget: { ctx in\n"
out += " let w = AnyView(self).makeWidget(ctx)\n"
out += " Portico.bindProperty(\n"
out += " w, \(prop.name), registry: ctx.registry, notifyDetail: \"\(gobjectPropertyName(prop.name))\",\n"
out += " read: { [w] in \(re)\(readCast) },\n"
out += " write: { [w] v in \(setterCall) }\n"
out += " )\n"
out += " return w\n"
out += " })\n"
}
} else {
// One-way: subscribe to binding only.
emitWrapperStart(&out, style: style)
if isLowered {
out += " if let v = \(prop.name).untrackedValue { w.\(prop.setterName)(\(prop.setterLabel): v) }\n"
out += " ctx.registry.add(\(prop.name).subscribe { [w] v in if let v { w.\(prop.setterName)(\(prop.setterLabel): v) } })\n"
} else {
out += " w.\(prop.setterName)(\(prop.setterLabel): \(prop.name).untrackedValue)\n"
out += " ctx.registry.add(\(prop.name).subscribe { [w] v in w.\(prop.setterName)(\(prop.setterLabel): v) })\n"
}
emitWrapperEnd(&out, style: style)
}
out += " }\n"
return out
}
// MARK: - Wrapper helpers
/// Emits the opening lines of a modifier body, wrapping the configuration
/// step in either a `WidgetView.appending` call or an `AnyView` mount thunk.
/// - Parameter usesCtx: When `false`, the closure captures `_` instead of `ctx`
/// to avoid an unused-variable warning.
/// - Parameter lead: Text emitted between the indent and the wrapper call, e.g. `"return "`.
func emitWrapperStart(_ out: inout String, style: ModifierStyle, usesCtx: Bool = true, lead: String = "") {
switch style {
case .widgetView:
let ctxParam = usesCtx ? "ctx" : "_"
out += " \(lead)appending { w, \(ctxParam) in\n"
case .erasingView:
// `ctx` is always used for `makeWidget(ctx)` on the next line.
out += " \(lead)AnyView(makeWidget: { ctx in\n"
out += " let w = AnyView(self).makeWidget(ctx)\n"
}
}
/// Emits the closing lines for a wrapper started by ``emitWrapperStart``.
func emitWrapperEnd(_ out: inout String, style: ModifierStyle) {
switch style {
case .widgetView:
out += " }\n"
case .erasingView:
out += " return w\n"
out += " })\n"
}
}
// MARK: - Signal modifier generation
func generateSignalModifier(widget: WidgetModel, signal: SignalModel, style: ModifierStyle) -> String {
var out = ""
let ret = style == .widgetView ? "Self" : "AnyView"
let root = style == .widgetView ? "generateModifierExtension" : "generateViewExtension"
let retStr = style == .widgetView
? "A copy of this view with the modifier applied."
: "An `AnyView` wrapping this view with the modifier applied."
let trampolineArgs: String
let handlerType: String
if signal.argTypes.isEmpty {
trampolineArgs = ""
handlerType = "() -> \(qualifyType(signal.returnType, module: widget.module))"
} else {
let qualifiedArgs = signal.argTypes.map { qualifyType($0, module: widget.module) }
trampolineArgs = qualifiedArgs.enumerated().map { (i, _) in "a\(i)" }.joined(separator: ", ")
handlerType = "(\(qualifiedArgs.joined(separator: ", "))) -> \(qualifyType(signal.returnType, module: widget.module))"
}
let summaryLines = widget.memberDocs["connect\(signal.baseName)"] ?? ["Connects a handler to the `\(signal.signalName)` signal."]
let handlerNote = signalHandlerDoc(signal)
out += docBlock(
indent: " ",
chain: [root, "generateSignalModifier"],
sources: ["\(widget.module).\(widget.className).connect\(signal.baseName)(_:)"],
summary: summaryLines,
parameters: [(name: "handler", text: handlerNote)],
returns: retStr
)
out += " public func on\(signal.baseName)(_ handler: @escaping \(handlerType)) -> \(ret) {\n"
emitWrapperStart(&out, style: style)
if signal.argTypes.isEmpty {
out += " ctx.registry.add(w.connect\(signal.baseName) { _ in handler() })\n"
} else {
out += " ctx.registry.add(w.connect\(signal.baseName) { _, \(trampolineArgs) in handler(\(trampolineArgs)) })\n"
}
emitWrapperEnd(&out, style: style)
out += " }\n"
out += "\n"
return out
}
// MARK: - Modifier extension generation
/// Emits `extension WidgetView where Target: <Module>.<Class> { }` with every
/// property and signal modifier the class owns. Returns `""` when the owner has
/// no settable properties and no signals.
func generateModifierExtension(widget: WidgetModel) -> String {
guard !widget.properties.isEmpty || !widget.signals.isEmpty || !widget.childAdders.isEmpty else { return "" }
var out = ""
let target = "\(widget.module).\(widget.className)"
out += docBlock(
indent: "",
chain: ["generateModifierExtension"],
sources: ["\(widget.module).\(widget.className)"],
summary: ["Modifiers for `\(target)`, available on every Portico view whose", "backing widget is `\(target)` or one of its subclasses."]
)
out += "extension WidgetView where Target: \(target) {\n"
out += generateChildAdderModifiers(widget: widget)
for prop in widget.properties {
out += generatePropertyModifiers(owner: widget, prop: prop, style: .widgetView)
}
for signal in widget.signals {
out += generateSignalModifier(widget: widget, signal: signal, style: .widgetView)
}
out += "}\n"
return out
}
/// Emits the erasing `extension View { }` fallback so non-`WidgetView` views
/// (user composites, `AnyView`, raw `Gtk.Widget` values, `VStack`/`ForEach`/)
/// still accept widget-level modifiers. Invoked only for the `Gtk.Widget` model.
func generateViewExtension(widget: WidgetModel) -> String {
var out = ""
out += docBlock(
indent: "",
chain: ["generateViewExtension"],
sources: ["\(widget.module).\(widget.className)"],
summary: [
"Widget-level modifiers for any `View`.",
"",
"These erase to `AnyView` so they compose with views that do not conform",
"to `WidgetView`. `WidgetView` conformers prefer the `Self`-returning",
"`WidgetView where Target: Gtk.Widget` overloads by Swift's overload",
"resolution, preserving their concrete type for chaining.",
]
)
out += "extension View {\n"
for prop in widget.properties {
out += generatePropertyModifiers(owner: widget, prop: prop, style: .erasingView)
}
for signal in widget.signals {
out += generateSignalModifier(widget: widget, signal: signal, style: .erasingView)
}
out += "}\n"
return out
}