Update main loop scheduling, lifecycle, and reactive teardown
This commit is contained in:
parent
7c651e2064
commit
9f1afe9a91
28 changed files with 577 additions and 294 deletions
|
|
@ -37,9 +37,15 @@ import Darwin
|
|||
private static var drainHandle: _Concurrency.Task<Void, Never>?
|
||||
private static var drainTaskHandle: _Concurrency.Task<Void, Never>?
|
||||
|
||||
/// Windows with a shutdown gate installed, keyed by wrapper identity so a
|
||||
/// window is retired exactly once no matter which path retires it: a
|
||||
/// `close-request` through the gate, or ``SceneHandle/dismantle()``, whose
|
||||
/// `gtk_window_destroy` emits no `close-request` at all.
|
||||
private static var gatedWindows: Set<ObjectIdentifier> = []
|
||||
|
||||
/// Number of gate-equipped windows still open. The shutdown drain starts
|
||||
/// only when the last window requests close.
|
||||
private static var openWindows = 0
|
||||
/// only when the last one is retired.
|
||||
@_spi(Portico) public static var _openWindowCount: Int { gatedWindows.count }
|
||||
|
||||
/// Bounded total shutdown drain time.
|
||||
@_spi(Portico) public static var _drainBound: Duration = .seconds(5)
|
||||
|
|
@ -69,7 +75,7 @@ import Darwin
|
|||
application = nil
|
||||
isDraining = false
|
||||
didDrain = false
|
||||
openWindows = 0
|
||||
gatedWindows.removeAll()
|
||||
drainDone.withLock { $0 = false }
|
||||
drainHandle?.cancel()
|
||||
drainHandle = nil
|
||||
|
|
@ -89,14 +95,15 @@ import Darwin
|
|||
@_spi(Portico) public static func _installShutdownGate(
|
||||
on window: Adw.ApplicationWindow
|
||||
) {
|
||||
PorticoRuntime.openWindows += 1
|
||||
let key = ObjectIdentifier(window)
|
||||
guard gatedWindows.insert(key).inserted else { return }
|
||||
_ = window.connectCloseRequest { _ in
|
||||
if PorticoRuntime.didDrain { return false }
|
||||
if PorticoRuntime.isDraining { return true }
|
||||
|
||||
// Non-last window: let it close without starting the drain.
|
||||
PorticoRuntime.openWindows -= 1
|
||||
if PorticoRuntime.openWindows > 0 { return false }
|
||||
PorticoRuntime.retireGate(key)
|
||||
if !PorticoRuntime.gatedWindows.isEmpty { return false }
|
||||
|
||||
// Last window: veto now and drain handlers while the loop spins.
|
||||
PorticoRuntime.isDraining = true
|
||||
|
|
@ -109,6 +116,23 @@ import Darwin
|
|||
}
|
||||
}
|
||||
|
||||
/// Retires `window`'s shutdown gate without closing it.
|
||||
///
|
||||
/// Called by ``SceneHandle/dismantle()``: `gtk_window_destroy` emits no
|
||||
/// `close-request`, so the gate handler never runs and the window would
|
||||
/// otherwise be counted open forever. Idempotent.
|
||||
@_spi(Portico) public static func _retireShutdownGate(
|
||||
on window: Adw.ApplicationWindow
|
||||
) {
|
||||
retireGate(ObjectIdentifier(window))
|
||||
}
|
||||
|
||||
/// Drops one gated window. Idempotent - a repeat retire is a no-op, which
|
||||
/// is what makes the two retire paths safe to overlap.
|
||||
private static func retireGate(_ key: ObjectIdentifier) {
|
||||
gatedWindows.remove(key)
|
||||
}
|
||||
|
||||
/// Drains registered handlers while the GLib main loop continues pumping.
|
||||
///
|
||||
/// The watchdog polls a completion flag on a short cadence instead of
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ import Adw
|
|||
registry.teardown()
|
||||
}
|
||||
|
||||
/// Releases reactive resources and destroys the window. Used when the runtime
|
||||
/// swaps to a different scene branch. Idempotent.
|
||||
/// Releases reactive resources, retires the window's shutdown gate, and
|
||||
/// destroys the window. Used when the runtime swaps to a different scene
|
||||
/// branch. Idempotent.
|
||||
@_spi(Portico) public func dismantle() {
|
||||
guard !isDismantled else { return }
|
||||
releaseResources()
|
||||
window.destroy()
|
||||
isDismantled = true
|
||||
releaseResources()
|
||||
PorticoRuntime._retireShutdownGate(on: window)
|
||||
window.destroy()
|
||||
}
|
||||
|
||||
/// Re-presents the window, for a second `activate` on a single-instance app.
|
||||
|
|
|
|||
|
|
@ -37,24 +37,28 @@ extension View {
|
|||
/// `Task.isCancelled` / `Task.checkCancellation()` check it makes
|
||||
/// itself), matching Swift's cooperative cancellation model.
|
||||
///
|
||||
/// A remap waits for the cancelled task to finish before the new body
|
||||
/// starts, so two copies never run concurrently. An action that ignores
|
||||
/// cancellation therefore delays its own replacement.
|
||||
///
|
||||
/// `action` runs on the main actor. Task creation goes through
|
||||
/// ``GLibMainExecutor`` (installed by `PorticoRuntime.run`), which
|
||||
/// defers the first hop to a later main-loop iteration - like the
|
||||
/// modifier it replaces, `action` observes a fully laid-out widget
|
||||
/// rather than firing synchronously the way ``onAppear(perform:)`` does.
|
||||
/// defers the first hop to a later main-loop iteration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - priority: The task's priority. Defaults to `.userInitiated`,
|
||||
/// matching SwiftUI's `task(priority:_:)`.
|
||||
/// - action: The asynchronous work to perform. Use nested
|
||||
/// `Task.detached` for CPU/IO work that must leave the main actor.
|
||||
/// - priority: The task's priority. Defaults to `.userInitiated`.
|
||||
/// - action: The asynchronous work to perform.
|
||||
public func task(
|
||||
priority: TaskPriority = .userInitiated,
|
||||
_ action: @escaping @MainActor () async -> Void
|
||||
) -> AnyView {
|
||||
scopedToAppearance(
|
||||
start: { () -> _Concurrency.Task<Void, Never> in
|
||||
_Concurrency.Task<Void, Never>(priority: priority) { await action() }
|
||||
start: { (previous: _Concurrency.Task<Void, Never>?) in
|
||||
_Concurrency.Task<Void, Never>(priority: priority) {
|
||||
await previous?.value
|
||||
guard !_Concurrency.Task.isCancelled else { return }
|
||||
await action()
|
||||
}
|
||||
},
|
||||
stop: { (task: _Concurrency.Task<Void, Never>) in task.cancel() }
|
||||
)
|
||||
|
|
@ -74,7 +78,7 @@ extension View {
|
|||
_ body: @escaping @MainActor () -> Void
|
||||
) -> AnyView {
|
||||
scopedToAppearance(
|
||||
start: { Timeout(interval: interval, priority: priority, repeats: true, body) },
|
||||
start: { _ in Timeout(interval: interval, priority: priority, repeats: true, body) },
|
||||
stop: { $0.remove() }
|
||||
)
|
||||
}
|
||||
|
|
@ -84,16 +88,23 @@ extension View {
|
|||
/// live handle, `stop` is called with that handle on the next `unmap` or
|
||||
/// on subtree teardown - whichever comes first. At most one handle is
|
||||
/// live at a time, so remap cycles do not accumulate handles.
|
||||
///
|
||||
/// `start` receives the handle produced by the previous appearance (`nil`
|
||||
/// on the first), so a caller that must not overlap successive runs can
|
||||
/// chain on it.
|
||||
private func scopedToAppearance<Handle>(
|
||||
start: @escaping @MainActor () -> Handle,
|
||||
start: @escaping @MainActor (Handle?) -> Handle,
|
||||
stop: @escaping @MainActor (Handle) -> Void
|
||||
) -> AnyView {
|
||||
AnyView(makeWidget: { ctx in
|
||||
let w = AnyView(self).makeWidget(ctx)
|
||||
var live: Handle?
|
||||
var previous: Handle?
|
||||
let begin: @MainActor () -> Void = {
|
||||
guard live == nil else { return }
|
||||
live = start()
|
||||
let handle = start(previous)
|
||||
previous = handle
|
||||
live = handle
|
||||
}
|
||||
let end: @MainActor () -> Void = {
|
||||
if let handle = live { stop(handle) }
|
||||
|
|
|
|||
|
|
@ -52,40 +52,27 @@ nonisolated func portico_g_main_context_iteration(
|
|||
|
||||
// MARK: - Main-thread predicate
|
||||
|
||||
/// Returns `true` if the calling thread is the main thread OR owns the
|
||||
/// default `GMainContext`. This is deliberately broader than ownership
|
||||
/// alone: `MainActor.assumeIsolated` in Portico's GLib trampolines can
|
||||
/// fire from plain main-thread code (e.g. `SourceId.remove()`), not just
|
||||
/// from inside a GLib dispatch. On Darwin it delegates to
|
||||
/// `pthread_main_np`.
|
||||
/// Returns `true` only on the process's initial thread - the thread that runs
|
||||
/// `main`, owns Swift's `MainActor`, and blocks inside `g_application_run`.
|
||||
///
|
||||
/// Deliberately does not accept ownership of the default `GMainContext`: a
|
||||
/// worker can acquire that context, but it is not the GTK main thread.
|
||||
#if canImport(Darwin)
|
||||
nonisolated func porticoIsMainThread() -> Bool { pthread_main_np() != 0 }
|
||||
#else
|
||||
@_silgen_name("gettid")
|
||||
nonisolated func portico_gettid() -> Int32
|
||||
|
||||
@_silgen_name("g_main_context_default")
|
||||
nonisolated func portico_g_main_context_default() -> UnsafeMutableRawPointer
|
||||
|
||||
@_silgen_name("g_main_context_is_owner")
|
||||
nonisolated func portico_g_main_context_is_owner(
|
||||
_ context: UnsafeMutableRawPointer
|
||||
) -> Int32
|
||||
|
||||
nonisolated func porticoIsMainThread() -> Bool {
|
||||
portico_g_main_context_is_owner(portico_g_main_context_default()) != 0
|
||||
|| portico_gettid() == getpid()
|
||||
}
|
||||
nonisolated func porticoIsMainThread() -> Bool { portico_gettid() == getpid() }
|
||||
#endif
|
||||
|
||||
// MARK: - Executor drain trampoline
|
||||
|
||||
/// `GSourceFunc` trampoline for the idle source that drains Swift jobs.
|
||||
///
|
||||
/// The executor pointer is stored as `GSource` user data with no destroy
|
||||
/// notify; the executor is a process-lifetime singleton so there is nothing
|
||||
/// to free. Returns `G_SOURCE_REMOVE` (0) because the source is one-shot -
|
||||
/// each batch arms a fresh source.
|
||||
/// The source owns a +1 on the executor, released by
|
||||
/// ``portico_executor_destroy_notify``. Returns `G_SOURCE_REMOVE` (0) because
|
||||
/// the source is one-shot - each batch arms a fresh source.
|
||||
@_cdecl("portico_executor_drain")
|
||||
nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int32 {
|
||||
guard let data else { return 0 }
|
||||
|
|
@ -95,6 +82,14 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
|
|||
return 0
|
||||
}
|
||||
|
||||
/// `GDestroyNotify` for the executor drain source. Releases the +1 handed to
|
||||
/// GLib by ``GLibMainExecutor/arm()``.
|
||||
@_cdecl("portico_executor_destroy_notify")
|
||||
nonisolated func portico_executor_destroy_notify(_ data: UnsafeMutableRawPointer?) {
|
||||
guard let data else { return }
|
||||
Unmanaged<GLibMainExecutor>.fromOpaque(data).release()
|
||||
}
|
||||
|
||||
// MARK: - Main executor
|
||||
|
||||
/// A `MainExecutor` that drains Swift MainActor jobs through the GLib main loop.
|
||||
|
|
@ -110,7 +105,7 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
|
|||
/// main-actor resumption is drained by `g_application_run` with no change to
|
||||
/// the GTK application lifecycle.
|
||||
@_spi(Portico) nonisolated public final class GLibMainExecutor:
|
||||
MainExecutor, @unchecked Sendable
|
||||
MainExecutor, Sendable
|
||||
{
|
||||
/// Pending jobs and the armed flag, guarded by a `Mutex` because `enqueue`
|
||||
/// is called from cooperative-pool threads.
|
||||
|
|
@ -148,9 +143,11 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
|
|||
private func arm() {
|
||||
let source = portico_g_idle_source_new()
|
||||
portico_g_source_set_priority(source, SourcePriority.defaultIdle.rawValue)
|
||||
let notify: @convention(c) (UnsafeMutableRawPointer?) -> Void =
|
||||
portico_executor_destroy_notify
|
||||
portico_g_source_set_callback(
|
||||
source, portico_executor_drain,
|
||||
Unmanaged.passUnretained(self).toOpaque(), nil
|
||||
Unmanaged.passRetained(self).toOpaque(), notify
|
||||
)
|
||||
_ = portico_g_source_attach(source, nil)
|
||||
portico_g_source_unref(source)
|
||||
|
|
|
|||
|
|
@ -33,11 +33,8 @@
|
|||
// MARK: - Trampolines
|
||||
|
||||
/// GLib `GSourceFunc` trampoline. Bridges the C callback back to the main
|
||||
/// actor via `MainActor.assumeIsolated`, which is valid because
|
||||
/// ``GLibMainExecutor`` is installed as the process main executor by
|
||||
/// ``PorticoRuntime/run(_:)``; its ``GLibMainExecutor/checkIsolated()``
|
||||
/// accepts the main thread and any thread that owns the default
|
||||
/// `GMainContext`.
|
||||
/// actor via `MainActor.assumeIsolated`. Dispatch runs on the thread pumping
|
||||
/// the GLib main context, which must be the process's main thread.
|
||||
@_cdecl("portico_source_dispatch")
|
||||
nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> Int32 {
|
||||
guard let data else { return 0 }
|
||||
|
|
@ -49,11 +46,13 @@ nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> In
|
|||
|
||||
/// GLib `GDestroyNotify` trampoline. Releases the +1 retain that GLib holds
|
||||
/// on the callback box.
|
||||
///
|
||||
/// Destroy-notify may run on any thread, so this is a plain nonisolated
|
||||
/// refcount release rather than a `MainActor.assumeIsolated` call.
|
||||
@_cdecl("portico_source_destroy_notify")
|
||||
nonisolated func portico_source_destroy_notify(_ data: UnsafeMutableRawPointer?) {
|
||||
guard let data else { return }
|
||||
let unmanaged = Unmanaged<SourceCallbackBox>.fromOpaque(data)
|
||||
MainActor.assumeIsolated { _ = unmanaged.takeRetainedValue() }
|
||||
Unmanaged<SourceCallbackBox>.fromOpaque(data).release()
|
||||
}
|
||||
|
||||
// MARK: - Duration conversion
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ nonisolated func portico_g_source_is_destroyed(_ source: UnsafeMutableRawPointer
|
|||
/// detaches it with `g_source_destroy`, so it never risks acting on a reissued
|
||||
/// source tag the way `g_source_remove` does.
|
||||
///
|
||||
/// Letting the handle go out of scope does **not** remove the source - the same
|
||||
/// contract as ``SubscriptionToken``. Removal is always explicit, or automatic
|
||||
/// via the appearance-scoped ``View/task(priority:_:)`` /
|
||||
/// Letting the handle go out of scope does **not** remove the source - unlike
|
||||
/// ``SubscriptionToken``, which cancels on release. Removal is always explicit,
|
||||
/// or automatic via the appearance-scoped ``View/task(priority:_:)`` /
|
||||
/// ``View/task(every:priority:_:)`` modifiers.
|
||||
@MainActor public final class SourceId {
|
||||
/// The GLib source tag (`g_source_get_id`), assigned when the source is
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@ extension Bool: DialogPropertySource {
|
|||
|
||||
/// 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)) }
|
||||
/// Returns this integer as a fixed `Int32` value, clamped to the `Int32`
|
||||
/// range rather than trapping on an out-of-range argument.
|
||||
public var _dialogPropertyValue: DialogPropertyValue<Int32> {
|
||||
.constant(Int32(clamping: self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides a fixed `Int32` dialog property value.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,16 @@ import Observation
|
|||
@_spi(Portico) public func teardown() {
|
||||
for d in deregistrations { d() }
|
||||
deregistrations.removeAll()
|
||||
if let id = observationID {
|
||||
observationID = nil
|
||||
ObservationBridge.unregister(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the ``ObservationBridge`` registration of a tracker released
|
||||
/// without ``teardown()``. `isolated deinit` is required because the bridge
|
||||
/// is main-actor isolated.
|
||||
isolated deinit {
|
||||
if let id = observationID { ObservationBridge.unregister(id) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@
|
|||
private static var dirty: [UInt64] = []
|
||||
private static var flushScheduled = false
|
||||
|
||||
/// Number of trackers currently registered with the bridge.
|
||||
static var registrationCount: Int { registered.count }
|
||||
|
||||
/// Weak handle so a torn-down tracker is not kept alive by the registry.
|
||||
private struct WeakTracker {
|
||||
weak var tracker: DependencyTracker?
|
||||
|
|
@ -67,11 +70,19 @@
|
|||
|
||||
/// Trampoline out of the non-isolated, `@Sendable` `onChange` closure.
|
||||
///
|
||||
/// Valid because every Portico mutation happens on the GLib main thread; this
|
||||
/// is the same `MainActor.assumeIsolated` bridge the GLib source callbacks in
|
||||
/// `MainLoopSources.swift` already rely on.
|
||||
/// `onChange` runs on the thread that mutated the observable. Off-main
|
||||
/// mutations therefore hop to the main actor before marking the tracker dirty.
|
||||
nonisolated func _porticoObservationDidChange(_ id: UInt64) {
|
||||
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
|
||||
if porticoIsMainThread() {
|
||||
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
|
||||
} else {
|
||||
_Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of trackers currently registered with the Observation bridge.
|
||||
@_spi(Portico) @MainActor public func _porticoObservationRegistrationCount() -> Int {
|
||||
ObservationBridge.registrationCount
|
||||
}
|
||||
|
||||
/// Empty marker captured by a tracker's `onChange` closure.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/// A handle that can cancel a live subscription.
|
||||
///
|
||||
/// ``cancel()`` is idempotent; deinit does NOT automatically cancel.
|
||||
/// Until explicitly cancelled, the subscription remains active regardless
|
||||
/// of whether the token is still held.
|
||||
/// The subscription lives exactly as long as the token: ``cancel()`` ends it
|
||||
/// early, and releasing the last reference to the token ends it too. Store the
|
||||
/// token (or hand it to a ``NodeRegistry``) for as long as the callback should
|
||||
/// keep firing. ``cancel()`` is idempotent.
|
||||
@MainActor public final class SubscriptionToken {
|
||||
private var onCancel: (() -> Void)?
|
||||
|
||||
|
|
@ -10,9 +11,12 @@
|
|||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
/// Removes the underlying subscription. Idempotent; does NOT fire on deinit.
|
||||
/// Removes the underlying subscription. Idempotent.
|
||||
public func cancel() {
|
||||
onCancel?()
|
||||
onCancel = nil
|
||||
}
|
||||
|
||||
/// Ends the subscription when the token is released.
|
||||
isolated deinit { cancel() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ private nonisolated func test_g_main_context_iteration(
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
|
||||
for _ in 0..<turns {
|
||||
if condition() { return }
|
||||
_ = test_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
@Observable
|
||||
|
|
@ -49,7 +57,7 @@ private struct Editor: View {
|
|||
/// `$model.isEnabled` is a two-way binding: widget writes propagate to the
|
||||
/// model synchronously, and model mutations propagate back to widgets
|
||||
/// through the Observation bridge.
|
||||
@Test func dynamicMemberBindingIsTwoWay() {
|
||||
@Test func dynamicMemberBindingIsTwoWay() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Model()
|
||||
|
|
@ -68,13 +76,13 @@ private struct Editor: View {
|
|||
|
||||
// Model -> widget propagates through Observation.
|
||||
model.isEnabled = false
|
||||
pump(until: { row.getActive() == false })
|
||||
await asyncPump(until: { row.getActive() == false })
|
||||
#expect(row.getActive() == false)
|
||||
}
|
||||
|
||||
/// `$model.title` drives the label independently of `$model.isEnabled`,
|
||||
/// proving that two bindings into the same model don't interfere.
|
||||
@Test func dynamicMemberBindingDrivesLabel() {
|
||||
@Test func dynamicMemberBindingDrivesLabel() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Model()
|
||||
|
|
@ -86,14 +94,14 @@ private struct Editor: View {
|
|||
#expect(label.getText() == "start")
|
||||
|
||||
model.title = "next"
|
||||
pump(until: { label.getText() == "next" })
|
||||
await asyncPump(until: { label.getText() == "next" })
|
||||
#expect(label.getText() == "next")
|
||||
}
|
||||
|
||||
/// `Bindable(model).amount` vends a binding without the property-wrapper
|
||||
/// declaration syntax, matching `Binding(model, \.amount)` but more
|
||||
/// concise.
|
||||
@Test func inlineBindableWrapsObject() {
|
||||
@Test func inlineBindableWrapsObject() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Model()
|
||||
|
|
@ -112,7 +120,7 @@ private struct Editor: View {
|
|||
|
||||
// Model -> widget propagates through Observation.
|
||||
model.amount = 12
|
||||
pump(until: { row.getValue() == 12 })
|
||||
await asyncPump(until: { row.getValue() == 12 })
|
||||
#expect(row.getValue() == 12)
|
||||
#expect(model.amount == 12) // no write-back echo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,18 +24,16 @@ import Adw
|
|||
guard Gtk.initCheck() else { return }
|
||||
let titleBox = StateBox("Title")
|
||||
let subtitleBox = StateBox("Sub")
|
||||
let context = MountContext()
|
||||
let row = AnyView(ActionRow(
|
||||
Binding(titleBox),
|
||||
subtitle: Binding(subtitleBox)
|
||||
)).makeWidget(MountContext()) as! Adw.ActionRow
|
||||
Binding(titleBox), subtitle: Binding(subtitleBox)
|
||||
)).makeWidget(context) as! Adw.ActionRow
|
||||
#expect(row.getTitle() == "Title")
|
||||
#expect(row.getSubtitle() == "Sub")
|
||||
|
||||
titleBox.set("New")
|
||||
subtitleBox.set("New sub")
|
||||
#expect(row.getTitle() == "New")
|
||||
#expect(row.getSubtitle() == "New sub")
|
||||
|
||||
row.setTitle(title: "Widget title")
|
||||
row.setSubtitle(subtitle: "Widget sub")
|
||||
#expect(titleBox.peek() == "Widget title")
|
||||
|
|
@ -44,9 +42,10 @@ import Adw
|
|||
|
||||
@Test func switchEntryPasswordAndSpinMixedBindings() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let context = MountContext()
|
||||
let activeBox = StateBox(false)
|
||||
let switchRow = AnyView(SwitchRow("Wi-Fi", active: Binding(activeBox)))
|
||||
.makeWidget(MountContext()) as! Adw.SwitchRow
|
||||
.makeWidget(context) as! Adw.SwitchRow
|
||||
activeBox.set(true)
|
||||
#expect(switchRow.getActive() == true)
|
||||
switchRow.setActive(isActive: false)
|
||||
|
|
@ -54,7 +53,7 @@ import Adw
|
|||
|
||||
let textBox = StateBox("initial")
|
||||
let entry = AnyView(EntryRow("Name", text: Binding(textBox)))
|
||||
.makeWidget(MountContext()) as! Adw.EntryRow
|
||||
.makeWidget(context) as! Adw.EntryRow
|
||||
textBox.set("changed")
|
||||
#expect(entry.getText() == "changed")
|
||||
entry.setText(text: "typed")
|
||||
|
|
@ -62,23 +61,16 @@ import Adw
|
|||
|
||||
let passwordBox = StateBox("secret")
|
||||
let password = AnyView(PasswordEntryRow("Password", text: Binding(passwordBox)))
|
||||
.makeWidget(MountContext()) as! Adw.PasswordEntryRow
|
||||
.makeWidget(context) as! Adw.PasswordEntryRow
|
||||
password.setText(text: "updated")
|
||||
#expect(passwordBox.peek() == "updated")
|
||||
passwordBox.set("again")
|
||||
#expect(password.getText() == "again")
|
||||
|
||||
let valueBox = StateBox(2.0)
|
||||
let adjustment = Adjustment(
|
||||
value: 1,
|
||||
lower: 0,
|
||||
upper: 10,
|
||||
stepIncrement: 1,
|
||||
pageIncrement: 1,
|
||||
pageSize: 0
|
||||
)
|
||||
let adjustment = Adjustment(value: 1, lower: 0, upper: 10, stepIncrement: 1, pageIncrement: 1, pageSize: 0)
|
||||
let spin = AnyView(SpinRow("Zoom", adjustment: adjustment, value: Binding(valueBox)))
|
||||
.makeWidget(MountContext()) as! Adw.SpinRow
|
||||
.makeWidget(context) as! Adw.SpinRow
|
||||
#expect(spin.getValue() == 2.0)
|
||||
spin.setValue(value: 4.0)
|
||||
#expect(valueBox.peek() == 4.0)
|
||||
|
|
@ -87,15 +79,16 @@ import Adw
|
|||
@Test func bannerAndButtonRowInitializers() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let revealedBox = StateBox(false)
|
||||
let context = MountContext()
|
||||
let banner = AnyView(Banner("Offline", revealed: Binding(revealedBox)))
|
||||
.makeWidget(MountContext()) as! Adw.Banner
|
||||
.makeWidget(context) as! Adw.Banner
|
||||
#expect(banner.getTitle() == "Offline")
|
||||
revealedBox.set(true)
|
||||
#expect(banner.getRevealed() == true)
|
||||
banner.setRevealed(revealed: false)
|
||||
#expect(revealedBox.peek() == false)
|
||||
let button = AnyView(ButtonRow("Save", startIconName: "document-save") { })
|
||||
.makeWidget(MountContext()) as! Adw.ButtonRow
|
||||
.makeWidget(context) as! Adw.ButtonRow
|
||||
#expect(button.getTitle() == "Save")
|
||||
#expect(button.getStartIconName() == "document-save")
|
||||
}
|
||||
|
|
@ -145,9 +138,10 @@ import Adw
|
|||
let titleBox = StateBox("Theme")
|
||||
let itemsBox = StateBox(["A", "B"])
|
||||
var selected: String?
|
||||
let context = MountContext()
|
||||
let row = AnyView(ComboRow(Binding(titleBox), items: Binding(itemsBox)) {
|
||||
selected = $0
|
||||
}).makeWidget(MountContext()) as! Adw.ComboRow
|
||||
}).makeWidget(context) as! Adw.ComboRow
|
||||
itemsBox.set(["X", "Y", "Z"])
|
||||
row.setSelected(position: 2)
|
||||
#expect(selected == "Z")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,17 @@ private nonisolated func test_g_main_context_iteration(
|
|||
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
||||
) -> Int32
|
||||
|
||||
@_silgen_name("g_main_context_default")
|
||||
private nonisolated func test_g_main_context_default() -> UnsafeMutableRawPointer
|
||||
|
||||
@_silgen_name("g_main_context_acquire")
|
||||
private nonisolated func test_g_main_context_acquire(
|
||||
_ context: UnsafeMutableRawPointer
|
||||
) -> Int32
|
||||
|
||||
@_silgen_name("g_main_context_release")
|
||||
private nonisolated func test_g_main_context_release(_ context: UnsafeMutableRawPointer)
|
||||
|
||||
// MARK: - Recorder
|
||||
|
||||
private nonisolated final class Recorder: @unchecked Sendable {
|
||||
|
|
@ -20,6 +31,8 @@ private nonisolated final class Recorder: @unchecked Sendable {
|
|||
let isolatingInCallback = Mutex<Bool?>(nil)
|
||||
/// `isIsolatingCurrentContext()` sampled on a cooperative-pool thread.
|
||||
let isolatingDetached = Mutex<Bool?>(nil)
|
||||
/// Whether the detached worker acquired the default context.
|
||||
let acquiredContext = Mutex(false)
|
||||
}
|
||||
|
||||
// MARK: - Custom-executor actor
|
||||
|
|
@ -47,7 +60,7 @@ private actor Subject {
|
|||
|
||||
/// Pumps the default main context until `condition` is true OR `timeout`
|
||||
/// elapses. Returns `true` if the watchdog fired (timeout elapsed without
|
||||
/// `condition` becoming true). Copied from `MainLoopSourceTests`.
|
||||
/// `condition` becoming true).
|
||||
private func pump(
|
||||
until condition: () -> Bool, timeout: Duration = .seconds(2)
|
||||
) -> Bool {
|
||||
|
|
@ -60,18 +73,25 @@ private actor Subject {
|
|||
return expired
|
||||
}
|
||||
|
||||
/// Async analogue of `pump(until:)`: yields each turn so finished tasks
|
||||
/// can be reclaimed while GLib keeps iterating.
|
||||
private func asyncPump(
|
||||
until condition: () -> Bool, timeout: Duration = .seconds(2)
|
||||
) async -> Bool {
|
||||
var expired = false
|
||||
let watchdog = Timeout(interval: timeout, repeats: false) { expired = true }
|
||||
defer { watchdog.remove() }
|
||||
while !condition() && !expired {
|
||||
_ = test_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// The core claim: an enqueued job on a `GLibMainExecutor` is dispatched
|
||||
/// by a GLib main-context iteration, and inside that dispatch the thread
|
||||
/// owns the default `GMainContext`.
|
||||
///
|
||||
/// These tests MUST NOT call `GLibExecutorFactory.install()` - that would
|
||||
/// change the process main executor and swift-testing's own MainActor work
|
||||
/// would then only run when someone pumps GLib, hanging the suite. Instead
|
||||
/// each test drives a *local* `GLibMainExecutor` through a custom-executor
|
||||
/// actor, which is probe-verified to work cross-module without the stdlib
|
||||
/// SPI import.
|
||||
/// by a GLib main-context iteration.
|
||||
@Test func enqueuedJobRunsOnTheMainThreadWhenTheLoopIterates() {
|
||||
let executor = GLibMainExecutor()
|
||||
let subject = Subject(executor)
|
||||
|
|
@ -82,8 +102,8 @@ private actor Subject {
|
|||
let timedOut = pump(until: { recorder.ran.withLock { $0 } })
|
||||
#expect(!timedOut, "main loop pump timed out - job never ran")
|
||||
let isolating = recorder.isolatingInCallback.withLock { $0 }
|
||||
#expect(isolating == true,
|
||||
"isIsolatingCurrentContext should be true inside the drain callback, got \(String(describing: isolating))")
|
||||
#expect(isolating == false,
|
||||
"a local test executor does not run on the process initial thread")
|
||||
}
|
||||
|
||||
/// Guards against the `g_main_context_invoke_full` style bug where a job
|
||||
|
|
@ -126,4 +146,40 @@ private actor Subject {
|
|||
#expect(isolating == false,
|
||||
"isIsolatingCurrentContext should be false on a cooperative-pool thread, got \(String(describing: isolating))")
|
||||
}
|
||||
|
||||
/// A worker thread that owns the default context is not the process initial
|
||||
/// thread and must not be treated as the main actor's isolation thread.
|
||||
@Test func defaultContextOwnerOnAWorkerThreadIsNotTheMainThread() async {
|
||||
let executor = GLibMainExecutor()
|
||||
let recorder = Recorder()
|
||||
await Task.detached {
|
||||
let context = test_g_main_context_default()
|
||||
guard test_g_main_context_acquire(context) != 0 else { return }
|
||||
recorder.acquiredContext.withLock { $0 = true }
|
||||
recorder.isolatingDetached.withLock {
|
||||
$0 = executor.isIsolatingCurrentContext()
|
||||
}
|
||||
test_g_main_context_release(context)
|
||||
}.value
|
||||
|
||||
#expect(recorder.acquiredContext.withLock { $0 })
|
||||
#expect(recorder.isolatingDetached.withLock { $0 } == false)
|
||||
}
|
||||
|
||||
/// The drain source's retained executor reference is released after the
|
||||
/// source fires, allowing a local executor to deallocate.
|
||||
@Test func armedSourceReleasesTheExecutorWhenItFires() async {
|
||||
let recorder = Recorder()
|
||||
weak var executorRef: GLibMainExecutor?
|
||||
do {
|
||||
let executor = GLibMainExecutor()
|
||||
executorRef = executor
|
||||
let subject = Subject(executor)
|
||||
Task.detached { await subject.record(into: recorder) }
|
||||
let timedOut = await asyncPump(until: { recorder.ran.withLock { $0 } })
|
||||
#expect(!timedOut, "job never drained")
|
||||
}
|
||||
_ = await asyncPump(until: { executorRef == nil }, timeout: .seconds(1))
|
||||
#expect(executorRef == nil, "drain source retained the executor")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,10 @@ import Adw
|
|||
let sizeBox = StateBox<Int32>(48)
|
||||
let textBinding = Binding(get: { Optional("AB") }, set: { _ in })
|
||||
let initialsBinding = Binding(get: { true }, set: { _ in })
|
||||
let context = MountContext()
|
||||
let w = AnyView(Avatar(
|
||||
size: Binding(sizeBox),
|
||||
text: textBinding,
|
||||
showInitials: initialsBinding
|
||||
)).makeWidget(MountContext())
|
||||
size: Binding(sizeBox), text: textBinding, showInitials: initialsBinding
|
||||
)).makeWidget(context)
|
||||
guard let avatar = w as? Adw.Avatar else { return }
|
||||
#expect(avatar.getSize() == 48)
|
||||
sizeBox.set(64)
|
||||
|
|
@ -70,9 +69,9 @@ import Adw
|
|||
@Test func avatarModifierBinding() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(32)
|
||||
let context = MountContext()
|
||||
let w = AnyView(Avatar(size: 48, text: "X", showInitials: true)
|
||||
.size(Portico.Binding(box)))
|
||||
.makeWidget(MountContext())
|
||||
.size(Portico.Binding(box))).makeWidget(context)
|
||||
guard let avatar = w as? Adw.Avatar else { return }
|
||||
#expect(avatar.getSize() == 32)
|
||||
box.set(96)
|
||||
|
|
@ -98,9 +97,8 @@ import Adw
|
|||
@Test func comboRowUseSubtitleModifierBinding() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Bool>(false)
|
||||
let w = AnyView(ComboRow()
|
||||
.useSubtitle(Portico.Binding(box)))
|
||||
.makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(ComboRow().useSubtitle(Portico.Binding(box))).makeWidget(context)
|
||||
guard let row = w as? Adw.ComboRow else { return }
|
||||
#expect(row.getUseSubtitle() == false)
|
||||
box.set(true)
|
||||
|
|
@ -147,8 +145,8 @@ import Adw
|
|||
@Test func nullablePropertyAcceptsNonOptionalBinding() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<String>("first")
|
||||
let w = AnyView(StatusPage().description(Portico.Binding(box)))
|
||||
.makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(StatusPage().description(Portico.Binding(box))).makeWidget(context)
|
||||
guard let page = w as? Adw.StatusPage else { return }
|
||||
#expect(page.getDescription() == "first")
|
||||
box.set("second")
|
||||
|
|
@ -161,8 +159,9 @@ import Adw
|
|||
guard Gtk.initCheck() else { return }
|
||||
let factory = Gtk.SignalListItemFactory()
|
||||
let box = StateBox<Gtk.ListItemFactory?>(factory)
|
||||
let context = MountContext()
|
||||
let w = AnyView(DropDown(model: nil, expression: nil).factory(Portico.Binding(box)))
|
||||
.makeWidget(MountContext())
|
||||
.makeWidget(context)
|
||||
guard let dropDown = w as? Gtk.DropDown else { return }
|
||||
#expect(dropDown.getFactory() != nil)
|
||||
box.set(nil)
|
||||
|
|
|
|||
|
|
@ -29,17 +29,11 @@ import Adw
|
|||
@Test func twoWayText() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("hello")
|
||||
let view = AnyView(
|
||||
Entry().text(Binding(box))
|
||||
)
|
||||
let entry = view.makeWidget(MountContext()) as! Gtk.Entry
|
||||
let context = MountContext()
|
||||
let entry = AnyView(Entry().text(Binding(box))).makeWidget(context) as! Gtk.Entry
|
||||
#expect(entry.text == "hello")
|
||||
|
||||
// Binding → widget
|
||||
box.set("world")
|
||||
#expect(entry.text == "world")
|
||||
|
||||
// Widget → binding (simulates a keystroke via changed signal)
|
||||
entry.setText(text: "typed")
|
||||
#expect(box.peek() == "typed")
|
||||
}
|
||||
|
|
@ -49,15 +43,11 @@ import Adw
|
|||
@Test func twoWayToggle() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let view = AnyView(
|
||||
ToggleButton().active(Binding(box))
|
||||
)
|
||||
let toggle = view.makeWidget(MountContext()) as! Gtk.ToggleButton
|
||||
let context = MountContext()
|
||||
let toggle = AnyView(ToggleButton().active(Binding(box))).makeWidget(context) as! Gtk.ToggleButton
|
||||
#expect(toggle.getActive() == false)
|
||||
|
||||
box.set(true)
|
||||
#expect(toggle.getActive() == true)
|
||||
|
||||
toggle.setActive(isActive: false)
|
||||
#expect(box.peek() == false)
|
||||
}
|
||||
|
|
@ -67,12 +57,8 @@ import Adw
|
|||
@Test func noEchoLoop() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("a")
|
||||
let view = AnyView(
|
||||
Entry().text(Binding(box))
|
||||
)
|
||||
let entry = view.makeWidget(MountContext()) as! Gtk.Entry
|
||||
// Set from binding side; the signal fires but the equality guard
|
||||
// in the handler sees values are already equal and stops.
|
||||
let context = MountContext()
|
||||
let entry = AnyView(Entry().text(Binding(box))).makeWidget(context) as! Gtk.Entry
|
||||
box.set("a")
|
||||
#expect(box.peek() == "a")
|
||||
#expect(entry.text == "a")
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ private nonisolated func test_g_main_context_iteration(
|
|||
}
|
||||
|
||||
/// Mounts `view` into a presented `Gtk.Window` and returns the window.
|
||||
/// Caller must pump the main loop after this to observe async effects.
|
||||
private func mountInPresentedWindow<V: View>(_ view: V) -> Gtk.Window {
|
||||
/// The caller retains `context` for the lifetime of reactive subscriptions.
|
||||
private func mountInPresentedWindow<V: View>(_ view: V, context: MountContext) -> Gtk.Window {
|
||||
let w = Gtk.Window()
|
||||
w.setChild(child: AnyView(view).makeWidget(MountContext()))
|
||||
w.setChild(child: AnyView(view).makeWidget(context))
|
||||
w.present()
|
||||
return w
|
||||
}
|
||||
|
|
@ -62,7 +62,8 @@ private nonisolated func test_g_main_context_iteration(
|
|||
return
|
||||
}
|
||||
var appeared = 0
|
||||
_ = mountInPresentedWindow(Label(str: "x").onAppear { appeared += 1 })
|
||||
let context = MountContext()
|
||||
_ = mountInPresentedWindow(Label(str: "x").onAppear { appeared += 1 }, context: context)
|
||||
let timedOut = pump(until: { appeared > 0 })
|
||||
#expect(!timedOut, "main loop pump timed out")
|
||||
#expect(appeared == 1)
|
||||
|
|
@ -76,8 +77,9 @@ private nonisolated func test_g_main_context_iteration(
|
|||
return
|
||||
}
|
||||
var disappeared = 0
|
||||
let context = MountContext()
|
||||
let window = mountInPresentedWindow(
|
||||
Label(str: "x").onDisappear { disappeared += 1 }
|
||||
Label(str: "x").onDisappear { disappeared += 1 }, context: context
|
||||
)
|
||||
// Wait for the window to map.
|
||||
let timedOut = pump(until: { window.getMapped() })
|
||||
|
|
@ -98,8 +100,9 @@ private nonisolated func test_g_main_context_iteration(
|
|||
return
|
||||
}
|
||||
var ticks = 0
|
||||
let context = MountContext()
|
||||
let window = mountInPresentedWindow(
|
||||
Label(str: "x").task(every: .milliseconds(1)) { ticks += 1 }
|
||||
Label(str: "x").task(every: .milliseconds(1)) { ticks += 1 }, context: context
|
||||
)
|
||||
let timedOut = pump(until: { ticks >= 2 })
|
||||
#expect(!timedOut, "main loop pump timed out")
|
||||
|
|
@ -136,12 +139,13 @@ private nonisolated func test_g_main_context_iteration(
|
|||
return
|
||||
}
|
||||
var stage = 0
|
||||
let context = MountContext()
|
||||
_ = mountInPresentedWindow(
|
||||
Label(str: "x").task {
|
||||
stage = 1
|
||||
try? await _Concurrency.Task.sleep(for: .milliseconds(5))
|
||||
stage = 2
|
||||
}
|
||||
}, context: context
|
||||
)
|
||||
let timedOut = await asyncPump(until: { stage == 2 })
|
||||
#expect(!timedOut, "async pump timed out waiting for the task to suspend and resume")
|
||||
|
|
@ -156,6 +160,7 @@ private nonisolated func test_g_main_context_iteration(
|
|||
}
|
||||
var started = false
|
||||
var wasCancelled = false
|
||||
let context = MountContext()
|
||||
let window = mountInPresentedWindow(
|
||||
Label(str: "x").task {
|
||||
started = true
|
||||
|
|
@ -164,9 +169,8 @@ private nonisolated func test_g_main_context_iteration(
|
|||
} catch is CancellationError {
|
||||
wasCancelled = true
|
||||
} catch {
|
||||
// Leave wasCancelled false - the #expect below still fails.
|
||||
}
|
||||
}
|
||||
}, context: context
|
||||
)
|
||||
let startTimedOut = await asyncPump(until: { started })
|
||||
#expect(!startTimedOut, "task never started")
|
||||
|
|
@ -174,4 +178,38 @@ private nonisolated func test_g_main_context_iteration(
|
|||
let timedOut = await asyncPump(until: { wasCancelled }, timeout: .milliseconds(500))
|
||||
#expect(!timedOut, "task was never cancelled after unmap")
|
||||
}
|
||||
|
||||
/// A rapid unmap/remap never runs two copies of the task action at once.
|
||||
@Test func remapDoesNotOverlapTaskBodies() async {
|
||||
guard Gtk.initCheck() else {
|
||||
#expect(Bool(false), "GTK not initialized - tests need a display")
|
||||
return
|
||||
}
|
||||
var active = 0
|
||||
var peak = 0
|
||||
var entries = 0
|
||||
let context = MountContext()
|
||||
let window = mountInPresentedWindow(
|
||||
Label(str: "x").task {
|
||||
entries += 1
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
try? await _Concurrency.Task.sleep(for: .milliseconds(50))
|
||||
active -= 1
|
||||
}, context: context
|
||||
)
|
||||
#expect(!(await asyncPump(until: { entries >= 1 })), "task never started")
|
||||
|
||||
var expected = 1
|
||||
for cycle in 1...3 {
|
||||
window.setVisible(visible: false)
|
||||
_ = await asyncPump(until: { false }, timeout: .milliseconds(30))
|
||||
window.setVisible(visible: true)
|
||||
expected += 1
|
||||
let restarted = await asyncPump(until: { entries >= expected })
|
||||
#expect(!restarted, "remap \(cycle) never restarted the task")
|
||||
}
|
||||
_ = await asyncPump(until: { active == 0 }, timeout: .seconds(1))
|
||||
#expect(peak == 1, "two task bodies overlapped")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ private nonisolated func test_g_main_context_iteration(
|
|||
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
||||
) -> Int32
|
||||
|
||||
@_silgen_name("g_main_context_find_source_by_id")
|
||||
private nonisolated func test_g_main_context_find_source_by_id(
|
||||
_ context: UnsafeMutableRawPointer?, _ sourceId: UInt32
|
||||
) -> UnsafeMutableRawPointer?
|
||||
|
||||
@_silgen_name("g_source_destroy")
|
||||
private nonisolated func test_g_source_destroy(_ source: UnsafeMutableRawPointer)
|
||||
|
||||
@MainActor @Suite(.serialized) struct MainLoopSourceTests {
|
||||
|
||||
/// Pumps the default main context until `condition` is true OR `timeout`
|
||||
|
|
@ -110,4 +118,22 @@ private nonisolated func test_g_main_context_iteration(
|
|||
#expect(!timedOut, "main loop pump timed out")
|
||||
#expect(ticks == 1)
|
||||
}
|
||||
/// Destroy-notify may run on a worker thread and must release the callback
|
||||
/// box without asserting main-actor isolation.
|
||||
@Test func destroyNotifyOffMainDoesNotTrap() async {
|
||||
var fired = false
|
||||
let id = Idle(repeats: true) { fired = true }
|
||||
guard let source = test_g_main_context_find_source_by_id(nil, id.rawValue) else {
|
||||
#expect(Bool(false), "attached source not found by id")
|
||||
return
|
||||
}
|
||||
let address = UInt(bitPattern: source)
|
||||
await Task.detached {
|
||||
test_g_source_destroy(UnsafeMutableRawPointer(bitPattern: address)!)
|
||||
}.value
|
||||
|
||||
#expect(id.isRemoved)
|
||||
for _ in 0..<20 { _ = test_g_main_context_iteration(nil, 0) }
|
||||
#expect(!fired)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ import Testing
|
|||
@Test func reactivePreferredWidth() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(40)
|
||||
let w = AnyView(Label(str: "x").preferredWidth(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").preferredWidth(Binding(box))).makeWidget(context)
|
||||
defer { box.set(0) }
|
||||
|
||||
#expect(w.getSizeRequest().width == 40)
|
||||
|
|
@ -63,8 +63,8 @@ import Testing
|
|||
@Test func reactiveCssClass() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("a")
|
||||
let w = AnyView(Label(str: "x").cssClass(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").cssClass(Binding(box))).makeWidget(context)
|
||||
defer { box.set("") }
|
||||
|
||||
#expect(w.hasCssClass(cssClass: "a"))
|
||||
|
|
@ -76,8 +76,8 @@ import Testing
|
|||
@Test func reactivePreferredHeight() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(99)
|
||||
let w = AnyView(Label(str: "x").preferredHeight(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").preferredHeight(Binding(box))).makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getSizeRequest().height == 99)
|
||||
|
|
@ -88,8 +88,8 @@ import Testing
|
|||
@Test func reactiveHexpand() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Bool>(false)
|
||||
let w = AnyView(Label(str: "x").hexpand(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").hexpand(Binding(box))).makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getHexpand() == false)
|
||||
|
|
@ -100,8 +100,8 @@ import Testing
|
|||
@Test func reactiveVexpand() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Bool>(false)
|
||||
let w = AnyView(Label(str: "x").vexpand(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").vexpand(Binding(box))).makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getVexpand() == false)
|
||||
|
|
@ -112,8 +112,8 @@ import Testing
|
|||
@Test func reactiveMargin() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(5)
|
||||
let w = AnyView(Label(str: "x").margin(Binding(box))).makeWidget(
|
||||
MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").margin(Binding(box))).makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getMarginStart() == 5)
|
||||
|
|
@ -144,12 +144,13 @@ import Testing
|
|||
@Test func reactiveWidthPreservesHeight() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(50)
|
||||
let context = MountContext()
|
||||
let w = AnyView(
|
||||
Label(str: "x")
|
||||
.preferredWidth(100) // static height 0 (unset)
|
||||
.preferredHeight(200) // static width 100 preserved
|
||||
.preferredWidth(Binding(box)) // reactive width
|
||||
).makeWidget(MountContext())
|
||||
.preferredWidth(100)
|
||||
.preferredHeight(200)
|
||||
.preferredWidth(Binding(box))
|
||||
).makeWidget(context)
|
||||
defer { box.set(0) }
|
||||
|
||||
// After static: width=100, height=200
|
||||
|
|
@ -168,9 +169,9 @@ import Testing
|
|||
@Test func bindBoolToVisible() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Bool>(true)
|
||||
let w = AnyView(
|
||||
Label(str: "x").bind(Binding(box), to: "visible")
|
||||
).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").bind(Binding(box), to: "visible"))
|
||||
.makeWidget(context)
|
||||
defer { box.set(false) }
|
||||
|
||||
#expect(w.getVisible() == true)
|
||||
|
|
@ -181,9 +182,9 @@ import Testing
|
|||
@Test func bindStringToTooltip() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("hello")
|
||||
let w = AnyView(
|
||||
Label(str: "x").bind(Binding(box), to: "tooltip-text")
|
||||
).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").bind(Binding(box), to: "tooltip-text"))
|
||||
.makeWidget(context)
|
||||
defer { box.set("") }
|
||||
|
||||
#expect(w.getTooltipText() == "hello")
|
||||
|
|
@ -194,9 +195,9 @@ import Testing
|
|||
@Test func bindIntToWidthRequest() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Int32>(50)
|
||||
let w = AnyView(
|
||||
Label(str: "x").bind(Binding(box), to: "width-request")
|
||||
).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").bind(Binding(box), to: "width-request"))
|
||||
.makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getSizeRequest().width == 50)
|
||||
|
|
@ -207,9 +208,9 @@ import Testing
|
|||
@Test func bindDoubleToOpacity() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox<Double>(1.0)
|
||||
let w = AnyView(
|
||||
Label(str: "x").bind(Binding(box), to: "opacity")
|
||||
).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").bind(Binding(box), to: "opacity"))
|
||||
.makeWidget(context)
|
||||
_ = box
|
||||
|
||||
#expect(w.getOpacity() == 1.0)
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ import PorticoGtk
|
|||
@Test func menuButtonLabelModifierReactive() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("a")
|
||||
let w = AnyView(
|
||||
MenuButton().label(Binding(box))
|
||||
).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(MenuButton().label(Binding(box))).makeWidget(context)
|
||||
guard let mb = w as? Gtk.MenuButton else {
|
||||
Issue.record("Expected Gtk.MenuButton, got \(type(of: w))")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ private nonisolated func observable_g_main_context_iteration(
|
|||
}
|
||||
}
|
||||
|
||||
/// Async pump that yields so a main-actor observation hop can run.
|
||||
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
|
||||
for _ in 0..<turns {
|
||||
if condition() { return }
|
||||
_ = observable_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observable model & probes
|
||||
|
||||
@Observable
|
||||
|
|
@ -93,6 +102,12 @@ private struct Readout: View {
|
|||
@Observable
|
||||
private final class Model { var text = "before" }
|
||||
|
||||
/// A deliberately nonisolated observable model for the off-main mutation test.
|
||||
@Observable
|
||||
private nonisolated final class OffMainModel: @unchecked Sendable {
|
||||
var text = "before"
|
||||
}
|
||||
|
||||
@Observable
|
||||
private final class Mixed { var observed = 0 }
|
||||
|
||||
|
|
@ -118,7 +133,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
/// `@Environment(AppState.self)`. Mutating the model from either one
|
||||
/// updates the widget built by the other, in both directions, with no
|
||||
/// `Binding` and no tree rebuild.
|
||||
@Test func observableEnvironmentPropagatesBothWays() {
|
||||
@Test func observableEnvironmentPropagatesBothWays() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
BodyCount.reset()
|
||||
|
||||
|
|
@ -141,14 +156,14 @@ private final class Bench { var amount: Double = 0 }
|
|||
|
||||
// Direction 1: mutate the model directly (as A's handler would).
|
||||
state.isFullscreen = true
|
||||
pump(until: { widgets[0].getText() == "A:on" })
|
||||
await asyncPump(until: { widgets[0].getText() == "A:on" })
|
||||
#expect(widgets.map { $0.getText() } == ["A:on", "B:on"])
|
||||
|
||||
// Direction 2: mutate through B's own `@Environment` declaration.
|
||||
let paneB = PaneB()
|
||||
_ = AnyView(paneB.environment(state)).makeWidget(ctx)
|
||||
paneB.toggleFromB()
|
||||
pump(until: { widgets[0].getText() == "A:off" })
|
||||
await asyncPump(until: { widgets[0].getText() == "A:off" })
|
||||
#expect(widgets.map { $0.getText() } == ["A:off", "B:off"])
|
||||
|
||||
// Targeted: same widget objects, zero body re-evaluations.
|
||||
|
|
@ -158,7 +173,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
|
||||
/// The remaining direction: a widget edit writes back into the model and
|
||||
/// reaches the other layout.
|
||||
@Test func widgetEditWritesBackThroughTheModel() {
|
||||
@Test func widgetEditWritesBackThroughTheModel() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let state = AppState()
|
||||
|
|
@ -180,12 +195,12 @@ private final class Bench { var amount: Double = 0 }
|
|||
// Simulate the user flipping the switch.
|
||||
row.setActive(isActive: true)
|
||||
#expect(state.isFullscreen) // widget -> model is synchronous
|
||||
pump(until: { readout.getText() == "A:on" })
|
||||
await asyncPump(until: { readout.getText() == "A:on" })
|
||||
#expect(readout.getText() == "A:on")
|
||||
|
||||
// And model -> widget still works in the same tree.
|
||||
state.isFullscreen = false
|
||||
pump(until: { row.getActive() == false })
|
||||
await asyncPump(until: { row.getActive() == false })
|
||||
#expect(row.getActive() == false)
|
||||
#expect(readout.getText() == "A:off")
|
||||
}
|
||||
|
|
@ -206,7 +221,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
}
|
||||
|
||||
/// Several mutations in one turn coalesce into a single re-evaluation.
|
||||
@Test func observableMutationsCoalesce() {
|
||||
@Test func observableMutationsCoalesce() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let state = AppState()
|
||||
|
|
@ -221,7 +236,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
state.title = "a"
|
||||
state.title = "b"
|
||||
state.title = "c"
|
||||
pump(until: { label.getText() == "c" })
|
||||
await asyncPump(until: { label.getText() == "c" })
|
||||
|
||||
#expect(label.getText() == "c")
|
||||
#expect(evaluations == 2) // one flush, not three
|
||||
|
|
@ -246,7 +261,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
/// widget bound to an observable connects the same single `notify` handler
|
||||
/// a `@State` binding does - and terminates the echo after one hop rather
|
||||
/// than oscillating.
|
||||
@Test func twoWayObservableBindingDoesNotEcho() {
|
||||
@Test func twoWayObservableBindingDoesNotEcho() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Bench()
|
||||
|
|
@ -265,7 +280,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
|
||||
// model -> widget, on the coalesced flush, exactly once
|
||||
model.amount = 12
|
||||
pump(until: { row.getValue() == 12 })
|
||||
await asyncPump(until: { row.getValue() == 12 })
|
||||
#expect(row.getValue() == 12)
|
||||
#expect(model.amount == 12) // no write-back echo changed it
|
||||
}
|
||||
|
|
@ -274,7 +289,7 @@ private final class Bench { var amount: Double = 0 }
|
|||
/// observation on every StateBox-driven re-run, and Observation keeps each
|
||||
/// stale registration until it fires. The bridge de-duplicates by tracker,
|
||||
/// so the fan-out costs one flush, not one per stale registration.
|
||||
@Test func staleRearmsCollapseToOneReevaluation() {
|
||||
@Test func staleRearmsCollapseToOneReevaluation() async {
|
||||
let box = StateBox(0)
|
||||
let model = Mixed()
|
||||
var runs = 0
|
||||
|
|
@ -294,20 +309,20 @@ private final class Bench { var amount: Double = 0 }
|
|||
// One observable mutation fires all 50 stale onChange closures; the
|
||||
// bridge collapses them into a single queued re-evaluation.
|
||||
model.observed = 1
|
||||
pump(until: { runs == 52 })
|
||||
await asyncPump(until: { runs == 52 })
|
||||
#expect(runs == 52)
|
||||
|
||||
// And the stale registrations are gone: the next mutation behaves the
|
||||
// same, not worse.
|
||||
model.observed = 2
|
||||
pump(until: { runs == 53 })
|
||||
await asyncPump(until: { runs == 53 })
|
||||
#expect(runs == 53)
|
||||
tracker.teardown()
|
||||
}
|
||||
|
||||
/// End to end: a non-static interpolation reading nothing reactive leaves
|
||||
/// no tracker behind, while one reading an observable stays live.
|
||||
@Test func interpolationRetentionIsExact() {
|
||||
@Test func interpolationRetentionIsExact() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let inert = 41
|
||||
|
|
@ -324,14 +339,14 @@ private final class Bench { var amount: Double = 0 }
|
|||
#expect(live.getText() == "value before")
|
||||
|
||||
model.text = "after"
|
||||
pump(until: { live.getText() == "value after" })
|
||||
await asyncPump(until: { live.getText() == "value after" })
|
||||
#expect(live.getText() == "value after")
|
||||
}
|
||||
|
||||
/// A mutation is NOT visible in the widget on the same turn: Observation's
|
||||
/// `onChange` is a willSet hook, so the new value is not yet readable and
|
||||
/// the flush has to be deferred.
|
||||
@Test func observableUpdateIsDeferredNotSynchronous() {
|
||||
@Test func observableUpdateIsDeferredNotSynchronous() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Model()
|
||||
|
|
@ -342,13 +357,13 @@ private final class Bench { var amount: Double = 0 }
|
|||
|
||||
model.text = "after"
|
||||
#expect(label.getText() == "before") // same turn: still stale
|
||||
pump(until: { label.getText() == "after" })
|
||||
await asyncPump(until: { label.getText() == "after" })
|
||||
#expect(label.getText() == "after")
|
||||
}
|
||||
|
||||
/// ...but it always lands BEFORE anything at GTK's redraw priority, so no
|
||||
/// frame can be composited from stale widget state.
|
||||
@Test func flushRunsBeforeGtkRedrawPriority() {
|
||||
@Test func flushRunsBeforeGtkRedrawPriority() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = Model()
|
||||
|
|
@ -360,9 +375,10 @@ private final class Bench { var amount: Double = 0 }
|
|||
var textSeenAtResize: String?
|
||||
|
||||
model.text = "after"
|
||||
await _Concurrency.Task.yield()
|
||||
Idle(priority: SourcePriority(rawValue: 110)) { textSeenAtResize = label.getText() }
|
||||
Idle(priority: SourcePriority(rawValue: 120)) { textSeenAtRedraw = label.getText() }
|
||||
pump(until: { textSeenAtRedraw != nil })
|
||||
await asyncPump(until: { textSeenAtRedraw != nil })
|
||||
|
||||
#expect(textSeenAtResize == "after")
|
||||
#expect(textSeenAtRedraw == "after")
|
||||
|
|
@ -374,10 +390,28 @@ private final class Bench { var amount: Double = 0 }
|
|||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let box = StateBox("before")
|
||||
let ctx = MountContext()
|
||||
let label = AnyView(Label(str: "").label(Portico.Binding(box)))
|
||||
.makeWidget(MountContext()) as! Gtk.Label
|
||||
.makeWidget(ctx) as! Gtk.Label
|
||||
#expect(label.getText() == "before")
|
||||
box.set("after")
|
||||
#expect(label.getText() == "after") // no pump needed
|
||||
}
|
||||
/// An off-main observable mutation hops to the main actor before the
|
||||
/// observation bridge updates the mounted label.
|
||||
@Test func offMainObservableMutationDoesNotTrap() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
|
||||
let model = OffMainModel()
|
||||
let ctx = MountContext()
|
||||
let label = AnyView(
|
||||
Portico.Label(str: "").label { model.text }
|
||||
).makeWidget(ctx) as! Gtk.Label
|
||||
defer { ctx.registry.teardown() }
|
||||
#expect(label.getText() == "before")
|
||||
|
||||
await _Concurrency.Task.detached { model.text = "after" }.value
|
||||
await asyncPump(until: { label.getText() == "after" })
|
||||
#expect(label.getText() == "after")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ private nonisolated func onChange_g_main_context_iteration(
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor private func asyncPumpOnChange(until condition: () -> Bool, turns: Int = 500) async {
|
||||
for _ in 0..<turns {
|
||||
if condition() { return }
|
||||
_ = onChange_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable model used to verify closure-backed Observation updates.
|
||||
@Observable
|
||||
private final class OnChangeModel {
|
||||
|
|
@ -114,7 +122,7 @@ private struct OnChangePair: Equatable {
|
|||
#expect(pairs == [OnChangePair(old: 1, new: 2)])
|
||||
}
|
||||
|
||||
@Test func trackedClosureFiresForObservable() {
|
||||
@Test func trackedClosureFiresForObservable() async {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let model = OnChangeModel()
|
||||
var pairs: [OnChangePair] = []
|
||||
|
|
@ -125,7 +133,7 @@ private struct OnChangePair: Equatable {
|
|||
defer { ctx.registry.teardown() }
|
||||
|
||||
model.number = 1
|
||||
pumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
|
||||
await asyncPumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
|
||||
#expect(pairs == [OnChangePair(old: 0, new: 1)])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,9 +79,10 @@ private nonisolated func test_g_main_context_iteration(
|
|||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let show = Binding(box)
|
||||
let context = MountContext()
|
||||
let host = AnyView(VStack {}.dialog(isPresented: show, title: "Details", contentWidth: 360, canClose: false) {
|
||||
Label(str: "content")
|
||||
}).makeWidget(MountContext())
|
||||
}).makeWidget(context)
|
||||
let window = Adw.Window()
|
||||
window.setContent(content: host)
|
||||
window.present()
|
||||
|
|
@ -101,9 +102,10 @@ private nonisolated func test_g_main_context_iteration(
|
|||
let titleBox = StateBox("First")
|
||||
let show = Binding(showBox)
|
||||
let title = Binding(titleBox)
|
||||
let context = MountContext()
|
||||
let host = AnyView(VStack {}.dialog(isPresented: show, title: title) {
|
||||
Label(str: "content")
|
||||
}).makeWidget(MountContext())
|
||||
}).makeWidget(context)
|
||||
let window = Adw.Window()
|
||||
window.setContent(content: host)
|
||||
window.present()
|
||||
|
|
@ -177,4 +179,38 @@ private nonisolated func test_g_main_context_iteration(
|
|||
pump(until: { dismissed.value }, limit: .seconds(5))
|
||||
#expect(dismissed.value)
|
||||
}
|
||||
|
||||
/// Out-of-range integer dialog properties clamp instead of trapping.
|
||||
@Test func oversizedIntDialogPropertyClamps() {
|
||||
guard case .constant(let high) = Int.max._dialogPropertyValue else {
|
||||
#expect(Bool(false), "Int source must be a constant")
|
||||
return
|
||||
}
|
||||
#expect(high == Int32.max)
|
||||
guard case .constant(let low) = Int.min._dialogPropertyValue else {
|
||||
#expect(Bool(false), "Int source must be a constant")
|
||||
return
|
||||
}
|
||||
#expect(low == Int32.min)
|
||||
}
|
||||
|
||||
/// An oversized content width still permits dialog presentation.
|
||||
@Test func dialogWithOversizedContentWidthPresents() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let show = Binding(box)
|
||||
let context = MountContext()
|
||||
let host = AnyView(VStack {}.dialog(isPresented: show, contentWidth: Int.max) {
|
||||
Label(str: "content")
|
||||
}).makeWidget(context)
|
||||
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))
|
||||
#expect(window.getVisibleDialog() != nil)
|
||||
window.close()
|
||||
context.registry.teardown()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import Testing
|
|||
@Test func explicitBindingUpdatesLabel() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("a")
|
||||
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(MountContext())
|
||||
let ctx = MountContext()
|
||||
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx)
|
||||
let lbl = w as! Gtk.Label
|
||||
defer { box.set("") } // suppress unused-variable in release
|
||||
defer { box.set("") }
|
||||
|
||||
#expect(lbl.getText() == "a")
|
||||
box.set("b")
|
||||
|
|
@ -21,8 +22,8 @@ import Testing
|
|||
@Test func explicitBindingUpdatesMultipleTimes() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("first")
|
||||
let lbl = AnyView(Label(str: "").label(Binding(box))).makeWidget(
|
||||
MountContext()) as! Gtk.Label
|
||||
let ctx = MountContext()
|
||||
let lbl = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx) as! Gtk.Label
|
||||
defer { box.set("") }
|
||||
|
||||
#expect(lbl.getText() == "first")
|
||||
|
|
@ -50,10 +51,10 @@ import Testing
|
|||
guard Gtk.initCheck() else { return }
|
||||
MountProbe.count = 0
|
||||
let box = StateBox(0)
|
||||
let ctx = MountContext()
|
||||
defer { box.set(0) }
|
||||
|
||||
let lbl = AnyView(ProbeLabel(box: box))
|
||||
.makeWidget(MountContext()) as! Gtk.Label
|
||||
let lbl = AnyView(ProbeLabel(box: box)).makeWidget(ctx) as! Gtk.Label
|
||||
|
||||
#expect(MountProbe.count == 1)
|
||||
#expect(lbl.getText() == "n=0")
|
||||
|
|
@ -66,10 +67,10 @@ import Testing
|
|||
guard Gtk.initCheck() else { return }
|
||||
MountProbe.count = 0
|
||||
let box = StateBox("a")
|
||||
let ctx = MountContext()
|
||||
defer { box.set("") }
|
||||
|
||||
let lbl = AnyView(ProbeBindingLabel(box: box))
|
||||
.makeWidget(MountContext()) as! Gtk.Label
|
||||
let lbl = AnyView(ProbeBindingLabel(box: box)).makeWidget(ctx) as! Gtk.Label
|
||||
|
||||
#expect(MountProbe.count == 1)
|
||||
#expect(lbl.getText() == "a")
|
||||
|
|
@ -105,8 +106,9 @@ import Testing
|
|||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("a")
|
||||
let parent = Gtk.Box(orientation: .vertical, spacing: 0)
|
||||
let ctx = MountContext()
|
||||
do {
|
||||
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(MountContext())
|
||||
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx)
|
||||
parent.append(child: w)
|
||||
}
|
||||
box.set("b")
|
||||
|
|
|
|||
|
|
@ -168,4 +168,42 @@ private nonisolated func test_g_main_context_iteration(
|
|||
#expect(ran)
|
||||
#expect(PorticoRuntime.didDrain)
|
||||
}
|
||||
|
||||
/// Branch swaps dismantle old windows without stranding the shutdown gate.
|
||||
@Test func branchFlipsDoNotStrandTheShutdownDrain() async {
|
||||
guard Gtk.initCheck() else {
|
||||
#expect(Bool(false), "GTK not initialized - tests need a display")
|
||||
return
|
||||
}
|
||||
beginTest()
|
||||
defer { restoreState() }
|
||||
|
||||
var ran = false
|
||||
ApplicationLifecycle.onShutdown { ran = true }
|
||||
|
||||
let box = StateBox(false)
|
||||
let app = Adw.Application(applicationId: nil, flags: .defaultFlags)
|
||||
@SceneBuilder func makeScene() -> some Scene {
|
||||
if Binding(box).wrappedValue {
|
||||
ApplicationWindow { _ in Gtk.Label(str: "on") }.title("On")
|
||||
} else {
|
||||
ApplicationWindow { _ in Gtk.Label(str: "off") }.title("Off")
|
||||
}
|
||||
}
|
||||
let host = SceneHost(app: app, makeScene: { makeScene() })
|
||||
host.start()
|
||||
|
||||
for flip in 1...3 {
|
||||
box.set(flip % 2 == 1)
|
||||
let expected: SceneIdentity = flip % 2 == 1 ? .first(.leaf) : .second(.leaf)
|
||||
let timedOut = await asyncPump(until: { host.liveIdentity == expected })
|
||||
#expect(!timedOut, "branch swap \(flip) never landed")
|
||||
}
|
||||
#expect(PorticoRuntime._openWindowCount == 1)
|
||||
|
||||
host.live?.window.close()
|
||||
let timedOut = await asyncPump(until: { PorticoRuntime.didDrain })
|
||||
#expect(!timedOut, "shutdown drain never started after branch flips")
|
||||
#expect(ran)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,52 +10,78 @@ import Testing
|
|||
@Test func stateBoxNotifiesSubscribers() {
|
||||
let box = StateBox(1)
|
||||
var seen: [Int] = []
|
||||
_ = box.subscribe { seen.append($0) }
|
||||
box.set(2)
|
||||
box.set(3)
|
||||
#expect(seen == [2, 3])
|
||||
let token = box.subscribe { seen.append($0) }
|
||||
withExtendedLifetime(token) {
|
||||
box.set(2)
|
||||
box.set(3)
|
||||
#expect(seen == [2, 3])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func reentrantSetDoesNotClobberOuterNotify() {
|
||||
let box = StateBox(0)
|
||||
var log: [Int] = []
|
||||
var didReenter = false
|
||||
var tokens: [SubscriptionToken] = []
|
||||
for _ in 0..<3 {
|
||||
_ = box.subscribe { value in
|
||||
tokens.append(box.subscribe { value in
|
||||
log.append(value)
|
||||
if !didReenter {
|
||||
didReenter = true
|
||||
box.set(99)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
box.set(1)
|
||||
withExtendedLifetime(tokens) {
|
||||
box.set(1)
|
||||
|
||||
#expect(log.count == 6)
|
||||
#expect(log.filter { $0 == 1 }.count == 3)
|
||||
#expect(log.filter { $0 == 99 }.count == 3)
|
||||
let nestedIndexes = log.indices.filter { log[$0] == 99 }
|
||||
#expect(nestedIndexes.count == 3)
|
||||
#expect(nestedIndexes.last! - nestedIndexes.first! == 2)
|
||||
#expect(box.peek() == 99)
|
||||
#expect(log.count == 6)
|
||||
#expect(log.filter { $0 == 1 }.count == 3)
|
||||
#expect(log.filter { $0 == 99 }.count == 3)
|
||||
let nestedIndexes = log.indices.filter { log[$0] == 99 }
|
||||
#expect(nestedIndexes.count == 3)
|
||||
#expect(nestedIndexes.last! - nestedIndexes.first! == 2)
|
||||
#expect(box.peek() == 99)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func setIfChangedSkipsEqualWrites() {
|
||||
let box = StateBox(7)
|
||||
var callCount = 0
|
||||
_ = box.subscribe { _ in callCount += 1 }
|
||||
let token = box.subscribe { _ in callCount += 1 }
|
||||
let binding = Binding(box)
|
||||
|
||||
binding.setIfChanged(7)
|
||||
withExtendedLifetime(token) {
|
||||
binding.setIfChanged(7)
|
||||
|
||||
#expect(callCount == 0)
|
||||
#expect(box.peek() == 7)
|
||||
|
||||
binding.setIfChanged(8)
|
||||
|
||||
#expect(callCount == 1)
|
||||
#expect(box.peek() == 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping a token cancels the subscription it owns.
|
||||
@Test func droppedTokenCancelsSubscription() {
|
||||
let box = StateBox(0)
|
||||
var callCount = 0
|
||||
do { _ = box.subscribe { _ in callCount += 1 } }
|
||||
box.set(1)
|
||||
#expect(callCount == 0)
|
||||
#expect(box.peek() == 7)
|
||||
}
|
||||
|
||||
binding.setIfChanged(8)
|
||||
|
||||
#expect(callCount == 1)
|
||||
#expect(box.peek() == 8)
|
||||
/// An observing tracker dropped without `teardown()` unregisters itself.
|
||||
@Test func droppedObservingTrackerUnregisters() {
|
||||
let before = _porticoObservationRegistrationCount()
|
||||
for _ in 0..<1000 {
|
||||
let tracker = DependencyTracker { }
|
||||
tracker.run()
|
||||
}
|
||||
#expect(_porticoObservationRegistrationCount() == before)
|
||||
}
|
||||
|
||||
@Test func cancelStopsNotifications() {
|
||||
|
|
@ -143,9 +169,10 @@ import Testing
|
|||
let b = Binding(get: { 42 }, set: { _ in })
|
||||
var called = false
|
||||
let token = b.subscribe { _ in called = true }
|
||||
_ = token
|
||||
// Custom bindings never fire subscriptions
|
||||
#expect(!called)
|
||||
withExtendedLifetime(token) {
|
||||
// Custom bindings never fire subscriptions
|
||||
#expect(!called)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State property wrapper
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ import Adw
|
|||
@Test func bindingTogglesInPlace() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(context)
|
||||
defer { box.set(false) }
|
||||
|
||||
#expect(!w.hasCssClass(cssClass: "card"))
|
||||
box.set(true)
|
||||
#expect(w.hasCssClass(cssClass: "card"))
|
||||
|
|
@ -37,9 +37,9 @@ import Adw
|
|||
@Test func bindingInitialTrue() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(true)
|
||||
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(context)
|
||||
defer { box.set(false) }
|
||||
|
||||
#expect(w.hasCssClass(cssClass: "card"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,17 +11,12 @@ import Adw
|
|||
@Test func switchRowActiveTwoWay() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(false)
|
||||
let view = AnyView(
|
||||
SwitchRow().active(Binding(box))
|
||||
)
|
||||
let row = view.makeWidget(MountContext()) as! Adw.SwitchRow
|
||||
let context = MountContext()
|
||||
let view = AnyView(SwitchRow().active(Binding(box)))
|
||||
let row = view.makeWidget(context) as! Adw.SwitchRow
|
||||
#expect(row.getActive() == false)
|
||||
|
||||
// Binding -> widget
|
||||
box.set(true)
|
||||
#expect(row.getActive() == true)
|
||||
|
||||
// Widget -> binding (simulates user toggling)
|
||||
row.setActive(isActive: false)
|
||||
#expect(box.peek() == false)
|
||||
}
|
||||
|
|
@ -107,17 +102,12 @@ import Adw
|
|||
@Test func spinRowValueTwoWay() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(10.0)
|
||||
let view = AnyView(
|
||||
SpinRow(min: 0, max: 100, step: 5).value(Binding(box))
|
||||
)
|
||||
let row = view.makeWidget(MountContext()) as! Adw.SpinRow
|
||||
let context = MountContext()
|
||||
let view = AnyView(SpinRow(min: 0, max: 100, step: 5).value(Binding(box)))
|
||||
let row = view.makeWidget(context) as! Adw.SpinRow
|
||||
#expect(row.getValue() == 10.0)
|
||||
|
||||
// Binding -> widget
|
||||
box.set(42.0)
|
||||
#expect(row.getValue() == 42.0)
|
||||
|
||||
// Widget -> binding
|
||||
row.setValue(value: 20.0)
|
||||
#expect(box.peek() == 20.0)
|
||||
}
|
||||
|
|
@ -128,17 +118,12 @@ import Adw
|
|||
@Test func enumPropertyTwoWay() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox(Gtk.Align.start)
|
||||
let view = AnyView(
|
||||
Label(str: "x").halign(Binding(box))
|
||||
)
|
||||
let w = view.makeWidget(MountContext())
|
||||
let context = MountContext()
|
||||
let view = AnyView(Label(str: "x").halign(Binding(box)))
|
||||
let w = view.makeWidget(context)
|
||||
#expect(w.getHalign() == Gtk.Align.start)
|
||||
|
||||
// Binding -> widget
|
||||
box.set(.end)
|
||||
#expect(w.getHalign() == .end)
|
||||
|
||||
// Widget -> binding
|
||||
w.setHalign(align: .center)
|
||||
#expect(box.peek() == .center)
|
||||
}
|
||||
|
|
@ -150,21 +135,14 @@ import Adw
|
|||
@Test func liftedOptionalSkipsNil() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let box = StateBox("A")
|
||||
let view = AnyView(
|
||||
Portico.Window().title(Binding(box))
|
||||
)
|
||||
let win = view.makeWidget(MountContext()) as! Adw.Window
|
||||
let context = MountContext()
|
||||
let view = AnyView(Portico.Window().title(Binding(box)))
|
||||
let win = view.makeWidget(context) as! Adw.Window
|
||||
#expect(win.getTitle() == "A")
|
||||
|
||||
// Binding -> widget
|
||||
box.set("B")
|
||||
#expect(win.getTitle() == "B")
|
||||
|
||||
// Widget -> binding (non-nil)
|
||||
win.setTitle(title: "C")
|
||||
#expect(box.peek() == "C")
|
||||
|
||||
// Widget -> nil should NOT write back
|
||||
win.setTitle(title: nil)
|
||||
#expect(box.peek() == "C")
|
||||
}
|
||||
|
|
@ -172,26 +150,19 @@ import Adw
|
|||
// MARK: - Non-Equatable stays one-way
|
||||
|
||||
/// Gtk.Adjustment is not Equatable, so the binding is one-way only.
|
||||
/// We verify by checking that the binding pushes a value (adj.getValue())
|
||||
/// but the widget->binding direction does NOT write back.
|
||||
@Test func nonEquatableStaysOneWay() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let initial = Gtk.Adjustment(value: 0, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
|
||||
let other = Gtk.Adjustment(value: 50, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
|
||||
let box = StateBox(initial)
|
||||
let view = AnyView(
|
||||
SpinButton(adjustment: nil, climbRate: 0, digits: 0).adjustment(Binding(box))
|
||||
)
|
||||
let sb = view.makeWidget(MountContext()) as! Gtk.SpinButton
|
||||
|
||||
// Binding -> widget works (one-way push); verify via the value
|
||||
let context = MountContext()
|
||||
let view = AnyView(SpinButton(adjustment: nil, climbRate: 0, digits: 0).adjustment(Binding(box)))
|
||||
let sb = view.makeWidget(context) as! Gtk.SpinButton
|
||||
box.set(other)
|
||||
#expect(sb.getAdjustment().getValue() == 50.0)
|
||||
|
||||
// Widget -> binding does NOT write back (non-Equatable)
|
||||
let third = Gtk.Adjustment(value: 75, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
|
||||
sb.setAdjustment(adjustment: third)
|
||||
#expect(box.peek() === other) // unchanged
|
||||
#expect(box.peek() === other)
|
||||
}
|
||||
|
||||
// MARK: - Teardown stops write-back
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import Testing
|
|||
@Test func backwardReference() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let ref = WidgetRef<Adw.Carousel>()
|
||||
let context = MountContext()
|
||||
let root = AnyView(VStack {
|
||||
Carousel { Label(str: "a") }.ref(ref)
|
||||
CarouselIndicatorDots().carousel(ref.projectedValue)
|
||||
}).makeWidget(MountContext()) as! Gtk.Box
|
||||
|
||||
}).makeWidget(context) as! Gtk.Box
|
||||
let first = root.getFirstChild()!
|
||||
let carousel = Adw.Carousel(retaining: first.pointer)
|
||||
let dots = Adw.CarouselIndicatorDots(retaining: first.getNextSibling()!.pointer)
|
||||
|
|
@ -21,11 +21,11 @@ import Testing
|
|||
@Test func forwardReference() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let ref = WidgetRef<Adw.Carousel>()
|
||||
let context = MountContext()
|
||||
let root = AnyView(VStack {
|
||||
CarouselIndicatorDots().carousel(ref.projectedValue)
|
||||
Carousel { Label(str: "a") }.ref(ref)
|
||||
}).makeWidget(MountContext()) as! Gtk.Box
|
||||
|
||||
}).makeWidget(context) as! Gtk.Box
|
||||
let first = root.getFirstChild()!
|
||||
let dots = Adw.CarouselIndicatorDots(retaining: first.pointer)
|
||||
let carousel = Adw.Carousel(retaining: first.getNextSibling()!.pointer)
|
||||
|
|
@ -36,17 +36,14 @@ import Testing
|
|||
guard Gtk.initCheck() else { return }
|
||||
let ref = WidgetRef<Adw.Carousel>()
|
||||
let items = StateBox<[Int]>([])
|
||||
let context = MountContext()
|
||||
let root = AnyView(VStack {
|
||||
CarouselIndicatorDots().carousel(ref.projectedValue)
|
||||
ForEach(Binding(items), id: \.self) { _ in
|
||||
Carousel().ref(ref)
|
||||
}
|
||||
}).makeWidget(MountContext()) as! Gtk.Box
|
||||
|
||||
ForEach(Binding(items), id: \.self) { _ in Carousel().ref(ref) }
|
||||
}).makeWidget(context) as! Gtk.Box
|
||||
let first = root.getFirstChild()!
|
||||
let dots = Adw.CarouselIndicatorDots(retaining: first.pointer)
|
||||
#expect(dots.getCarousel() == nil)
|
||||
|
||||
items.set([1])
|
||||
let carousel = Adw.Carousel(retaining: first.getNextSibling()!.pointer)
|
||||
#expect(dots.getCarousel()?.pointer == carousel.pointer)
|
||||
|
|
@ -73,14 +70,16 @@ import Testing
|
|||
/// A `WidgetRef` declared at the concrete subclass feeds a modifier whose property
|
||||
/// is declared `Gtk.Widget?`; the generated overload is generic, so `Binding`'s
|
||||
/// invariance does not reject it.
|
||||
/// A `WidgetRef` declared at the concrete subclass feeds a modifier whose property
|
||||
/// is declared `Gtk.Widget?`; the generated overload is generic.
|
||||
@Test func widgetTypedBindingOverload() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let entryRef = WidgetRef<Gtk.Entry>()
|
||||
let context = MountContext()
|
||||
let root = AnyView(VStack {
|
||||
Entry().ref(entryRef)
|
||||
Label(str: "N").mnemonicWidget(entryRef.projectedValue)
|
||||
}).makeWidget(MountContext()) as! Gtk.Box
|
||||
|
||||
}).makeWidget(context) as! Gtk.Box
|
||||
let first = root.getFirstChild()!
|
||||
let entry = Gtk.Entry(retaining: first.pointer)
|
||||
let label = Gtk.Label(retaining: first.getNextSibling()!.pointer)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue