155 lines
6.4 KiB
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())
|
|
}
|
|
}
|