From 948ae7b1f0ec3b27961a1c5de6e093255341a18a Mon Sep 17 00:00:00 2001 From: DraftingDreamer <264591489+DraftingDreamer@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:27:08 +0800 Subject: [PATCH 1/2] fix(TextAreaControl): measure glyph width The horizontal scrollbar works in WideSpaceWidth units (the width of 'x'), but its extent was derived from GetVisualColumnFast, which counts characters and assumes every one of them is exactly one column wide. Any glyph wider than 'x' - CJK, emoji, glyphs coming from a fallback font, or any proportional font - therefore makes the scroll range shorter than the line actually painted. Depending on how far off it is, either the scrollbar is not shown at all, or its thumb cannot be dragged to the end of the line. Setting HScrollBar.Value from code (caret movement, selection drag) uses the accurate GetVisualColumn and can reach Maximum, so those paths scroll further right than the thumb allows - which is how the mismatch becomes visible to the user. Replace the character count with GetVisualWidthColumns, which measures the glyphs using the existing per-character width cache and mirrors the tab handling of PaintLinePart. The cached line widths now depend on the font, so they are invalidated on OptionsChanged. Refs: #63 --- Project/Src/Gui/TextAreaControl.cs | 21 ++++++++- Project/Src/Gui/TextView.cs | 58 +++++++++++++++++++++++ Test/TextEditorControl/ScrollBarTests.cs | 59 ++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/Project/Src/Gui/TextAreaControl.cs b/Project/Src/Gui/TextAreaControl.cs index 827aa14..c6d624c 100644 --- a/Project/Src/Gui/TextAreaControl.cs +++ b/Project/Src/Gui/TextAreaControl.cs @@ -34,6 +34,14 @@ public class TextAreaControl : Panel private HRuler hRuler; private int[] lineLengthCache; + + /// + /// Kept for the lifetime of the control so that measuring line widths does not create a + /// device context per layout pass. Created on first use, because the handle it needs does + /// not exist yet while the control is being constructed. + /// + private Graphics measureGraphics; + private TextEditorControl motherTextEditorControl; private Point scrollToPosOnNextUpdate; @@ -106,6 +114,12 @@ protected override void Dispose(bool disposing) hRuler.Dispose(); hRuler = null; } + + if (measureGraphics != null) + { + measureGraphics.Dispose(); + measureGraphics = null; + } } base.Dispose(disposing); @@ -291,7 +305,8 @@ static ScrollVisibilities GetScrollVisibilities(bool h, bool v) } else { - var visualLength = view.GetVisualColumnFast(lineSegment, lineSegment.Length); + measureGraphics ??= TextArea.CreateGraphics(); + var visualLength = view.GetVisualWidthColumns(measureGraphics, lineSegment); lineLengthCache[lineIndex] = Math.Max(1, visualLength); maxLength = Math.Max(maxLength, visualLength); } @@ -337,6 +352,10 @@ public void OptionsChanged() { TextArea.OptionsChanged(); + // the cached line widths depend on the rendering options - font, tab size, highlighting - + // so any of them changing makes the cache stale + AdjustScrollBarsClearCache(); + if (TextArea.TextEditorProperties.ShowHorizontalRuler) { if (hRuler == null) diff --git a/Project/Src/Gui/TextView.cs b/Project/Src/Gui/TextView.cs index 9e109c3..e796811 100644 --- a/Project/Src/Gui/TextView.cs +++ b/Project/Src/Gui/TextView.cs @@ -699,6 +699,64 @@ public int GetVisualColumnFast(LineSegment line, int logicalColumn) return guessedColumn; } + /// + /// Returns the width of expressed in units, + /// which is the unit the horizontal scrollbar operates in. + /// + /// + /// assumes that every character is exactly one column wide. + /// That assumption does not hold for glyphs which are wider than 'x' - CJK characters, emoji, + /// glyphs taken from a fallback font, or any proportional font - so a line containing them ends up + /// being reported as narrower than it is painted. This method measures the glyphs instead, + /// word by word with the fonts the highlighting assigns, mirroring . + /// + public int GetVisualWidthColumns(Graphics g, LineSegment line) + { + var fontContainer = TextEditorProperties.FontContainer; + var tabWidth = WideSpaceWidth*Document.TextEditorProperties.TabIndent; + var lineOffset = line.Offset; + var width = 0; + var measured = 0; + + var words = line.Words; + if (words is not null) + { + for (var i = 0; i < words.Count; i++) + { + var word = words[i]; + switch (word.Type) + { + case TextWordType.Space: + width += SpaceWidth; + break; + case TextWordType.Tab: + width = (width + MinTabWidth)/tabWidth*tabWidth + tabWidth; + break; + default: + width += MeasureStringWidth(g, word.Word, word.GetFont(fontContainer) ?? fontContainer.RegularFont); + break; + } + + measured += word.Length; + } + } + + // Whatever the highlighting has not covered - the whole line while it has not run yet, or a + // trailing remainder - still has to be accounted for, otherwise the line comes out too narrow + // again. There is no per-word font for it, so use the one WideSpaceWidth itself is derived from. + for (var i = measured; i < line.Length; ++i) + { + var ch = Document.GetCharAt(lineOffset + i); + if (ch == '\t') + width = (width + MinTabWidth)/tabWidth*tabWidth + tabWidth; + else + width += GetWidth(g, ch, fontContainer.RegularFont); + } + + // round up so that a glyph which only partially reaches into the last column stays reachable + return (width + WideSpaceWidth - 1)/WideSpaceWidth; + } + /// /// returns line/column for a visual point position /// diff --git a/Test/TextEditorControl/ScrollBarTests.cs b/Test/TextEditorControl/ScrollBarTests.cs index e90d177..0c3c191 100644 --- a/Test/TextEditorControl/ScrollBarTests.cs +++ b/Test/TextEditorControl/ScrollBarTests.cs @@ -134,6 +134,65 @@ a VScrollBar _textEditorControl.ActiveTextAreaControl.HScrollBar.Visible.Should().BeFalse(); } + [Test] + public void HScrollBar_should_be_shown_when_wide_glyphs_exceed_the_visible_width() + { + SetupForm(width: 300, height: 200); + + var textAreaControl = _textEditorControl.ActiveTextAreaControl; + var view = textAreaControl.TextArea.TextView; + var font = view.TextEditorProperties.FontContainer.RegularFont; + + int glyphWidth; + using (var g = textAreaControl.CreateGraphics()) + { + glyphWidth = view.GetWidth(g, WideGlyph, font); + } + + // the test is only meaningful if the glyph really is wider than one column + glyphWidth.Should().BeGreaterThan(view.WideSpaceWidth); + + // exactly as many characters as there are visible columns: counting characters sees no + // overflow at all, while the glyphs need roughly twice that width to be painted + _textEditorControl.Text = new string(WideGlyph, view.DrawingPosition.Width / view.WideSpaceWidth); + + Application.DoEvents(); + + textAreaControl.HScrollBar.Visible.Should().BeTrue(); + } + + [Test] + public void HScrollBar_should_allow_scrolling_to_the_end_of_a_line_of_wide_glyphs() + { + SetupForm(width: 300, height: 200); + + _textEditorControl.Text = new string(WideGlyph, 100); + + Application.DoEvents(); + + var textAreaControl = _textEditorControl.ActiveTextAreaControl; + var view = textAreaControl.TextArea.TextView; + var font = view.TextEditorProperties.FontContainer.RegularFont; + + int lineWidth; + using (var g = textAreaControl.CreateGraphics()) + { + view.GetWidth(g, WideGlyph, font).Should().BeGreaterThan(view.WideSpaceWidth); + lineWidth = view.GetWidth(g, _textEditorControl.Text, font); + } + + // dragging the thumb all the way to the right stops at Maximum - LargeChange + 1; + // only setting HScrollBar.Value programmatically can go as far as Maximum + var hScrollBar = textAreaControl.HScrollBar; + var rightmostThumbValue = hScrollBar.Maximum - hScrollBar.LargeChange + 1; + var rightmostVisiblePixel = (rightmostThumbValue * view.WideSpaceWidth) + view.DrawingPosition.Width; + + rightmostVisiblePixel.Should().BeGreaterThanOrEqualTo(lineWidth); + } + + /// U+4E2D, a CJK ideograph: a single character which is painted about two columns wide. + private const char WideGlyph = (char)0x4E2D; + private void SetupForm(int width, int height) { _form = new Form From 114f0c543ec24ae27c6538deaeb7b69f08798242 Mon Sep 17 00:00:00 2001 From: Sandip Patel <55301948+SAPHemlock@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:12:24 -0700 Subject: [PATCH 2/2] fix(TextAreaControl): do not cache measuring DC A Graphics from Control.CreateGraphics() wraps a DC borrowed from the system cache and is bound to the control's current window handle. Keeping one for the lifetime of the control leaves a stale HDC behind once that handle goes away, so the next layout pass throws "Parameter is not valid" out of TextRenderer.MeasureText before it measures anything. Scope it to the layout pass instead, which is the pattern every other CreateGraphics() call in this library already uses. The ??= is kept, so a pass which only reads cached line widths still creates no device context at all. Refs: #64 Co-authored-by: Bhanu Santin Co-authored-by: Claude Opus 5 (1M context) --- Project/Src/Gui/TextAreaControl.cs | 50 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Project/Src/Gui/TextAreaControl.cs b/Project/Src/Gui/TextAreaControl.cs index c6d624c..d7241b6 100644 --- a/Project/Src/Gui/TextAreaControl.cs +++ b/Project/Src/Gui/TextAreaControl.cs @@ -35,13 +35,6 @@ public class TextAreaControl : Panel private int[] lineLengthCache; - /// - /// Kept for the lifetime of the control so that measuring line widths does not create a - /// device context per layout pass. Created on first use, because the handle it needs does - /// not exist yet while the control is being constructed. - /// - private Graphics measureGraphics; - private TextEditorControl motherTextEditorControl; private Point scrollToPosOnNextUpdate; @@ -114,12 +107,6 @@ protected override void Dispose(bool disposing) hRuler.Dispose(); hRuler = null; } - - if (measureGraphics != null) - { - measureGraphics.Dispose(); - measureGraphics = null; - } } base.Dispose(disposing); @@ -294,24 +281,37 @@ static ScrollVisibilities GetScrollVisibilities(bool h, bool v) lineLengthCache = new int[lastLineIndex + LineLengthCacheAdditionalSize]; var maxLength = 0; - for (var lineIndex = firstLineIndex; lineIndex <= lastLineIndex; lineIndex++) + + // The device context must not outlive this layout pass: a Graphics from + // Control.CreateGraphics() is bound to the control's current window handle and to a DC + // borrowed from the system cache, so keeping it across calls leaves a stale HDC which + // makes TextRenderer.MeasureText throw "Parameter is not valid". + Graphics measureGraphics = null; + try { - var lineSegment = Document.GetLineSegment(lineIndex); - if (Document.FoldingManager.IsLineVisible(lineIndex)) + for (var lineIndex = firstLineIndex; lineIndex <= lastLineIndex; lineIndex++) { - if (lineLengthCache[lineIndex] > 0) - { - maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]); - } - else + var lineSegment = Document.GetLineSegment(lineIndex); + if (Document.FoldingManager.IsLineVisible(lineIndex)) { - measureGraphics ??= TextArea.CreateGraphics(); - var visualLength = view.GetVisualWidthColumns(measureGraphics, lineSegment); - lineLengthCache[lineIndex] = Math.Max(1, visualLength); - maxLength = Math.Max(maxLength, visualLength); + if (lineLengthCache[lineIndex] > 0) + { + maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]); + } + else + { + measureGraphics ??= TextArea.CreateGraphics(); + var visualLength = view.GetVisualWidthColumns(measureGraphics, lineSegment); + lineLengthCache[lineIndex] = Math.Max(1, visualLength); + maxLength = Math.Max(maxLength, visualLength); + } } } } + finally + { + measureGraphics?.Dispose(); + } var hScrollBarVisible = HScrollBar.Value != 0 || maxLength > visibleColumnCount || (hScrollBarVisibleNow && VScrollBar.IsMouseDown); vScrollBarVisible = vScrollBarVisible || (hScrollBarVisible && hScrollBarWouldCauseVScrollBar);