Fixes #4258 - Glyphs drawn at mid-point of wide glyphs don't get drawn with clipping (#4462)

* Enhanced `View.Drawing.cs` with improved comments, a new
`DoDrawComplete` method for clip region updates, and
clarified terminology. Added detailed remarks for the
`OnDrawComplete` method and `DrawComplete` event.

Refactored `ViewDrawingClippingTests` to simplify driver
setup, use target-typed `new`, and add a new test for wide
glyph clipping with bordered subviews. Improved handling of
edge cases like empty viewports and nested clips.

Added `WideGlyphs.DrawFlow.md` and
`ViewDrawingClippingTests.DrawFlow.md` to document the draw
flow, clipping behavior, and coordinate systems for both the
scenario and the test.

Commented out redundant `Driver.Clip` initialization in
`ApplicationImpl`. Added a `BUGBUG` comment in `Border` to
highlight missing redraw logic for `LineStyle` changes.

* Uncomment Driver.Clip initialization in Screen redraw

* Fixed it!

* Fixes #4258 - Correct wide glyph and border rendering

Refactored `OutputBufferImpl.AddStr` to improve handling of wide glyphs:
- Wide glyphs now modify only the first column they occupy, leaving the second column untouched.
- Removed redundant code that set replacement characters and marked cells as not dirty.
- Synchronized cursor updates (`Col` and `Row`) with the buffer lock to prevent race conditions.
- Modularized logic with helper methods for better readability and maintainability.

Updated `WideGlyphs.cs`:
- Removed dashed `BorderStyle` and added border thickness and subview for `arrangeableViewAtEven`.
- Removed unused `superView` initialization.

Enhanced tests:
- Added unit tests to verify correct rendering of borders and content at odd columns overlapping wide glyphs.
- Updated existing tests to reflect the new behavior of wide glyph handling.
- Introduced `DriverAssert.AssertDriverOutputIs` to validate raw ANSI output.

Improved documentation:
- Expanded problem description and root cause analysis in `WideGlyphBorderBugFix.md`.
- Detailed the fix and its impact, ensuring proper layering of content at any column position.

General cleanup:
- Removed unused imports and redundant code.
- Improved code readability and maintainability.

* Code cleanup

* Update Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.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 Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs

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

* Update Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs

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

* Fixed test slowness problem

* Simplified

* Rmoved temp .md files

* Refactor I/O handling and improve testability

Refactored `InputProcessor` and `Output` access by replacing direct property usage with `GetInputProcessor()` and `GetOutput()` methods to enhance encapsulation. Introduced `GetLastOutput()` and `GetLastBuffer()` methods for better debugging and testability.

Centralized `StringBuilder` usage in `OutputBase` implementations to ensure consistency. Improved exception handling with clearer messages. Updated tests to align with the refactored structure and added a new test for wide glyph handling.

Enhanced ANSI sequence handling and simplified cursor visibility logic to prevent flickering. Standardized method naming for consistency. Cleaned up redundant code and improved documentation for better developer clarity.

* Refactored `NetOutput`, `FakeOutput`, `UnixOutput`, and `WindowsOutput` classes to support access to `Output` and added a `IDriver.GetOutput` to acess the `IOutput`. `IOutput` now has a `GetLastOutput` method.

Simplified `DriverAssert` logic and enhanced `DriverTests` with a new test for wide glyph clipping across drivers.

Performed general cleanup, including removal of unused code, improved formatting, and adoption of modern C# practices. Added `using System.Diagnostics` in `OutputBufferImpl` for debugging.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tig
2025-12-08 12:28:32 -07:00
committed by GitHub
parent 5da7e59aa2
commit f548059a27
20 changed files with 1046 additions and 324 deletions

View File

@@ -158,7 +158,7 @@ public class Keys : Scenario
appKeyListView.SchemeName = "Runnable";
win.Add (onSwallowedListView);
Application.Driver!.InputProcessor.AnsiSequenceSwallowed += (s, e) => { swallowedList.Add (e.Replace ("\x1b", "Esc")); };
Application.Driver!.GetInputProcessor ().AnsiSequenceSwallowed += (s, e) => { swallowedList.Add (e.Replace ("\x1b", "Esc")); };
Application.KeyDown += (s, a) => KeyDownPressUp (a, "Down");
Application.KeyUp += (s, a) => KeyDownPressUp (a, "Up");

View File

@@ -25,7 +25,7 @@ public sealed class WideGlyphs : Scenario
};
// Build the array of codepoints once when subviews are laid out
appWindow.SubViewsLaidOut += (s, e) =>
appWindow.SubViewsLaidOut += (s, _) =>
{
View? view = s as View;
if (view is null)
@@ -34,8 +34,8 @@ public sealed class WideGlyphs : Scenario
}
// Only rebuild if size changed or array is null
if (_codepoints is null ||
_codepoints.GetLength (0) != view.Viewport.Height ||
if (_codepoints is null ||
_codepoints.GetLength (0) != view.Viewport.Height ||
_codepoints.GetLength (1) != view.Viewport.Width)
{
_codepoints = new Rune [view.Viewport.Height, view.Viewport.Width];
@@ -51,7 +51,9 @@ public sealed class WideGlyphs : Scenario
};
// Fill the window with the pre-built codepoints array
appWindow.DrawingContent += (s, e) =>
// For detailed documentation on the draw code flow from Application.Run to this event,
// see WideGlyphs.DrawFlow.md in this directory
appWindow.DrawingContent += (s, _) =>
{
View? view = s as View;
if (view is null || _codepoints is null)
@@ -73,7 +75,7 @@ public sealed class WideGlyphs : Scenario
}
};
Line verticalLineAtEven = new Line ()
Line verticalLineAtEven = new ()
{
X = 10,
Orientation = Orientation.Vertical,
@@ -81,7 +83,7 @@ public sealed class WideGlyphs : Scenario
};
appWindow.Add (verticalLineAtEven);
Line verticalLineAtOdd = new Line ()
Line verticalLineAtOdd = new ()
{
X = 25,
Orientation = Orientation.Vertical,
@@ -97,8 +99,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" });
appWindow.Add (arrangeableViewAtEven);
View arrangeableViewAtOdd = new ()
@@ -112,6 +118,70 @@ public sealed class WideGlyphs : Scenario
BorderStyle = LineStyle.Dashed,
};
appWindow.Add (arrangeableViewAtOdd);
var superView = new View
{
CanFocus = true,
X = 30, // on an even column to start
Y = Pos.Center (),
Width = Dim.Auto () + 4,
Height = Dim.Auto () + 1,
BorderStyle = LineStyle.Single,
Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable
};
Rune codepoint = Glyphs.Apple;
superView.DrawingContent += (s, e) =>
{
var view = s as View;
for (var r = 0; r < view!.Viewport.Height; r++)
{
for (var c = 0; c < view.Viewport.Width; c += 2)
{
if (codepoint != default (Rune))
{
view.AddRune (c, r, codepoint);
}
}
}
e.DrawContext?.AddDrawnRectangle (view.Viewport);
e.Cancel = true;
};
appWindow.Add (superView);
var viewWithBorderAtX0 = new View
{
Text = "viewWithBorderAtX0",
BorderStyle = LineStyle.Dashed,
X = 0,
Y = 1,
Width = Dim.Auto (),
Height = 3
};
var viewWithBorderAtX1 = new View
{
Text = "viewWithBorderAtX1",
BorderStyle = LineStyle.Dashed,
X = 1,
Y = Pos.Bottom (viewWithBorderAtX0) + 1,
Width = Dim.Auto (),
Height = 3
};
var viewWithBorderAtX2 = new View
{
Text = "viewWithBorderAtX2",
BorderStyle = LineStyle.Dashed,
X = 2,
Y = Pos.Bottom (viewWithBorderAtX1) + 1,
Width = Dim.Auto (),
Height = 3
};
superView.Add (viewWithBorderAtX0, viewWithBorderAtX1, viewWithBorderAtX2);
// Run - Start the application.
Application.Run (appWindow);
appWindow.Dispose ();
@@ -124,6 +194,6 @@ public sealed class WideGlyphs : Scenario
{
Random random = new ();
int codepoint = random.Next (0x4E00, 0x9FFF);
return new Rune (codepoint);
return new (codepoint);
}
}

View File

@@ -109,6 +109,7 @@ public class NetOutput : OutputBase, IOutput
/// <inheritdoc />
protected override void Write (StringBuilder output)
{
base.Write (output);
try
{
Console.Out.Write (output);
@@ -140,7 +141,7 @@ public class NetOutput : OutputBase, IOutput
}
catch (Exception)
{
return false;
return true;
}
}

View File

