diff --git a/demos/clipboard/dub.json b/demos/clipboard/dub.json new file mode 100644 index 0000000..229aefe --- /dev/null +++ b/demos/clipboard/dub.json @@ -0,0 +1,12 @@ +{ + "copyright": "Copyright 2025 Garrett D'Amore", + "description": "Clipboard Demo in Dcell", + "name": "clipboard", + "targetType": "executable", + "targetName": "clipboard", + "targetPath": "bin", + "mainSourceFile": "source/clipboard.d", + "dependencies": { + "dcell": { "path": "../.." } + } +} diff --git a/demos/clipboard/source/clipboard.d b/demos/clipboard/source/clipboard.d new file mode 100644 index 0000000..fefa4b5 --- /dev/null +++ b/demos/clipboard/source/clipboard.d @@ -0,0 +1,150 @@ +/** + * Clipboard demo for Dcell. + * + * Copyright: Copyright 2025 Garrett D'Amore + * Authors: Garrett D'Amore + * License: + * Distributed under the Boost Software License, Version 1.0. + * (See accompanying file LICENSE or https://www.boost.org/LICENSE_1_0.txt) + * SPDX-License-Identifier: BSL-1.0 + */ +module clipboard; + +import std.stdio; +import std.string; +import std.conv; +import std.utf; + +import dcell; + +class Demo +{ + string content; + Screen s; + bool done = false; + + this(Screen scr) @safe + { + s = scr; + } + + void centerStr(int y, Style style, string str) @safe + { + s.style = style; + s.position = Coord((s.size.x - cast(int)(str.length)) / 2, y); + s.write(str); + } + + void display() @safe + { + auto size = s.size(); + Style def; + def.bg = Color.black; + def.fg = Color.white; + s.clear(); + Style style = {fg: Color.cadetBlue, bg: Color.black}; + s.style = style; + centerStr(size.y / 2 - 1, style, "Press 1 to set the clipboard"); + centerStr(size.y / 2 + 1, style, "Press 2 to get the clipboard"); + + auto msg = "No clipboard data"; + if (!content.empty) + { + auto len = content.length; + if (content.length >= 40) + { + msg = format("Clipboard (length %d): %s ...", len, content[0 .. 36]); + } + else + { + msg = format("Clipboard (length %d): %s", len, content); + } + } + s.style = def; + centerStr(size.y / 2 + 3, def, msg); + centerStr(size.y / 2 + 5, def, "Press ESC to Exit."); + + s.show(); + } + + void handleEvent(Event ev) + { + switch (ev.type) + { + case EventType.key: + if (ev.key.key == Key.esc || ev.key.key == Key.f1) + { + done = true; + } + else if (ev.key.key == Key.graph && ev.key.mod == Modifiers.none) + { + switch (ev.key.ch) + { + case '1': + s.setClipboard("Enjoy your new clipboard content!".representation); + break; + case '2': + s.getClipboard(); + break; + default: + } + } + + break; + case EventType.paste: + if (ev.paste.content.length > 0) + { + content = ev.paste.content; + } + else if (ev.paste.binary.length > 0) + { + try + { + auto chars = cast(char[])(ev.paste.binary); + validate(chars); + content = cast(string)(chars.idup); + } + catch (UTFException) + { + content = "Invalid UTF-8"; + } + } + break; + case EventType.resize: + s.resize(); + display(); + s.sync(); + break; + default: + break; + } + } + + void run() + { + s.start(); + scope (exit) + { + s.stop(); + } + + display(); + s.enablePaste(true); + while (!done) + { + s.waitForEvent(); + foreach (ev; s.events()) + { + handleEvent(ev); + } + display(); + } + + } +} + +void main() +{ + auto app = new Demo(newScreen()); + app.run(); +} diff --git a/source/dcell/event.d b/source/dcell/event.d index 13c6ec2..0c60a59 100644 --- a/source/dcell/event.d +++ b/source/dcell/event.d @@ -58,11 +58,12 @@ struct ResizeEvent } /** - * Paste start or stop. + * Paste start or stop. Only one of the content or binary fields will have data. */ struct PasteEvent { - dstring content; + string content; /// string content for normal paste + ubyte[] binary; /// binary data via OSC 52 or similar } /// Focus event. diff --git a/source/dcell/parser.d b/source/dcell/parser.d index 7426cb6..a5ca0cf 100644 --- a/source/dcell/parser.d +++ b/source/dcell/parser.d @@ -14,6 +14,7 @@ module dcell.parser; import core.time; import std.algorithm : max; import std.ascii; +import std.base64; import std.conv : to; import std.process : environment; import std.string; @@ -661,14 +662,18 @@ private: void handleOsc() { - // TODO: OSC 52 is for clipboard - // if content, ok := strings.CutPrefix(str, "52;c;"); ok { - // decoded := make([]byte, base64.StdEncoding.DecodedLen(len(content))) - // if count, err := base64.StdEncoding.Decode(decoded, []byte(content)); err == nil { - // ip.post(NewEventClipboard(decoded[:count])) - // return - // } - // } + if (scratch.startsWith("52;c;")) + { + scratch = scratch["52;c;".length .. $]; + try + { + auto bin = Base64.decode(scratch); + evs ~= newPasteEvent(bin); + } + catch (Base64Exception) // just discard the data if it was malformed + { + } + } // string is located in scratch. parseState = ParseState.ini; @@ -819,7 +824,7 @@ private: { if (pasting) { - evs ~= newPasteEvent(pasteBuf); + evs ~= newPasteEvent(pasteBuf.to!string); pasting = false; pasteBuf = null; } @@ -1207,7 +1212,7 @@ private: return ev; } - Event newPasteEvent(dstring buffer) nothrow @safe + Event newPasteEvent(string buffer) nothrow @safe { Event ev = { type: EventType.paste, when: MonoTime.currTime(), paste: { @@ -1217,6 +1222,16 @@ private: return ev; } + Event newPasteEvent(ubyte[] buffer) nothrow @safe + { + Event ev = { + type: EventType.paste, when: MonoTime.currTime(), paste: { + binary: buffer + } + }; + return ev; + } + unittest { import core.thread; diff --git a/source/dcell/screen.d b/source/dcell/screen.d index ab6b893..cd5ec8f 100644 --- a/source/dcell/screen.d +++ b/source/dcell/screen.d @@ -259,7 +259,31 @@ interface Screen @property Coord position() const @safe; @property Coord position(const(Coord)) @safe; + /** + * Write content to the screen. The content will be displayed using the + * position and the style currently set. It will wrap at the edge of the + * display if it is too long. + */ void write(string) @safe; void write(wstring) @safe; void write(dstring) @safe; + + /** + * Post arbitrary data to the system clipboard. + * It's up to the recipient to decode the data meaningfully. + * Terminals may prevent this for security reasons. + */ + void setClipboard(const(ubyte[])) @safe; + + /** + * Request the clipboard contents. It may be ignored. + * + * If the terminal is willing, it will post the clipboard contents using a + * `PasteEvent` with the clipboard content as the `binary`. (This may or may + * not be valid UTF-8. In most terminals it seems that only string data is posted, + * such as names of files rather than binary file content.) + * + * Terminals may prevent this for security reasons. + */ + void getClipboard() @safe; } diff --git a/source/dcell/vt.d b/source/dcell/vt.d index 85168b9..2fb9314 100644 --- a/source/dcell/vt.d +++ b/source/dcell/vt.d @@ -19,9 +19,10 @@ package: import core.atomic; import core.time; import std.algorithm : canFind; -import std.format; +import std.base64; import std.datetime; import std.exception; +import std.format; import std.outbuffer; import std.process; import std.range; @@ -119,6 +120,13 @@ class VtScreen : Screen // - win32-input-mode (uses CSI _) string enableCsiU = "\x1b[>4;2m" ~ "\x1b[>1u" ~ "\x1b[?9001h"; string disableCsiU = "\x1b[?9001l" ~ "\x1b[4;0m"; + + // OSC 52 is for saving to the clipboard. + // This string takes a base64 string and sends it to the clipboard. + // It will also be able to retrieve the clipboard using "?" as the + // sent string, when we support that. + string setClipboard = "\x1b]52;c;%s\x1b\\"; + // number of colors - again this can be overridden. // Typical values are 0 (monochrome), 8, 16, 256, and 1<<24. // There are some oddballs like xterm-88color. The first @@ -246,6 +254,7 @@ class VtScreen : Screen vt.saveTitle = null; vt.enableCsiU = null; vt.disableCsiU = null; + vt.setClipboard = null; } } @@ -547,6 +556,24 @@ class VtScreen : Screen cells.write(s); } + void setClipboard(const(ubyte[]) b) @safe + { + if (!vt.setClipboard.empty) + { + puts(format(vt.setClipboard, Base64.encode(b))); + flush(); + } + } + + void getClipboard() @safe + { + if (!vt.setClipboard.empty) + { + puts(format(vt.setClipboard, "?")); + flush(); + } + } + private: struct KeyCode {