1235 lines
57 KiB
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
|
|
}
|