fix(TextAreaControl): measure glyph width for the horizontal scroll extent - #63
Conversation
…xtent 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.
There was a problem hiding this comment.
Pull request overview
This PR fixes horizontal scrollbar sizing in TextAreaControl by measuring the painted glyph widths (rather than counting characters) so wide glyphs (e.g., CJK) properly contribute to horizontal scroll extent, addressing gitextensions/gitextensions#13200.
Changes:
- Add
TextView.GetVisualWidthColumns()to compute a line’s visual width inWideSpaceWidthunits using glyph measurements and tab-stop arithmetic. - Update
TextAreaControl.UpdateLayout()to use the measured visual width (with lazyGraphicscreation) and clear cached widths on option changes. - Add two scrollbar regression tests covering wide-glyph overflow and rightmost reachability.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| Test/TextEditorControl/ScrollBarTests.cs | Adds regression tests to ensure wide glyphs trigger and correctly size horizontal scrolling. |
| Project/Src/Gui/TextView.cs | Introduces a new API to compute measured visual line width in scrollbar column units. |
| Project/Src/Gui/TextAreaControl.cs | Switches scrollbar extent calculation to measured widths and invalidates cached widths when editor options change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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; | ||
| } |
There was a problem hiding this comment.
Good catch - adopted. GetVisualWidthColumns now walks line.Words and measures each word with word.GetFont(fontContainer), keeping the tab-stop arithmetic, so it lines up with PaintLinePart and CountColumns.
One thing I kept from the previous version: LineSegment.Words is null until the line has been highlighted (which is why CountColumns bails out with return 0), and it can cover only part of the line. Returning 0 there would be worse than the bug this PR fixes, so anything the words do not cover is still measured per character with the regular font.
For the record, how much this matters in practice - measured with the same TextRenderer.MeasureText flags:
| font | x |
U+4E2D | 50 ASCII chars |
|---|---|---|---|
| Consolas Regular | 8 px | 14 px | 400 px |
| Consolas Bold | 8 px | 15 px | 400 px |
| Consolas Italic | 8 px | 14 px | 400 px |
| Segoe UI Regular | 6 px | 14 px | 310 px |
| Segoe UI Bold | 8 px | 15 px | 355 px |
So with the default monospaced diff font it is 1 px per bold CJK glyph, but a bold run in a proportional font was off by ~14%. Worth fixing either way.
| { | ||
| TextArea.OptionsChanged(); | ||
|
|
||
| // the cached line widths are measured with the current font, so they are stale after a font change |
There was a problem hiding this comment.
Agreed, reworded: the cached widths depend on the rendering options in general - font, tab size and highlighting alike - not just on the font.
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.
| default: | ||
| width += MeasureStringWidth( | ||
| g, | ||
| Document.GetText(lineOffset + measured, word.Length), |
There was a problem hiding this comment.
Why not word.Word as currentWord.Word in PaintLinePart?
| if (words != null) | ||
| for (var i = 0; i < words.Count; i++) |
There was a problem hiding this comment.
style
| if (words != null) | |
| for (var i = 0; i < words.Count; i++) | |
| if (words is not null) | |
| { | |
| for (var i = 0; i < words.Count; i++) |
| } | ||
| finally | ||
| { | ||
| measureGraphics?.Dispose(); |
There was a problem hiding this comment.
Rather keep the instance for the entire lifetime of the TextAreaControl, i.e. readonly field _measureGraphics?
Fixes gitextensions/gitextensions#13200
Proposed changes
TextView.GetVisualWidthColumns()(new) returns the painted width of a line inWideSpaceWidthunits. It walksline.Wordsand measures each one with the font the highlighting assigns (word.GetFont), mirroring the tab-stop arithmetic ofPaintLinePart.LineSegment.Wordsis null until the line has been highlighted and may cover only part of it, so any remainder is measured per character through the existing width cache instead of being dropped.TextAreaControl.UpdateLayout()uses it instead ofGetVisualColumnFast(), which counted characters and so underestimated every glyph wider thanx(CJK, emoji, fallback-font glyphs). TheGraphicsis created lazily, so a warmlineLengthCachestill measures nothing.TextAreaControl.OptionsChanged()clearslineLengthCache: the cached widths now depend on the rendering options - font, tab size and highlighting alike.Screenshots
All shots are Git Extensions built from
masterat 200% scaling (192 dpi), on a throwaway repository containing CJK source lines, cropped to the diff pane.Before
The scrollbar has been dragged fully right and the right arrow clicked repeatedly afterwards - the view does not move any further, yet the string literal is cut off mid-sentence:
A file whose longest line has fewer characters than the viewer has columns, but whose glyphs need about twice that width: the line is clipped and there is no scrollbar at all.
The Commit dialog, same story:
After
Dragging fully right now reaches the end of the line, closing quote and semicolon included:
Same file and same scroll position as the second "before" shot - the scrollbar is now there:
Test methodology
Two new tests in
ScrollBarTests, both failing without the change:HScrollBar_should_be_shown_when_wide_glyphs_exceed_the_visible_widthHScrollBar_should_allow_scrolling_to_the_end_of_a_line_of_wide_glyphsThey derive their thresholds from the control at run time (glyph width,
WideSpaceWidth,DrawingPosition) rather than hard-coding pixels, and they assert the precondition that the glyph really is wider than one column, so on a machine without CJK fallback fonts they fail loudly instead of silently passing.Whole suite: 53/53 pass (
dotnet test --test-adapter-path:.).Pure ASCII is provably unchanged: with Consolas 10,
TextRenderer.MeasureTextreports exactly 100 xWideSpaceWidthfor 100 ASCII characters, so the computed extent is identical to before.Verified end to end in Git Extensions itself - Diff tab and Commit dialog, before and after builds - see the screenshots above.
Notes for the reviewer
GetVisualColumnFast()has no callers left, here or in gitextensions. I left it in place because it is public API - happy to remove it in this PR if you would rather.MeasureStringWidthcall per word - already served by the existingmeasureCache- plus a dictionary lookup per character for anything the highlighting has not covered, and only for lines that misslineLengthCache(the visible lines right after a document change or resize).Test environment(s)
Merge strategy
I agree that the maintainer squash merge this PR (if the commit message is clear).
✒️ I contribute this code under The Developer Certificate of Origin.