190 lines
7.9 KiB
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
|
|
}
|
|
}
|