Add dialog, alert, and toast presentation modifiers

This commit is contained in:
Brendan Szymanski 2026-08-08 00:53:25 -04:00
parent 30f84b0aee
commit 3099e76766
12 changed files with 770 additions and 1 deletions

View file

@ -30,6 +30,7 @@ struct ExampleApp: App {
AsyncDemoPage()
EnvironmentDemoPage()
navigationDemoPage
PresentationDemoPage()
}
.onPageChanged { page in currentPage = page }
.ref(_pager)
@ -38,7 +39,7 @@ struct ExampleApp: App {
.carousel($pager)
.halign(.center)
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 6")
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 7")
.dimmed()
.halign(.center)
.margin(8)

View file

@ -0,0 +1,39 @@
import Portico
struct PresentationDemoPage: View {
@State private var showDialog = false
@State private var showAlert = false
@State private var lastResponse = "none"
private let toasts = ToastManager()
var body: some View {
ToastOverlay {
VStack(spacing: 12) {
Button().label("Show dialog").onClicked { showDialog = true }
Button().label("Show alert").onClicked { showAlert = true }
Button().label("Toast").onClicked {
toasts.addToast(Toast(title: "Saved", timeout: .seconds(3), buttonLabel: "Undo") {
lastResponse = "undo"
})
}
Button().label("Dismiss toasts").onClicked { toasts.dismissAll() }
Label("Last response: \(lastResponse)").dimmed()
}
.margin(24)
.dialog(isPresented: $showDialog) {
VStack(spacing: 12) {
Label("Dialog")
Button().label("Close").onClicked { showDialog = false }
}
.margin(24)
}
.alert(isPresented: $showAlert, 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
lastResponse = response
}
}
.toastManager(toasts)
}
}

View file

@ -0,0 +1,63 @@
import Adw
extension WidgetView where Target: Adw.AlertDialog {
/// Adds one response button to the alert dialog.
///
/// `AdwAlertDialog` shows no buttons until responses are added.
///
/// - Parameters:
/// - id: The response identifier reported to ``onResponse(_:)``.
/// - label: The button label. An underscore marks the mnemonic character.
/// - appearance: Visual appearance. Defaults to ``ResponseAppearance/default``.
/// - isEnabled: Whether the button is clickable. Defaults to `true`.
/// - Returns: A copy of this view with the response added at mount time.
public func addResponse(
id: String,
label: String,
appearance: ResponseAppearance = .default,
isEnabled: Bool = true
) -> Self {
appending { w, _ in
w.addResponse(id: id, label: label)
if appearance != .default {
w.setResponseAppearance(response: id, appearance: appearance)
}
if !isEnabled {
w.setResponseEnabled(response: id, enabled: false)
}
}
}
/// Adds every response in `responses`, in order.
///
/// - Parameter responses: The response buttons to add.
/// - Returns: A copy of this view with the responses added at mount time.
public func responses(_ responses: [AlertResponse]) -> Self {
var result = self
for response in responses {
result = result.addResponse(
id: response.id,
label: response.label,
appearance: response.appearance,
isEnabled: response.isEnabled
)
}
return result
}
/// Runs `handler` with the response identifier each time the dialog emits
/// `::response`.
///
/// Fires for button activations and, on dismissal, for the close response.
/// The signal handler is disconnected on subtree teardown.
///
/// - Parameter handler: Receives the activated response identifier.
/// - Returns: A copy of this view with the handler connected at mount time.
public func onResponse(_ handler: @escaping (String) -> Void) -> Self {
appending { w, ctx in
ctx.registry.add(w.connectResponse(detail: nil) { _, response in
handler(response)
})
}
}
}

View file

@ -0,0 +1,62 @@
import Adw
/// Converts `duration` to the whole seconds `adw_toast_set_timeout` expects.
///
/// A positive duration never rounds down to `0`, because `0` means "never
/// auto-dismiss" in libadwaita and silently turning a brief toast into a
/// permanent one would be worse than rounding up. `.zero` and negative
/// durations map to `0` deliberately.
///
/// - Parameter duration: The requested on-screen time.
/// - Returns: Whole seconds, clamped to at least `1` for any positive duration.
private func toastTimeoutSeconds(_ duration: Duration) -> UInt32 {
let components = duration.components
guard components.seconds > 0 || (components.seconds == 0 && components.attoseconds > 0)
else { return 0 }
let rounded = components.seconds + (components.attoseconds >= 500_000_000_000_000_000 ? 1 : 0)
return UInt32(clamping: max(1, rounded))
}
extension Adw.Toast {
/// Creates a fully configured toast in one expression.
///
/// ```swift
/// toasts.addToast(Toast(title: "File saved", buttonLabel: "Undo") { undo() })
/// ```
///
/// `onClick` has no default value, so the inherited `Toast(title:)`
/// initializer stays reachable and unambiguous for the bare case.
///
/// - Parameters:
/// - title: The toast text, interpreted as Pango markup unless `useMarkup`
/// is `false`.
/// - timeout: How long the toast stays on screen. `nil` keeps libadwaita's
/// default. `.zero` (or a negative duration) means the toast never
/// dismisses itself. `AdwToast` counts whole seconds, so the value is
/// rounded to the nearest second and any positive duration is clamped to
/// at least one second rather than collapsing to "never".
/// - priority: How the toast behaves when one is already displayed. `nil`
/// keeps libadwaita's default.
/// - buttonLabel: Label for the toast's action button. `nil` shows no button.
/// - useMarkup: Whether `title` is Pango markup. `nil` keeps libadwaita's
/// default.
/// - onClick: Runs when the action button is clicked. The
/// `::button-clicked` handler is owned by the toast and released with it,
/// so there is nothing to unregister - but avoid capturing the toast
/// itself, which would keep it alive forever.
public convenience init(
title: String,
timeout: Duration? = nil,
priority: ToastPriority? = nil,
buttonLabel: String? = nil,
useMarkup: Bool? = nil,
_ onClick: @escaping () -> Void
) {
self.init(title: title)
if let timeout { setTimeout(timeout: toastTimeoutSeconds(timeout)) }
if let priority { setPriority(priority: priority) }
if let buttonLabel { setButtonLabel(buttonLabel: buttonLabel) }
if let useMarkup { setUseMarkup(useMarkup: useMarkup) }
_ = connectButtonClicked { _ in onClick() }
}
}

View file

@ -0,0 +1,21 @@
import Adw
extension WidgetView where Target: Adw.ToastOverlay {
/// Attaches `manager` to this overlay so ``ToastManager/addToast(_:)`` and
/// ``ToastManager/dismissAll()`` act on it.
///
/// Any toasts the manager queued before mount are flushed immediately. The
/// manager detaches on subtree teardown.
///
/// ``ToastManager`` is a reference type, so pass it directly - no
/// ``Binding`` is involved, mirroring ``View/environment(_:)``.
///
/// - Parameter manager: The manager to attach.
/// - Returns: A copy of this view with the manager attached at mount time.
public func toastManager(_ manager: ToastManager) -> Self {
appending { w, ctx in
manager._attach(w)
ctx.registry.add(SubscriptionToken { manager._detach(w) })
}
}
}

View file

@ -0,0 +1,41 @@
import Adw
/// One response button on an ``AlertDialog``.
///
/// `AdwAlertDialog` shows no buttons until responses are added, so an alert
/// without at least one response can only be dismissed with Escape. The
/// `defaultResponse` and `closeResponse` identifiers passed to
/// ``View/alert(isPresented:heading:body:defaultResponse:closeResponse:responses:_:extraChild:)``
/// must name a response added beforehand.
public struct AlertResponse {
/// The response identifier reported to the response handler.
public let id: String
/// The button label. An underscore marks the mnemonic character.
public let label: String
/// The button's visual appearance.
public let appearance: ResponseAppearance
/// Whether the button is clickable.
public let isEnabled: Bool
/// Creates a response button.
///
/// - Parameters:
/// - id: The response identifier reported to the response handler.
/// - label: The button label. An underscore marks the mnemonic character.
/// - appearance: Visual appearance. Defaults to ``ResponseAppearance/default``.
/// - isEnabled: Whether the button is clickable. Defaults to `true`.
public init(
id: String,
label: String,
appearance: ResponseAppearance = .default,
isEnabled: Bool = true
) {
self.id = id
self.label = label
self.appearance = appearance
self.isEnabled = isEnabled
}
}

View file

@ -0,0 +1,97 @@
import Adw
import Gtk
/// Ties `dialog`'s on-screen state to `isPresented`, in both directions.
///
/// Shared by ``View/dialog(isPresented:content:)`` and every `View.alert`
/// overload so both features get identical presentation semantics.
///
/// Presentation is deferred until `host` is mapped: `adw_dialog_present()`
/// falls back to a separate top-level window when the parent widget is not yet
/// rooted in an `AdwWindow`. Dismissal by any route - Escape, the close button,
/// swiping a bottom sheet down, or a programmatic `close()` - reaches `::closed`
/// and writes `false` back through the binding.
///
/// Three invariants are load-bearing rather than defensive:
///
/// - `isShown` gates `close()`. `adw_dialog_close()` logs a `g_critical` when
/// called on a dialog that is not currently presented.
/// - `isUpdating` breaks the `close() -> ::closed -> isPresented = false ->
/// close()` loop, because ``StateBox/set(_:)`` notifies unconditionally with
/// no equality check.
/// - The `close()` return value is honored. A `can-close = false` dialog, or one
/// whose `::close-attempt` handler vetoes, stays on screen; the binding is
/// restored rather than left desynchronized.
///
/// Every resource is registered on `ctx.registry`. The teardown token sets
/// `isUpdating` before force-closing, and ``NodeRegistry/teardown()`` cancels
/// tokens before disconnecting signal handles, so unmounting a still-presented
/// dialog cannot write back into user state that is already going away.
///
/// - Parameters:
/// - dialog: The dialog to show and hide. Captured strongly - Portico does not
/// otherwise retain it, and it is not in the widget tree until shown.
/// - isPresented: The two-way source of truth for visibility.
/// - host: The widget the modifier was applied to, used as the presentation
/// parent so libadwaita can find the enclosing `AdwWindow`.
/// - ctx: The mount context whose registry owns the subscriptions.
@MainActor
func bindDialogPresentation(
_ dialog: Adw.Dialog,
isPresented: Binding<Bool>,
host: Gtk.Widget,
ctx: MountContext
) {
var isShown = false
var isUpdating = false
// `parent` is a parameter rather than a capture so the `map` handler below
// does not strongly retain the very widget it is connected to.
let show: @MainActor (Gtk.Widget) -> Void = { parent in
guard !isShown, parent.getMapped() else { return }
isShown = true
dialog.present(parent: parent)
}
let hide: @MainActor () -> Void = {
guard isShown else { return }
if dialog.close() {
isShown = false
} else {
// Vetoed: the dialog is still on screen, so restore the binding
// instead of letting it desync. This write re-enters the subscriber
// below, which the `isUpdating` guard already suppresses.
isPresented.wrappedValue = true
}
}
ctx.registry.add(dialog.connectClosed { _ in
isShown = false
guard !isUpdating else { return }
isUpdating = true
isPresented.wrappedValue = false
isUpdating = false
})
ctx.registry.add(isPresented.subscribe { [host] shouldShow in
guard !isUpdating else { return }
isUpdating = true
if shouldShow { show(host) } else { hide() }
isUpdating = false
})
// Covers both "presented before the window was shown" and a later re-map.
ctx.registry.add(host.connectMap { mapped in
guard isPresented.untrackedValue else { return }
show(mapped)
})
// No-op unless the host is already mapped at mount time.
if isPresented.untrackedValue { show(host) }
ctx.registry.add(SubscriptionToken {
isUpdating = true // never reset: suppress write-back into user state
if isShown { dialog.forceClose() }
isShown = false
})
}

View file

@ -0,0 +1,82 @@
import Adw
/// Adds and dismisses toasts on a mounted ``ToastOverlay``.
///
/// Create one, attach it with ``WidgetView/toastManager(_:)``, then call
/// ``addToast(_:)`` and ``dismissAll()``:
///
/// ```swift
/// private let toasts = ToastManager()
///
/// var body: some View {
/// ToastOverlay {
/// Button().label("Save").onClicked {
/// toasts.addToast(Toast(title: "Saved", buttonLabel: "Undo") { undo() })
/// }
/// }
/// .toastManager(toasts)
/// }
/// ```
///
/// Toasts added before the overlay mounts are queued and flushed on attach, so
/// a manager is usable from `init` or from a `.task` that races the first mount.
///
/// The manager holds its overlay strongly. Portico does not retain the Swift
/// wrapper objects for widgets it mounts into a container, so a weak reference
/// would be cleared as soon as the mount returned. There is no retain cycle: the
/// overlay does not reference the manager.
@MainActor public final class ToastManager {
private var overlay: Adw.ToastOverlay?
private var pending: [Adw.Toast] = []
/// The attached overlay, or `nil` before mount and after teardown.
@_spi(Portico) public var _attachedOverlay: Adw.ToastOverlay? { overlay }
/// How many toasts are queued awaiting attachment.
@_spi(Portico) public var _pendingCount: Int { pending.count }
/// Creates a manager that is not yet attached to an overlay.
public init() {}
/// Shows `toast` on the attached overlay, queueing it if none is attached yet.
///
/// - Parameter toast: The toast to display. Ownership follows libadwaita's
/// `(transfer full)` contract for `adw_toast_overlay_add_toast`; the caller
/// may release its reference immediately.
public func addToast(_ toast: Adw.Toast) {
guard let overlay else {
pending.append(toast)
return
}
overlay.addToast(toast: toast)
}
/// Dismisses every toast shown or queued on the attached overlay, and
/// discards any toasts queued here before attachment.
public func dismissAll() {
pending.removeAll()
overlay?.dismissAll()
}
/// Binds this manager to `overlay` and flushes the queue.
///
/// The most recent attachment wins.
///
/// - Parameter overlay: The freshly mounted overlay to publish toasts to.
@_spi(Portico) public func _attach(_ overlay: Adw.ToastOverlay) {
self.overlay = overlay
let queued = pending
pending.removeAll()
for toast in queued { overlay.addToast(toast: toast) }
}
/// Releases `overlay` if it is still the attached one.
///
/// Guarding on identity stops an older overlay's teardown from detaching a
/// newer one that has already attached.
///
/// - Parameter overlay: The overlay being torn down.
@_spi(Portico) public func _detach(_ overlay: Adw.ToastOverlay) {
if self.overlay === overlay { self.overlay = nil }
}
}

View file

@ -0,0 +1,155 @@
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())
}
}

