Skip to content

Commit 3620634

Browse files
committed
feat: HtmlControl keyboard navigation, perf fixes, image sizing, docs
HtmlControl: - Keyboard link navigation (Tab/Shift+Tab/Enter) - Focus-aware scrollbar (cyan when focused) - Visual link highlight (inverted colors for focused, bright for hovered) - Multi-line links grouped as single Tab stops via LinkId - Focus preservation across relayout Performance: - Cache parsed DOM in HtmlLayoutEngine — skip re-parse on width change - Debounce relayout during resize (150ms) — UI stays responsive Bug fixes: - Link highlight off-by-one (EndX now exclusive) - TryCommitPartialImageLayout missing InvalidateLinkCache - Image sizing respects HTML width attribute, natural size clamped Documentation: - Add docs/controls/HtmlControl.md - Add docs/ALPHA_BLENDING.md (transparent windows, TransparencyBrush) - Update CONTROLS.md and README.md with new doc links
1 parent c13a5cc commit 3620634

15 files changed

Lines changed: 805 additions & 25 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ Full reference: [nickprotop.github.io/ConsoleEx/docfx/_site/CONTROLS.html](https
246246
| [Tutorials](https://nickprotop.github.io/ConsoleEx/docfx/_site/tutorials/README.html) | Three step-by-step tutorials |
247247
| [Controls](https://nickprotop.github.io/ConsoleEx/docfx/_site/CONTROLS.html) | All 30+ controls |
248248
| [Examples](https://nickprotop.github.io/ConsoleEx/docfx/_site/EXAMPLES.html) | 20+ runnable example projects |
249+
| [Alpha Blending](https://nickprotop.github.io/ConsoleEx/docfx/_site/ALPHA_BLENDING.html) | Transparent windows & TransparencyBrush |
249250
| [Compositor Effects](https://nickprotop.github.io/ConsoleEx/docfx/_site/COMPOSITOR_EFFECTS.html) | PreBufferPaint / PostBufferPaint |
250251
| [Video Playback](https://nickprotop.github.io/ConsoleEx/docfx/_site/VIDEO_PLAYBACK.html) | VideoControl reference |
251252
| [Panel System](https://nickprotop.github.io/ConsoleEx/docfx/_site/PANELS.html) | Taskbar, Start Menu, Clock |

SharpConsoleUI/Controls/HtmlControl/HtmlControl.Keyboard.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,60 @@
66
// License: MIT
77
// -----------------------------------------------------------------------
88

9+
using SharpConsoleUI.Events;
10+
911
namespace SharpConsoleUI.Controls
1012
{
1113
public partial class HtmlControl
1214
{
15+
/// <summary>
16+
/// Whether this control wants to consume Tab key events (for link navigation).
17+
/// </summary>
18+
public bool WantsTabKey => true;
19+
1320
/// <inheritdoc/>
1421
public bool ProcessKey(ConsoleKeyInfo key)
1522
{
1623
if (!_isEnabled || !HasFocus)
1724
return false;
1825

19-
// Don't consume modifier keys
26+
// Tab / Shift+Tab — navigate between links
27+
if (key.Key == ConsoleKey.Tab)
28+
{
29+
var links = GetAllLinks();
30+
if (links.Count == 0)
31+
return false;
32+
33+
if (key.Modifiers.HasFlag(ConsoleModifiers.Shift))
34+
{
35+
// Previous link (wrap to last)
36+
FocusedLinkIndex = _focusedLinkIndex <= 0
37+
? links.Count - 1
38+
: _focusedLinkIndex - 1;
39+
}
40+
else
41+
{
42+
// Next link (wrap to first)
43+
FocusedLinkIndex = _focusedLinkIndex >= links.Count - 1
44+
? 0
45+
: _focusedLinkIndex + 1;
46+
}
47+
return true;
48+
}
49+
50+
// Enter — activate focused link
51+
if (key.Key == ConsoleKey.Enter && _focusedLinkIndex >= 0)
52+
{
53+
var links = GetAllLinks();
54+
if (_focusedLinkIndex < links.Count)
55+
{
56+
var focused = links[_focusedLinkIndex];
57+
LinkClicked?.Invoke(this, new LinkClickedEventArgs(focused.link.Url, focused.link.Text));
58+
return true;
59+
}
60+
}
61+
62+
// Don't consume other modifier keys
2063
if (key.Modifiers.HasFlag(ConsoleModifiers.Alt) || key.Modifiers.HasFlag(ConsoleModifiers.Control))
2164
return false;
2265

SharpConsoleUI/Controls/HtmlControl/HtmlControl.Mouse.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ public bool ProcessMouseEvent(MouseEventArgs args)
141141
var link = _isNavigating ? null : FindLinkAtPosition(args, viewportHeight, contentWidth - scrollbarWidth);
142142
if (link.HasValue)
143143
{
144+
// Sync focused link index with clicked link
145+
SyncFocusedLinkFromMouse(link.Value);
144146
LinkClicked?.Invoke(this, new LinkClickedEventArgs(link.Value.Url, link.Value.Text, args));
145147
args.Handled = true;
146148
return true;
@@ -299,6 +301,23 @@ private void HandleLinkHover(MouseEventArgs args, int viewportHeight, int render
299301
return null;
300302
}
301303

304+
/// <summary>
305+
/// Syncs the focused link index with a mouse-clicked link.
306+
/// </summary>
307+
private void SyncFocusedLinkFromMouse((string Url, string Text, int LineIndex, int LinkIndex) clicked)
308+
{
309+
var links = GetAllLinks();
310+
for (int i = 0; i < links.Count; i++)
311+
{
312+
if (links[i].lineIndex == clicked.LineIndex && links[i].linkIndex == clicked.LinkIndex)
313+
{
314+
_focusedLinkIndex = i;
315+
Container?.Invalidate(true);
316+
return;
317+
}
318+
}
319+
}
320+
302321
#endregion
303322
}
304323
}

SharpConsoleUI/Controls/HtmlControl/HtmlControl.Rendering.cs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,25 @@ public override LayoutSize MeasureDOM(LayoutConstraints constraints)
4646
// Re-layout only if width actually changed AND we have a real width (not huge)
4747
if (layoutWidth != _lastLayoutWidth && _rawHtml != null && layoutWidth < 10000)
4848
{
49-
lock (_contentLock)
49+
// Debounce: defer relayout during rapid width changes (e.g., window resize).
50+
// The cached DOM parse means re-flow is cheaper, but for large documents
51+
// even the flow pass can be expensive. Schedule relayout after 150ms idle.
52+
_pendingLayoutWidth = layoutWidth;
53+
if (_resizeDebounceTimer == null)
5054
{
51-
RunLayout(layoutWidth);
55+
_resizeDebounceTimer = new System.Timers.Timer(150) { AutoReset = false };
56+
_resizeDebounceTimer.Elapsed += (_, _) =>
57+
{
58+
lock (_contentLock)
59+
{
60+
if (_pendingLayoutWidth > 0 && _pendingLayoutWidth != _lastLayoutWidth)
61+
RunLayout(_pendingLayoutWidth);
62+
}
63+
Container?.Invalidate(true);
64+
};
5265
}
66+
_resizeDebounceTimer.Stop();
67+
_resizeDebounceTimer.Start();
5368
}
5469

5570
int measuredWidth = targetWidth;
@@ -87,13 +102,8 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
87102
// between measure and paint on large documents (see ComputeLayoutWidth).
88103
int layoutWidth = ComputeLayoutWidth(contentWidth);
89104

90-
if (layoutWidth != _lastLayoutWidth && _rawHtml != null)
91-
{
92-
lock (_contentLock)
93-
{
94-
RunLayout(layoutWidth);
95-
}
96-
}
105+
// Note: relayout on width change is handled by MeasureDOM with debouncing.
106+
// PaintDOM renders from the current (possibly stale) layout snapshot.
97107

98108
int contentAreaX = bounds.X + Margin.Left;
99109
int contentAreaY = bounds.Y + Margin.Top;
@@ -171,15 +181,21 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
171181
}
172182

173183
buffer.WriteCellsClipped(screenX, screenY, line.Cells, tightClip);
184+
185+
// Highlight links on this line (focused and hovered)
186+
if (line.Links != null)
187+
{
188+
HighlightLinksOnLine(buffer, i, ref line, screenX, screenY, tightClip);
189+
}
174190
}
175191
}
176192

177193
// Draw scrollbar
178194
if (needsScrollbar)
179195
{
180196
int scrollbarX = contentAreaX + contentWidth - 1;
181-
var thumbColor = Color.Grey;
182-
var trackColor = Color.Grey23;
197+
var thumbColor = HasFocus ? Color.Cyan1 : Color.Grey;
198+
var trackColor = HasFocus ? Color.Grey : Color.Grey23;
183199

184200
ScrollbarHelper.DrawVerticalScrollbar(
185201
buffer, scrollbarX, contentAreaY, viewportHeight,
@@ -265,5 +281,48 @@ private void DrawLoadingOverlay(CharacterBuffer buffer, int areaX, int areaY, in
265281
}
266282
}
267283
}
284+
/// <summary>
285+
/// Highlights focused and hovered links on a rendered line by overriding cell colors.
286+
/// </summary>
287+
private void HighlightLinksOnLine(CharacterBuffer buffer, int lineIndex, ref LayoutLine line,
288+
int screenX, int screenY, LayoutRect clipRect)
289+
{
290+
int focusedLinkId = GetFocusedLinkId();
291+
292+
for (int j = 0; j < line.Links!.Length; j++)
293+
{
294+
ref var link = ref line.Links[j];
295+
296+
// Check if this segment belongs to the focused link (by LinkId — covers all segments)
297+
bool isFocused = focusedLinkId >= 0 && link.LinkId == focusedLinkId;
298+
bool isHovered = lineIndex == _hoveredLinkLineIndex && j == _hoveredLinkIndex;
299+
300+
if (!isFocused && !isHovered) continue;
301+
302+
// Apply highlight colors
303+
for (int x = link.StartX; x < link.EndX; x++)
304+
{
305+
int cellX = screenX + x;
306+
if (cellX < clipRect.X || cellX >= clipRect.Right) continue;
307+
if (screenY < clipRect.Y || screenY >= clipRect.Bottom) continue;
308+
309+
var cell = buffer.GetCell(cellX, screenY);
310+
if (isFocused)
311+
{
312+
// Focused link: inverted colors for clear visibility
313+
buffer.SetCellColors(cellX, screenY, cell.Background, cell.Foreground);
314+
}
315+
else if (isHovered)
316+
{
317+
// Hovered link: subtle underline-like highlight (brighter foreground)
318+
var brightFg = new Color(
319+
(byte)Math.Min(255, cell.Foreground.R + 60),
320+
(byte)Math.Min(255, cell.Foreground.G + 60),
321+
(byte)Math.Min(255, cell.Foreground.B + 60));
322+
buffer.SetCellColors(cellX, screenY, brightFg, cell.Background);
323+
}
324+
}
325+
}
326+
}
268327
}
269328
}

0 commit comments

Comments
 (0)