@@ -45,19 +45,19 @@ internal class DriverImpl : IDriver
ISizeMonitor sizeMonitor
)
{
InputProcessor = inputProcessor;
_inputProcessor = inputProcessor;
_output = output;
OutputBuffer = outputBuffer;
_ansiRequestScheduler = ansiRequestScheduler;
InputProcessor.KeyDown += (s, e) => KeyDown?.Invoke (s, e);
InputProcessor.KeyUp += (s, e) => KeyUp?.Invoke (s, e);
GetInputProcessor ().KeyDown += (s, e) => KeyDown?.Invoke (s, e);
GetInputProcessor ().KeyUp += (s, e) => KeyUp?.Invoke (s, e);
InputProcessor.MouseEvent += (s, e) =>
{
//Logging.Logger.LogTrace ($"Mouse {e.Flags} at x={e.ScreenPosition.X} y={e.ScreenPosition.Y}");
MouseEvent?.Invoke (s, e);
};
GetInputProcessor ().MouseEvent += (s, e) =>
{
//Logging.Logger.LogTrace ($"Mouse {e.Flags} at x={e.ScreenPosition.X} y={e.ScreenPosition.Y}");
MouseEvent?.Invoke (s, e);
};
SizeMonitor = sizeMonitor;
SizeMonitor.SizeChanged += OnSizeMonitorOnSizeChanged;
@@ -73,15 +73,18 @@ internal class DriverImpl : IDriver
public void Init () { throw new NotSupportedException (); }
/// <inheritdoc/>
public void Refresh () { _output.Write (OutputBuffer); }
public void Refresh ()
{
_output.Write (OutputBuffer);
}
/// <inheritdoc/>
public string? GetName () => InputProcessor.DriverName?.ToLowerInvariant ();
public string? GetName () => GetInputProcessor ().DriverName?.ToLowerInvariant ();
/// <inheritdoc/>
public virtual string GetVersionInfo ()
{
string type = InputProcessor.DriverName ?? throw new ArgumentNullException (nameof (InputProcessor.DriverName));
string type = GetInputProcessor ().DriverName ?? throw new InvalidOperationException ("Driver name is not set.");
return type;
}
@@ -143,8 +146,12 @@ internal class DriverImpl : IDriver
private readonly IOutput _output;
public IOutput GetOutput () => _output;
private readonly IInputProcessor _inputProcessor;
/// <inheritdoc/>
public IInputProcessor InputProcessor { get; }
public IInputProcessor GetInputProcessor () => _inputProcessor;
/// <inheritdoc/>
public IOutputBuffer OutputBuffer { get; }
@@ -157,7 +164,7 @@ internal class DriverImpl : IDriver
private void CreateClipboard ()
{
if (InputProcessor.DriverName is { } && InputProcessor.DriverName.Contains ("fake"))
if (GetInputProcessor ().DriverName is { } && GetInputProcessor ()!.DriverName!.Contains ("fake"))
{
if (Clipboard is null)
{
@@ -414,7 +421,7 @@ internal class DriverImpl : IDriver
public event EventHandler<Key>? KeyUp;
/// <inheritdoc/>
public void EnqueueKeyEvent (Key key) { InputProcessor.EnqueueKeyDownEvent (key); }
public void EnqueueKeyEvent (Key key) { GetInputProcessor ().EnqueueKeyDownEvent (key); }
#endregion Input Events

View File

@@ -7,29 +7,29 @@ namespace Terminal.Gui.Drivers;
/// </summary>
public class FakeOutput : OutputBase, IOutput
{
private readonly StringBuilder _output = new ();
// private readonly StringBuilder _outputStringBuilder = new ();
private int _cursorLeft;
private int _cursorTop;
private Size _consoleSize = new (80, 25);
private IOutputBuffer? _lastBuffer;
/// <summary>
///
/// </summary>
public FakeOutput ()
{
LastBuffer = new OutputBufferImpl ();
LastBuffer.SetSize (80, 25);
_lastBuffer = new OutputBufferImpl ();
_lastBuffer.SetSize (80, 25);
}
/// <summary>
/// Gets or sets the last output buffer written.
/// Gets or sets the last output buffer written. The <see cref="IOutputBuffer.Contents"/> contains
/// a reference to the buffer last written with <see cref="Write(IOutputBuffer)"/>.
/// </summary>
public IOutputBuffer? LastBuffer { get; set; }
public IOutputBuffer? GetLastBuffer () => _lastBuffer;
/// <summary>
/// Gets the captured output as a string.
/// </summary>
public string Output => _output.ToString ();
///// <inheritdoc cref="IOutput.GetLastOutput"/>
//public override string GetLastOutput () => _outputStringBuilder.ToString ();
/// <inheritdoc />
public Point GetCursorPosition ()
@@ -61,28 +61,28 @@ public class FakeOutput : OutputBase, IOutput
/// <inheritdoc/>
public void Write (ReadOnlySpan<char> text)
{
_output.Append (text);
// _outputStringBuilder.Append (text);
}
/// <inheritdoc cref="IDriver"/>
/// <inheritdoc cref="IOutput.Write(IOutputBuffer)"/>
public override void Write (IOutputBuffer buffer)
{
LastBuffer = buffer;
_lastBuffer = buffer;
base.Write (buffer);
}
///// <inheritdoc/>
//protected override void Write (StringBuilder output)
//{
// _outputStringBuilder.Append (output);
//}
/// <inheritdoc cref="IDriver"/>
public override void SetCursorVisibility (CursorVisibility visibility)
{
// Capture but don't act on it in fake output
}
/// <inheritdoc/>
public void Dispose ()
{
// Nothing to dispose
}
/// <inheritdoc/>
protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle)
{
@@ -123,8 +123,8 @@ public class FakeOutput : OutputBase, IOutput
}
/// <inheritdoc/>
protected override void Write (StringBuilder output)
public void Dispose ()
{
_output.Append (output);
// Nothing to dispose
}
}

View File

@@ -61,7 +61,12 @@ public interface IDriver : IDisposable
/// e.g. <see cref="ConsoleKeyInfo"/> into <see cref="Key"/> events
/// and detecting and processing ansi escape sequences.
/// </summary>
IInputProcessor InputProcessor { get; }
IInputProcessor GetInputProcessor ();
/// <summary>
/// Gets the output handler responsible for writing to the terminal.
/// </summary>
IOutput GetOutput ();
/// <summary>Get the operating system clipboard.</summary>
IClipboard? Clipboard { get; }

View File

@@ -65,6 +65,12 @@ public interface IOutput : IDisposable
/// <param name="buffer"></param>
void Write (IOutputBuffer buffer);
/// <summary>
/// Gets a string containing the ANSI escape sequences and content most recently written
/// to the terminal via <see cref="Write(IOutputBuffer)"/>
/// </summary>
string GetLastOutput ();
/// <summary>
/// Generates an ANSI escape sequence string representation of the given <paramref name="buffer"/> contents.
/// This is the same output that would be written to the terminal to recreate the current screen contents.

View File

@@ -56,19 +56,27 @@ public abstract class OutputBase
/// <param name="visibility"></param>
public abstract void SetCursorVisibility (CursorVisibility visibility);
/// <inheritdoc cref="IOutput.Write(IOutputBuffer)"/>
StringBuilder _lastOutputStringBuilder = new ();
/// <summary>
/// Writes dirty cells from the buffer to the console. Hides cursor, iterates rows/cols,
/// skips clean cells, batches dirty cells into ANSI sequences, wraps URLs with OSC 8,
/// then renders sixel images. Cursor visibility is managed by <c>ApplicationMainLoop.SetCursor()</c>.
/// </summary>
public virtual void Write (IOutputBuffer buffer)
{
var top = 0;
var left = 0;
StringBuilder outputStringBuilder = new ();
int top = 0;
int left = 0;
int rows = buffer.Rows;
int cols = buffer.Cols;
var output = new StringBuilder ();
Attribute? redrawAttr = null;
int lastCol = -1;
// Hide cursor during rendering to prevent flicker
SetCursorVisibility (CursorVisibility.Invisible);
// Process each row
for (int row = top; row < rows; row++)
{
if (!SetCursorPositionImpl (0, row))
@@ -76,20 +84,24 @@ public abstract class OutputBase
return;
}
output.Clear ();
outputStringBuilder.Clear ();
// Process columns in row
for (int col = left; col < cols; col++)
{
lastCol = -1;
var outputWidth = 0;
// Batch consecutive dirty cells
for (; col < cols; col++)
{
// Skip clean cells - position cursor and continue
if (!buffer.Contents! [row, col].IsDirty)
{
if (output.Length > 0)
if (outputStringBuilder.Length > 0)
{
WriteToConsole (output, ref lastCol, ref outputWidth);
// This clears outputStringBuilder
WriteToConsole (outputStringBuilder, ref lastCol, ref outputWidth);
}
else if (lastCol == -1)
{
@@ -111,24 +123,26 @@ public abstract class OutputBase
lastCol = col;
}
// Append dirty cell as ANSI and mark clean
Cell cell = buffer.Contents [row, col];
buffer.Contents [row, col].IsDirty = false;
AppendCellAnsi (cell, output, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth);
AppendCellAnsi (cell, outputStringBuilder, ref redrawAttr, ref _redrawTextStyle, cols, ref col, ref outputWidth);
}
}
if (output.Length > 0)
// Flush buffered output for row
if (outputStringBuilder.Length > 0)
{
if (IsLegacyConsole)
{
Write (output);
Write (outputStringBuilder);
}
else
{
SetCursorPositionImpl (lastCol, row);
// Wrap URLs with OSC 8 hyperlink sequences using the new Osc8UrlLinker
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (output);
// Wrap URLs with OSC 8 hyperlink sequences
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder);
Write (processed);
}
}
@@ -139,6 +153,7 @@ public abstract class OutputBase
return;
}
// Render queued sixel images
foreach (SixelToRender s in GetSixels ())
{
if (string.IsNullOrWhiteSpace (s.SixelData))
@@ -150,12 +165,12 @@ public abstract class OutputBase
Write ((StringBuilder)new (s.SixelData));
}
// DO NOT restore cursor visibility here - let ApplicationMainLoop.SetCursor() handle it
// The old code was saving/restoring visibility which caused flickering because
// it would restore to the old value even if the application wanted it hidden
// Cursor visibility restored by ApplicationMainLoop.SetCursor() to prevent flicker
}
/// <inheritdoc cref="IOutput.GetLastOutput" />
public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString ();
/// <summary>
/// Changes the color and text style of the console to the given <paramref name="attr"/> and
/// <paramref name="redrawTextStyle"/>.
@@ -180,7 +195,10 @@ public abstract class OutputBase
/// Output the contents of the <paramref name="output"/> to the console.
/// </summary>
/// <param name="output"></param>
protected abstract void Write (StringBuilder output);
protected virtual void Write (StringBuilder output)
{
_lastOutputStringBuilder.Append (output);
}
/// <summary>
/// Builds ANSI escape sequences for the specified rectangular region of the buffer.
@@ -273,7 +291,7 @@ public abstract class OutputBase
/// <returns>A string containing ANSI escape sequences representing the buffer contents.</returns>
public string ToAnsi (IOutputBuffer buffer)
{
var output = new StringBuilder ();
StringBuilder output = new ();
Attribute? lastAttr = null;
BuildAnsiForRegion (buffer, 0, buffer.Rows, 0, buffer.Cols, output, ref lastAttr);
@@ -281,6 +299,10 @@ public abstract class OutputBase
return output.ToString ();
}
/// <summary>
/// Writes buffered output to console, wrapping URLs with OSC 8 hyperlinks (non-legacy only),
/// then clears the buffer and advances <paramref name="lastCol"/> by <paramref name="outputWidth"/>.
/// </summary>
private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outputWidth)
{
if (IsLegacyConsole)
@@ -289,7 +311,7 @@ public abstract class OutputBase
}
else
{
// Wrap URLs with OSC 8 hyperlink sequences using the new Osc8UrlLinker
// Wrap URLs with OSC 8 hyperlink sequences
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (output);
Write (processed);
}

View File

@@ -14,7 +14,7 @@ public class OutputBufferImpl : IOutputBuffer
/// UpdateScreen is called.
/// <remarks>The format of the array is rows, columns. The first index is the row, the second index is the column.</remarks>
/// </summary>
public Cell [,]? Contents { get; set; } = new Cell[0, 0];
public Cell [,]? Contents { get; set; } = new Cell [0, 0];
private int _cols;
private int _rows;
@@ -66,7 +66,7 @@ public class OutputBufferImpl : IOutputBuffer
public virtual int Top { get; set; } = 0;
/// <summary>
/// Indicates which lines have been modified and need to be redrawn.
/// Indicates which lines have been modified and need to be redrawn.
/// </summary>
public bool [] DirtyLines { get; set; } = [];
@@ -138,118 +138,151 @@ public class OutputBufferImpl : IOutputBuffer
{
foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
{
string text = grapheme;
AddGrapheme (grapheme);
}
}
if (Contents is null)
/// <summary>
/// Adds a single grapheme to the display at the current cursor position.
/// </summary>
/// <param name="grapheme">The grapheme to add.</param>
private void AddGrapheme (string grapheme)
{
if (Contents is null)
{
return;
}
Clip ??= new (Screen);
Rectangle clipRect = Clip!.GetBounds ();
string text = grapheme;
int textWidth = -1;
lock (Contents)
{
bool validLocation = IsValidLocation (text, Col, Row);
if (validLocation)
{
return;
}
Clip ??= new (Screen);
Rectangle clipRect = Clip!.GetBounds ();
int textWidth = -1;
bool validLocation = false;
lock (Contents)
{
// Validate location inside the lock to prevent race conditions
validLocation = IsValidLocation (text, Col, Row);
if (validLocation)
{
text = text.MakePrintable ();
textWidth = text.GetColumns ();
Contents [Row, Col].Attribute = CurrentAttribute;
Contents [Row, Col].IsDirty = true;
if (Col > 0)
{
// Check if cell to left has a wide glyph
if (Contents [Row, Col - 1].Grapheme.GetColumns () > 1)
{
// Invalidate cell to left
Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString ();
Contents [Row, Col - 1].IsDirty = true;
}
}
if (textWidth is 0 or 1)
{
Contents [Row, Col].Grapheme = text;
if (Col < clipRect.Right - 1 && Col + 1 < Cols)
{
Contents [Row, Col + 1].IsDirty = true;
}
}
else if (textWidth == 2)
{
if (!Clip.Contains (Col + 1, Row))
{
// We're at the right edge of the clip, so we can't display a wide character.
Contents [Row, Col].Grapheme = Rune.ReplacementChar.ToString ();
}
else if (!Clip.Contains (Col, Row))
{
// Our 1st column is outside the clip, so we can't display a wide character.
if (Col + 1 < Cols)
{
Contents [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString ();
}
}
else
{
Contents [Row, Col].Grapheme = text;
if (Col < clipRect.Right - 1 && Col + 1 < Cols)
{
// Invalidate cell to right so that it doesn't get drawn
Contents [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString ();
Contents [Row, Col + 1].IsDirty = true;
}
}
}
else
{
// This is a non-spacing character, so we don't need to do anything
Contents [Row, Col].Grapheme = " ";
Contents [Row, Col].IsDirty = false;
}
DirtyLines [Row] = true;
}
text = text.MakePrintable ();
textWidth = text.GetColumns ();
// Set attribute and mark dirty for current cell
Contents [Row, Col].Attribute = CurrentAttribute;
Contents [Row, Col].IsDirty = true;
InvalidateOverlappedWideGlyph ();
WriteGraphemeByWidth (text, textWidth, clipRect);
DirtyLines [Row] = true;
}
// Always advance cursor (even if location was invalid)
// Keep Col/Row updates inside the lock to prevent race conditions
Col++;
if (textWidth > 1)
{
Debug.Assert (textWidth <= 2);
if (validLocation)
{
lock (Contents!)
{
// Re-validate Col is still in bounds after increment
if (Col < Cols && Row < Rows && Col < clipRect.Right)
{
// This is a double-width character, and we are not at the end of the line.
// Col now points to the second column of the character. Ensure it doesn't
// Get rendered.
Contents [Row, Col].IsDirty = false;
Contents [Row, Col].Attribute = CurrentAttribute;
}
}
}
// 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
Col++;
}
}
}
/// <summary>
/// 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.
/// </summary>
private void InvalidateOverlappedWideGlyph ()
{
if (Col > 0 && Contents! [Row, Col - 1].Grapheme.GetColumns () > 1)
{
Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString ();
Contents [Row, Col - 1].IsDirty = true;
}
}
/// <summary>
/// Writes a grapheme to the buffer based on its width (0, 1, or 2 columns).
/// </summary>
/// <param name="text">The printable text to write.</param>
/// <param name="textWidth">The column width of the text.</param>
/// <param name="clipRect">The clipping rectangle.</param>
private void WriteGraphemeByWidth (string text, int textWidth, Rectangle clipRect)
{
switch (textWidth)
{
case 0:
case 1:
WriteSingleWidthGrapheme (text, clipRect);
break;
case 2:
WriteWideGrapheme (text);
break;
default:
// Negative width or non-spacing character (shouldn't normally occur)
Contents! [Row, Col].Grapheme = " ";
Contents [Row, Col].IsDirty = false;
break;
}
}
/// <summary>
/// Writes a single-width character (0 or 1 column wide).
/// </summary>
private void WriteSingleWidthGrapheme (string text, Rectangle clipRect)
{
Contents! [Row, Col].Grapheme = text;
// Mark the next cell as dirty to ensure proper rendering of adjacent content
if (Col < clipRect.Right - 1 && Col + 1 < Cols)
{
Contents [Row, Col + 1].IsDirty = true;
}
}
/// <summary>
/// Writes a wide character (2 columns wide) handling clipping and partial overlap cases.
/// </summary>
private void WriteWideGrapheme (string text)
{
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 ();
}
}
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;
// DO NOT modify column N+1 here!
// The wide glyph will naturally render across both columns.
// If we set column N+1 to replacement char, we would overwrite
// any content that was intentionally drawn there (like borders at odd columns).
// See: https://github.com/gui-cs/Terminal.Gui/issues/4258
}
}
/// <summary>Clears the <see cref="Contents"/> of the driver.</summary>
public void ClearContents ()
{

View File

@@ -66,6 +66,7 @@ internal class UnixOutput : OutputBase, IOutput
/// <inheritdoc />
protected override void Write (StringBuilder output)
{
base.Write (output);
try
{
byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ());

View File

@@ -184,7 +184,8 @@ internal partial class WindowsOutput : OutputBase, IOutput
if (!WriteConsole (!IsLegacyConsole ? _outputHandle : _screenBuffer, str, (uint)str.Length, out uint _, nint.Zero))
{
throw new Win32Exception (Marshal.GetLastWin32Error (), "Failed to write to console screen buffer.");
// Don't throw in unit tests
// throw new Win32Exception (Marshal.GetLastWin32Error (), "Failed to write to console screen buffer.");
}
}
@@ -318,6 +319,7 @@ internal partial class WindowsOutput : OutputBase, IOutput
{
return;
}
base.Write (output);
var str = output.ToString ();

View File

@@ -214,6 +214,7 @@ public partial class Border : Adornment
// TODO: all this.
return Parent?.SuperView?.BorderStyle ?? LineStyle.None;
}
// BUGBUG: Setting LineStyle should SetNeedsDraw
set => _lineStyle = value;
}

