Make Clipboard Observable to remove manual subscription plumbing

This commit is contained in:
Brendan Szymanski 2026-08-12 00:44:12 -04:00
parent 2a0a15d38f
commit 1ae3a4ed37
3 changed files with 68 additions and 44 deletions

View file

@ -1,14 +1,13 @@
import Portico import Portico
/// Demonstrates ``Clipboard``: an `Entry`-backed compose field feeds a Copy /// Demonstrates ``Clipboard``: an `Entry`-backed compose field feeds a Copy
/// button, a Paste button reads the clipboard back into `@State`, and an /// button, a Paste button reads the clipboard back into `@State`, and a
/// `onChange` subscription reports live ownership changes - including ones /// reactive `Label` displays ``Clipboard/changeCount`` live - including
/// made by other applications. /// changes made by other applications - with no subscription of any kind.
struct ClipboardDemoPage: View { struct ClipboardDemoPage: View {
@Environment(\.clipboard) private var clipboard
@State private var composeText = "Hello from Portico" @State private var composeText = "Hello from Portico"
@State private var pasted: String? @State private var pasted: String?
@State private var changeCount = 0
@State private var subscription: SubscriptionToken?
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
@ -18,22 +17,17 @@ struct ClipboardDemoPage: View {
.text($composeText) .text($composeText)
.placeholderText("Type something to copy") .placeholderText("Type something to copy")
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Copy") { Clipboard.general.setText(composeText) } Button("Copy") { clipboard.setText(composeText) }
.suggestedAction() .suggestedAction()
Button("Paste") { Button("Paste") { Task { pasted = try? await clipboard.text() } }
Task { pasted = (try? await Clipboard.general.text()) ?? pasted }
}
} }
Label { "Pasted: \(pasted ?? "(nothing yet)")" } Label { "Pasted: \(pasted ?? "(nothing yet)")" }
.dimmed() .dimmed()
Label { "Clipboard changed \(changeCount) time(s)" } Label { "Clipboard changed \(clipboard.changeCount) time(s)" }
.dimmed() .dimmed()
} }
.margin(24) .margin(24)
.hexpand(true) .hexpand(true)
.vexpand(true) .vexpand(true)
.onAppear {
subscription = Clipboard.general.onChange { changeCount += 1 }
}
} }
} }

View file

@ -1,8 +1,10 @@
import Gdk import Gdk
import GLib import GLib
import Gtk 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 /// 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 /// ``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` /// `PorticoRuntime.run` activates the application - every read returns `nil`
/// and every write returns `false` until a display exists. /// 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 /// ```swift
/// Button("Copy").onClicked { Clipboard.general.setText(message) } /// struct CopyPasteDemo: View {
/// Button("Paste").onClicked { /// @Environment(\.clipboard) private var clipboard
/// Task { message = (try? await Clipboard.general.text()) ?? message } /// @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)" }
/// }
/// }
/// } /// }
/// ``` /// ```
/// @Observable @MainActor public final class Clipboard {
/// Also reachable through `@Environment(\.clipboard)`, which defaults to
/// ``general`` and can be overridden per-subtree with
/// `.environment(\.clipboard, .primary)`.
@MainActor public final class Clipboard {
/// Which of a display's two clipboards an instance addresses. /// Which of a display's two clipboards an instance addresses.
public enum Selection: Sendable { public enum Selection: Sendable {
/// The system clipboard, written by Ctrl+C and read by Ctrl+V. /// 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. /// The MIME type Portico advertises for plain text.
private static let textMimeType = "text/plain;charset=utf-8" private static let textMimeType = "text/plain;charset=utf-8"
private let selection: Selection @ObservationIgnored private let selection: Selection
/// Held only while an `onChange` subscription is live, so the connected /// Cached on first resolution, once a display exists; GDK hands out one
/// GObject outlives the handle. /// clipboard object per display, so there is nothing to re-resolve.
private var subscribed: Gdk.Clipboard? @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 } private init(_ selection: Selection) { self.selection = selection }
/// The live GDK clipboard, or `nil` before a display exists. /// 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? { private var gdk: Gdk.Clipboard? {
guard let display = Gdk.Display.getDefault() else { return nil } if backing == nil, let display = Gdk.Display.getDefault() {
return selection == .general ? display.getClipboard() : display.getPrimaryClipboard() 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. /// Reads the clipboard's contents as plain text.
/// ///
/// - Parameter cancellable: An optional GIO cancellable for the read. /// - 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. /// Whether the clipboard's current contents are owned by this process.
public var isLocal: Bool { public var isLocal: Bool {
gdk?.isLocal() ?? false trackChanges()
return gdk?.isLocal() ?? false
} }
/// Whether the clipboard currently offers content in `mimeType`. /// Whether the clipboard currently offers content in `mimeType`.
/// ///
/// - Parameter mimeType: The MIME type to check for. /// - Parameter mimeType: The MIME type to check for.
public func contains(mimeType: String) -> Bool { 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 /// Asks the display to preserve the clipboard's contents beyond this
@ -150,18 +187,4 @@ import Gtk
public func store() async throws -> Bool { public func store() async throws -> Bool {
try await gdk?.storeAsync(ioPriority: 0, cancellable: nil) ?? false 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() }
}
} }

View file

@ -85,4 +85,11 @@ private nonisolated func test_g_main_context_iteration(
let result = try await pumpedGeneralText() let result = try await pumpedGeneralText()
#expect(result == nil) #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)
}
} }