portico/Sources/Portico/Clipboard/Clipboard.swift

190 lines
7.9 KiB
Swift

import Gdk
import GLib
import Gtk
import Observation
/// 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
/// text, read by a middle click). Both resolve their backing `Gdk.Clipboard`
/// lazily from the default display, so they are safe to reference before
/// `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
/// 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)" }
/// }
/// }
/// }
/// ```
@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.
case general
/// The primary selection, written by selecting text and read by a
/// middle click. X11 and Wayland only.
case primary
}
/// The system clipboard of the default display.
public static let general = Clipboard(.general)
/// The primary selection of the default display.
public static let primary = Clipboard(.primary)
/// The MIME type Portico advertises for plain text.
private static let textMimeType = "text/plain;charset=utf-8"
@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? {
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.
/// - Returns: The clipboard's text, or `nil` if it holds no text (or no
/// display exists yet).
/// - Throws: `GLibError` if GIO fails to negotiate or deserialize the
/// content.
public func text(cancellable: Cancellable? = nil) async throws -> String? {
guard let gdk, !gdk.getFormats().isEmpty() else { return nil }
return try await gdk.readTextAsync(cancellable: cancellable)
}
/// Reads the clipboard's contents as an image.
///
/// - Parameter cancellable: An optional GIO cancellable for the read.
/// - Returns: The clipboard's image, or `nil` if it holds no image (or no
/// display exists yet).
/// - Throws: `GLibError` if GIO fails to negotiate or deserialize the
/// content.
public func texture(cancellable: Cancellable? = nil) async throws -> Texture? {
guard let gdk, !gdk.getFormats().isEmpty() else { return nil }
return try await gdk.readTextureAsync(cancellable: cancellable)
}
/// Publishes `text` as the clipboard's contents.
///
/// - Parameter text: The text to publish.
/// - Returns: `true` if the clipboard accepted the new content, `false`
/// on failure or before a display exists.
@discardableResult
public func setText(_ text: String) -> Bool {
guard let gdk else { return false }
let provider = ContentProvider(mimeType: Self.textMimeType, bytes: Bytes(data: Array(text.utf8)))
return gdk.setContent(provider: provider)
}
/// Publishes `texture` as the clipboard's contents, encoded as PNG.
///
/// - Parameter texture: The image to publish.
/// - Returns: `true` if the clipboard accepted the new content, `false`
/// on failure or before a display exists.
@discardableResult
public func setTexture(_ texture: Texture) -> Bool {
guard let gdk else { return false }
let provider = ContentProvider(mimeType: "image/png", bytes: texture.saveToPngBytes())
return gdk.setContent(provider: provider)
}
/// Publishes raw `data` under `mimeType` as the clipboard's contents.
///
/// - Parameters:
/// - data: The bytes to publish.
/// - mimeType: The MIME type other applications should request to
/// receive `data`.
/// - Returns: `true` if the clipboard accepted the new content, `false`
/// on failure or before a display exists.
@discardableResult
public func setData(_ data: [UInt8], mimeType: String) -> Bool {
guard let gdk else { return false }
let provider = ContentProvider(mimeType: mimeType, bytes: Bytes(data: data))
return gdk.setContent(provider: provider)
}
/// Clears the clipboard's contents.
///
/// - Returns: `true` if the clipboard was cleared, `false` on failure or
/// before a display exists.
@discardableResult
public func clear() -> Bool {
gdk?.setContent(provider: nil) ?? false
}
/// Whether the clipboard's current contents are owned by this process.
public var isLocal: Bool {
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 {
trackChanges()
return gdk?.getFormats().containMimeType(mimeType: mimeType) ?? false
}
/// Asks the display to preserve the clipboard's contents beyond this
/// application's lifetime, typically called on exit.
///
/// Does nothing but report success if the clipboard is not local. GTK
/// calls this automatically on `GApplication` shutdown, so most callers
/// do not need it.
///
/// - Returns: `true` on success, `false` on failure or before a display exists.
/// - Throws: `GLibError` if GIO fails to store the contents.
@discardableResult
public func store() async throws -> Bool {
try await gdk?.storeAsync(ioPriority: 0, cancellable: nil) ?? false
}
}