View File

@@ -6,7 +6,7 @@ namespace Terminal.Gui.ViewBase;
public partial class View // Drawing APIs
{
/// <summary>
/// Draws a set of views.
/// Draws a set of peer views (views that share the same SuperView).
/// </summary>
/// <param name="views">The peer views to draw.</param>
/// <param name="force">If <see langword="true"/>, <see cref="View.SetNeedsDraw()"/> will be called on each view to force it to be drawn.</param>
@@ -39,8 +39,8 @@ public partial class View // Drawing APIs
// After all peer views have been drawn and cleared, we can now clear the SuperView's SubViewNeedsDraw flag.
// ClearNeedsDraw() does not clear SuperView.SubViewNeedsDraw (by design, to avoid premature clearing
// when siblings still need drawing), so we must do it here after ALL peers are processed.
// We only clear the flag if ALL the SuperView's subviews no longer need drawing.
// when peer subviews still need drawing), so we must do it here after ALL peers are processed.
// We only clear the flag if ALL the SuperView's SubViews no longer need drawing.
View? lastSuperView = null;
foreach (View view in viewsArray)
{
@@ -85,8 +85,8 @@ public partial class View // Drawing APIs
if (NeedsDraw || SubViewNeedsDraw)
{
// ------------------------------------
// Draw the Border and Padding.
// Note Margin with a Shadow is special-cased and drawn in a separate pass to support
// Draw the Border and Padding Adornments.
// Note: Margin with a Shadow is special-cased and drawn in a separate pass to support
// transparent shadows.
DoDrawAdornments (originalClip);
SetClip (originalClip);
@@ -106,7 +106,7 @@ public partial class View // Drawing APIs
DoClearViewport (context);
// ------------------------------------
// Draw the subviews first (order matters: SubViews, Text, Content)
// Draw the SubViews first (order matters: SubViews, Text, Content)
if (SubViewNeedsDraw)
{
DoDrawSubViews (context);
@@ -130,8 +130,8 @@ public partial class View // Drawing APIs
DoRenderLineCanvas (context);
// ------------------------------------
// Re-draw the border and padding subviews
// HACK: This is a hack to ensure that the border and padding subviews are drawn after the line canvas.
// Re-draw the Border and Padding Adornment SubViews
// HACK: This is a hack to ensure that the Border and Padding Adornment SubViews are drawn after the line canvas.
DoDrawAdornmentsSubViews ();
// ------------------------------------
@@ -170,15 +170,20 @@ public partial class View // Drawing APIs
SetClip (originalClip);
// ------------------------------------
// We're done drawing - The Clip is reset to what it was before we started.
// We're done drawing - The Clip is reset to what it was before we started
// But the context contains the region that was drawn by this view
DoDrawComplete (context);
// When DoDrawComplete returns, Driver.Clip has been updated to exclude this view's area.
// The next view drawn (earlier in Z-order, typically a peer view or the SuperView) will see
// a clip with "holes" where this view (and any SubViews drawn before it) are located.
}
#region DrawAdornments
private void DoDrawAdornmentsSubViews ()
{
// NOTE: We do not support subviews of Margin?
// NOTE: We do not support SubViews of Margin
if (Border?.SubViews is { } && Border.Thickness != Thickness.Empty && Border.NeedsDraw)
{
@@ -302,7 +307,7 @@ public partial class View // Drawing APIs
/// <summary>
/// Called when the View's Adornments are to be drawn. Prepares <see cref="View.LineCanvas"/>. If
/// <see cref="SuperViewRendersLineCanvas"/> is true, only the
/// <see cref="LineCanvas"/> of this view's subviews will be rendered. If <see cref="SuperViewRendersLineCanvas"/> is
/// <see cref="LineCanvas"/> of this view's SubViews will be rendered. If <see cref="SuperViewRendersLineCanvas"/> is
/// false (the default), this method will cause the <see cref="LineCanvas"/> be prepared to be rendered.
/// </summary>
/// <returns><see langword="true"/> to stop further drawing of the Adornments.</returns>
@@ -481,7 +486,7 @@ public partial class View // Drawing APIs
Rectangle.Empty);
}
// We assume that the text has been drawn over the entire area; ensure that the subviews are redrawn.
// We assume that the text has been drawn over the entire area; ensure that the SubViews are redrawn.
SetSubViewNeedsDrawDownHierarchy ();
}
@@ -571,7 +576,7 @@ public partial class View // Drawing APIs
/// such as <see cref="View.AddRune(int, int, Rune)"/>, <see cref="View.AddStr(string)"/>, and <see cref="View.FillRect(Rectangle, Rune)"/>.
/// </para>
/// <para>
/// The event is invoked after <see cref="ClearingViewport"/> and <see cref="Text"/> have been drawn, but before any <see cref="SubViews"/> are drawn.
/// The event is invoked after <see cref="ClearingViewport"/> and <see cref="Text"/> have been drawn, but after <see cref="SubViews"/> have been drawn.
/// </para>
/// <para>
/// <b>Transparency Support:</b> If the View has <see cref="ViewportSettings"/> with <see cref="ViewportSettingsFlags.Transparent"/>
@@ -650,7 +655,8 @@ public partial class View // Drawing APIs
return;
}
// Draw the subviews in reverse order to leverage clipping.
// Draw the SubViews in reverse Z-order to leverage clipping.
// SubViews earlier in the collection are drawn last (on top).
foreach (View view in InternalSubViews.Snapshot ().Where (v => v.Visible).Reverse ())
{
// TODO: HACK - This forcing of SetNeedsDraw with SuperViewRendersLineCanvas enables auto line join to work, but is brute force.
@@ -691,23 +697,22 @@ public partial class View // Drawing APIs
/// <returns><see langword="true"/> to stop further drawing of <see cref="LineCanvas"/>.</returns>
protected virtual bool OnRenderingLineCanvas () { return false; }
/// <summary>The canvas that any line drawing that is to be shared by subviews of this view should add lines to.</summary>
/// <summary>The canvas that any line drawing that is to be shared by SubViews of this view should add lines to.</summary>
/// <remarks><see cref="Border"/> adds border lines to this LineCanvas.</remarks>
public LineCanvas LineCanvas { get; } = new ();
/// <summary>
/// Gets or sets whether this View will use it's SuperView's <see cref="LineCanvas"/> for rendering any
/// lines. If <see langword="true"/> the rendering of any borders drawn by this Frame will be done by its parent's
/// Gets or sets whether this View will use its SuperView's <see cref="LineCanvas"/> for rendering any
/// lines. If <see langword="true"/> the rendering of any borders drawn by this view will be done by its
/// SuperView. If <see langword="false"/> (the default) this View's <see cref="OnDrawingAdornments"/> method will
/// be
/// called to render the borders.
/// be called to render the borders.
/// </summary>
public virtual bool SuperViewRendersLineCanvas { get; set; } = false;
/// <summary>
/// Causes the contents of <see cref="LineCanvas"/> to be drawn.
/// If <see cref="SuperViewRendersLineCanvas"/> is true, only the
/// <see cref="LineCanvas"/> of this view's subviews will be rendered. If <see cref="SuperViewRendersLineCanvas"/> is
/// <see cref="LineCanvas"/> of this view's SubViews will be rendered. If <see cref="SuperViewRendersLineCanvas"/> is
/// false (the default), this method will cause the <see cref="LineCanvas"/> to be rendered.
/// </summary>
/// <param name="context"></param>
@@ -732,7 +737,7 @@ public partial class View // Drawing APIs
AddStr (p.Value.Value.Grapheme);
// Add each drawn cell to the context
context?.AddDrawnRectangle (new Rectangle (p.Key, new (1, 1)) );
//context?.AddDrawnRectangle (new Rectangle (p.Key, new (1, 1)) );
}
}
@@ -744,60 +749,153 @@ public partial class View // Drawing APIs
#region DrawComplete
/// <summary>
/// Called at the end of <see cref="Draw(DrawContext)"/> to finalize drawing and update the clip region.
/// </summary>
/// <param name="context">
/// The <see cref="DrawContext"/> tracking what regions were drawn by this view and its subviews.
/// May be <see langword="null"/> if not tracking drawn regions.
/// </param>
private void DoDrawComplete (DrawContext? context)
{
// Phase 1: Notify that drawing is complete
// Raise virtual method first, then event. This allows subclasses to override behavior
// before subscribers see the event.
OnDrawComplete (context);
DrawComplete?.Invoke (this, new (Viewport, Viewport, context));
// Now, update the clip to exclude this view (not including Margin)
// Phase 2: Update Driver.Clip to exclude this view's drawn area
// This prevents views "behind" this one (earlier in draw order/Z-order) from drawing over it.
// Adornments (Margin, Border, Padding) are handled by their Adornment.Parent view and don't exclude themselves.
if (this is not Adornment)
{
if (ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent))
{
// context!.DrawnRegion is the region that was drawn by this view. It may include regions outside
// the Viewport. We need to clip it to the Viewport.
// Transparent View Path:
// Only exclude the regions that were actually drawn, allowing views beneath
// to show through in areas where nothing was drawn.
// The context.DrawnRegion may include areas outside the Viewport (e.g., if content
// was drawn with ViewportSettingsFlags.AllowContentOutsideViewport). We need to clip
// it to the Viewport bounds to prevent excluding areas that aren't visible.
context!.ClipDrawnRegion (ViewportToScreen (Viewport));
// Exclude the drawn region from the clip
// Exclude the actually-drawn region from Driver.Clip
ExcludeFromClip (context.GetDrawnRegion ());
// Exclude the Border and Padding from the clip
// Border and Padding are always opaque (they draw lines/fills), so exclude them too
ExcludeFromClip (Border?.Thickness.AsRegion (Border.FrameToScreen ()));
ExcludeFromClip (Padding?.Thickness.AsRegion (Padding.FrameToScreen ()));
}
else
{
// Exclude this view (not including Margin) from the Clip
// Opaque View Path (default):
// Exclude the entire view area from Driver.Clip. This is the typical case where
// the view is considered fully opaque.
// Start with the Frame in screen coordinates
Rectangle borderFrame = FrameToScreen ();
// If there's a Border, use its frame instead (includes the border thickness)
if (Border is { })
{
borderFrame = Border.FrameToScreen ();
}
// In the non-transparent (typical case), we want to exclude the entire view area (borderFrame) from the clip
// Exclude this view's entire area (Border inward, but not Margin) from the clip.
// This prevents any view drawn after this one from drawing in this area.
ExcludeFromClip (borderFrame);
// Update context.DrawnRegion to include the entire view (borderFrame), but clipped to our SuperView's viewport
// This enables the SuperView to know what was drawn by this view.
// Update the DrawContext to track that we drew this entire rectangle.
// This allows our SuperView (if any) to know what area we occupied,
// which is important for transparency calculations at higher levels.
context?.AddDrawnRectangle (borderFrame);
}
}
// TODO: Determine if we need another event that conveys the FINAL DrawContext
// When this method returns, Driver.Clip has been updated to exclude this view's area.
// The next view drawn (earlier in Z-order, typically a peer view or the SuperView) will see
// a clip with "holes" where this view (and any SubViews drawn before it) are located.
}
/// <summary>
/// Called when the View is completed drawing.
/// Called when the View has completed drawing and is about to update the clip region.
/// </summary>
/// <param name="context">
/// The <see cref="DrawContext"/> containing the regions that were drawn by this view and its subviews.
/// May be <see langword="null"/> if not tracking drawn regions.
/// </param>
/// <remarks>
/// The <paramref name="context"/> parameter provides the drawn region of the View.
/// <para>
/// This method is called at the very end of <see cref="Draw(DrawContext)"/>, after all drawing
/// (adornments, content, text, subviews, line canvas) has completed but before the view's area
/// is excluded from <see cref="IDriver.Clip"/>.
/// </para>
/// <para>
/// Use this method to:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>Perform any final drawing operations that need to happen after SubViews are drawn</description>
/// </item>
/// <item>
/// <description>Inspect what was drawn via the <paramref name="context"/> parameter</description>
/// </item>
/// <item>
/// <description>Add additional regions to the <paramref name="context"/> if needed</description>
/// </item>
/// </list>
/// <para>
/// <b>Important:</b> At this point, <see cref="IDriver.Clip"/> has been restored to the state
/// it was in when <see cref="Draw(DrawContext)"/> began. After this method returns, the view's
/// area will be excluded from the clip (see <see cref="DoDrawComplete"/> for details).
/// </para>
/// <para>
/// <b>Transparency Support:</b> If <see cref="ViewportSettings"/> includes
/// <see cref="ViewportSettingsFlags.Transparent"/>, the <paramref name="context"/> parameter
/// contains the actual regions that were drawn. You can inspect this to see what areas
/// will be excluded from the clip, and optionally add more regions if needed.
/// </para>
/// </remarks>
/// <seealso cref="DrawComplete"/>
/// <seealso cref="Draw(DrawContext)"/>
/// <seealso cref="DoDrawComplete"/>
protected virtual void OnDrawComplete (DrawContext? context) { }
/// <summary>Raised when the View is completed drawing.</summary>
/// <summary>Raised when the View has completed drawing and is about to update the clip region.</summary>
/// <remarks>
/// <para>
/// This event is raised at the very end of <see cref="Draw(DrawContext)"/>, after all drawing
/// operations have completed but before the view's area is excluded from <see cref="IDriver.Clip"/>.
/// </para>
/// <para>
/// The <see cref="DrawEventArgs.DrawContext"/> property provides information about what regions
/// were drawn by this view and its subviews. This is particularly useful for views with
/// <see cref="ViewportSettingsFlags.Transparent"/> enabled, as it shows exactly which areas
/// will be excluded from the clip.
/// </para>
/// <para>
/// Use this event to:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>Perform any final drawing operations</description>
/// </item>
/// <item>
/// <description>Inspect what was drawn</description>
/// </item>
/// <item>
/// <description>Track drawing statistics or metrics</description>
/// </item>
/// </list>
/// <para>
/// <b>Note:</b> This event fires <i>after</i> <see cref="OnDrawComplete(DrawContext)"/>. If you need
/// to override the behavior, prefer overriding the virtual method in a subclass rather than
/// subscribing to this event.
/// </para>
/// </remarks>
/// <seealso cref="OnDrawComplete(DrawContext)"/>
/// <seealso cref="Draw(DrawContext)"/>
public event EventHandler<DrawEventArgs>? DrawComplete;
#endregion DrawComplete

