diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 5c0d5ad72..2368dd631 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -330,9 +330,6 @@ internal class DriverImpl : IDriver /// public void FillRect (Rectangle rect, Rune rune = default) { OutputBuffer.FillRect (rect, rune); } - /// - public void FillRect (Rectangle rect, char c) { OutputBuffer.FillRect (rect, c); } - /// public Attribute SetAttribute (Attribute newAttribute) { diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index 0abd121b7..41ff4d091 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -257,14 +257,6 @@ public interface IDriver : IDisposable /// The Rune used to fill the rectangle void FillRect (Rectangle rect, Rune rune = default); - /// - /// Fills the specified rectangle with the specified . This method is a convenience method - /// that calls . - /// - /// - /// - void FillRect (Rectangle rect, char c); - /// Selects the specified attribute as the attribute to use for future calls to AddRune and AddString. /// Implementations should call base.SetAttribute(c). /// C. diff --git a/Terminal.Gui/Drivers/OutputBase.cs b/Terminal.Gui/Drivers/OutputBase.cs index 347eba70b..cbeb403a0 100644 --- a/Terminal.Gui/Drivers/OutputBase.cs +++ b/Terminal.Gui/Drivers/OutputBase.cs @@ -127,6 +127,13 @@ public abstract class OutputBase Cell cell = buffer.Contents [row, col]; buffer.Contents [row, col].IsDirty = false; AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); + + if (col != lastCol) + { + // Was a wide grapheme so mark clean next cell + // See https://github.com/gui-cs/Terminal.Gui/issues/4466 + buffer.Contents [row, col].IsDirty = false; + } } } diff --git a/Terminal.Gui/Drivers/OutputBufferImpl.cs b/Terminal.Gui/Drivers/OutputBufferImpl.cs index c12dc29f5..be03a26c6 100644 --- a/Terminal.Gui/Drivers/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/OutputBufferImpl.cs @@ -86,7 +86,7 @@ public class OutputBufferImpl : IOutputBuffer get => _clip; set { - if (_clip == value) + if (ReferenceEquals (_clip, value)) { return; } @@ -94,10 +94,7 @@ public class OutputBufferImpl : IOutputBuffer _clip = value; // Don't ever let Clip be bigger than Screen - if (_clip is { }) - { - _clip.Intersect (Screen); - } + _clip?.Intersect (Screen); } } @@ -105,7 +102,7 @@ public class OutputBufferImpl : IOutputBuffer /// /// /// When the method returns, will be incremented by the number of columns - /// required, even if the new column value is outside of the or screen + /// required, even if the new column value is outside the or screen /// dimensions defined by . /// /// @@ -156,25 +153,19 @@ public class OutputBufferImpl : IOutputBuffer Clip ??= new (Screen); Rectangle clipRect = Clip!.GetBounds (); - string text = grapheme; - int textWidth = -1; + int printableGraphemeWidth = -1; lock (Contents) { - bool validLocation = IsValidLocation (text, Col, Row); - - if (validLocation) + if (IsValidLocation (grapheme, Col, Row)) { - text = text.MakePrintable (); - textWidth = text.GetColumns (); - // Set attribute and mark dirty for current cell - Contents [Row, Col].Attribute = CurrentAttribute; - Contents [Row, Col].IsDirty = true; + SetAttributeAndDirty (Col, Row); + InvalidateOverlappedWideGlyph (Col, Row); - InvalidateOverlappedWideGlyph (); - - WriteGraphemeByWidth (text, textWidth, clipRect); + string printableGrapheme = grapheme.MakePrintable (); + printableGraphemeWidth = printableGrapheme.GetColumns (); + WriteGraphemeByWidth (Col, Row, printableGrapheme, printableGraphemeWidth, clipRect); DirtyLines [Row] = true; } @@ -183,7 +174,7 @@ public class OutputBufferImpl : IOutputBuffer // Keep Col/Row updates inside the lock to prevent race conditions Col++; - if (textWidth > 1) + if (printableGraphemeWidth > 1) { // Skip the second column of a wide character // IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here. @@ -194,86 +185,111 @@ public class OutputBufferImpl : IOutputBuffer } /// - /// If we're writing at an odd column and there's a wide glyph to our left, + /// INTERNAL: Helper to set the attribute and mark the cell as dirty. + /// + /// The column. + /// The row. + private void SetAttributeAndDirty (int col, int row) + { + Contents! [row, col].Attribute = CurrentAttribute; + Contents [row, col].IsDirty = true; + } + + /// + /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, /// invalidate it since we're overwriting the second half. /// - private void InvalidateOverlappedWideGlyph () + /// The column. + /// The row. + private void InvalidateOverlappedWideGlyph (int col, int row) { - if (Col > 0 && Contents! [Row, Col - 1].Grapheme.GetColumns () > 1) + if (col > 0 && Contents! [row, col - 1].Grapheme.GetColumns () > 1) { - Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString (); - Contents [Row, Col - 1].IsDirty = true; + Contents [row, col - 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents [row, col - 1].IsDirty = true; } } /// - /// Writes a grapheme to the buffer based on its width (0, 1, or 2 columns). + /// INTERNAL: Writes a Grapheme to the buffer based on its width (0, 1, or 2 columns). /// + /// The column. + /// The row. /// The printable text to write. /// The column width of the text. /// The clipping rectangle. - private void WriteGraphemeByWidth (string text, int textWidth, Rectangle clipRect) + private void WriteGraphemeByWidth (int col, int row, string text, int textWidth, Rectangle clipRect) { switch (textWidth) { case 0: case 1: - WriteSingleWidthGrapheme (text, clipRect); + WriteGrapheme (col, row, text, clipRect); break; case 2: - WriteWideGrapheme (text); + WriteWideGrapheme (col, row, text); break; default: // Negative width or non-spacing character (shouldn't normally occur) - Contents! [Row, Col].Grapheme = " "; - Contents [Row, Col].IsDirty = false; + Contents! [row, col].Grapheme = " "; + Contents [row, col].IsDirty = false; break; } } /// - /// Writes a single-width character (0 or 1 column wide). + /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. /// - private void WriteSingleWidthGrapheme (string text, Rectangle clipRect) + /// The column. + /// The row. + /// The single-width Grapheme to write. + /// The clipping rectangle. + private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) { - Contents! [Row, Col].Grapheme = text; + Debug.Assert (grapheme.GetColumns () < 2); + Contents! [row, col].Grapheme = grapheme; // Mark the next cell as dirty to ensure proper rendering of adjacent content - if (Col < clipRect.Right - 1 && Col + 1 < Cols) + if (col < clipRect.Right - 1 && col + 1 < Cols) { - Contents [Row, Col + 1].IsDirty = true; + Contents [row, col + 1].IsDirty = true; } } /// - /// Writes a wide character (2 columns wide) handling clipping and partial overlap cases. + /// INTERNAL: Writes a wide Grapheme (2 columns wide) handling clipping and partial overlap cases. /// - private void WriteWideGrapheme (string text) + /// The column. + /// The row. + /// The wide Grapheme to write. + private void WriteWideGrapheme (int col, int row, string grapheme) { - if (!Clip!.Contains (Col + 1, Row)) + Debug.Assert (grapheme.GetColumns () == 2); + if (!Clip!.Contains (col + 1, row)) { // Second column is outside clip - can't fit wide char here - Contents! [Row, Col].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col].Grapheme = Rune.ReplacementChar.ToString (); } - else if (!Clip.Contains (Col, Row)) + else if (!Clip.Contains (col, row)) { // First column is outside clip but second isn't // Mark second column as replacement to indicate partial overlap - if (Col + 1 < Cols) + if (col + 1 < Cols) { - Contents! [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col + 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col + 1].IsDirty = true; } } else { // Both columns are in bounds - write the wide character // It will naturally render across both columns when output to the terminal - Contents! [Row, Col].Grapheme = text; + Contents! [row, col].Grapheme = grapheme; // DO NOT modify column N+1 here! // The wide glyph will naturally render across both columns. @@ -288,7 +304,7 @@ public class OutputBufferImpl : IOutputBuffer { Contents = new Cell [Rows, Cols]; - //CONCURRENCY: Unsynchronized access to Clip isn't safe. + // CONCURRENCY: Unsynchronized access to Clip isn't safe. // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. Clip = new (Screen); @@ -311,9 +327,6 @@ public class OutputBufferImpl : IOutputBuffer DirtyLines [row] = true; } } - - // TODO: Who uses this and why? I am removing for now - this class is a state class not an events class - //ClearedContents?.Invoke (this, EventArgs.Empty); } /// Tests whether the specified coordinate are valid for drawing the specified Text. @@ -342,8 +355,9 @@ public class OutputBufferImpl : IOutputBuffer /// public void FillRect (Rectangle rect, Rune rune) { + Rectangle clipBounds = Clip?.GetBounds () ?? Screen; // BUGBUG: This should be a method on Region - rect = Rectangle.Intersect (rect, Clip?.GetBounds () ?? Screen); + rect = Rectangle.Intersect (rect, clipBounds); lock (Contents!) { @@ -356,11 +370,12 @@ public class OutputBufferImpl : IOutputBuffer continue; } - Contents [r, c] = new () - { - Grapheme = rune != default (Rune) ? rune.ToString () : " ", - Attribute = CurrentAttribute, IsDirty = true - }; + // We could call AddGrapheme here, but that would acquire the lock again. + // So we inline the logic instead. + SetAttributeAndDirty (c, r); + InvalidateOverlappedWideGlyph (c, r); + string grapheme = rune != default (Rune) ? rune.ToString () : " "; + WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); } } } @@ -379,7 +394,6 @@ public class OutputBufferImpl : IOutputBuffer } } - // TODO: Make internal once Menu is upgraded /// /// Updates and to the specified column and row in . /// Used by and to determine where to add content. @@ -393,9 +407,8 @@ public class OutputBufferImpl : IOutputBuffer /// /// Column to move to. /// Row to move to. - public virtual void Move (int col, int row) + public void Move (int col, int row) { - //Debug.Assert (col >= 0 && row >= 0 && col < Contents.GetLength(1) && row < Contents.GetLength(0)); Col = col; Row = row; } diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index a33c71b8d..e3f73a543 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -426,6 +426,7 @@ True True True + True True True True diff --git a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs index c07d4070d..d36323999 100644 --- a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs @@ -23,12 +23,12 @@ public class ClipRegionTests (ITestOutputHelper output) : FakeDriverBase Assert.Equal ("x", driver.Contents [5, 5].Grapheme); // Clear the contents - driver.FillRect (new Rectangle (0, 0, driver.Rows, driver.Cols), ' '); + driver.FillRect (new (0, 0, driver.Rows, driver.Cols), new Rune(' ')); Assert.Equal (" ", driver.Contents [0, 0].Grapheme); // Setup the region with a single rectangle, fill screen with 'x' - driver.Clip = new (new Rectangle (5, 5, 5, 5)); - driver.FillRect (new Rectangle (0, 0, driver.Rows, driver.Cols), 'x'); + driver.Clip = new (new (5, 5, 5, 5)); + driver.FillRect (new (0, 0, driver.Rows, driver.Cols), new Rune ('x')); Assert.Equal (" ", driver.Contents [0, 0].Grapheme); Assert.Equal (" ", driver.Contents [4, 9].Grapheme); Assert.Equal ("x", driver.Contents [5, 5].Grapheme); diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs index 6ac00a739..19c6ddcec 100644 --- a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs @@ -189,9 +189,9 @@ public class OutputBaseTests // Column 0 was written (wide glyph) Assert.False (buffer.Contents! [0, 0].IsDirty); - // Column 1 was skipped by OutputBase.Write because column 0 had a wide glyph - // So its dirty flag remains true (it was initialized as dirty by ClearContents) - Assert.True (buffer.Contents! [0, 1].IsDirty); + // Column 1 was marked as clean by OutputBase.Write when it processed the wide glyph at column 0 + // See: https://github.com/gui-cs/Terminal.Gui/issues/4466 + Assert.False (buffer.Contents! [0, 1].IsDirty); // Column 2 was written ('A') Assert.False (buffer.Contents! [0, 2].IsDirty); diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs new file mode 100644 index 000000000..d8da44b65 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs @@ -0,0 +1,359 @@ +using System.Text; +using Xunit.Abstractions; + +namespace DriverTests; + +/// +/// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466. +/// These tests validate that FillRect properly handles wide characters when overlapping existing content. +/// Specifically, they ensure that wide characters are properly invalidated and replaced when a MessageBox border or similar UI element is drawn over them, preventing visual corruption. +/// +public class OutputBufferWideCharTests (ITestOutputHelper output) +{ + /// + /// Tests that FillRect properly invalidates wide characters when overwriting them. + /// This is the core issue in #4466 - when a MessageBox border is drawn over Chinese text, + /// the wide characters need to be properly invalidated. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_OverwritesWideChar_InvalidatesProperly () + { + // Arrange - Create a buffer and draw a wide character + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a Chinese character (2 columns wide) at position 2,1 + buffer.Move (2, 1); + buffer.AddStr ("你"); // Chinese character "you", 2 columns wide + + // Verify the wide character was drawn + Assert.Equal ("你", buffer.Contents! [1, 2].Grapheme); + Assert.True (buffer.Contents [1, 2].IsDirty); + + // With the fix, the second column should NOT be modified by AddStr + // The wide glyph naturally renders across both columns + Assert.NotEqual ("你", buffer.Contents [1, 3].Grapheme); + + // Clear dirty flags to test FillRect behavior + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill a rectangle that overlaps the first column of the wide character + // This simulates drawing a MessageBox border over Chinese text + buffer.FillRect (new (2, 1, 1, 1), new Rune ('│')); + + // Assert + + // With FIXES_4466: FillRect calls AddStr, which properly invalidates the wide character + // The wide character at [1,2] should be replaced with replacement char or the new content + Assert.Equal ("│", buffer.Contents [1, 2].Grapheme); + Assert.True (buffer.Contents [1, 2].IsDirty, "Cell [1,2] should be marked dirty after FillRect"); + + // The adjacent cell should also be marked dirty for proper rendering + Assert.True (buffer.Contents [1, 3].IsDirty, "Adjacent cell [1,3] should be marked dirty to ensure proper rendering"); + } + + /// + /// Tests that FillRect handles overwriting the second column of a wide character. + /// When drawing at an odd column that's the second half of a wide glyph, the + /// wide glyph should be invalidated. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_OverwritesSecondColumnOfWideChar_InvalidatesWideChar () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a wide character at position 2,1 + buffer.Move (2, 1); + buffer.AddStr ("好"); // Chinese character, 2 columns wide + + Assert.Equal ("好", buffer.Contents! [1, 2].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill at the second column of the wide character (position 3) + buffer.FillRect (new (3, 1, 1, 1), new Rune ('│')); + + // Assert + // With the fix: The original wide character at col 2 should be invalidated + // because we're overwriting its second column + Assert.True (buffer.Contents [1, 2].IsDirty, "Wide char at col 2 should be invalidated when its second column is overwritten"); + Assert.Equal (buffer.Contents [1, 2].Grapheme, Rune.ReplacementChar.ToString ()); + + Assert.Equal ("│", buffer.Contents [1, 3].Grapheme); + Assert.True (buffer.Contents [1, 3].IsDirty); + } + + /// + /// Tests the ChineseUI scenario: Drawing a MessageBox with borders over Chinese button text. + /// This simulates the specific repro case from the issue. See: https://github.com/gui-cs/Terminal.Gui/issues/4466 + /// + [Fact] + [Trait ("Category", "Output")] + public void ChineseUI_MessageBox_Over_WideChars () + { + // Arrange - Simulate the ChineseUI scenario + OutputBufferImpl buffer = new () + { + Rows = 10, Cols = 30, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw Chinese button text (like "你好呀") + buffer.Move (5, 3); + buffer.AddStr ("你好呀"); // 3 Chinese characters, 6 columns total + + // Verify initial state + Assert.Equal ("你", buffer.Contents! [3, 5].Grapheme); + Assert.Equal ("好", buffer.Contents [3, 7].Grapheme); + Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme); + + // Clear dirty flags to simulate the state before MessageBox draws + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Draw a MessageBox border that partially overlaps the Chinese text + // This simulates the mouse moving over the border, causing HighlightState changes + // Draw vertical line at column 8 (overlaps second char "好") + for (var row = 2; row < 6; row++) + { + buffer.FillRect (new (8, row, 1, 1), new Rune ('│')); + } + + // Assert - The wide characters should be properly handled + // With the fix: Wide characters are properly invalidated + // The first character "你" at col 5 should be unaffected + Assert.Equal ("你", buffer.Contents [3, 5].Grapheme); + + // The second character "好" at col 7 had its second column overwritten + // so it should be replaced with replacement char + Assert.Equal (buffer.Contents [3, 7].Grapheme, Rune.ReplacementChar.ToString ()); + Assert.True (buffer.Contents [3, 7].IsDirty, "Invalidated wide char should be marked dirty"); + + // The border should be drawn at col 8 + Assert.Equal ("│", buffer.Contents [3, 8].Grapheme); + Assert.True (buffer.Contents [3, 8].IsDirty); + + // The third character "呀" at col 9 should be unaffected + Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme); + } + + /// + /// Tests that FillRect works correctly with single-width characters (baseline behavior). + /// This should work the same with or without FIXES_4466. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_SingleWidthChars_WorksCorrectly () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw some ASCII text + buffer.Move (2, 1); + buffer.AddStr ("ABC"); + + Assert.Equal ("A", buffer.Contents! [1, 2].Grapheme); + Assert.Equal ("B", buffer.Contents [1, 3].Grapheme); + Assert.Equal ("C", buffer.Contents [1, 4].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Overwrite with FillRect + buffer.FillRect (new (3, 1, 1, 1), new Rune ('X')); + + // Assert - This should work the same regardless of FIXES_4466 + Assert.Equal ("A", buffer.Contents [1, 2].Grapheme); + Assert.Equal ("X", buffer.Contents [1, 3].Grapheme); + Assert.True (buffer.Contents [1, 3].IsDirty); + Assert.Equal ("C", buffer.Contents [1, 4].Grapheme); + } + + /// + /// Tests FillRect with wide characters at buffer boundaries. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_WideChar_AtBufferBoundary () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a wide character at the right edge (col 8, which would extend to col 9) + buffer.Move (8, 1); + buffer.AddStr ("山"); // Chinese character "mountain", 2 columns wide + + Assert.Equal ("山", buffer.Contents! [1, 8].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - FillRect at the wide character position + buffer.FillRect (new (8, 1, 1, 1), new Rune ('│')); + + // Assert + Assert.Equal ("│", buffer.Contents [1, 8].Grapheme); + Assert.True (buffer.Contents [1, 8].IsDirty); + + // Adjacent cell should be marked dirty + Assert.True ( + buffer.Contents [1, 9].IsDirty, + "Cell after wide char replacement should be marked dirty"); + } + + /// + /// Tests OutputBase.Write method marks cells dirty correctly for wide characters. + /// This tests the other half of the fix in OutputBase.cs. + /// + [Fact] + [Trait ("Category", "Output")] + public void OutputBase_Write_WideChar_MarksCellsDirty () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 20, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a line with wide characters + buffer.Move (0, 1); + buffer.AddStr ("你好"); // Two wide characters + + // Mark all as not dirty to simulate post-Write state + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents! [r, c].IsDirty = false; + } + } + + // Verify initial state + Assert.Equal ("你", buffer.Contents! [1, 0].Grapheme); + Assert.Equal ("好", buffer.Contents [1, 2].Grapheme); + + // Act - Now overwrite the first wide char by writing at its position + buffer.Move (0, 1); + buffer.AddStr ("A"); // Single width char + + // Assert + // With the fix: The first cell is replaced with 'A' and marked dirty + Assert.Equal ("A", buffer.Contents [1, 0].Grapheme); + Assert.True (buffer.Contents [1, 0].IsDirty); + + // The adjacent cell (col 1) should be marked dirty for proper rendering + Assert.True ( + buffer.Contents [1, 1].IsDirty, + "Adjacent cell should be marked dirty after writing single-width char over wide char"); + + // The second wide char should remain + Assert.Equal ("好", buffer.Contents [1, 2].Grapheme); + } + + /// + /// Tests that filling a rectangle with spaces properly handles wide character cleanup. + /// This simulates clearing a region that contains wide characters. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_WithSpaces_OverWideChars () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 15, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a line of mixed content + buffer.Move (2, 2); + buffer.AddStr ("A你B好C"); + + // Verify setup + Assert.Equal ("A", buffer.Contents! [2, 2].Grapheme); + Assert.Equal ("你", buffer.Contents [2, 3].Grapheme); + Assert.Equal ("B", buffer.Contents [2, 5].Grapheme); + Assert.Equal ("好", buffer.Contents [2, 6].Grapheme); + Assert.Equal ("C", buffer.Contents [2, 8].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill the region with spaces (simulating clearing) + buffer.FillRect (new (3, 2, 4, 1), new Rune (' ')); + + // Assert + // With the fix: Wide characters are properly handled + Assert.Equal (" ", buffer.Contents [2, 3].Grapheme); + Assert.True (buffer.Contents [2, 3].IsDirty); + + // Wide character '你' at col 3 was replaced, so col 4 should be marked dirty + Assert.True ( + buffer.Contents [2, 4].IsDirty, + "Cell after replaced wide char should be dirty"); + + Assert.Equal (" ", buffer.Contents [2, 4].Grapheme); + Assert.Equal (" ", buffer.Contents [2, 5].Grapheme); + Assert.Equal (" ", buffer.Contents [2, 6].Grapheme); + + // Cell 7 should be dirty because '好' was partially overwritten + Assert.True ( + buffer.Contents [2, 7].IsDirty, + "Adjacent cell should be dirty after wide char replacement"); + } +}