⌘V grows an image-data branch, below the app's own clipboard format and refused outright while a file URL is on the pasteboard: a screenshot or a browser's Copy Image lands as "Pasted Image.png" in the anchor card's attachments/, through the very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style collision ladder, one set of banners, and the same silence a drop's arrival has. A card window's ⌘V pastes onto its own card; a focused text field still wins the selector natively. A file-shaped flavor travels byte for byte, PNG preferred when several are offered; TIFF and BMP are re-encoded to PNG, being interchange encodings rather than files anyone wants in a folder. The hero key gets the setter it was born owing: "Set as Hero" on any image row of the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins so the context entry is nobody's only home. It writes as a restyle — one key, one bracket, one invertible step on the window's own stack — and replaces rather than refusing, because a card has one hero and the row that has it says Remove instead. Edit ▸ Paste as Board Background is the same payload's other destination, taking the existing background.image convention at its word: the picture into the board folder as "Pasted Background.png", the colour subkey untouched, the generator's overwrite-our-own-name rule inherited and its echo memo taught to tell the two producers apart. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
211 lines
12 KiB
Swift
211 lines
12 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
import ImageIO
|
|
import UniformTypeIdentifiers
|
|
import os
|
|
|
|
// MARK: - PastedImage
|
|
|
|
/// **A picture on the pasteboard, as a file this app could write** (04-interactions.md ▸ Clipboard,
|
|
/// the image-data branch ruled 2026-08-09) — which flavor to take, what the landed file is called,
|
|
/// and whether the bytes travel verbatim or are re-encoded on the way.
|
|
///
|
|
/// ### Why this is a pure rule with no pasteboard in it
|
|
///
|
|
/// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision*
|
|
/// about a list of type identifiers, and a decision that can be a value function should be one — the
|
|
/// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of
|
|
/// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam
|
|
/// (`ClipboardStore`); nothing here touches `NSPasteboard`.
|
|
///
|
|
/// ### The precedence, which is the whole of the classification
|
|
///
|
|
/// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board
|
|
/// payload and pastes as cards or lanes exactly as it always did — an image flavor riding beside
|
|
/// it (there is none today, but a future manifest could carry a preview) must never divert ⌘V.
|
|
/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes
|
|
/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is
|
|
/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has
|
|
/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture
|
|
/// with a different answer, and this branch declines rather than guessing at it.
|
|
/// 3. **Raw image data is the fallback**, which is the screenshot (⌃⇧⌘4), the browser's Copy Image,
|
|
/// and Preview's ⌘C.
|
|
///
|
|
/// ### The format rule
|
|
///
|
|
/// **A file-shaped flavor is kept byte for byte** — PNG, JPEG, GIF, HEIC, WebP. These are the
|
|
/// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a
|
|
/// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the
|
|
/// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that
|
|
/// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with
|
|
/// no ceremony.
|
|
///
|
|
/// **Everything else is re-encoded to PNG** — in practice `public.tiff`, AppKit's lossless
|
|
/// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and
|
|
/// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file
|
|
/// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a
|
|
/// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason.
|
|
///
|
|
/// The preference order is PNG first and TIFF last, so the overwhelmingly common paste — a screenshot
|
|
/// offering `public.png` and `public.tiff` together — lands as the PNG it already is, with no decode
|
|
/// and no re-encode at all.
|
|
public enum PastedImage {
|
|
|
|
// MARK: The name
|
|
|
|
/// **The name a pasted image lands under** — Finder's own shape for a file that arrives with no
|
|
/// name of its own ("Pasted Image.png", then "Pasted Image 2.png", …).
|
|
///
|
|
/// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the
|
|
/// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary
|
|
/// file with this name on it. Nothing in this app climbs a second ladder — "Finder-style rename
|
|
/// on collision is one rule wherever the app has to find a free name".
|
|
public static let baseName = "Pasted Image"
|
|
|
|
/// **The board backdrop's own stem** — the same rule one level up (03-board-ui.md § Styling ▸
|
|
/// Capabilities), so a pasted background is as recognizable in a board folder as a pasted
|
|
/// attachment is in a card's.
|
|
///
|
|
/// Its own constant rather than `baseName` reused: the two files land in different folders for
|
|
/// different reasons, and a board folder holding something called "Pasted Image.png" would say
|
|
/// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on.
|
|
public static let backgroundBaseName = "Pasted Background"
|
|
|
|
// MARK: The flavor
|
|
|
|
/// One readable image payload on the pasteboard: where to read it from, what to write, and what
|
|
/// to call it.
|
|
public struct Flavor: Equatable, Sendable {
|
|
|
|
/// The pasteboard type identifier the bytes come from.
|
|
public let type: String
|
|
|
|
/// The landed file's extension — the flavor's own for a verbatim write, `png` for a
|
|
/// converted one.
|
|
public let fileExtension: String
|
|
|
|
/// Whether the bytes are re-encoded on the way (see the type comment's format rule).
|
|
public let convertsToPNG: Bool
|
|
|
|
/// The file name a paste of this flavor mints, before the Finder ladder ever sees it.
|
|
public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" }
|
|
|
|
/// The board-backdrop file name for the same flavor.
|
|
public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" }
|
|
}
|
|
|
|
// MARK: Classification
|
|
|
|
/// The flavors kept verbatim, **in this app's preference order** — not the pasteboard's, which
|
|
/// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to
|
|
/// be written.
|
|
///
|
|
/// PNG leads because it is lossless, universally readable, and the flavor a screenshot already
|
|
/// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather
|
|
/// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering
|
|
/// only one of them is still a paste rather than a refusal.
|
|
public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP]
|
|
|
|
/// The flavors re-encoded to PNG — the interchange bitmaps (see the type comment).
|
|
public static let convertedTypes: [UTType] = [.tiff, .bmp]
|
|
|
|
/// What this pasteboard offers the image branch, or `nil` when the branch does not apply.
|
|
///
|
|
/// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable —
|
|
/// clause 1 of the precedence. Passed in rather than read here because deciding *that* is
|
|
/// `ClipboardManifest`'s job and this type has no pasteboard.
|
|
/// - Parameter types: every type identifier the pasteboard currently carries.
|
|
public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? {
|
|
guard !hasBoardItems, !carriesFileURL(types) else { return nil }
|
|
|
|
let offered = Set(types)
|
|
for type in verbatimTypes where offered.contains(type.identifier) {
|
|
// A registered type with no preferred extension is not something a file can be named
|
|
// after; skipping it lets the ladder fall through to the converted branch rather than
|
|
// minting "Pasted Image." with nothing after the dot.
|
|
guard let ext = type.preferredFilenameExtension else { continue }
|
|
return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false)
|
|
}
|
|
for type in convertedTypes where offered.contains(type.identifier) {
|
|
return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Whether the pasteboard is carrying a file reference — clause 2 of the precedence.
|
|
///
|
|
/// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory
|
|
/// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is
|
|
/// about what the value *is*. A type the system does not know is not a file URL, which is the
|
|
/// same optimistic reading a drag's unknown types get.
|
|
public static func carriesFileURL(_ types: [String]) -> Bool {
|
|
types.contains { UTType($0)?.conforms(to: .fileURL) ?? false }
|
|
}
|
|
|
|
// MARK: The bytes
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image")
|
|
|
|
/// The bytes this flavor writes, given what the pasteboard handed over — `raw` itself for a
|
|
/// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out
|
|
/// not to be a decodable image at all.
|
|
///
|
|
/// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and
|
|
/// writing an unreadable file into somebody's card folder is worse than doing nothing. The
|
|
/// caller treats it exactly as it treats an empty pasteboard.
|
|
public static func encode(_ raw: Data, as flavor: Flavor) -> Data? {
|
|
guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw }
|
|
return pngData(from: raw)
|
|
}
|
|
|
|
/// A bitmap payload re-encoded as PNG, through ImageIO.
|
|
///
|
|
/// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode`
|
|
/// makes and for the same reasons: it is the framework that actually owns the codecs, it is
|
|
/// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The
|
|
/// full image is decoded rather than a thumbnail — this is a *conversion*, and downsampling a
|
|
/// picture the user pasted would silently cost them resolution they never agreed to lose.
|
|
///
|
|
/// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it —
|
|
/// which matters for exactly the payload this branch sees most, a screenshot of a rounded window.
|
|
public static func pngData(from data: Data) -> Data? {
|
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
|
let image = CGImageSourceCreateImageAtIndex(
|
|
source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary
|
|
)
|
|
else {
|
|
logger.debug("pasteboard bitmap could not be decoded")
|
|
return nil
|
|
}
|
|
let output = NSMutableData()
|
|
guard let destination = CGImageDestinationCreateWithData(
|
|
output, UTType.png.identifier as CFString, 1, nil
|
|
) else { return nil }
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
guard CGImageDestinationFinalize(destination) else {
|
|
logger.debug("pasteboard bitmap could not be re-encoded as PNG")
|
|
return nil
|
|
}
|
|
return output as Data
|
|
}
|
|
|
|
// MARK: Which attachments can be a hero
|
|
|
|
/// Whether an attachment named `name` is one **"Set as Hero" may point at** — an image, by its
|
|
/// name's own extension (05-card-window.md ▸ Attachments; 03-board-ui.md § Card face ▸ Hero
|
|
/// image).
|
|
///
|
|
/// **By extension rather than by opening the file**, deliberately. The context menu is built
|
|
/// while the pointer is going down on a row, and the sidebar may be showing a hundred of them;
|
|
/// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the
|
|
/// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is
|
|
/// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s
|
|
/// structural degrade), and an image the system does not recognize by extension is simply not
|
|
/// offered the row — the `hero` key is still hand-writable, which is what it was born as.
|
|
public static func isImageName(_ name: String) -> Bool {
|
|
let ext = (name as NSString).pathExtension
|
|
guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false }
|
|
return type.conforms(to: .image)
|
|
}
|
|
}
|