Files
Terminal.Gui/Tests/UnitTests/View/Draw/ClipTests.cs
Tig 4c772bd5f3 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>
2025-12-15 09:26:52 -07:00

316 lines
9.5 KiB
C#

#nullable enable
using System.Text;
using UnitTests;
using Xunit.Abstractions;
namespace UnitTests.ViewBaseTests;
[Trait ("Category", "Output")]
public class ClipTests (ITestOutputHelper _output)
{
[Fact]
[SetupFakeApplication]
public void Move_Is_Not_Constrained_To_Viewport ()
{
var view = new View
{
App = ApplicationImpl.Instance,
X = 1,
Y = 1,
Width = 3, Height = 3
};
view.Margin!.Thickness = new (1);
view.Move (0, 0);
Assert.Equal (new (2, 2), new Point (Application.Driver!.Col, Application.Driver!.Row));
view.Move (-1, -1);
Assert.Equal (new (1, 1), new Point (Application.Driver!.Col, Application.Driver!.Row));
view.Move (1, 1);
Assert.Equal (new (3, 3), new Point (Application.Driver!.Col, Application.Driver!.Row));
}
[Fact]
[SetupFakeApplication]
public void AddRune_Is_Constrained_To_Viewport ()
{
var view = new View
{
App = ApplicationImpl.Instance,
X = 1,
Y = 1,
Width = 3, Height = 3
};
view.Padding!.Thickness = new (1);
view.Padding.Diagnostics = ViewDiagnosticFlags.Thickness;
view.BeginInit ();
view.EndInit ();
view.Draw ();
// Only valid location w/in Viewport is 0, 0 (view) - 2, 2 (screen)
Assert.Equal (" ", Application.Driver?.Contents! [2, 2].Grapheme);
// When we exit Draw, the view is excluded from the clip. So drawing at 0,0, is not valid and is clipped.
view.AddRune (0, 0, Rune.ReplacementChar);
Assert.Equal (" ", Application.Driver?.Contents! [2, 2].Grapheme);
view.AddRune (-1, -1, Rune.ReplacementChar);
Assert.Equal ("P", Application.Driver?.Contents! [1, 1].Grapheme);
view.AddRune (1, 1, Rune.ReplacementChar);
Assert.Equal ("P", Application.Driver?.Contents! [3, 3].Grapheme);
}
[Theory]
[InlineData (0, 0, 1, 1)]
[InlineData (0, 0, 2, 2)]
[InlineData (-1, -1, 2, 2)]
[SetupFakeApplication]
public void FillRect_Fills_HonorsClip (int x, int y, int width, int height)
{
var superView = new View
{
App = ApplicationImpl.Instance,
Width = Dim.Fill (), Height = Dim.Fill ()
};
var view = new View
{
Text = "X",
X = 1, Y = 1,
Width = 3, Height = 3,
BorderStyle = LineStyle.Single
};
superView.Add (view);
superView.BeginInit ();
superView.EndInit ();
superView.LayoutSubViews ();
superView.Draw ();
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│X│
└─┘",
_output);
Rectangle toFill = new (x, y, width, height);
superView.SetClipToScreen ();
view.FillRect (toFill);
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│ │
└─┘",
_output);
// Now try to clear beyond Viewport (invalid; clipping should prevent)
superView.SetNeedsDraw ();
superView.Draw ();
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│X│
└─┘",
_output);
toFill = new (-width, -height, width, height);
view.FillRect (toFill);
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│X│
└─┘",
_output);
// Now try to clear beyond Viewport (valid)
superView.SetNeedsDraw ();
superView.Draw ();
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│X│
└─┘",
_output);
toFill = new (-1, -1, width + 1, height + 1);
superView.SetClipToScreen ();
view.FillRect (toFill);
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│ │
└─┘",
_output);
// Now clear too much size
superView.SetNeedsDraw ();
superView.Draw ();
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│X│
└─┘",
_output);
toFill = new (0, 0, width * 2, height * 2);
superView.SetClipToScreen ();
view.FillRect (toFill);
DriverAssert.AssertDriverContentsWithFrameAre (
@"
┌─┐
│ │
└─┘",
_output);
}
// TODO: Simplify this test to just use AddRune directly
[Fact]
[SetupFakeApplication]
[Trait ("Category", "Unicode")]
public void Clipping_Wide_Runes ()
{
Application.Driver!.SetScreenSize (30, 1);
Application.Driver!.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①');
var top = new View
{
App = ApplicationImpl.Instance,
Id = "top",
Width = Dim.Fill (),
Height = Dim.Fill ()
};
var frameView = new View
{
Id = "frameView",
Width = Dim.Fill (),
Height = Dim.Fill (),
Text = """
"""
};
frameView.Border!.LineStyle = LineStyle.Single;
frameView.Border!.Thickness = new (1, 0, 0, 0);
top.Add (frameView);
top.SetClipToScreen ();
top.Layout ();
top.Draw ();
var expectedOutput = """
""";
DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output);
var view = new View
{
Text = "0123456789",
//Text = "ワイドルー。",
X = 2,
Height = Dim.Auto (),
Width = Dim.Auto (),
BorderStyle = LineStyle.Single
};
view.Border!.Thickness = new (1, 0, 1, 0);
top.Add (view);
top.Layout ();
top.SetClipToScreen ();
top.Draw ();
// 012345678901234567890123456789012345678
// 012 34 56 78 90 12 34 56 78 90 12 34 56 78
// │こ れ は 広 い ル ー ン ラ イ ン で す 。
// 01 2345678901234 56 78 90 12 34 56
// │① |0123456989│① ン ラ イ ン で す 。
expectedOutput = """
0123456789
""";
DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output);
}
// TODO: Add more AddRune tests to cover all the cases where wide runes are clipped
[Fact]
[SetupFakeApplication]
public void SetClip_ClipVisibleContentOnly_VisibleContentIsClipped ()
{
// Screen is 25x25
// View is 25x25
// Viewport is (0, 0, 23, 23)
// ContentSize is (10, 10)
// ViewportToScreen is (1, 1, 23, 23)
// Visible content is (1, 1, 10, 10)
// Expected clip is (1, 1, 10, 10) - same as visible content
Rectangle expectedClip = new (1, 1, 10, 10);
// Arrange
var view = new View
{
Width = Dim.Fill (),
Height = Dim.Fill (),
ViewportSettings = ViewportSettingsFlags.ClipContentOnly,
App = ApplicationImpl.Instance
};
view.SetContentSize (new Size (10, 10));
view.Border!.Thickness = new (1);
view.BeginInit ();
view.EndInit ();
Assert.Equal (view.Frame, view.GetClip ()!.GetBounds ());
// Act
view.AddViewportToClip ();
// Assert
Assert.Equal (expectedClip, view.GetClip ()!.GetBounds ());
view.Dispose ();
}
[Fact]
[SetupFakeApplication]
public void SetClip_Default_ClipsToViewport ()
{
// Screen is 25x25
Application.Driver!.SetScreenSize (25, 25);
// View is 25x25
// Viewport is (0, 0, 23, 23)
// ContentSize is (10, 10)
// ViewportToScreen is (1, 1, 23, 23)
// Visible content is (1, 1, 10, 10)
// Expected clip is (1, 1, 23, 23) - same as Viewport
Rectangle expectedClip = new (1, 1, 23, 23);
// Arrange
var view = new View
{
Width = Dim.Fill (),
Height = Dim.Fill (),
App = ApplicationImpl.Instance
};
view.SetContentSize (new Size (10, 10));
view.Border!.Thickness = new (1);
view.BeginInit ();
view.EndInit ();
Assert.Equal (view.Frame, view.GetClip ()!.GetBounds ());
view.Viewport = view.Viewport with { X = 1, Y = 1 };
// Act
view.AddViewportToClip ();
// Assert
Assert.Equal (expectedClip, view.GetClip ()!.GetBounds ());
view.Dispose ();
}
}