View file

@ -0,0 +1,52 @@
import Adw
import Gtk
extension View {
/// Presents an `Adw.Dialog` over this view's window while `isPresented` is `true`.
///
/// The dialog and its content are built once, at mount, following Portico's
/// build-once/bind-live model; showing and hiding re-present the same
/// instance rather than rebuilding it. The binding is two-way: dismissing
/// the dialog through the UI - Escape, the close button, or swiping a bottom
/// sheet down - writes `false` back.
///
/// Presentation waits until this view is mapped, so setting `isPresented` to
/// `true` before the window appears shows the dialog inside that window
/// rather than spawning a separate one.
///
/// ```swift
/// VStack {
/// Button().label("Details").onClicked { showDetails = true }
/// }
/// .dialog(isPresented: $showDetails) {
/// VStack(spacing: 12) {
/// Label("Details").title1()
/// Button().label("Close").onClicked { showDetails = false }
/// }
/// .margin(24)
/// }
/// ```
///
/// - Parameters:
/// - isPresented: Two-way control over the dialog's visibility.
/// - content: A ``ViewBuilder`` closure whose first view becomes the
/// dialog's child. An empty closure leaves the dialog empty; views
/// beyond the first are ignored, matching Portico's other single-slot
/// builders.
/// - Returns: This view, unchanged; the dialog is presented over it.
public func dialog(
isPresented: Binding<Bool>,
@ViewBuilder content: () -> [AnyView]
) -> AnyView {
let contentViews = content()
return AnyView(makeWidget: { ctx in
let host = AnyView(self).makeWidget(ctx)
let dialog = Adw.Dialog()
if let first = contentViews.first {
dialog.setChild(child: first.makeWidget(ctx))
}
bindDialogPresentation(dialog, isPresented: isPresented, host: host, ctx: ctx)
return host
})
}
}