View File

@@ -64,7 +64,7 @@ public partial class GuiTestContext
{
mouseEvent.Position = mouseEvent.ScreenPosition;
app.Driver.InputProcessor.EnqueueMouseEvent (app, mouseEvent);
app.Driver.GetInputProcessor ().EnqueueMouseEvent (app, mouseEvent);
}
else
{

View File

@@ -47,7 +47,7 @@ internal partial class DriverAssert
{
driver = Application.Driver;
}
ArgumentNullException.ThrowIfNull(driver);
ArgumentNullException.ThrowIfNull (driver);
Cell [,] contents = driver!.Contents!;
@@ -193,6 +193,134 @@ internal partial class DriverAssert
Assert.Equal (expectedLook, actualLook);
}
#pragma warning disable xUnit1013 // Public method should be marked as test
/// <summary>Asserts that the driver raw ANSI output matches the expected output.</summary>
/// <param name="expectedLook">Expected output with C# escape sequences (e.g., \x1b for ESC)</param>
/// <param name="output"></param>
/// <param name="driver">The IDriver to use. If null <see cref="Application.Driver"/> will be used.</param>
public static void AssertDriverOutputIs (
string expectedLook,
ITestOutputHelper output,
IDriver? driver = null
)
{
#pragma warning restore xUnit1013 // Public method should be marked as test
if (driver is null && ApplicationImpl.ModelUsage == ApplicationModelUsage.LegacyStatic)
{
driver = Application.Driver;
}
ArgumentNullException.ThrowIfNull (driver);
string? actualLook = driver.GetOutput().GetLastOutput ();
// Unescape the expected string to convert C# escape sequences like \x1b to actual characters
string unescapedExpected = UnescapeString (expectedLook);
// Trim trailing whitespace from actual (screen padding)
actualLook = actualLook.TrimEnd ();
unescapedExpected = unescapedExpected.TrimEnd ();
if (string.Equals (unescapedExpected, actualLook))
{
return;
}
// If test is about to fail show user what things looked like
if (!string.Equals (unescapedExpected, actualLook))
{
output?.WriteLine ($"Expected (length={unescapedExpected.Length}):" + Environment.NewLine + unescapedExpected);
output?.WriteLine ($" But Was (length={actualLook.Length}):" + Environment.NewLine + actualLook);
// Show the difference at the end
int minLen = Math.Min (unescapedExpected.Length, actualLook.Length);
output?.WriteLine ($"Lengths: Expected={unescapedExpected.Length}, Actual={actualLook.Length}, MinLen={minLen}");
if (actualLook.Length > unescapedExpected.Length)
{
output?.WriteLine ($"Actual has {actualLook.Length - unescapedExpected.Length} extra characters at the end");
}
}
Assert.Equal (unescapedExpected, actualLook);
}
/// <summary>
/// Unescapes a C# string literal by processing escape sequences like \x1b, \n, \r, \t, etc.
/// </summary>
/// <param name="input">String with C# escape sequences</param>
/// <returns>String with escape sequences converted to actual characters</returns>
private static string UnescapeString (string input)
{
if (string.IsNullOrEmpty (input))
{
return input;
}
var result = new StringBuilder (input.Length);
int i = 0;
while (i < input.Length)
{
if (input [i] == '\\' && i + 1 < input.Length)
{
char next = input [i + 1];
switch (next)
{
case 'x' when i + 3 < input.Length:
// Handle \xHH (2-digit hex)
string hex = input.Substring (i + 2, 2);
if (int.TryParse (hex, System.Globalization.NumberStyles.HexNumber, null, out int hexValue))
{
result.Append ((char)hexValue);
i += 4; // Skip \xHH
continue;
}
break;
case 'n':
result.Append ('\n');
i += 2;
continue;
case 'r':
result.Append ('\r');
i += 2;
continue;
case 't':
result.Append ('\t');
i += 2;
continue;
case '\\':
result.Append ('\\');
i += 2;
continue;
case '"':
result.Append ('"');
i += 2;
continue;
case '\'':
result.Append ('\'');
i += 2;
continue;
case '0':
result.Append ('\0');
i += 2;
continue;
}
}
// Not an escape sequence, add the character as-is
result.Append (input [i]);
i++;
}
return result.ToString ();
}
/// <summary>
/// Asserts that the driver contents are equal to the provided string.
/// </summary>

