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