View file

@ -0,0 +1,33 @@
import Adw
// Curated re-exports of the `Adw`/`Gtk` types named in Portico's own public API,
// so consumers only need `import Portico`.
//
// Only names that do not collide with a Portico view struct may be aliased here.
// PorticoGen names every generated view identically to the widget class it wraps,
// so a blanket `@_exported import Adw` would make 62 names ambiguous (`Dialog`,
// `Window`, `AlertDialog`, `ToastOverlay`, ...) and `@_exported import Gtk`
// another 97 (`Box`, `Button`, `Label`, ...).
//
// Rule: add an alias here whenever a public Portico signature mentions an `Adw`
// or `Gtk` type that has no Portico view struct of the same name.
/// A toast notification shown by a ``ToastOverlay``.
///
/// Create one with ``Adw/Toast/init(title:timeout:priority:buttonLabel:useMarkup:_:)``
/// and present it through ``ToastManager/addToast(_:)``.
public typealias Toast = Adw.Toast
/// How a toast behaves when another toast is already being displayed.
///
/// `.normal` queues behind the current toast; `.high` displaces it.
public typealias ToastPriority = Adw.ToastPriority
/// The visual appearance of an alert dialog response button.
///
/// Used by ``AlertResponse/appearance`` and
/// ``WidgetView/addResponse(id:label:appearance:isEnabled:)``.
public typealias ResponseAppearance = Adw.ResponseAppearance
/// Whether a dialog presents itself as a floating window or a bottom sheet.
public typealias DialogPresentationMode = Adw.DialogPresentationMode