View File

@@ -3,14 +3,10 @@ using System.Text;
using UnitTests;
using Xunit.Abstractions;
// Alias Console to MockConsole so we don't accidentally use Console
namespace DriverTests;
public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
{
private readonly ITestOutputHelper _output = output;
[Fact]
public void AddRune ()
{
@@ -179,4 +175,36 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
driver.Dispose ();
}
[Fact]
public void AddStr_Glyph_On_Second_Cell_Of_Wide_Glyph_Outputs_Correctly ()
{
IDriver? driver = CreateFakeDriver ();
driver.SetScreenSize (6, 3);
driver!.Clip = new (driver.Screen);
driver.Move (1, 0);
driver.AddStr ("┌");
driver.Move (2, 0);
driver.AddStr ("─");
driver.Move (3, 0);
driver.AddStr ("┐");
driver.Clip.Exclude (new Region (new (1, 0, 3, 1)));
driver.Move (0, 0);
driver.AddStr ("🍎🍎🍎🍎");
DriverAssert.AssertDriverContentsAre (
"""
<EFBFBD>🍎
""",
output,
driver);
driver.Refresh ();
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m<30>┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m",
output, driver);
}
}

View File

@@ -1,4 +1,5 @@
#nullable enable
using System.Text;
using UnitTests;
using Xunit.Abstractions;

