OutputBufferWideCharTests.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. using System.Text;
  2. namespace DriverTests;
  3. /// <summary>
  4. /// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466.
  5. /// These tests validate that FillRect properly handles wide characters when overlapping existing content.
  6. /// Specifically, they ensure that wide characters are properly invalidated and replaced when a MessageBox border or
  7. /// similar UI element is drawn over them, preventing visual corruption.
  8. /// </summary>
  9. public class OutputBufferWideCharTests
  10. {
  11. /// <summary>
  12. /// Tests that FillRect properly invalidates wide characters when overwriting them.
  13. /// This is the core issue in #4466 - when a MessageBox border is drawn over Chinese text,
  14. /// the wide characters need to be properly invalidated.
  15. /// </summary>
  16. [Fact]
  17. [Trait ("Category", "Output")]
  18. public void FillRect_OverwritesWideChar_InvalidatesProperly ()
  19. {
  20. // Arrange - Create a buffer and draw a wide character
  21. OutputBufferImpl buffer = new ()
  22. {
  23. Rows = 5, Cols = 10,
  24. CurrentAttribute = new (Color.White, Color.Black)
  25. };
  26. // Draw a Chinese character (2 columns wide) at position 2,1
  27. buffer.Move (2, 1);
  28. buffer.AddStr ("你"); // Chinese character "you", 2 columns wide
  29. // Verify the wide character was drawn
  30. Assert.Equal ("你", buffer.Contents! [1, 2].Grapheme);
  31. Assert.True (buffer.Contents [1, 2].IsDirty);
  32. // With the fix, the second column should NOT be modified by AddStr
  33. // The wide glyph naturally renders across both columns
  34. Assert.NotEqual ("你", buffer.Contents [1, 3].Grapheme);
  35. // Clear dirty flags to test FillRect behavior
  36. for (var r = 0; r < buffer.Rows; r++)
  37. {
  38. for (var c = 0; c < buffer.Cols; c++)
  39. {
  40. buffer.Contents [r, c].IsDirty = false;
  41. }
  42. }
  43. // Act - Fill a rectangle that overlaps the first column of the wide character
  44. // This simulates drawing a MessageBox border over Chinese text
  45. buffer.FillRect (new (2, 1, 1, 1), new Rune ('│'));
  46. // Assert
  47. // With FIXES_4466: FillRect calls AddStr, which properly invalidates the wide character
  48. // The wide character at [1,2] should be replaced with replacement char or the new content
  49. Assert.Equal ("│", buffer.Contents [1, 2].Grapheme);
  50. Assert.True (buffer.Contents [1, 2].IsDirty, "Cell [1,2] should be marked dirty after FillRect");
  51. // The adjacent cell should also be marked dirty for proper rendering
  52. Assert.True (buffer.Contents [1, 3].IsDirty, "Adjacent cell [1,3] should be marked dirty to ensure proper rendering");
  53. }
  54. /// <summary>
  55. /// Tests that FillRect handles overwriting the second column of a wide character.
  56. /// When drawing at an odd column that's the second half of a wide glyph, the
  57. /// wide glyph should be invalidated.
  58. /// </summary>
  59. [Fact]
  60. [Trait ("Category", "Output")]
  61. public void FillRect_OverwritesSecondColumnOfWideChar_InvalidatesWideChar ()
  62. {
  63. // Arrange
  64. OutputBufferImpl buffer = new ()
  65. {
  66. Rows = 5, Cols = 10,
  67. CurrentAttribute = new (Color.White, Color.Black)
  68. };
  69. // Draw a wide character at position 2,1
  70. buffer.Move (2, 1);
  71. buffer.AddStr ("好"); // Chinese character, 2 columns wide
  72. Assert.Equal ("好", buffer.Contents! [1, 2].Grapheme);
  73. // Clear dirty flags
  74. for (var r = 0; r < buffer.Rows; r++)
  75. {
  76. for (var c = 0; c < buffer.Cols; c++)
  77. {
  78. buffer.Contents [r, c].IsDirty = false;
  79. }
  80. }
  81. // Act - Fill at the second column of the wide character (position 3)
  82. buffer.FillRect (new (3, 1, 1, 1), new Rune ('│'));
  83. // Assert
  84. // With the fix: The original wide character at col 2 should be invalidated
  85. // because we're overwriting its second column
  86. Assert.True (buffer.Contents [1, 2].IsDirty, "Wide char at col 2 should be invalidated when its second column is overwritten");
  87. Assert.Equal (buffer.Contents [1, 2].Grapheme, Glyphs.WideGlyphReplacement.ToString ());
  88. Assert.Equal ("│", buffer.Contents [1, 3].Grapheme);
  89. Assert.True (buffer.Contents [1, 3].IsDirty);
  90. }
  91. /// <summary>
  92. /// Tests the ChineseUI scenario: Drawing a MessageBox with borders over Chinese button text.
  93. /// This simulates the specific repro case from the issue. See: https://github.com/gui-cs/Terminal.Gui/issues/4466
  94. /// </summary>
  95. [Fact]
  96. [Trait ("Category", "Output")]
  97. public void ChineseUI_MessageBox_Over_WideChars ()
  98. {
  99. // Arrange - Simulate the ChineseUI scenario
  100. OutputBufferImpl buffer = new ()
  101. {
  102. Rows = 10, Cols = 30,
  103. CurrentAttribute = new (Color.White, Color.Black)
  104. };
  105. // Draw Chinese button text (like "你好呀")
  106. buffer.Move (5, 3);
  107. buffer.AddStr ("你好呀"); // 3 Chinese characters, 6 columns total
  108. // Verify initial state
  109. Assert.Equal ("你", buffer.Contents! [3, 5].Grapheme);
  110. Assert.Equal ("好", buffer.Contents [3, 7].Grapheme);
  111. Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme);
  112. // Clear dirty flags to simulate the state before MessageBox draws
  113. for (var r = 0; r < buffer.Rows; r++)
  114. {
  115. for (var c = 0; c < buffer.Cols; c++)
  116. {
  117. buffer.Contents [r, c].IsDirty = false;
  118. }
  119. }
  120. // Act - Draw a MessageBox border that partially overlaps the Chinese text
  121. // This simulates the mouse moving over the border, causing HighlightState changes
  122. // Draw vertical line at column 8 (overlaps second char "好")
  123. for (var row = 2; row < 6; row++)
  124. {
  125. buffer.FillRect (new (8, row, 1, 1), new Rune ('│'));
  126. }
  127. // Assert - The wide characters should be properly handled
  128. // With the fix: Wide characters are properly invalidated
  129. // The first character "你" at col 5 should be unaffected
  130. Assert.Equal ("你", buffer.Contents [3, 5].Grapheme);
  131. // The second character "好" at col 7 had its second column overwritten
  132. // so it should be replaced with replacement char
  133. Assert.Equal (buffer.Contents [3, 7].Grapheme, Glyphs.WideGlyphReplacement.ToString ());
  134. Assert.True (buffer.Contents [3, 7].IsDirty, "Invalidated wide char should be marked dirty");
  135. // The border should be drawn at col 8
  136. Assert.Equal ("│", buffer.Contents [3, 8].Grapheme);
  137. Assert.True (buffer.Contents [3, 8].IsDirty);
  138. // The third character "呀" at col 9 should be unaffected
  139. Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme);
  140. }
  141. /// <summary>
  142. /// Tests that FillRect works correctly with single-width characters (baseline behavior).
  143. /// This should work the same with or without FIXES_4466.
  144. /// </summary>
  145. [Fact]
  146. [Trait ("Category", "Output")]
  147. public void FillRect_SingleWidthChars_WorksCorrectly ()
  148. {
  149. // Arrange
  150. OutputBufferImpl buffer = new ()
  151. {
  152. Rows = 5, Cols = 10,
  153. CurrentAttribute = new (Color.White, Color.Black)
  154. };
  155. // Draw some ASCII text
  156. buffer.Move (2, 1);
  157. buffer.AddStr ("ABC");
  158. Assert.Equal ("A", buffer.Contents! [1, 2].Grapheme);
  159. Assert.Equal ("B", buffer.Contents [1, 3].Grapheme);
  160. Assert.Equal ("C", buffer.Contents [1, 4].Grapheme);
  161. // Clear dirty flags
  162. for (var r = 0; r < buffer.Rows; r++)
  163. {
  164. for (var c = 0; c < buffer.Cols; c++)
  165. {
  166. buffer.Contents [r, c].IsDirty = false;
  167. }
  168. }
  169. // Act - Overwrite with FillRect
  170. buffer.FillRect (new (3, 1, 1, 1), new Rune ('X'));
  171. // Assert - This should work the same regardless of FIXES_4466
  172. Assert.Equal ("A", buffer.Contents [1, 2].Grapheme);
  173. Assert.Equal ("X", buffer.Contents [1, 3].Grapheme);
  174. Assert.True (buffer.Contents [1, 3].IsDirty);
  175. Assert.Equal ("C", buffer.Contents [1, 4].Grapheme);
  176. }
  177. /// <summary>
  178. /// Tests FillRect with wide characters at buffer boundaries.
  179. /// </summary>
  180. [Fact]
  181. [Trait ("Category", "Output")]
  182. public void FillRect_WideChar_AtBufferBoundary ()
  183. {
  184. // Arrange
  185. OutputBufferImpl buffer = new ()
  186. {
  187. Rows = 5, Cols = 10,
  188. CurrentAttribute = new (Color.White, Color.Black)
  189. };
  190. // Draw a wide character at the right edge (col 8, which would extend to col 9)
  191. buffer.Move (8, 1);
  192. buffer.AddStr ("山"); // Chinese character "mountain", 2 columns wide
  193. Assert.Equal ("山", buffer.Contents! [1, 8].Grapheme);
  194. // Clear dirty flags
  195. for (var r = 0; r < buffer.Rows; r++)
  196. {
  197. for (var c = 0; c < buffer.Cols; c++)
  198. {
  199. buffer.Contents [r, c].IsDirty = false;
  200. }
  201. }
  202. // Act - FillRect at the wide character position
  203. buffer.FillRect (new (8, 1, 1, 1), new Rune ('│'));
  204. // Assert
  205. Assert.Equal ("│", buffer.Contents [1, 8].Grapheme);
  206. Assert.True (buffer.Contents [1, 8].IsDirty);
  207. // Adjacent cell should be marked dirty
  208. Assert.True (
  209. buffer.Contents [1, 9].IsDirty,
  210. "Cell after wide char replacement should be marked dirty");
  211. }
  212. /// <summary>
  213. /// Tests OutputBase.Write method marks cells dirty correctly for wide characters.
  214. /// This tests the other half of the fix in OutputBase.cs.
  215. /// </summary>
  216. [Fact]
  217. [Trait ("Category", "Output")]
  218. public void OutputBase_Write_WideChar_MarksCellsDirty ()
  219. {
  220. // Arrange
  221. OutputBufferImpl buffer = new ()
  222. {
  223. Rows = 5, Cols = 20,
  224. CurrentAttribute = new (Color.White, Color.Black)
  225. };
  226. // Draw a line with wide characters
  227. buffer.Move (0, 1);
  228. buffer.AddStr ("你好"); // Two wide characters
  229. // Mark all as not dirty to simulate post-Write state
  230. for (var r = 0; r < buffer.Rows; r++)
  231. {
  232. for (var c = 0; c < buffer.Cols; c++)
  233. {
  234. buffer.Contents! [r, c].IsDirty = false;
  235. }
  236. }
  237. // Verify initial state
  238. Assert.Equal ("你", buffer.Contents! [1, 0].Grapheme);
  239. Assert.Equal ("好", buffer.Contents [1, 2].Grapheme);
  240. // Act - Now overwrite the first wide char by writing at its position
  241. buffer.Move (0, 1);
  242. buffer.AddStr ("A"); // Single width char
  243. // Assert
  244. // With the fix: The first cell is replaced with 'A' and marked dirty
  245. Assert.Equal ("A", buffer.Contents [1, 0].Grapheme);
  246. Assert.True (buffer.Contents [1, 0].IsDirty);
  247. // The adjacent cell (col 1) should be marked dirty for proper rendering
  248. Assert.True (
  249. buffer.Contents [1, 1].IsDirty,
  250. "Adjacent cell should be marked dirty after writing single-width char over wide char");
  251. // The second wide char should remain
  252. Assert.Equal ("好", buffer.Contents [1, 2].Grapheme);
  253. }
  254. /// <summary>
  255. /// Tests that filling a rectangle with spaces properly handles wide character cleanup.
  256. /// This simulates clearing a region that contains wide characters.
  257. /// </summary>
  258. [Fact]
  259. [Trait ("Category", "Output")]
  260. public void FillRect_WithSpaces_OverWideChars ()
  261. {
  262. // Arrange
  263. OutputBufferImpl buffer = new ()
  264. {
  265. Rows = 5, Cols = 15,
  266. CurrentAttribute = new (Color.White, Color.Black)
  267. };
  268. // Draw a line of mixed content
  269. buffer.Move (2, 2);
  270. buffer.AddStr ("A你B好C");
  271. // Verify setup
  272. Assert.Equal ("A", buffer.Contents! [2, 2].Grapheme);
  273. Assert.Equal ("你", buffer.Contents [2, 3].Grapheme);
  274. Assert.Equal ("B", buffer.Contents [2, 5].Grapheme);
  275. Assert.Equal ("好", buffer.Contents [2, 6].Grapheme);
  276. Assert.Equal ("C", buffer.Contents [2, 8].Grapheme);
  277. // Clear dirty flags
  278. for (var r = 0; r < buffer.Rows; r++)
  279. {
  280. for (var c = 0; c < buffer.Cols; c++)
  281. {
  282. buffer.Contents [r, c].IsDirty = false;
  283. }
  284. }
  285. // Act - Fill the region with spaces (simulating clearing)
  286. buffer.FillRect (new (3, 2, 4, 1), new Rune (' '));
  287. // Assert
  288. // With the fix: Wide characters are properly handled
  289. Assert.Equal (" ", buffer.Contents [2, 3].Grapheme);
  290. Assert.True (buffer.Contents [2, 3].IsDirty);
  291. // Wide character '你' at col 3 was replaced, so col 4 should be marked dirty
  292. Assert.True (
  293. buffer.Contents [2, 4].IsDirty,
  294. "Cell after replaced wide char should be dirty");
  295. Assert.Equal (" ", buffer.Contents [2, 4].Grapheme);
  296. Assert.Equal (" ", buffer.Contents [2, 5].Grapheme);
  297. Assert.Equal (" ", buffer.Contents [2, 6].Grapheme);
  298. // Cell 7 should be dirty because '好' was partially overwritten
  299. Assert.True (
  300. buffer.Contents [2, 7].IsDirty,
  301. "Adjacent cell should be dirty after wide char replacement");
  302. }
  303. /// <summary>
  304. /// Tests the edge case where a wide character's first column is outside the clip region
  305. /// but the second column is inside.
  306. /// IMPORTANT: This test documents that the code path in WriteWideGrapheme where:
  307. /// - !Clip.Contains(col, row) is true (first column outside)
  308. /// - Clip.Contains(col + 1, row) is true (second column inside)
  309. /// is CURRENTLY UNREACHABLE because IsValidLocation checks Clip.Contains(col, row) and
  310. /// returns false before WriteWideGrapheme is called. This test verifies the current behavior
  311. /// (nothing is written when first column is outside clip).
  312. /// If the behavior should change to write the second column with a replacement character,
  313. /// the logic in IsValidLocation or AddGrapheme needs to be modified.
  314. /// </summary>
  315. [Fact]
  316. [Trait ("Category", "Output")]
  317. public void AddStr_WideChar_FirstColumnOutsideClip_SecondColumnInside_CurrentBehavior ()
  318. {
  319. // Arrange
  320. OutputBufferImpl buffer = new ()
  321. {
  322. Rows = 5,
  323. Cols = 10,
  324. CurrentAttribute = new (Color.White, Color.Black)
  325. };
  326. // Set custom replacement characters to verify they're being used
  327. Rune customColumn1Replacement = new ('◄');
  328. Rune customColumn2Replacement = new ('►');
  329. buffer.SetWideGlyphReplacement (customColumn1Replacement);
  330. // Set clip region that starts at column 3 (odd column)
  331. // This creates a scenario where col 2 is outside clip, but col 3 is inside
  332. buffer.Clip = new (new (3, 1, 5, 3));
  333. // Clear initial contents to ensure clean state
  334. for (var r = 0; r < buffer.Rows; r++)
  335. {
  336. for (var c = 0; c < buffer.Cols; c++)
  337. {
  338. buffer.Contents! [r, c].IsDirty = false;
  339. buffer.Contents [r, c].Grapheme = " ";
  340. }
  341. }
  342. // Act - Try to draw a wide character at column 2
  343. // Column 2 is outside clip, but column 3 is inside clip
  344. buffer.Move (2, 1);
  345. buffer.AddStr ("你"); // Chinese character "you", 2 columns wide
  346. // Assert
  347. // CURRENT BEHAVIOR: IsValidLocation returns false when col 2 is outside clip,
  348. // so NOTHING is written - neither column 2 nor column 3
  349. Assert.Equal (" ", buffer.Contents! [1, 2].Grapheme);
  350. Assert.False (buffer.Contents [1, 2].IsDirty, "Cell outside clip should not be marked dirty");
  351. // Column 3 is also not written because IsValidLocation returned false
  352. // The code path in WriteWideGrapheme that would write the replacement char
  353. // to column 3 is never reached
  354. Assert.Equal (" ", buffer.Contents [1, 3].Grapheme);
  355. Assert.False (
  356. buffer.Contents [1, 3].IsDirty,
  357. "Currently, second column is not written when first column is outside clip");
  358. // Verify Col has been advanced by only 1 (not by the wide character width)
  359. // because the grapheme was not validated/processed when IsValidLocation returned false
  360. Assert.Equal (3, buffer.Col);
  361. }
  362. /// <summary>
  363. /// Tests the complementary case: wide character's second column is outside clip
  364. /// but first column is inside. This should use the column 1 replacement character.
  365. /// </summary>
  366. [Fact]
  367. [Trait ("Category", "Output")]
  368. public void AddStr_WideChar_SecondColumnOutsideClip_FirstColumnInside_UsesColumn1Replacement ()
  369. {
  370. // Arrange
  371. OutputBufferImpl buffer = new ()
  372. {
  373. Rows = 5,
  374. Cols = 10,
  375. CurrentAttribute = new (Color.White, Color.Black)
  376. };
  377. // Set custom replacement characters
  378. Rune customColumn1Replacement = new ('◄');
  379. Rune customColumn2Replacement = new ('►');
  380. buffer.SetWideGlyphReplacement (customColumn1Replacement);
  381. // Set clip region that ends at column 6 (even column)
  382. // This creates a scenario where col 5 is inside, but col 6 is outside
  383. buffer.Clip = new (new (0, 1, 6, 3));
  384. // Clear initial contents
  385. for (var r = 0; r < buffer.Rows; r++)
  386. {
  387. for (var c = 0; c < buffer.Cols; c++)
  388. {
  389. buffer.Contents! [r, c].IsDirty = false;
  390. buffer.Contents [r, c].Grapheme = " ";
  391. }
  392. }
  393. // Act - Try to draw a wide character at column 5
  394. // Column 5 is inside clip, but column 6 is outside clip
  395. buffer.Move (5, 1);
  396. buffer.AddStr ("好"); // Chinese character, 2 columns wide
  397. // Assert
  398. // The first column (col 5) is inside clip but second column (6) is outside
  399. // Should use column 1 replacement char to indicate it can't fit
  400. Assert.Equal (
  401. customColumn1Replacement.ToString (),
  402. buffer.Contents! [1, 5].Grapheme);
  403. Assert.True (
  404. buffer.Contents [1, 5].IsDirty,
  405. "First column should be marked dirty with replacement char when second column is clipped");
  406. // The second column is outside clip boundaries entirely
  407. Assert.Equal (" ", buffer.Contents [1, 6].Grapheme);
  408. Assert.False (buffer.Contents [1, 6].IsDirty, "Cell outside clip should not be modified");
  409. // Verify Col has been advanced by 2 (wide character width)
  410. Assert.Equal (7, buffer.Col);
  411. }
  412. /// <summary>
  413. /// Tests that when both columns of a wide character are inside the clip,
  414. /// the character is drawn normally without replacement characters.
  415. /// </summary>
  416. [Fact]
  417. [Trait ("Category", "Output")]
  418. public void AddStr_WideChar_BothColumnsInsideClip_DrawsNormally ()
  419. {
  420. // Arrange
  421. OutputBufferImpl buffer = new ()
  422. {
  423. Rows = 5,
  424. Cols = 10,
  425. CurrentAttribute = new (Color.White, Color.Black)
  426. };
  427. // Set custom replacement characters (should NOT be used in this case)
  428. Rune customColumn1Replacement = new ('◄');
  429. Rune customColumn2Replacement = new ('►');
  430. buffer.SetWideGlyphReplacement (customColumn1Replacement);
  431. // Set clip region that includes columns 2-7
  432. buffer.Clip = new (new (2, 1, 6, 3));
  433. // Clear initial contents
  434. for (var r = 0; r < buffer.Rows; r++)
  435. {
  436. for (var c = 0; c < buffer.Cols; c++)
  437. {
  438. buffer.Contents! [r, c].IsDirty = false;
  439. buffer.Contents [r, c].Grapheme = " ";
  440. }
  441. }
  442. // Act - Draw a wide character at column 4 (both 4 and 5 are inside clip)
  443. buffer.Move (4, 1);
  444. buffer.AddStr ("山"); // Chinese character "mountain", 2 columns wide
  445. // Assert
  446. // Both columns are inside clip, so the wide character should be drawn normally
  447. Assert.Equal ("山", buffer.Contents! [1, 4].Grapheme);
  448. Assert.True (buffer.Contents [1, 4].IsDirty, "First column should be marked dirty");
  449. // The second column should NOT be marked dirty by WriteWideGrapheme
  450. // The wide glyph naturally renders across both columns without modifying column N+1
  451. // See: https://github.com/gui-cs/Terminal.Gui/issues/4258
  452. Assert.False (
  453. buffer.Contents [1, 5].IsDirty,
  454. "Adjacent cell should NOT be marked dirty when writing wide char (see #4258)");
  455. // Verify no replacement characters were used
  456. Assert.NotEqual (customColumn1Replacement.ToString (), buffer.Contents [1, 4].Grapheme);
  457. Assert.NotEqual (customColumn2Replacement.ToString (), buffer.Contents [1, 5].Grapheme);
  458. // Verify Col has been advanced by 2
  459. Assert.Equal (6, buffer.Col);
  460. }
  461. }