Skip to content

Commit 77d8a5c

Browse files
authored
specs: design for custom clipboard formats (#17) (#101)
* specs: add design for custom clipboard formats (#17) Documents the MIME-keyed format registry design that supersedes the POC in #43 and subsumes #40 (raw read/write). Maps the abstraction against all open platform/feature issues (#6, #64, #67, #89) and records the orthogonal axes that Format must not absorb. * specs: reflect that #89's tagged watch shipped; pin Watch's custom-format surface #124 landed the variadic Watch(ctx, ...Format) <-chan Data, so the spec's treatment of #89 is now partly shipped rather than fully reserved. Note that the existing variadic Watch already accepts registered tokens (no new watch API needed for custom formats), and pin the no-args semantics: Watch(ctx) watches the built-ins only, so the default stays stable as the registry grows. The remaining reserved half of #89 is enumeration (Formats() []Format).
1 parent cc84b7a commit 77d8a5c

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

specs/custom-clipboard-formats.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Design: Custom clipboard formats
2+
3+
| | |
4+
|---|---|
5+
| **Status** | Proposed |
6+
| **Issue** | [#17](https://github.com/golang-design/clipboard/issues/17) |
7+
| **Supersedes** | POC in [#43](https://github.com/golang-design/clipboard/pull/43) |
8+
| **Also closes** | [#40](https://github.com/golang-design/clipboard/issues/40) (raw read/write) |
9+
10+
## 1. Current state
11+
12+
**Platforms:** macOS (Cgo), Linux/X11 (Cgo), Windows (pure syscall), iOS + Android
13+
(gomobile), and FreeBSD/OpenBSD/NetBSD (added in `aa2c0d4`).
14+
15+
**Formats:** only `FmtText` (UTF-8) and `FmtImage` (PNG). `FmtImage` performs
16+
*conversion* under the hood — DIB↔PNG on Windows, TIFF→PNG on macOS.
17+
18+
**API:** `Init`, `Read(Format) []byte`, `Write(Format, []byte) <-chan struct{}`,
19+
`Watch(ctx, Format) <-chan []byte`, with `type Format int` and consts
20+
`FmtText = 0`, `FmtImage = 1`.
21+
22+
## 2. Why the #43 POC stalled (idea check)
23+
24+
The POC validated the *idea* but its shape isn't shippable:
25+
26+
1. Redefines `Format` from `int``interface{}`, breaking type safety and the
27+
`iota` consts.
28+
2. A custom format is an `unsafe.Pointer(C.NSPasteboardTypePDF)` — this **forces
29+
user code to import Cgo + Cocoa**, which defeats the cross-platform purpose and
30+
collides directly with the purego migration in #83.
31+
3. Only darwin is implemented; Linux / Windows / mobile are untouched.
32+
4. `Handler` carries only `Format() interface{}`; the read/write transforms are an
33+
unfinished `// TODO: generics`.
34+
35+
The POC has served its purpose and should be closed in favour of this design.
36+
37+
## 3. Design principle
38+
39+
> A custom format is a **portable MIME-type string**. The package maps it to each
40+
> platform's native type behind an opaque `Format` token. No platform/Cgo detail
41+
> ever leaks into user code.
42+
43+
MIME is not an arbitrary choice — it is the *native data model* of the platforms we
44+
still want to add (see §6): Wayland advertises `mime_type` strings on
45+
`wl_data_offer`; the Web Clipboard API keys `ClipboardItem` by MIME type. X11
46+
targets are already MIME-shaped (`image/png`), and macOS `UTType` bridges to MIME.
47+
Choosing MIME as the identity makes the abstraction *forward-compatible* with those
48+
backends instead of fighting them.
49+
50+
## 4. Public API
51+
52+
```go
53+
// type Format int stays exactly as-is. FmtText = 0, FmtImage = 1 are unchanged.
54+
55+
// Register maps a MIME type to a Format token usable with Read/Write/Watch.
56+
// Idempotent: Register("text/html") always returns the same token.
57+
// Safe to call before Init and concurrently.
58+
func Register(mime string) Format
59+
60+
// ReadAs decodes a custom format into a typed value. This relocates the
61+
// generic idea from the original issue sketch to where generics actually
62+
// compose — a free helper — instead of a heterogeneous registry that would
63+
// have to box every func([]byte)(T,error) back to `any`.
64+
func ReadAs[T any](f Format, decode func([]byte) (T, error)) (T, error)
65+
```
66+
67+
That's the entire new surface: one function plus one generic helper. `Read`
68+
and `Write` are unchanged and accept the new tokens directly. `Watch` is
69+
already variadic — `Watch(ctx, ...Format) <-chan Data` (shipped in #124 for
70+
#89) — so it accepts registered tokens too and tags every value with the
71+
`Format` it was detected in. Watching a custom format therefore needs **no
72+
new API**: `Watch(ctx, Register("text/html"))` works as soon as `resolve`
73+
(§5) maps the token. `Watch(ctx)` with no formats watches the built-ins
74+
(`FmtText`, `FmtImage`) only; registered formats are watched by passing
75+
their tokens explicitly, so the no-args default stays stable and cheap as
76+
the registry grows (rather than implicitly spinning up a watcher per
77+
ever-registered MIME type).
78+
79+
### Why not the original `Register[T any](fmt, read, write)` sketch?
80+
81+
A package-level generic registry can't store `func([]byte)(T, error)` for varying
82+
`T` without erasing it to `any` — so the generics buy nothing at the registry
83+
level. And `Read(f) []byte` already returns bytes; a *typed* decode can't be looked
84+
up by format at runtime, so it belongs in caller code. `ReadAs[T]` keeps the typed
85+
ergonomics exactly where they're sound.
86+
87+
### Semantics
88+
89+
- **Raw byte passthrough.** Custom formats do **no** conversion (unlike `FmtImage`'s
90+
DIB/PNG/TIFF handling). Bytes in = bytes out. This is precisely what #40 asks
91+
for, so #40 is subsumed: `Register("application/octet-stream")` (or any native
92+
type) gives raw access, and it's also the escape hatch around the #48 Windows
93+
image-conversion bug.
94+
- **Idempotent registration**, guarded by an `RWMutex`-protected registry.
95+
- **nocgo build:** `Register` returns a token and never panics; `Read`/`Write`
96+
degrade exactly like the existing nocgo path (nil / closed channel).
97+
98+
## 5. Per-platform resolution (the load-bearing part)
99+
100+
A Go-level indirection — `resolve(Format) (nativeType, error)` — keeps all platform
101+
specifics out of the public API and out of user code.
102+
103+
| Platform | Native type | MIME → native |
104+
|---|---|---|
105+
| Linux/X11 | target atom (`XInternAtom`) | MIME ≈ atom directly (`text/html`, `application/pdf`) |
106+
| macOS | `NSPasteboardType` (NSString) | any string round-trips with itself; cross-app interop via best-effort MIME→UTI alias table (`text/html`→`public.html`, `application/pdf`→`com.adobe.pdf`) |
107+
| Windows | `CF_*` or `RegisterClipboardFormat` | predefined `CF_*` for known MIME, else register the MIME string as a named format |
108+
109+
**Honest scope:** *self round-trip* (this library writing and reading its own data)
110+
works with any string on every desktop platform. *Cross-application interop* is
111+
best-effort and depends on the alias tables matching what the other app expects. We
112+
should not overclaim portability here.
113+
114+
Because `resolve` is pure Go, it adds **no new Cgo surface** and stays compatible
115+
with the purego migration (#83) — the exact opposite of the #43 POC.
116+
117+
## 6. Is the abstraction wide enough? (all open platform/feature issues)
118+
119+
| Issue | Bucket | How the design relates |
120+
|---|---|---|
121+
| #40 raw r/w | **Subsumed** | Raw passthrough *is* the custom-format path. |
122+
| #6 Wayland | **Fits natively** | Wayland's data model is MIME strings → the registry maps 1:1. New backend, same API. |
123+
| #64 web (wasm) | **Fits natively** | `ClipboardItem` is MIME-keyed → same. (Caveats below.) |
124+
| #67 Linux 2nd selection | **Orthogonal axis** | PRIMARY vs CLIPBOARD is a *selection* axis, not a data-type axis. Must NOT be folded into `Format`. |
125+
| #89 watch-all + type | **Partly shipped** | Tagged multi-format watch shipped in #124: `Watch(ctx, ...Format) <-chan Data` already accepts registered tokens. Remaining: *enumeration* (“what's on the clipboard?”), reserved as `Formats() []Format`. |
126+
| #48 Windows img bug | **Escape hatch** | Raw passthrough lets users bypass the lossy DIB/PNG conversion. |
127+
| #25/#69/#83 remove Cgo | **Unaffected** | `resolve` is pure Go; compatible with purego. |
128+
| #22 throttle reads | **Unaffected** | X11 serving behavior; independent. |
129+
130+
### Axes the design deliberately keeps separate
131+
132+
To stay wide without becoming a combinatorial mess, `Format` is **only** the
133+
data-type axis. Three independent axes are reserved for future work and must not be
134+
overloaded onto `Format`:
135+
136+
1. **Selection** (#67): CLIPBOARD vs PRIMARY (X11), general vs find pasteboard
137+
(macOS). Future: a functional-option parameter on Read/Write/Watch, e.g.
138+
`clipboard.Read(f, clipboard.Primary())` — never a new `Format`.
139+
2. **Enumeration** (#89): discover available formats. The *typed/tagged watch*
140+
half of #89 already shipped (`Watch(ctx, ...Format) <-chan Data`, #124) and
141+
accepts registered tokens directly, so the registry needs no watch-API
142+
changes. What remains reserved is enumeration — `func Formats() []Format`
143+
(“what's on the clipboard right now?”). The MIME registry is what makes this
144+
expressible.
145+
3. **Capability / async** (#64 web, Wayland permissions): the web clipboard is async
146+
and permission-gated, and today's `Read() []byte` swallows errors as `nil`.
147+
`ReadAs[T]` (which returns `error`) is the seam where an error-returning /
148+
capability-aware path can grow without breaking the byte-based core.
149+
150+
## 7. Example
151+
152+
```go
153+
clipboard.Init()
154+
155+
html := clipboard.Register("text/html")
156+
clipboard.Write(html, []byte("<b>hi</b>"))
157+
b := clipboard.Read(html)
158+
159+
// typed decode via the generic helper:
160+
doc, err := clipboard.ReadAs(html, func(b []byte) (*Node, error) {
161+
return parseHTML(b)
162+
})
163+
```
164+
165+
## 8. Scope of the first PR
166+
167+
- `Register`, `ReadAs[T]`, the registry, and `resolve` on all desktop platforms.
168+
- Round-trip tests per platform; doc + README update (incl. the BSD platforms
169+
currently missing from the README).
170+
- Out of scope (reserved, §6.x): selection axis (#67), enumeration (#89's
171+
remaining half — the tagged watch already shipped in #124), Wayland/web
172+
backends (#6/#64) — each lands as its own PR on top of this foundation.

0 commit comments

Comments
 (0)