View File

@@ -92,6 +92,51 @@ public class DriverTests (ITestOutputHelper output) : FakeDriverBase
app.Dispose ();
}
// Tests fix for https://github.com/gui-cs/Terminal.Gui/issues/4258
[Theory]
[InlineData ("fake")]
[InlineData ("windows")]
[InlineData ("dotnet")]
[InlineData ("unix")]
public void All_Drivers_When_Clipped_AddStr_Glyph_On_Second_Cell_Of_Wide_Glyph_Outputs_Correctly (string driverName)
{
IApplication? app = Application.Create ();
app.Init (driverName);
IDriver driver = app.Driver!;
// Need to force "windows" driver to override legacy console mode for this test
driver.IsLegacyConsole = false;
driver.Force16Colors = false;
driver.SetScreenSize (6, 3);
driver!.Clip = new (driver.Screen);
driver.Move (1, 0);
driver.AddStr ("┌");
driver.Move (2, 0);
driver.AddStr ("─");
driver.Move (3, 0);
driver.AddStr ("┐");
driver.Clip.Exclude (new Region (new (1, 0, 3, 1)));
driver.Move (0, 0);
driver.AddStr ("🍎🍎🍎🍎");
DriverAssert.AssertDriverContentsAre (
"""
<EFBFBD>🍎
""",
output,
driver);
driver.Refresh ();
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m<30>┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m",
output, driver);
}
}
public class TestTop : Runnable

View File

@@ -1,6 +1,4 @@
#nullable enable
namespace DriverTests;
namespace DriverTests;
public class OutputBaseTests
{
@@ -9,7 +7,7 @@ public class OutputBaseTests
{
// Arrange
var output = new FakeOutput ();
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (1, 1);
// Act
@@ -32,21 +30,21 @@ public class OutputBaseTests
// Create DriverImpl and associate it with the FakeOutput to test Sixel output
IDriver driver = new DriverImpl (
new FakeInputProcessor (null!),
new OutputBufferImpl (),
output,
new (new AnsiResponseParser ()),
new SizeMonitorImpl (output));
new FakeInputProcessor (null!),
new OutputBufferImpl (),
output,
new (new AnsiResponseParser ()),
new SizeMonitorImpl (output));
driver.Force16Colors = force16Colors;
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (1, 1);
// Use a known RGB color and attribute
var fg = new Color (1, 2, 3);
var bg = new Color (4, 5, 6);
buffer.CurrentAttribute = new Attribute (fg, bg);
buffer.CurrentAttribute = new (fg, bg);
buffer.AddStr ("X");
// Act
@@ -59,7 +57,7 @@ public class OutputBaseTests
}
else if (!isLegacyConsole && force16Colors)
{
var expected16 = EscSeqUtils.CSI_SetForegroundColor (fg.GetAnsiColorCode ());
string expected16 = EscSeqUtils.CSI_SetForegroundColor (fg.GetAnsiColorCode ());
Assert.Contains (expected16, ansi);
}
else
@@ -78,7 +76,7 @@ public class OutputBaseTests
{
// Arrange
var output = new FakeOutput ();
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (2, 1);
// Mark two characters as dirty by writing them into the buffer
@@ -92,7 +90,7 @@ public class OutputBaseTests
output.Write (buffer); // calls OutputBase.Write via FakeOutput
// Assert: content was written to the fake output and dirty flags cleared
Assert.Contains ("AB", output.Output);
Assert.Contains ("AB", output.GetLastOutput ());
Assert.False (buffer.Contents! [0, 0].IsDirty);
Assert.False (buffer.Contents! [0, 1].IsDirty);
}
@@ -105,7 +103,7 @@ public class OutputBaseTests
// Arrange
// FakeOutput exposes this because it's in test scope
var output = new FakeOutput { IsLegacyConsole = isLegacyConsole };
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (3, 1);
// Write 'A' at col 0 and 'C' at col 2; leave col 1 untouched (not dirty)
@@ -122,15 +120,15 @@ public class OutputBaseTests
output.Write (buffer);
// Assert: both characters were written (use Contains to avoid CI side effects)
Assert.Contains ("A", output.Output);
Assert.Contains ("C", output.Output);
Assert.Contains ("A", output.GetLastOutput ());
Assert.Contains ("C", output.GetLastOutput ());
// Dirty flags cleared for the written cells
Assert.False (buffer.Contents! [0, 0].IsDirty);
Assert.False (buffer.Contents! [0, 2].IsDirty);
// Verify SetCursorPositionImpl was invoked by WriteToConsole (cursor set to a written column)
Assert.Equal (new Point (0, 0), output.GetCursorPosition ());
Assert.Equal (new (0, 0), output.GetCursorPosition ());
// Now write 'X' at col 0 to verify subsequent writes also work
buffer.Move (0, 0);
@@ -143,15 +141,15 @@ public class OutputBaseTests
output.Write (buffer);
// Assert: both characters were written (use Contains to avoid CI side effects)
Assert.Contains ("A", output.Output);
Assert.Contains ("C", output.Output);
Assert.Contains ("A", output.GetLastOutput ());
Assert.Contains ("C", output.GetLastOutput ());
// Dirty flags cleared for the written cells
Assert.False (buffer.Contents! [0, 0].IsDirty);
Assert.False (buffer.Contents! [0, 2].IsDirty);
// Verify SetCursorPositionImpl was invoked by WriteToConsole (cursor set to a written column)
Assert.Equal (new Point (2, 0), output.GetCursorPosition ());
Assert.Equal (new (2, 0), output.GetCursorPosition ());
}
[Theory]
@@ -162,44 +160,57 @@ public class OutputBaseTests
// Arrange
// FakeOutput exposes this because it's in test scope
var output = new FakeOutput { IsLegacyConsole = isLegacyConsole };
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (3, 1);
// Write '🦮' at col 0 and 'A' at col 3; leave col 1 untouched (not dirty)
// Write '🦮' at col 0 and 'A' at col 2
buffer.Move (0, 0);
buffer.AddStr ("🦮A");
// Confirm some dirtiness before to write
// After the fix for https://github.com/gui-cs/Terminal.Gui/issues/4258:
// Writing a wide glyph at column 0 no longer sets column 1 to IsDirty = false.
// Column 1 retains whatever state it had (in this case, it was initialized as dirty
// by ClearContents, but may have been cleared by a previous Write call).
//
// What we care about is that wide glyphs work correctly and don't prevent
// other content from being drawn at odd columns.
Assert.True (buffer.Contents! [0, 0].IsDirty);
Assert.False (buffer.Contents! [0, 1].IsDirty);
// Column 1 state depends on whether it was cleared by a previous Write - don't assert
Assert.True (buffer.Contents! [0, 2].IsDirty);
// Act
output.Write (buffer);
Assert.Contains ("🦮", output.Output);
Assert.Contains ("A", output.Output);
Assert.Contains ("🦮", output.GetLastOutput ());
Assert.Contains ("A", output.GetLastOutput ());
// Dirty flags cleared for the written cells
// Column 0 was written (wide glyph)
Assert.False (buffer.Contents! [0, 0].IsDirty);
Assert.False (buffer.Contents! [0, 1].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 2 was written ('A')
Assert.False (buffer.Contents! [0, 2].IsDirty);
Assert.Equal (new (0, 0), output.GetCursorPosition ());
// Now write 'X' at col 1 which replaces with the replacement character the col 0
// Now write 'X' at col 1 which invalidates the wide glyph at col 0
buffer.Move (1, 0);
buffer.AddStr ("X");
// Confirm dirtiness state before to write
Assert.True (buffer.Contents! [0, 0].IsDirty);
Assert.True (buffer.Contents! [0, 1].IsDirty);
Assert.True (buffer.Contents! [0, 2].IsDirty);
Assert.True (buffer.Contents! [0, 0].IsDirty); // Invalidated by writing at col 1
Assert.True (buffer.Contents! [0, 1].IsDirty); // Just written
Assert.True (buffer.Contents! [0, 2].IsDirty); // Marked dirty by writing at col 1
output.Write (buffer);
Assert.Contains ("<22>", output.Output);
Assert.Contains ("X", output.Output);
Assert.Contains ("<22>", output.GetLastOutput ());
Assert.Contains ("X", output.GetLastOutput ());
// Dirty flags cleared for the written cells
Assert.False (buffer.Contents! [0, 0].IsDirty);
@@ -217,7 +228,7 @@ public class OutputBaseTests
{
// Arrange
var output = new FakeOutput ();
IOutputBuffer buffer = output.LastBuffer!;
IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (1, 1);
// Ensure the buffer has some content so Write traverses rows
@@ -227,16 +238,16 @@ public class OutputBaseTests
var s = new SixelToRender
{
SixelData = "SIXEL-DATA",
ScreenPosition = new Point (4, 2)
ScreenPosition = new (4, 2)
};
// Create DriverImpl and associate it with the FakeOutput to test Sixel output
IDriver driver = new DriverImpl (
new FakeInputProcessor (null!),
new OutputBufferImpl (),
output,
new (new AnsiResponseParser ()),
new SizeMonitorImpl (output));
new FakeInputProcessor (null!),
new OutputBufferImpl (),
output,
new (new AnsiResponseParser ()),
new SizeMonitorImpl (output));
// Add the Sixel to the driver
driver.GetSixels ().Enqueue (s);
@@ -250,7 +261,7 @@ public class OutputBaseTests
if (!isLegacyConsole)
{
// Assert: Sixel data was emitted (use Contains to avoid equality/side-effects)
Assert.Contains ("SIXEL-DATA", output.Output);
Assert.Contains ("SIXEL-DATA", output.GetLastOutput ());
// Cursor was moved to Sixel position
Assert.Equal (s.ScreenPosition, output.GetCursorPosition ());
@@ -258,7 +269,7 @@ public class OutputBaseTests
else
{
// Assert: Sixel data was NOT emitted
Assert.DoesNotContain ("SIXEL-DATA", output.Output);
Assert.DoesNotContain ("SIXEL-DATA", output.GetLastOutput ());
// Cursor was NOT moved to Sixel position
Assert.NotEqual (s.ScreenPosition, output.GetCursorPosition ());
@@ -271,4 +282,4 @@ public class OutputBaseTests
app.Dispose ();
}
}
}

