Add dialog, alert, and toast presentation modifiers
This commit is contained in:
parent
30f84b0aee
commit
6772b805b7
13 changed files with 919 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
41
Sources/Example/PresentationDemo.swift
Normal file
41
Sources/Example/PresentationDemo.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Portico
|
||||
|
||||
struct PresentationDemoPage: View {
|
||||
@State private var dialogLocked = false
|
||||
@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, title: "Details", contentWidth: 360, canClose: $dialogLocked) {
|
||||
VStack(spacing: 12) {
|
||||
Label("Dialog")
|
||||
Button().label("Toggle lock").onClicked { dialogLocked.toggle() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
63
Sources/Portico/Extensions/AlertDialog+Extras.swift
Normal file
63
Sources/Portico/Extensions/AlertDialog+Extras.swift
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Sources/Portico/Extensions/Toast+Extras.swift
Normal file
62
Sources/Portico/Extensions/Toast+Extras.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
21
Sources/Portico/Extensions/ToastOverlay+Extras.swift
Normal file
21
Sources/Portico/Extensions/ToastOverlay+Extras.swift
Normal 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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sources/Portico/Presentation/AlertResponse.swift
Normal file
41
Sources/Portico/Presentation/AlertResponse.swift
Normal 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
|
||||
}
|
||||
}
|
||||
97
Sources/Portico/Presentation/DialogPresentation.swift
Normal file
97
Sources/Portico/Presentation/DialogPresentation.swift
Normal 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
|
||||
})
|
||||
}
|
||||
68
Sources/Portico/Presentation/DialogPropertySource.swift
Normal file
68
Sources/Portico/Presentation/DialogPropertySource.swift
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Adw
|
||||
|
||||
/// The source of a dialog property, either a fixed value or a live binding.
|
||||
public enum DialogPropertyValue<Value> {
|
||||
/// A fixed property value applied once when the dialog mounts.
|
||||
case constant(Value)
|
||||
/// A live property binding reapplied when its value changes.
|
||||
case binding(Binding<Value>)
|
||||
}
|
||||
|
||||
/// Supplies a dialog property as a fixed value or a live binding.
|
||||
public protocol DialogPropertySource<Value> {
|
||||
/// The value type accepted by this property source.
|
||||
associatedtype Value
|
||||
/// Returns the fixed value or binding represented by this source.
|
||||
var _dialogPropertyValue: DialogPropertyValue<Value> { get }
|
||||
}
|
||||
|
||||
/// Provides a fixed string dialog property value.
|
||||
extension String: DialogPropertySource {
|
||||
/// Returns this string as a fixed dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<String> { .constant(self) }
|
||||
}
|
||||
|
||||
/// Provides a fixed Boolean dialog property value.
|
||||
extension Bool: DialogPropertySource {
|
||||
/// Returns this Boolean as a fixed dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<Bool> { .constant(self) }
|
||||
}
|
||||
|
||||
/// Provides an integer literal as an `Int32` dialog property value.
|
||||
extension Int: DialogPropertySource {
|
||||
/// Returns this integer converted to a fixed `Int32` dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<Int32> { .constant(Int32(self)) }
|
||||
}
|
||||
|
||||
/// Provides a fixed `Int32` dialog property value.
|
||||
extension Int32: DialogPropertySource {
|
||||
/// Returns this value as a fixed dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<Int32> { .constant(self) }
|
||||
}
|
||||
|
||||
/// Provides a fixed dialog presentation mode.
|
||||
extension DialogPresentationMode: DialogPropertySource {
|
||||
/// Returns this mode as a fixed dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<DialogPresentationMode> { .constant(self) }
|
||||
}
|
||||
|
||||
/// Provides a live dialog property value through a Portico binding.
|
||||
extension Binding: DialogPropertySource {
|
||||
/// Returns this binding as a live dialog property value.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<Value> { .binding(self) }
|
||||
}
|
||||
|
||||
/// Applies a static or binding dialog property source using the matching applier.
|
||||
@MainActor
|
||||
func appliedDialogProperty<Value>(
|
||||
_ source: (any DialogPropertySource<Value>)?,
|
||||
to view: Dialog,
|
||||
constant: (Dialog, Value) -> Dialog,
|
||||
binding: (Dialog, Binding<Value>) -> Dialog
|
||||
) -> Dialog {
|
||||
guard let source else { return view }
|
||||
switch source._dialogPropertyValue {
|
||||
case .constant(let value): return constant(view, value)
|
||||
case .binding(let bound): return binding(view, bound)
|
||||
}
|
||||
}
|
||||
82
Sources/Portico/Presentation/ToastManager.swift
Normal file
82
Sources/Portico/Presentation/ToastManager.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
155
Sources/Portico/Presentation/View+Alert.swift
Normal file
155
Sources/Portico/Presentation/View+Alert.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
74
Sources/Portico/Presentation/View+Dialog.swift
Normal file
74
Sources/Portico/Presentation/View+Dialog.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Adw
|
||||
import Gtk
|
||||
|
||||
extension View {
|
||||
@MainActor private func mountDialog(_ view: Dialog, _ ctx: MountContext) -> Adw.Dialog {
|
||||
let widget = AnyView(view).makeWidget(ctx)
|
||||
guard let dialog = widget as? Adw.Dialog else {
|
||||
preconditionFailure("Dialog mounted a \(type(of: widget)); expected Adw.Dialog.")
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
/// Presents a configurable `Adw.Dialog` over this view while `isPresented` is true.
|
||||
///
|
||||
/// Each property accepts a fixed value or a live `Binding`. Width and height use `Int32`;
|
||||
/// integer literals are accepted. Use `DialogPresentationMode.bottomSheet` explicitly for
|
||||
/// `presentationMode`. Setting `canClose` to false vetoes user dismissal.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - isPresented: Two-way control over the dialog visibility.
|
||||
/// - title: Optional dialog title.
|
||||
/// - contentWidth: Optional content width in pixels.
|
||||
/// - contentHeight: Optional content height in pixels.
|
||||
/// - presentationMode: Optional dialog presentation mode.
|
||||
/// - canClose: Whether user dismissal is allowed.
|
||||
/// - followsContentSize: Whether the dialog follows its content size.
|
||||
/// - content: Builder whose first view becomes the dialog child.
|
||||
/// - Returns: This view, unchanged, with the dialog presentation attached.
|
||||
public func dialog(
|
||||
isPresented: Binding<Bool>,
|
||||
title: (any DialogPropertySource<String>)? = nil,
|
||||
contentWidth: (any DialogPropertySource<Int32>)? = nil,
|
||||
contentHeight: (any DialogPropertySource<Int32>)? = nil,
|
||||
presentationMode: (any DialogPropertySource<DialogPresentationMode>)? = nil,
|
||||
canClose: (any DialogPropertySource<Bool>)? = nil,
|
||||
followsContentSize: (any DialogPropertySource<Bool>)? = nil,
|
||||
@ViewBuilder content: () -> [AnyView]
|
||||
) -> AnyView {
|
||||
let contentViews = content()
|
||||
var configured = Dialog()
|
||||
configured = appliedDialogProperty(title, to: configured, constant: { $0.title($1) }, binding: { $0.title($1) })
|
||||
configured = appliedDialogProperty(contentWidth, to: configured, constant: { $0.contentWidth($1) }, binding: { $0.contentWidth($1) })
|
||||
configured = appliedDialogProperty(contentHeight, to: configured, constant: { $0.contentHeight($1) }, binding: { $0.contentHeight($1) })
|
||||
configured = appliedDialogProperty(presentationMode, to: configured, constant: { $0.presentationMode($1) }, binding: { $0.presentationMode($1) })
|
||||
configured = appliedDialogProperty(canClose, to: configured, constant: { $0.canClose($1) }, binding: { $0.canClose($1) })
|
||||
configured = appliedDialogProperty(followsContentSize, to: configured, constant: { $0.followsContentSize($1) }, binding: { $0.followsContentSize($1) })
|
||||
|
||||
return AnyView(makeWidget: { ctx in
|
||||
let host = AnyView(self).makeWidget(ctx)
|
||||
let dialog = mountDialog(configured, ctx)
|
||||
if let first = contentViews.first {
|
||||
dialog.setChild(child: first.makeWidget(ctx))
|
||||
}
|
||||
bindDialogPresentation(dialog, isPresented: isPresented, host: host, ctx: ctx)
|
||||
return host
|
||||
})
|
||||
}
|
||||
|
||||
/// Presents a pre-configured `Dialog` over this view while `isPresented` is true.
|
||||
///
|
||||
/// Use this overload for widget-slot properties such as `defaultWidget` and `focusWidget`.
|
||||
/// - Parameters:
|
||||
/// - isPresented: Two-way control over the dialog visibility.
|
||||
/// - dialog: The configured dialog view to mount.
|
||||
/// - Returns: This view, unchanged, with the dialog presentation attached.
|
||||
public func dialog(isPresented: Binding<Bool>, _ dialog: Dialog) -> AnyView {
|
||||
AnyView(makeWidget: { ctx in
|
||||
let host = AnyView(self).makeWidget(ctx)
|
||||
let mounted = mountDialog(dialog, ctx)
|
||||
bindDialogPresentation(mounted, isPresented: isPresented, host: host, ctx: ctx)
|
||||
return host
|
||||
})
|
||||
}
|
||||
}
|
||||
33
Sources/Portico/Reexports.swift
Normal file
33
Sources/Portico/Reexports.swift
Normal 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
|
||||
180
Tests/PorticoTests/PresentationTests.swift
Normal file
180
Tests/PorticoTests/PresentationTests.swift
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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)
|
||||
}
|
||||
|
||||
@Test func dialogParametersApplyStaticValues() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let show = Binding(box)
|
||||
let host = AnyView(VStack {}.dialog(isPresented: show, title: "Details", contentWidth: 360, canClose: false) {
|
||||
Label(str: "content")
|
||||
}).makeWidget(MountContext())
|
||||
let window = Adw.Window()
|
||||
window.setContent(content: host)
|
||||
window.present()
|
||||
pump(until: { host.getMapped() }, limit: .seconds(2))
|
||||
show.wrappedValue = true
|
||||
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
|
||||
let dialog = window.getVisibleDialog()
|
||||
#expect(dialog?.getTitle() == "Details")
|
||||
#expect(dialog?.getContentWidth() == 360)
|
||||
#expect(dialog?.getCanClose() == false)
|
||||
window.close()
|
||||
}
|
||||
|
||||
@Test func dialogParameterBindingsTrackChanges() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let showBox = StateBox(false)
|
||||
let titleBox = StateBox("First")
|
||||
let show = Binding(showBox)
|
||||
let title = Binding(titleBox)
|
||||
let host = AnyView(VStack {}.dialog(isPresented: show, title: title) {
|
||||
Label(str: "content")
|
||||
}).makeWidget(MountContext())
|
||||
let window = Adw.Window()
|
||||
window.setContent(content: host)
|
||||
window.present()
|
||||
pump(until: { host.getMapped() }, limit: .seconds(2))
|
||||
show.wrappedValue = true
|
||||
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
|
||||
title.wrappedValue = "Second"
|
||||
pump(until: { window.getVisibleDialog()?.getTitle() == "Second" }, limit: .seconds(2))
|
||||
#expect(window.getVisibleDialog()?.getTitle() == "Second")
|
||||
window.close()
|
||||
}
|
||||
|
||||
@Test func dialogOverloadAcceptsConfiguredDialog() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let view = Dialog()
|
||||
.title("Configured")
|
||||
.contentWidth(320)
|
||||
.presentationMode(DialogPresentationMode.bottomSheet)
|
||||
let widget = AnyView(view).makeWidget(MountContext())
|
||||
#expect(widget is Adw.Dialog)
|
||||
let dialog = widget as! Adw.Dialog
|
||||
#expect(dialog.getTitle() == "Configured")
|
||||
#expect(dialog.getContentWidth() == 320)
|
||||
#expect(dialog.getPresentationMode() == .bottomSheet)
|
||||
let host = AnyView(VStack {}.dialog(isPresented: Binding(StateBox(false)), Dialog())).makeWidget(MountContext())
|
||||
#expect(host 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue