Fixes #4497 - makes replacement char conifgurable (#4498)

* Add Glyphs.ReplacementChar config property

Introduced Glyphs.ReplacementChar to allow overriding the Unicode replacement character, defaulting to a space (' '). Updated both config.json and Glyphs.cs with this property, scoped to ThemeScope and documented as an override for Rune.ReplacementChar.

* Standardize to Glyphs.ReplacementChar for wide char invalidation

Replaced all uses of Rune.ReplacementChar.ToString() with Glyphs.ReplacementChar.ToString() in OutputBufferImpl and related tests. This ensures consistent use of the replacement character when invalidating or overwriting wide characters in the output buffer.

* Add configurable wide glyph replacement chars to OutputBuffer

Allows setting custom replacement characters for wide glyphs that cannot fit in the available space via IOutputBuffer.SetReplacementChars. Updated IDriver to expose GetOutputBuffer. All code paths and tests now use the configurable characters, improving testability and flexibility. Tests now use '①' and '②' for clarity instead of the default replacement character.

* Fixed warnings.

* Update IOutputBuffer.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add tests for wide char clipping edge cases in OutputBuffer

Added three unit tests to OutputBufferWideCharTests.cs to verify and document OutputBufferImpl's behavior when wide (double-width) characters are written at the edges of a clipping region. Tests cover cases where the first or second column of a wide character is outside the clip, as well as when both columns are inside. The tests assert correct use of replacement characters, dirty flags, and column advancement, and document that certain code paths are currently unreachable due to IsValidLocation checks.

* Clarify dead code path with explanatory comments

Added comments to mark a rarely executed code path as dead code, noting it is apparently never called. Referenced the related test scenario AddStr_WideChar_FirstColumnOutsideClip_SecondColumnInside_CurrentBehavior for context. No functional changes were made.

* Remove dead code for wide char partial clip handling

Removed unreachable code that handled the case where the first column of a wide character is outside the clipping region but the second column is inside. This logic was marked as dead code and never called. Now, only the cases where the second column is outside the clip or both columns are in bounds are handled. This simplifies the code and removes unnecessary checks.

* Replaces Glyphs.ReplacementChar with Glyphs.WideGlyphReplacement to clarify its use for clipped wide glyphs. Updates IOutputBuffer to use SetWideGlyphReplacement (single Rune) instead of SetReplacementChars (two Runes). Refactors OutputBufferImpl and all test code to use the new property and method. Removes second-column replacement logic, simplifying the API and improving consistency. Updates comments and test assertions to match the new naming and behavior.

* Update themes in config.json and add new UI Catalog props

Renamed "UI Catalog Theme" to "UI Catalog" and removed the
"Glyphs.ReplacementChar" property. Added several new properties
to the "UI Catalog" theme, including default shadow, highlight
states, button alignment, and separator line style. Also added
"Glyphs.WideGlyphReplacement" to the "Hot Dog Stand" theme.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tig
2025-12-15 09:26:52 -07:00
committed by GitHub
parent fb1a3e03f3
commit 4c772bd5f3
14 changed files with 270 additions and 54 deletions

View File

