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);