Fixes #4492, #4480 - Transparent shadows cause underlying wide glyph rendering issues (#4490)

* WIP - experiments in fixing shadow rendering issues based on #4465

Previously, shadow size was fixed at 1x1. This change introduces ShadowWidth and ShadowHeight properties to both Margin and View, allowing variable shadow dimensions. The Margin class now manages its own shadow sizing, enforcing valid values based on ShadowStyle (e.g., Opaque and Transparent require a minimum of 1, and Opaque only allows 1). Margin.Thickness is dynamically adjusted to account for shadow size, with original values preserved and restored as needed.

ShadowView rendering is updated to correctly handle wide graphemes (such as emojis) in the shadow area, preventing rendering errors. The View class exposes ShadowWidth and ShadowHeight, synchronizing with Margin. Extensive new unit tests verify correct behavior for shadow sizing, style changes, thickness adjustments, and rendering, including edge cases and visual output.

Additional minor bug fixes and refactoring are included, such as proper management of Margin's cached clip region and correcting a loop order bug in ShadowView. The codebase is also modernized with recent C# features.

* more merge

* added border tests

* Experiment...

* Incorporated latest wideglyphs

* Comment tweaks

* Add Adornments and ViewportSettings editors to WideGlyphs

Introduce AdornmentsEditor and ViewportSettingsEditor with custom border styles and positioning, enhancing UI editing capabilities. Also update arrangeableViewAtEven to use Color.Black and Color.Green, and adjust a commented border style from Dashed to Dotted.

* Fix scenario editors and tweak scenarios.

Enhance ShadowStyles with a second shadow window (transparent style) and a button event handler that shows a message box. In WideGlyphs, add AdornmentsEditor and ViewportSettingsEditor for view property editing, apply custom color schemes to arrangeable views, and update superView with a transparent shadow and increased shadow width. These changes improve interactivity and visualization in the demo scenarios.

* Fix scenario editors and tweak scenarios.

Enhance ShadowStyles with a second shadow window (transparent style) and a button event handler that shows a message box. In WideGlyphs, add AdornmentsEditor and ViewportSettingsEditor for view property editing, apply custom color schemes to arrangeable views, and update superView with a transparent shadow and increased shadow width. These changes improve interactivity and visualization in the demo scenarios.

* Make replacement char themeable via Glyphs.ReplacementChar

Adds Glyphs.ReplacementChar as a configurable replacement character, replacing all uses of Rune.ReplacementChar. The default is now a space (' ') and can be set via config.json. Updates all rendering, string decoding, and buffer invalidation logic to use the new property, ensuring consistency and themeability. Updates tests and comments accordingly. Also includes minor UI tweaks in WideGlyphs.cs and .DotSettings updates.

* merging

* merge errors

* merged

* merged

* Refactor shadow properties to Margin; update tests

ShadowWidth and ShadowHeight are now managed solely in the Margin class, with related properties and validation logic removed from View. All code and tests now use view.Margin.ShadowWidth/ShadowHeight. Tests and documentation were updated accordingly, and wide glyph handling in test output was improved for consistency.

* Simplify ShadowSize; remove it from View as it's infreqnetly used. Make it a Size to reduce API surface area.

Replace ShadowWidth/ShadowHeight with a single ShadowSize property (of type Size) in the Margin class and related code. Update all usages, validation logic, and tests to use ShadowSize.Width and ShadowSize.Height. Introduce TryValidateShadowSize for unified validation. Modernize code with C# features and improve clarity and maintainability by treating shadow dimensions as a single unit.

* reveted

* Fix wide glyph attribute handling for second column

Ensure the attribute for the second column of wide glyphs is set correctly when within the clip region, addressing issues #4258 and #4492. Add comprehensive unit tests to verify correct attribute assignment and output rendering, including scenarios with transparent shadows. Remove obsolete test code for clarity. This improves color/style consistency for wide glyphs, especially in overlapping UI situations.

* added url
This commit is contained in:
Tig
2025-12-16 14:23:32 -07:00
committed by GitHub
parent 4673cfc106
commit 0a9f4b8ef1
12 changed files with 1028 additions and 259 deletions

View File

