Skip to content

Commit

Permalink
Display URI tooltip, render dashed/solid underline for links (#7420)
Browse files Browse the repository at this point in the history
- Render hyperlinks with a dashed underline
- Render hovered hyperlinks with a solid underline
- Show URI tooltip on hover

TermControl now has a canvas that contains a tiny border to which a
tooltip is attached. When we hover over hyperlinked text, we move the
border to the mouse location and update the tooltip content with the
URI. 

Introduced a new underline type (HyperlinkUnderline), supports rendering
for it, and uses it to render hyperlinks. HyperlinkUnderline is usually
a dashed underline, but when a link is hovered, all text with the same
hyperlink ID is rendered with a solid underline. 

References #5001
  • Loading branch information
PankajBhojwani authored Sep 10, 2020
1 parent cb037f3 commit be50e56
Show file tree
Hide file tree
Showing 12 changed files with 142 additions and 6 deletions.
3 changes: 3 additions & 0 deletions src/cascadia/TerminalControl/Resources/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,7 @@
<data name="TermControl_RendererRetryButton.Content" xml:space="preserve">
<value>Resume</value>
</data>
<data name="HowToOpenRun.Text" xml:space="preserve">
<value>ctrl+click to follow link</value>
</data>
</root>
40 changes: 40 additions & 0 deletions src/cascadia/TerminalControl/TermControl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,46 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation
_TryStopAutoScroll(ptr.PointerId());
}
}
const auto terminalPos = _GetTerminalPosition(point.Position());
if (terminalPos != _lastHoveredCell)
{
const auto uri = _terminal->GetHyperlinkAtPosition(terminalPos);
if (!uri.empty())
{
// Update the tooltip with the URI
HoveredUri().Text(uri);

// Set the border thickness so it covers the entire cell
const auto charSizeInPixels = CharacterDimensions();
const auto htInDips = charSizeInPixels.Height / SwapChainPanel().CompositionScaleY();
const auto wtInDips = charSizeInPixels.Width / SwapChainPanel().CompositionScaleX();
const Thickness newThickness{ wtInDips, htInDips, 0, 0 };
HyperlinkTooltipBorder().BorderThickness(newThickness);

// Compute the location of the top left corner of the cell in DIPS
const til::size marginsInDips{ til::math::rounding, GetPadding().Left, GetPadding().Top };
const til::point startPos{ terminalPos.X, terminalPos.Y };
const til::size fontSize{ _actualFont.GetSize() };
const til::point posInPixels{ startPos * fontSize };
const til::point posInDIPs{ posInPixels / SwapChainPanel().CompositionScaleX() };
const til::point locationInDIPs{ posInDIPs + marginsInDips };

// Move the border to the top left corner of the cell
OverlayCanvas().SetLeft(HyperlinkTooltipBorder(), (locationInDIPs.x() - SwapChainPanel().ActualOffset().x));
OverlayCanvas().SetTop(HyperlinkTooltipBorder(), (locationInDIPs.y() - SwapChainPanel().ActualOffset().y));
}
_lastHoveredCell = terminalPos;

const auto newId = _terminal->GetHyperlinkIdAtPosition(terminalPos);
// If the hyperlink ID changed, trigger a redraw all (so this will happen both when we move
// onto a link and when we move off a link)
if (newId != _lastHoveredId)
{
_renderEngine->UpdateHyperlinkHoveredId(newId);
_renderer->TriggerRedrawAll();
_lastHoveredId = newId;
}
}
}
else if (ptr.PointerDeviceType() == Windows::Devices::Input::PointerDeviceType::Touch && _touchAnchor)
{
Expand Down
5 changes: 5 additions & 0 deletions src/cascadia/TerminalControl/TermControl.h
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ namespace winrt::Microsoft::Terminal::TerminalControl::implementation
// viewport via touch input.
std::optional<winrt::Windows::Foundation::Point> _touchAnchor;

// Track the last cell we hovered over (used in pointerMovedHandler)
COORD _lastHoveredCell;
// Track the last hyperlink ID we hovered over
uint16_t _lastHoveredId;

using Timestamp = uint64_t;

// imported from WinUser
Expand Down
21 changes: 20 additions & 1 deletion src/cascadia/TerminalControl/TermControl.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,26 @@

<SwapChainPanel x:Name="SwapChainPanel"
SizeChanged="_SwapChainSizeChanged"
CompositionScaleChanged="_SwapChainScaleChanged" />
CompositionScaleChanged="_SwapChainScaleChanged">

<Canvas x:Name="OverlayCanvas"
Visibility="Visible">
<Border x:Name="HyperlinkTooltipBorder"
BorderBrush="Transparent">
<ToolTipService.ToolTip>
<ToolTip x:Name="LinkTip"
Placement="Mouse">
<TextBlock IsTextSelectionEnabled="True">
<Run x:Name="HoveredUri"/> <LineBreak />
<Run x:Uid="HowToOpenRun"
FontStyle="Italic">
</Run>
</TextBlock>
</ToolTip>
</ToolTipService.ToolTip>
</Border>
</Canvas>
</SwapChainPanel>

<!-- Putting this in a grid w/ the SwapChainPanel
ensures that it's always aligned w/ the scrollbar -->
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalControl/pch.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#include <winrt/Windows.UI.Xaml.Controls.h>
#include <winrt/Windows.UI.Xaml.Controls.Primitives.h>
#include <winrt/Windows.UI.Xaml.Data.h>
#include <winrt/Windows.Ui.Xaml.Documents.h>
#include <winrt/Windows.UI.Xaml.Media.h>
#include <winrt/Windows.UI.Xaml.Media.Imaging.h>
#include <winrt/Windows.UI.Xaml.Input.h>
Expand Down
11 changes: 11 additions & 0 deletions src/cascadia/TerminalCore/Terminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,17 @@ std::wstring Terminal::GetHyperlinkAtPosition(const COORD position)
return {};
}

