⚡ Sangsakala Proxy 🏠 Home
Skip to content

Commit 1b945f3

Browse files
committed
fix: eliminate ghost selection offsets and GDI+ float advance drift in WrapTextView
1 parent fabdf1d commit 1b945f3

2 files changed

Lines changed: 120 additions & 81 deletions

File tree

‎ST.Library.UI.STTextBox/Implementation/TextView/WrapTextView.cs‎

Lines changed: 63 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ public override string ToString() {
3232

3333
private class WordInfo
3434
{
35-
public int Width;
35+
public float Width;
3636
public int CharCount;
3737
public int[] GraphemeLengths;
38-
public int[] GraphemeWidths;
38+
public float[] GraphemeWidths;
3939
}
4040

4141
private class SubLineInfo
@@ -53,7 +53,7 @@ private class SubLineInfo
5353

5454
private int m_n_line_count_start_change = 0;
5555
private HashSet<TextLine> m_hs_changed_temp = new HashSet<TextLine>();
56-
private readonly Dictionary<char, int> m_dic_char_widths = new Dictionary<char, int>();
56+
private readonly Dictionary<char, float> m_dic_char_widths = new Dictionary<char, float>();
5757

5858
private readonly StringFormat m_sf = new StringFormat(StringFormat.GenericTypographic) {
5959
Alignment = StringAlignment.Far,
@@ -154,22 +154,25 @@ public override void OnLineChanged(ISTTextBoxRender render, TextManagerLineEvent
154154
m_hs_changed_temp.Add(e.Line);
155155
}
156156

157-
private int GetCharWidthFast(STTextBox.Core c, TextLine line, int charIndexInLine, string strChar, int curX) {
157+
private float GetCharWidthFastFloat(STTextBox.Core c, TextLine line, int charIndexInLine, string strChar, float curX) {
158+
if (string.IsNullOrEmpty(strChar)) return 0f;
158159
if (strChar.Length == 1) {
159160
char ch = strChar[0];
161+
if (ch == '\r' || ch == '\n') return 0f;
160162
if (ch < 128 && c.ITextBoxRender.IsMonospaced) {
161-
return c.ITextBoxRender.GetSpaceWidth();
163+
return c.ITextBoxRender.CharWidthFloat > 0f ? c.ITextBoxRender.CharWidthFloat : (float)c.ITextBoxRender.GetSpaceWidth();
162164
}
163-
if (m_dic_char_widths.TryGetValue(ch, out int cachedW)) {
165+
if (m_dic_char_widths.TryGetValue(ch, out float cachedW)) {
164166
return cachedW;
165167
}
166168
var style = c.GetStyleFromCharIndex(line.IndexOfFirstChar + charIndexInLine);
167-
int w = c.GetStringWidth(strChar, style, curX);
169+
float w = c.GetStringWidthFloat(strChar, style, curX);
168170
m_dic_char_widths[ch] = w;
169171
return w;
170172
} else {
173+
if (strChar == "\r\n") return 0f;
171174
var style = c.GetStyleFromCharIndex(line.IndexOfFirstChar + charIndexInLine);
172-
return c.GetStringWidth(strChar, style, curX);
175+
return c.GetStringWidthFloat(strChar, style, curX);
173176
}
174177
}
175178

@@ -178,7 +181,7 @@ private List<SubLineInfo> GetCache(TextLine line, ISTTextBoxRender render) {
178181
List<SubLineInfo> lst = null;
179182
if (this.WrapMethod == Method.Char) {
180183
lst = new List<SubLineInfo>();
181-
this.GetCacheByChar(line, render, lst, 0, 0);
184+
this.GetCacheByChar(line, render, lst, 0);
182185
} else {
183186
lst = this.GetCacheByWord(line, render);
184187
}
@@ -187,79 +190,45 @@ private List<SubLineInfo> GetCache(TextLine line, ISTTextBoxRender render) {
187190
switch (this.WrapAlignment) {
188191
case Alignment.Center:
189192
sub.X = (base.TextRectangle.Width - sub.Width) >> 1;
190-
if (sub.Length > 0) {
191-
switch (line.RawString[sub.Index + sub.Length - 1]) {
192-
case '\r':
193-
case '\n':
194-
sub.X += c.ITextBoxRender.GetSpaceWidth() / 2;
195-
break;
196-
}
197-
}
198193
break;
199194
case Alignment.Right:
200195
sub.X = base.TextRectangle.Width - sub.Width - c.ITextBoxRender.GetSpaceWidth();
201-
if (sub.Length > 0) {
202-
switch (line.RawString[sub.Index]) {
203-
case '\r':
204-
case '\n':
205-
if (i == 0) break;
206-
var temp = lst[i - 1];
207-
temp.Width += sub.Width;
208-
temp.Length += sub.Length;
209-
temp.CharCount += 1;
210-
if (temp.Offsets != null && sub.Offsets != null) {
211-
int[] mergedOffsets = new int[temp.Length + 1];
212-
Array.Copy(temp.Offsets, 0, mergedOffsets, 0, temp.Offsets.Length);
213-
for (int k = temp.Offsets.Length; k < mergedOffsets.Length; k++) {
214-
mergedOffsets[k] = temp.Width;
215-
}
216-
temp.Offsets = mergedOffsets;
217-
}
218-
lst[i - 1] = temp;
219-
lst.RemoveAt(i);
220-
return lst;
221-
}
222-
switch (line.RawString[sub.Index + sub.Length - 1]) {
223-
case '\r':
224-
case '\n':
225-
sub.X += c.ITextBoxRender.GetSpaceWidth();
226-
break;
227-
}
228-
break;
229-
}
230196
break;
231197
}
232198
lst[i] = sub;
233199
}
234200
return lst;
235201
}
236202

237-
private int GetCacheByChar(TextLine line, ISTTextBoxRender render, List<SubLineInfo> lst, int nStartIndex, int nWidth) {
203+
private void GetCacheByChar(TextLine line, ISTTextBoxRender render, List<SubLineInfo> lst, int nStartIndex) {
238204
var c = base.Core;
239-
int nLineWidth = nWidth, nCharWidth = 0;
240-
int nSubStrLen = 0;
241-
int nCharCount = 0;
242-
int nTextViewLineWidth = Math.Max(10, base.TextRectangle.Width);
205+
float nTextViewLineWidth = (float)Math.Max(10, base.TextRectangle.Width);
243206
if (this.WrapAlignment != Alignment.Left) {
244-
nTextViewLineWidth -= c.ITextBoxRender.GetSpaceWidth();
207+
nTextViewLineWidth -= (float)c.ITextBoxRender.GetSpaceWidth();
245208
}
246-
nTextViewLineWidth = Math.Max(1, nTextViewLineWidth);
209+
nTextViewLineWidth = Math.Max(1f, nTextViewLineWidth);
247210

211+
float nLineWidth = 0f;
212+
int nSubStrLen = 0;
213+
int nCharCount = 0;
248214
List<int> curOffsets = new List<int>() { 0 };
249215

250216
c.IGraphemeSplitter.Each(line.RawString, nStartIndex, (str, nStart, nLen) => {
251217
string strChar = str.Substring(nStart, nLen);
252-
if (c.IsEmoji(strChar)) {
253-
nCharWidth = c.FontHeight;
218+
float nCharWidth;
219+
if (strChar == "\r\n" || strChar == "\r" || strChar == "\n") {
220+
nCharWidth = 0f;
221+
} else if (c.IsEmoji(strChar)) {
222+
nCharWidth = (float)c.FontHeight;
254223
} else {
255-
nCharWidth = GetCharWidthFast(c, line, nStart, strChar, nLineWidth);
224+
nCharWidth = GetCharWidthFastFloat(c, line, nStart, strChar, nLineWidth);
256225
}
257226
if (nLineWidth + nCharWidth >= nTextViewLineWidth && nSubStrLen > 0) {
258227
lst.Add(new SubLineInfo() {
259228
Index = nStartIndex,
260229
Length = nSubStrLen,
261230
CharCount = nCharCount,
262-
Width = nLineWidth,
231+
Width = (int)Math.Round(nLineWidth),
263232
Offsets = curOffsets.ToArray()
264233
});
265234
curOffsets.Clear();
@@ -268,15 +237,17 @@ private int GetCacheByChar(TextLine line, ISTTextBoxRender render, List<SubLineI
268237
nStartIndex += nSubStrLen;
269238
nSubStrLen = nLen;
270239
nLineWidth = nCharWidth;
240+
int off = (int)Math.Round(nLineWidth);
271241
for (int k = 1; k <= nLen; k++) {
272-
curOffsets.Add(nLineWidth);
242+
curOffsets.Add(off);
273243
}
274244
} else {
275245
nSubStrLen += nLen;
276246
nLineWidth += nCharWidth;
277247
nCharCount++;
248+
int off = (int)Math.Round(nLineWidth);
278249
for (int k = 1; k <= nLen; k++) {
279-
curOffsets.Add(nLineWidth);
250+
curOffsets.Add(off);
280251
}
281252
}
282253
return true;
@@ -285,24 +256,23 @@ private int GetCacheByChar(TextLine line, ISTTextBoxRender render, List<SubLineI
285256
Index = nStartIndex,
286257
Length = nSubStrLen,
287258
CharCount = nCharCount,
288-
Width = nLineWidth,
259+
Width = (int)Math.Round(nLineWidth),
289260
Offsets = curOffsets.ToArray()
290261
});
291-
return nLineWidth;
292262
}
293263

294264
private List<SubLineInfo> GetCacheByWord(TextLine line, ISTTextBoxRender render) {
295265
var c = base.Core;
296-
int nTextViewLineWidth = Math.Max(10, base.TextRectangle.Width);
266+
float nTextViewLineWidth = (float)Math.Max(10, base.TextRectangle.Width);
297267
if (this.WrapAlignment != Alignment.Left) {
298-
nTextViewLineWidth -= c.ITextBoxRender.GetSpaceWidth();
268+
nTextViewLineWidth -= (float)c.ITextBoxRender.GetSpaceWidth();
299269
}
300-
nTextViewLineWidth = Math.Max(1, nTextViewLineWidth);
270+
nTextViewLineWidth = Math.Max(1f, nTextViewLineWidth);
301271

302272
List<SubLineInfo> lst = new List<SubLineInfo>();
303273
int curSubStart = 0;
304274
int curSubLen = 0;
305-
int curSubWidth = 0;
275+
float curSubWidth = 0f;
306276
int curCharCount = 0;
307277
List<int> curOffsets = new List<int>() { 0 };
308278

@@ -314,27 +284,28 @@ private List<SubLineInfo> GetCacheByWord(TextLine line, ISTTextBoxRender render)
314284
// Single word is wider than the entire line -> split character by character
315285
for (int g = 0; g < wi.GraphemeLengths.Length; g++) {
316286
int gLen = wi.GraphemeLengths[g];
317-
int gWidth = wi.GraphemeWidths[g];
287+
float gWidth = wi.GraphemeWidths[g];
318288
if (curSubWidth + gWidth >= nTextViewLineWidth && curSubLen > 0) {
319289
lst.Add(new SubLineInfo() {
320290
Index = curSubStart,
321291
Length = curSubLen,
322292
CharCount = curCharCount,
323-
Width = curSubWidth,
293+
Width = (int)Math.Round(curSubWidth),
324294
Offsets = curOffsets.ToArray()
325295
});
326296
curSubStart += curSubLen;
327297
curSubLen = 0;
328-
curSubWidth = 0;
298+
curSubWidth = 0f;
329299
curCharCount = 0;
330300
curOffsets.Clear();
331301
curOffsets.Add(0);
332302
}
333303
curSubLen += gLen;
334304
curSubWidth += gWidth;
335305
curCharCount++;
306+
int off = (int)Math.Round(curSubWidth);
336307
for (int k = 1; k <= gLen; k++) {
337-
curOffsets.Add(curSubWidth);
308+
curOffsets.Add(off);
338309
}
339310
}
340311
return true;
@@ -346,12 +317,12 @@ private List<SubLineInfo> GetCacheByWord(TextLine line, ISTTextBoxRender render)
346317
Index = curSubStart,
347318
Length = curSubLen,
348319
CharCount = curCharCount,
349-
Width = curSubWidth,
320+
Width = (int)Math.Round(curSubWidth),
350321
Offsets = curOffsets.ToArray()
351322
});
352323
curSubStart += curSubLen;
353324
curSubLen = 0;
354-
curSubWidth = 0;
325+
curSubWidth = 0f;
355326
curCharCount = 0;
356327
curOffsets.Clear();
357328
curOffsets.Add(0);
@@ -360,12 +331,13 @@ private List<SubLineInfo> GetCacheByWord(TextLine line, ISTTextBoxRender render)
360331
// Add the word to the current subline
361332
for (int g = 0; g < wi.GraphemeLengths.Length; g++) {
362333
int gLen = wi.GraphemeLengths[g];
363-
int gWidth = wi.GraphemeWidths[g];
334+
float gWidth = wi.GraphemeWidths[g];
364335
curSubLen += gLen;
365336
curSubWidth += gWidth;
366337
curCharCount++;
338+
int off = (int)Math.Round(curSubWidth);
367339
for (int k = 1; k <= gLen; k++) {
368-
curOffsets.Add(curSubWidth);
340+
curOffsets.Add(off);
369341
}
370342
}
371343
return true;
@@ -375,27 +347,37 @@ private List<SubLineInfo> GetCacheByWord(TextLine line, ISTTextBoxRender render)
375347
Index = curSubStart,
376348
Length = curSubLen,
377349
CharCount = curCharCount,
378-
Width = curSubWidth,
350+
Width = (int)Math.Round(curSubWidth),
379351
Offsets = curOffsets.ToArray()
380352
});
381353

382354
return lst;
383355
}
384356

385-
private WordInfo GetWordInfo(TextLine line, ISTTextBoxRender render, string strWord, int nIndex, int nLeftWidth) {
357+
private WordInfo GetWordInfo(TextLine line, ISTTextBoxRender render, string strWord, int nIndex, float nLeftWidth) {
386358
var c = base.Core;
387-
int nWordWidth = 0;
359+
if (strWord == "\r\n" || strWord == "\r" || strWord == "\n") {
360+
return new WordInfo() {
361+
Width = 0f,
362+
CharCount = 1,
363+
GraphemeLengths = new int[] { strWord.Length },
364+
GraphemeWidths = new float[] { 0f }
365+
};
366+
}
367+
float nWordWidth = 0f;
388368
int nCharCount = 0;
389369
List<int> gLens = new List<int>();
390-
List<int> gWidths = new List<int>();
370+
List<float> gWidths = new List<float>();
391371

392372
c.IGraphemeSplitter.Each(strWord, (s, ns, nl) => {
393373
string strChar = s.Substring(ns, nl);
394-
int nCharWidth;
395-
if (c.IsEmoji(strChar)) {
396-
nCharWidth = c.FontHeight;
374+
float nCharWidth;
375+
if (strChar == "\r\n" || strChar == "\r" || strChar == "\n") {
376+
nCharWidth = 0f;
377+
} else if (c.IsEmoji(strChar)) {
378+
nCharWidth = (float)c.FontHeight;
397379
} else {
398-
nCharWidth = GetCharWidthFast(c, line, nIndex + ns, strChar, nLeftWidth + nWordWidth);
380+
nCharWidth = GetCharWidthFastFloat(c, line, nIndex + ns, strChar, nLeftWidth + nWordWidth);
399381
}
400382
gLens.Add(nl);
401383
gWidths.Add(nCharWidth);

‎STTextBox.Tests/QuickSelectionTests.cs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,5 +825,62 @@ public void WrapTextView_Performance_Benchmark()
825825
Assert.True(gdipTypingMs < 250, $"GDIP Typing too slow: {gdipTypingMs}ms for 100 chars");
826826
}
827827
}
828+
829+
[Fact]
830+
public void WrapTextView_Selection_NoGhostOffsets_GDI_GDIP()
831+
{
832+
using (var tbx = new ST.Library.UI.STTextBox.STTextBox())
833+
using (var gdipRender = new STTextBoxGDIPRender())
834+
using (var gdiRender = new STTextBoxGDIRender()) {
835+
tbx.SetBounds(0, 0, 1170, 700);
836+
tbx.Font = new Font("Segoe UI", 10.5f);
837+
838+
string line52_content = "52. 也多霜法起后栻玉开所wn4^ 薇列晏玄可下列子我列张fvvvj2 作为fd&zh 分昌玄不出生下5ni&q 周为都这霰性而出冬暑一能bmae^ 闺浑\\% \r\n";
839+
string line53_content = "53. ntcd zgdn 称数物当狄实御lib i6d2 法号分们他出成b*o7r2 进为雨也业绩诸使其邑展hkuu uý cw edpoend 夸爱方\r\n";
840+
string line54_content = "54. 说我到高水地若如a3im 二出过雨用民pf^npa ph^*9 性昃岁大水h 8f7 nq0m o0m* $6 i0u 它们而产%(&zkod 余足k1\r\n";
841+
842+
// Test across both renderers: GDIP and GDI
843+
foreach (ISTTextBoxRender render in new ISTTextBoxRender[] { gdipRender, gdiRender }) {
844+
tbx.SetTextBoxRender(render);
845+
var wrapView = new WrapTextView(WrapTextView.Method.Word, WrapTextView.Alignment.Center);
846+
wrapView.ShowLineNumber = true;
847+
tbx.SetTextView(wrapView);
848+
tbx.SetText(line52_content + line53_content + line54_content);
849+
850+
var core = typeof(ST.Library.UI.STTextBox.STTextBox).GetField("m_core", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(tbx) as ST.Library.UI.STTextBox.STTextBox.Core;
851+
var line0 = core.TextManager[0];
852+
853+
var getTag = typeof(WrapTextView).GetMethod("_Tag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
854+
var tag = getTag.Invoke(wrapView, new object[] { line0 });
855+
var subLinesField = tag.GetType().GetProperty("SubLines").GetValue(tag) as System.Collections.IList;
856+
857+
var sub = subLinesField[0];
858+
int sLen = (int)sub.GetType().GetField("Length").GetValue(sub);
859+
int sWidth = (int)sub.GetType().GetField("Width").GetValue(sub);
860+
int sX = (int)sub.GetType().GetField("X").GetValue(sub);
861+
int[] offsets = (int[])sub.GetType().GetField("Offsets").GetValue(sub);
862+
863+
string drawStr = line52_content.TrimEnd('\r', '\n');
864+
var style = new TextStyle();
865+
float actualStrWidthFloat = render.GetStringWidthFloat(drawStr, style, 0);
866+
int actualStrWidthInt = (int)Math.Ceiling(actualStrWidthFloat);
867+
868+
// Offsets at line end must equal visual text width (0 ghost offset)
869+
Assert.True(Math.Abs(offsets[sLen] - actualStrWidthInt) <= 1, $"Offset drift in {render.GetType().Name}: {offsets[sLen]} vs {actualStrWidthInt}");
870+
871+
// Selection end must align exactly with text visual end
872+
core.Selection.SetSelection(4, line52_content.Length + line53_content.Length + 50);
873+
int relStart = 4;
874+
int relEnd = sLen;
875+
int startX = offsets[relStart];
876+
int endX = offsets[relEnd];
877+
int nLeft = wrapView.TextRectangle.X + sX + startX;
878+
int nWidth = endX - startX;
879+
880+
int visualRight = wrapView.TextRectangle.X + sX + actualStrWidthInt;
881+
Assert.True(Math.Abs((nLeft + nWidth) - visualRight) <= 1, $"Selection block mismatch in {render.GetType().Name}: {nLeft + nWidth} vs {visualRight}");
882+
}
883+
}
884+
}
828885
}
829886
}

0 commit comments

Comments
 (0)