View file

@ -0,0 +1,123 @@
import Adw
import Testing
import Gtk
@_spi(Portico) import Portico
// MARK: - Main-loop pump
@_silgen_name("g_main_context_iteration")
private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib default main context until `condition` holds or `limit`
/// elapses.
///
/// Iterates in blocking mode so real time can pass while libadwaita's toast
/// timeout source is pending; a non-repeating watchdog guarantees the loop
/// wakes and terminates even if no other source ever fires.
@MainActor private func pump(until condition: () -> Bool, limit: Duration) {
let expired = Box()
let watchdog = Timeout(interval: limit, repeats: false) { expired.value = true }
while !condition() && !expired.value {
_ = test_g_main_context_iteration(nil, 1)
}
if !expired.value { watchdog.remove() }
}
/// Mutable flag reachable from an escaping `@MainActor` closure.
@MainActor private final class Box {
var value = false
}
@MainActor @Suite(.serialized) struct PresentationTests {
@Test func toastInitializerConfiguresProperties() {
guard Gtk.initCheck() else { return }
let toast = Toast(title: "Saved", timeout: .seconds(2), priority: .high, buttonLabel: "Undo", useMarkup: true) {}
#expect(toast.getTitle() == "Saved")
#expect(toast.getTimeout() == 2)
#expect(toast.getPriority() == .high)
#expect(toast.getButtonLabel() == "Undo")
#expect(toast.getUseMarkup())
}
@Test func toastManagerQueuesAndFlushes() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
manager.addToast(Toast(title: "One"))
manager.addToast(Toast(title: "Two"))
#expect(manager._pendingCount == 2)
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
#expect(manager._attachedOverlay != nil)
#expect(manager._pendingCount == 0)
context.registry.teardown()
#expect(manager._attachedOverlay == nil)
}
@Test func alertResponsesMount() {
guard Gtk.initCheck() else { return }
let view = AlertDialog(heading: "Heading", body: "Body")
.responses([AlertResponse(id: "ok", label: "OK", appearance: .suggested)])
.defaultResponse("ok")
let widget = AnyView(view).makeWidget(MountContext())
#expect(widget is Adw.AlertDialog)
let dialog = widget as! Adw.AlertDialog
#expect(dialog.getHeading() == "Heading")
#expect(dialog.getDefaultResponse() == "ok")
}
@Test func dialogModifierReturnsHostWidget() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let widget = AnyView(VStack {}.dialog(isPresented: Binding(box)) { Label(str: "content") }).makeWidget(MountContext())
#expect(widget is Gtk.Box)
}
// MARK: - Toast ownership regressions
//
// `adw_toast_overlay_add_toast()` is `(transfer full)`: it consumes a
// reference. gtk-swift's generated wrapper used to pass the pointer
// without compensating, so the Swift `Toast` wrapper's `deinit` released a
// reference it no longer owned. The toast was then finalized early and
// libadwaita dereferenced freed memory when draining its queue.
//
// Both tests deliberately drop the Swift wrapper while libadwaita still
// owns the toast. Against the unfixed binding they abort the test process
// inside libadwaita rather than failing an expectation.
@Test func queuedToastsSurviveDismissAfterWrapperRelease() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
// Each wrapper is released at the end of its statement, leaving the
// toast owned solely by libadwaita's queue.
for index in 0..<5 {
manager.addToast(Toast(title: "queued \(index)"))
}
manager.dismissAll()
pump(until: { false }, limit: .milliseconds(150))
#expect(manager._attachedOverlay != nil)
}
@Test func toastSurvivesNaturalTimeoutAfterWrapperRelease() {
guard Gtk.initCheck() else { return }
let manager = ToastManager()
let context = MountContext()
_ = AnyView(ToastOverlay {}.toastManager(manager)).makeWidget(context)
let dismissed = Box()
do {
let toast = Toast(title: "expires", timeout: .seconds(1)) {}
_ = toast.connectDismissed { _ in dismissed.value = true }
manager.addToast(toast)
}
pump(until: { dismissed.value }, limit: .seconds(5))
#expect(dismissed.value)
}
}