// Method Description:
// - Gets the hyperlink ID of the text at the given terminal position
// Arguments:
// - The position of the text
// Return value:
// - The hyperlink ID
uint16_t Terminal::GetHyperlinkIdAtPosition(const COORD position)
{
return _buffer->GetCellDataAt(_ConvertToBufferCell(position))->TextAttr().GetHyperlinkId();
}

// Method Description:
// - Send this particular (non-character) key event to the terminal.
// - The terminal will translate the key and the modifiers pressed into the
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalCore/Terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class Microsoft::Terminal::Core::Terminal final :
bool IsTrackingMouseInput() const noexcept;

std::wstring GetHyperlinkAtPosition(const COORD position);
uint16_t GetHyperlinkIdAtPosition(const COORD position);
#pragma endregion

#pragma region IBaseData(base to IRenderData and IUiaData)
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/base/renderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,11 @@ IRenderEngine::GridLines Renderer::s_GetGridlines(const TextAttribute& textAttri
{
lines |= IRenderEngine::GridLines::DoubleUnderline;
}

if (textAttribute.IsHyperlink())
{
lines |= IRenderEngine::GridLines::HyperlinkUnderline;
}
return lines;
}

Expand Down
48 changes: 44 additions & 4 deletions src/renderer/dx/DxRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ static constexpr D2D1_ALPHA_MODE _dxgiAlphaToD2d1Alpha(DXGI_ALPHA_MODE mode) noe
RETURN_IF_FAILED(_d2dDeviceContext->CreateSolidColorBrush(D2D1::ColorF(D2D1::ColorF::White),
&_d2dBrushForeground));

const D2D1_STROKE_STYLE_PROPERTIES strokeStyleProperties{
_strokeStyleProperties = D2D1_STROKE_STYLE_PROPERTIES{
D2D1_CAP_STYLE_SQUARE, // startCap
D2D1_CAP_STYLE_SQUARE, // endCap
D2D1_CAP_STYLE_SQUARE, // dashCap
Expand All @@ -627,7 +627,19 @@ static constexpr D2D1_ALPHA_MODE _dxgiAlphaToD2d1Alpha(DXGI_ALPHA_MODE mode) noe
D2D1_DASH_STYLE_SOLID, // dashStyle
0.f, // dashOffset
};
RETURN_IF_FAILED(_d2dFactory->CreateStrokeStyle(&strokeStyleProperties, nullptr, 0, &_strokeStyle));
RETURN_IF_FAILED(_d2dFactory->CreateStrokeStyle(&_strokeStyleProperties, nullptr, 0, &_strokeStyle));

_dashStrokeStyleProperties = D2D1_STROKE_STYLE_PROPERTIES{
D2D1_CAP_STYLE_SQUARE, // startCap
D2D1_CAP_STYLE_SQUARE, // endCap
D2D1_CAP_STYLE_SQUARE, // dashCap
D2D1_LINE_JOIN_MITER, // lineJoin
0.f, // miterLimit
D2D1_DASH_STYLE_DASH, // dashStyle
0.f, // dashOffset
};
RETURN_IF_FAILED(_d2dFactory->CreateStrokeStyle(&_dashStrokeStyleProperties, nullptr, 0, &_dashStrokeStyle));
_hyperlinkStrokeStyle = _dashStrokeStyle;

// If in composition mode, apply scaling factor matrix
if (_chainMode == SwapChainMode::ForComposition)
Expand Down Expand Up @@ -1492,6 +1504,10 @@ try
_d2dDeviceContext->DrawLine({ x0, y0 }, { x1, y1 }, _d2dBrushForeground.Get(), strokeWidth, _strokeStyle.Get());
};

