portico/Sources/Portico/Presentation/View+Alert.swift

155 lines
6.4 KiB
Swift

import Adw
import Gtk
/// Mounts a Portico ``AlertDialog`` view and recovers the concrete widget.
///
/// ``AlertDialog`` declares `Target == Adw.AlertDialog`, so the cast always
/// succeeds; the check exists to fail loudly rather than silently should the
/// generated struct ever change shape.
///
/// - Parameters:
/// - view: The alert dialog view to mount.
/// - ctx: The mount context that will own the dialog's subscriptions.
/// - Returns: The mounted `Adw.AlertDialog`.
@MainActor private func mountAlertDialog(
_ view: AlertDialog,
_ ctx: MountContext
) -> Adw.AlertDialog {
let widget = AnyView(view).makeWidget(ctx)
guard let dialog = widget as? Adw.AlertDialog else {
preconditionFailure(
"AlertDialog mounted a \(type(of: widget)); expected Adw.AlertDialog."
)
}
return dialog
}
extension View {
/// Presents an `Adw.AlertDialog` over this view's window while `isPresented`
/// is `true`.
///
/// The dialog is built once, at mount. The binding is two-way: dismissing
/// through the UI writes `false` back.
///
/// `AdwAlertDialog` shows no buttons until responses are added, so pass
/// `responses` for anything the user should be able to click.
/// `defaultResponse` and `closeResponse` are identifiers that must name an
/// entry in `responses`.
///
/// `onResponse` is positional rather than trailing, because the trailing
/// closure position belongs to `extraChild`:
///
/// ```swift
/// .alert(
/// isPresented: $confirmDelete,
/// heading: "Delete file?",
/// body: "This cannot be undone.",
/// defaultResponse: "cancel",
/// closeResponse: "cancel",
/// responses: [
/// AlertResponse(id: "cancel", label: "Cancel"),
/// AlertResponse(id: "delete", label: "Delete", appearance: .destructive),
/// ],
/// { response in if response == "delete" { deleteFile() } }
/// )
/// ```
///
/// - Parameters:
/// - isPresented: Two-way control over the dialog's visibility.
/// - heading: The dialog heading.
/// - body: The dialog body text.
/// - defaultResponse: Identifier of the response activated by Enter. `nil`
/// sets none.
/// - closeResponse: Identifier of the response reported when the dialog is
/// dismissed without a button press. `nil` keeps libadwaita's default.
/// - responses: Buttons to add, in order.
/// - onResponse: Receives the activated response identifier.
/// - extraChild: A ``ViewBuilder`` closure whose first view is placed below
/// the heading and body.
/// - Returns: This view, unchanged; the alert is presented over it.
public func alert(
isPresented: Binding<Bool>,
heading: String,
body: String,
defaultResponse: String? = nil,
closeResponse: String? = nil,
responses: [AlertResponse] = [],
_ onResponse: @escaping (String) -> Void = { _ in },
@ViewBuilder extraChild: () -> [AnyView] = { [] }
) -> AnyView {
let extraViews = extraChild()
return AnyView(makeWidget: { ctx in
let host = AnyView(self).makeWidget(ctx)
let dialog = Adw.AlertDialog(heading: heading, body: body)
for response in responses {
dialog.addResponse(id: response.id, label: response.label)
if response.appearance != .default {
dialog.setResponseAppearance(
response: response.id, appearance: response.appearance
)
}
if !response.isEnabled {
dialog.setResponseEnabled(response: response.id, enabled: false)
}
}
if let defaultResponse { dialog.setDefaultResponse(response: defaultResponse) }
if let closeResponse { dialog.setCloseResponse(response: closeResponse) }
if let first = extraViews.first {
dialog.setExtraChild(child: first.makeWidget(ctx))
}
ctx.registry.add(dialog.connectResponse(detail: nil) { _, response in
onResponse(response)
})
bindDialogPresentation(dialog, isPresented: isPresented, host: host, ctx: ctx)
return host
})
}
/// Presents a preconfigured ``AlertDialog`` over this view's window while
/// `isPresented` is `true`.
///
/// Configure the dialog with the usual modifiers - ``WidgetView/responses(_:)``,
/// ``WidgetView/onResponse(_:)``, ``WidgetView/addResponse(id:label:appearance:isEnabled:)``
/// - before passing it here. The dialog mounts into this view's registry, so
/// its reactive bindings tear down with the surrounding subtree.
///
/// - Parameters:
/// - isPresented: Two-way control over the dialog's visibility.
/// - dialog: The alert dialog view to present.
/// - Returns: This view, unchanged; the alert is presented over it.
public func alert(isPresented: Binding<Bool>, _ dialog: AlertDialog) -> AnyView {
AnyView(makeWidget: { ctx in
let host = AnyView(self).makeWidget(ctx)
bindDialogPresentation(
mountAlertDialog(dialog, ctx),
isPresented: isPresented,
host: host,
ctx: ctx
)
return host
})
}
/// Presents an ``AlertDialog`` built inside `dialog` while `isPresented` is
/// `true`.
///
/// `dialog` is a plain closure evaluated once, immediately - deliberately not
/// a ``ViewBuilder``, which would erase the concrete type to `[AnyView]` and
/// lose access to the `AlertDialog` modifiers.
///
/// ```swift
/// .alert(isPresented: $confirmDelete) {
/// AlertDialog(heading: "Delete file?", body: "This cannot be undone.")
/// .responses([AlertResponse(id: "ok", label: "OK")])
/// .onResponse { _ in deleteFile() }
/// }
/// ```
///
/// - Parameters:
/// - isPresented: Two-way control over the dialog's visibility.
/// - dialog: A closure returning the alert dialog view to present.
/// - Returns: This view, unchanged; the alert is presented over it.
public func alert(isPresented: Binding<Bool>, _ dialog: () -> AlertDialog) -> AnyView {
alert(isPresented: isPresented, dialog())
}
}