diff --git a/Examples/UICatalog/Scenarios/WideGlyphs.cs b/Examples/UICatalog/Scenarios/WideGlyphs.cs index 771355f1a..7d95dec34 100644 --- a/Examples/UICatalog/Scenarios/WideGlyphs.cs +++ b/Examples/UICatalog/Scenarios/WideGlyphs.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Text; @@ -90,29 +90,16 @@ public sealed class WideGlyphs : Scenario Rune codepoint = _codepoints [r, c]; if (codepoint != default (Rune)) { - view.AddRune (c, r, codepoint); + view.Move (c, r); + Attribute attr = view.GetAttributeForRole (VisualRole.Normal); + view.SetAttribute (attr with { Background = attr.Background + (r * 5) }); + view.AddRune (codepoint); } } } e.DrawContext?.AddDrawnRectangle (view.Viewport); }; - Line verticalLineAtEven = new () - { - X = 10, - Orientation = Orientation.Vertical, - Length = Dim.Fill () - }; - appWindow.Add (verticalLineAtEven); - - Line verticalLineAtOdd = new () - { - X = 25, - Orientation = Orientation.Vertical, - Length = Dim.Fill () - }; - appWindow.Add (verticalLineAtOdd); - View arrangeableViewAtEven = new () { CanFocus = true, @@ -124,13 +111,16 @@ public sealed class WideGlyphs : Scenario //BorderStyle = LineStyle.Dashed }; + arrangeableViewAtEven.SetScheme (new () { Normal = new (Color.Black, Color.Green) }); + // Proves it's not LineCanvas related arrangeableViewAtEven!.Border!.Thickness = new (1); arrangeableViewAtEven.Border.Add (new View () { Height = Dim.Auto (), Width = Dim.Auto (), Text = "Even" }); appWindow.Add (arrangeableViewAtEven); - View arrangeableViewAtOdd = new () + Button arrangeableViewAtOdd = new () { + Title = $"你 {Glyphs.Apple}", CanFocus = true, Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, X = 31, @@ -138,8 +128,12 @@ public sealed class WideGlyphs : Scenario Width = 15, Height = 5, BorderStyle = LineStyle.Dashed, + SchemeName = "error" }; - + arrangeableViewAtOdd.Accepting += (sender, args) => + { + MessageBox.Query ((sender as View)?.App, "Button Pressed", "You Pressed it!"); + }; appWindow.Add (arrangeableViewAtOdd); var superView = new View @@ -150,8 +144,11 @@ public sealed class WideGlyphs : Scenario Width = Dim.Auto (), Height = Dim.Auto (), BorderStyle = LineStyle.Single, - Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + ShadowStyle = ShadowStyle.Transparent, }; + superView.Margin!.ShadowSize = superView.Margin!.ShadowSize with { Width = 2 }; + Rune codepoint = Glyphs.Apple; diff --git a/Terminal.Gui/Drivers/OutputBufferImpl.cs b/Terminal.Gui/Drivers/OutputBufferImpl.cs index 9f0b074fe..00592f0e9 100644 --- a/Terminal.Gui/Drivers/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/OutputBufferImpl.cs @@ -185,8 +185,17 @@ public class OutputBufferImpl : IOutputBuffer if (printableGraphemeWidth > 1) { // Skip the second column of a wide character - // IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here. - // See: https://github.com/gui-cs/Terminal.Gui/issues/4258 + // See issue: https://github.com/gui-cs/Terminal.Gui/issues/4492 + // Test: AddStr_WideGlyph_Second_Column_Attribute_Outputs_Correctly + // Test: AddStr_WideGlyph_Second_Column_Attribute_Set_When_In_Clip + if (Clip.Contains (Col, Row)) + { + // IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here. + // See: https://github.com/gui-cs/Terminal.Gui/issues/4258 + Contents [Row, Col].Attribute = CurrentAttribute; + } + + // Advance cursor again for wide character Col++; } } diff --git a/Terminal.Gui/ViewBase/Adornment/Margin.cs b/Terminal.Gui/ViewBase/Adornment/Margin.cs index 54e9c2a67..998478b15 100644 --- a/Terminal.Gui/ViewBase/Adornment/Margin.cs +++ b/Terminal.Gui/ViewBase/Adornment/Margin.cs @@ -1,8 +1,3 @@ - - -using System.Diagnostics; -using System.Runtime.InteropServices; - namespace Terminal.Gui.ViewBase; /// The Margin for a . Accessed via @@ -21,8 +16,6 @@ namespace Terminal.Gui.ViewBase; /// public class Margin : Adornment { - private const int SHADOW_WIDTH = 1; - private const int SHADOW_HEIGHT = 1; private const int PRESS_MOVE_HORIZONTAL = 1; private const int PRESS_MOVE_VERTICAL = 0; @@ -35,6 +28,7 @@ public class Margin : Adornment public Margin (View parent) : base (parent) { SubViewLayout += Margin_LayoutStarted; + ThicknessChanged += OnThicknessChanged; // Margin should not be focusable CanFocus = false; @@ -46,6 +40,15 @@ public class Margin : Adornment ViewportSettings |= ViewportSettingsFlags.TransparentMouse; } + private void OnThicknessChanged (object? sender, EventArgs e) + { + if (!_isThicknessChanging) + { + _originalThickness = new (Thickness.Left, Thickness.Top, Thickness.Right, Thickness.Bottom); + SetShadow (ShadowStyle); + } + } + // When the Parent is drawn, we cache the clip region so we can draw the Margin after all other Views // QUESTION: Why can't this just be the NeedsDisplay region? private Region? _cachedClip; @@ -56,7 +59,7 @@ public class Margin : Adornment internal void CacheClip () { - if (Thickness != Thickness.Empty /*&& ShadowStyle != ShadowStyle.None*/) + if (Thickness != Thickness.Empty && ShadowStyle != ShadowStyle.None) { // PERFORMANCE: How expensive are these clones? _cachedClip = GetClip ()?.Clone (); @@ -64,12 +67,15 @@ public class Margin : Adornment } /// - /// INTERNAL API - Draws the margins for the specified views. This is called by the on each + /// INTERNAL API - Draws the transparent margins for the specified views. This is called from on each /// iteration of the main loop after all Views have been drawn. /// + /// + /// Non-transparent margins are drawn as-normal in . + /// /// /// - internal static bool DrawMargins (IEnumerable views) + internal static bool DrawTransparentMargins (IEnumerable views) { Stack stack = new (views); @@ -77,7 +83,10 @@ public class Margin : Adornment { View view = stack.Pop (); - if (view.Margin is { } margin && margin.Thickness != Thickness.Empty && margin.GetCachedClip () != null) + if (view.Margin is { } margin + && margin.Thickness != Thickness.Empty + && margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) + && margin.GetCachedClip () != null) { margin.SetNeedsDraw (); Region? saved = view.GetClip (); @@ -87,8 +96,6 @@ public class Margin : Adornment margin.ClearCachedClip (); } - view.ClearNeedsDraw (); - foreach (View subview in view.SubViews) { stack.Push (subview); @@ -134,7 +141,7 @@ public class Margin : Adornment if (ShadowStyle != ShadowStyle.None) { // Don't clear where the shadow goes - screen = Rectangle.Inflate (screen, -SHADOW_WIDTH, -SHADOW_HEIGHT); + screen = Rectangle.Inflate (screen, -ShadowSize.Width, -ShadowSize.Height); } return true; @@ -151,6 +158,8 @@ public class Margin : Adornment // private bool _pressed; private ShadowView? _bottomShadow; private ShadowView? _rightShadow; + private bool _isThicknessChanging; + private Thickness? _originalThickness; /// /// Sets whether the Margin includes a shadow effect. The shadow is drawn on the right and bottom sides of the @@ -172,25 +181,29 @@ public class Margin : Adornment _bottomShadow = null; } + _originalThickness ??= Thickness; + if (ShadowStyle != ShadowStyle.None) { // Turn off shadow - Thickness = new (Thickness.Left, Thickness.Top, Thickness.Right - SHADOW_WIDTH, Thickness.Bottom - SHADOW_HEIGHT); + _originalThickness = new (Thickness.Left, Thickness.Top, Math.Max (Thickness.Right - ShadowSize.Width, 0), Math.Max (Thickness.Bottom - ShadowSize.Height, 0)); } if (style != ShadowStyle.None) { // Turn on shadow - Thickness = new (Thickness.Left, Thickness.Top, Thickness.Right + SHADOW_WIDTH, Thickness.Bottom + SHADOW_HEIGHT); + _isThicknessChanging = true; + Thickness = new (_originalThickness.Value.Left, _originalThickness.Value.Top, _originalThickness.Value.Right + ShadowSize.Width, _originalThickness.Value.Bottom + ShadowSize.Height); + _isThicknessChanging = false; } if (style != ShadowStyle.None) { _rightShadow = new () { - X = Pos.AnchorEnd (SHADOW_WIDTH), + X = Pos.AnchorEnd (ShadowSize.Width), Y = 0, - Width = SHADOW_WIDTH, + Width = ShadowSize.Width, Height = Dim.Fill (), ShadowStyle = style, Orientation = Orientation.Vertical @@ -199,14 +212,20 @@ public class Margin : Adornment _bottomShadow = new () { X = 0, - Y = Pos.AnchorEnd (SHADOW_HEIGHT), + Y = Pos.AnchorEnd (ShadowSize.Height), Width = Dim.Fill (), - Height = SHADOW_HEIGHT, + Height = ShadowSize.Height, ShadowStyle = style, Orientation = Orientation.Horizontal }; Add (_rightShadow, _bottomShadow); } + else if (Thickness != _originalThickness) + { + _isThicknessChanging = true; + Thickness = new (_originalThickness.Value.Left, _originalThickness.Value.Top, _originalThickness.Value.Right, _originalThickness.Value.Bottom); + _isThicknessChanging = false; + } return style; } @@ -215,7 +234,90 @@ public class Margin : Adornment public override ShadowStyle ShadowStyle { get => base.ShadowStyle; - set => base.ShadowStyle = SetShadow (value); + set + { + if (value == ShadowStyle.Opaque || (value == ShadowStyle.Transparent && (ShadowSize.Width == 0 || ShadowSize.Height == 0))) + { + if (ShadowSize.Width != 1) + { + ShadowSize = ShadowSize with { Width = 1 }; + } + + if (ShadowSize.Height != 1) + { + ShadowSize = ShadowSize with { Height = 1 }; + } + } + + base.ShadowStyle = SetShadow (value); + } + } + + private Size _shadowSize; + + /// + /// Gets or sets the size of the shadow effect. + /// + public Size ShadowSize + { + get => _shadowSize; + set + { + if (TryValidateShadowSize (_shadowSize, value, out Size result)) + { + _shadowSize = value; + SetShadow (ShadowStyle); + } + else + { + _shadowSize = result; + } + } + } + + private bool TryValidateShadowSize (Size originalValue, in Size newValue, out Size result) + { + result = newValue; + + bool wasValid = true; + + if (newValue.Width < 0) + { + result = ShadowStyle is ShadowStyle.Opaque or ShadowStyle.Transparent ? result with { Width = 1 } : originalValue; + + wasValid = false; + } + + + if (newValue.Height < 0) + { + result = ShadowStyle is ShadowStyle.Opaque or ShadowStyle.Transparent ? result with { Height = 1 } : originalValue; + + wasValid = false; + } + + if (!wasValid) + { + return false; + } + + bool wasUpdated = false; + + if ((ShadowStyle == ShadowStyle.Opaque && newValue.Width != 1) || (ShadowStyle == ShadowStyle.Transparent && newValue.Width < 1)) + { + result = result with { Width = 1 }; + + wasUpdated = true; + } + + if ((ShadowStyle == ShadowStyle.Opaque && newValue.Height != 1) || (ShadowStyle == ShadowStyle.Transparent && newValue.Height < 1)) + { + result = result with { Height = 1 }; + + wasUpdated = true; + } + + return !wasUpdated; } private void OnParentOnMouseStateChanged (object? sender, EventArgs args) @@ -226,7 +328,7 @@ public class Margin : Adornment } bool pressed = args.Value.HasFlag (MouseState.Pressed) && parent.HighlightStates.HasFlag (MouseState.Pressed); - bool pressedOutside = args.Value.HasFlag (MouseState.PressedOutside) && parent.HighlightStates.HasFlag (MouseState.PressedOutside); ; + bool pressedOutside = args.Value.HasFlag (MouseState.PressedOutside) && parent.HighlightStates.HasFlag (MouseState.PressedOutside); if (pressedOutside) { @@ -238,11 +340,13 @@ public class Margin : Adornment // If the view is pressed and the highlight is being removed, move the shadow back. // Note, for visual effects reasons, we only move horizontally. // TODO: Add a setting or flag that lets the view move vertically as well. + _isThicknessChanging = true; Thickness = new ( Thickness.Left - PRESS_MOVE_HORIZONTAL, Thickness.Top - PRESS_MOVE_VERTICAL, Thickness.Right + PRESS_MOVE_HORIZONTAL, Thickness.Bottom + PRESS_MOVE_VERTICAL); + _isThicknessChanging = false; if (_rightShadow is { }) { @@ -264,11 +368,14 @@ public class Margin : Adornment // If the view is not pressed, and we want highlight move the shadow // Note, for visual effects reasons, we only move horizontally. // TODO: Add a setting or flag that lets the view move vertically as well. + _isThicknessChanging = true; Thickness = new ( Thickness.Left + PRESS_MOVE_HORIZONTAL, Thickness.Top + PRESS_MOVE_VERTICAL, Thickness.Right - PRESS_MOVE_HORIZONTAL, Thickness.Bottom - PRESS_MOVE_VERTICAL); + _isThicknessChanging = false; + MouseState |= MouseState.Pressed; if (_rightShadow is { }) diff --git a/Terminal.Gui/ViewBase/Adornment/ShadowView.cs b/Terminal.Gui/ViewBase/Adornment/ShadowView.cs index 90f84219c..78eb31355 100644 --- a/Terminal.Gui/ViewBase/Adornment/ShadowView.cs +++ b/Terminal.Gui/ViewBase/Adornment/ShadowView.cs @@ -100,7 +100,13 @@ internal class ShadowView : View if (c < ScreenContents?.GetLength (1) && r < ScreenContents?.GetLength (0)) { - AddStr (ScreenContents [r, c].Grapheme); + string grapheme = ScreenContents [r, c].Grapheme; + AddStr (grapheme); + + if (grapheme.GetColumns () > 1) + { + c++; + } } } } @@ -125,21 +131,31 @@ internal class ShadowView : View Rectangle screen = ViewportToScreen (Viewport); // Fill in the rest of the rectangle - for (int c = Math.Max (0, screen.X); c < screen.X + screen.Width; c++) + for (int r = Math.Max (0, screen.Y); r < screen.Y + viewport.Height; r++) { - for (int r = Math.Max (0, screen.Y); r < screen.Y + viewport.Height; r++) + for (int c = Math.Max (0, screen.X); c < screen.X + screen.Width; c++) { Driver?.Move (c, r); SetAttribute (GetAttributeUnderLocation (new (c, r))); - if (ScreenContents is { } && screen.X < ScreenContents.GetLength (1) && r < ScreenContents.GetLength (0)) + if (ScreenContents is { } && screen.X < ScreenContents.GetLength (1) && r < ScreenContents.GetLength (0) + && c < ScreenContents.GetLength (1) && r < ScreenContents.GetLength (0)) { - AddStr (ScreenContents [r, c].Grapheme); + string grapheme = ScreenContents [r, c].Grapheme; + AddStr (grapheme); + + if (grapheme.GetColumns () > 1) + { + c++; + } } } } } + // BUGBUG: This will never really work completely right by looking at an underlying cell and trying + // BUGBUG: to do transparency by adjusting colors. Instead, it might be possible to use the A in argb for this. + // BUGBUG: See https://github.com/gui-cs/Terminal.Gui/issues/4491 private Attribute GetAttributeUnderLocation (Point location) { if (SuperView is not Adornment diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index a5e17da18..38d398c3f 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -28,8 +28,8 @@ public partial class View // Drawing APIs view.Draw (context); } - // Draw the margins last to ensure they are drawn on top of the content. - Margin.DrawMargins (viewsArray); + // Draw Transparent margins last to ensure they are drawn on top of the content. + Margin.DrawTransparentMargins (viewsArray); // DrawMargins may have caused some views have NeedsDraw/NeedsSubViewDraw set; clear them all. foreach (View view in viewsArray) @@ -183,7 +183,18 @@ public partial class View // Drawing APIs private void DoDrawAdornmentsSubViews () { - // NOTE: We do not support SubViews of Margin + // Only SetNeedsDraw on Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass in the static View.Draw + // via Margin.DrawTransparentMargins. + if (Margin is { NeedsDraw: true } && !Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) + { + foreach (View subview in Margin.SubViews) + { + subview.SetNeedsDraw (); + } + + // NOTE: We do not support arbitrary SubViews of Margin (only ShadowView) + // NOTE: so we do not call DoDrawSubViews on Margin. + } if (Border?.SubViews is { } && Border.Thickness != Thickness.Empty && Border.NeedsDraw) { @@ -268,7 +279,12 @@ public partial class View // Drawing APIs /// public void DrawAdornments () { - // We do not attempt to draw Margin. It is drawn in a separate pass. + // Only draw Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass in the static View.Draw + // via Margin.DrawTransparentMargins. + if (Margin is { } && !Margin.ViewportSettings.HasFlag(ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) + { + Margin?.Draw (); + } // Each of these renders lines to this View's LineCanvas // Those lines will be finally rendered in OnRenderLineCanvas diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index a2ec2772f..dfbd047be 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -382,6 +382,7 @@ False True True + BMP CWP LL LR @@ -431,6 +432,7 @@ True True True + True True True True diff --git a/Tests/UnitTests/View/Draw/ClipTests.cs b/Tests/UnitTests/View/Draw/ClipTests.cs index bbeff3518..0210aa742 100644 --- a/Tests/UnitTests/View/Draw/ClipTests.cs +++ b/Tests/UnitTests/View/Draw/ClipTests.cs @@ -52,13 +52,13 @@ public class ClipTests (ITestOutputHelper _output) 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); + view.AddRune (0, 0, Glyphs.WideGlyphReplacement); Assert.Equal (" ", Application.Driver?.Contents! [2, 2].Grapheme); - view.AddRune (-1, -1, Rune.ReplacementChar); + view.AddRune (-1, -1, Glyphs.WideGlyphReplacement); Assert.Equal ("P", Application.Driver?.Contents! [1, 1].Grapheme); - view.AddRune (1, 1, Rune.ReplacementChar); + view.AddRune (1, 1, Glyphs.WideGlyphReplacement); Assert.Equal ("P", Application.Driver?.Contents! [3, 3].Grapheme); } diff --git a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs index 522f917c6..c1c4f93e6 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs @@ -50,25 +50,6 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase Assert.Equal (expected, driver.Contents [0, 0].Grapheme); Assert.Equal (" ", driver.Contents [0, 1].Grapheme); - // var s = "a\u0301\u0300\u0306"; - - // DriverAsserts.AssertDriverContentsWithFrameAre (@" - //ắ", output); - - // tf.Text = "\u1eaf"; - // Application.Refresh (); - // DriverAsserts.AssertDriverContentsWithFrameAre (@" - //ắ", output); - - // tf.Text = "\u0103\u0301"; - // Application.Refresh (); - // DriverAsserts.AssertDriverContentsWithFrameAre (@" - //ắ", output); - - // tf.Text = "\u0061\u0306\u0301"; - // Application.Refresh (); - // DriverAsserts.AssertDriverContentsWithFrameAre (@" - //ắ", output); driver.Dispose (); } @@ -148,31 +129,6 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase Assert.Equal (0, driver.Row); Assert.Equal (2, driver.Col); - //driver.AddRune ('b'); - //Assert.Equal ((Text)'b', driver.Contents [0, 1].Text); - //Assert.Equal (0, driver.Row); - //Assert.Equal (2, driver.Col); - - //// Move to the last column of the first row - //var lastCol = driver.Cols - 1; - //driver.Move (lastCol, 0); - //Assert.Equal (0, driver.Row); - //Assert.Equal (lastCol, driver.Col); - - //// Add a rune to the last column of the first row; should increment the row or col even though it's now invalid - //driver.AddRune ('c'); - //Assert.Equal ((Text)'c', driver.Contents [0, lastCol].Text); - //Assert.Equal (lastCol + 1, driver.Col); - - //// Add a rune; should succeed but do nothing as it's outside of Contents - //driver.AddRune ('d'); - //Assert.Equal (lastCol + 2, driver.Col); - //for (var col = 0; col < driver.Cols; col++) { - // for (var row = 0; row < driver.Rows; row++) { - // Assert.NotEqual ((Text)'d', driver.Contents [row, col].Text); - // } - //} - driver.Dispose (); } @@ -183,7 +139,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase driver.SetScreenSize (6, 3); driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); - driver!.Clip = new (driver.Screen); + driver.Clip = new (driver.Screen); driver.Move (1, 0); driver.AddStr ("┌"); driver.Move (2, 0); @@ -207,4 +163,125 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase 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); } + + [Fact] + public void AddStr_WideGlyph_Second_Column_Attribute_Set_When_In_Clip () + { + // This test verifies the fix for issue #4258 + // When a wide glyph is added and the second column is within the clip region, + // the attribute for column N+1 should be set to match the current attribute. + // See: OutputBufferImpl.cs line 194 + using IDriver driver = CreateFakeDriver (); + driver.SetScreenSize (4, 2); + + // Set a specific attribute for the wide glyph + Attribute wideGlyphAttr = new (Color.BrightRed, Color.BrightYellow); + driver.CurrentAttribute = wideGlyphAttr; + + // Add a wide glyph at position (0, 0) + driver.Move (0, 0); + driver.AddStr ("🍎"); + + // Verify the wide glyph is in column 0 + Assert.Equal ("🍎", driver.Contents! [0, 0].Grapheme); + Assert.Equal (wideGlyphAttr, driver.Contents [0, 0].Attribute); + + // Verify column 1 (the second column of the wide glyph) has the correct attribute set + // This is the fix: column N+1 should have CurrentAttribute set (line 194 in OutputBufferImpl.cs) + Assert.Equal (wideGlyphAttr, driver.Contents [0, 1].Attribute); + + // Verify cursor moved to column 2 + Assert.Equal (2, driver.Col); + } + + [Fact] + public void AddStr_WideGlyph_Second_Column_Attribute_Not_Set_When_Outside_Clip () + { + // This test verifies that when a wide glyph's second column is outside the clip, + // the attribute for column N+1 is NOT modified + using IDriver driver = CreateFakeDriver (); + driver.SetScreenSize (4, 2); + + // Set initial attribute for the entire contents + Attribute initialAttr = new (Color.White, Color.Black); + driver.CurrentAttribute = initialAttr; + driver.Move (0, 0); + driver.AddStr (" "); + driver.Move (0, 1); + driver.AddStr (" "); + + // Create a clip that excludes column 1 + driver.Clip = new (new Rectangle (0, 0, 1, 2)); + + // Set a different attribute for the wide glyph + Attribute wideGlyphAttr = new (Color.BrightRed, Color.BrightYellow); + driver.CurrentAttribute = wideGlyphAttr; + + // Try to add a wide glyph at position (0, 0) + // Column 0 is in clip, but column 1 is NOT + driver.Move (0, 0); + driver.AddStr ("🍎"); + + // Verify column 0 has the replacement character (can't fit wide glyph) + Assert.NotEqual ("🍎", driver.Contents! [0, 0].Grapheme); + + // Verify column 1 still has the original attribute (NOT modified) + Assert.Equal (initialAttr, driver.Contents [0, 1].Attribute); + } + + [Fact] + public void AddStr_WideGlyph_Second_Column_Attribute_Outputs_Correctly () + { + // This test verifies the fix for issue #4258 by checking the actual driver output + // This mimics what happens when TransparentShadow redraws a wide glyph from ScreenContents + // WITHOUT line 194, column N+1's attribute doesn't get set, causing wrong colors in output + // See: OutputBufferImpl.cs line ~196 (Contents [Row, Col].Attribute = CurrentAttribute;) + using IDriver driver = CreateFakeDriver (); + driver.SetScreenSize (3, 1); + driver.Force16Colors = true; + + // Step 1: Draw initial content - a wide glyph at column 1 with white-on-black + driver.CurrentAttribute = new Attribute (Color.White, Color.Black); + driver.Move (1, 0); + driver.AddStr ("🍎X"); // Wide glyph at columns 1-2, 'X' at column 3 doesn't exist (off-screen) + + // At this point: + // - Column 0: space (default) with white-on-black + // - Column 1: 🍎 with white-on-black + // - Column 2: (part of 🍎) with white-on-black (from initial ClearContents) + + // Step 2: Now redraw the SAME wide glyph at column 1 but with a DIFFERENT attribute (red-on-yellow) + // This simulates what transparent shadow does - it redraws what's underneath with a dimmed attribute + driver.CurrentAttribute = new Attribute (Color.BrightRed, Color.BrightYellow); + driver.Move (1, 0); + driver.AddStr ("🍎"); + + // Verify internal state + Assert.Equal ("🍎", driver.Contents! [0, 1].Grapheme); + Assert.Equal (new Attribute (Color.BrightRed, Color.BrightYellow), driver.Contents [0, 1].Attribute); + + // THIS is the critical assertion - column 2's attribute MUST be red-on-yellow + // WITHOUT line 194: column 2 retains white-on-black + // WITH line 194: column 2 gets red-on-yellow + Assert.Equal (new Attribute (Color.BrightRed, Color.BrightYellow), driver.Contents [0, 2].Attribute); + + driver.Refresh (); + + // Expected output: + // Column 0: space with white-on-black + // Columns 1-2: 🍎 with red-on-yellow (both columns must have same attribute!) + // + // WITHOUT line 196, the output would be: + // \x1b[97m\x1b[40m (white-on-black for column 0) + // \x1b[91m\x1b[103m🍎 (red-on-yellow starts at column 1) + // \x1b[97m\x1b[40m (WRONG! Attribute changes mid-glyph because column 2 still has white-on-black) + // + // WITH line 196, the output is: + // \x1b[97m\x1b[40m (white-on-black for column 0) + // \x1b[91m\x1b[103m🍎 (red-on-yellow for both columns 1 and 2) + DriverAssert.AssertDriverOutputIs ( + "\x1b[97m\x1b[40m \x1b[91m\x1b[103m🍎", + output, + driver); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderArrangementTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderArrangementTests.cs index e69de29bb..71ad1202c 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderArrangementTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderArrangementTests.cs @@ -0,0 +1,193 @@ +#nullable enable +using System.Text; +using UnitTests; +using Xunit.Abstractions; + +namespace ViewBaseTests.Adornments; + +[Collection ("Global Test Setup")] +public class BorderArrangementTests (ITestOutputHelper output) +{ + [Fact] + public void Arrangement_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 () + { + X = 2, Width = 4, Height = 4, BorderStyle = LineStyle.Single, + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, CanFocus = true + }; + superview.Add (view); + + app.Begin (superview); + + Assert.Equal ("Absolute(2)", view.X.ToString ()); + + DriverAssert.AssertDriverContentsAre ( + """ + 🍎┌──┐ + 🍎│ │ + 🍎│ │ + 🍎└──┘ + 🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.F5.WithCtrl)); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + 🍎◊──┐ + 🍎│ │ + 🍎│ │ + 🍎└──↘ + 🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.Equal ("Absolute(1)", view.X.ToString ()); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + �◊──┐ + �│ │ + �│ │ + �└──↘ + 🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.Equal ("Absolute(0)", view.X.ToString ()); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + ◊──┐🍎 + │ │🍎 + │ │🍎 + └──↘🍎 + 🍎🍎🍎 + """, + output, + app.Driver); + } + + [Fact] + public void Arrangement_With_SubView_In_Border_Handles_Wide_Glyphs_Correctly () + { + IApplication app = Application.Create (); + app.Init ("fake"); + + app.Driver?.SetScreenSize (8, 7); + app.Driver?.GetOutputBuffer ().SetWideGlyphReplacement (Rune.ReplacementChar); + + Runnable superview = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + superview.Text = """ + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + 🍎🍎🍎🍎 + """; + + View view = new () + { + X = 2, Width = 6, Height = 6, Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, CanFocus = true + }; + view.Border!.Thickness = new (1); + view.Border.Add (new View { Height = Dim.Auto (), Width = Dim.Auto (), Text = "Hi" }); + superview.Add (view); + + app.Begin (superview); + + Assert.Equal ("Absolute(2)", view.X.ToString ()); + + DriverAssert.AssertDriverContentsAre ( + """ + 🍎Hi + 🍎 + 🍎 + 🍎 + 🍎 + 🍎 + 🍎🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.F5.WithCtrl)); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + 🍎◊i + 🍎 + 🍎 + 🍎 + 🍎 + 🍎 ↘ + 🍎🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.Equal ("Absolute(1)", view.X.ToString ()); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + �◊i + � + � + � + � + � ↘ + 🍎🍎🍎🍎 + """, + output, + app.Driver); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.Equal ("Absolute(0)", view.X.ToString ()); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + ◊i 🍎 + 🍎 + 🍎 + 🍎 + 🍎 + ↘🍎 + 🍎🍎🍎🍎 + """, + output, + app.Driver); + } +} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/MarginTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/MarginTests.cs index 482b2519e..eeaf1165a 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/MarginTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/MarginTests.cs @@ -133,4 +133,26 @@ MMM", Assert.True (view.Margin!.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent), "Margin should be transparent when ShadowStyle is Opaque.."); } + [Fact] + public void Margin_Layouts_Correctly () + { + View superview = new () { Width = 10, Height = 5 }; + View view = new () { Width = 3, Height = 1, BorderStyle = LineStyle.Single }; + view.Margin!.Thickness = new (1); + View view2 = new () { X = Pos.Right (view), Width = 3, Height = 1, BorderStyle = LineStyle.Single }; + view2.Margin!.Thickness = new (1); + View view3 = new () { Y = Pos.Bottom (view), Width = 3, Height = 1, BorderStyle = LineStyle.Single }; + view3.Margin!.Thickness = new (1); + superview.Add (view, view2, view3); + + superview.LayoutSubViews (); + + Assert.Equal (new (0, 0, 10, 5), superview.Frame); + Assert.Equal (new (0, 0, 3, 1), view.Frame); + Assert.Equal (Rectangle.Empty, view.Viewport); + Assert.Equal (new (3, 0, 3, 1), view2.Frame); + Assert.Equal (Rectangle.Empty, view2.Viewport); + Assert.Equal (new (0, 1, 3, 1), view3.Frame); + Assert.Equal (Rectangle.Empty, view3.Viewport); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs deleted file mode 100644 index 7af4b777c..000000000 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using UnitTests; -using Xunit.Abstractions; - -namespace ViewBaseTests.Adornments; - -[Collection ("Global Test Setup")] - -public class ShadowStyleTests (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 (); - } - - - [Fact] - public void TransparentShadow_Draws_Transparent_At_Driver_Output () - { - // Arrange - IApplication app = Application.Create (); - app.Init ("fake"); - app.Driver!.SetScreenSize (5, 3); - - // Force 16-bit colors off to get predictable RGB output - app.Driver.Force16Colors = false; - - var superView = new Runnable - { - Width = Dim.Fill (), - Height = Dim.Fill (), - Text = "ABC".Repeat (40)! - }; - superView.SetScheme (new (new Attribute (Color.White, Color.Blue))); - superView.TextFormatter.WordWrap = true; - - // Create an overlapped view with transparent shadow - var overlappedView = new View - { - Width = 4, - Height = 2, - Text = "123", - Arrangement = ViewArrangement.Overlapped, - ShadowStyle = ShadowStyle.Transparent - }; - overlappedView.SetScheme (new (new Attribute (Color.Black, Color.Green))); - - superView.Add (overlappedView); - - // Act - SessionToken? token = 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[38;2;0;0;0m\x1b[48;2;0;128;0m123\x1b[38;2;0;0;0m\x1b[48;2;189;189;189mA\x1b[38;2;0;0;255m\x1b[48;2;255;255;255mBC\x1b[38;2;0;0;0m\x1b[48;2;189;189;189mABC\x1b[38;2;0;0;255m\x1b[48;2;255;255;255mABCABC - """, _output, app.Driver); - - // The output should contain ANSI color codes for the transparent shadow - // which will have dimmed colors compared to the original - Assert.Contains ("\x1b[38;2;", output); // Should have RGB foreground color codes - Assert.Contains ("\x1b[48;2;", output); // Should have RGB background color codes - - // Verify driver contents show the background text in shadow areas - int shadowX = overlappedView.Frame.X + overlappedView.Frame.Width; - int shadowY = overlappedView.Frame.Y + overlappedView.Frame.Height; - - Cell shadowCell = app.Driver.Contents! [shadowY, shadowX]; - _output.WriteLine ($"\nShadow cell at [{shadowY},{shadowX}]: Grapheme='{shadowCell.Grapheme}', Attr={shadowCell.Attribute}"); - - // The grapheme should be from background text - Assert.NotEqual (string.Empty, shadowCell.Grapheme); - Assert.Contains (shadowCell.Grapheme, "ABC"); // Should be one of the background characters - - // Cleanup - if (token is { }) - { - app.End (token); - } - - superView.Dispose (); - app.Dispose (); - } - -} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs new file mode 100644 index 000000000..281fa1b2f --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs @@ -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 ( + """ + ┌──┐🍎 + │ │🍎 + │ │🍎 + └──┘🍎 + � 🍎🍎 + """, + output, + app.Driver); + + view.Margin!.ShadowSize = new (1, 2); + + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre ( + """ + ┌──┐🍎 + │ │� + └──┘� + � 🍎🍎 + � 🍎🍎 + """, + 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); + } +}