const auto DrawHyperlinkLine = [=](const auto x0, const auto y0, const auto x1, const auto y1, const auto strokeWidth) noexcept {
_d2dDeviceContext->DrawLine({ x0, y0 }, { x1, y1 }, _d2dBrushForeground.Get(), strokeWidth, _hyperlinkStrokeStyle.Get());
};

// NOTE: Line coordinates are centered within the line, so they need to be
// offset by half the stroke width. For the start coordinate we add half
// the stroke width, and for the end coordinate we subtract half the width.
Expand Down Expand Up @@ -1543,17 +1559,26 @@ try
// In the case of the underline and strikethrough offsets, the stroke width
// is already accounted for, so they don't require further adjustments.

if (lines & (GridLines::Underline | GridLines::DoubleUnderline))
if (lines & (GridLines::Underline | GridLines::DoubleUnderline | GridLines::HyperlinkUnderline))
{
const auto halfUnderlineWidth = _lineMetrics.underlineWidth / 2.0f;
const auto startX = target.x + halfUnderlineWidth;
const auto endX = target.x + fullRunWidth - halfUnderlineWidth;
const auto y = target.y + _lineMetrics.underlineOffset;

DrawLine(startX, y, endX, y, _lineMetrics.underlineWidth);
if (lines & GridLines::Underline)
{
DrawLine(startX, y, endX, y, _lineMetrics.underlineWidth);
}

if (lines & GridLines::HyperlinkUnderline)
{
DrawHyperlinkLine(startX, y, endX, y, _lineMetrics.underlineWidth);
}

if (lines & GridLines::DoubleUnderline)
{
DrawLine(startX, y, endX, y, _lineMetrics.underlineWidth);
const auto y2 = target.y + _lineMetrics.underlineOffset2;
DrawLine(startX, y2, endX, y2, _lineMetrics.underlineWidth);
}
Expand Down Expand Up @@ -1715,6 +1740,11 @@ CATCH_RETURN()
_drawingContext->forceGrayscaleAA = _ShouldForceGrayscaleAA();
}

if (textAttributes.IsHyperlink())
{
_hyperlinkStrokeStyle = (textAttributes.GetHyperlinkId() == _hyperlinkHoveredId) ? _strokeStyle : _dashStrokeStyle;
}

return S_OK;
}

Expand Down Expand Up @@ -2403,6 +2433,16 @@ try
}
CATCH_LOG()

// Method Description:
// - Updates our internal tracker for which hyperlink ID we are hovering over
// This is needed for UpdateDrawingBrushes to know where we need to set a different style
// Arguments:
// - The new link ID we are hovering over
void DxEngine::UpdateHyperlinkHoveredId(const uint16_t hoveredId) noexcept
{
_hyperlinkHoveredId = hoveredId;
}

// Method Description:
// - Informs this render engine about certain state for this frame at the
// beginning of this frame. We'll use it to get information about the cursor
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/dx/DxRenderer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ namespace Microsoft::Console::Render
void SetAntialiasingMode(const D2D1_TEXT_ANTIALIAS_MODE antialiasingMode) noexcept;
void SetDefaultTextBackgroundOpacity(const float opacity) noexcept;

void UpdateHyperlinkHoveredId(const uint16_t hoveredId) noexcept;

protected:
[[nodiscard]] HRESULT _DoUpdateTitle(_In_ const std::wstring& newTitle) noexcept override;
[[nodiscard]] HRESULT _PaintTerminalEffects() noexcept;
Expand Down Expand Up @@ -164,6 +166,8 @@ namespace Microsoft::Console::Render
D2D1_COLOR_F _backgroundColor;
D2D1_COLOR_F _selectionBackground;

uint16_t _hyperlinkHoveredId;

bool _firstFrame;
bool _invalidateFullRows;
til::bitmap _invalidMap;
Expand All @@ -188,6 +192,11 @@ namespace Microsoft::Console::Render
::Microsoft::WRL::ComPtr<CustomTextLayout> _customLayout;
::Microsoft::WRL::ComPtr<CustomTextRenderer> _customRenderer;
::Microsoft::WRL::ComPtr<ID2D1StrokeStyle> _strokeStyle;
::Microsoft::WRL::ComPtr<ID2D1StrokeStyle> _dashStrokeStyle;
::Microsoft::WRL::ComPtr<ID2D1StrokeStyle> _hyperlinkStrokeStyle;

D2D1_STROKE_STYLE_PROPERTIES _strokeStyleProperties;
D2D1_STROKE_STYLE_PROPERTIES _dashStrokeStyleProperties;

// Device-Dependent Resources
bool _recreateDeviceRequested;
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/inc/IRenderEngine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ namespace Microsoft::Console::Render
Right = 0x8,
Underline = 0x10,
DoubleUnderline = 0x20,
Strikethrough = 0x40
Strikethrough = 0x40,
HyperlinkUnderline = 0x80
};

virtual ~IRenderEngine() = 0;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/vt/vtrenderer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ 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;

Expand Down

0 comments on commit be50e56

Please sign in to comment.