portico/Sources/Portico/Presentation/DialogPresentation.swift

97 lines
3.8 KiB
Swift

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
})
}