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

This commit is contained in:
Tig
2025-12-12 18:13:29 -07:00
8 changed files with 443 additions and 74 deletions

View File

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

View File

@@ -257,14 +257,6 @@ public interface IDriver : IDisposable
/// <param name="rune">The Rune used to fill the rectangle</param> /// <param name="rune">The Rune used to fill the rectangle</param>
void FillRect (Rectangle rect, Rune rune = default); 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> /// <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> /// <remarks>Implementations should call <c>base.SetAttribute(c)</c>.</remarks>
/// <param name="c">C.</param> /// <param name="c">C.</param>

View File

@@ -127,6 +127,13 @@ public abstract class OutputBase
Cell cell = buffer.Contents [row, col]; Cell cell = buffer.Contents [row, col];
buffer.Contents [row, col].IsDirty = false; buffer.Contents [row, col].IsDirty = false;
AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); 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;
}
} }
} }

View File

@@ -86,7 +86,7 @@ public class OutputBufferImpl : IOutputBuffer
get => _clip; get => _clip;
set set
{ {
if (_clip == value) if (ReferenceEquals (_clip, value))
{ {
return; return;
} }
@@ -94,10 +94,7 @@ public class OutputBufferImpl : IOutputBuffer
_clip = value; _clip = value;
// Don't ever let Clip be bigger than Screen // 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> /// <remarks>
/// <para> /// <para>
/// When the method returns, <see cref="Col"/> will be incremented by the number of columns /// 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"/>. /// dimensions defined by <see cref="Cols"/>.
/// </para> /// </para>
/// <para> /// <para>
@@ -156,25 +153,19 @@ public class OutputBufferImpl : IOutputBuffer
Clip ??= new (Screen); Clip ??= new (Screen);
Rectangle clipRect = Clip!.GetBounds (); Rectangle clipRect = Clip!.GetBounds ();
string text = grapheme; int printableGraphemeWidth = -1;
int textWidth = -1;
lock (Contents) lock (Contents)
{ {
bool validLocation = IsValidLocation (text, Col, Row); if (IsValidLocation (grapheme, Col, Row))
if (validLocation)
{ {
text = text.MakePrintable ();
textWidth = text.GetColumns ();
// Set attribute and mark dirty for current cell // Set attribute and mark dirty for current cell
Contents [Row, Col].Attribute = CurrentAttribute; SetAttributeAndDirty (Col, Row);
Contents [Row, Col].IsDirty = true; InvalidateOverlappedWideGlyph (Col, Row);
InvalidateOverlappedWideGlyph (); string printableGrapheme = grapheme.MakePrintable ();
printableGraphemeWidth = printableGrapheme.GetColumns ();
WriteGraphemeByWidth (text, textWidth, clipRect); WriteGraphemeByWidth (Col, Row, printableGrapheme, printableGraphemeWidth, clipRect);
DirtyLines [Row] = true; DirtyLines [Row] = true;
} }
@@ -183,7 +174,7 @@ public class OutputBufferImpl : IOutputBuffer
// Keep Col/Row updates inside the lock to prevent race conditions // Keep Col/Row updates inside the lock to prevent race conditions
Col++; Col++;
if (textWidth > 1) if (printableGraphemeWidth > 1)
{ {
// Skip the second column of a wide character // Skip the second column of a wide character
// IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here. // IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here.
@@ -194,86 +185,111 @@ public class OutputBufferImpl : IOutputBuffer
} }
/// <summary> /// <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. /// invalidate it since we're overwriting the second half.
/// </summary> /// </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].Grapheme = Rune.ReplacementChar.ToString ();
Contents [Row, Col - 1].IsDirty = true; Contents [row, col - 1].IsDirty = true;
} }
} }
/// <summary> /// <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> /// </summary>
/// <param name="col">The column.</param>
/// <param name="row">The row.</param>
/// <param name="text">The printable text to write.</param> /// <param name="text">The printable text to write.</param>
/// <param name="textWidth">The column width of the text.</param> /// <param name="textWidth">The column width of the text.</param>
/// <param name="clipRect">The clipping rectangle.</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) switch (textWidth)
{ {
case 0: case 0:
case 1: case 1:
WriteSingleWidthGrapheme (text, clipRect); WriteGrapheme (col, row, text, clipRect);
break; break;
case 2: case 2:
WriteWideGrapheme (text); WriteWideGrapheme (col, row, text);
break; break;
default: default:
// Negative width or non-spacing character (shouldn't normally occur) // Negative width or non-spacing character (shouldn't normally occur)
Contents! [Row, Col].Grapheme = " "; Contents! [row, col].Grapheme = " ";
Contents [Row, Col].IsDirty = false; Contents [row, col].IsDirty = false;
break; break;
} }
} }
/// <summary> /// <summary>
/// Writes a single-width character (0 or 1 column wide). /// INTERNAL: Writes a (0 or 1 column wide) Grapheme.
/// </summary> /// </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 // 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> /// <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> /// </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 // 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 // First column is outside clip but second isn't
// Mark second column as replacement to indicate partial overlap // 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 else
{ {
// Both columns are in bounds - write the wide character // Both columns are in bounds - write the wide character
// It will naturally render across both columns when output to the terminal // 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! // DO NOT modify column N+1 here!
// The wide glyph will naturally render across both columns. // The wide glyph will naturally render across both columns.
@@ -288,7 +304,7 @@ public class OutputBufferImpl : IOutputBuffer
{ {
Contents = new Cell [Rows, Cols]; 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. // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere.
Clip = new (Screen); Clip = new (Screen);
@@ -311,9 +327,6 @@ public class OutputBufferImpl : IOutputBuffer
DirtyLines [row] = true; 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> /// <summary>Tests whether the specified coordinate are valid for drawing the specified Text.</summary>
@@ -342,8 +355,9 @@ public class OutputBufferImpl : IOutputBuffer
/// <inheritdoc/> /// <inheritdoc/>
public void FillRect (Rectangle rect, Rune rune) public void FillRect (Rectangle rect, Rune rune)
{ {
Rectangle clipBounds = Clip?.GetBounds () ?? Screen;
// BUGBUG: This should be a method on Region // BUGBUG: This should be a method on Region
rect = Rectangle.Intersect (rect, Clip?.GetBounds () ?? Screen); rect = Rectangle.Intersect (rect, clipBounds);
lock (Contents!) lock (Contents!)
{ {
@@ -356,11 +370,12 @@ public class OutputBufferImpl : IOutputBuffer
continue; continue;
} }
Contents [r, c] = new () // We could call AddGrapheme here, but that would acquire the lock again.
{ // So we inline the logic instead.
Grapheme = rune != default (Rune) ? rune.ToString () : " ", SetAttributeAndDirty (c, r);
Attribute = CurrentAttribute, IsDirty = true 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> /// <summary>
/// Updates <see cref="Col"/> and <see cref="Row"/> to the specified column and row in <see cref="Contents"/>. /// 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. /// 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> /// </remarks>
/// <param name="col">Column to move to.</param> /// <param name="col">Column to move to.</param>
/// <param name="row">Row 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; Col = col;
Row = row; Row = row;
} }

View File

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

View File

@@ -23,12 +23,12 @@ public class ClipRegionTests (ITestOutputHelper output) : FakeDriverBase
Assert.Equal ("x", driver.Contents [5, 5].Grapheme); Assert.Equal ("x", driver.Contents [5, 5].Grapheme);
// Clear the contents // 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); Assert.Equal (" ", driver.Contents [0, 0].Grapheme);
// Setup the region with a single rectangle, fill screen with 'x' // Setup the region with a single rectangle, fill screen with 'x'
driver.Clip = new (new Rectangle (5, 5, 5, 5)); driver.Clip = new (new (5, 5, 5, 5));
driver.FillRect (new Rectangle (0, 0, driver.Rows, driver.Cols), 'x'); driver.FillRect (new (0, 0, driver.Rows, driver.Cols), new Rune ('x'));
Assert.Equal (" ", driver.Contents [0, 0].Grapheme); Assert.Equal (" ", driver.Contents [0, 0].Grapheme);
Assert.Equal (" ", driver.Contents [4, 9].Grapheme); Assert.Equal (" ", driver.Contents [4, 9].Grapheme);
Assert.Equal ("x", driver.Contents [5, 5].Grapheme); Assert.Equal ("x", driver.Contents [5, 5].Grapheme);

View File

@@ -189,9 +189,9 @@ public class OutputBaseTests
// Column 0 was written (wide glyph) // Column 0 was written (wide glyph)
Assert.False (buffer.Contents! [0, 0].IsDirty); Assert.False (buffer.Contents! [0, 0].IsDirty);
// Column 1 was skipped by OutputBase.Write because column 0 had a wide glyph // Column 1 was marked as clean by OutputBase.Write when it processed the wide glyph at column 0
// So its dirty flag remains true (it was initialized as dirty by ClearContents) // See: https://github.com/gui-cs/Terminal.Gui/issues/4466
Assert.True (buffer.Contents! [0, 1].IsDirty); Assert.False (buffer.Contents! [0, 1].IsDirty);
// Column 2 was written ('A') // Column 2 was written ('A')
Assert.False (buffer.Contents! [0, 2].IsDirty); Assert.False (buffer.Contents! [0, 2].IsDirty);

View File

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