@@ -9,6 +9,7 @@
"Themes": [ "Themes": [
{ {
"Hot Dog Stand": { "Hot Dog Stand": {
"Glyphs.WideGlyphReplacement": "①",
"Schemes": [ "Schemes": [
{ {
"Runnable": { "Runnable": {
@@ -134,7 +135,7 @@
} }
}, },
{ {
"UI Catalog Theme": { "UI Catalog": {
"Window.DefaultShadow": "Transparent", "Window.DefaultShadow": "Transparent",
"Button.DefaultShadow": "None", "Button.DefaultShadow": "None",
"CheckBox.DefaultHighlightStates": "In, Pressed, PressedOutside", "CheckBox.DefaultHighlightStates": "In, Pressed, PressedOutside",

View File

@@ -34,10 +34,6 @@ public sealed class WideGlyphs : Scenario
AutoSelectAdornments = false, AutoSelectAdornments = false,
ShowViewIdentifier = true ShowViewIdentifier = true
}; };
adornmentsEditor.ExpanderButton.Accepting += (sender, args) =>
{
//adornmentsEditor.ExpanderButton.Collapsed = args.NewValue;
};
appWindow.Add (adornmentsEditor); appWindow.Add (adornmentsEditor);
ViewportSettingsEditor viewportSettingsEditor = new () ViewportSettingsEditor viewportSettingsEditor = new ()

View File

@@ -26,6 +26,11 @@ public class Glyphs
// IMPORTANT: Configuration Manager test SaveDefaults uses this class to generate the default config file // IMPORTANT: Configuration Manager test SaveDefaults uses this class to generate the default config file
// IMPORTANT: in ./UnitTests/bin/Debug/netX.0/config.json // IMPORTANT: in ./UnitTests/bin/Debug/netX.0/config.json
/// <summary>Unicode replacement character; used by Drivers when rendering in cases where a wide glyph can't
/// be output because it would be clipped. Defaults to ' ' (Space).</summary>
[ConfigurationProperty (Scope = typeof (ThemeScope))]
public static Rune WideGlyphReplacement { get; set; } = (Rune)' ';
/// <summary>File icon. Defaults to ☰ (Trigram For Heaven)</summary> /// <summary>File icon. Defaults to ☰ (Trigram For Heaven)</summary>
[ConfigurationProperty (Scope = typeof (ThemeScope))] [ConfigurationProperty (Scope = typeof (ThemeScope))]
public static Rune File { get; set; } = (Rune)'☰'; public static Rune File { get; set; } = (Rune)'☰';

View File

@@ -146,6 +146,9 @@ internal class DriverImpl : IDriver
private readonly IOutput _output; private readonly IOutput _output;
/// <inheritdoc />
public IOutputBuffer GetOutputBuffer () => OutputBuffer;
public IOutput GetOutput () => _output; public IOutput GetOutput () => _output;
private readonly IInputProcessor _inputProcessor; private readonly IInputProcessor _inputProcessor;

View File

@@ -64,7 +64,13 @@ public interface IDriver : IDisposable
IInputProcessor GetInputProcessor (); IInputProcessor GetInputProcessor ();
/// <summary> /// <summary>
/// Gets the output handler responsible for writing to the terminal. /// Gets the <see cref="IOutputBuffer"/> containing the buffered screen contents.
/// </summary>
/// <returns></returns>
IOutputBuffer GetOutputBuffer ();
/// <summary>
/// Gets the <see cref="IOutput"/> responsible for writing to the terminal.
/// </summary> /// </summary>
IOutput GetOutput (); IOutput GetOutput ();

View File

@@ -1,5 +1,4 @@
 namespace Terminal.Gui.Drivers;
namespace Terminal.Gui.Drivers;
/// <summary> /// <summary>
/// Represents the desired screen state for console rendering. This interface provides methods for building up /// Represents the desired screen state for console rendering. This interface provides methods for building up
@@ -128,4 +127,15 @@ public interface IOutputBuffer
/// Changing this may have unexpected consequences. /// Changing this may have unexpected consequences.
/// </summary> /// </summary>
int Top { get; set; } int Top { get; set; }
/// <summary>
/// Sets the replacement character that will be used when a wide glyph (double-width character) cannot fit in the
/// available space.
/// If not set, the default will be <see cref="Glyphs.WideGlyphReplacement"/>.
/// </summary>
/// <param name="column1ReplacementChar">
/// The character used when the first column of a wide character is invalid (for example, when it is overlapped by the
/// trailing half of a previous wide character).
/// </param>
void SetWideGlyphReplacement (Rune column1ReplacementChar);
} }

View File

@@ -65,6 +65,14 @@ public class OutputBufferImpl : IOutputBuffer
/// <summary>The topmost row in the terminal.</summary> /// <summary>The topmost row in the terminal.</summary>
public virtual int Top { get; set; } = 0; public virtual int Top { get; set; } = 0;
private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement;
/// <inheritdoc />
public void SetWideGlyphReplacement (Rune column1ReplacementChar)
{
_column1ReplacementChar = column1ReplacementChar;
}
/// <summary> /// <summary>
/// Indicates which lines have been modified and need to be redrawn. /// Indicates which lines have been modified and need to be redrawn.
/// </summary> /// </summary>
@@ -205,7 +213,7 @@ public class OutputBufferImpl : IOutputBuffer
{ {
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 = _column1ReplacementChar.ToString ();
Contents [row, col - 1].IsDirty = true; Contents [row, col - 1].IsDirty = true;
} }
} }
@@ -273,17 +281,7 @@ public class OutputBufferImpl : IOutputBuffer
if (!Clip!.Contains (col + 1, row)) 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 = _column1ReplacementChar.ToString ();
}
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)
{
Contents! [row, col + 1].Grapheme = Rune.ReplacementChar.ToString ();
Contents! [row, col + 1].IsDirty = true;
}
} }
else else
{ {

View File

@@ -256,7 +256,7 @@ public partial class View // Mouse APIs
/// <item> /// <item>
/// <description> /// <description>
/// Invokes commands bound to mouse clicks via <see cref="MouseBindings"/> /// Invokes commands bound to mouse clicks via <see cref="MouseBindings"/>
/// (default: <see cref="Command.Select"/> → <see cref="Selecting"/> event) /// (default: <see cref="Command.Activate"/> → <see cref="Activating"/> event)
/// </description> /// </description>
/// </item> /// </item>
/// <item> /// <item>
@@ -295,7 +295,7 @@ public partial class View // Mouse APIs
/// <seealso cref="MouseEvent"/> /// <seealso cref="MouseEvent"/>
/// <seealso cref="OnMouseEvent"/> /// <seealso cref="OnMouseEvent"/>
/// <seealso cref="MouseBindings"/> /// <seealso cref="MouseBindings"/>
/// <seealso cref="Selecting"/> /// <seealso cref="Activating"/>
/// <seealso cref="WantContinuousButtonPressed"/> /// <seealso cref="WantContinuousButtonPressed"/>
/// <seealso cref="HighlightStates"/> /// <seealso cref="HighlightStates"/>
public bool? NewMouseEvent (MouseEventArgs mouseEvent) public bool? NewMouseEvent (MouseEventArgs mouseEvent)
@@ -414,8 +414,8 @@ public partial class View // Mouse APIs
/// <summary> /// <summary>
/// INTERNAL: For cases where the view is grabbed and the mouse is pressed, this method handles the pressed events from /// INTERNAL: For cases where the view is grabbed and the mouse is pressed, this method handles the pressed events from
/// the driver. /// the driver.
/// When <see cref="WantContinuousButtonPressed"/> is set, this method will raise the Clicked/Selecting event /// When <see cref="WantContinuousButtonPressed"/> is set, this method will raise the Clicked/Activating event
/// via <see cref="Command.Select"/> each time it is called (after the first time the mouse is pressed). /// via <see cref="Command.Activate"/> each time it is called (after the first time the mouse is pressed).
/// </summary> /// </summary>
/// <param name="mouseEvent"></param> /// <param name="mouseEvent"></param>
/// <returns><see langword="true"/>, if processing should stop, <see langword="false"/> otherwise.</returns> /// <returns><see langword="true"/>, if processing should stop, <see langword="false"/> otherwise.</returns>
@@ -531,7 +531,7 @@ public partial class View // Mouse APIs
/// <summary> /// <summary>
/// INTERNAL API: Converts mouse click events into <see cref="Command"/>s by invoking the commands bound /// INTERNAL API: Converts mouse click events into <see cref="Command"/>s by invoking the commands bound
/// to the mouse button via <see cref="MouseBindings"/>. By default, all mouse clicks are bound to /// to the mouse button via <see cref="MouseBindings"/>. By default, all mouse clicks are bound to
/// <see cref="Command.Select"/> which raises the <see cref="Selecting"/> event. /// <see cref="Command.Activate"/> which raises the <see cref="Activating"/> event.
/// </summary> /// </summary>
protected bool RaiseCommandsBoundToMouse (MouseEventArgs args) protected bool RaiseCommandsBoundToMouse (MouseEventArgs args)
{ {

View File

@@ -178,6 +178,7 @@ public class ClipTests (ITestOutputHelper _output)
public void Clipping_Wide_Runes () public void Clipping_Wide_Runes ()
{ {
Application.Driver!.SetScreenSize (30, 1); Application.Driver!.SetScreenSize (30, 1);
Application.Driver!.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
var top = new View var top = new View
{ {
@@ -231,9 +232,9 @@ public class ClipTests (ITestOutputHelper _output)
// 012 34 56 78 90 12 34 56 78 90 12 34 56 78 // 012 34 56 78 90 12 34 56 78 90 12 34 56 78
// │こ れ は 広 い ル ー ン ラ イ ン で す 。 // │こ れ は 広 い ル ー ン ラ イ ン で す 。
// 01 2345678901234 56 78 90 12 34 56 // 01 2345678901234 56 78 90 12 34 56
// │<EFBFBD> |0123456989│<EFBFBD> ン ラ イ ン で す 。 // │ |0123456989│ ン ラ イ ン で す 。
expectedOutput = """ expectedOutput = """
<EFBFBD>0123456789 0123456789
"""; """;
DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output); DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output);

View File

@@ -181,9 +181,9 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
{ {
IDriver? driver = CreateFakeDriver (); IDriver? driver = CreateFakeDriver ();
driver.SetScreenSize (6, 3); driver.SetScreenSize (6, 3);
driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
driver!.Clip = new (driver.Screen); driver!.Clip = new (driver.Screen);
driver.Move (1, 0); driver.Move (1, 0);
driver.AddStr ("┌"); driver.AddStr ("┌");
driver.Move (2, 0); driver.Move (2, 0);
@@ -197,14 +197,14 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
DriverAssert.AssertDriverContentsAre ( DriverAssert.AssertDriverContentsAre (
""" """
<EFBFBD>🍎 🍎
""", """,
output, output,
driver); driver);
driver.Refresh (); driver.Refresh ();
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m<EFBFBD>┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m", DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m",
output, driver); output, driver);
} }
} }

View File

@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using System.Text;
using UnitTests; using UnitTests;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -104,6 +105,7 @@ public class DriverTests (ITestOutputHelper output) : FakeDriverBase
IApplication? app = Application.Create (); IApplication? app = Application.Create ();
app.Init (driverName); app.Init (driverName);
IDriver driver = app.Driver!; IDriver driver = app.Driver!;
driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
// Need to force "windows" driver to override legacy console mode for this test // Need to force "windows" driver to override legacy console mode for this test
driver.IsLegacyConsole = false; driver.IsLegacyConsole = false;
@@ -127,14 +129,14 @@ public class DriverTests (ITestOutputHelper output) : FakeDriverBase
DriverAssert.AssertDriverContentsAre ( DriverAssert.AssertDriverContentsAre (
""" """
<EFBFBD>🍎 🍎
""", """,
output, output,
driver); driver);
driver.Refresh (); driver.Refresh ();
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m<EFBFBD>┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m", DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m",
output, driver); output, driver);
} }
} }

