Browse Source

Fixes #4466 - `FillRect` Corrupts Wide Characters When Overlapping (#4486)

* Improve wide character handling in output buffer

Enhances rendering and state management for wide (double-width) characters. Marks both cells as clean after rendering wide graphemes, ensures replacement cells are marked dirty when partially clipped, and uses Move/AddStr for proper wide character handling and invalidation.

* Fix FillRect to handle wide Unicode chars correctly

Refactored OutputBufferImpl.FillRect to properly handle wide (double-width) Unicode characters, fixing visual corruption when overwriting CJK text (e.g., with MessageBox borders). Removed the char-based FillRect overload in favor of Rune-based handling. Added helper methods for attribute/dirty management and wide glyph invalidation. Updated OutputBase.Write to always mark adjacent cells dirty for wide chars. Updated tests and added OutputBufferWideCharTests to verify correct behavior in all scenarios. This resolves issue #4466 and ensures robust rendering for wide Unicode text.

* Handle wide grapheme clusters in OutputBase rendering

Added logic to mark both cells of wide grapheme clusters as clean after rendering, preventing unnecessary redraws. Also included a commented-out preprocessor directive and using statement for potential future use.

* Clarify comment for IsDirty logic on wide graphemes

Updated the comment explaining why the next cell is marked clean (IsDirty = false) after handling wide graphemes, and added a reference to GitHub issue #4466 for context.

* Update test for dirty flag after wide glyph write

Adjusted OutputBaseTests to expect column 1's dirty flag to be cleared after writing a wide glyph to column 0, matching current OutputBase.Write behavior. Added clarifying comment and GitHub issue reference.

* Update Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs

Co-authored-by: Copilot <[email protected]>

* Update Terminal.Gui/Drivers/OutputBufferImpl.cs

Co-authored-by: Copilot <[email protected]>

* Update Terminal.Gui/Drivers/OutputBase.cs

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
Tig 1 ngày trước cách đây
mục cha
commit
48d6e13138

+ 0 - 3
Terminal.Gui/Drivers/DriverImpl.cs

@@ -330,9 +330,6 @@ internal class DriverImpl : IDriver
     /// <inheritdoc/>
     public void FillRect (Rectangle rect, Rune rune = default) { OutputBuffer.FillRect (rect, rune); }
 
-    /// <inheritdoc/>
-    public void FillRect (Rectangle rect, char c) { OutputBuffer.FillRect (rect, c); }
-
     /// <inheritdoc/>
     public Attribute SetAttribute (Attribute newAttribute)
     {

+ 0 - 8
Terminal.Gui/Drivers/IDriver.cs

@@ -257,14 +257,6 @@ public interface IDriver : IDisposable
     /// <param name="rune">The Rune used to fill the rectangle</param>
     void FillRect (Rectangle rect, Rune rune = default);
 
-    /// <summary>
-    ///     Fills the specified rectangle with the specified <see langword="char"/>. This method is a convenience method
-    ///     that calls <see cref="IDriver.FillRect(System.Drawing.Rectangle,System.Text.Rune)"/>.
-    /// </summary>
-    /// <param name="rect"></param>
-    /// <param name="c"></param>
-    void FillRect (Rectangle rect, char c);
-
     /// <summary>Selects the specified attribute as the attribute to use for future calls to AddRune and AddString.</summary>
     /// <remarks>Implementations should call <c>base.SetAttribute(c)</c>.</remarks>
     /// <param name="c">C.</param>

+ 7 - 0
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;
+                    }
                 }
             }
 

+ 70 - 57
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
     /// <remarks>
     ///     <para>
     ///         When the method returns, <see cref="Col"/> will be incremented by the number of columns
-    ///         <paramref name="rune"/> required, even if the new column value is outside of the <see cref="Clip"/> or screen
+    ///         <paramref name="rune"/> required, even if the new column value is outside the <see cref="Clip"/> or screen
     ///         dimensions defined by <see cref="Cols"/>.
     ///     </para>
     ///     <para>
@@ -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;
-
-                InvalidateOverlappedWideGlyph ();
+                SetAttributeAndDirty (Col, Row);
+                InvalidateOverlappedWideGlyph (Col, Row);
 
-                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
     }
 
     /// <summary>
-    ///     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.
+    /// </summary>
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
+    private void SetAttributeAndDirty (int col, int row)
+    {
+        Contents! [row, col].Attribute = CurrentAttribute;
+        Contents [row, col].IsDirty = true;
+    }
+
+    /// <summary>
+    ///     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.
     /// </summary>
-    private void InvalidateOverlappedWideGlyph ()
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
+    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;
         }
     }
 
     /// <summary>
-    ///     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).
     /// </summary>
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
     /// <param name="text">The printable text to write.</param>
     /// <param name="textWidth">The column width of the text.</param>
     /// <param name="clipRect">The clipping rectangle.</param>
-    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;
         }
     }
 
     /// <summary>
-    ///     Writes a single-width character (0 or 1 column wide).
+    ///     INTERNAL: Writes a (0 or 1 column wide) Grapheme.
     /// </summary>
-    private void WriteSingleWidthGrapheme (string text, Rectangle clipRect)
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
+    /// <param name="grapheme">The single-width Grapheme to write.</param>
+    /// <param name="clipRect">The clipping rectangle.</param>
+    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;
         }
     }
 
     /// <summary>
-    ///     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.
     /// </summary>
-    private void WriteWideGrapheme (string text)
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
+    /// <param name="grapheme">The wide Grapheme to write.</param>
+    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);
     }
 
     /// <summary>Tests whether the specified coordinate are valid for drawing the specified Text.</summary>
@@ -342,8 +355,9 @@ public class OutputBufferImpl : IOutputBuffer
     /// <inheritdoc/>
     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
     /// <summary>
     ///     Updates <see cref="Col"/> and <see cref="Row"/> to the specified column and row in <see cref="Contents"/>.
     ///     Used by <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content.
@@ -393,9 +407,8 @@ public class OutputBufferImpl : IOutputBuffer
     /// </remarks>
     /// <param name="col">Column to move to.</param>
     /// <param name="row">Row to move to.</param>
-    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;
     }

+ 1 - 0
Terminal.sln.DotSettings

@@ -426,6 +426,7 @@
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Mazing/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=ogonek/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Quattro/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=repro/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Roslynator/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=RRGGBB/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=runnables/@EntryIndexedValue">True</s:Boolean>

+ 3 - 3
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);

+ 3 - 3
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);

+ 359 - 0
Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs

@@ -0,0 +1,359 @@
+using System.Text;
+using Xunit.Abstractions;
+
+namespace DriverTests;
+
+/// <summary>
+///     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.
+/// </summary>
+public class OutputBufferWideCharTests (ITestOutputHelper output)
+{
+    /// <summary>
+    ///     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.
+    /// </summary>
+    [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");
+    }
+
+    /// <summary>
+    ///     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.
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    ///     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
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    ///     Tests that FillRect works correctly with single-width characters (baseline behavior).
+    ///     This should work the same with or without FIXES_4466.
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    ///     Tests FillRect with wide characters at buffer boundaries.
+    /// </summary>
+    [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");
+    }
+
+    /// <summary>
+    ///     Tests OutputBase.Write method marks cells dirty correctly for wide characters.
+    ///     This tests the other half of the fix in OutputBase.cs.
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    ///     Tests that filling a rectangle with spaces properly handles wide character cleanup.
+    ///     This simulates clearing a region that contains wide characters.
+    /// </summary>
+    [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");
+    }
+}