From 1ae3a4ed37e432942312a9cd3a474889b3bbb14a Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Wed, 12 Aug 2026 00:44:12 -0400 Subject: [PATCH] Make Clipboard Observable to remove manual subscription plumbing --- Sources/Example/ClipboardDemo.swift | 20 ++---- Sources/Portico/Clipboard/Clipboard.swift | 85 ++++++++++++++--------- Tests/PorticoTests/ClipboardTests.swift | 7 ++ 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/Sources/Example/ClipboardDemo.swift b/Sources/Example/ClipboardDemo.swift index f56cd4e..8105f36 100644 --- a/Sources/Example/ClipboardDemo.swift +++ b/Sources/Example/ClipboardDemo.swift @@ -1,14 +1,13 @@ import Portico /// Demonstrates ``Clipboard``: an `Entry`-backed compose field feeds a Copy -/// button, a Paste button reads the clipboard back into `@State`, and an -/// `onChange` subscription reports live ownership changes - including ones -/// made by other applications. +/// button, a Paste button reads the clipboard back into `@State`, and a +/// reactive `Label` displays ``Clipboard/changeCount`` live - including +/// changes made by other applications - with no subscription of any kind. struct ClipboardDemoPage: View { + @Environment(\.clipboard) private var clipboard @State private var composeText = "Hello from Portico" @State private var pasted: String? - @State private var changeCount = 0 - @State private var subscription: SubscriptionToken? var body: some View { VStack(spacing: 12) { @@ -18,22 +17,17 @@ struct ClipboardDemoPage: View { .text($composeText) .placeholderText("Type something to copy") HStack(spacing: 8) { - Button("Copy") { Clipboard.general.setText(composeText) } + Button("Copy") { clipboard.setText(composeText) } .suggestedAction() - Button("Paste") { - Task { pasted = (try? await Clipboard.general.text()) ?? pasted } - } + Button("Paste") { Task { pasted = try? await clipboard.text() } } } Label { "Pasted: \(pasted ?? "(nothing yet)")" } .dimmed() - Label { "Clipboard changed \(changeCount) time(s)" } + Label { "Clipboard changed \(clipboard.changeCount) time(s)" } .dimmed() } .margin(24) .hexpand(true) .vexpand(true) - .onAppear { - subscription = Clipboard.general.onChange { changeCount += 1 } - } } } diff --git a/Sources/Portico/Clipboard/Clipboard.swift b/Sources/Portico/Clipboard/Clipboard.swift index 224cb8a..8d737f8 100644 --- a/Sources/Portico/Clipboard/Clipboard.swift +++ b/Sources/Portico/Clipboard/Clipboard.swift @@ -1,8 +1,10 @@ import Gdk import GLib import Gtk +import Observation -/// A display-scoped handle to the system clipboard or the primary selection. +/// A display-scoped, `Observable` handle to the system clipboard or the +/// primary selection. /// /// Use ``general`` for the clipboard written by Ctrl+C and read by Ctrl+V, or /// ``primary`` for the X11/Wayland primary selection (written by selecting @@ -11,17 +13,29 @@ import Gtk /// `PorticoRuntime.run` activates the application - every read returns `nil` /// and every write returns `false` until a display exists. /// +/// Being `@Observable`, ``changeCount`` and every read-only accessor +/// (``isLocal``, ``contains(mimeType:)``) participate in Portico's tracked +/// closures the same way `@State` does: read one from a reactive `Label`, and +/// it updates itself whenever ownership changes - including changes made by +/// another application - with no subscription to set up or tear down. +/// /// ```swift -/// Button("Copy").onClicked { Clipboard.general.setText(message) } -/// Button("Paste").onClicked { -/// Task { message = (try? await Clipboard.general.text()) ?? message } +/// struct CopyPasteDemo: View { +/// @Environment(\.clipboard) private var clipboard +/// @State private var message = "Hello, Portico" +/// @State private var pasted: String? +/// +/// var body: some View { +/// VStack { +/// Entry().text($message) +/// Button("Copy") { clipboard.setText(message) } +/// Button("Paste") { Task { pasted = try? await clipboard.text() } } +/// Label { "Changed \(clipboard.changeCount) time(s)" } +/// } +/// } /// } /// ``` -/// -/// Also reachable through `@Environment(\.clipboard)`, which defaults to -/// ``general`` and can be overridden per-subtree with -/// `.environment(\.clipboard, .primary)`. -@MainActor public final class Clipboard { +@Observable @MainActor public final class Clipboard { /// Which of a display's two clipboards an instance addresses. public enum Selection: Sendable { /// The system clipboard, written by Ctrl+C and read by Ctrl+V. @@ -40,19 +54,40 @@ import Gtk /// The MIME type Portico advertises for plain text. private static let textMimeType = "text/plain;charset=utf-8" - private let selection: Selection - /// Held only while an `onChange` subscription is live, so the connected - /// GObject outlives the handle. - private var subscribed: Gdk.Clipboard? + @ObservationIgnored private let selection: Selection + /// Cached on first resolution, once a display exists; GDK hands out one + /// clipboard object per display, so there is nothing to re-resolve. + @ObservationIgnored private var backing: Gdk.Clipboard? + @ObservationIgnored private var changeHandle: Gdk.SignalHandle? + + /// How many times this clipboard's ownership has changed - by this + /// process or another - since the app started. + /// + /// A tracked read (inside a reactive `Label`, `.onChange(of:)`, etc.) + /// subscribes to live updates automatically. + public private(set) var changeCount = 0 private init(_ selection: Selection) { self.selection = selection } /// The live GDK clipboard, or `nil` before a display exists. + /// + /// Resolves and subscribes to ownership changes exactly once, on first + /// access after a display appears. private var gdk: Gdk.Clipboard? { - guard let display = Gdk.Display.getDefault() else { return nil } - return selection == .general ? display.getClipboard() : display.getPrimaryClipboard() + if backing == nil, let display = Gdk.Display.getDefault() { + let clipboard = selection == .general ? display.getClipboard() : display.getPrimaryClipboard() + backing = clipboard + changeHandle = clipboard.connectChanged { [weak self] _ in self?.changeCount += 1 } + } + return backing } + /// Touches ``changeCount`` so a tracked read of a clipboard-derived + /// property (``isLocal``, ``contains(mimeType:)``) re-evaluates whenever + /// ownership changes, even though the property itself reads live GDK + /// state rather than stored data the Observation macro can see directly. + private func trackChanges() { _ = changeCount } + /// Reads the clipboard's contents as plain text. /// /// - Parameter cancellable: An optional GIO cancellable for the read. @@ -127,14 +162,16 @@ import Gtk /// Whether the clipboard's current contents are owned by this process. public var isLocal: Bool { - gdk?.isLocal() ?? false + trackChanges() + return gdk?.isLocal() ?? false } /// Whether the clipboard currently offers content in `mimeType`. /// /// - Parameter mimeType: The MIME type to check for. public func contains(mimeType: String) -> Bool { - gdk?.getFormats().containMimeType(mimeType: mimeType) ?? false + trackChanges() + return gdk?.getFormats().containMimeType(mimeType: mimeType) ?? false } /// Asks the display to preserve the clipboard's contents beyond this @@ -150,18 +187,4 @@ import Gtk public func store() async throws -> Bool { try await gdk?.storeAsync(ioPriority: 0, cancellable: nil) ?? false } - - /// Subscribes to clipboard ownership changes. - /// - /// - Parameter handler: Invoked whenever another application (or this one) - /// claims the clipboard. - /// - Returns: A token that ends the subscription when cancelled or - /// released. Inert (fires nothing, cancels nothing) if no display exists - /// yet. - public func onChange(_ handler: @escaping () -> Void) -> SubscriptionToken { - guard let gdk else { return SubscriptionToken(onCancel: {}) } - subscribed = gdk - var handle = gdk.connectChanged { _ in handler() } - return SubscriptionToken { handle.disconnect() } - } } diff --git a/Tests/PorticoTests/ClipboardTests.swift b/Tests/PorticoTests/ClipboardTests.swift index 2255c19..bf275a0 100644 --- a/Tests/PorticoTests/ClipboardTests.swift +++ b/Tests/PorticoTests/ClipboardTests.swift @@ -85,4 +85,11 @@ private nonisolated func test_g_main_context_iteration( let result = try await pumpedGeneralText() #expect(result == nil) } + + @Test func setTextIncrementsChangeCount() { + guard Gtk.initCheck() else { return } + let before = Clipboard.general.changeCount + #expect(Clipboard.general.setText("increment-check")) + #expect(Clipboard.general.changeCount > before) + } }