View File

@@ -1,4 +1,7 @@
namespace DriverTests; using System.Text;
using Terminal.Gui.Drivers;
namespace DriverTests;
public class OutputBaseTests public class OutputBaseTests
{ {
@@ -161,6 +164,8 @@ public class OutputBaseTests
// FakeOutput exposes this because it's in test scope // FakeOutput exposes this because it's in test scope
var output = new FakeOutput { IsLegacyConsole = isLegacyConsole }; var output = new FakeOutput { IsLegacyConsole = isLegacyConsole };
IOutputBuffer buffer = output.GetLastBuffer ()!; IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetWideGlyphReplacement ((Rune)'①');
buffer.SetSize (3, 1); buffer.SetSize (3, 1);
// Write '🦮' at col 0 and 'A' at col 2 // Write '🦮' at col 0 and 'A' at col 2
@@ -209,7 +214,7 @@ public class OutputBaseTests
output.Write (buffer); output.Write (buffer);
Assert.Contains ("<EFBFBD>", output.GetLastOutput ()); Assert.Contains ("", output.GetLastOutput ());
Assert.Contains ("X", output.GetLastOutput ()); Assert.Contains ("X", output.GetLastOutput ());
// Dirty flags cleared for the written cells // Dirty flags cleared for the written cells

View File

@@ -1,14 +1,14 @@
using System.Text; using System.Text;
using Xunit.Abstractions;
namespace DriverTests; namespace DriverTests;
/// <summary> /// <summary>
/// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466. /// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466.
/// These tests validate that FillRect properly handles wide characters when overlapping existing content. /// 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. /// 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> /// </summary>
public class OutputBufferWideCharTests (ITestOutputHelper output) public class OutputBufferWideCharTests
{ {
/// <summary> /// <summary>
/// Tests that FillRect properly invalidates wide characters when overwriting them. /// Tests that FillRect properly invalidates wide characters when overwriting them.
@@ -100,7 +100,7 @@ public class OutputBufferWideCharTests (ITestOutputHelper output)
// With the fix: The original wide character at col 2 should be invalidated // With the fix: The original wide character at col 2 should be invalidated
// because we're overwriting its second column // 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.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, 2].Grapheme, Glyphs.WideGlyphReplacement.ToString ());
Assert.Equal ("│", buffer.Contents [1, 3].Grapheme); Assert.Equal ("│", buffer.Contents [1, 3].Grapheme);
Assert.True (buffer.Contents [1, 3].IsDirty); Assert.True (buffer.Contents [1, 3].IsDirty);
@@ -154,7 +154,7 @@ public class OutputBufferWideCharTests (ITestOutputHelper output)
// The second character "好" at col 7 had its second column overwritten // The second character "好" at col 7 had its second column overwritten
// so it should be replaced with replacement char // so it should be replaced with replacement char
Assert.Equal (buffer.Contents [3, 7].Grapheme, Rune.ReplacementChar.ToString ()); Assert.Equal (buffer.Contents [3, 7].Grapheme, Glyphs.WideGlyphReplacement.ToString ());
Assert.True (buffer.Contents [3, 7].IsDirty, "Invalidated wide char should be marked dirty"); Assert.True (buffer.Contents [3, 7].IsDirty, "Invalidated wide char should be marked dirty");
// The border should be drawn at col 8 // The border should be drawn at col 8
@@ -356,4 +356,189 @@ public class OutputBufferWideCharTests (ITestOutputHelper output)
buffer.Contents [2, 7].IsDirty, buffer.Contents [2, 7].IsDirty,
"Adjacent cell should be dirty after wide char replacement"); "Adjacent cell should be dirty after wide char replacement");
} }
/// <summary>
/// Tests the edge case where a wide character's first column is outside the clip region
/// but the second column is inside.
/// IMPORTANT: This test documents that the code path in WriteWideGrapheme where:
/// - !Clip.Contains(col, row) is true (first column outside)
/// - Clip.Contains(col + 1, row) is true (second column inside)
/// is CURRENTLY UNREACHABLE because IsValidLocation checks Clip.Contains(col, row) and
/// returns false before WriteWideGrapheme is called. This test verifies the current behavior
/// (nothing is written when first column is outside clip).
/// If the behavior should change to write the second column with a replacement character,
/// the logic in IsValidLocation or AddGrapheme needs to be modified.
/// </summary>
[Fact]
[Trait ("Category", "Output")]
public void AddStr_WideChar_FirstColumnOutsideClip_SecondColumnInside_CurrentBehavior ()
{
// Arrange
OutputBufferImpl buffer = new ()
{
Rows = 5,
Cols = 10,
CurrentAttribute = new (Color.White, Color.Black)
};
// Set custom replacement characters to verify they're being used
Rune customColumn1Replacement = new ('◄');
Rune customColumn2Replacement = new ('►');
buffer.SetWideGlyphReplacement (customColumn1Replacement);
// Set clip region that starts at column 3 (odd column)
// This creates a scenario where col 2 is outside clip, but col 3 is inside
buffer.Clip = new (new (3, 1, 5, 3));
// Clear initial contents to ensure clean state
for (var r = 0; r < buffer.Rows; r++)
{
for (var c = 0; c < buffer.Cols; c++)
{
buffer.Contents! [r, c].IsDirty = false;
buffer.Contents [r, c].Grapheme = " ";
}
}
// Act - Try to draw a wide character at column 2
// Column 2 is outside clip, but column 3 is inside clip
buffer.Move (2, 1);
buffer.AddStr ("你"); // Chinese character "you", 2 columns wide
// Assert
// CURRENT BEHAVIOR: IsValidLocation returns false when col 2 is outside clip,
// so NOTHING is written - neither column 2 nor column 3
Assert.Equal (" ", buffer.Contents! [1, 2].Grapheme);
Assert.False (buffer.Contents [1, 2].IsDirty, "Cell outside clip should not be marked dirty");
// Column 3 is also not written because IsValidLocation returned false
// The code path in WriteWideGrapheme that would write the replacement char
// to column 3 is never reached
Assert.Equal (" ", buffer.Contents [1, 3].Grapheme);
Assert.False (
buffer.Contents [1, 3].IsDirty,
"Currently, second column is not written when first column is outside clip");
// Verify Col has been advanced by only 1 (not by the wide character width)
// because the grapheme was not validated/processed when IsValidLocation returned false
Assert.Equal (3, buffer.Col);
}
/// <summary>
/// Tests the complementary case: wide character's second column is outside clip
/// but first column is inside. This should use the column 1 replacement character.
/// </summary>
[Fact]
[Trait ("Category", "Output")]
public void AddStr_WideChar_SecondColumnOutsideClip_FirstColumnInside_UsesColumn1Replacement ()
{
// Arrange
OutputBufferImpl buffer = new ()
{
Rows = 5,
Cols = 10,
CurrentAttribute = new (Color.White, Color.Black)
};
// Set custom replacement characters
Rune customColumn1Replacement = new ('◄');
Rune customColumn2Replacement = new ('►');
buffer.SetWideGlyphReplacement (customColumn1Replacement);
// Set clip region that ends at column 6 (even column)
// This creates a scenario where col 5 is inside, but col 6 is outside
buffer.Clip = new (new (0, 1, 6, 3));
// Clear initial contents
for (var r = 0; r < buffer.Rows; r++)
{
for (var c = 0; c < buffer.Cols; c++)
{
buffer.Contents! [r, c].IsDirty = false;
buffer.Contents [r, c].Grapheme = " ";
}
}
// Act - Try to draw a wide character at column 5
// Column 5 is inside clip, but column 6 is outside clip
buffer.Move (5, 1);
buffer.AddStr ("好"); // Chinese character, 2 columns wide
// Assert
// The first column (col 5) is inside clip but second column (6) is outside
// Should use column 1 replacement char to indicate it can't fit
Assert.Equal (
customColumn1Replacement.ToString (),
buffer.Contents! [1, 5].Grapheme);
Assert.True (
buffer.Contents [1, 5].IsDirty,
"First column should be marked dirty with replacement char when second column is clipped");
// The second column is outside clip boundaries entirely
Assert.Equal (" ", buffer.Contents [1, 6].Grapheme);
Assert.False (buffer.Contents [1, 6].IsDirty, "Cell outside clip should not be modified");
// Verify Col has been advanced by 2 (wide character width)
Assert.Equal (7, buffer.Col);
}
/// <summary>
/// Tests that when both columns of a wide character are inside the clip,
/// the character is drawn normally without replacement characters.
/// </summary>
[Fact]
[Trait ("Category", "Output")]
public void AddStr_WideChar_BothColumnsInsideClip_DrawsNormally ()
{
// Arrange
OutputBufferImpl buffer = new ()
{
Rows = 5,
Cols = 10,
CurrentAttribute = new (Color.White, Color.Black)
};
// Set custom replacement characters (should NOT be used in this case)
Rune customColumn1Replacement = new ('◄');
Rune customColumn2Replacement = new ('►');
buffer.SetWideGlyphReplacement (customColumn1Replacement);
// Set clip region that includes columns 2-7
buffer.Clip = new (new (2, 1, 6, 3));
// Clear initial contents
for (var r = 0; r < buffer.Rows; r++)
{
for (var c = 0; c < buffer.Cols; c++)
{
buffer.Contents! [r, c].IsDirty = false;
buffer.Contents [r, c].Grapheme = " ";
}
}
// Act - Draw a wide character at column 4 (both 4 and 5 are inside clip)
buffer.Move (4, 1);
buffer.AddStr ("山"); // Chinese character "mountain", 2 columns wide
// Assert
// Both columns are inside clip, so the wide character should be drawn normally
Assert.Equal ("山", buffer.Contents! [1, 4].Grapheme);
Assert.True (buffer.Contents [1, 4].IsDirty, "First column should be marked dirty");
// The second column should NOT be marked dirty by WriteWideGrapheme
// The wide glyph naturally renders across both columns without modifying column N+1
// See: https://github.com/gui-cs/Terminal.Gui/issues/4258
Assert.False (
buffer.Contents [1, 5].IsDirty,
"Adjacent cell should NOT be marked dirty when writing wide char (see #4258)");
// Verify no replacement characters were used
Assert.NotEqual (customColumn1Replacement.ToString (), buffer.Contents [1, 4].Grapheme);
Assert.NotEqual (customColumn2Replacement.ToString (), buffer.Contents [1, 5].Grapheme);
// Verify Col has been advanced by 2
Assert.Equal (6, buffer.Col);
}
} }

