Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion Project/Src/Gui/TextAreaControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ public class TextAreaControl : Panel
private HRuler hRuler;

private int[] lineLengthCache;

/// <summary>
/// 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.
/// </summary>
private Graphics measureGraphics;

private TextEditorControl motherTextEditorControl;
private Point scrollToPosOnNextUpdate;

Expand Down Expand Up @@ -106,6 +114,12 @@ protected override void Dispose(bool disposing)
hRuler.Dispose();
hRuler = null;
}

if (measureGraphics != null)
{
measureGraphics.Dispose();
measureGraphics = null;
}
}

base.Dispose(disposing);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions Project/Src/Gui/TextView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,64 @@ public int GetVisualColumnFast(LineSegment line, int logicalColumn)
return guessedColumn;
}

/// <summary>
/// Returns the width of <paramref name="line" /> expressed in <see cref="WideSpaceWidth" /> units,
/// which is the unit the horizontal scrollbar operates in.
/// </summary>
/// <remarks>
/// <see cref="GetVisualColumnFast" /> 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 <see cref="PaintLinePart" />.
/// </remarks>
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;
}

/// <summary>
/// returns line/column for a visual point position
/// </summary>
Expand Down
59 changes: 59 additions & 0 deletions Test/TextEditorControl/ScrollBarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>U+4E2D, a CJK ideograph: a single character which is painted about two columns wide.</summary>
private const char WideGlyph = (char)0x4E2D;

private void SetupForm(int width, int height)
{
_form = new Form
Expand Down