Make Clipboard Observable to remove manual subscription plumbing
This commit is contained in:
parent
2a0a15d38f
commit
1ae3a4ed37
3 changed files with 68 additions and 44 deletions
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue