From 48d6e131385e4e8a356be6ff705a8f483623afb9 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 12 Dec 2025 16:57:29 -0700 Subject: [PATCH 1/6] Fixes #4466 - `FillRect` Corrupts Wide Characters When Overlapping (#4486) * Improve wide character handling in output buffer Enhances rendering and state management for wide (double-width) characters. Marks both cells as clean after rendering wide graphemes, ensures replacement cells are marked dirty when partially clipped, and uses Move/AddStr for proper wide character handling and invalidation. * Fix FillRect to handle wide Unicode chars correctly Refactored OutputBufferImpl.FillRect to properly handle wide (double-width) Unicode characters, fixing visual corruption when overwriting CJK text (e.g., with MessageBox borders). Removed the char-based FillRect overload in favor of Rune-based handling. Added helper methods for attribute/dirty management and wide glyph invalidation. Updated OutputBase.Write to always mark adjacent cells dirty for wide chars. Updated tests and added OutputBufferWideCharTests to verify correct behavior in all scenarios. This resolves issue #4466 and ensures robust rendering for wide Unicode text. * Handle wide grapheme clusters in OutputBase rendering Added logic to mark both cells of wide grapheme clusters as clean after rendering, preventing unnecessary redraws. Also included a commented-out preprocessor directive and using statement for potential future use. * Clarify comment for IsDirty logic on wide graphemes Updated the comment explaining why the next cell is marked clean (IsDirty = false) after handling wide graphemes, and added a reference to GitHub issue #4466 for context. * Update test for dirty flag after wide glyph write Adjusted OutputBaseTests to expect column 1's dirty flag to be cleared after writing a wide glyph to column 0, matching current OutputBase.Write behavior. Added clarifying comment and GitHub issue reference. * Update Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Terminal.Gui/Drivers/OutputBufferImpl.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Terminal.Gui/Drivers/OutputBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Drivers/DriverImpl.cs | 3 - Terminal.Gui/Drivers/IDriver.cs | 8 - Terminal.Gui/Drivers/OutputBase.cs | 7 + Terminal.Gui/Drivers/OutputBufferImpl.cs | 127 ++++--- Terminal.sln.DotSettings | 1 + .../Drivers/ClipRegionTests.cs | 6 +- .../Drivers/OutputBaseTests.cs | 6 +- .../Drivers/OutputBufferWideCharTests.cs | 359 ++++++++++++++++++ 8 files changed, 443 insertions(+), 74 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 5c0d5ad72..2368dd631 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -330,9 +330,6 @@ internal class DriverImpl : IDriver /// public void FillRect (Rectangle rect, Rune rune = default) { OutputBuffer.FillRect (rect, rune); } - /// - public void FillRect (Rectangle rect, char c) { OutputBuffer.FillRect (rect, c); } - /// public Attribute SetAttribute (Attribute newAttribute) { diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index 0abd121b7..41ff4d091 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -257,14 +257,6 @@ public interface IDriver : IDisposable /// The Rune used to fill the rectangle void FillRect (Rectangle rect, Rune rune = default); - /// - /// Fills the specified rectangle with the specified . This method is a convenience method - /// that calls . - /// - /// - /// - void FillRect (Rectangle rect, char c); - /// Selects the specified attribute as the attribute to use for future calls to AddRune and AddString. /// Implementations should call base.SetAttribute(c). /// C. diff --git a/Terminal.Gui/Drivers/OutputBase.cs b/Terminal.Gui/Drivers/OutputBase.cs index 347eba70b..cbeb403a0 100644 --- a/Terminal.Gui/Drivers/OutputBase.cs +++ b/Terminal.Gui/Drivers/OutputBase.cs @@ -127,6 +127,13 @@ public abstract class OutputBase Cell cell = buffer.Contents [row, col]; buffer.Contents [row, col].IsDirty = false; AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth); + + if (col != lastCol) + { + // Was a wide grapheme so mark clean next cell + // See https://github.com/gui-cs/Terminal.Gui/issues/4466 + buffer.Contents [row, col].IsDirty = false; + } } } diff --git a/Terminal.Gui/Drivers/OutputBufferImpl.cs b/Terminal.Gui/Drivers/OutputBufferImpl.cs index c12dc29f5..be03a26c6 100644 --- a/Terminal.Gui/Drivers/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/OutputBufferImpl.cs @@ -86,7 +86,7 @@ public class OutputBufferImpl : IOutputBuffer get => _clip; set { - if (_clip == value) + if (ReferenceEquals (_clip, value)) { return; } @@ -94,10 +94,7 @@ public class OutputBufferImpl : IOutputBuffer _clip = value; // Don't ever let Clip be bigger than Screen - if (_clip is { }) - { - _clip.Intersect (Screen); - } + _clip?.Intersect (Screen); } } @@ -105,7 +102,7 @@ public class OutputBufferImpl : IOutputBuffer /// /// /// When the method returns, will be incremented by the number of columns - /// required, even if the new column value is outside of the or screen + /// required, even if the new column value is outside the or screen /// dimensions defined by . /// /// @@ -156,25 +153,19 @@ public class OutputBufferImpl : IOutputBuffer Clip ??= new (Screen); Rectangle clipRect = Clip!.GetBounds (); - string text = grapheme; - int textWidth = -1; + int printableGraphemeWidth = -1; lock (Contents) { - bool validLocation = IsValidLocation (text, Col, Row); - - if (validLocation) + if (IsValidLocation (grapheme, Col, Row)) { - text = text.MakePrintable (); - textWidth = text.GetColumns (); - // Set attribute and mark dirty for current cell - Contents [Row, Col].Attribute = CurrentAttribute; - Contents [Row, Col].IsDirty = true; + SetAttributeAndDirty (Col, Row); + InvalidateOverlappedWideGlyph (Col, Row); - InvalidateOverlappedWideGlyph (); - - WriteGraphemeByWidth (text, textWidth, clipRect); + string printableGrapheme = grapheme.MakePrintable (); + printableGraphemeWidth = printableGrapheme.GetColumns (); + WriteGraphemeByWidth (Col, Row, printableGrapheme, printableGraphemeWidth, clipRect); DirtyLines [Row] = true; } @@ -183,7 +174,7 @@ public class OutputBufferImpl : IOutputBuffer // Keep Col/Row updates inside the lock to prevent race conditions Col++; - if (textWidth > 1) + if (printableGraphemeWidth > 1) { // Skip the second column of a wide character // IMPORTANT: We do NOT modify column N+1's IsDirty or Attribute here. @@ -194,86 +185,111 @@ public class OutputBufferImpl : IOutputBuffer } /// - /// If we're writing at an odd column and there's a wide glyph to our left, + /// INTERNAL: Helper to set the attribute and mark the cell as dirty. + /// + /// The column. + /// The row. + private void SetAttributeAndDirty (int col, int row) + { + Contents! [row, col].Attribute = CurrentAttribute; + Contents [row, col].IsDirty = true; + } + + /// + /// INTERNAL: If we're writing at an odd column and there's a wide glyph to our left, /// invalidate it since we're overwriting the second half. /// - private void InvalidateOverlappedWideGlyph () + /// The column. + /// The row. + private void InvalidateOverlappedWideGlyph (int col, int row) { - if (Col > 0 && Contents! [Row, Col - 1].Grapheme.GetColumns () > 1) + if (col > 0 && Contents! [row, col - 1].Grapheme.GetColumns () > 1) { - Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString (); - Contents [Row, Col - 1].IsDirty = true; + Contents [row, col - 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents [row, col - 1].IsDirty = true; } } /// - /// Writes a grapheme to the buffer based on its width (0, 1, or 2 columns). + /// INTERNAL: Writes a Grapheme to the buffer based on its width (0, 1, or 2 columns). /// + /// The column. + /// The row. /// The printable text to write. /// The column width of the text. /// The clipping rectangle. - private void WriteGraphemeByWidth (string text, int textWidth, Rectangle clipRect) + private void WriteGraphemeByWidth (int col, int row, string text, int textWidth, Rectangle clipRect) { switch (textWidth) { case 0: case 1: - WriteSingleWidthGrapheme (text, clipRect); + WriteGrapheme (col, row, text, clipRect); break; case 2: - WriteWideGrapheme (text); + WriteWideGrapheme (col, row, text); break; default: // Negative width or non-spacing character (shouldn't normally occur) - Contents! [Row, Col].Grapheme = " "; - Contents [Row, Col].IsDirty = false; + Contents! [row, col].Grapheme = " "; + Contents [row, col].IsDirty = false; break; } } /// - /// Writes a single-width character (0 or 1 column wide). + /// INTERNAL: Writes a (0 or 1 column wide) Grapheme. /// - private void WriteSingleWidthGrapheme (string text, Rectangle clipRect) + /// The column. + /// The row. + /// The single-width Grapheme to write. + /// The clipping rectangle. + private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRect) { - Contents! [Row, Col].Grapheme = text; + Debug.Assert (grapheme.GetColumns () < 2); + Contents! [row, col].Grapheme = grapheme; // Mark the next cell as dirty to ensure proper rendering of adjacent content - if (Col < clipRect.Right - 1 && Col + 1 < Cols) + if (col < clipRect.Right - 1 && col + 1 < Cols) { - Contents [Row, Col + 1].IsDirty = true; + Contents [row, col + 1].IsDirty = true; } } /// - /// Writes a wide character (2 columns wide) handling clipping and partial overlap cases. + /// INTERNAL: Writes a wide Grapheme (2 columns wide) handling clipping and partial overlap cases. /// - private void WriteWideGrapheme (string text) + /// The column. + /// The row. + /// The wide Grapheme to write. + private void WriteWideGrapheme (int col, int row, string grapheme) { - if (!Clip!.Contains (Col + 1, Row)) + Debug.Assert (grapheme.GetColumns () == 2); + if (!Clip!.Contains (col + 1, row)) { // Second column is outside clip - can't fit wide char here - Contents! [Row, Col].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col].Grapheme = Rune.ReplacementChar.ToString (); } - else if (!Clip.Contains (Col, Row)) + else if (!Clip.Contains (col, row)) { // First column is outside clip but second isn't // Mark second column as replacement to indicate partial overlap - if (Col + 1 < Cols) + if (col + 1 < Cols) { - Contents! [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col + 1].Grapheme = Rune.ReplacementChar.ToString (); + Contents! [row, col + 1].IsDirty = true; } } else { // Both columns are in bounds - write the wide character // It will naturally render across both columns when output to the terminal - Contents! [Row, Col].Grapheme = text; + Contents! [row, col].Grapheme = grapheme; // DO NOT modify column N+1 here! // The wide glyph will naturally render across both columns. @@ -288,7 +304,7 @@ public class OutputBufferImpl : IOutputBuffer { Contents = new Cell [Rows, Cols]; - //CONCURRENCY: Unsynchronized access to Clip isn't safe. + // CONCURRENCY: Unsynchronized access to Clip isn't safe. // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. Clip = new (Screen); @@ -311,9 +327,6 @@ public class OutputBufferImpl : IOutputBuffer DirtyLines [row] = true; } } - - // TODO: Who uses this and why? I am removing for now - this class is a state class not an events class - //ClearedContents?.Invoke (this, EventArgs.Empty); } /// Tests whether the specified coordinate are valid for drawing the specified Text. @@ -342,8 +355,9 @@ public class OutputBufferImpl : IOutputBuffer /// public void FillRect (Rectangle rect, Rune rune) { + Rectangle clipBounds = Clip?.GetBounds () ?? Screen; // BUGBUG: This should be a method on Region - rect = Rectangle.Intersect (rect, Clip?.GetBounds () ?? Screen); + rect = Rectangle.Intersect (rect, clipBounds); lock (Contents!) { @@ -356,11 +370,12 @@ public class OutputBufferImpl : IOutputBuffer continue; } - Contents [r, c] = new () - { - Grapheme = rune != default (Rune) ? rune.ToString () : " ", - Attribute = CurrentAttribute, IsDirty = true - }; + // We could call AddGrapheme here, but that would acquire the lock again. + // So we inline the logic instead. + SetAttributeAndDirty (c, r); + InvalidateOverlappedWideGlyph (c, r); + string grapheme = rune != default (Rune) ? rune.ToString () : " "; + WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds); } } } @@ -379,7 +394,6 @@ public class OutputBufferImpl : IOutputBuffer } } - // TODO: Make internal once Menu is upgraded /// /// Updates and to the specified column and row in . /// Used by and to determine where to add content. @@ -393,9 +407,8 @@ public class OutputBufferImpl : IOutputBuffer /// /// Column to move to. /// Row to move to. - public virtual void Move (int col, int row) + public void Move (int col, int row) { - //Debug.Assert (col >= 0 && row >= 0 && col < Contents.GetLength(1) && row < Contents.GetLength(0)); Col = col; Row = row; } diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index a33c71b8d..e3f73a543 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -426,6 +426,7 @@ True True True + True True True True diff --git a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs index c07d4070d..d36323999 100644 --- a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs @@ -23,12 +23,12 @@ public class ClipRegionTests (ITestOutputHelper output) : FakeDriverBase Assert.Equal ("x", driver.Contents [5, 5].Grapheme); // Clear the contents - driver.FillRect (new Rectangle (0, 0, driver.Rows, driver.Cols), ' '); + driver.FillRect (new (0, 0, driver.Rows, driver.Cols), new Rune(' ')); Assert.Equal (" ", driver.Contents [0, 0].Grapheme); // Setup the region with a single rectangle, fill screen with 'x' - driver.Clip = new (new Rectangle (5, 5, 5, 5)); - driver.FillRect (new Rectangle (0, 0, driver.Rows, driver.Cols), 'x'); + driver.Clip = new (new (5, 5, 5, 5)); + driver.FillRect (new (0, 0, driver.Rows, driver.Cols), new Rune ('x')); Assert.Equal (" ", driver.Contents [0, 0].Grapheme); Assert.Equal (" ", driver.Contents [4, 9].Grapheme); Assert.Equal ("x", driver.Contents [5, 5].Grapheme); diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs index 6ac00a739..19c6ddcec 100644 --- a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs @@ -189,9 +189,9 @@ public class OutputBaseTests // Column 0 was written (wide glyph) Assert.False (buffer.Contents! [0, 0].IsDirty); - // Column 1 was skipped by OutputBase.Write because column 0 had a wide glyph - // So its dirty flag remains true (it was initialized as dirty by ClearContents) - Assert.True (buffer.Contents! [0, 1].IsDirty); + // Column 1 was marked as clean by OutputBase.Write when it processed the wide glyph at column 0 + // See: https://github.com/gui-cs/Terminal.Gui/issues/4466 + Assert.False (buffer.Contents! [0, 1].IsDirty); // Column 2 was written ('A') Assert.False (buffer.Contents! [0, 2].IsDirty); diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs new file mode 100644 index 000000000..d8da44b65 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs @@ -0,0 +1,359 @@ +using System.Text; +using Xunit.Abstractions; + +namespace DriverTests; + +/// +/// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466. +/// These tests validate that FillRect properly handles wide characters when overlapping existing content. +/// Specifically, they ensure that wide characters are properly invalidated and replaced when a MessageBox border or similar UI element is drawn over them, preventing visual corruption. +/// +public class OutputBufferWideCharTests (ITestOutputHelper output) +{ + /// + /// Tests that FillRect properly invalidates wide characters when overwriting them. + /// This is the core issue in #4466 - when a MessageBox border is drawn over Chinese text, + /// the wide characters need to be properly invalidated. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_OverwritesWideChar_InvalidatesProperly () + { + // Arrange - Create a buffer and draw a wide character + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a Chinese character (2 columns wide) at position 2,1 + buffer.Move (2, 1); + buffer.AddStr ("你"); // Chinese character "you", 2 columns wide + + // Verify the wide character was drawn + Assert.Equal ("你", buffer.Contents! [1, 2].Grapheme); + Assert.True (buffer.Contents [1, 2].IsDirty); + + // With the fix, the second column should NOT be modified by AddStr + // The wide glyph naturally renders across both columns + Assert.NotEqual ("你", buffer.Contents [1, 3].Grapheme); + + // Clear dirty flags to test FillRect behavior + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill a rectangle that overlaps the first column of the wide character + // This simulates drawing a MessageBox border over Chinese text + buffer.FillRect (new (2, 1, 1, 1), new Rune ('│')); + + // Assert + + // With FIXES_4466: FillRect calls AddStr, which properly invalidates the wide character + // The wide character at [1,2] should be replaced with replacement char or the new content + Assert.Equal ("│", buffer.Contents [1, 2].Grapheme); + Assert.True (buffer.Contents [1, 2].IsDirty, "Cell [1,2] should be marked dirty after FillRect"); + + // The adjacent cell should also be marked dirty for proper rendering + Assert.True (buffer.Contents [1, 3].IsDirty, "Adjacent cell [1,3] should be marked dirty to ensure proper rendering"); + } + + /// + /// Tests that FillRect handles overwriting the second column of a wide character. + /// When drawing at an odd column that's the second half of a wide glyph, the + /// wide glyph should be invalidated. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_OverwritesSecondColumnOfWideChar_InvalidatesWideChar () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a wide character at position 2,1 + buffer.Move (2, 1); + buffer.AddStr ("好"); // Chinese character, 2 columns wide + + Assert.Equal ("好", buffer.Contents! [1, 2].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill at the second column of the wide character (position 3) + buffer.FillRect (new (3, 1, 1, 1), new Rune ('│')); + + // Assert + // With the fix: The original wide character at col 2 should be invalidated + // because we're overwriting its second column + Assert.True (buffer.Contents [1, 2].IsDirty, "Wide char at col 2 should be invalidated when its second column is overwritten"); + Assert.Equal (buffer.Contents [1, 2].Grapheme, Rune.ReplacementChar.ToString ()); + + Assert.Equal ("│", buffer.Contents [1, 3].Grapheme); + Assert.True (buffer.Contents [1, 3].IsDirty); + } + + /// + /// Tests the ChineseUI scenario: Drawing a MessageBox with borders over Chinese button text. + /// This simulates the specific repro case from the issue. See: https://github.com/gui-cs/Terminal.Gui/issues/4466 + /// + [Fact] + [Trait ("Category", "Output")] + public void ChineseUI_MessageBox_Over_WideChars () + { + // Arrange - Simulate the ChineseUI scenario + OutputBufferImpl buffer = new () + { + Rows = 10, Cols = 30, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw Chinese button text (like "你好呀") + buffer.Move (5, 3); + buffer.AddStr ("你好呀"); // 3 Chinese characters, 6 columns total + + // Verify initial state + Assert.Equal ("你", buffer.Contents! [3, 5].Grapheme); + Assert.Equal ("好", buffer.Contents [3, 7].Grapheme); + Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme); + + // Clear dirty flags to simulate the state before MessageBox draws + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Draw a MessageBox border that partially overlaps the Chinese text + // This simulates the mouse moving over the border, causing HighlightState changes + // Draw vertical line at column 8 (overlaps second char "好") + for (var row = 2; row < 6; row++) + { + buffer.FillRect (new (8, row, 1, 1), new Rune ('│')); + } + + // Assert - The wide characters should be properly handled + // With the fix: Wide characters are properly invalidated + // The first character "你" at col 5 should be unaffected + Assert.Equal ("你", buffer.Contents [3, 5].Grapheme); + + // The second character "好" at col 7 had its second column overwritten + // so it should be replaced with replacement char + Assert.Equal (buffer.Contents [3, 7].Grapheme, Rune.ReplacementChar.ToString ()); + Assert.True (buffer.Contents [3, 7].IsDirty, "Invalidated wide char should be marked dirty"); + + // The border should be drawn at col 8 + Assert.Equal ("│", buffer.Contents [3, 8].Grapheme); + Assert.True (buffer.Contents [3, 8].IsDirty); + + // The third character "呀" at col 9 should be unaffected + Assert.Equal ("呀", buffer.Contents [3, 9].Grapheme); + } + + /// + /// Tests that FillRect works correctly with single-width characters (baseline behavior). + /// This should work the same with or without FIXES_4466. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_SingleWidthChars_WorksCorrectly () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw some ASCII text + buffer.Move (2, 1); + buffer.AddStr ("ABC"); + + Assert.Equal ("A", buffer.Contents! [1, 2].Grapheme); + Assert.Equal ("B", buffer.Contents [1, 3].Grapheme); + Assert.Equal ("C", buffer.Contents [1, 4].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Overwrite with FillRect + buffer.FillRect (new (3, 1, 1, 1), new Rune ('X')); + + // Assert - This should work the same regardless of FIXES_4466 + Assert.Equal ("A", buffer.Contents [1, 2].Grapheme); + Assert.Equal ("X", buffer.Contents [1, 3].Grapheme); + Assert.True (buffer.Contents [1, 3].IsDirty); + Assert.Equal ("C", buffer.Contents [1, 4].Grapheme); + } + + /// + /// Tests FillRect with wide characters at buffer boundaries. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_WideChar_AtBufferBoundary () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 10, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a wide character at the right edge (col 8, which would extend to col 9) + buffer.Move (8, 1); + buffer.AddStr ("山"); // Chinese character "mountain", 2 columns wide + + Assert.Equal ("山", buffer.Contents! [1, 8].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - FillRect at the wide character position + buffer.FillRect (new (8, 1, 1, 1), new Rune ('│')); + + // Assert + Assert.Equal ("│", buffer.Contents [1, 8].Grapheme); + Assert.True (buffer.Contents [1, 8].IsDirty); + + // Adjacent cell should be marked dirty + Assert.True ( + buffer.Contents [1, 9].IsDirty, + "Cell after wide char replacement should be marked dirty"); + } + + /// + /// Tests OutputBase.Write method marks cells dirty correctly for wide characters. + /// This tests the other half of the fix in OutputBase.cs. + /// + [Fact] + [Trait ("Category", "Output")] + public void OutputBase_Write_WideChar_MarksCellsDirty () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 20, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a line with wide characters + buffer.Move (0, 1); + buffer.AddStr ("你好"); // Two wide characters + + // Mark all as not dirty to simulate post-Write state + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents! [r, c].IsDirty = false; + } + } + + // Verify initial state + Assert.Equal ("你", buffer.Contents! [1, 0].Grapheme); + Assert.Equal ("好", buffer.Contents [1, 2].Grapheme); + + // Act - Now overwrite the first wide char by writing at its position + buffer.Move (0, 1); + buffer.AddStr ("A"); // Single width char + + // Assert + // With the fix: The first cell is replaced with 'A' and marked dirty + Assert.Equal ("A", buffer.Contents [1, 0].Grapheme); + Assert.True (buffer.Contents [1, 0].IsDirty); + + // The adjacent cell (col 1) should be marked dirty for proper rendering + Assert.True ( + buffer.Contents [1, 1].IsDirty, + "Adjacent cell should be marked dirty after writing single-width char over wide char"); + + // The second wide char should remain + Assert.Equal ("好", buffer.Contents [1, 2].Grapheme); + } + + /// + /// Tests that filling a rectangle with spaces properly handles wide character cleanup. + /// This simulates clearing a region that contains wide characters. + /// + [Fact] + [Trait ("Category", "Output")] + public void FillRect_WithSpaces_OverWideChars () + { + // Arrange + OutputBufferImpl buffer = new () + { + Rows = 5, Cols = 15, + CurrentAttribute = new (Color.White, Color.Black) + }; + + // Draw a line of mixed content + buffer.Move (2, 2); + buffer.AddStr ("A你B好C"); + + // Verify setup + Assert.Equal ("A", buffer.Contents! [2, 2].Grapheme); + Assert.Equal ("你", buffer.Contents [2, 3].Grapheme); + Assert.Equal ("B", buffer.Contents [2, 5].Grapheme); + Assert.Equal ("好", buffer.Contents [2, 6].Grapheme); + Assert.Equal ("C", buffer.Contents [2, 8].Grapheme); + + // Clear dirty flags + for (var r = 0; r < buffer.Rows; r++) + { + for (var c = 0; c < buffer.Cols; c++) + { + buffer.Contents [r, c].IsDirty = false; + } + } + + // Act - Fill the region with spaces (simulating clearing) + buffer.FillRect (new (3, 2, 4, 1), new Rune (' ')); + + // Assert + // With the fix: Wide characters are properly handled + Assert.Equal (" ", buffer.Contents [2, 3].Grapheme); + Assert.True (buffer.Contents [2, 3].IsDirty); + + // Wide character '你' at col 3 was replaced, so col 4 should be marked dirty + Assert.True ( + buffer.Contents [2, 4].IsDirty, + "Cell after replaced wide char should be dirty"); + + Assert.Equal (" ", buffer.Contents [2, 4].Grapheme); + Assert.Equal (" ", buffer.Contents [2, 5].Grapheme); + Assert.Equal (" ", buffer.Contents [2, 6].Grapheme); + + // Cell 7 should be dirty because '好' was partially overwritten + Assert.True ( + buffer.Contents [2, 7].IsDirty, + "Adjacent cell should be dirty after wide char replacement"); + } +} From 84f977937beb8f46cbdd6166027de325469ce2c1 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 12 Dec 2025 18:24:34 -0700 Subject: [PATCH 2/6] Adds transparent shadow test (#4487) * Add tests for overlapped view transparent shadow config * Add test for transparent shadow showing background text Added a unit test to verify that overlapped views with transparent shadows allow background text to show through the shadow area. The test checks both right and bottom shadow regions and asserts that the underlying content is visible, ensuring correct rendering behavior. * Add test for transparent shadow in overlapped views Adds Overlapped_View_With_TransparentShadow_Driver_Output_Shows_Background_Text to verify that background text is visible through transparent shadows in overlapped views. The test checks for correct rendering, ANSI color codes, and ensures the shadow area displays the expected background character. * Refactor transparent shadow test for stricter output check Update Overlapped_View_With_TransparentShadow_Driver_Output_Shows_Background_Text to use a smaller screen, dynamic sizing, and new overlapped view text. Replace loose output assertion with strict ANSI output comparison. Make shadow cell coordinate calculation dynamic based on view frame. * Consolidate transparent shadow tests into ShadowStyleTests Moved the transparent shadow driver output test from OverlappedViewTransparentShadowTests to ShadowStyleTests for better organization. Removed the now-redundant OverlappedViewTransparentShadowTests class and updated ShadowStyleTests to support output logging. All transparent shadow rendering tests are now grouped under ShadowStyleTests. --- .../ViewBase/Adornment/ShadowStyletests.cs | 85 ++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs index 49bb7f0e7..7af4b777c 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs @@ -1,9 +1,14 @@ -namespace ViewBaseTests.Adornments; +using UnitTests; +using Xunit.Abstractions; + +namespace ViewBaseTests.Adornments; [Collection ("Global Test Setup")] -public class ShadowStyleTests +public class ShadowStyleTests (ITestOutputHelper output) { + private readonly ITestOutputHelper _output = output; + [Fact] public void Default_None () { @@ -73,4 +78,80 @@ public class ShadowStyleTests 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 (); + } + } From fb1a3e03f35cd724486b5fd97a1dc5c9666c742f Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 14 Dec 2025 17:31:57 -0700 Subject: [PATCH 3/6] Fixes some bugs in the `Scenario/EditorsAndHelpers` and backports `WideGlyphs` (#4494) * 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. * Scenario Editors code cleanup & latent bug fixes. Refactored event handler patterns to use correct sender values and discards for unused parameters, aligning with .NET conventions. Cleaned up code by removing redundant usings and comments, and clarified logic in property setters and switch statements. Enhanced robustness and clarity in editor components, fixing issues with value changes, event subscriptions, and nullability. Improved disposal logic in EditorBase and made minor UI and label adjustments. Added "diag" to the custom dictionary. These changes modernize event handling and address subtle bugs in the UICatalog editors. * code cleanup * Update Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * PR feedback. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Adornments.cs | 1 + .../EditorsAndHelpers/AdornmentEditor.cs | 37 ++++--- .../EditorsAndHelpers/AdornmentsEditor.cs | 17 +-- .../EditorsAndHelpers/AllViewsView.cs | 19 +--- .../EditorsAndHelpers/BorderEditor.cs | 49 +++++---- .../Scenarios/EditorsAndHelpers/DimEditor.cs | 71 ++++++------ .../Scenarios/EditorsAndHelpers/EditorBase.cs | 51 ++++----- .../Scenarios/EditorsAndHelpers/EventLog.cs | 4 +- .../EditorsAndHelpers/ExpanderButton.cs | 20 ++-- .../EditorsAndHelpers/LayoutEditor.cs | 5 - .../EditorsAndHelpers/MarginEditor.cs | 17 +-- .../Scenarios/EditorsAndHelpers/PosEditor.cs | 9 +- .../EditorsAndHelpers/ViewPropertiesEditor.cs | 30 ++--- .../ViewportSettingsEditor.cs | 104 +++++++++--------- Examples/UICatalog/Scenarios/ShadowStyles.cs | 27 ++++- .../UICatalog/Scenarios/ViewportSettings.cs | 2 + Examples/UICatalog/Scenarios/WideGlyphs.cs | 37 ++++++- Terminal.Gui/App/ApplicationImpl.Run.cs | 2 +- Terminal.Gui/App/ApplicationNavigation.cs | 2 +- Terminal.Gui/App/CWP/CWPEventHelper.cs | 1 + Terminal.Gui/App/CWP/CWPPropertyHelper.cs | 6 +- Terminal.Gui/App/CWP/CWPWorkflowHelper.cs | 2 + Terminal.Gui/App/Keyboard/KeyboardImpl.cs | 4 +- Terminal.Gui/App/Mouse/MouseImpl.cs | 2 +- Terminal.sln.DotSettings | 1 + 25 files changed, 269 insertions(+), 251 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Adornments.cs b/Examples/UICatalog/Scenarios/Adornments.cs index 6dd491f65..453ee2478 100644 --- a/Examples/UICatalog/Scenarios/Adornments.cs +++ b/Examples/UICatalog/Scenarios/Adornments.cs @@ -19,6 +19,7 @@ public class Adornments : Scenario var editor = new AdornmentsEditor { + BorderStyle = LineStyle.Single, AutoSelectViewToEdit = true, // This is for giggles, to show that the editor can be moved around. diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentEditor.cs index 907cbb42e..f4339b801 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentEditor.cs @@ -1,6 +1,4 @@ #nullable enable -using System; - namespace UICatalog.Scenarios; /// @@ -57,11 +55,13 @@ public class AdornmentEditor : EditorBase _bottomEdit!.Value = _adornment.Thickness.Bottom; _rightEdit!.Value = _adornment.Thickness.Right; - _adornment.Initialized += (sender, args) => + _adornment.Initialized += (_, _) => { - Scheme? cs = _adornment.GetScheme (); - _foregroundColorPicker.SelectedColor = _adornment.GetAttributeForRole (VisualRole.Normal).Foreground.GetClosestNamedColor16 (); - _backgroundColorPicker.SelectedColor = _adornment.GetAttributeForRole (VisualRole.Normal).Background.GetClosestNamedColor16 (); + _foregroundColorPicker.SelectedColor = + _adornment.GetAttributeForRole (VisualRole.Normal).Foreground.GetClosestNamedColor16 (); + + _backgroundColorPicker.SelectedColor = + _adornment.GetAttributeForRole (VisualRole.Normal).Background.GetClosestNamedColor16 (); }; } @@ -125,12 +125,12 @@ public class AdornmentEditor : EditorBase _bottomEdit.ValueChanging += Bottom_ValueChanging; Add (_bottomEdit); - var copyTop = new Button + Button copyTop = new () { X = Pos.Center (), Y = Pos.Bottom (_bottomEdit), Text = "Cop_y Top" }; - copyTop.Accepting += (s, e) => + copyTop.Accepting += (_, _) => { AdornmentToEdit!.Thickness = new (_topEdit.Value); _leftEdit.Value = _rightEdit.Value = _bottomEdit.Value = _topEdit.Value; @@ -168,9 +168,9 @@ public class AdornmentEditor : EditorBase _diagThicknessCheckBox.CheckedState = Diagnostics.FastHasFlags (ViewDiagnosticFlags.Thickness) ? CheckState.Checked : CheckState.UnChecked; } - _diagThicknessCheckBox.CheckedStateChanging += (s, e) => + _diagThicknessCheckBox.CheckedStateChanging += (_, args) => { - if (e.Result == CheckState.Checked) + if (args.Result == CheckState.Checked) { AdornmentToEdit!.Diagnostics |= ViewDiagnosticFlags.Thickness; } @@ -194,9 +194,9 @@ public class AdornmentEditor : EditorBase _diagRulerCheckBox.CheckedState = Diagnostics.FastHasFlags (ViewDiagnosticFlags.Ruler) ? CheckState.Checked : CheckState.UnChecked; } - _diagRulerCheckBox.CheckedStateChanging += (s, e) => + _diagRulerCheckBox.CheckedStateChanging += (_, args) => { - if (e.Result == CheckState.Checked) + if (args.Result == CheckState.Checked) { AdornmentToEdit!.Diagnostics |= ViewDiagnosticFlags.Ruler; } @@ -212,18 +212,19 @@ public class AdornmentEditor : EditorBase private EventHandler> ColorPickerColorChanged () { - return (o, a) => + return (_, _) => { if (AdornmentToEdit is null) { return; } - AdornmentToEdit.SetScheme (new (AdornmentToEdit.GetScheme ()) - { - Normal = new (_foregroundColorPicker.SelectedColor, _backgroundColorPicker.SelectedColor) - }) - ; + AdornmentToEdit.SetScheme ( + new (AdornmentToEdit.GetScheme ()) + { + Normal = new (_foregroundColorPicker.SelectedColor, _backgroundColorPicker.SelectedColor) + }) + ; }; } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs index 30c3ffda4..b65169f5d 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs @@ -14,8 +14,6 @@ public class AdornmentsEditor : EditorBase TabStop = TabBehavior.TabGroup; - ExpanderButton!.Orientation = Orientation.Horizontal; - Initialized += AdornmentsEditor_Initialized; SchemeName = "Dialog"; @@ -28,8 +26,6 @@ public class AdornmentsEditor : EditorBase /// protected override void OnViewToEditChanged () { - //Enabled = ViewToEdit is not Adornment; - if (MarginEditor is { }) { MarginEditor.AdornmentToEdit = ViewToEdit?.Margin ?? null; @@ -47,7 +43,7 @@ public class AdornmentsEditor : EditorBase if (Padding is { }) { - Padding.Text = $"View: {GetIdentifyingString (ViewToEdit)}"; + Padding.Text = GetIdentifyingString (ViewToEdit); } } @@ -92,12 +88,17 @@ public class AdornmentsEditor : EditorBase private void AdornmentsEditor_Initialized (object? sender, EventArgs e) { + if (ExpanderButton is { }) + { + ExpanderButton.Orientation = Orientation.Horizontal; + } + MarginEditor = new () { X = -1, Y = 0, SuperViewRendersLineCanvas = true, - BorderStyle = LineStyle.Single + BorderStyle = BorderStyle }; MarginEditor.Border!.Thickness = MarginEditor.Border!.Thickness with { Bottom = 0 }; Add (MarginEditor); @@ -107,7 +108,7 @@ public class AdornmentsEditor : EditorBase X = Pos.Left (MarginEditor), Y = Pos.Bottom (MarginEditor), SuperViewRendersLineCanvas = true, - BorderStyle = LineStyle.Single + BorderStyle = BorderStyle }; BorderEditor.Border!.Thickness = BorderEditor.Border!.Thickness with { Bottom = 0 }; Add (BorderEditor); @@ -117,7 +118,7 @@ public class AdornmentsEditor : EditorBase X = Pos.Left (BorderEditor), Y = Pos.Bottom (BorderEditor), SuperViewRendersLineCanvas = true, - BorderStyle = LineStyle.Single + BorderStyle = BorderStyle }; PaddingEditor.Border!.Thickness = PaddingEditor.Border!.Thickness with { Bottom = 0 }; Add (PaddingEditor); diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs index f8f78ac6f..b495cc200 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs @@ -77,7 +77,7 @@ public class AllViewsView : View View? previousView = null; - foreach (Type? type in allClasses) + foreach (Type type in allClasses) { View? view = CreateView (type); @@ -118,15 +118,8 @@ public class AllViewsView : View // Check if the generic parameter has constraints Type [] constraints = arg.GetGenericParameterConstraints (); - if (constraints.Length > 0) - { - // Use the first constraint type to satisfy the constraint - typeArguments.Add (constraints [0]); - } - else - { - typeArguments.Add (typeof (object)); - } + // Use the first constraint type to satisfy the constraint + typeArguments.Add (constraints.Length > 0 ? constraints [0] : typeof (object)); } } @@ -193,17 +186,17 @@ public class AllViewsView : View return; } - if (view.Width == Dim.Absolute (0) || view.Width is null) + if (view.Width == Dim.Absolute (0)) { view.Width = Dim.Fill (); } - if (view.Height == Dim.Absolute (0) || view.Height is null) + if (view.Height == Dim.Absolute (0)) { view.Height = MAX_VIEW_FRAME_HEIGHT - 2; } - if (!view.Width!.Has (out _)) + if (!view.Width.Has (out _)) { view.Width = Dim.Fill (); } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs index ca962eb30..9f1f799cc 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs @@ -1,7 +1,6 @@ #nullable enable -using System; -using System.Collections.Generic; -using System.Linq; +using System.Reflection; +using Terminal.Gui.ViewBase; namespace UICatalog.Scenarios; @@ -33,10 +32,10 @@ public class BorderEditor : AdornmentEditor Y = Pos.Bottom (SubViews.ToArray () [^1]), Width = Dim.Fill (), - Value = ((Border)AdornmentToEdit!)?.LineStyle ?? LineStyle.None, + Value = (AdornmentToEdit as Border)?.LineStyle ?? LineStyle.None, BorderStyle = LineStyle.Single, Title = "Border St_yle", - SuperViewRendersLineCanvas = true, + SuperViewRendersLineCanvas = true }; Add (_osBorderStyle); @@ -49,7 +48,7 @@ public class BorderEditor : AdornmentEditor CheckedState = CheckState.Checked, SuperViewRendersLineCanvas = true, - Text = "Title", + Text = "Title" }; _ckbTitle.CheckedStateChanging += OnCkbTitleOnToggle; @@ -62,7 +61,7 @@ public class BorderEditor : AdornmentEditor CheckedState = CheckState.Checked, SuperViewRendersLineCanvas = true, - Text = "Gradient", + Text = "Gradient" }; _ckbGradient.CheckedStateChanging += OnCkbGradientOnToggle; @@ -72,51 +71,55 @@ public class BorderEditor : AdornmentEditor void OnRbBorderStyleOnValueChanged (object? s, EventArgs args) { - LineStyle prevBorderStyle = AdornmentToEdit!.BorderStyle; + if (AdornmentToEdit is not Border border) + { + return; + } if (args.Value is { }) { - ((Border)AdornmentToEdit).LineStyle = (LineStyle)args.Value; + border.LineStyle = (LineStyle)args.Value; } - if (((Border)AdornmentToEdit).LineStyle == LineStyle.None) - { - ((Border)AdornmentToEdit).Thickness = new (0); - } - else if (prevBorderStyle == LineStyle.None && ((Border)AdornmentToEdit).LineStyle != LineStyle.None) - { - ((Border)AdornmentToEdit).Thickness = new (1); - } - - ((Border)AdornmentToEdit).SetNeedsDraw (); + border.SetNeedsDraw (); SetNeedsLayout (); } void OnCkbTitleOnToggle (object? _, ResultEventArgs args) { + if (AdornmentToEdit is not Border border) + { + return; + } + if (args.Result == CheckState.Checked) { - ((Border)AdornmentToEdit!).Settings |= BorderSettings.Title; + border.Settings |= BorderSettings.Title; } else { - ((Border)AdornmentToEdit!).Settings &= ~BorderSettings.Title; + border.Settings &= ~BorderSettings.Title; } } void OnCkbGradientOnToggle (object? _, ResultEventArgs args) { + if (AdornmentToEdit is not Border border) + { + return; + } + if (args.Result == CheckState.Checked) { - ((Border)AdornmentToEdit!).Settings |= BorderSettings.Gradient; + border.Settings |= BorderSettings.Gradient; } else { - ((Border)AdornmentToEdit!).Settings &= ~BorderSettings.Gradient; + border.Settings &= ~BorderSettings.Gradient; } } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs index 7f1f795c9..b8e28730c 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/DimEditor.cs @@ -1,8 +1,5 @@ #nullable enable -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; namespace UICatalog.Scenarios; @@ -21,7 +18,7 @@ public class DimEditor : EditorBase private OptionSelector? _dimOptionSelector; private TextField? _valueEdit; - /// + /// protected override void OnViewToEditChanged () { if (ViewToEdit is { }) @@ -39,12 +36,11 @@ public class DimEditor : EditorBase return; } - Dim? dim; - dim = Dimension == Dimension.Width ? ViewToEdit.Width : ViewToEdit.Height; + Dim dim = Dimension == Dimension.Width ? ViewToEdit.Width : ViewToEdit.Height; try { - _dimOptionSelector!.Value = _dimNames.IndexOf (_dimNames.First (s => dim!.ToString ().StartsWith (s))); + _dimOptionSelector!.Value = _dimNames.IndexOf (_dimNames.First (s => dim.ToString ().StartsWith (s))); } catch (InvalidOperationException e) { @@ -53,31 +49,37 @@ public class DimEditor : EditorBase } _valueEdit!.Enabled = false; + switch (dim) { case DimAbsolute absolute: _valueEdit.Enabled = true; _value = absolute.Size; _valueEdit!.Text = _value.ToString (); + break; case DimFill fill: var margin = fill.Margin as DimAbsolute; _valueEdit.Enabled = margin is { }; _value = margin?.Size ?? 0; _valueEdit!.Text = _value.ToString (); + break; case DimFunc func: _valueEdit.Enabled = true; _value = func.Fn (null); _valueEdit!.Text = _value.ToString (); + break; case DimPercent percent: _valueEdit.Enabled = true; _value = percent.Percentage; _valueEdit!.Text = _value.ToString (); + break; default: - _valueEdit!.Text = dim!.ToString (); + _valueEdit!.Text = dim.ToString (); + break; } } @@ -94,6 +96,7 @@ public class DimEditor : EditorBase Add (label); _dimOptionSelector = new () { X = 0, Y = Pos.Bottom (label), Labels = _optionLabels }; _dimOptionSelector.ValueChanged += OnOptionSelectorOnValueChanged; + _valueEdit = new () { X = Pos.Right (label) + 1, @@ -102,30 +105,30 @@ public class DimEditor : EditorBase Text = $"{_value}" }; - _valueEdit.Accepting += (s, args) => - { - try - { - _value = int.Parse (_valueEdit.Text); - DimChanged (); - } - catch - { - // ignored - } - args.Handled = true; - }; + _valueEdit.Accepting += (_, args) => + { + try + { + _value = int.Parse (_valueEdit.Text); + DimChanged (); + } + catch + { + // ignored + } + + args.Handled = true; + }; Add (_valueEdit); Add (_dimOptionSelector); - } private void OnOptionSelectorOnValueChanged (object? s, EventArgs selected) { DimChanged (); } - // These need to have same order - private readonly List _dimNames = ["Absolute", "Auto", "Fill", "Func", "Percent",]; - private readonly string [] _optionLabels = ["Absolute(n)", "Auto", "Fill(n)", "Func(()=>n)", "Percent(n)",]; + // These need to have same order + private readonly List _dimNames = ["Absolute", "Auto", "Fill", "Func", "Percent"]; + private readonly string [] _optionLabels = ["Absolute(n)", "Auto", "Fill(n)", "Func(()=>n)", "Percent(n)"]; private void DimChanged () { @@ -136,15 +139,15 @@ public class DimEditor : EditorBase try { - Dim? dim = _dimOptionSelector!.Value switch - { - 0 => Dim.Absolute (_value), - 1 => Dim.Auto (), - 2 => Dim.Fill (_value), - 3 => Dim.Func (_ => _value), - 4 => Dim.Percent (_value), - _ => Dimension == Dimension.Width ? ViewToEdit.Width : ViewToEdit.Height - }; + Dim dim = _dimOptionSelector!.Value switch + { + 0 => Dim.Absolute (_value), + 1 => Dim.Auto (), + 2 => Dim.Fill (_value), + 3 => Dim.Func (_ => _value), + 4 => Dim.Percent (_value), + _ => Dimension == Dimension.Width ? ViewToEdit.Width : ViewToEdit.Height + }; if (Dimension == Dimension.Width) { diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs index b4c548d0c..9556be4a2 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs @@ -1,8 +1,4 @@ #nullable enable -using System; -using System.Diagnostics; -using System.Linq; - namespace UICatalog.Scenarios; public abstract class EditorBase : View @@ -19,36 +15,21 @@ public abstract class EditorBase : View Orientation = Orientation.Vertical }; - TabStop = TabBehavior.TabStop; Initialized += OnInitialized; void OnInitialized (object? sender, EventArgs e) { - if (Border is { }) - { - Border.Add (ExpanderButton); - - if (ExpanderButton.Orientation == Orientation.Vertical) - { - ExpanderButton.X = Pos.AnchorEnd () - 1; - } - else - { - ExpanderButton.Y = Pos.AnchorEnd () - 1; - } - } - - Application.MouseEvent += ApplicationOnMouseEvent; - Application.Navigation!.FocusedChanged += NavigationOnFocusedChanged; + Border?.Add (ExpanderButton); + App!.Mouse.MouseEvent += ApplicationOnMouseEvent; + App!.Navigation!.FocusedChanged += NavigationOnFocusedChanged; } AddCommand (Command.Accept, () => true); SchemeName = "Dialog"; - } private readonly ExpanderButton? _expanderButton; @@ -58,15 +39,16 @@ public abstract class EditorBase : View get => _expanderButton; init { - if (_expanderButton == value) + if (ReferenceEquals (_expanderButton, value)) { return; } + _expanderButton = value; } } - public bool UpdatingLayoutSettings { get; private set; } = false; + public bool UpdatingLayoutSettings { get; private set; } private void View_LayoutComplete (object? sender, LayoutEventArgs e) { @@ -77,7 +59,6 @@ public abstract class EditorBase : View UpdatingLayoutSettings = false; } - private View? _viewToEdit; public View? ViewToEdit @@ -90,7 +71,6 @@ public abstract class EditorBase : View return; } - if (value is null && _viewToEdit is { }) { _viewToEdit.SubViewsLaidOut -= View_LayoutComplete; @@ -127,7 +107,6 @@ public abstract class EditorBase : View /// public bool AutoSelectAdornments { get; set; } - private void NavigationOnFocusedChanged (object? sender, EventArgs e) { if (AutoSelectSuperView is null) @@ -135,17 +114,17 @@ public abstract class EditorBase : View return; } - if (ApplicationNavigation.IsInHierarchy (this, Application.Navigation!.GetFocused ())) + if (ApplicationNavigation.IsInHierarchy (this, App?.Navigation?.GetFocused ())) { return; } - if (!ApplicationNavigation.IsInHierarchy (AutoSelectSuperView, Application.Navigation!.GetFocused ())) + if (!ApplicationNavigation.IsInHierarchy (AutoSelectSuperView, App?.Navigation?.GetFocused ())) { return; } - ViewToEdit = Application.Navigation!.GetFocused (); + ViewToEdit = App!.Navigation!.GetFocused (); } private void ApplicationOnMouseEvent (object? sender, MouseEventArgs e) @@ -177,4 +156,16 @@ public abstract class EditorBase : View ViewToEdit = view; } } + + /// + protected override void Dispose (bool disposing) + { + if (disposing && App is {}) + { + App.Navigation!.FocusedChanged -= NavigationOnFocusedChanged; + App.Mouse.MouseEvent -= ApplicationOnMouseEvent; + } + + base.Dispose (disposing); + } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs index 4bdd61066..25acdb876 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EventLog.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Collections.ObjectModel; namespace UICatalog.Scenarios; @@ -19,8 +18,7 @@ public class EventLog : ListView X = Pos.AnchorEnd (); Y = 0; - Width = Dim.Func ( - _ => + Width = Dim.Func (_ => { if (!IsInitialized) { diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs index 13c2aee8a..2630703fa 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ExpanderButton.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Text; namespace UICatalog.Scenarios; @@ -43,14 +42,11 @@ public class ExpanderButton : Button Orientation = Orientation.Vertical; - HighlightStates = Terminal.Gui.ViewBase.MouseState.None; + HighlightStates = MouseState.In; Initialized += ExpanderButton_Initialized; - EnabledChanged += (sender, args) => - { - ShowHide (); - }; + EnabledChanged += (_, _) => { ShowHide (); }; } private void ShowHide () @@ -85,7 +81,7 @@ public class ExpanderButton : Button if (SuperView is Border { } border) { - border.ThicknessChanged += (o, args) => ShowHide (); + border.ThicknessChanged += (_, _) => ShowHide (); } } @@ -111,7 +107,7 @@ public class ExpanderButton : Button /// True of the event was cancelled. protected virtual bool OnOrientationChanging (Orientation newOrientation) { - CancelEventArgs args = new CancelEventArgs (in _orientation, ref newOrientation); + CancelEventArgs args = new (in _orientation, ref newOrientation); OrientationChanging?.Invoke (this, args); if (!args.Cancel) @@ -120,7 +116,7 @@ public class ExpanderButton : Button if (Orientation == Orientation.Vertical) { - X = Pos.AnchorEnd (); + X = Pos.AnchorEnd () - 1; Y = 0; CollapseGlyph = new ('\u21d1'); // ⇑ ExpandGlyph = new ('\u21d3'); // ⇓ @@ -128,7 +124,7 @@ public class ExpanderButton : Button else { X = 0; - Y = Pos.AnchorEnd (); + Y = Pos.AnchorEnd () - 1; CollapseGlyph = new ('\u21d0'); // ⇐ ExpandGlyph = new ('\u21d2'); // ⇒ } @@ -222,12 +218,12 @@ public class ExpanderButton : Button // Collapse if (Orientation == Orientation.Vertical) { - _previousDim = superView!.Height!; + _previousDim = superView.Height; superView.Height = 1; } else { - _previousDim = superView!.Width!; + _previousDim = superView.Width; superView.Width = 1; } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/LayoutEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/LayoutEditor.cs index cb84ac31d..a0034ee2a 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/LayoutEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/LayoutEditor.cs @@ -1,8 +1,4 @@ #nullable enable -using System; -using System.Collections.Generic; -using System.Linq; - namespace UICatalog.Scenarios; /// @@ -64,7 +60,6 @@ public class LayoutEditor : EditorBase X = Pos.Right (_xEditor) + 1 }; - _widthEditor = new () { Title = "_Width", diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs index 7d5d0f254..2904171d8 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs @@ -1,6 +1,4 @@ #nullable enable -using System; - namespace UICatalog.Scenarios; public class MarginEditor : AdornmentEditor @@ -34,7 +32,7 @@ public class MarginEditor : AdornmentEditor _optionsShadow = new () { X = 0, - Y = Pos.Bottom (SubViews.ElementAt(SubViews.Count-1)), + Y = Pos.Bottom (SubViews.ElementAt (SubViews.Count - 1)), SuperViewRendersLineCanvas = true, Title = "_Shadow", @@ -51,14 +49,14 @@ public class MarginEditor : AdornmentEditor Add (_optionsShadow); - _flagSelectorTransparent = new FlagSelector () + _flagSelectorTransparent = new FlagSelector { X = 0, Y = Pos.Bottom (_optionsShadow), SuperViewRendersLineCanvas = true, Title = "_ViewportSettings", - BorderStyle = LineStyle.Single, + BorderStyle = LineStyle.Single }; _flagSelectorTransparent.Values = [(int)ViewportSettingsFlags.Transparent, (int)ViewportSettingsFlags.TransparentMouse]; _flagSelectorTransparent.Labels = ["Transparent", "TransparentMouse"]; @@ -71,11 +69,6 @@ public class MarginEditor : AdornmentEditor _flagSelectorTransparent.Value = (int)((Margin)AdornmentToEdit).ViewportSettings; } - _flagSelectorTransparent.ValueChanged += (_, args) => - { - ((Margin)AdornmentToEdit!).ViewportSettings = (ViewportSettingsFlags)args.Value!; - }; - - + _flagSelectorTransparent.ValueChanged += (_, args) => { ((Margin)AdornmentToEdit!).ViewportSettings = (ViewportSettingsFlags)args.Value!; }; } -} \ No newline at end of file +} diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs index 467b54756..ae35161e6 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/PosEditor.cs @@ -1,8 +1,5 @@ #nullable enable -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; namespace UICatalog.Scenarios; @@ -102,7 +99,7 @@ public class PosEditor : EditorBase Text = $"{_value}" }; - _valueEdit.Accepting += (s, args) => + _valueEdit.Accepting += (_, args) => { try { @@ -123,7 +120,7 @@ public class PosEditor : EditorBase private void OnOptionSelectorOnValueChanged (object? s, EventArgs selected) { PosChanged (); } - // These need to have same order + // These need to have same order private readonly List _posNames = ["Absolute", "Align", "AnchorEnd", "Center", "Func", "Percent"]; private readonly string [] _optionLabels = ["Absolute(n)", "Align", "AnchorEnd", "Center", "Func(()=>n)", "Percent(n)"]; @@ -136,7 +133,7 @@ public class PosEditor : EditorBase try { - Pos? pos = _posOptionSelector!.Value switch + Pos pos = _posOptionSelector!.Value switch { 0 => Pos.Absolute (_value), 1 => Pos.Align (Alignment.Start), diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewPropertiesEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewPropertiesEditor.cs index cfca9f433..5b6679bbf 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewPropertiesEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewPropertiesEditor.cs @@ -20,7 +20,7 @@ public class ViewPropertiesEditor : EditorBase CheckedState = ViewToEdit is { } ? ViewToEdit.CanFocus ? CheckState.Checked : CheckState.UnChecked : CheckState.UnChecked }; - _canFocusCheckBox.CheckedStateChanged += (s, args) => + _canFocusCheckBox.CheckedStateChanged += (_, _) => { if (ViewToEdit is { }) { @@ -37,7 +37,7 @@ public class ViewPropertiesEditor : EditorBase CheckedState = ViewToEdit is { } ? ViewToEdit.Enabled ? CheckState.Checked : CheckState.UnChecked : CheckState.UnChecked }; - _enabledCheckBox.CheckedStateChanged += (s, args) => + _enabledCheckBox.CheckedStateChanged += (_, _) => { if (ViewToEdit is { }) { @@ -55,13 +55,13 @@ public class ViewPropertiesEditor : EditorBase Orientation = Orientation.Horizontal }; - _orientationOptionSelector.ValueChanged += (s, selected) => - { - if (ViewToEdit is IOrientation orientatedView) - { - orientatedView.Orientation = _orientationOptionSelector.Value!.Value; - } - }; + _orientationOptionSelector.ValueChanged += (_, _) => + { + if (ViewToEdit is IOrientation orientatedView) + { + orientatedView.Orientation = _orientationOptionSelector.Value!.Value; + } + }; Add (label, _orientationOptionSelector); label = new () { X = 0, Y = Pos.Bottom (_orientationOptionSelector), Text = "Text:" }; @@ -75,7 +75,7 @@ public class ViewPropertiesEditor : EditorBase Text = "This is demo text" }; - _text.ContentsChanged += (s, e) => + _text.ContentsChanged += (_, _) => { if (ViewToEdit is { }) { @@ -90,15 +90,7 @@ public class ViewPropertiesEditor : EditorBase public string DemoText { - get - { - if (_text is null) - { - return string.Empty; - } - - return _text!.Text; - } + get => _text is null ? string.Empty : _text!.Text; set => _text!.Text = value; } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs index e54835989..b7a86feaf 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs @@ -1,6 +1,4 @@ #nullable enable -using System; - namespace UICatalog.Scenarios; /// @@ -60,8 +58,8 @@ public sealed class ViewportSettingsEditor : EditorBase : CheckState.UnChecked; _cbTransparentMouse!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse) - ? CheckState.Checked - : CheckState.UnChecked; + ? CheckState.Checked + : CheckState.UnChecked; _cbVerticalScrollBar!.CheckedState = ViewToEdit.VerticalScrollBar.Visible ? CheckState.Checked : CheckState.UnChecked; _cbAutoShowVerticalScrollBar!.CheckedState = ViewToEdit.VerticalScrollBar.AutoShow ? CheckState.Checked : CheckState.UnChecked; @@ -115,27 +113,27 @@ public sealed class ViewportSettingsEditor : EditorBase Add (_cbAllowXGreaterThanContentWidth); - void AllowNegativeXToggle (object? sender, ResultEventArgs e) + void AllowNegativeXToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowNegativeX; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.AllowNegativeX; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowNegativeX; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.AllowNegativeX; } } - void AllowXGreaterThanContentWidthToggle (object? sender, ResultEventArgs e) + void AllowXGreaterThanContentWidthToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowXGreaterThanContentWidth; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.AllowXGreaterThanContentWidth; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowXGreaterThanContentWidth; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.AllowXGreaterThanContentWidth; } } @@ -153,27 +151,27 @@ public sealed class ViewportSettingsEditor : EditorBase Add (_cbAllowYGreaterThanContentHeight); - void AllowNegativeYToggle (object? sender, ResultEventArgs e) + void AllowNegativeYToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowNegativeY; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.AllowNegativeY; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowNegativeY; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.AllowNegativeY; } } - void AllowYGreaterThanContentHeightToggle (object? sender, ResultEventArgs e) + void AllowYGreaterThanContentHeightToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowYGreaterThanContentHeight; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.AllowYGreaterThanContentHeight; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.AllowYGreaterThanContentHeight; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.AllowYGreaterThanContentHeight; } } @@ -193,17 +191,16 @@ public sealed class ViewportSettingsEditor : EditorBase }; _contentSizeWidth.ValueChanging += ContentSizeWidthValueChanged; - void ContentSizeWidthValueChanged (object? sender, CancelEventArgs e) + void ContentSizeWidthValueChanged (object? sender, CancelEventArgs cea) { - if (e.NewValue < 0) + if (cea.NewValue < 0) { - e.Cancel = true; + cea.Cancel = true; return; } - // BUGBUG: set_ContentSize is supposed to be `protected`. - ViewToEdit!.SetContentSize (ViewToEdit.GetContentSize () with { Width = e.NewValue }); + ViewToEdit!.SetContentSize (ViewToEdit.GetContentSize () with { Width = cea.NewValue }); } var labelComma = new Label @@ -221,17 +218,16 @@ public sealed class ViewportSettingsEditor : EditorBase }; _contentSizeHeight.ValueChanging += ContentSizeHeightValueChanged; - void ContentSizeHeightValueChanged (object? sender, CancelEventArgs e) + void ContentSizeHeightValueChanged (object? sender, CancelEventArgs cea) { - if (e.NewValue < 0) + if (cea.NewValue < 0) { - e.Cancel = true; + cea.Cancel = true; return; } - // BUGBUG: set_ContentSize is supposed to be `protected`. - ViewToEdit?.SetContentSize (ViewToEdit.GetContentSize () with { Height = e.NewValue }); + ViewToEdit?.SetContentSize (ViewToEdit.GetContentSize () with { Height = cea.NewValue }); } _cbClearContentOnly = new () @@ -243,15 +239,15 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbClearContentOnly.CheckedStateChanging += ClearContentOnlyToggle; - void ClearContentOnlyToggle (object? sender, ResultEventArgs e) + void ClearContentOnlyToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.ClearContentOnly; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.ClearContentOnly; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.ClearContentOnly; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.ClearContentOnly; } } @@ -264,15 +260,15 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbClipContentOnly.CheckedStateChanging += ClipContentOnlyToggle; - void ClipContentOnlyToggle (object? sender, ResultEventArgs e) + void ClipContentOnlyToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.ClipContentOnly; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.ClipContentOnly; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.ClipContentOnly; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.ClipContentOnly; } } @@ -285,15 +281,15 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbTransparent.CheckedStateChanging += TransparentToggle; - void TransparentToggle (object? sender, ResultEventArgs e) + void TransparentToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.Transparent; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.Transparent; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.Transparent; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.Transparent; } } @@ -306,15 +302,15 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbTransparentMouse.CheckedStateChanging += TransparentMouseToggle; - void TransparentMouseToggle (object? sender, ResultEventArgs e) + void TransparentMouseToggle (object? sender, ResultEventArgs rea) { - if (e.Result == CheckState.Checked) + if (rea.Result == CheckState.Checked) { - ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewBase.ViewportSettingsFlags.TransparentMouse; + ViewToEdit!.ViewportSettings |= ViewportSettingsFlags.TransparentMouse; } else { - ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewBase.ViewportSettingsFlags.TransparentMouse; + ViewToEdit!.ViewportSettings &= ~ViewportSettingsFlags.TransparentMouse; } } @@ -327,9 +323,9 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbVerticalScrollBar.CheckedStateChanging += VerticalScrollBarToggle; - void VerticalScrollBarToggle (object? sender, ResultEventArgs e) + void VerticalScrollBarToggle (object? sender, ResultEventArgs rea) { - ViewToEdit!.VerticalScrollBar.Visible = e.Result == CheckState.Checked; + ViewToEdit!.VerticalScrollBar.Visible = rea.Result == CheckState.Checked; } _cbAutoShowVerticalScrollBar = new () @@ -341,9 +337,9 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbAutoShowVerticalScrollBar.CheckedStateChanging += AutoShowVerticalScrollBarToggle; - void AutoShowVerticalScrollBarToggle (object? sender, ResultEventArgs e) + void AutoShowVerticalScrollBarToggle (object? sender, ResultEventArgs rea) { - ViewToEdit!.VerticalScrollBar.AutoShow = e.Result == CheckState.Checked; + ViewToEdit!.VerticalScrollBar.AutoShow = rea.Result == CheckState.Checked; } _cbHorizontalScrollBar = new () @@ -355,9 +351,9 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbHorizontalScrollBar.CheckedStateChanging += HorizontalScrollBarToggle; - void HorizontalScrollBarToggle (object? sender, ResultEventArgs e) + void HorizontalScrollBarToggle (object? sender, ResultEventArgs rea) { - ViewToEdit!.HorizontalScrollBar.Visible = e.Result == CheckState.Checked; + ViewToEdit!.HorizontalScrollBar.Visible = rea.Result == CheckState.Checked; } _cbAutoShowHorizontalScrollBar = new () @@ -369,9 +365,9 @@ public sealed class ViewportSettingsEditor : EditorBase }; _cbAutoShowHorizontalScrollBar.CheckedStateChanging += AutoShowHorizontalScrollBarToggle; - void AutoShowHorizontalScrollBarToggle (object? sender, ResultEventArgs e) + void AutoShowHorizontalScrollBarToggle (object? sender, ResultEventArgs rea) { - ViewToEdit!.HorizontalScrollBar.AutoShow = e.Result == CheckState.Checked; + ViewToEdit!.HorizontalScrollBar.AutoShow = rea.Result == CheckState.Checked; } Add ( diff --git a/Examples/UICatalog/Scenarios/ShadowStyles.cs b/Examples/UICatalog/Scenarios/ShadowStyles.cs index 25a2a335c..2ca3002d5 100644 --- a/Examples/UICatalog/Scenarios/ShadowStyles.cs +++ b/Examples/UICatalog/Scenarios/ShadowStyles.cs @@ -62,6 +62,22 @@ public class ShadowStyles : Scenario shadowWindow.Add (buttonInWin); app.Add (shadowWindow); + Window shadowWindow2 = new () + { + + Id = "shadowWindow2", + X = Pos.Right (editor) + 10, + Y = 10, + Width = Dim.Percent (30), + Height = Dim.Percent (30), + Title = "Shadow Window #2", + Arrangement = ViewArrangement.Movable | ViewArrangement.Overlapped, + BorderStyle = LineStyle.Double, + ShadowStyle = ShadowStyle.Transparent, + }; + app.Add (shadowWindow2); + + var button = new Button { Id = "button", @@ -69,6 +85,7 @@ public class ShadowStyles : Scenario Y = Pos.Center (), Text = "Button", ShadowStyle = ShadowStyle.Opaque }; + button.Accepting += ButtonOnAccepting; ColorPicker colorPicker = new () { @@ -77,12 +94,12 @@ public class ShadowStyles : Scenario Id = "colorPicker16", X = Pos.Center (), Y = Pos.AnchorEnd (), - Width = Dim.Percent(80), + Width = Dim.Percent (80), }; colorPicker.ColorChanged += (sender, args) => { var normal = app.GetScheme ().Normal; - app.SetScheme (app.GetScheme() with {Normal = new Attribute(normal.Foreground, args.Result)}); + app.SetScheme (app.GetScheme () with { Normal = new Attribute (normal.Foreground, args.Result) }); }; app.Add (button, colorPicker); @@ -96,4 +113,10 @@ public class ShadowStyles : Scenario Application.Shutdown (); } + + private void ButtonOnAccepting (object sender, CommandEventArgs e) + { + MessageBox.Query ((sender as View)?.App, "Hello", "You pushed the button!"); + e.Handled = true; + } } diff --git a/Examples/UICatalog/Scenarios/ViewportSettings.cs b/Examples/UICatalog/Scenarios/ViewportSettings.cs index b4934a064..4a3f66c4f 100644 --- a/Examples/UICatalog/Scenarios/ViewportSettings.cs +++ b/Examples/UICatalog/Scenarios/ViewportSettings.cs @@ -108,6 +108,7 @@ public class ViewportSettings : Scenario var adornmentsEditor = new AdornmentsEditor { + BorderStyle = LineStyle.Single, X = Pos.AnchorEnd (), AutoSelectViewToEdit = true, ShowViewIdentifier = true @@ -224,6 +225,7 @@ public class ViewportSettings : Scenario view.Initialized += (s, e) => { viewportSettingsEditor.ViewToEdit = view; + adornmentsEditor.ViewToEdit = view; }; view.SetFocus (); Application.Run (app); diff --git a/Examples/UICatalog/Scenarios/WideGlyphs.cs b/Examples/UICatalog/Scenarios/WideGlyphs.cs index 16e30b723..9b2e80d26 100644 --- a/Examples/UICatalog/Scenarios/WideGlyphs.cs +++ b/Examples/UICatalog/Scenarios/WideGlyphs.cs @@ -24,6 +24,31 @@ public sealed class WideGlyphs : Scenario BorderStyle = LineStyle.None }; + // Add Editors + + AdornmentsEditor adornmentsEditor = new () + { + BorderStyle = LineStyle.Single, + X = Pos.AnchorEnd (), + AutoSelectViewToEdit = true, + AutoSelectAdornments = false, + ShowViewIdentifier = true + }; + adornmentsEditor.ExpanderButton.Accepting += (sender, args) => + { + //adornmentsEditor.ExpanderButton.Collapsed = args.NewValue; + }; + appWindow.Add (adornmentsEditor); + + ViewportSettingsEditor viewportSettingsEditor = new () + { + BorderStyle = LineStyle.Single, + Y = Pos.AnchorEnd (), + X = Pos.AnchorEnd (), + AutoSelectViewToEdit = true, + }; + appWindow.Add (viewportSettingsEditor); + // Build the array of codepoints once when subviews are laid out appWindow.SubViewsLaidOut += (s, _) => { @@ -53,7 +78,7 @@ public sealed class WideGlyphs : Scenario // Fill the window with the pre-built codepoints array // For detailed documentation on the draw code flow from Application.Run to this event, // see WideGlyphs.DrawFlow.md in this directory - appWindow.DrawingContent += (s, _) => + appWindow.DrawingContent += (s, e) => { View? view = s as View; if (view is null || _codepoints is null) @@ -73,6 +98,7 @@ public sealed class WideGlyphs : Scenario } } } + e.DrawContext?.AddDrawnRectangle (view.Viewport); }; Line verticalLineAtEven = new () @@ -99,12 +125,12 @@ public sealed class WideGlyphs : Scenario Y = 5, Width = 15, Height = 5, - //BorderStyle = LineStyle.Dashed, + //BorderStyle = LineStyle.Dashed }; // Proves it's not LineCanvas related arrangeableViewAtEven!.Border!.Thickness = new (1); - arrangeableViewAtEven.Border.Add(new View () { Height = Dim.Auto(), Width = Dim.Auto(), Text = "Even" }); + arrangeableViewAtEven.Border.Add (new View () { Height = Dim.Auto (), Width = Dim.Auto (), Text = "Even" }); appWindow.Add (arrangeableViewAtEven); View arrangeableViewAtOdd = new () @@ -117,6 +143,7 @@ public sealed class WideGlyphs : Scenario Height = 5, BorderStyle = LineStyle.Dashed, }; + appWindow.Add (arrangeableViewAtOdd); var superView = new View @@ -124,8 +151,8 @@ public sealed class WideGlyphs : Scenario CanFocus = true, X = 30, // on an even column to start Y = Pos.Center (), - Width = Dim.Auto () + 4, - Height = Dim.Auto () + 1, + Width = Dim.Auto (), + Height = Dim.Auto (), BorderStyle = LineStyle.Single, Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable }; diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 9f8e0a9b8..8926f2423 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -36,7 +36,7 @@ internal partial class ApplicationImpl public event EventHandler>? Iteration; /// - public void RaiseIteration () { Iteration?.Invoke (null, new (this)); } + public void RaiseIteration () { Iteration?.Invoke (this, new (this)); } #endregion Main Loop Iteration diff --git a/Terminal.Gui/App/ApplicationNavigation.cs b/Terminal.Gui/App/ApplicationNavigation.cs index 871bd3691..a852f642e 100644 --- a/Terminal.Gui/App/ApplicationNavigation.cs +++ b/Terminal.Gui/App/ApplicationNavigation.cs @@ -89,7 +89,7 @@ public class ApplicationNavigation _focused = value; - FocusedChanged?.Invoke (null, EventArgs.Empty); + FocusedChanged?.Invoke (this, EventArgs.Empty); } /// diff --git a/Terminal.Gui/App/CWP/CWPEventHelper.cs b/Terminal.Gui/App/CWP/CWPEventHelper.cs index 4840a358c..72fd9f7b5 100644 --- a/Terminal.Gui/App/CWP/CWPEventHelper.cs +++ b/Terminal.Gui/App/CWP/CWPEventHelper.cs @@ -49,6 +49,7 @@ public static class CWPEventHelper return false; } + // BUGBUG: This should pass this not null; need to test eventHandler.Invoke (null, args); return args.Handled; } diff --git a/Terminal.Gui/App/CWP/CWPPropertyHelper.cs b/Terminal.Gui/App/CWP/CWPPropertyHelper.cs index 09bcd7fa0..d7095fd7e 100644 --- a/Terminal.Gui/App/CWP/CWPPropertyHelper.cs +++ b/Terminal.Gui/App/CWP/CWPPropertyHelper.cs @@ -84,6 +84,7 @@ public static class CWPPropertyHelper } } + // BUGBUG: This should pass this not null; need to test changingEvent?.Invoke (null, args); if (args.Handled) @@ -100,13 +101,14 @@ public static class CWPPropertyHelper } finalValue = args.NewValue; - + // Do the work (set backing field, update related properties, etc.) BEFORE raising Changed events doWork (finalValue); - + ValueChangedEventArgs changedArgs = new (currentValue, finalValue); currentValue = finalValue; onChanged?.Invoke (changedArgs); + // BUGBUG: This should pass this not null; need to test changedEvent?.Invoke (null, changedArgs); return true; diff --git a/Terminal.Gui/App/CWP/CWPWorkflowHelper.cs b/Terminal.Gui/App/CWP/CWPWorkflowHelper.cs index 4c0328589..061df53d9 100644 --- a/Terminal.Gui/App/CWP/CWPWorkflowHelper.cs +++ b/Terminal.Gui/App/CWP/CWPWorkflowHelper.cs @@ -53,6 +53,7 @@ public static class CWPWorkflowHelper return true; } + // BUGBUG: This should pass this not null; need to test eventHandler?.Invoke (null, args); if (args.Handled) { @@ -112,6 +113,7 @@ public static class CWPWorkflowHelper return args.Result!; } + // BUGBUG: This should pass this not null; need to test eventHandler?.Invoke (null, args); if (!args.Handled) diff --git a/Terminal.Gui/App/Keyboard/KeyboardImpl.cs b/Terminal.Gui/App/Keyboard/KeyboardImpl.cs index dbdd2d67c..40041c974 100644 --- a/Terminal.Gui/App/Keyboard/KeyboardImpl.cs +++ b/Terminal.Gui/App/Keyboard/KeyboardImpl.cs @@ -160,7 +160,7 @@ internal class KeyboardImpl : IKeyboard, IDisposable //#endif // TODO: This should match standard event patterns - KeyDown?.Invoke (null, key); + KeyDown?.Invoke (this, key); if (key.Handled) { @@ -216,7 +216,7 @@ internal class KeyboardImpl : IKeyboard, IDisposable return true; } - KeyUp?.Invoke (null, key); + KeyUp?.Invoke (this, key); if (key.Handled) { diff --git a/Terminal.Gui/App/Mouse/MouseImpl.cs b/Terminal.Gui/App/Mouse/MouseImpl.cs index 59b5d16f9..355c72879 100644 --- a/Terminal.Gui/App/Mouse/MouseImpl.cs +++ b/Terminal.Gui/App/Mouse/MouseImpl.cs @@ -86,7 +86,7 @@ internal class MouseImpl : IMouse, IDisposable mouseEvent.View = deepestViewUnderMouse; } - MouseEvent?.Invoke (null, mouseEvent); + MouseEvent?.Invoke (this, mouseEvent); if (mouseEvent.Handled) { diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index e3f73a543..a2ec2772f 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -416,6 +416,7 @@ True True True + True True True From 4c772bd5f3d5403026ca9a3ad81c620891ca0ba5 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 15 Dec 2025 09:26:52 -0700 Subject: [PATCH 4/6] Fixes #4497 - makes replacement char conifgurable (#4498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- Examples/UICatalog/Resources/config.json | 3 +- Examples/UICatalog/Scenarios/WideGlyphs.cs | 4 - Terminal.Gui/Drawing/Glyphs.cs | 5 + Terminal.Gui/Drivers/DriverImpl.cs | 3 + Terminal.Gui/Drivers/IDriver.cs | 8 +- Terminal.Gui/Drivers/IOutputBuffer.cs | 14 +- Terminal.Gui/Drivers/OutputBufferImpl.cs | 22 +- Terminal.Gui/ViewBase/View.Mouse.cs | 10 +- Tests/UnitTests/View/Draw/ClipTests.cs | 5 +- .../Drivers/AddRuneTests.cs | 6 +- .../Drivers/DriverTests.cs | 6 +- .../Drivers/OutputBaseTests.cs | 9 +- .../Drivers/OutputBufferWideCharTests.cs | 195 +++++++++++++++++- .../ViewBase/Draw/ViewDrawingClippingTests.cs | 34 +-- 14 files changed, 270 insertions(+), 54 deletions(-) diff --git a/Examples/UICatalog/Resources/config.json b/Examples/UICatalog/Resources/config.json index 74586e878..cc7b5009f 100644 --- a/Examples/UICatalog/Resources/config.json +++ b/Examples/UICatalog/Resources/config.json @@ -9,6 +9,7 @@ "Themes": [ { "Hot Dog Stand": { + "Glyphs.WideGlyphReplacement": "①", "Schemes": [ { "Runnable": { @@ -134,7 +135,7 @@ } }, { - "UI Catalog Theme": { + "UI Catalog": { "Window.DefaultShadow": "Transparent", "Button.DefaultShadow": "None", "CheckBox.DefaultHighlightStates": "In, Pressed, PressedOutside", diff --git a/Examples/UICatalog/Scenarios/WideGlyphs.cs b/Examples/UICatalog/Scenarios/WideGlyphs.cs index 9b2e80d26..771355f1a 100644 --- a/Examples/UICatalog/Scenarios/WideGlyphs.cs +++ b/Examples/UICatalog/Scenarios/WideGlyphs.cs @@ -34,10 +34,6 @@ public sealed class WideGlyphs : Scenario AutoSelectAdornments = false, ShowViewIdentifier = true }; - adornmentsEditor.ExpanderButton.Accepting += (sender, args) => - { - //adornmentsEditor.ExpanderButton.Collapsed = args.NewValue; - }; appWindow.Add (adornmentsEditor); ViewportSettingsEditor viewportSettingsEditor = new () diff --git a/Terminal.Gui/Drawing/Glyphs.cs b/Terminal.Gui/Drawing/Glyphs.cs index 71336009d..f83666006 100644 --- a/Terminal.Gui/Drawing/Glyphs.cs +++ b/Terminal.Gui/Drawing/Glyphs.cs @@ -26,6 +26,11 @@ public class Glyphs // IMPORTANT: Configuration Manager test SaveDefaults uses this class to generate the default config file // IMPORTANT: in ./UnitTests/bin/Debug/netX.0/config.json + /// 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). + [ConfigurationProperty (Scope = typeof (ThemeScope))] + public static Rune WideGlyphReplacement { get; set; } = (Rune)' '; + /// File icon. Defaults to ☰ (Trigram For Heaven) [ConfigurationProperty (Scope = typeof (ThemeScope))] public static Rune File { get; set; } = (Rune)'☰'; diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 2368dd631..ed61da17a 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -146,6 +146,9 @@ internal class DriverImpl : IDriver private readonly IOutput _output; + /// + public IOutputBuffer GetOutputBuffer () => OutputBuffer; + public IOutput GetOutput () => _output; private readonly IInputProcessor _inputProcessor; diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index 41ff4d091..eda8841c8 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -64,7 +64,13 @@ public interface IDriver : IDisposable IInputProcessor GetInputProcessor (); /// - /// Gets the output handler responsible for writing to the terminal. + /// Gets the containing the buffered screen contents. + /// + /// + IOutputBuffer GetOutputBuffer (); + + /// + /// Gets the responsible for writing to the terminal. /// IOutput GetOutput (); diff --git a/Terminal.Gui/Drivers/IOutputBuffer.cs b/Terminal.Gui/Drivers/IOutputBuffer.cs index 3344d0ba8..cf096b834 100644 --- a/Terminal.Gui/Drivers/IOutputBuffer.cs +++ b/Terminal.Gui/Drivers/IOutputBuffer.cs @@ -1,5 +1,4 @@ - -namespace Terminal.Gui.Drivers; +namespace Terminal.Gui.Drivers; /// /// 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. /// int Top { get; set; } + + /// + /// 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 . + /// + /// + /// 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). + /// + void SetWideGlyphReplacement (Rune column1ReplacementChar); } diff --git a/Terminal.Gui/Drivers/OutputBufferImpl.cs b/Terminal.Gui/Drivers/OutputBufferImpl.cs index be03a26c6..9f0b074fe 100644 --- a/Terminal.Gui/Drivers/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/OutputBufferImpl.cs @@ -65,6 +65,14 @@ public class OutputBufferImpl : IOutputBuffer /// The topmost row in the terminal. public virtual int Top { get; set; } = 0; + private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; + + /// + public void SetWideGlyphReplacement (Rune column1ReplacementChar) + { + _column1ReplacementChar = column1ReplacementChar; + } + /// /// Indicates which lines have been modified and need to be redrawn. /// @@ -205,7 +213,7 @@ public class OutputBufferImpl : IOutputBuffer { 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; } } @@ -273,17 +281,7 @@ public class OutputBufferImpl : IOutputBuffer if (!Clip!.Contains (col + 1, row)) { // Second column is outside clip - can't fit wide char here - Contents! [row, col].Grapheme = Rune.ReplacementChar.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; - } + Contents! [row, col].Grapheme = _column1ReplacementChar.ToString (); } else { diff --git a/Terminal.Gui/ViewBase/View.Mouse.cs b/Terminal.Gui/ViewBase/View.Mouse.cs index a1df6dd59..88cc4579d 100644 --- a/Terminal.Gui/ViewBase/View.Mouse.cs +++ b/Terminal.Gui/ViewBase/View.Mouse.cs @@ -256,7 +256,7 @@ public partial class View // Mouse APIs /// /// /// Invokes commands bound to mouse clicks via - /// (default: event) + /// (default: event) /// /// /// @@ -295,7 +295,7 @@ public partial class View // Mouse APIs /// /// /// - /// + /// /// /// public bool? NewMouseEvent (MouseEventArgs mouseEvent) @@ -414,8 +414,8 @@ public partial class View // Mouse APIs /// /// INTERNAL: For cases where the view is grabbed and the mouse is pressed, this method handles the pressed events from /// the driver. - /// When is set, this method will raise the Clicked/Selecting event - /// via each time it is called (after the first time the mouse is pressed). + /// When is set, this method will raise the Clicked/Activating event + /// via each time it is called (after the first time the mouse is pressed). /// /// /// , if processing should stop, otherwise. @@ -531,7 +531,7 @@ public partial class View // Mouse APIs /// /// INTERNAL API: Converts mouse click events into s by invoking the commands bound /// to the mouse button via . By default, all mouse clicks are bound to - /// which raises the event. + /// which raises the event. /// protected bool RaiseCommandsBoundToMouse (MouseEventArgs args) { diff --git a/Tests/UnitTests/View/Draw/ClipTests.cs b/Tests/UnitTests/View/Draw/ClipTests.cs index 565795f85..bbeff3518 100644 --- a/Tests/UnitTests/View/Draw/ClipTests.cs +++ b/Tests/UnitTests/View/Draw/ClipTests.cs @@ -178,6 +178,7 @@ public class ClipTests (ITestOutputHelper _output) public void Clipping_Wide_Runes () { Application.Driver!.SetScreenSize (30, 1); + Application.Driver!.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); 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 // │こ れ は 広 い ル ー ン ラ イ ン で す 。 // 01 2345678901234 56 78 90 12 34 56 - // │� |0123456989│� ン ラ イ ン で す 。 + // │① |0123456989│① ン ラ イ ン で す 。 expectedOutput = """ - │�│0123456789│ ンラインです。 + │①│0123456789│ ンラインです。 """; DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output); diff --git a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs index 4ec35f770..522f917c6 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs @@ -181,9 +181,9 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase { IDriver? driver = CreateFakeDriver (); driver.SetScreenSize (6, 3); + driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); driver!.Clip = new (driver.Screen); - driver.Move (1, 0); driver.AddStr ("┌"); driver.Move (2, 0); @@ -197,14 +197,14 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase DriverAssert.AssertDriverContentsAre ( """ - �┌─┐🍎 + ①┌─┐🍎 """, output, driver); driver.Refresh (); - 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", + 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); } } diff --git a/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs b/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs index 9d88eb730..371331deb 100644 --- a/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Text; using UnitTests; using Xunit.Abstractions; @@ -104,6 +105,7 @@ public class DriverTests (ITestOutputHelper output) : FakeDriverBase IApplication? app = Application.Create (); app.Init (driverName); IDriver driver = app.Driver!; + driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); // Need to force "windows" driver to override legacy console mode for this test driver.IsLegacyConsole = false; @@ -127,14 +129,14 @@ public class DriverTests (ITestOutputHelper output) : FakeDriverBase DriverAssert.AssertDriverContentsAre ( """ - �┌─┐🍎 + ①┌─┐🍎 """, output, driver); driver.Refresh (); - 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", + 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); } } diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs index 19c6ddcec..9931551b0 100644 --- a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs @@ -1,4 +1,7 @@ -namespace DriverTests; +using System.Text; +using Terminal.Gui.Drivers; + +namespace DriverTests; public class OutputBaseTests { @@ -161,6 +164,8 @@ public class OutputBaseTests // FakeOutput exposes this because it's in test scope var output = new FakeOutput { IsLegacyConsole = isLegacyConsole }; IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetWideGlyphReplacement ((Rune)'①'); + buffer.SetSize (3, 1); // Write '🦮' at col 0 and 'A' at col 2 @@ -209,7 +214,7 @@ public class OutputBaseTests output.Write (buffer); - Assert.Contains ("�", output.GetLastOutput ()); + Assert.Contains ("①", output.GetLastOutput ()); Assert.Contains ("X", output.GetLastOutput ()); // Dirty flags cleared for the written cells diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs index d8da44b65..4e6a596af 100644 --- a/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/OutputBufferWideCharTests.cs @@ -1,14 +1,14 @@ using System.Text; -using Xunit.Abstractions; namespace DriverTests; /// /// Tests for https://github.com/gui-cs/Terminal.Gui/issues/4466. /// These tests validate that FillRect properly handles wide characters when overlapping existing content. -/// Specifically, they ensure that wide characters are properly invalidated and replaced when a MessageBox border or similar UI element is drawn over them, preventing visual corruption. +/// 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. /// -public class OutputBufferWideCharTests (ITestOutputHelper output) +public class OutputBufferWideCharTests { /// /// 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 // because we're overwriting its second column Assert.True (buffer.Contents [1, 2].IsDirty, "Wide char at col 2 should be invalidated when its second column is overwritten"); - Assert.Equal (buffer.Contents [1, 2].Grapheme, Rune.ReplacementChar.ToString ()); + Assert.Equal (buffer.Contents [1, 2].Grapheme, Glyphs.WideGlyphReplacement.ToString ()); Assert.Equal ("│", buffer.Contents [1, 3].Grapheme); 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 // 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"); // The border should be drawn at col 8 @@ -356,4 +356,189 @@ public class OutputBufferWideCharTests (ITestOutputHelper output) buffer.Contents [2, 7].IsDirty, "Adjacent cell should be dirty after wide char replacement"); } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// Tests that when both columns of a wide character are inside the clip, + /// the character is drawn normally without replacement characters. + /// + [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); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs index 7774d1886..e24280295 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs @@ -574,6 +574,7 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas }; superView.Add (viewWithBorderAtX0, viewWithBorderAtX1, viewWithBorderAtX2); + driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); app.Begin (superView); // Begin calls LayoutAndDraw, so no need to call it again here // app.LayoutAndDraw(); @@ -585,9 +586,9 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas ┆viewWithBorderAtX0┆🍎🍎🍎 └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 - �┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎 - �┆viewWithBorderAtX1┆ 🍎🍎 - �└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ 🍎🍎 + ①┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎 + ①┆viewWithBorderAtX1┆ 🍎🍎 + ①└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ 🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎 🍎┆viewWithBorderAtX2┆🍎🍎 @@ -597,7 +598,7 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas output, 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�┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎\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", + 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); DriverImpl? driverImpl = driver as DriverImpl; @@ -617,9 +618,9 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas ┆viewWithBorderAtX0┆🍎🍎🍎 └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘🍎🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 - �┌──────────────────┐ 🍎🍎 - �│viewWithBorderAtX1│ 🍎🍎 - �└──────────────────┘ 🍎🍎 + ①┌──────────────────┐ 🍎🍎 + ①│viewWithBorderAtX1│ 🍎🍎 + ①└──────────────────┘ 🍎🍎 🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎 🍎┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐🍎🍎 🍎┆viewWithBorderAtX2┆🍎🍎 @@ -675,18 +676,19 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas }; superView.Add (viewWithBorder); + driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); app.Begin (superView); DriverAssert.AssertDriverContentsAre ( """ - �┌─┐🍎 - �│X│🍎 - �└─┘🍎 + ①┌─┐🍎 + ①│X│🍎 + ①└─┘🍎 """, output, driver); - DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m�┌─┐🍎�│X│🍎�└─┘🍎", + DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m①┌─┐🍎①│X│🍎①└─┘🍎", output, driver); DriverImpl? driverImpl = driver as DriverImpl; @@ -738,19 +740,21 @@ public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBas Height = 3 }; + driver.GetOutputBuffer ().SetWideGlyphReplacement ((Rune)'①'); + superView.Add (viewWithBorder); app.Begin (superView); DriverAssert.AssertDriverContentsAre ( """ - 🍎�┌─┐ - 🍎�│X│ - 🍎�└─┘ + 🍎①┌─┐ + 🍎①│X│ + 🍎①└─┘ """, output, driver); - DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎�┌─┐🍎�│X│🍎�└─┘", + DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎①┌─┐🍎①│X│🍎①└─┘", output, driver); DriverImpl? driverImpl = driver as DriverImpl; From 4673cfc106cf40353e125cf7e6faa98d628d7441 Mon Sep 17 00:00:00 2001 From: Purple Date: Tue, 16 Dec 2025 08:33:22 +0600 Subject: [PATCH 5/6] Showing popup everytime we have new suggestions (#4482) --- Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs b/Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs index 8a7ca9d3e..996abc68f 100644 --- a/Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs +++ b/Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs @@ -388,6 +388,7 @@ public abstract partial class PopupAutocomplete : AutocompleteBase ); } + _popup.Visible = true; _popup.Move (0, 0); for (var i = 0; i < toRender.Length; i++) From 0a9f4b8ef105e6630ac38767234b12f52f646d53 Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 16 Dec 2025 14:23:32 -0700 Subject: [PATCH 6/6] 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 --- Examples/UICatalog/Scenarios/WideGlyphs.cs | 39 +- Terminal.Gui/Drivers/OutputBufferImpl.cs | 13 +- Terminal.Gui/ViewBase/Adornment/Margin.cs | 151 +++++- Terminal.Gui/ViewBase/Adornment/ShadowView.cs | 26 +- Terminal.Gui/ViewBase/View.Drawing.cs | 24 +- Terminal.sln.DotSettings | 2 + Tests/UnitTests/View/Draw/ClipTests.cs | 6 +- .../Drivers/AddRuneTests.cs | 167 ++++-- .../Adornment/BorderArrangementTests.cs | 193 +++++++ .../ViewBase/Adornment/MarginTests.cs | 22 + .../ViewBase/Adornment/ShadowStyletests.cs | 157 ------ .../ViewBase/Adornment/ShadowTests.cs | 487 ++++++++++++++++++ 12 files changed, 1028 insertions(+), 259 deletions(-) delete mode 100644 Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowStyletests.cs create mode 100644 Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs 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); + } +}