View File

@@ -1,19 +1,18 @@
#nullable enable
using System.Text;
using UnitTests;
using Xunit.Abstractions;
namespace ViewBaseTests.Drawing;
public class ViewDrawingClippingTests () : FakeDriverBase
public class ViewDrawingClippingTests (ITestOutputHelper output) : FakeDriverBase
{
#region GetClip / SetClip Tests
[Fact]
public void GetClip_ReturnsDriverClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
var region = new Region (new Rectangle (10, 10, 20, 20));
IDriver driver = CreateFakeDriver ();
var region = new Region (new (10, 10, 20, 20));
driver.Clip = region;
View view = new () { Driver = driver };
@@ -26,8 +25,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void SetClip_NullRegion_DoesNothing ()
{
IDriver driver = CreateFakeDriver (80, 25);
var original = new Region (new Rectangle (5, 5, 10, 10));
IDriver driver = CreateFakeDriver ();
var original = new Region (new (5, 5, 10, 10));
driver.Clip = original;
View view = new () { Driver = driver };
@@ -40,8 +39,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void SetClip_ValidRegion_SetsDriverClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
var region = new Region (new Rectangle (10, 10, 30, 30));
IDriver driver = CreateFakeDriver ();
var region = new Region (new (10, 10, 30, 30));
View view = new () { Driver = driver };
view.SetClip (region);
@@ -56,8 +55,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void SetClipToScreen_ReturnsPreviousClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
var original = new Region (new Rectangle (5, 5, 10, 10));
IDriver driver = CreateFakeDriver ();
var original = new Region (new (5, 5, 10, 10));
driver.Clip = original;
View view = new () { Driver = driver };
@@ -70,7 +69,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void SetClipToScreen_SetsClipToScreen ()
{
IDriver driver = CreateFakeDriver (80, 25);
IDriver driver = CreateFakeDriver ();
View view = new () { Driver = driver };
view.SetClipToScreen ();
@@ -87,15 +86,15 @@ public class ViewDrawingClippingTests () : FakeDriverBase
public void ExcludeFromClip_Rectangle_NullDriver_DoesNotThrow ()
{
View view = new () { Driver = null };
var exception = Record.Exception (() => view.ExcludeFromClip (new Rectangle (5, 5, 10, 10)));
Exception? exception = Record.Exception (() => view.ExcludeFromClip (new Rectangle (5, 5, 10, 10)));
Assert.Null (exception);
}
[Fact]
public void ExcludeFromClip_Rectangle_ExcludesArea ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (new Rectangle (0, 0, 80, 25));
IDriver driver = CreateFakeDriver ();
driver.Clip = new (new (0, 0, 80, 25));
View view = new () { Driver = driver };
var toExclude = new Rectangle (10, 10, 20, 20);
@@ -111,19 +110,18 @@ public class ViewDrawingClippingTests () : FakeDriverBase
{
View view = new () { Driver = null };
var exception = Record.Exception (() => view.ExcludeFromClip (new Region (new Rectangle (5, 5, 10, 10))));
Exception? exception = Record.Exception (() => view.ExcludeFromClip (new Region (new (5, 5, 10, 10))));
Assert.Null (exception);
}
[Fact]
public void ExcludeFromClip_Region_ExcludesArea ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (new Rectangle (0, 0, 80, 25));
IDriver driver = CreateFakeDriver ();
driver.Clip = new (new (0, 0, 80, 25));
View view = new () { Driver = driver };
var toExclude = new Region (new Rectangle (10, 10, 20, 20));
var toExclude = new Region (new (10, 10, 20, 20));
view.ExcludeFromClip (toExclude);
// Verify the region was excluded
@@ -150,8 +148,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void AddFrameToClip_IntersectsWithFrame ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -171,7 +169,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Assert.NotNull (driver.Clip);
// The clip should now be the intersection of the screen and the view's frame
Rectangle expectedBounds = new Rectangle (1, 1, 20, 20);
var expectedBounds = new Rectangle (1, 1, 20, 20);
Assert.Equal (expectedBounds, driver.Clip.GetBounds ());
}
@@ -194,8 +192,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void AddViewportToClip_IntersectsWithViewport ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -222,8 +220,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void AddViewportToClip_WithClipContentOnly_LimitsToVisibleContent ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -260,7 +258,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
public void ClipRegions_StackCorrectly_WithNestedViews ()
{
IDriver driver = CreateFakeDriver (100, 100);
driver.Clip = new Region (driver.Screen);
driver.Clip = new (driver.Screen);
var superView = new View
{
@@ -278,7 +276,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
X = 5,
Y = 5,
Width = 30,
Height = 30,
Height = 30
};
superView.Add (view);
superView.LayoutSubViews ();
@@ -296,14 +294,15 @@ public class ViewDrawingClippingTests () : FakeDriverBase
// Restore superView clip
view.SetClip (superViewClip);
// Assert.Equal (superViewBounds, driver.Clip.GetBounds ());
}
[Fact]
public void ClipRegions_RespectPreviousClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
var initialClip = new Region (new Rectangle (20, 20, 40, 40));
IDriver driver = CreateFakeDriver ();
var initialClip = new Region (new (20, 20, 40, 40));
driver.Clip = initialClip;
var view = new View
@@ -322,9 +321,9 @@ public class ViewDrawingClippingTests () : FakeDriverBase
// The new clip should be the intersection of the initial clip and the view's frame
Rectangle expected = Rectangle.Intersect (
initialClip.GetBounds (),
view.FrameToScreen ()
);
initialClip.GetBounds (),
view.FrameToScreen ()
);
Assert.Equal (expected, driver.Clip.GetBounds ());
@@ -340,8 +339,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void AddFrameToClip_EmptyFrame_WorksCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -364,18 +363,18 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void AddViewportToClip_EmptyViewport_WorksCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
X = 1,
Y = 1,
Width = 1, // Minimal size to have adornments
Width = 1, // Minimal size to have adornments
Height = 1,
Driver = driver
};
view.Border!.Thickness = new Thickness (1);
view.Border!.Thickness = new (1);
view.BeginInit ();
view.EndInit ();
view.LayoutSubViews ();
@@ -391,12 +390,12 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void ClipRegions_OutOfBounds_HandledCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
X = 100, // Outside screen bounds
X = 100, // Outside screen bounds
Y = 100,
Width = 20,
Height = 20,
@@ -409,6 +408,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Region? previous = view.AddFrameToClip ();
Assert.NotNull (previous);
// The clip should be empty since the view is outside the screen
Assert.True (driver.Clip.IsEmpty () || !driver.Clip.Contains (100, 100));
}
@@ -420,8 +420,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Clip_Set_BeforeDraw_ClipsDrawing ()
{
IDriver driver = CreateFakeDriver (80, 25);
var clip = new Region (new Rectangle (10, 10, 10, 10));
IDriver driver = CreateFakeDriver ();
var clip = new Region (new (10, 10, 10, 10));
driver.Clip = clip;
var view = new View
@@ -445,8 +445,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_UpdatesDriverClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -464,14 +464,15 @@ public class ViewDrawingClippingTests () : FakeDriverBase
// Clip should be updated to exclude the drawn view
Assert.NotNull (driver.Clip);
// Assert.False (driver.Clip.Contains (15, 15)); // Point inside the view should be excluded
}
[Fact]
public void Draw_WithSubViews_ClipsCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var superView = new View
{
@@ -491,13 +492,277 @@ public class ViewDrawingClippingTests () : FakeDriverBase
// Both superView and view should be excluded from clip
Assert.NotNull (driver.Clip);
// Assert.False (driver.Clip.Contains (15, 15)); // Point in superView should be excluded
}
/// <summary>
/// Tests that wide glyphs (🍎) are correctly clipped when overlapped by bordered subviews
/// at different column alignments (even vs odd). Demonstrates:
/// 1. Full clipping at even columns (X=0, X=2)
/// 2. Partial clipping at odd columns (X=1) resulting in half-glyphs (<28>)
/// 3. The recursive draw flow and clip exclusion mechanism
///
/// For detailed draw flow documentation, see ViewDrawingClippingTests.DrawFlow.md
/// </summary>
[Fact]
public void Draw_WithBorderSubView_DrawsCorrectly ()
{
IApplication app = Application.Create ();
app.Init ("fake");
IDriver driver = app!.Driver!;
driver.SetScreenSize (30, 20);
driver!.Clip = new (driver.Screen);
var superView = new Runnable ()
{
X = 0,
Y = 0,
Width = Dim.Auto () + 4,
Height = Dim.Auto () + 1,
Driver = driver
};
Rune codepoint = Glyphs.Apple;
superView.DrawingContent += (s, e) =>
{
var view = s as View;
for (var r = 0; r < view!.Viewport.Height; r++)
{
for (var c = 0; c < view.Viewport.Width; c += 2)
{
if (codepoint != default (Rune))
{
view.AddRune (c, r, codepoint);
}
}
}
e.DrawContext?.AddDrawnRectangle (view.Viewport);
e.Cancel = true;
};
var viewWithBorderAtX0 = new View
{
Text = "viewWithBorderAtX0",
BorderStyle = LineStyle.Dashed,
X = 0,
Y = 1,
Width = Dim.Auto (),
Height = 3
};
var viewWithBorderAtX1 = new View
{
Text = "viewWithBorderAtX1",
BorderStyle = LineStyle.Dashed,
X = 1,
Y = Pos.Bottom (viewWithBorderAtX0) + 1,
Width = Dim.Auto (),
Height = 3
};
var viewWithBorderAtX2 = new View
{
Text = "viewWithBorderAtX2",
BorderStyle = LineStyle.Dashed,
X = 2,
Y = Pos.Bottom (viewWithBorderAtX1) + 1,
Width = Dim.Auto (),
Height = 3
};
superView.Add (viewWithBorderAtX0, viewWithBorderAtX1, viewWithBorderAtX2);
app.Begin (superView);
// Begin calls LayoutAndDraw, so no need to call it again here
// app.LayoutAndDraw();
DriverAssert.AssertDriverContentsAre (
"""
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎
viewWithBorderAtX0🍎🍎🍎
🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
<EFBFBD> 🍎🍎
<EFBFBD>viewWithBorderAtX1 🍎🍎
<EFBFBD> 🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎
🍎viewWithBorderAtX2🍎🍎
🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
""",
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<39>┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ 🍎🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m \x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<39>┆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<39>└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ 🍎🍎\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;
FakeOutput? fakeOutput = driverImpl!.GetOutput () as FakeOutput;
output.WriteLine ("Driver Output After Redraw:\n" + driver.GetOutput().GetLastOutput());
// BUGBUG: Border.set_LineStyle does not call SetNeedsDraw
viewWithBorderAtX1!.Border!.LineStyle = LineStyle.Single;
viewWithBorderAtX1.Border!.SetNeedsDraw ();
app.LayoutAndDraw ();
DriverAssert.AssertDriverContentsAre (
"""
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎
viewWithBorderAtX0🍎🍎🍎
🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
<EFBFBD> 🍎🍎
<EFBFBD>viewWithBorderAtX1 🍎🍎
<EFBFBD> 🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
🍎🍎🍎
🍎viewWithBorderAtX2🍎🍎
🍎🍎🍎
🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎🍎
""",
output,
driver);
}
[Fact]
public void Draw_WithBorderSubView_At_Col1_In_WideGlyph_DrawsCorrectly ()
{
IApplication app = Application.Create ();
app.Init ("fake");
IDriver driver = app!.Driver!;
driver.SetScreenSize (6, 3); // Minimal: 6 cols wide (3 for content + 2 for border + 1), 3 rows high (1 for content + 2 for border)
driver!.Clip = new (driver.Screen);
var superView = new Runnable ()
{
X = 0,
Y = 0,
Width = Dim.Fill (),
Height = Dim.Fill (),
Driver = driver
};
Rune codepoint = Glyphs.Apple;
superView.DrawingContent += (s, e) =>
{
View? view = s as View;
view?.AddStr (0, 0, "🍎🍎🍎🍎");
view?.AddStr (0, 1, "🍎🍎🍎🍎");
view?.AddStr (0, 2, "🍎🍎🍎🍎");
e.DrawContext?.AddDrawnRectangle (view!.Viewport);
e.Cancel = true;
};
// Minimal border at X=1 (odd column), Width=3, Height=3 (includes border)
var viewWithBorder = new View
{
Text = "X",
BorderStyle = LineStyle.Single,
X = 1,
Y = 0,
Width = 3,
Height = 3
};
superView.Add (viewWithBorder);
app.Begin (superView);
DriverAssert.AssertDriverContentsAre (
"""
<EFBFBD>🍎
<EFBFBD>X🍎
<EFBFBD>🍎
""",
output,
driver);
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m<39>┌─┐🍎<E29490>│X│🍎<E29482>└─┘🍎",
output, driver);
DriverImpl? driverImpl = driver as DriverImpl;
FakeOutput? fakeOutput = driverImpl!.GetOutput () as FakeOutput;
output.WriteLine ("Driver Output:\n" + fakeOutput!.GetLastOutput ());
}
[Fact]
public void Draw_WithBorderSubView_At_Col3_In_WideGlyph_DrawsCorrectly ()
{
IApplication app = Application.Create ();
app.Init ("fake");
IDriver driver = app!.Driver!;
driver.SetScreenSize (6, 3); // Screen: 6 cols wide, 3 rows high; enough for 3x3 border subview at col 3 plus content on the left
driver!.Clip = new (driver.Screen);
var superView = new Runnable ()
{
X = 0,
Y = 0,
Width = Dim.Fill (),
Height = Dim.Fill (),
Driver = driver
};
Rune codepoint = Glyphs.Apple;
superView.DrawingContent += (s, e) =>
{
View? view = s as View;
view?.AddStr (0, 0, "🍎🍎🍎🍎");
view?.AddStr (0, 1, "🍎🍎🍎🍎");
view?.AddStr (0, 2, "🍎🍎🍎🍎");
e.DrawContext?.AddDrawnRectangle (view!.Viewport);
e.Cancel = true;
};
// Minimal border at X=3 (odd column), Width=3, Height=3 (includes border)
var viewWithBorder = new View
{
Text = "X",
BorderStyle = LineStyle.Single,
X = 3,
Y = 0,
Width = 3,
Height = 3
};
superView.Add (viewWithBorder);
app.Begin (superView);
DriverAssert.AssertDriverContentsAre (
"""
🍎<EFBFBD>
🍎<EFBFBD>X
🍎<EFBFBD>
""",
output,
driver);
DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m🍎<6D>┌─┐🍎<E29490>│X│🍎<E29482>└─┘",
output, driver);
DriverImpl? driverImpl = driver as DriverImpl;
FakeOutput? fakeOutput = driverImpl!.GetOutput () as FakeOutput;
output.WriteLine ("Driver Output:\n" + fakeOutput!.GetLastOutput ());
}
[Fact]
public void Draw_NonVisibleView_DoesNotUpdateClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
IDriver driver = CreateFakeDriver ();
var originalClip = new Region (driver.Screen);
driver.Clip = originalClip.Clone ();
@@ -522,8 +787,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void ExcludeFromClip_ExcludesRegion ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -542,13 +807,12 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Assert.NotNull (driver.Clip);
Assert.False (driver.Clip.Contains (20, 20)); // Point inside excluded rect should not be in clip
}
[Fact]
public void ExcludeFromClip_WithNullClip_DoesNotThrow ()
{
IDriver driver = CreateFakeDriver (80, 25);
IDriver driver = CreateFakeDriver ();
driver.Clip = null!;
var view = new View
@@ -560,10 +824,9 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Driver = driver
};
var exception = Record.Exception (() => view.ExcludeFromClip (new Rectangle (15, 15, 10, 10)));
Exception? exception = Record.Exception (() => view.ExcludeFromClip (new Rectangle (15, 15, 10, 10)));
Assert.Null (exception);
}
#endregion
@@ -573,7 +836,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void SetClip_SetsDriverClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
IDriver driver = CreateFakeDriver ();
var view = new View
{
@@ -584,7 +847,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Driver = driver
};
var newClip = new Region (new Rectangle (5, 5, 30, 30));
var newClip = new Region (new (5, 5, 30, 30));
view.SetClip (newClip);
Assert.Equal (newClip, driver.Clip);
@@ -593,8 +856,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact (Skip = "See BUGBUG in SetClip")]
public void SetClip_WithNullClip_ClearsClip ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (new Rectangle (10, 10, 20, 20));
IDriver driver = CreateFakeDriver ();
driver.Clip = new (new (10, 10, 20, 20));
var view = new View
{
@@ -613,7 +876,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_Excludes_View_From_Clip ()
{
IDriver driver = CreateFakeDriver (80, 25);
IDriver driver = CreateFakeDriver ();
var originalClip = new Region (driver.Screen);
driver.Clip = originalClip.Clone ();
@@ -641,8 +904,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_EmptyViewport_DoesNotCrash ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -652,13 +915,13 @@ public class ViewDrawingClippingTests () : FakeDriverBase
Height = 1,
Driver = driver
};
view.Border!.Thickness = new Thickness (1);
view.Border!.Thickness = new (1);
view.BeginInit ();
view.EndInit ();
view.LayoutSubViews ();
// With border of 1, viewport should be empty (0x0 or negative)
var exception = Record.Exception (() => view.Draw ());
Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}
@@ -666,8 +929,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_VeryLargeView_HandlesClippingCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -681,7 +944,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
view.EndInit ();
view.LayoutSubViews ();
var exception = Record.Exception (() => view.Draw ());
Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}
@@ -689,8 +952,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_NegativeCoordinates_HandlesClippingCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -704,7 +967,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
view.EndInit ();
view.LayoutSubViews ();
var exception = Record.Exception (() => view.Draw ());
Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}
@@ -712,8 +975,8 @@ public class ViewDrawingClippingTests () : FakeDriverBase
[Fact]
public void Draw_OutOfScreenBounds_HandlesClippingCorrectly ()
{
IDriver driver = CreateFakeDriver (80, 25);
driver.Clip = new Region (driver.Screen);
IDriver driver = CreateFakeDriver ();
driver.Clip = new (driver.Screen);
var view = new View
{
@@ -727,7 +990,7 @@ public class ViewDrawingClippingTests () : FakeDriverBase
view.EndInit ();
view.LayoutSubViews ();
var exception = Record.Exception (() => view.Draw ());
Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}