From 2060676a5392545986903022e5aa1c2a4a90ca79 Mon Sep 17 00:00:00 2001 From: Steffen Carlsen Date: Fri, 5 Jun 2026 03:50:31 +0200 Subject: [PATCH 1/2] Fix tab header measured hit testing --- src/ModernOverlay.UI/AdvancedControls.cs | 34 +++++++++++++++++-- .../OverlayUiTabSegmentedTests.cs | 32 +++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/ModernOverlay.UI/AdvancedControls.cs b/src/ModernOverlay.UI/AdvancedControls.cs index 88668d2..da09a66 100644 --- a/src/ModernOverlay.UI/AdvancedControls.cs +++ b/src/ModernOverlay.UI/AdvancedControls.cs @@ -305,6 +305,8 @@ public sealed class TabControl : UiPanel { private const float HeaderHeight = 30f; private int selectedIndex = -1; + private float[] renderedHeaderWidths = []; + private string[] renderedHeaderTexts = []; /// /// Initializes a new instance of the class. @@ -388,10 +390,15 @@ protected override void RenderCore(UiRenderContext context) } float x = Bounds.X; + float[] headerWidths = Items.Count == 0 ? [] : new float[Items.Count]; + string[] headerTexts = Items.Count == 0 ? [] : new string[Items.Count]; for (int index = 0; index < Items.Count; index++) { TabItem item = Items[index]; - float width = item.Header.Length * context.Theme.Theme.FontSize * 0.62f + 24f; + SizeF textSize = context.Draw.Measure.Text(item.Header, context.Theme.Font); + float width = textSize.Width + 24f; + headerWidths[index] = width; + headerTexts[index] = item.Header; RectF tab = new(x, Bounds.Y, width, HeaderHeight); bool itemEnabled = enabled && item.IsEnabled; if (index == SelectedIndex && itemEnabled) @@ -399,12 +406,13 @@ protected override void RenderCore(UiRenderContext context) context.Draw.Fill.Rectangle(new RectF(tab.X + 8f, tab.Y + tab.Height - 4f, MathF.Max(0f, tab.Width - 16f), 4f), context.Theme.Accent); } - SizeF textSize = context.Draw.Measure.Text(item.Header, context.Theme.Font); float textX = tab.X + MathF.Max(0f, tab.Width - textSize.Width) / 2f; context.Draw.Draw.Text(item.Header, context.Theme.Font, itemEnabled ? context.Theme.Foreground : context.Theme.Disabled, new PointF(textX, tab.Y + 7f)); x += width + 2f; } + renderedHeaderWidths = headerWidths; + renderedHeaderTexts = headerTexts; ActiveContent?.Render(context); } @@ -467,7 +475,7 @@ private int HeaderIndexAt(PointF point) float x = Bounds.X; for (int index = 0; index < Items.Count; index++) { - float width = Items[index].Header.Length * (Root?.ThemeResources.Theme.FontSize ?? UiTheme.Default.FontSize) * 0.62f + 24f; + float width = HeaderWidth(index); RectF header = new(x, Bounds.Y, width, HeaderHeight); if (UiGeometry.ContainsInputBand(header, point)) { @@ -480,6 +488,26 @@ private int HeaderIndexAt(PointF point) return -1; } + private float HeaderWidth(int index) + { + string header = Items[index].Header; + return TryGetRenderedHeaderWidth(index, header, out float width) + ? width + : header.Length * (Root?.ThemeResources.Theme.FontSize ?? UiTheme.Default.FontSize) * 0.62f + 24f; + } + + private bool TryGetRenderedHeaderWidth(int index, string header, out float width) + { + bool hasWidth = renderedHeaderWidths.Length == Items.Count + && renderedHeaderTexts.Length == Items.Count + && index >= 0 + && index < renderedHeaderWidths.Length + && string.Equals(renderedHeaderTexts[index], header, StringComparison.Ordinal); + + width = hasWidth ? renderedHeaderWidths[index] : 0f; + return hasWidth; + } + private void MoveSelection(int direction) { if (Items.Count == 0) diff --git a/tests/ModernOverlay.Tests/OverlayUiTabSegmentedTests.cs b/tests/ModernOverlay.Tests/OverlayUiTabSegmentedTests.cs index 5a9bde9..bdc1429 100644 --- a/tests/ModernOverlay.Tests/OverlayUiTabSegmentedTests.cs +++ b/tests/ModernOverlay.Tests/OverlayUiTabSegmentedTests.cs @@ -49,7 +49,7 @@ public async Task TabPointerBoundaryKeepsPreviousRenderedHeader() using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false }); UiTabControl tabs = CreateTabs(out _, out _, out _); ui.Root.Children.Add(tabs); - ui.Render(new DrawContext()); + ui.Render(new DrawContext(new RecordingDrawCommandSink())); float boundaryX = 10f + TabHeaderWidth("One"); ClickUi(ui, new PointF(boundaryX, 20f)); @@ -118,6 +118,32 @@ public async Task TabHeadersRenderTextCenteredByDefault() Assert.AreEqual(expectedX, oneOrigin.X, 0.001f); } + [TestMethod] + [TestCategory("WindowsIntegration")] + public async Task TabHeaderHitTestingUsesMeasuredTextWidth() + { + await using OverlayWindow overlay = await CreateOverlayAsync(); + using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false }); + UiTabControl tabs = new() + { + Width = 220f, + Height = 120f, + MinWidth = 0f, + MinHeight = 0f, + }; + Canvas.SetLeft(tabs, 10f); + Canvas.SetTop(tabs, 10f); + tabs.Add("WWW", new ProbeElement()); + tabs.Add("Two", new ProbeElement()); + tabs.SelectedIndex = 1; + ui.Root.Children.Add(tabs); + ui.Render(new DrawContext(new RecordingDrawCommandSink())); + + ClickUi(ui, new PointF(130f, 20f)); + + Assert.AreEqual(0, tabs.SelectedIndex); + } + [TestMethod] [TestCategory("WindowsIntegration")] public async Task SegmentedControlPointerAndKeyboardSelection() @@ -353,7 +379,9 @@ public SizeF MeasureTextLayout(TextLayoutHandle layout) => new(MeasureTextWidth(layout.Text), layout.Font.Options.Size); public static float MeasureTextWidth(string text) - => text.Length * UiTheme.Default.FontSize * 0.62f; + => text == "WWW" + ? 120f + : text.Length * UiTheme.Default.FontSize * 0.62f; private void AddPrimitive() { From 3fc7a8a418b8cbbee097711c4c1ea51664b25a7b Mon Sep 17 00:00:00 2001 From: Steffen Carlsen Date: Thu, 18 Jun 2026 19:56:02 +0200 Subject: [PATCH 2/2] Reuse tab header measurement cache --- src/ModernOverlay.UI/AdvancedControls.cs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ModernOverlay.UI/AdvancedControls.cs b/src/ModernOverlay.UI/AdvancedControls.cs index da09a66..aa06b8b 100644 --- a/src/ModernOverlay.UI/AdvancedControls.cs +++ b/src/ModernOverlay.UI/AdvancedControls.cs @@ -389,16 +389,15 @@ protected override void RenderCore(UiRenderContext context) context.Draw.Draw.Line(new PointF(Bounds.X, Bounds.Y + HeaderHeight - 1f), new PointF(Bounds.X + Bounds.Width, Bounds.Y + HeaderHeight - 1f), context.Theme.Accent); } + EnsureRenderedHeaderCache(); float x = Bounds.X; - float[] headerWidths = Items.Count == 0 ? [] : new float[Items.Count]; - string[] headerTexts = Items.Count == 0 ? [] : new string[Items.Count]; for (int index = 0; index < Items.Count; index++) { TabItem item = Items[index]; SizeF textSize = context.Draw.Measure.Text(item.Header, context.Theme.Font); float width = textSize.Width + 24f; - headerWidths[index] = width; - headerTexts[index] = item.Header; + renderedHeaderWidths[index] = width; + renderedHeaderTexts[index] = item.Header; RectF tab = new(x, Bounds.Y, width, HeaderHeight); bool itemEnabled = enabled && item.IsEnabled; if (index == SelectedIndex && itemEnabled) @@ -411,8 +410,6 @@ protected override void RenderCore(UiRenderContext context) x += width + 2f; } - renderedHeaderWidths = headerWidths; - renderedHeaderTexts = headerTexts; ActiveContent?.Render(context); } @@ -508,6 +505,19 @@ private bool TryGetRenderedHeaderWidth(int index, string header, out float width return hasWidth; } + private void EnsureRenderedHeaderCache() + { + if (renderedHeaderWidths.Length != Items.Count) + { + renderedHeaderWidths = Items.Count == 0 ? [] : new float[Items.Count]; + } + + if (renderedHeaderTexts.Length != Items.Count) + { + renderedHeaderTexts = Items.Count == 0 ? [] : new string[Items.Count]; + } + } + private void MoveSelection(int direction) { if (Items.Count == 0)