2
0
Эх сурвалжийг харах

Merge branch 'v2_develop' of tig:tig/Terminal.Gui into v2_develop

Tig 1 өдөр өмнө
parent
commit
e2ab7df3e4

+ 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");
+    }
+}