Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow copying with ANSI escape code control sequences #17059

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/cascadia/profiles.schema.json
Expand Up @@ -851,6 +851,11 @@
"default": false,
"description": "If true, the copied content will be copied as a single line (even if there are hard line breaks present in the text). If false, newlines persist from the selected text."
},
"withControlSequences": {
"type": "boolean",
"default": false,
"description": "If true, copied content will contain ANSI escape code control sequences representing the styling of the content."
},
"dismissSelection": {
"type": "boolean",
"default": true,
Expand Down
444 changes: 250 additions & 194 deletions src/buffer/out/textBuffer.cpp

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/buffer/out/textBuffer.hpp
Expand Up @@ -271,6 +271,8 @@ class TextBuffer final

std::wstring GetPlainText(const CopyRequest& req) const;

std::wstring GetWithControlSequences(const CopyRequest& req) const;

std::string GenHTML(const CopyRequest& req,
const int fontHeightPoints,
const std::wstring_view fontFaceName,
Expand All @@ -285,7 +287,7 @@ class TextBuffer final
const bool isIntenseBold,
std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept;

void Serialize(const wchar_t* destination) const;
void SerializeToPath(const wchar_t* destination) const;

struct PositionInformation
{
Expand Down Expand Up @@ -342,6 +344,8 @@ class TextBuffer final

std::tuple<til::CoordType, til::CoordType, bool> _RowCopyHelper(const CopyRequest& req, const til::CoordType iRow, const ROW& row) const;

void _SerializeRow(const ROW& row, const til::CoordType startX, const til::CoordType endX, const bool addLineBreak, std::wstring& buffer, std::optional<TextAttribute>& previousTextAttr) const;

static void _AppendRTFText(std::string& contentBuilder, const std::wstring_view& text);

Microsoft::Console::Render::Renderer& _renderer;
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalApp/AppActionHandlers.cpp
Expand Up @@ -548,7 +548,7 @@ namespace winrt::TerminalApp::implementation
{
if (const auto& realArgs = args.ActionArgs().try_as<CopyTextArgs>())
{
const auto handled = _CopyText(realArgs.DismissSelection(), realArgs.SingleLine(), realArgs.CopyFormatting());
const auto handled = _CopyText(realArgs.DismissSelection(), realArgs.SingleLine(), realArgs.WithControlSequences(), realArgs.CopyFormatting());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we can add a new CopyFormat, CopyFormat::VT, that would avoid the need for plumbing WithControlSequences down to ControlCore + introducing a new setting. It will just be carried forward with other copy formats.

If the user has "vt" in the copyFormatting list:

"copyFormatting": ["vt"],

we copy the text in the new VT format (including in any other formats set by the user).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add the new format VT = 0x04 here:

enum CopyFormat
{
HTML = 0x1,
RTF = 0x2,
All = 0xffffffff
};

In ControlCore::CopySelectionToClipboard, you can check if we need to copy in VT format like so:

const auto copyVt = WI_IsFlagSet(copyFormats, CopyFormat::VT);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I had the same thought when implementing this but wasn't sure if that's actually what we want here. In particular, currently the formats represent a wholly different clipboard format. That being said singleLine does affect all the formats so it's a bit odd to pass this boolean through to just affect the "plain text" (CF_UNICODETEXT) format, so I'm certainly open to having a "pseudo format" here that modifies the plain text rather than opting into another one! Not totally sure how to make this call haha.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely this isn't plain text, so it should actually be a separate clipboard format? Personally I would expect it to paste as ANSI only if the application can handle that, and the plain text format should still be available for applications that just accept text. Typically it should be the app's decision as to what format they want.

That said, I don't know if there's a generally agreed upon clipboard registration for ANSI text that will work automatically, but worst case I think apps should be able to offer users a choice of the available formats, and let them decide want they want at the time of the paste.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I don't know if there's a generally agreed upon clipboard registration for ANSI text that will work automatically, but worst case I think apps should be able to offer users a choice of the available formats, and let them decide want they want at the time of the paste.

As far as I'm aware there's no such format. It likely wouldn't work with a custom format either. My understanding is that the goal is that you can paste the ANSI text into markdown documents. For instance Discord supports this:
image

This will only work if the ANSI text is in the CF_UNICODETEXT format.

We've briefly talked about this as a team today and we felt like this new format should not be exposed in the settings UI here:
image

Precisely because of the interop issue that James mentioned. Instead it could be triggered with an action via the command palette. That is, we could for instance introduce a new action type for this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In an ideal world, the apps that wanted functionality like this would have got together and decided on a custom clipboard format name, say "ANSIX3.64", which they'd all agree to support. Then when someone like Discord saw that on the clipboard, they could have automatically pasted it surrounded by their ansi markdown wrapper. Having to do that manually is sad. But I guess we have to live with what we've got.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hahaha indeed, I'm the person who hacked in the ansi "syntax highlighting" in Discord 😅

It's interesting (and, to be clear, I'm not sure what the "right" answer is here or if there is one), but in the context of using this in markdown (aka in Discord or in some other markdown as mentioned in #15703), what you're authoring is plaintext. Like, if I copied code from a website and it populated both the unicode and the HTML formats in the clipboard, when I pasted it into my markdown editor I'd personally expect it to paste the plaintext and not try to... paste formatted/wysiwyg contents, or to auto-insert a code block for me. And (again, in my opinion/use cases) similarly here. I would expect this to act less like a seamless parallel copy and more like a different behavior.

introduce a new action type for this

Especially given the above that makes sense! Would that mean something like what's done here, or not piggy backing on the copy action at all and introducing a new one with it's own arguments (if any)?

I really appreciate the discussion here, as well as you taking the time to talk this over with your team! 🙏

Copy link
Collaborator

@j4james j4james Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when I pasted it into my markdown editor I'd personally expect it to paste the plaintext and not try to... paste formatted/wysiwyg contents

This is what the "Paste Special" menu option is for. Have a look at WordPad if you want to see an example of how it's supposed to work. Just type in some text with some formatting and copy it to the clipboard, then press Ctrl+Alt+V. You should see a popup dialog offering you a bunch of choices for how you want the content to paste. If you paste as "Rich Text" it'll keep the formatting. If you paste as "Unformatted Text", you just get the plain text.

If you're responsible for the Discord clipboard code, you should be able to implement the same sort of thing. So by default you can paste as plain text, but still give users the option to paste as ANSI via a "Paste Special" context menu, or shortcut key.

Edit: I should add that I don't feel strongly about this if everyone else disagrees with me, or it's too much effort. It's just that this is the way I would have expected it to work in a perfect world.

args.Handled(handled);
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/cascadia/TerminalApp/TerminalPage.cpp
Expand Up @@ -2856,14 +2856,15 @@ namespace winrt::TerminalApp::implementation
// Arguments:
// - dismissSelection: if not enabled, copying text doesn't dismiss the selection
// - singleLine: if enabled, copy contents as a single line of text
// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
// - formats: dictate which formats need to be copied
// Return Value:
// - true iff we we able to copy text (if a selection was active)
bool TerminalPage::_CopyText(const bool dismissSelection, const bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats)
bool TerminalPage::_CopyText(const bool dismissSelection, const bool singleLine, const bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats)
{
if (const auto& control{ _GetActiveControl() })
{
return control.CopySelectionToClipboard(dismissSelection, singleLine, formats);
return control.CopySelectionToClipboard(dismissSelection, singleLine, withControlSequences, formats);
}
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalApp/TerminalPage.h
Expand Up @@ -401,7 +401,7 @@ namespace winrt::TerminalApp::implementation
bool _IsUriSupported(const winrt::Windows::Foundation::Uri& parsedUri);

void _ShowCouldNotOpenDialog(winrt::hstring reason, winrt::hstring uri);
bool _CopyText(const bool dismissSelection, const bool singleLine, const Windows::Foundation::IReference<Microsoft::Terminal::Control::CopyFormat>& formats);
bool _CopyText(const bool dismissSelection, const bool singleLine, const bool withControlSequences, const Windows::Foundation::IReference<Microsoft::Terminal::Control::CopyFormat>& formats);

winrt::fire_and_forget _SetTaskbarProgressHandler(const IInspectable sender, const IInspectable eventArgs);

Expand Down
6 changes: 4 additions & 2 deletions src/cascadia/TerminalControl/ControlCore.cpp
Expand Up @@ -568,7 +568,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
else if (vkey == VK_RETURN && !mods.IsCtrlPressed() && !mods.IsAltPressed())
{
// [Shift +] Enter --> copy text
CopySelectionToClipboard(mods.IsShiftPressed(), nullptr);
CopySelectionToClipboard(mods.IsShiftPressed(), false, nullptr);
_terminal->ClearSelection();
_updateSelectionUI();
return true;
Expand Down Expand Up @@ -1307,9 +1307,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
// Windows Clipboard (CascadiaWin32:main.cpp).
// Arguments:
// - singleLine: collapse all of the text to one line
// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
// - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
// if we should defer which formats are copied to the global setting
bool ControlCore::CopySelectionToClipboard(bool singleLine,
bool withControlSequences,
const Windows::Foundation::IReference<CopyFormat>& formats)
{
::Microsoft::Terminal::Core::Terminal::TextCopyData payload;
Expand All @@ -1331,7 +1333,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation

// extract text from buffer
// RetrieveSelectedTextFromBuffer will lock while it's reading
payload = _terminal->RetrieveSelectedTextFromBuffer(singleLine, copyHtml, copyRtf);
payload = _terminal->RetrieveSelectedTextFromBuffer(singleLine, withControlSequences, copyHtml, copyRtf);
}

copyToClipboard(payload.plainText, payload.html, payload.rtf);
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalControl/ControlCore.h
Expand Up @@ -121,7 +121,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation

void SendInput(const winrt::hstring& wstr);
void PasteText(const winrt::hstring& hstr);
bool CopySelectionToClipboard(bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats);
bool CopySelectionToClipboard(bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats);
void SelectAll();
void ClearSelection();
bool ToggleBlockSelection();
Expand Down
8 changes: 5 additions & 3 deletions src/cascadia/TerminalControl/ControlInteractivity.cpp
Expand Up @@ -194,9 +194,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
// Windows Clipboard (CascadiaWin32:main.cpp).
// Arguments:
// - singleLine: collapse all of the text to one line
// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
// - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
// if we should defer which formats are copied to the global setting
bool ControlInteractivity::CopySelectionToClipboard(bool singleLine,
bool withControlSequences,
const Windows::Foundation::IReference<CopyFormat>& formats)
{
if (_core)
Expand All @@ -213,7 +215,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
// Mark the current selection as copied
_selectionNeedsToBeCopied = false;

return _core->CopySelectionToClipboard(singleLine, formats);
return _core->CopySelectionToClipboard(singleLine, withControlSequences, formats);
}

return false;
Expand Down Expand Up @@ -312,7 +314,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
else
{
// Try to copy the text and clear the selection
const auto successfulCopy = CopySelectionToClipboard(shiftEnabled, nullptr);
const auto successfulCopy = CopySelectionToClipboard(shiftEnabled, false, nullptr);
_core->ClearSelection();
if (_core->CopyOnSelect() || !successfulCopy)
{
Expand Down Expand Up @@ -441,7 +443,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
// IMPORTANT!
// DO NOT clear the selection here!
// Otherwise, the selection will be cleared immediately after you make it.
CopySelectionToClipboard(false, nullptr);
CopySelectionToClipboard(false, false, nullptr);
}

_singleClickTouchdownPos = std::nullopt;
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalControl/ControlInteractivity.h
Expand Up @@ -83,6 +83,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
#pragma endregion

bool CopySelectionToClipboard(bool singleLine,
bool withControlSequences,
const Windows::Foundation::IReference<CopyFormat>& formats);
void RequestPasteTextFromClipboard();
void SetEndSelectionPoint(const Core::Point pixelPosition);
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalControl/ControlInteractivity.idl
Expand Up @@ -32,7 +32,7 @@ namespace Microsoft.Terminal.Control

InteractivityAutomationPeer OnCreateAutomationPeer();

Boolean CopySelectionToClipboard(Boolean singleLine, Windows.Foundation.IReference<CopyFormat> formats);
Boolean CopySelectionToClipboard(Boolean singleLine, Boolean withControlSequences, Windows.Foundation.IReference<CopyFormat> formats);
void RequestPasteTextFromClipboard();
void SetEndSelectionPoint(Microsoft.Terminal.Core.Point point);

Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalControl/HwndTerminal.cpp
Expand Up @@ -116,7 +116,7 @@ try
const auto lock = publicTerminal->_terminal->LockForWriting();
if (publicTerminal->_terminal->IsSelectionActive())
{
const auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false, true, true);
const auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false, false, true, true);
LOG_IF_FAILED(publicTerminal->_CopyTextToSystemClipboard(bufferData.plainText, bufferData.html, bufferData.rtf));
publicTerminal->_ClearSelection();
return 0;
Expand Down
7 changes: 4 additions & 3 deletions src/cascadia/TerminalControl/TermControl.cpp
Expand Up @@ -2217,16 +2217,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation
// Arguments:
// - dismissSelection: dismiss the text selection after copy
// - singleLine: collapse all of the text to one line
// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
// - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
// if we should defer which formats are copied to the global setting
bool TermControl::CopySelectionToClipboard(bool dismissSelection, bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats)
bool TermControl::CopySelectionToClipboard(bool dismissSelection, bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats)
{
if (_IsClosing())
{
return false;
}

const auto successfulCopy = _interactivity.CopySelectionToClipboard(singleLine, formats);
const auto successfulCopy = _interactivity.CopySelectionToClipboard(singleLine, withControlSequences, formats);

if (dismissSelection)
{
Expand Down Expand Up @@ -3697,7 +3698,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
const IInspectable& /*args*/)
{
// formats = nullptr -> copy all formats
_interactivity.CopySelectionToClipboard(false, nullptr);
_interactivity.CopySelectionToClipboard(false, false, nullptr);
ContextMenu().Hide();
SelectionContextMenu().Hide();
}
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalControl/TermControl.h
Expand Up @@ -38,7 +38,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation

hstring GetProfileName() const;

bool CopySelectionToClipboard(bool dismissSelection, bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats);
bool CopySelectionToClipboard(bool dismissSelection, bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats);
void PasteTextFromClipboard();
void SelectAll();
bool ToggleBlockSelection();
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalControl/TermControl.idl
Expand Up @@ -84,7 +84,7 @@ namespace Microsoft.Terminal.Control
event Windows.Foundation.TypedEventHandler<Object, Object> CloseTerminalRequested;
event Windows.Foundation.TypedEventHandler<Object, Object> RestartTerminalRequested;

Boolean CopySelectionToClipboard(Boolean dismissSelection, Boolean singleLine, Windows.Foundation.IReference<CopyFormat> formats);
Boolean CopySelectionToClipboard(Boolean dismissSelection, Boolean singleLine, Boolean withControlSequences, Windows.Foundation.IReference<CopyFormat> formats);
void PasteTextFromClipboard();
void SelectAll();
Boolean ToggleBlockSelection();
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalCore/Terminal.cpp
Expand Up @@ -1539,7 +1539,7 @@ std::wstring Terminal::CurrentCommand() const

void Terminal::SerializeMainBuffer(const wchar_t* destination) const
{
_mainBuffer->Serialize(destination);
_mainBuffer->SerializeToPath(destination);
}

void Terminal::ColorSelection(const TextAttribute& attr, winrt::Microsoft::Terminal::Core::MatchMode matchMode)
Expand Down
2 changes: 1 addition & 1 deletion src/cascadia/TerminalCore/Terminal.hpp
Expand Up @@ -313,7 +313,7 @@ class Microsoft::Terminal::Core::Terminal final :
til::point SelectionEndForRendering() const;
const SelectionEndpoint SelectionEndpointTarget() const noexcept;

TextCopyData RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html = false, const bool rtf = false) const;
TextCopyData RetrieveSelectedTextFromBuffer(const bool singleLine, const bool withControlSequences = false, const bool html = false, const bool rtf = false) const;
#pragma endregion

#ifndef NDEBUG
Expand Down
12 changes: 10 additions & 2 deletions src/cascadia/TerminalCore/TerminalSelection.cpp
Expand Up @@ -871,12 +871,13 @@ void Terminal::ClearSelection()
// - Optionally, get the highlighted text in HTML and RTF formats
// Arguments:
// - singleLine: collapse all of the text to one line. (Turns off trailing whitespace trimming)
// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
// - html: also get text in HTML format
// - rtf: also get text in RTF format
// Return Value:
// - Plain and formatted selected text from buffer. Empty string represents no data for that format.
// - If extended to multiple lines, each line is separated by \r\n
Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html, const bool rtf) const
Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singleLine, const bool withControlSequences, const bool html, const bool rtf) const
{
TextCopyData data;

Expand All @@ -894,7 +895,14 @@ Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singl
const auto& textBuffer = _activeBuffer();

const auto req = TextBuffer::CopyRequest::FromConfig(textBuffer, _selection->start, _selection->end, singleLine, _blockSelection, _trimBlockSelection);
data.plainText = textBuffer.GetPlainText(req);
if (withControlSequences)
{
data.plainText = textBuffer.GetWithControlSequences(req);
}
else
{
data.plainText = textBuffer.GetPlainText(req);
}

if (html || rtf)
{
Expand Down
5 changes: 5 additions & 0 deletions src/cascadia/TerminalSettingsModel/ActionArgs.cpp
Expand Up @@ -206,6 +206,11 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
ss << RS_(L"CopyTextCommandKey").c_str();
}

if (WithControlSequences())
{
ss << L", withControlSequences: true";
}

Comment on lines +209 to +213
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if I should have actual, translated special case names pulled from the resources when this is set (eg Copy with control sequences and Copy single line with control sequences). Happy to make that change if it's preferred!

if (!DismissSelection())
{
ss << L", dismissSelection: false";
Expand Down
7 changes: 4 additions & 3 deletions src/cascadia/TerminalSettingsModel/ActionArgs.h
Expand Up @@ -102,9 +102,10 @@ protected: \
// false, if we don't really care if the parameter is required or not.

////////////////////////////////////////////////////////////////////////////////
#define COPY_TEXT_ARGS(X) \
X(bool, DismissSelection, "dismissSelection", false, true) \
X(bool, SingleLine, "singleLine", false, false) \
#define COPY_TEXT_ARGS(X) \
X(bool, DismissSelection, "dismissSelection", false, true) \
X(bool, SingleLine, "singleLine", false, false) \
X(bool, WithControlSequences, "withControlSequences", false, false) \
X(Windows::Foundation::IReference<Control::CopyFormat>, CopyFormatting, "copyFormatting", false, nullptr)

////////////////////////////////////////////////////////////////////////////////
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalSettingsModel/ActionArgs.idl
Expand Up @@ -179,6 +179,7 @@ namespace Microsoft.Terminal.Settings.Model
CopyTextArgs();
Boolean DismissSelection { get; };
Boolean SingleLine { get; };
Boolean WithControlSequences { get; };
Windows.Foundation.IReference<Microsoft.Terminal.Control.CopyFormat> CopyFormatting { get; };
};

Expand Down