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
|
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue