A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop
⌘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
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user