diff --git a/.github/actions/spell-check/dictionary/apis.txt b/.github/actions/spell-check/dictionary/apis.txt index 96d302a13c3..6e450ee12bb 100644 --- a/.github/actions/spell-check/dictionary/apis.txt +++ b/.github/actions/spell-check/dictionary/apis.txt @@ -50,5 +50,6 @@ syscall tmp tx userenv +wcstoui XDocument XElement diff --git a/.github/actions/spell-check/dictionary/dictionary.txt b/.github/actions/spell-check/dictionary/dictionary.txt index e1b7da0f86a..7344a626ea2 100644 --- a/.github/actions/spell-check/dictionary/dictionary.txt +++ b/.github/actions/spell-check/dictionary/dictionary.txt @@ -186988,6 +186988,7 @@ hyperleucocytotic hyperleukocytosis hyperlexis hyperlink +hyperlinks hyperlinking hyperlipaemia hyperlipaemic diff --git a/src/buffer/out/AttrRow.cpp b/src/buffer/out/AttrRow.cpp index 5df4da3978d..ffd95ad3f09 100644 --- a/src/buffer/out/AttrRow.cpp +++ b/src/buffer/out/AttrRow.cpp @@ -175,6 +175,23 @@ size_t ATTR_ROW::FindAttrIndex(const size_t index, size_t* const pApplies) const return runPos - _list.cbegin(); } +// Routine Description: +// - Finds the hyperlink IDs present in this row and returns them +// Return value: +// - An unordered set containing the hyperlink IDs present in this row +std::unordered_set ATTR_ROW::GetHyperlinks() +{ + std::unordered_set ids; + for (const auto& run : _list) + { + if (run.GetAttributes().IsHyperlink()) + { + ids.emplace(run.GetAttributes().GetHyperlinkId()); + } + } + return ids; +} + // Routine Description: // - Sets the attributes (colors) of all character positions from the given position through the end of the row. // Arguments: diff --git a/src/buffer/out/AttrRow.hpp b/src/buffer/out/AttrRow.hpp index 2f0a07794c3..a0802a74591 100644 --- a/src/buffer/out/AttrRow.hpp +++ b/src/buffer/out/AttrRow.hpp @@ -41,6 +41,8 @@ class ATTR_ROW final size_t FindAttrIndex(const size_t index, size_t* const pApplies) const; + std::unordered_set GetHyperlinks(); + bool SetAttrToEnd(const UINT iStart, const TextAttribute attr); void ReplaceAttrs(const TextAttribute& toBeReplacedAttr, const TextAttribute& replaceWith) noexcept; diff --git a/src/buffer/out/TextAttribute.cpp b/src/buffer/out/TextAttribute.cpp index 06c94184432..8053ece1352 100644 --- a/src/buffer/out/TextAttribute.cpp +++ b/src/buffer/out/TextAttribute.cpp @@ -112,6 +112,17 @@ std::pair TextAttribute::CalculateRgbColors(const gsl::span< return { fg, bg }; } +// Method description: +// - Tells us whether the text is a hyperlink or not +// Return value: +// - True if it is a hyperlink, false otherwise +bool TextAttribute::IsHyperlink() const noexcept +{ + // All non-hyperlink text have a default hyperlinkId of 0 while + // all hyperlink text have a non-zero hyperlinkId + return _hyperlinkId != 0; +} + TextColor TextAttribute::GetForeground() const noexcept { return _foreground; @@ -122,6 +133,15 @@ TextColor TextAttribute::GetBackground() const noexcept return _background; } +// Method description: +// - Retrieves the hyperlink ID of the text +// Return value: +// - The hyperlink ID +uint16_t TextAttribute::GetHyperlinkId() const noexcept +{ + return _hyperlinkId; +} + void TextAttribute::SetForeground(const TextColor foreground) noexcept { _foreground = foreground; @@ -174,6 +194,15 @@ void TextAttribute::SetColor(const COLORREF rgbColor, const bool fIsForeground) } } +// Method description: +// - Sets the hyperlink ID of the text +// Arguments: +// - id - the id we wish to set +void TextAttribute::SetHyperlinkId(uint16_t id) noexcept +{ + _hyperlinkId = id; +} + bool TextAttribute::IsLeadingByte() const noexcept { return WI_IsFlagSet(_wAttrLegacy, COMMON_LVB_LEADING_BYTE); @@ -336,6 +365,14 @@ void TextAttribute::SetDefaultBackground() noexcept _background = TextColor(); } +// Method description: +// - Resets only the meta and extended attributes +void TextAttribute::SetDefaultMetaAttrs() noexcept +{ + _extendedAttrs = ExtendedAttributes::Normal; + _wAttrLegacy = 0; +} + // Method Description: // - Returns true if this attribute indicates its background is the "default" // background. Its _rgbBackground will contain the actual value of the @@ -356,6 +393,6 @@ bool TextAttribute::BackgroundIsDefault() const noexcept // requires for most erasing and filling operations. void TextAttribute::SetStandardErase() noexcept { - _extendedAttrs = ExtendedAttributes::Normal; - _wAttrLegacy = 0; + SetDefaultMetaAttrs(); + _hyperlinkId = 0; } diff --git a/src/buffer/out/TextAttribute.hpp b/src/buffer/out/TextAttribute.hpp index 22c61a6aa91..b3d28f443fb 100644 --- a/src/buffer/out/TextAttribute.hpp +++ b/src/buffer/out/TextAttribute.hpp @@ -36,7 +36,8 @@ class TextAttribute final _wAttrLegacy{ 0 }, _foreground{}, _background{}, - _extendedAttrs{ ExtendedAttributes::Normal } + _extendedAttrs{ ExtendedAttributes::Normal }, + _hyperlinkId{ 0 } { } @@ -44,7 +45,8 @@ class TextAttribute final _wAttrLegacy{ gsl::narrow_cast(wLegacyAttr & META_ATTRS) }, _foreground{ s_LegacyIndexOrDefault(wLegacyAttr & FG_ATTRS, s_legacyDefaultForeground) }, _background{ s_LegacyIndexOrDefault((wLegacyAttr & BG_ATTRS) >> 4, s_legacyDefaultBackground) }, - _extendedAttrs{ ExtendedAttributes::Normal } + _extendedAttrs{ ExtendedAttributes::Normal }, + _hyperlinkId{ 0 } { // If we're given lead/trailing byte information with the legacy color, strip it. WI_ClearAllFlags(_wAttrLegacy, COMMON_LVB_SBCSDBCS); @@ -55,7 +57,8 @@ class TextAttribute final _wAttrLegacy{ 0 }, _foreground{ rgbForeground }, _background{ rgbBackground }, - _extendedAttrs{ ExtendedAttributes::Normal } + _extendedAttrs{ ExtendedAttributes::Normal }, + _hyperlinkId{ 0 } { } @@ -112,8 +115,11 @@ class TextAttribute final ExtendedAttributes GetExtendedAttributes() const noexcept; + bool IsHyperlink() const noexcept; + TextColor GetForeground() const noexcept; TextColor GetBackground() const noexcept; + uint16_t GetHyperlinkId() const noexcept; void SetForeground(const TextColor foreground) noexcept; void SetBackground(const TextColor background) noexcept; void SetForeground(const COLORREF rgbForeground) noexcept; @@ -123,9 +129,11 @@ class TextAttribute final void SetIndexedForeground256(const BYTE fgIndex) noexcept; void SetIndexedBackground256(const BYTE bgIndex) noexcept; void SetColor(const COLORREF rgbColor, const bool fIsForeground) noexcept; + void SetHyperlinkId(uint16_t id) noexcept; void SetDefaultForeground() noexcept; void SetDefaultBackground() noexcept; + void SetDefaultMetaAttrs() noexcept; bool BackgroundIsDefault() const noexcept; @@ -147,7 +155,8 @@ class TextAttribute final (_wAttrLegacy & META_ATTRS) == (other._wAttrLegacy & META_ATTRS) && ((checkForeground && _foreground == other._foreground) || (!checkForeground && _background == other._background)) && - _extendedAttrs == other._extendedAttrs; + _extendedAttrs == other._extendedAttrs && + IsHyperlink() == other.IsHyperlink(); } constexpr bool IsAnyGridLineEnabled() const noexcept @@ -169,6 +178,8 @@ class TextAttribute final TextColor _background; ExtendedAttributes _extendedAttrs; + uint16_t _hyperlinkId; + #ifdef UNIT_TESTING friend class TextBufferTests; friend class TextAttributeTests; @@ -182,7 +193,7 @@ class TextAttribute final // 4 for _foreground // 4 for _background // 1 for _extendedAttrs -static_assert(sizeof(TextAttribute) <= 11 * sizeof(BYTE), "We should only need 11B for an entire TextColor. Any more than that is just waste"); +static_assert(sizeof(TextAttribute) <= 13 * sizeof(BYTE), "We should only need 13B for an entire TextAttribute. We may need to increment this in the future as we add additional attributes"); enum class TextAttributeBehavior { @@ -196,7 +207,8 @@ constexpr bool operator==(const TextAttribute& a, const TextAttribute& b) noexce return a._wAttrLegacy == b._wAttrLegacy && a._foreground == b._foreground && a._background == b._background && - a._extendedAttrs == b._extendedAttrs; + a._extendedAttrs == b._extendedAttrs && + a._hyperlinkId == b._hyperlinkId; } constexpr bool operator!=(const TextAttribute& a, const TextAttribute& b) noexcept diff --git a/src/buffer/out/textBuffer.cpp b/src/buffer/out/textBuffer.cpp index 6ecb989f2f0..294813a3f55 100644 --- a/src/buffer/out/textBuffer.cpp +++ b/src/buffer/out/textBuffer.cpp @@ -34,7 +34,8 @@ TextBuffer::TextBuffer(const COORD screenBufferSize, _storage{}, _unicodeStorage{}, _renderTarget{ renderTarget }, - _size{} + _size{}, + _currentHyperlinkId{ 1 } { // initialize ROWs for (size_t i = 0; i < static_cast(screenBufferSize.Y); ++i) @@ -551,7 +552,10 @@ bool TextBuffer::IncrementCircularBuffer(const bool inVtMode) // to the logical position 0 in the window (cursor coordinates and all other coordinates). _renderTarget.TriggerCircling(); - // First, clean out the old "first row" as it will become the "last row" of the buffer after the circle is performed. + // Prune hyperlinks to delete obsolete references + _PruneHyperlinks(); + + // Second, clean out the old "first row" as it will become the "last row" of the buffer after the circle is performed. auto fillAttributes = _currentAttributes; if (inVtMode) { @@ -1185,6 +1189,46 @@ const COORD TextBuffer::_GetWordEndForSelection(const COORD target, const std::w return result; } +void TextBuffer::_PruneHyperlinks() +{ + // Check the old first row for hyperlink references + // If there are any, search the entire buffer for the same reference + // If the buffer does not contain the same reference, we can remove that hyperlink from our map + // This way, obsolete hyperlink references are cleared from our hyperlink map instead of hanging around + // Get all the hyperlink references in the row we're erasing + auto firstRowRefs = _storage.at(_firstRow).GetAttrRow().GetHyperlinks(); + if (!firstRowRefs.empty()) + { + const auto total = TotalRowCount(); + // Loop through all the rows in the buffer except the first row - + // we have found all hyperlink references in the first row and put them in refs, + // now we need to search the rest of the buffer (i.e. all the rows except the first) + // to see if those references are anywhere else + for (size_t i = 1; i != total; ++i) + { + const auto nextRowRefs = GetRowByOffset(i).GetAttrRow().GetHyperlinks(); + for (auto id : nextRowRefs) + { + if (firstRowRefs.find(id) != firstRowRefs.end()) + { + firstRowRefs.erase(id); + } + } + if (firstRowRefs.empty()) + { + // No more hyperlink references left to search for, terminate early + break; + } + } + } + + // Now delete obsolete references from our map + for (auto hyperlinkReference : firstRowRefs) + { + RemoveHyperlinkFromMap(hyperlinkReference); + } +} + // Method Description: // - Update pos to be the position of the first character of the next word. This is used for accessibility // Arguments: @@ -2142,6 +2186,7 @@ HRESULT TextBuffer::Reflow(TextBuffer& oldBuffer, { // Finish copying remaining parameters from the old text buffer to the new one newBuffer.CopyProperties(oldBuffer); + newBuffer.CopyHyperlinkMaps(oldBuffer); // If we found where to put the cursor while placing characters into the buffer, // just put the cursor there. Otherwise we have to advance manually. @@ -2207,3 +2252,104 @@ HRESULT TextBuffer::Reflow(TextBuffer& oldBuffer, return hr; } + +// Method Description: +// - Adds or updates a hyperlink in our hyperlink table +// Arguments: +// - The hyperlink URI, the hyperlink id (could be new or old) +void TextBuffer::AddHyperlinkToMap(std::wstring_view uri, uint16_t id) +{ + _hyperlinkMap[id] = uri; +} + +// Method Description: +// - Retrieves the URI associated with a particular hyperlink ID +// Arguments: +// - The hyperlink ID +// Return Value: +// - The URI +std::wstring TextBuffer::GetHyperlinkUriFromId(uint16_t id) const +{ + return _hyperlinkMap.at(id); +} + +// Method description: +// - Provides the hyperlink ID to be assigned as a text attribute, based on the optional custom id provided +// Arguments: +// - The user-defined id +// Return value: +// - The internal hyperlink ID +uint16_t TextBuffer::GetHyperlinkId(std::wstring_view params) +{ + uint16_t id = 0; + if (params.empty()) + { + // no custom id specified, return our internal count + id = _currentHyperlinkId; + ++_currentHyperlinkId; + } + else + { + // assign _currentHyperlinkId if the custom id does not already exist + const auto result = _hyperlinkCustomIdMap.emplace(params, _currentHyperlinkId); + if (result.second) + { + // the custom id did not already exist + ++_currentHyperlinkId; + } + id = (*(result.first)).second; + } + // _currentHyperlinkId could overflow, make sure its not 0 + if (_currentHyperlinkId == 0) + { + ++_currentHyperlinkId; + } + return id; +} + +// Method Description: +// - Removes a hyperlink from the hyperlink map and the associated +// user defined id from the custom id map (if there is one) +// Arguments: +// - The ID of the hyperlink to be removed +void TextBuffer::RemoveHyperlinkFromMap(uint16_t id) +{ + _hyperlinkMap.erase(id); + for (const auto& customIdPair : _hyperlinkCustomIdMap) + { + if (customIdPair.second == id) + { + _hyperlinkCustomIdMap.erase(customIdPair.first); + break; + } + } +} + +// Method Description: +// - Obtains the custom ID, if there was one, associated with the +// uint16_t id of a hyperlink +// Arguments: +// - The uint16_t id of the hyperlink +// Return Value: +// - The custom ID if there was one, empty string otherwise +std::wstring TextBuffer::GetCustomIdFromId(uint16_t id) const +{ + for (auto customIdPair : _hyperlinkCustomIdMap) + { + if (customIdPair.second == id) + { + return customIdPair.first; + } + } + return {}; +} + +// Method Description: +// - Copies the hyperlink/customID maps of the old buffer into this one +// Arguments: +// - The other buffer +void TextBuffer::CopyHyperlinkMaps(const TextBuffer& other) +{ + _hyperlinkMap = other._hyperlinkMap; + _hyperlinkCustomIdMap = other._hyperlinkCustomIdMap; +} diff --git a/src/buffer/out/textBuffer.hpp b/src/buffer/out/textBuffer.hpp index 9ed2ce8b1af..56a3c59f3b0 100644 --- a/src/buffer/out/textBuffer.hpp +++ b/src/buffer/out/textBuffer.hpp @@ -141,6 +141,13 @@ class TextBuffer final const std::vector GetTextRects(COORD start, COORD end, bool blockSelection = false) const; + void AddHyperlinkToMap(std::wstring_view uri, uint16_t id); + std::wstring GetHyperlinkUriFromId(uint16_t id) const; + uint16_t GetHyperlinkId(std::wstring_view params); + void RemoveHyperlinkFromMap(uint16_t id); + std::wstring GetCustomIdFromId(uint16_t id) const; + void CopyHyperlinkMaps(const TextBuffer& OtherBuffer); + class TextAndColor { public: @@ -188,6 +195,10 @@ class TextBuffer final // storage location for glyphs that can't fit into the buffer normally UnicodeStorage _unicodeStorage; + std::unordered_map _hyperlinkMap; + std::unordered_map _hyperlinkCustomIdMap; + uint16_t _currentHyperlinkId; + void _RefreshRowIDs(std::optional newRowWidth); Microsoft::Console::Render::IRenderTarget& _renderTarget; @@ -216,6 +227,8 @@ class TextBuffer final const COORD _GetWordEndForAccessibility(const COORD target, const std::wstring_view wordDelimiters) const; const COORD _GetWordEndForSelection(const COORD target, const std::wstring_view wordDelimiters) const; + void _PruneHyperlinks(); + #ifdef UNIT_TESTING friend class TextBufferTests; friend class UiaTextRangeTests; diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 0023ccd285f..ceb86a52481 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1136,6 +1136,8 @@ namespace winrt::TerminalApp::implementation // Add an event handler when the terminal wants to paste data from the Clipboard. term.PasteFromClipboard({ this, &TerminalPage::_PasteFromClipboardHandler }); + term.OpenHyperlink({ this, &TerminalPage::_OpenHyperlinkHandler }); + // Bind Tab events to the TermControl and the Tab's Pane hostingTab.Initialize(term); @@ -1794,6 +1796,19 @@ namespace winrt::TerminalApp::implementation CATCH_LOG(); } + void TerminalPage::_OpenHyperlinkHandler(const IInspectable /*sender*/, const Microsoft::Terminal::TerminalControl::OpenHyperlinkEventArgs eventArgs) + { + try + { + auto parsed = winrt::Windows::Foundation::Uri(eventArgs.Uri().c_str()); + if (parsed.SchemeName() == L"http" || parsed.SchemeName() == L"https") + { + ShellExecute(nullptr, L"open", eventArgs.Uri().c_str(), nullptr, nullptr, SW_SHOWNORMAL); + } + } + CATCH_LOG(); + } + // Method Description: // - Copy text from the focused terminal to the Windows Clipboard // Arguments: diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 00f18a4cc1c..ac68997afae 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -171,7 +171,10 @@ namespace winrt::TerminalApp::implementation winrt::fire_and_forget _CopyToClipboardHandler(const IInspectable sender, const winrt::Microsoft::Terminal::TerminalControl::CopyToClipboardEventArgs copiedData); winrt::fire_and_forget _PasteFromClipboardHandler(const IInspectable sender, const Microsoft::Terminal::TerminalControl::PasteFromClipboardEventArgs eventArgs); + + void _OpenHyperlinkHandler(const IInspectable sender, const Microsoft::Terminal::TerminalControl::OpenHyperlinkEventArgs eventArgs); bool _CopyText(const bool singleLine, const Windows::Foundation::IReference& formats); + void _PasteText(); fire_and_forget _LaunchSettings(const winrt::TerminalApp::SettingsTarget target); diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 6c86ac93e36..e1dc47f6554 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -1083,6 +1083,7 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation // macro directly with a VirtualKeyModifiers const auto altEnabled = WI_IsFlagSet(modifiers, static_cast(VirtualKeyModifiers::Menu)); const auto shiftEnabled = WI_IsFlagSet(modifiers, static_cast(VirtualKeyModifiers::Shift)); + const auto ctrlEnabled = WI_IsFlagSet(modifiers, static_cast(VirtualKeyModifiers::Control)); if (_CanSendVTMouseInput()) { @@ -1123,7 +1124,12 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation } // Update the selection appropriately - if (shiftEnabled && _terminal->IsSelectionActive()) + if (ctrlEnabled && multiClickMapper == 1 && + !(_terminal->GetHyperlinkAtPosition(terminalPosition).empty())) + { + _HyperlinkHandler(_terminal->GetHyperlinkAtPosition(terminalPosition)); + } + else if (shiftEnabled && _terminal->IsSelectionActive()) { // Shift+Click: only set expand on the "end" selection point _terminal->SetSelectionEnd(terminalPosition, mode); @@ -2839,6 +2845,16 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation e.DragUIOverride().IsGlyphVisible(false); } + // Method description: + // - Checks if the uri is valid and sends an event if so + // Arguments: + // - The uri + void TermControl::_HyperlinkHandler(const std::wstring_view uri) + { + auto hyperlinkArgs = winrt::make_self(winrt::hstring{ uri }); + _openHyperlinkHandlers(*this, *hyperlinkArgs); + } + // Method Description: // - Produces the error dialog that notifies the user that rendering cannot proceed. winrt::fire_and_forget TermControl::_RendererEnteredErrorState() @@ -2884,5 +2900,6 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation DEFINE_EVENT_WITH_TYPED_EVENT_HANDLER(TermControl, PasteFromClipboard, _clipboardPasteHandlers, TerminalControl::TermControl, TerminalControl::PasteFromClipboardEventArgs); DEFINE_EVENT_WITH_TYPED_EVENT_HANDLER(TermControl, CopyToClipboard, _clipboardCopyHandlers, TerminalControl::TermControl, TerminalControl::CopyToClipboardEventArgs); + DEFINE_EVENT_WITH_TYPED_EVENT_HANDLER(TermControl, OpenHyperlink, _openHyperlinkHandlers, TerminalControl::TermControl, TerminalControl::OpenHyperlinkEventArgs); // clang-format on } diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index 2f9de1d7f07..2e3160958b4 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -6,6 +6,7 @@ #include "TermControl.g.h" #include "CopyToClipboardEventArgs.g.h" #include "PasteFromClipboardEventArgs.g.h" +#include "OpenHyperlinkEventArgs.g.h" #include #include "../../renderer/base/Renderer.hpp" #include "../../renderer/dx/DxRenderer.hpp" @@ -67,6 +68,19 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation std::function m_clipboardDataHandler; }; + struct OpenHyperlinkEventArgs : + public OpenHyperlinkEventArgsT + { + public: + OpenHyperlinkEventArgs(hstring uri) : + _uri(uri) {} + + hstring Uri() { return _uri; }; + + private: + hstring _uri; + }; + struct TermControl : TermControlT { TermControl(IControlSettings settings, TerminalConnection::ITerminalConnection connection); @@ -133,6 +147,7 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation DECLARE_EVENT_WITH_TYPED_EVENT_HANDLER(PasteFromClipboard, _clipboardPasteHandlers, TerminalControl::TermControl, TerminalControl::PasteFromClipboardEventArgs); DECLARE_EVENT_WITH_TYPED_EVENT_HANDLER(CopyToClipboard, _clipboardCopyHandlers, TerminalControl::TermControl, TerminalControl::CopyToClipboardEventArgs); + DECLARE_EVENT_WITH_TYPED_EVENT_HANDLER(OpenHyperlink, _openHyperlinkHandlers, TerminalControl::TermControl, TerminalControl::OpenHyperlinkEventArgs); TYPED_EVENT(ConnectionStateChanged, TerminalControl::TermControl, IInspectable); TYPED_EVENT(Initialized, TerminalControl::TermControl, Windows::UI::Xaml::RoutedEventArgs); @@ -228,6 +243,7 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation void _LostFocusHandler(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::RoutedEventArgs const& e); winrt::fire_and_forget _DragDropHandler(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::DragEventArgs const e); void _DragOverHandler(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::DragEventArgs const& e); + void _HyperlinkHandler(const std::wstring_view uri); void _CursorTimerTick(Windows::Foundation::IInspectable const& sender, Windows::Foundation::IInspectable const& e); void _SetEndSelectionPointAtCursor(Windows::Foundation::Point const& cursorPosition); diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl index e7f9c958802..8c570838b82 100644 --- a/src/cascadia/TerminalControl/TermControl.idl +++ b/src/cascadia/TerminalControl/TermControl.idl @@ -40,6 +40,11 @@ namespace Microsoft.Terminal.TerminalControl void HandleClipboardData(String data); } + runtimeclass OpenHyperlinkEventArgs + { + String Uri { get; }; + } + [default_interface] runtimeclass TermControl : Windows.UI.Xaml.Controls.UserControl, IDirectKeyListener, IMouseWheelListener { TermControl(Microsoft.Terminal.TerminalControl.IControlSettings settings, Microsoft.Terminal.TerminalConnection.ITerminalConnection connection); @@ -54,6 +59,7 @@ namespace Microsoft.Terminal.TerminalControl event FontSizeChangedEventArgs FontSizeChanged; event Windows.Foundation.TypedEventHandler CopyToClipboard; event Windows.Foundation.TypedEventHandler PasteFromClipboard; + event Windows.Foundation.TypedEventHandler OpenHyperlink; event Windows.Foundation.TypedEventHandler Initialized; // This is an event handler forwarder for the underlying connection. diff --git a/src/cascadia/TerminalCore/ITerminalApi.hpp b/src/cascadia/TerminalCore/ITerminalApi.hpp index 98945de1cfe..9661a2db51d 100644 --- a/src/cascadia/TerminalCore/ITerminalApi.hpp +++ b/src/cascadia/TerminalCore/ITerminalApi.hpp @@ -59,6 +59,9 @@ namespace Microsoft::Terminal::Core virtual bool CopyToClipboard(std::wstring_view content) noexcept = 0; + virtual bool AddHyperlink(std::wstring_view uri, std::wstring_view params) noexcept = 0; + virtual bool EndHyperlink() noexcept = 0; + protected: ITerminalApi() = default; }; diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index 55d238d401b..03afa6c0084 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -406,6 +406,21 @@ bool Terminal::IsTrackingMouseInput() const noexcept return _terminalInput->IsTrackingMouseInput(); } +// Method Description: +// - If the clicked text is a hyperlink, open it +// Arguments: +// - The position of the clicked text +std::wstring Terminal::GetHyperlinkAtPosition(const COORD position) +{ + auto attr = _buffer->GetCellDataAt(_ConvertToBufferCell(position))->TextAttr(); + if (attr.IsHyperlink()) + { + auto uri = _buffer->GetHyperlinkUriFromId(attr.GetHyperlinkId()); + return uri; + } + return {}; +} + // Method Description: // - Send this particular (non-character) key event to the terminal. // - The terminal will translate the key and the modifiers pressed into the diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index df70bc0184c..6b0eea1eba4 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -111,6 +111,9 @@ class Microsoft::Terminal::Core::Terminal final : bool IsVtInputEnabled() const noexcept override; bool CopyToClipboard(std::wstring_view content) noexcept override; + + bool AddHyperlink(std::wstring_view uri, std::wstring_view params) noexcept override; + bool EndHyperlink() noexcept override; #pragma endregion #pragma region ITerminalInput @@ -125,6 +128,8 @@ class Microsoft::Terminal::Core::Terminal final : void TrySnapOnInput() override; bool IsTrackingMouseInput() const noexcept; + + std::wstring GetHyperlinkAtPosition(const COORD position); #pragma endregion #pragma region IBaseData(base to IRenderData and IUiaData) @@ -152,6 +157,8 @@ class Microsoft::Terminal::Core::Terminal final : bool IsScreenReversed() const noexcept override; const std::vector GetOverlays() const noexcept override; const bool IsGridLineDrawingAllowed() noexcept override; + const std::wstring GetHyperlinkUri(uint16_t id) const noexcept override; + const std::wstring GetHyperlinkCustomId(uint16_t id) const noexcept override; #pragma endregion #pragma region IUiaData diff --git a/src/cascadia/TerminalCore/TerminalApi.cpp b/src/cascadia/TerminalCore/TerminalApi.cpp index 1e27a59702c..92149471f69 100644 --- a/src/cascadia/TerminalCore/TerminalApi.cpp +++ b/src/cascadia/TerminalCore/TerminalApi.cpp @@ -560,3 +560,32 @@ try return true; } CATCH_LOG_RETURN_FALSE() + +// Method Description: +// - Updates the buffer's current text attributes to start a hyperlink +// Arguments: +// - The hyperlink URI +// - The customID provided (if there was one) +// Return Value: +// - true +bool Terminal::AddHyperlink(std::wstring_view uri, std::wstring_view params) noexcept +{ + auto attr = _buffer->GetCurrentAttributes(); + const auto id = _buffer->GetHyperlinkId(params); + attr.SetHyperlinkId(id); + _buffer->SetCurrentAttributes(attr); + _buffer->AddHyperlinkToMap(uri, id); + return true; +} + +// Method Description: +// - Updates the buffer's current text attributes to end a hyperlink +// Return Value: +// - true +bool Terminal::EndHyperlink() noexcept +{ + auto attr = _buffer->GetCurrentAttributes(); + attr.SetHyperlinkId(0); + _buffer->SetCurrentAttributes(attr); + return true; +} diff --git a/src/cascadia/TerminalCore/TerminalDispatch.cpp b/src/cascadia/TerminalCore/TerminalDispatch.cpp index f119cc266c7..06874b38889 100644 --- a/src/cascadia/TerminalCore/TerminalDispatch.cpp +++ b/src/cascadia/TerminalCore/TerminalDispatch.cpp @@ -371,6 +371,27 @@ bool TerminalDispatch::ResetPrivateModes(const gsl::span /*params*/) noexcept override; // DECSET bool ResetPrivateModes(const gsl::span /*params*/) noexcept override; // DECRST + bool AddHyperlink(const std::wstring_view uri, const std::wstring_view params) noexcept override; + bool EndHyperlink() noexcept override; + private: ::Microsoft::Terminal::Core::ITerminalApi& _terminalApi; diff --git a/src/cascadia/TerminalCore/TerminalDispatchGraphics.cpp b/src/cascadia/TerminalCore/TerminalDispatchGraphics.cpp index 635d9bded26..bbc0984296b 100644 --- a/src/cascadia/TerminalCore/TerminalDispatchGraphics.cpp +++ b/src/cascadia/TerminalCore/TerminalDispatchGraphics.cpp @@ -107,7 +107,7 @@ bool TerminalDispatch::SetGraphicsRendition(const gsl::spanGetHyperlinkUriFromId(id); +} + +const std::wstring Microsoft::Terminal::Core::Terminal::GetHyperlinkCustomId(uint16_t id) const noexcept +{ + return _buffer->GetCustomIdFromId(id); +} + std::vector Terminal::GetSelectionRects() noexcept try { diff --git a/src/cascadia/UnitTests_TerminalCore/ConptyRoundtripTests.cpp b/src/cascadia/UnitTests_TerminalCore/ConptyRoundtripTests.cpp index a68aa213db1..204e2fb93e6 100644 --- a/src/cascadia/UnitTests_TerminalCore/ConptyRoundtripTests.cpp +++ b/src/cascadia/UnitTests_TerminalCore/ConptyRoundtripTests.cpp @@ -214,6 +214,8 @@ class TerminalCoreUnitTests::ConptyRoundtripTests final TEST_METHOD(DeleteWrappedWord); + TEST_METHOD(HyperlinkIdConsistency); + private: bool _writeCallback(const char* const pch, size_t const cch); void _flushFirstFrame(); @@ -3471,3 +3473,70 @@ void ConptyRoundtripTests::DeleteWrappedWord() Log::Comment(L"========== Checking the terminal buffer state (after) =========="); verifyBuffer(*termTb, term->_mutableViewport.ToInclusive(), true); } + +// This test checks that upon conpty rendering again, terminal still maintains +// the same hyperlink IDs +void ConptyRoundtripTests::HyperlinkIdConsistency() +{ + Log::Comment(NoThrowString().Format( + L"Write a link - the text will simply be 'Link' and the uri will be 'http://example.com'")); + + auto& g = ServiceLocator::LocateGlobals(); + auto& renderer = *g.pRender; + auto& gci = g.getConsoleInformation(); + auto& si = gci.GetActiveOutputBuffer(); + auto& hostSm = si.GetStateMachine(); + auto& hostTb = si.GetTextBuffer(); + auto& termTb = *term->_buffer; + + _flushFirstFrame(); + + hostSm.ProcessString(L"\x1b]8;;http://example.com\x1b/Link\x1b]8;;\x1b/"); + + // For self-generated IDs, conpty will send a custom ID of the form + // {sessionID}-{self-generated ID} + // self-generated IDs begin at 1 and increment from there + const std::string fmt{ "\x1b]8;id={}-1;http://example.com\x1b\\" }; + auto s = fmt::format(fmt, GetCurrentProcessId()); + expectedOutput.push_back(s); + expectedOutput.push_back("Link"); + expectedOutput.push_back("\x1b]8;;\x1b\\"); + + // Force a frame + VERIFY_SUCCEEDED(renderer.PaintFrame()); + + // Move the cursor down + hostSm.ProcessString(L"\x1b[2;1H"); + expectedOutput.push_back("\r\n"); + + // Force a frame + VERIFY_SUCCEEDED(renderer.PaintFrame()); + + // Move the cursor to somewhere in the link text + hostSm.ProcessString(L"\x1b[1;2H"); + expectedOutput.push_back("\x1b[1;2H"); + expectedOutput.push_back("\x1b[?25h"); + + // Force a frame + VERIFY_SUCCEEDED(renderer.PaintFrame()); + + // Move the cursor off the link + hostSm.ProcessString(L"\x1b[2;1H"); + expectedOutput.push_back("\r\n"); + + // Force a frame + VERIFY_SUCCEEDED(renderer.PaintFrame()); + + auto verifyData = [](TextBuffer& tb) { + // Check that all the linked cells still have the same ID + auto attrRow = tb.GetRowByOffset(0).GetAttrRow(); + auto id = attrRow.GetAttrByColumn(0).GetHyperlinkId(); + for (auto i = 1; i < 4; ++i) + { + VERIFY_ARE_EQUAL(id, attrRow.GetAttrByColumn(i).GetHyperlinkId()); + } + }; + + verifyData(hostTb); + verifyData(termTb); +} diff --git a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp index c3c4d1bcdd9..fc3db8a8164 100644 --- a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp +++ b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp @@ -34,6 +34,9 @@ namespace TerminalCoreUnitTests // PrintString() is called with more code units than the buffer width. TEST_METHOD(PrintStringOfSurrogatePairs); TEST_METHOD(CheckDoubleWidthCursor); + + TEST_METHOD(AddHyperlink); + TEST_METHOD(AddHyperlinkCustomId); }; }; @@ -251,3 +254,57 @@ void TerminalApiTest::CheckDoubleWidthCursor() term.SetCursorPosition(1, 1); VERIFY_IS_TRUE(term.IsCursorDoubleWidth()); } + +void TerminalCoreUnitTests::TerminalApiTest::AddHyperlink() +{ + // This is a nearly literal copy-paste of ScreenBufferTests::TestAddHyperlink, adapted for the Terminal + + Terminal term; + DummyRenderTarget emptyRT; + term.Create({ 100, 100 }, 0, emptyRT); + + auto& tbi = *(term._buffer); + auto& stateMachine = *(term._stateMachine); + + // Process the opening osc 8 sequence + stateMachine.ProcessString(L"\x1b]8;;test.url\x9c"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + + // Send any other text + stateMachine.ProcessString(L"Hello World"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + + // Process the closing osc 8 sequences + stateMachine.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(tbi.GetCurrentAttributes().IsHyperlink()); +} + +void TerminalCoreUnitTests::TerminalApiTest::AddHyperlinkCustomId() +{ + // This is a nearly literal copy-paste of ScreenBufferTests::TestAddHyperlinkCustomId, adapted for the Terminal + + Terminal term; + DummyRenderTarget emptyRT; + term.Create({ 100, 100 }, 0, emptyRT); + + auto& tbi = *(term._buffer); + auto& stateMachine = *(term._stateMachine); + + // Process the opening osc 8 sequence + stateMachine.ProcessString(L"\x1b]8;id=myId;test.url\x9c"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkId(L"myId"), tbi.GetCurrentAttributes().GetHyperlinkId()); + + // Send any other text + stateMachine.ProcessString(L"Hello World"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkId(L"myId"), tbi.GetCurrentAttributes().GetHyperlinkId()); + + // Process the closing osc 8 sequences + stateMachine.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(tbi.GetCurrentAttributes().IsHyperlink()); +} diff --git a/src/host/getset.cpp b/src/host/getset.cpp index 9c2d0e26494..b7c8e254a0e 100644 --- a/src/host/getset.cpp +++ b/src/host/getset.cpp @@ -1562,6 +1562,24 @@ void DoSrvSetCursorColor(SCREEN_INFORMATION& screenInfo, screenInfo.GetActiveBuffer().GetTextBuffer().GetCursor().SetColor(cursorColor); } +void DoSrvAddHyperlink(SCREEN_INFORMATION& screenInfo, + const std::wstring_view uri, + const std::wstring_view params) +{ + auto attr = screenInfo.GetAttributes(); + const auto id = screenInfo.GetTextBuffer().GetHyperlinkId(params); + attr.SetHyperlinkId(id); + screenInfo.GetTextBuffer().SetCurrentAttributes(attr); + screenInfo.GetTextBuffer().AddHyperlinkToMap(uri, id); +} + +void DoSrvEndHyperlink(SCREEN_INFORMATION& screenInfo) +{ + auto attr = screenInfo.GetAttributes(); + attr.SetHyperlinkId(0); + screenInfo.GetTextBuffer().SetCurrentAttributes(attr); +} + // Routine Description: // - A private API call for forcing the renderer to repaint the screen. If the // input screen buffer is not the active one, then just do nothing. We only diff --git a/src/host/getset.h b/src/host/getset.h index e6d43474a72..43b8d5fefe5 100644 --- a/src/host/getset.h +++ b/src/host/getset.h @@ -49,6 +49,12 @@ void DoSrvSetCursorStyle(SCREEN_INFORMATION& screenInfo, void DoSrvSetCursorColor(SCREEN_INFORMATION& screenInfo, const COLORREF cursorColor); +void DoSrvAddHyperlink(SCREEN_INFORMATION& screenInfo, + const std::wstring_view uri, + const std::wstring_view params); + +void DoSrvEndHyperlink(SCREEN_INFORMATION& screenInfo); + void DoSrvPrivateRefreshWindow(const SCREEN_INFORMATION& screenInfo); [[nodiscard]] HRESULT DoSrvSetConsoleOutputCodePage(const unsigned int codepage); diff --git a/src/host/outputStream.cpp b/src/host/outputStream.cpp index a4aa4c57de2..d98a40b2a67 100644 --- a/src/host/outputStream.cpp +++ b/src/host/outputStream.cpp @@ -770,3 +770,22 @@ bool ConhostInternalGetSet::PrivateIsVtInputEnabled() const { return _io.GetActiveInputBuffer()->IsInVirtualTerminalInputMode(); } + +// Method Description: +// - Updates the buffer's current text attributes depending on whether we are +// starting/ending a hyperlink +// Arguments: +// - The hyperlink URI +// Return Value: +// - true +bool ConhostInternalGetSet::PrivateAddHyperlink(const std::wstring_view uri, const std::wstring_view params) const +{ + DoSrvAddHyperlink(_io.GetActiveOutputBuffer(), uri, params); + return true; +} + +bool ConhostInternalGetSet::PrivateEndHyperlink() const +{ + DoSrvEndHyperlink(_io.GetActiveOutputBuffer()); + return true; +} diff --git a/src/host/outputStream.hpp b/src/host/outputStream.hpp index a2671396583..8e3274f16cc 100644 --- a/src/host/outputStream.hpp +++ b/src/host/outputStream.hpp @@ -145,6 +145,9 @@ class ConhostInternalGetSet final : public Microsoft::Console::VirtualTerminal:: bool PrivateIsVtInputEnabled() const override; + bool PrivateAddHyperlink(const std::wstring_view uri, const std::wstring_view params) const override; + bool PrivateEndHyperlink() const override; + private: Microsoft::Console::IIoProvider& _io; }; diff --git a/src/host/renderData.cpp b/src/host/renderData.cpp index b8397cbbb00..33ae6fa3485 100644 --- a/src/host/renderData.cpp +++ b/src/host/renderData.cpp @@ -324,6 +324,30 @@ const std::wstring RenderData::GetConsoleTitle() const noexcept return gci.GetTitleAndPrefix(); } +// Method Description: +// - Get the hyperlink URI associated with a hyperlink ID +// Arguments: +// - The hyperlink ID +// Return Value: +// - The URI +const std::wstring RenderData::GetHyperlinkUri(uint16_t id) const noexcept +{ + const CONSOLE_INFORMATION& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); + return gci.GetActiveOutputBuffer().GetTextBuffer().GetHyperlinkUriFromId(id); +} + +// Method Description: +// - Get the custom ID associated with a hyperlink ID +// Arguments: +// - The hyperlink ID +// Return Value: +// - The custom ID if there was one, empty string otherwise +const std::wstring RenderData::GetHyperlinkCustomId(uint16_t id) const noexcept +{ + const CONSOLE_INFORMATION& gci = ServiceLocator::LocateGlobals().getConsoleInformation(); + return gci.GetActiveOutputBuffer().GetTextBuffer().GetCustomIdFromId(id); +} + // Routine Description: // - Converts a text attribute into the RGB values that should be presented, applying // relevant table translation information and preferences. diff --git a/src/host/renderData.hpp b/src/host/renderData.hpp index 4b2bf2e3b58..2e8fa68790c 100644 --- a/src/host/renderData.hpp +++ b/src/host/renderData.hpp @@ -55,6 +55,9 @@ class RenderData final : const bool IsGridLineDrawingAllowed() noexcept override; const std::wstring GetConsoleTitle() const noexcept override; + + const std::wstring GetHyperlinkUri(uint16_t id) const noexcept override; + const std::wstring GetHyperlinkCustomId(uint16_t id) const noexcept override; #pragma endregion #pragma region IUiaData diff --git a/src/host/ut_host/ScreenBufferTests.cpp b/src/host/ut_host/ScreenBufferTests.cpp index 0b5453877b5..1e559505914 100644 --- a/src/host/ut_host/ScreenBufferTests.cpp +++ b/src/host/ut_host/ScreenBufferTests.cpp @@ -211,6 +211,9 @@ class ScreenBufferTests TEST_METHOD(TestCursorIsOn); + TEST_METHOD(TestAddHyperlink); + TEST_METHOD(TestAddHyperlinkCustomId); + TEST_METHOD(UpdateVirtualBottomWhenCursorMovesBelowIt); TEST_METHOD(TestWriteConsoleVTQuirkMode); @@ -5918,6 +5921,54 @@ void ScreenBufferTests::TestCursorIsOn() VERIFY_IS_FALSE(cursor.IsVisible()); } +void ScreenBufferTests::TestAddHyperlink() +{ + auto& g = ServiceLocator::LocateGlobals(); + auto& gci = g.getConsoleInformation(); + auto& si = gci.GetActiveOutputBuffer(); + auto& tbi = si.GetTextBuffer(); + auto& stateMachine = si.GetStateMachine(); + + // Process the opening osc 8 sequence with no custom id + stateMachine.ProcessString(L"\x1b]8;;test.url\x9c"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + + // Send any other text + stateMachine.ProcessString(L"Hello World"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + + // Process the closing osc 8 sequences + stateMachine.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(tbi.GetCurrentAttributes().IsHyperlink()); +} + +void ScreenBufferTests::TestAddHyperlinkCustomId() +{ + auto& g = ServiceLocator::LocateGlobals(); + auto& gci = g.getConsoleInformation(); + auto& si = gci.GetActiveOutputBuffer(); + auto& tbi = si.GetTextBuffer(); + auto& stateMachine = si.GetStateMachine(); + + // Process the opening osc 8 sequence with a custom id + stateMachine.ProcessString(L"\x1b]8;id=myId;test.url\x9c"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkId(L"myId"), tbi.GetCurrentAttributes().GetHyperlinkId()); + + // Send any other text + stateMachine.ProcessString(L"Hello World"); + VERIFY_IS_TRUE(tbi.GetCurrentAttributes().IsHyperlink()); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkUriFromId(tbi.GetCurrentAttributes().GetHyperlinkId()), L"test.url"); + VERIFY_ARE_EQUAL(tbi.GetHyperlinkId(L"myId"), tbi.GetCurrentAttributes().GetHyperlinkId()); + + // Process the closing osc 8 sequences + stateMachine.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(tbi.GetCurrentAttributes().IsHyperlink()); +} + void ScreenBufferTests::UpdateVirtualBottomWhenCursorMovesBelowIt() { auto& g = ServiceLocator::LocateGlobals(); diff --git a/src/host/ut_host/TextBufferTests.cpp b/src/host/ut_host/TextBufferTests.cpp index 6f4351b6c26..1ee79447b08 100644 --- a/src/host/ut_host/TextBufferTests.cpp +++ b/src/host/ut_host/TextBufferTests.cpp @@ -152,6 +152,9 @@ class TextBufferTests TEST_METHOD(GetTextRects); TEST_METHOD(GetText); + + TEST_METHOD(HyperlinkTrim); + TEST_METHOD(NoHyperlinkTrim); }; void TextBufferTests::TestBufferCreate() @@ -2443,3 +2446,79 @@ void TextBufferTests::GetText() VERIFY_ARE_EQUAL(expectedText, result); } } + +// This tests that when we increment the circular buffer, obsolete hyperlink references +// are removed from the hyperlink map +void TextBufferTests::HyperlinkTrim() +{ + // Set up a text buffer for us + const COORD bufferSize{ 80, 10 }; + const UINT cursorSize = 12; + const TextAttribute attr{ 0x7f }; + auto _buffer = std::make_unique(bufferSize, attr, cursorSize, _renderTarget); + + const auto url = L"test.url"; + const auto otherUrl = L"other.url"; + const auto customId = L"CustomId"; + const auto otherCustomId = L"OtherCustomId"; + + // Set a hyperlink id in the first row and add a hyperlink to our map + const COORD pos{ 70, 0 }; + const auto id = _buffer->GetHyperlinkId(customId); + TextAttribute newAttr{ 0x7f }; + newAttr.SetHyperlinkId(id); + _buffer->GetRowByOffset(pos.Y).GetAttrRow().SetAttrToEnd(pos.X, newAttr); + _buffer->AddHyperlinkToMap(url, id); + + // Set a different hyperlink id somewhere else in the buffer + const COORD otherPos{ 70, 5 }; + const auto otherId = _buffer->GetHyperlinkId(otherCustomId); + newAttr.SetHyperlinkId(otherId); + _buffer->GetRowByOffset(otherPos.Y).GetAttrRow().SetAttrToEnd(otherPos.X, newAttr); + _buffer->AddHyperlinkToMap(otherUrl, otherId); + + // Increment the circular buffer + _buffer->IncrementCircularBuffer(); + + // The hyperlink reference that was only in the first row should be deleted from the map + VERIFY_ARE_EQUAL(_buffer->_hyperlinkMap.find(id), _buffer->_hyperlinkMap.end()); + // Since there was a custom id, that should be deleted as well + VERIFY_ARE_EQUAL(_buffer->_hyperlinkCustomIdMap.find(customId), _buffer->_hyperlinkCustomIdMap.end()); + + // The other hyperlink reference should not be deleted + VERIFY_ARE_EQUAL(_buffer->_hyperlinkMap[otherId], otherUrl); + VERIFY_ARE_EQUAL(_buffer->_hyperlinkCustomIdMap[otherCustomId], otherId); +} + +// This tests that when we increment the circular buffer, non-obsolete hyperlink references +// do not get removed from the hyperlink map +void TextBufferTests::NoHyperlinkTrim() +{ + // Set up a text buffer for us + const COORD bufferSize{ 80, 10 }; + const UINT cursorSize = 12; + const TextAttribute attr{ 0x7f }; + auto _buffer = std::make_unique(bufferSize, attr, cursorSize, _renderTarget); + + const auto url = L"test.url"; + const auto customId = L"CustomId"; + + // Set a hyperlink id in the first row and add a hyperlink to our map + const COORD pos{ 70, 0 }; + const auto id = _buffer->GetHyperlinkId(customId); + TextAttribute newAttr{ 0x7f }; + newAttr.SetHyperlinkId(id); + _buffer->GetRowByOffset(pos.Y).GetAttrRow().SetAttrToEnd(pos.X, newAttr); + _buffer->AddHyperlinkToMap(url, id); + + // Set the same hyperlink id somewhere else in the buffer + const COORD otherPos{ 70, 5 }; + _buffer->GetRowByOffset(otherPos.Y).GetAttrRow().SetAttrToEnd(otherPos.X, newAttr); + + // Increment the circular buffer + _buffer->IncrementCircularBuffer(); + + // The hyperlink reference should not be deleted from the map since it is still present in the buffer + VERIFY_ARE_EQUAL(_buffer->GetHyperlinkUriFromId(id), url); + VERIFY_ARE_EQUAL(_buffer->_hyperlinkCustomIdMap[customId], id); +} diff --git a/src/host/ut_host/VtIoTests.cpp b/src/host/ut_host/VtIoTests.cpp index 599a29caec4..0e20bbcb3f5 100644 --- a/src/host/ut_host/VtIoTests.cpp +++ b/src/host/ut_host/VtIoTests.cpp @@ -391,6 +391,16 @@ class MockRenderData : public IRenderData, IUiaData void ColorSelection(const COORD /*coordSelectionStart*/, const COORD /*coordSelectionEnd*/, const TextAttribute /*attr*/) { } + + const std::wstring GetHyperlinkUri(uint16_t /*id*/) const noexcept + { + return {}; + } + + const std::wstring GetHyperlinkCustomId(uint16_t /*id*/) const noexcept + { + return {}; + } }; void VtIoTests::RendererDtorAndThread() diff --git a/src/renderer/inc/IRenderData.hpp b/src/renderer/inc/IRenderData.hpp index 5e0f1c77d8a..11b347e657e 100644 --- a/src/renderer/inc/IRenderData.hpp +++ b/src/renderer/inc/IRenderData.hpp @@ -66,6 +66,9 @@ namespace Microsoft::Console::Render virtual const bool IsGridLineDrawingAllowed() noexcept = 0; virtual const std::wstring GetConsoleTitle() const noexcept = 0; + virtual const std::wstring GetHyperlinkUri(uint16_t id) const noexcept = 0; + virtual const std::wstring GetHyperlinkCustomId(uint16_t id) const noexcept = 0; + protected: IRenderData() = default; }; diff --git a/src/renderer/vt/VtSequences.cpp b/src/renderer/vt/VtSequences.cpp index c67093280d7..b1ae2870bec 100644 --- a/src/renderer/vt/VtSequences.cpp +++ b/src/renderer/vt/VtSequences.cpp @@ -456,3 +456,46 @@ using namespace Microsoft::Console::Render; { return _Write("\x1b[?9001h"); } + +// Method Description: +// - Formats and writes a sequence to add a hyperlink to the terminal buffer +// Arguments: +// - The hyperlink URI +// Return Value: +// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write. +[[nodiscard]] HRESULT VtEngine::_SetHyperlink(const std::wstring_view& uri, const std::wstring_view& customId, const uint16_t& numberId) noexcept +{ + // Opening OSC8 sequence + if (customId.empty()) + { + // This is the case of auto-assigned IDs: + // send the auto-assigned ID, prefixed with the PID of this session + // (we do this so different conpty sessions do not overwrite each other's hyperlinks) + const auto sessionID = GetCurrentProcessId(); + const std::string fmt{ "\x1b]8;id={}-{};{}\x1b\\" }; + const std::string uri_str{ til::u16u8(uri) }; + auto s = fmt::format(fmt, sessionID, numberId, uri_str); + return _Write(s); + } + else + { + // This is the case of user-defined IDs: + // send the user-defined ID, prefixed with a "u" + // (we do this so no application can accidentally override a user defined ID) + const std::string fmt{ "\x1b]8;id=u-{};{}\x1b\\" }; + const std::string uri_str{ til::u16u8(uri) }; + const std::string customId_str{ til::u16u8(customId) }; + auto s = fmt::format(fmt, customId_str, uri_str); + return _Write(s); + } +} + +// Method Description: +// - Formats and writes a sequence to end a hyperlink to the terminal buffer +// Return Value: +// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write. +[[nodiscard]] HRESULT VtEngine::_EndHyperlink() noexcept +{ + // Closing OSC8 sequence + return _Write("\x1b]8;;\x1b\\"); +} diff --git a/src/renderer/vt/Xterm256Engine.cpp b/src/renderer/vt/Xterm256Engine.cpp index 0dee6be535e..91e220c33f2 100644 --- a/src/renderer/vt/Xterm256Engine.cpp +++ b/src/renderer/vt/Xterm256Engine.cpp @@ -25,10 +25,13 @@ Xterm256Engine::Xterm256Engine(_In_ wil::unique_hfile hPipe, // Return Value: // - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write. [[nodiscard]] HRESULT Xterm256Engine::UpdateDrawingBrushes(const TextAttribute& textAttributes, - const gsl::not_null /*pData*/, + const gsl::not_null pData, const bool /*isSettingDefaultBrushes*/) noexcept { RETURN_IF_FAILED(VtEngine::_RgbUpdateDrawingBrushes(textAttributes)); + + RETURN_IF_FAILED(_UpdateHyperlinkAttr(textAttributes, pData)); + // Only do extended attributes in xterm-256color, as to not break telnet.exe. return _UpdateExtendedAttrs(textAttributes); } @@ -128,6 +131,34 @@ Xterm256Engine::Xterm256Engine(_In_ wil::unique_hfile hPipe, return S_OK; } +// Routine Description: +// - Write a VT sequence to start/stop a hyperlink +// Arguments: +// - textAttributes - Text attributes to use for the hyperlink ID +// - pData - The interface to console data structures required for rendering +// Return Value: +// - S_OK if we succeeded, else an appropriate HRESULT for failing to allocate or write. +HRESULT Microsoft::Console::Render::Xterm256Engine::_UpdateHyperlinkAttr(const TextAttribute& textAttributes, + const gsl::not_null pData) noexcept +{ + if (textAttributes.GetHyperlinkId() != _lastTextAttributes.GetHyperlinkId()) + { + if (textAttributes.IsHyperlink()) + { + const auto id = textAttributes.GetHyperlinkId(); + const auto customId = pData->GetHyperlinkCustomId(id); + RETURN_IF_FAILED(_SetHyperlink(pData->GetHyperlinkUri(id), customId, id)); + } + else + { + RETURN_IF_FAILED(_EndHyperlink()); + } + _lastTextAttributes.SetHyperlinkId(textAttributes.GetHyperlinkId()); + } + + return S_OK; +} + // Method Description: // - Manually emit a "Erase Scrollback" sequence to the connected terminal. We // need to do this in certain cases that we've identified where we believe the diff --git a/src/renderer/vt/Xterm256Engine.hpp b/src/renderer/vt/Xterm256Engine.hpp index fe07065e024..b1ed02332eb 100644 --- a/src/renderer/vt/Xterm256Engine.hpp +++ b/src/renderer/vt/Xterm256Engine.hpp @@ -36,6 +36,8 @@ namespace Microsoft::Console::Render private: [[nodiscard]] HRESULT _UpdateExtendedAttrs(const TextAttribute& textAttributes) noexcept; + [[nodiscard]] HRESULT _UpdateHyperlinkAttr(const TextAttribute& textAttributes, + const gsl::not_null pData) noexcept; #ifdef UNIT_TESTING friend class VtRendererTest; diff --git a/src/renderer/vt/vtrenderer.hpp b/src/renderer/vt/vtrenderer.hpp index 084592be0a6..887e1ce8b70 100644 --- a/src/renderer/vt/vtrenderer.hpp +++ b/src/renderer/vt/vtrenderer.hpp @@ -195,6 +195,8 @@ namespace Microsoft::Console::Render [[nodiscard]] HRESULT _SetInvisible(const bool isInvisible) noexcept; [[nodiscard]] HRESULT _SetCrossedOut(const bool isCrossedOut) noexcept; [[nodiscard]] HRESULT _SetReverseVideo(const bool isReversed) noexcept; + [[nodiscard]] HRESULT _SetHyperlink(const std::wstring_view& uri, const std::wstring_view& customId, const uint16_t& numberId) noexcept; + [[nodiscard]] HRESULT _EndHyperlink() noexcept; [[nodiscard]] HRESULT _RequestCursor() noexcept; diff --git a/src/terminal/adapter/ITermDispatch.hpp b/src/terminal/adapter/ITermDispatch.hpp index b90705ac4a9..60639e67c3d 100644 --- a/src/terminal/adapter/ITermDispatch.hpp +++ b/src/terminal/adapter/ITermDispatch.hpp @@ -117,6 +117,9 @@ class Microsoft::Console::VirtualTerminal::ITermDispatch // DTTERM_WindowManipulation virtual bool WindowManipulation(const DispatchTypes::WindowManipulationType function, const gsl::span parameters) = 0; + + virtual bool AddHyperlink(const std::wstring_view uri, const std::wstring_view params) = 0; + virtual bool EndHyperlink() = 0; }; inline Microsoft::Console::VirtualTerminal::ITermDispatch::~ITermDispatch() {} #pragma warning(pop) diff --git a/src/terminal/adapter/adaptDispatch.cpp b/src/terminal/adapter/adaptDispatch.cpp index d435498c322..c1524021e65 100644 --- a/src/terminal/adapter/adaptDispatch.cpp +++ b/src/terminal/adapter/adaptDispatch.cpp @@ -2341,6 +2341,26 @@ bool AdaptDispatch::WindowManipulation(const DispatchTypes::WindowManipulationTy return success; } +// Method Description: +// - Starts a hyperlink +// Arguments: +// - The hyperlink URI, optional additional parameters +// Return Value: +// - true +bool AdaptDispatch::AddHyperlink(const std::wstring_view uri, const std::wstring_view params) +{ + return _pConApi->PrivateAddHyperlink(uri, params); +} + +// Method Description: +// - Ends a hyperlink +// Return Value: +// - true +bool AdaptDispatch::EndHyperlink() +{ + return _pConApi->PrivateEndHyperlink(); +} + // Routine Description: // - Determines whether we should pass any sequence that manipulates // TerminalInput's input generator through the PTY. It encapsulates diff --git a/src/terminal/adapter/adaptDispatch.hpp b/src/terminal/adapter/adaptDispatch.hpp index 2e5f27a7823..1d893e61ef8 100644 --- a/src/terminal/adapter/adaptDispatch.hpp +++ b/src/terminal/adapter/adaptDispatch.hpp @@ -118,6 +118,9 @@ namespace Microsoft::Console::VirtualTerminal bool WindowManipulation(const DispatchTypes::WindowManipulationType function, const gsl::span parameters) override; // DTTERM_WindowManipulation + bool AddHyperlink(const std::wstring_view uri, const std::wstring_view params) override; + bool EndHyperlink() override; + private: enum class ScrollDirection { diff --git a/src/terminal/adapter/adaptDispatchGraphics.cpp b/src/terminal/adapter/adaptDispatchGraphics.cpp index de873012193..13fc8a17ad6 100644 --- a/src/terminal/adapter/adaptDispatchGraphics.cpp +++ b/src/terminal/adapter/adaptDispatchGraphics.cpp @@ -116,7 +116,7 @@ bool AdaptDispatch::SetGraphicsRendition(const gsl::span clipRect, const COORD destinationOrigin, const bool standardFillAttrs) = 0; + + virtual bool PrivateAddHyperlink(const std::wstring_view uri, const std::wstring_view params) const = 0; + virtual bool PrivateEndHyperlink() const = 0; }; } diff --git a/src/terminal/adapter/termDispatch.hpp b/src/terminal/adapter/termDispatch.hpp index 47ecd990784..31070f85623 100644 --- a/src/terminal/adapter/termDispatch.hpp +++ b/src/terminal/adapter/termDispatch.hpp @@ -111,4 +111,7 @@ class Microsoft::Console::VirtualTerminal::TermDispatch : public Microsoft::Cons // DTTERM_WindowManipulation bool WindowManipulation(const DispatchTypes::WindowManipulationType /*function*/, const gsl::span /*params*/) noexcept override { return false; } + + bool AddHyperlink(const std::wstring_view /*uri*/, const std::wstring_view /*params*/) noexcept override { return false; } + bool EndHyperlink() noexcept override { return false; } }; diff --git a/src/terminal/adapter/ut_adapter/adapterTest.cpp b/src/terminal/adapter/ut_adapter/adapterTest.cpp index 92b295667ab..3d9128e496a 100644 --- a/src/terminal/adapter/ut_adapter/adapterTest.cpp +++ b/src/terminal/adapter/ut_adapter/adapterTest.cpp @@ -706,6 +706,20 @@ class TestGetSet final : public ConGetSet } } + bool PrivateAddHyperlink(const std::wstring_view /*uri*/, const std::wstring_view /*params*/) const + { + Log::Comment(L"PrivateAddHyperlink MOCK called..."); + + return TRUE; + } + + bool PrivateEndHyperlink() const + { + Log::Comment(L"PrivateEndHyperlink MOCK called..."); + + return TRUE; + } + void _SetMarginsHelper(SMALL_RECT* rect, SHORT top, SHORT bottom) { rect->Top = top; diff --git a/src/terminal/parser/OutputStateMachineEngine.cpp b/src/terminal/parser/OutputStateMachineEngine.cpp index cdecc28bbd8..15e946602fa 100644 --- a/src/terminal/parser/OutputStateMachineEngine.cpp +++ b/src/terminal/parser/OutputStateMachineEngine.cpp @@ -136,6 +136,7 @@ bool OutputStateMachineEngine::ActionPrintString(const std::wstring_view string) { return true; } + // Stash the last character of the string, if it's a graphical character const wchar_t wch = string.back(); if (wch >= AsciiChars::SPC) @@ -730,6 +731,8 @@ bool OutputStateMachineEngine::ActionOscDispatch(const wchar_t /*wch*/, bool success = false; std::wstring title; std::wstring setClipboardContent; + std::wstring params; + std::wstring uri; bool queryClipboard = false; size_t tableIndex = 0; DWORD color = 0; @@ -757,6 +760,9 @@ bool OutputStateMachineEngine::ActionOscDispatch(const wchar_t /*wch*/, color = 0xffffffff; success = true; break; + case OscActionCodes::Hyperlink: + success = _ParseHyperlink(string, params, uri); + break; default: // If no functions to call, overall dispatch was a failure. success = false; @@ -799,6 +805,16 @@ bool OutputStateMachineEngine::ActionOscDispatch(const wchar_t /*wch*/, success = _dispatch->SetCursorColor(color); TermTelemetry::Instance().Log(TermTelemetry::Codes::OSCRCC); break; + case OscActionCodes::Hyperlink: + if (uri.empty()) + { + success = _dispatch->EndHyperlink(); + } + else + { + success = _dispatch->AddHyperlink(uri, params); + } + break; default: // If no functions to call, overall dispatch was a failure. success = false; @@ -1574,6 +1590,44 @@ bool OutputStateMachineEngine::_GetOscSetColorTable(const std::wstring_view stri return success; } +// Routine Description: +// - Given a hyperlink string, attempts to parse the URI encoded. An 'id' parameter +// may be provided. +// If there is a URI, the well formatted string looks like: +// ";" +// If there is no URI, we need to close the hyperlink and the string looks like: +// ";" +// Arguments: +// - string - the string containing the parameters and URI +// - params - where to store the parameters +// - uri - where to store the uri +// Return Value: +// - True if a URI was successfully parsed or if we are meant to close a hyperlink +bool OutputStateMachineEngine::_ParseHyperlink(const std::wstring_view string, + std::wstring& params, + std::wstring& uri) const +{ + params.clear(); + uri.clear(); + const auto len = string.size(); + const size_t midPos = string.find(';'); + if (midPos != std::wstring::npos) + { + if (len != 1) + { + uri = string.substr(midPos + 1); + const auto paramStr = string.substr(0, midPos); + const auto idPos = paramStr.find(hyperlinkIDParameter); + if (idPos != std::wstring::npos) + { + params = paramStr.substr(idPos + hyperlinkIDParameter.size()); + } + } + return true; + } + return false; +} + // Routine Description: // - OSC 10, 11, 12 ; spec ST // spec: a color in the following format: diff --git a/src/terminal/parser/OutputStateMachineEngine.hpp b/src/terminal/parser/OutputStateMachineEngine.hpp index e6332a076e1..e96803ef36f 100644 --- a/src/terminal/parser/OutputStateMachineEngine.hpp +++ b/src/terminal/parser/OutputStateMachineEngine.hpp @@ -160,6 +160,7 @@ namespace Microsoft::Console::VirtualTerminal SetWindowTitle = 2, SetWindowProperty = 3, // Not implemented SetColor = 4, + Hyperlink = 8, SetForegroundColor = 10, SetBackgroundColor = 11, SetCursorColor = 12, @@ -250,6 +251,11 @@ namespace Microsoft::Console::VirtualTerminal std::wstring& content, bool& queryClipboard) const noexcept; + static constexpr std::wstring_view hyperlinkIDParameter{ L"id=" }; + bool _ParseHyperlink(const std::wstring_view string, + std::wstring& params, + std::wstring& uri) const; + void _ClearLastChar() noexcept; }; } diff --git a/src/terminal/parser/ft_fuzzer/VTCommandFuzzer.cpp b/src/terminal/parser/ft_fuzzer/VTCommandFuzzer.cpp index 70c823efe12..5a61e49f654 100644 --- a/src/terminal/parser/ft_fuzzer/VTCommandFuzzer.cpp +++ b/src/terminal/parser/ft_fuzzer/VTCommandFuzzer.cpp @@ -40,6 +40,7 @@ static std::string GenerateSoftResetToken(); static std::string GenerateOscColorTableToken(); static std::string GenerateVt52Token(); static std::string GenerateVt52CursorAddressToken(); +static std::string GenerateOscHyperlinkToken(); const fuzz::_fuzz_type_entry g_repeatMap[] = { { 4, [](BYTE) { return CFuzzChance::GetRandom(2, 0xF); } }, @@ -62,7 +63,8 @@ const std::function g_tokenGenerators[] = { GenerateSoftResetToken, GenerateOscColorTableToken, GenerateVt52Token, - GenerateVt52CursorAddressToken + GenerateVt52CursorAddressToken, + GenerateOscHyperlinkToken }; std::string GenerateTokenLowProbability() @@ -534,6 +536,72 @@ std::string GenerateVt52CursorAddressToken() return cux; } +// Osc Hyperlink String. An Osc followed by 8, followed by some optional key-value pairs, +// followed by a ";", followed by a string, and BEL terminated. +std::string GenerateOscHyperlinkToken() +{ + const LPSTR tokens[] = { "\x7" }; + const _fuzz_type_entry map[] = { + { 100, + [](std::string) { + std::string s; + AppendFormat(s, "%d", 8); + s.append(";"); + + // Maybe append some key-value pairs + SHORT numPairs = CFuzzChance::GetRandom(0, 5); + for (SHORT i = 0; i < numPairs; i++) + { + // usually add an id + SHORT limit = CFuzzChance::GetRandom(0, 10); + switch (limit) + { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + s.append("id="); + break; + case 7: + s.append("rgb="); + break; + case 8: + s.append("cmyk="); + break; + default: + // append some characters for the string + limit = CFuzzChance::GetRandom(); + for (SHORT j = 0; j < limit; j++) + { + AppendFormat(s, "%c", CFuzzChance::GetRandom()); + } + s.append("="); + } + // append some characters for the value + limit = CFuzzChance::GetRandom(); + for (SHORT j = 0; j < limit; j++) + { + AppendFormat(s, "%c", CFuzzChance::GetRandom()); + } + } + + s.append(";"); + // append some characters for the uri + SHORT limit = CFuzzChance::GetRandom(); + for (SHORT i = 0; i < limit; i++) + { + AppendFormat(s, "%c", CFuzzChance::GetRandom()); + } + return s; + } } + }; + + return GenerateFuzzedOscToken(FUZZ_MAP(map), tokens, ARRAYSIZE(tokens)); +} + int __cdecl wmain(int argc, WCHAR* argv[]) { if (argc != 3) diff --git a/src/terminal/parser/ut_parser/OutputEngineTest.cpp b/src/terminal/parser/ut_parser/OutputEngineTest.cpp index 166578b8055..aafb0cafbe3 100644 --- a/src/terminal/parser/ut_parser/OutputEngineTest.cpp +++ b/src/terminal/parser/ut_parser/OutputEngineTest.cpp @@ -816,6 +816,7 @@ class StatefulDispatch final : public TermDispatch _isDECCOLMAllowed{ false }, _windowWidth{ 80 }, _win32InputMode{ false }, + _hyperlinkMode{ false }, _options{ s_cMaxOptions, static_cast(s_uiGraphicsCleared) } // fill with cleared option { } @@ -1168,6 +1169,25 @@ class StatefulDispatch final : public TermDispatch return true; } + bool AddHyperlink(std::wstring_view uri, std::wstring_view params) noexcept override + { + _hyperlinkMode = true; + _uri = uri; + if (!params.empty()) + { + _customId = params; + } + return true; + } + + bool EndHyperlink() noexcept override + { + _hyperlinkMode = false; + _uri.clear(); + _customId.clear(); + return true; + } + size_t _cursorDistance; size_t _line; size_t _column; @@ -1214,7 +1234,10 @@ class StatefulDispatch final : public TermDispatch bool _isDECCOLMAllowed; size_t _windowWidth; bool _win32InputMode; + bool _hyperlinkMode; std::wstring _copyContent; + std::wstring _uri; + std::wstring _customId; static const size_t s_cMaxOptions = 16; static const size_t s_uiGraphicsCleared = UINT_MAX; @@ -2460,4 +2483,38 @@ class StateMachineExternalTest final pDispatch->ClearState(); } + + TEST_METHOD(TestAddHyperlink) + { + auto dispatch = std::make_unique(); + auto pDispatch = dispatch.get(); + auto engine = std::make_unique(std::move(dispatch)); + StateMachine mach(std::move(engine)); + + // First we test with no custom id + // Process the opening osc 8 sequence + mach.ProcessString(L"\x1b]8;;test.url\x9c"); + VERIFY_IS_TRUE(pDispatch->_hyperlinkMode); + VERIFY_ARE_EQUAL(pDispatch->_uri, L"test.url"); + VERIFY_IS_TRUE(pDispatch->_customId.empty()); + + // Process the closing osc 8 sequences + mach.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(pDispatch->_hyperlinkMode); + VERIFY_IS_TRUE(pDispatch->_uri.empty()); + + // Next we test with a custom id + // Process the opening osc 8 sequence + mach.ProcessString(L"\x1b]8;id=testId;test2.url\x9c"); + VERIFY_IS_TRUE(pDispatch->_hyperlinkMode); + VERIFY_ARE_EQUAL(pDispatch->_uri, L"test2.url"); + VERIFY_ARE_EQUAL(pDispatch->_customId, L"testId"); + + // Process the closing osc 8 sequence + mach.ProcessString(L"\x1b]8;;\x9c"); + VERIFY_IS_FALSE(pDispatch->_hyperlinkMode); + VERIFY_IS_TRUE(pDispatch->_uri.empty()); + + pDispatch->ClearState(); + } };