@@ -0,0 +1,487 @@
using System.Text;
using UnitTests;
using Xunit.Abstractions;
namespace ViewBaseTests.Adornments;
[Collection ("Global Test Setup")]
public class ShadowTests (ITestOutputHelper output)
{
private readonly ITestOutputHelper _output = output;
[Fact]
public void Default_None ()
{
var view = new View ();
Assert.Equal (ShadowStyle.None, view.ShadowStyle);
Assert.Equal (ShadowStyle.None, view.Margin!.ShadowStyle);
view.Dispose ();
}
[Theory]
[InlineData (ShadowStyle.None)]
[InlineData (ShadowStyle.Opaque)]
[InlineData (ShadowStyle.Transparent)]
public void Set_View_Sets_Margin (ShadowStyle style)
{
var view = new View ();
view.ShadowStyle = style;
Assert.Equal (style, view.ShadowStyle);
Assert.Equal (style, view.Margin!.ShadowStyle);
view.Dispose ();
}
[Theory]
[InlineData (ShadowStyle.None, 0, 0, 0, 0)]
[InlineData (ShadowStyle.Opaque, 0, 0, 1, 1)]
[InlineData (ShadowStyle.Transparent, 0, 0, 1, 1)]
public void ShadowStyle_Margin_Thickness (ShadowStyle style, int expectedLeft, int expectedTop, int expectedRight, int expectedBottom)
{
var superView = new View
{
Height = 10, Width = 10
};
View view = new ()
{
Width = Dim.Auto (),
Height = Dim.Auto (),
Text = "0123",
HighlightStates = MouseState.Pressed,
ShadowStyle = style,
CanFocus = true
};
superView.Add (view);
superView.BeginInit ();
superView.EndInit ();
Assert.Equal (new (expectedLeft, expectedTop, expectedRight, expectedBottom), view.Margin!.Thickness);
}
[Theory]
[InlineData (ShadowStyle.None, 3)]
[InlineData (ShadowStyle.Opaque, 4)]
[InlineData (ShadowStyle.Transparent, 4)]
public void Style_Changes_Margin_Thickness (ShadowStyle style, int expected)
{
var view = new View ();
view.Margin!.Thickness = new (3);
view.ShadowStyle = style;
Assert.Equal (new (3, 3, expected, expected), view.Margin.Thickness);
view.ShadowStyle = ShadowStyle.None;
Assert.Equal (new (3), view.Margin.Thickness);
view.Dispose ();
}
[Theory]
[InlineData (ShadowStyle.Opaque)]
[InlineData (ShadowStyle.Transparent)]
public void ShadowWidth_ShadowHeight_Defaults_To_One (ShadowStyle style)
{
View view = new () { ShadowStyle = style };
Assert.Equal (new (1, 1), view.Margin!.ShadowSize);
}
[Theory]
[InlineData (ShadowStyle.None, 0)]
[InlineData (ShadowStyle.Opaque, 1)]
[InlineData (ShadowStyle.Transparent, 1)]
public void Margin_ShadowWidth_ShadowHeight_Cannot_Be_Set_Less_Than_One (ShadowStyle style, int expectedLength)
{
View view = new () { ShadowStyle = style };
view.Margin!.ShadowSize = new (-1, -1);
Assert.Equal (expectedLength, view.Margin!.ShadowSize.Width);
Assert.Equal (expectedLength, view.Margin!.ShadowSize.Height);
}
[Fact]
public void Changing_ShadowStyle_Correctly_Set_ShadowWidth_ShadowHeight_Thickness ()
{
View view = new () { ShadowStyle = ShadowStyle.Transparent };
view.Margin!.ShadowSize = new (2, 2);
Assert.Equal (new (2, 2), view.Margin!.ShadowSize);
Assert.Equal (new (0, 0, 2, 2), view.Margin.Thickness);
view.ShadowStyle = ShadowStyle.None;
Assert.Equal (new (2, 2), view.Margin!.ShadowSize);
Assert.Equal (new (0, 0, 0, 0), view.Margin.Thickness);
view.ShadowStyle = ShadowStyle.Opaque;
Assert.Equal (new (1, 1), view.Margin!.ShadowSize);
Assert.Equal (new (0, 0, 1, 1), view.Margin.Thickness);
}
[Fact]
public void ShadowStyle_Transparent_Handles_Wide_Glyphs_Correctly ()
{
IApplication app = Application.Create ();
app.Init ("fake");
app.Driver?.SetScreenSize (6, 5);
app.Driver?.GetOutputBuffer ().SetWideGlyphReplacement (Rune.ReplacementChar);
Runnable superview = new () { Width = Dim.Fill (), Height = Dim.Fill () };
superview.Text = """
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
""";
View view = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.Single, ShadowStyle = ShadowStyle.Transparent };
view.Margin!.ShadowSize = view.Margin!.ShadowSize with { Width = 2 };
superview.Add (view);
app.Begin (superview);
DriverAssert.AssertDriverContentsAre (
"""
🍎
🍎
🍎
🍎
<EFBFBD> 🍎🍎
""",
output,
app.Driver);
view.Margin!.ShadowSize = new (1, 2);
app.LayoutAndDraw ();
DriverAssert.AssertDriverContentsAre (
"""
🍎
<EFBFBD>
<EFBFBD>
<EFBFBD> 🍎🍎
<EFBFBD> 🍎🍎
""",
output,
app.Driver);
}
[Fact]
public void ShadowStyle_Opaque_Change_Thickness_On_Mouse_Pressed_Released ()
{
IApplication app = Application.Create ();
app.Init ("fake");
app.Driver?.SetScreenSize (10, 4);
Runnable superview = new () { Width = Dim.Fill (), Height = Dim.Fill () };
View view = new () { Width = 7, Height = 2, ShadowStyle = ShadowStyle.Opaque, Text = "| Hi |", HighlightStates = MouseState.Pressed };
superview.Add (view);
app.Begin (superview);
DriverAssert.AssertDriverContentsAre (
"""
| Hi |
""",
output,
app.Driver);
app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (2, 0), Flags = MouseFlags.Button1Pressed });
app.LayoutAndDraw ();
DriverAssert.AssertDriverContentsAre (
"""
| Hi |
""",
output,
app.Driver);
app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (2, 0), Flags = MouseFlags.Button1Released });
app.LayoutAndDraw ();
DriverAssert.AssertDriverContentsAre (
"""
| Hi |
""",
output,
app.Driver);
}
[Fact]
public void ShadowStyle_Transparent_Never_Throws_Navigating_Outside_Bounds ()
{
IApplication app = Application.Create ();
app.Init ("fake");
app.Driver?.SetScreenSize (6, 5);
Runnable superview = new () { Width = Dim.Fill (), Height = Dim.Fill () };
superview.Text = """
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
🍎🍎🍎
""";
View view = new ()
{
Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.Single, ShadowStyle = ShadowStyle.Transparent,
Arrangement = ViewArrangement.Movable, CanFocus = true
};
view.Margin!.ShadowSize = view.Margin!.ShadowSize with { Width = 2 };
superview.Add (view);
app.Begin (superview);
Assert.Equal (new (0, 0), view.Frame.Location);
Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.F5.WithCtrl));
int i = 0;
DecrementValue (-10, Key.CursorLeft);
Assert.Equal (-10, i);
IncrementValue (0, Key.CursorRight);
Assert.Equal (0, i);
DecrementValue (-10, Key.CursorUp);
Assert.Equal (-10, i);
IncrementValue (20, Key.CursorDown);
Assert.Equal (20, i);
DecrementValue (0, Key.CursorUp);
Assert.Equal (0, i);
IncrementValue (20, Key.CursorRight);
Assert.Equal (20, i);
return;
void DecrementValue (int count, Key key)
{
for (; i > count; i--)
{
Assert.True (app.Keyboard.RaiseKeyDownEvent (key));
app.LayoutAndDraw ();
CheckAssertion (new (i - 1, 0), new (0, i - 1), key);
}
}
void IncrementValue (int count, Key key)
{
for (; i < count; i++)
{
Assert.True (app.Keyboard.RaiseKeyDownEvent (key));
app.LayoutAndDraw ();
CheckAssertion (new (i + 1, 0), new (0, i + 1), key);
}
}
bool? IsColumn (Key key)
{
if (key == Key.CursorLeft || key == Key.CursorRight)
{
return true;
}
if (key == Key.CursorUp || key == Key.CursorDown)
{
return false;
}
return null;
}
void CheckAssertion (Point colLocation, Point rowLocation, Key key)
{
bool? isCol = IsColumn (key);
switch (isCol)
{
case true:
Assert.Equal (colLocation, view.Frame.Location);
break;
case false:
Assert.Equal (rowLocation, view.Frame.Location);
break;
default:
throw new InvalidOperationException ();
}
}
}
[Theory]
[InlineData (ShadowStyle.None, 3)]
[InlineData (ShadowStyle.Opaque, 4)]
[InlineData (ShadowStyle.Transparent, 4)]
public void Margin_Thickness_Changes_Adjust_Correctly (ShadowStyle style, int expected)
{
var view = new View ();
view.Margin!.Thickness = new (3);
view.ShadowStyle = style;
Assert.Equal (new (3, 3, expected, expected), view.Margin.Thickness);
view.Margin.Thickness = new (3, 3, expected + 1, expected + 1);
Assert.Equal (new (3, 3, expected + 1, expected + 1), view.Margin.Thickness);
view.ShadowStyle = ShadowStyle.None;
Assert.Equal (new (3, 3, 4, 4), view.Margin.Thickness);
view.Dispose ();
}
[Fact]
public void Runnable_View_Overlap_Other_Runnables ()
{
IApplication app = Application.Create ();
app.Init ("fake");
app.Driver?.SetScreenSize (10, 5);
Runnable superview = new () { Width = Dim.Fill (), Height = Dim.Fill (), Text = "🍎".Repeat (25)! };
View view = new () { Width = 7, Height = 2, ShadowStyle = ShadowStyle.Opaque, Text = "| Hi |" };
superview.Add (view);
app.Begin (superview);
DriverAssert.AssertDriverContentsAre (
"""
| Hi | 🍎
🍎
🍎🍎🍎🍎🍎
🍎🍎🍎🍎🍎
🍎🍎🍎🍎🍎
""",
output,
app.Driver);
Runnable modalSuperview = new () { Y = 1, Width = Dim.Fill (), Height = 4, BorderStyle = LineStyle.Single };
View view1 = new () { Width = 8, Height = 2, ShadowStyle = ShadowStyle.Opaque, Text = "| Hey |" };
modalSuperview.Add (view1);
app.Begin (modalSuperview);
Assert.True (modalSuperview.IsModal);
DriverAssert.AssertDriverContentsAre (
"""
| Hi | 🍎
| Hey |
""",
output,
app.Driver);
app.Dispose ();
}
[Fact]
public void TransparentShadow_Draws_Transparent_At_Driver_Output ()
{
// Arrange
using IApplication app = Application.Create ();
app.Init ("fake");
app.Driver!.SetScreenSize (2, 1);
app.Driver.Force16Colors = true;
using Runnable superView = new ();
superView.Width = Dim.Fill ();
superView.Height = Dim.Fill ();
superView.Text = "AB";
superView.TextFormatter.WordWrap = true;
superView.SetScheme (new (new Attribute (Color.Black, Color.White)));
// Create view with transparent shadow
View viewWithShadow = new ()
{
Width = Dim.Auto (),
Height = Dim.Auto (),
Text = "*",
ShadowStyle = ShadowStyle.Transparent
};
// Make it so the margin is only on the right for simplicity
viewWithShadow.Margin!.Thickness = new (0, 0, 1, 0);
viewWithShadow.SetScheme (new (new Attribute (Color.Black, Color.White)));
superView.Add (viewWithShadow);
// Act
app.Begin (superView);
app.LayoutAndDraw ();
app.Driver.Refresh ();
// Assert
_output.WriteLine ("Actual driver contents:");
_output.WriteLine (app.Driver.ToString ());
_output.WriteLine ("\nActual driver output:");
string? output = app.Driver.GetOutput ().GetLastOutput ();
_output.WriteLine (output);
DriverAssert.AssertDriverOutputIs ("""
\x1b[30m\x1b[107m*\x1b[90m\x1b[100mB
""", _output, app.Driver);
}
[Fact]
public void TransparentShadow_OverWide_Draws_Transparent_At_Driver_Output ()
{
// Arrange
using IApplication app = Application.Create ();
app.Init ("fake");
app.Driver!.SetScreenSize (2, 3);
app.Driver.Force16Colors = true;
using Runnable superView = new ();
superView.Width = Dim.Fill ();
superView.Height = Dim.Fill ();
superView.Text = "🍎🍎🍎🍎";
superView.TextFormatter.WordWrap = true;
superView.SetScheme (new (new Attribute (Color.Black, Color.White)));
// Create view with transparent shadow
View viewWithShadow = new ()
{
Width = Dim.Auto (),
Height = Dim.Auto (),
Text = "*",
ShadowStyle = ShadowStyle.Transparent
};
// Make it so the margin is only on the bottom for simplicity
viewWithShadow.Margin!.Thickness = new (0, 0, 0, 1);
viewWithShadow.SetScheme (new (new Attribute (Color.Black, Color.White)));
superView.Add (viewWithShadow);
// Act
app.Begin (superView);
app.LayoutAndDraw ();
app.Driver.Refresh ();
// Assert
_output.WriteLine ("Actual driver contents:");
_output.WriteLine (app.Driver.ToString ());
_output.WriteLine ("\nActual driver output:");
string? output = app.Driver.GetOutput ().GetLastOutput ();
_output.WriteLine (output);
DriverAssert.AssertDriverOutputIs ("""
\x1b[30m\x1b[107m*\x1b[90m\x1b[103m \x1b[97m\x1b[40m \x1b[90m\x1b[100m \x1b[97m\x1b[40m🍎
""", _output, app.Driver);
}
}