From d28bc8cf4c08185d1ea5ec989e59164de4994d64 Mon Sep 17 00:00:00 2001
From: DraftingDreamer <264591489+DraftingDreamer@users.noreply.github.com>
Date: Sun, 26 Jul 2026 07:35:09 +0800
Subject: [PATCH 1/3] fix(TextAreaControl): measure glyph width for the
horizontal scroll extent
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.
---
Project/Src/Gui/TextAreaControl.cs | 35 +++++++++-----
Project/Src/Gui/TextView.cs | 31 +++++++++++++
Test/TextEditorControl/ScrollBarTests.cs | 59 ++++++++++++++++++++++++
3 files changed, 114 insertions(+), 11 deletions(-)
diff --git a/Project/Src/Gui/TextAreaControl.cs b/Project/Src/Gui/TextAreaControl.cs
index 827aa14..bbb7d29 100644
--- a/Project/Src/Gui/TextAreaControl.cs
+++ b/Project/Src/Gui/TextAreaControl.cs
@@ -280,23 +280,33 @@ static ScrollVisibilities GetScrollVisibilities(bool h, bool v)
lineLengthCache = new int[lastLineIndex + LineLengthCacheAdditionalSize];
var maxLength = 0;
- for (var lineIndex = firstLineIndex; lineIndex <= lastLineIndex; lineIndex++)
+ 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)
+ var lineSegment = Document.GetLineSegment(lineIndex);
+ if (Document.FoldingManager.IsLineVisible(lineIndex))
{
- maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]);
- }
- else
- {
- var visualLength = view.GetVisualColumnFast(lineSegment, lineSegment.Length);
- lineLengthCache[lineIndex] = Math.Max(1, visualLength);
- maxLength = Math.Max(maxLength, visualLength);
+ if (lineLengthCache[lineIndex] > 0)
+ {
+ maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]);
+ }
+ else
+ {
+ // created lazily - once the cache is warm no measuring is needed at all
+ 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);
@@ -337,6 +347,9 @@ public void OptionsChanged()
{
TextArea.OptionsChanged();
+ // the cached line widths are measured with the current font, so they are stale after a font change
+ AdjustScrollBarsClearCache();
+
if (TextArea.TextEditorProperties.ShowHorizontalRuler)
{
if (hRuler == null)
diff --git a/Project/Src/Gui/TextView.cs b/Project/Src/Gui/TextView.cs
index 9e109c3..e0c29a9 100644
--- a/Project/Src/Gui/TextView.cs
+++ b/Project/Src/Gui/TextView.cs
@@ -699,6 +699,37 @@ 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 and
+ /// mirrors the tab handling of .
+ ///
+ public int GetVisualWidthColumns(Graphics g, LineSegment line)
+ {
+ var font = TextEditorProperties.FontContainer.RegularFont;
+ var tabWidth = WideSpaceWidth*Document.TextEditorProperties.TabIndent;
+ var lineOffset = line.Offset;
+ var width = 0;
+
+ for (var i = 0; i < line.Length; ++i)
+ {
+ var ch = Document.GetCharAt(lineOffset + i);
+ if (ch == '\t')
+ width = (width + MinTabWidth)/tabWidth*tabWidth + tabWidth;
+ else
+ width += GetWidth(g, ch, font);
+ }
+
+ // 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 f23e59ce28092c3f9861b3ede687dc2466977b80 Mon Sep 17 00:00:00 2001
From: DraftingDreamer <264591489+DraftingDreamer@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:36:15 +0800
Subject: [PATCH 2/3] Measure per word with the highlighting font, per review
feedback
PaintLinePart draws each TextWord with the font the highlighting assigns to it,
so measuring the whole line with RegularFont could still be off - by 1px per CJK
glyph in bold Consolas, and by ~14% for a bold run in a proportional font.
Walk line.Words instead and use word.GetFont, keeping the tab-stop arithmetic.
LineSegment.Words is null until the line has been highlighted (CountColumns
guards against that too), and it may cover only part of the line, so anything
left over is still measured per character with the regular font rather than
being silently dropped.
Also widen the cache invalidation comment: the cached widths depend on tab size
and highlighting as well as on the font.
---
Project/Src/Gui/TextAreaControl.cs | 3 ++-
Project/Src/Gui/TextView.cs | 38 ++++++++++++++++++++++++++----
2 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/Project/Src/Gui/TextAreaControl.cs b/Project/Src/Gui/TextAreaControl.cs
index bbb7d29..c3cf36e 100644
--- a/Project/Src/Gui/TextAreaControl.cs
+++ b/Project/Src/Gui/TextAreaControl.cs
@@ -347,7 +347,8 @@ public void OptionsChanged()
{
TextArea.OptionsChanged();
- // the cached line widths are measured with the current font, so they are stale after a font change
+ // 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)
diff --git a/Project/Src/Gui/TextView.cs b/Project/Src/Gui/TextView.cs
index e0c29a9..f27750f 100644
--- a/Project/Src/Gui/TextView.cs
+++ b/Project/Src/Gui/TextView.cs
@@ -707,23 +707,51 @@ public int GetVisualColumnFast(LineSegment line, int logicalColumn)
/// 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 and
- /// mirrors the tab handling of .
+ /// 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 font = TextEditorProperties.FontContainer.RegularFont;
+ 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 != 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,
+ Document.GetText(lineOffset + measured, word.Length),
+ word.GetFont(fontContainer) ?? fontContainer.RegularFont);
+ break;
+ }
+
+ measured += word.Length;
+ }
- for (var i = 0; i < line.Length; ++i)
+ // 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, font);
+ width += GetWidth(g, ch, fontContainer.RegularFont);
}
// round up so that a glyph which only partially reaches into the last column stays reachable
From 310d21ab4174ddcfaee210ad63c67949341de7ab Mon Sep 17 00:00:00 2001
From: DraftingDreamer <264591489+DraftingDreamer@users.noreply.github.com>
Date: Thu, 30 Jul 2026 22:19:52 +0800
Subject: [PATCH 3/3] Address review: word.Word, braces, and a long lived
measuring Graphics
- Use word.Word instead of recomputing the text from the running offset, the
same way PaintLinePart does.
- Braces around the word loop and "is not null".
- Keep the measuring Graphics as a field of TextAreaControl instead of creating
and disposing one per layout pass, and dispose it with the control. It is
created on first use rather than in the constructor, because TextArea has no
handle yet while TextAreaControl is being constructed.
---
Project/Src/Gui/TextAreaControl.cs | 47 +++++++++++++++++-------------
Project/Src/Gui/TextView.cs | 9 +++---
2 files changed, 30 insertions(+), 26 deletions(-)
diff --git a/Project/Src/Gui/TextAreaControl.cs b/Project/Src/Gui/TextAreaControl.cs
index c3cf36e..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);
@@ -280,33 +294,24 @@ static ScrollVisibilities GetScrollVisibilities(bool h, bool v)
lineLengthCache = new int[lastLineIndex + LineLengthCacheAdditionalSize];
var maxLength = 0;
- Graphics measureGraphics = null;
- try
+ for (var lineIndex = firstLineIndex; lineIndex <= lastLineIndex; lineIndex++)
{
- for (var lineIndex = firstLineIndex; lineIndex <= lastLineIndex; lineIndex++)
+ var lineSegment = Document.GetLineSegment(lineIndex);
+ if (Document.FoldingManager.IsLineVisible(lineIndex))
{
- var lineSegment = Document.GetLineSegment(lineIndex);
- if (Document.FoldingManager.IsLineVisible(lineIndex))
+ if (lineLengthCache[lineIndex] > 0)
+ {
+ maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]);
+ }
+ else
{
- if (lineLengthCache[lineIndex] > 0)
- {
- maxLength = Math.Max(maxLength, lineLengthCache[lineIndex]);
- }
- else
- {
- // created lazily - once the cache is warm no measuring is needed at all
- measureGraphics ??= TextArea.CreateGraphics();
- var visualLength = view.GetVisualWidthColumns(measureGraphics, lineSegment);
- lineLengthCache[lineIndex] = Math.Max(1, visualLength);
- maxLength = Math.Max(maxLength, visualLength);
- }
+ 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);
diff --git a/Project/Src/Gui/TextView.cs b/Project/Src/Gui/TextView.cs
index f27750f..e796811 100644
--- a/Project/Src/Gui/TextView.cs
+++ b/Project/Src/Gui/TextView.cs
@@ -719,7 +719,8 @@ public int GetVisualWidthColumns(Graphics g, LineSegment line)
var measured = 0;
var words = line.Words;
- if (words != null)
+ if (words is not null)
+ {
for (var i = 0; i < words.Count; i++)
{
var word = words[i];
@@ -732,15 +733,13 @@ public int GetVisualWidthColumns(Graphics g, LineSegment line)
width = (width + MinTabWidth)/tabWidth*tabWidth + tabWidth;
break;
default:
- width += MeasureStringWidth(
- g,
- Document.GetText(lineOffset + measured, word.Length),
- word.GetFont(fontContainer) ?? fontContainer.RegularFont);
+ 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