View File

@@ -574,6 +574,7 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
}; };
superView.Add (viewWithBorderAtX0, viewWithBorderAtX1, viewWithBorderAtX2); superView.Add (viewWithBorderAtX0, viewWithBorderAtX1, viewWithBorderAtX2);
driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
app.Begin (superView); app.Begin (superView);
// Begin calls LayoutAndDraw, so no need to call it again here // Begin calls LayoutAndDraw, so no need to call it again here
// app.LayoutAndDraw(); // app.LayoutAndDraw();
@@ -585,9 +586,9 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
viewWithBorderAtX0🍎🍎🍎 viewWithBorderAtX0🍎🍎🍎
🍎🍎🍎 🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
<EFBFBD> 🍎🍎 🍎🍎
<EFBFBD>viewWithBorderAtX1 🍎🍎 viewWithBorderAtX1 🍎🍎
<EFBFBD> 🍎🍎 🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎 🍎🍎🍎
🍎viewWithBorderAtX2🍎🍎 🍎viewWithBorderAtX2🍎🍎
@@ -597,7 +598,7 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
output, output,
driver); driver);
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┆viewWithBorderAtX0┆🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<EFBFBD>┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<EFBFBD>┆viewWithBorderAtX1┆ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<EFBFBD>└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎┆viewWithBorderAtX2┆🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m", DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┆viewWithBorderAtX0┆🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┆viewWithBorderAtX1┆ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎┆viewWithBorderAtX2┆🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m",
output, driver); output, driver);
DriverImpl? driverImpl = driver as DriverImpl; DriverImpl? driverImpl = driver as DriverImpl;
@@ -617,9 +618,9 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
viewWithBorderAtX0🍎🍎🍎 viewWithBorderAtX0🍎🍎🍎
🍎🍎🍎 🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
<EFBFBD> 🍎🍎 🍎🍎
<EFBFBD>viewWithBorderAtX1 🍎🍎 viewWithBorderAtX1 🍎🍎
<EFBFBD> 🍎🍎 🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎 🍎🍎🍎
🍎viewWithBorderAtX2🍎🍎 🍎viewWithBorderAtX2🍎🍎
@@ -675,18 +676,19 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
}; };
superView.Add (viewWithBorder); superView.Add (viewWithBorder);
driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
app.Begin (superView); app.Begin (superView);
DriverAssert.AssertDriverContentsAre ( DriverAssert.AssertDriverContentsAre (
""" """
<EFBFBD>🍎 🍎
<EFBFBD>X🍎 X🍎
<EFBFBD>🍎 🍎
""", """,
output, output,
driver); driver);
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<EFBFBD>┌─┐🍎<EFBFBD>│X│🍎<EFBFBD>└─┘🍎", DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m┌─┐🍎│X│🍎└─┘🍎",
output, driver); output, driver);
DriverImpl? driverImpl = driver as DriverImpl; DriverImpl? driverImpl = driver as DriverImpl;
@@ -738,19 +740,21 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas
Height = 3 Height = 3
}; };
driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
superView.Add (viewWithBorder); superView.Add (viewWithBorder);
app.Begin (superView); app.Begin (superView);
DriverAssert.AssertDriverContentsAre ( DriverAssert.AssertDriverContentsAre (
""" """
🍎<EFBFBD> 🍎
🍎<EFBFBD>X 🍎X
🍎<EFBFBD> 🍎
""", """,
output, output,
driver); driver);
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎<EFBFBD>┌─┐🍎<EFBFBD>│X│🍎<EFBFBD>└─┘", DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎┌─┐🍎│X│🍎└─┘",
output, driver); output, driver);
DriverImpl? driverImpl = driver as DriverImpl; DriverImpl? driverImpl = driver as DriverImpl;