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