diff --git a/Examples/UICatalog/Scenarios/Keys.cs b/Examples/UICatalog/Scenarios/Keys.cs
index 49b9ccc70c..ef82b9208e 100644
--- a/Examples/UICatalog/Scenarios/Keys.cs
+++ b/Examples/UICatalog/Scenarios/Keys.cs
@@ -158,7 +158,7 @@ public override void Main ()
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");
diff --git a/Examples/UICatalog/Scenarios/WideGlyphs.cs b/Examples/UICatalog/Scenarios/WideGlyphs.cs
index 25e5011186..16e30b7230 100644
--- a/Examples/UICatalog/Scenarios/WideGlyphs.cs
+++ b/Examples/UICatalog/Scenarios/WideGlyphs.cs
@@ -25,7 +25,7 @@ public override void Main ()
};
// 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 override void Main ()
}
// 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 override void Main ()
};
// 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 override void Main ()
}
};
- Line verticalLineAtEven = new Line ()
+ Line verticalLineAtEven = new ()
{
X = 10,
Orientation = Orientation.Vertical,
@@ -81,7 +83,7 @@ public override void Main ()
};
appWindow.Add (verticalLineAtEven);
- Line verticalLineAtOdd = new Line ()
+ Line verticalLineAtOdd = new ()
{
X = 25,
Orientation = Orientation.Vertical,
@@ -97,8 +99,12 @@ public override void Main ()
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 override void Main ()
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 @@ private Rune GetRandomWideCodepoint ()
{
Random random = new ();
int codepoint = random.Next (0x4E00, 0x9FFF);
- return new Rune (codepoint);
+ return new (codepoint);
}
}
diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
index fb4ff0c9bf..9b07f8346d 100644
--- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
+++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
@@ -109,6 +109,7 @@ protected override void AppendOrWriteAttribute (StringBuilder output, Attribute
///
protected override void Write (StringBuilder output)
{
+ base.Write (output);
try
{
Console.Out.Write (output);
@@ -140,7 +141,7 @@ protected override bool SetCursorPositionImpl (int col, int row)
}
catch (Exception)
{
- return false;
+ return true;
}
}
diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs
index ac5e513bd5..5c0d5ad72c 100644
--- a/Terminal.Gui/Drivers/DriverImpl.cs
+++ b/Terminal.Gui/Drivers/DriverImpl.cs
@@ -45,19 +45,19 @@ public DriverImpl (
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 @@ ISizeMonitor sizeMonitor
public void Init () { throw new NotSupportedException (); }
///
- public void Refresh () { _output.Write (OutputBuffer); }
+ public void Refresh ()
+ {
+ _output.Write (OutputBuffer);
+ }
///
- public string? GetName () => InputProcessor.DriverName?.ToLowerInvariant ();
+ public string? GetName () => GetInputProcessor ().DriverName?.ToLowerInvariant ();
///
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 @@ public void Dispose ()
private readonly IOutput _output;
+ public IOutput GetOutput () => _output;
+
+ private readonly IInputProcessor _inputProcessor;
+
///
- public IInputProcessor InputProcessor { get; }
+ public IInputProcessor GetInputProcessor () => _inputProcessor;
///
public IOutputBuffer OutputBuffer { get; }
@@ -157,7 +164,7 @@ public void Dispose ()
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 @@ public bool SetCursorVisibility (CursorVisibility visibility)
public event EventHandler? KeyUp;
///
- public void EnqueueKeyEvent (Key key) { InputProcessor.EnqueueKeyDownEvent (key); }
+ public void EnqueueKeyEvent (Key key) { GetInputProcessor ().EnqueueKeyDownEvent (key); }
#endregion Input Events
diff --git a/Terminal.Gui/Drivers/FakeDriver/FakeOutput.cs b/Terminal.Gui/Drivers/FakeDriver/FakeOutput.cs
index 0bf504baba..1247389430 100644
--- a/Terminal.Gui/Drivers/FakeDriver/FakeOutput.cs
+++ b/Terminal.Gui/Drivers/FakeDriver/FakeOutput.cs
@@ -7,29 +7,29 @@ namespace Terminal.Gui.Drivers;
///
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;
///
///
///
public FakeOutput ()
{
- LastBuffer = new OutputBufferImpl ();
- LastBuffer.SetSize (80, 25);
+ _lastBuffer = new OutputBufferImpl ();
+ _lastBuffer.SetSize (80, 25);
}
///
- /// Gets or sets the last output buffer written.
+ /// Gets or sets the last output buffer written. The contains
+ /// a reference to the buffer last written with .
///
- public IOutputBuffer? LastBuffer { get; set; }
+ public IOutputBuffer? GetLastBuffer () => _lastBuffer;
- ///
- /// Gets the captured output as a string.
- ///
- public string Output => _output.ToString ();
+ /////
+ //public override string GetLastOutput () => _outputStringBuilder.ToString ();
///
public Point GetCursorPosition ()
@@ -61,28 +61,28 @@ protected override bool SetCursorPositionImpl (int col, int row)
///
public void Write (ReadOnlySpan text)
{
- _output.Append (text);
+// _outputStringBuilder.Append (text);
}
- ///
+ ///
public override void Write (IOutputBuffer buffer)
{
- LastBuffer = buffer;
+ _lastBuffer = buffer;
base.Write (buffer);
}
+ /////
+ //protected override void Write (StringBuilder output)
+ //{
+ // _outputStringBuilder.Append (output);
+ //}
+
///
public override void SetCursorVisibility (CursorVisibility visibility)
{
// Capture but don't act on it in fake output
}
- ///
- public void Dispose ()
- {
- // Nothing to dispose
- }
-
///
protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle)
{
@@ -123,8 +123,8 @@ protected override void AppendOrWriteAttribute (StringBuilder output, Attribute
}
///
- protected override void Write (StringBuilder output)
+ public void Dispose ()
{
- _output.Append (output);
+ // Nothing to dispose
}
}
diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs
index 0447ee95e3..0abd121b76 100644
--- a/Terminal.Gui/Drivers/IDriver.cs
+++ b/Terminal.Gui/Drivers/IDriver.cs
@@ -61,7 +61,12 @@ public interface IDriver : IDisposable
/// e.g. into events
/// and detecting and processing ansi escape sequences.
///
- IInputProcessor InputProcessor { get; }
+ IInputProcessor GetInputProcessor ();
+
+ ///
+ /// Gets the output handler responsible for writing to the terminal.
+ ///
+ IOutput GetOutput ();
/// Get the operating system clipboard.
IClipboard? Clipboard { get; }
diff --git a/Terminal.Gui/Drivers/IOutput.cs b/Terminal.Gui/Drivers/IOutput.cs
index d8ddc791e9..7c97de5521 100644
--- a/Terminal.Gui/Drivers/IOutput.cs
+++ b/Terminal.Gui/Drivers/IOutput.cs
@@ -65,6 +65,12 @@ public interface IOutput : IDisposable
///
void Write (IOutputBuffer buffer);
+ ///
+ /// Gets a string containing the ANSI escape sequences and content most recently written
+ /// to the terminal via
+ ///
+ string GetLastOutput ();
+
///
/// Generates an ANSI escape sequence string representation of the given contents.
/// This is the same output that would be written to the terminal to recreate the current screen contents.
diff --git a/Terminal.Gui/Drivers/OutputBase.cs b/Terminal.Gui/Drivers/OutputBase.cs
index 618448b458..347eba70b3 100644
--- a/Terminal.Gui/Drivers/OutputBase.cs
+++ b/Terminal.Gui/Drivers/OutputBase.cs
@@ -56,19 +56,27 @@ public bool IsLegacyConsole
///
public abstract void SetCursorVisibility (CursorVisibility visibility);
- ///
+ StringBuilder _lastOutputStringBuilder = new ();
+
+ ///
+ /// 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 ApplicationMainLoop.SetCursor().
+ ///
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 virtual void Write (IOutputBuffer buffer)
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 virtual void Write (IOutputBuffer buffer)
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 virtual void Write (IOutputBuffer buffer)
return;
}
+ // Render queued sixel images
foreach (SixelToRender s in GetSixels ())
{
if (string.IsNullOrWhiteSpace (s.SixelData))
@@ -150,12 +165,12 @@ public virtual void Write (IOutputBuffer buffer)
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
}
+ ///
+ public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString ();
+
///
/// Changes the color and text style of the console to the given and
/// .
@@ -180,7 +195,10 @@ public virtual void Write (IOutputBuffer buffer)
/// Output the contents of the to the console.
///
///
- protected abstract void Write (StringBuilder output);
+ protected virtual void Write (StringBuilder output)
+ {
+ _lastOutputStringBuilder.Append (output);
+ }
///
/// Builds ANSI escape sequences for the specified rectangular region of the buffer.
@@ -273,7 +291,7 @@ protected void AppendCellAnsi (Cell cell, StringBuilder output, ref Attribute? l
/// A string containing ANSI escape sequences representing the buffer contents.
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 string ToAnsi (IOutputBuffer buffer)
return output.ToString ();
}
+ ///
+ /// Writes buffered output to console, wrapping URLs with OSC 8 hyperlinks (non-legacy only),
+ /// then clears the buffer and advances by .
+ ///
private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outputWidth)
{
if (IsLegacyConsole)
@@ -289,7 +311,7 @@ private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outp
}
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);
}
diff --git a/Terminal.Gui/Drivers/OutputBufferImpl.cs b/Terminal.Gui/Drivers/OutputBufferImpl.cs
index ffe2548513..c12dc29f51 100644
--- a/Terminal.Gui/Drivers/OutputBufferImpl.cs
+++ b/Terminal.Gui/Drivers/OutputBufferImpl.cs
@@ -14,7 +14,7 @@ public class OutputBufferImpl : IOutputBuffer
/// UpdateScreen is called.
/// The format of the array is rows, columns. The first index is the row, the second index is the column.
///
- 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 int Cols
public virtual int Top { get; set; } = 0;
///
- /// Indicates which lines have been modified and need to be redrawn.
+ /// Indicates which lines have been modified and need to be redrawn.
///
public bool [] DirtyLines { get; set; } = [];
@@ -138,116 +138,149 @@ public void AddStr (string str)
{
foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
{
- string text = grapheme;
+ AddGrapheme (grapheme);
+ }
+ }
- if (Contents is null)
- {
- return;
- }
+ ///
+ /// Adds a single grapheme to the display at the current cursor position.
+ ///
+ /// The grapheme to add.
+ private void AddGrapheme (string grapheme)
+ {
+ if (Contents is null)
+ {
+ return;
+ }
- Clip ??= new (Screen);
+ Clip ??= new (Screen);
+ Rectangle clipRect = Clip!.GetBounds ();
- Rectangle clipRect = Clip!.GetBounds ();
+ string text = grapheme;
+ int textWidth = -1;
- int textWidth = -1;
- bool validLocation = false;
+ lock (Contents)
+ {
+ bool validLocation = IsValidLocation (text, Col, Row);
- lock (Contents)
+ if (validLocation)
{
- // 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;
+ text = text.MakePrintable ();
+ textWidth = text.GetColumns ();
- 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;
- }
- }
+ // Set attribute and mark dirty for current cell
+ Contents [Row, Col].Attribute = CurrentAttribute;
+ Contents [Row, Col].IsDirty = true;
- if (textWidth is 0 or 1)
- {
- Contents [Row, Col].Grapheme = text;
+ InvalidateOverlappedWideGlyph ();
- 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;
- }
+ WriteGraphemeByWidth (text, textWidth, clipRect);
- DirtyLines [Row] = true;
- }
+ 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);
+ // 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++;
+ }
+ }
+ }
- 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;
- }
- }
- }
+ ///
+ /// 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 ()
+ {
+ if (Col > 0 && Contents! [Row, Col - 1].Grapheme.GetColumns () > 1)
+ {
+ Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString ();
+ Contents [Row, Col - 1].IsDirty = true;
+ }
+ }
- Col++;
+ ///
+ /// Writes a grapheme to the buffer based on its width (0, 1, or 2 columns).
+ ///
+ /// The printable text to write.
+ /// The column width of the text.
+ /// The clipping rectangle.
+ 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;
+ }
+ }
+
+ ///
+ /// Writes a single-width character (0 or 1 column wide).
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Writes a wide character (2 columns wide) handling clipping and partial overlap cases.
+ ///
+ 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
+ }
}
/// Clears the of the driver.
diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs
index 6c1366777b..36381b7c35 100644
--- a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs
+++ b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs
@@ -66,6 +66,7 @@ protected override void AppendOrWriteAttribute (StringBuilder output, Attribute
///
protected override void Write (StringBuilder output)
{
+ base.Write (output);
try
{
byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ());
diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs
index 9ca53790a5..119a322746 100644
--- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs
+++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs
@@ -184,7 +184,8 @@ public void Write (ReadOnlySpan str)
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 @@ protected override void Write (StringBuilder output)
{
return;
}
+ base.Write (output);
var str = output.ToString ();
diff --git a/Terminal.Gui/ViewBase/Adornment/Border.cs b/Terminal.Gui/ViewBase/Adornment/Border.cs
index 3e996c270b..4d1b9ea8f8 100644
--- a/Terminal.Gui/ViewBase/Adornment/Border.cs
+++ b/Terminal.Gui/ViewBase/Adornment/Border.cs
@@ -214,6 +214,7 @@ public LineStyle LineStyle
// TODO: all this.
return Parent?.SuperView?.BorderStyle ?? LineStyle.None;
}
+ // BUGBUG: Setting LineStyle should SetNeedsDraw
set => _lineStyle = value;
}
diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs
index 1b46d0d752..871128e0af 100644
--- a/Terminal.Gui/ViewBase/View.Drawing.cs
+++ b/Terminal.Gui/ViewBase/View.Drawing.cs
@@ -6,7 +6,7 @@ namespace Terminal.Gui.ViewBase;
public partial class View // Drawing APIs
{
///
- /// Draws a set of views.
+ /// Draws a set of peer views (views that share the same SuperView).
///
/// The peer views to draw.
/// If , will be called on each view to force it to be drawn.
@@ -39,8 +39,8 @@ internal static void Draw (IEnumerable views, bool force)
// 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 void Draw (DrawContext? context = null)
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 void Draw (DrawContext? context = null)
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 void Draw (DrawContext? context = null)
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 void Draw (DrawContext? context = null)
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 @@ private void ClearFrame ()
///
/// Called when the View's Adornments are to be drawn. Prepares . If
/// is true, only the
- /// of this view's subviews will be rendered. If is
+ /// of this view's SubViews will be rendered. If is
/// false (the default), this method will cause the be prepared to be rendered.
///
/// to stop further drawing of the Adornments.
@@ -481,7 +486,7 @@ public void DrawText (DrawContext? context = null)
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 @@ private void DoDrawContent (DrawContext? context = null)
/// such as , , and .
///
///
- /// The event is invoked after and have been drawn, but before any are drawn.
+ /// The event is invoked after and have been drawn, but after have been drawn.
///
///
/// Transparency Support: If the View has with
@@ -650,7 +655,8 @@ public void DrawSubViews (DrawContext? context = null)
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 @@ private void DoRenderLineCanvas (DrawContext? context)
/// to stop further drawing of .
protected virtual bool OnRenderingLineCanvas () { return false; }
- /// The canvas that any line drawing that is to be shared by subviews of this view should add lines to.
+ /// The canvas that any line drawing that is to be shared by SubViews of this view should add lines to.
/// adds border lines to this LineCanvas.
public LineCanvas LineCanvas { get; } = new ();
///
- /// Gets or sets whether this View will use it's SuperView's for rendering any
- /// lines. If 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 for rendering any
+ /// lines. If the rendering of any borders drawn by this view will be done by its
/// SuperView. If (the default) this View's method will
- /// be
- /// called to render the borders.
+ /// be called to render the borders.
///
public virtual bool SuperViewRendersLineCanvas { get; set; } = false;
///
/// Causes the contents of to be drawn.
/// If is true, only the
- /// of this view's subviews will be rendered. If is
+ /// of this view's SubViews will be rendered. If is
/// false (the default), this method will cause the to be rendered.
///
///
@@ -732,7 +737,7 @@ public void RenderLineCanvas (DrawContext? context)
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 void RenderLineCanvas (DrawContext? context)
#region DrawComplete
+ ///
+ /// Called at the end of to finalize drawing and update the clip region.
+ ///
+ ///
+ /// The tracking what regions were drawn by this view and its subviews.
+ /// May be if not tracking drawn regions.
+ ///
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.
}
///
- /// Called when the View is completed drawing.
+ /// Called when the View has completed drawing and is about to update the clip region.
///
+ ///
+ /// The containing the regions that were drawn by this view and its subviews.
+ /// May be if not tracking drawn regions.
+ ///
///
- /// The parameter provides the drawn region of the View.
+ ///
+ /// This method is called at the very end of , after all drawing
+ /// (adornments, content, text, subviews, line canvas) has completed but before the view's area
+ /// is excluded from .
+ ///
+ ///
+ /// Use this method to:
+ ///
+ ///
+ /// -
+ /// Perform any final drawing operations that need to happen after SubViews are drawn
+ ///
+ /// -
+ /// Inspect what was drawn via the parameter
+ ///
+ /// -
+ /// Add additional regions to the if needed
+ ///
+ ///
+ ///
+ /// Important: At this point, has been restored to the state
+ /// it was in when began. After this method returns, the view's
+ /// area will be excluded from the clip (see for details).
+ ///
+ ///
+ /// Transparency Support: If includes
+ /// , the 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.
+ ///
///
+ ///
+ ///
+ ///
protected virtual void OnDrawComplete (DrawContext? context) { }
- /// Raised when the View is completed drawing.
+ /// Raised when the View has completed drawing and is about to update the clip region.
///
+ ///
+ /// This event is raised at the very end of , after all drawing
+ /// operations have completed but before the view's area is excluded from .
+ ///
+ ///
+ /// The property provides information about what regions
+ /// were drawn by this view and its subviews. This is particularly useful for views with
+ /// enabled, as it shows exactly which areas
+ /// will be excluded from the clip.
+ ///
+ ///
+ /// Use this event to:
+ ///
+ ///
+ /// -
+ /// Perform any final drawing operations
+ ///
+ /// -
+ /// Inspect what was drawn
+ ///
+ /// -
+ /// Track drawing statistics or metrics
+ ///
+ ///
+ ///
+ /// Note: This event fires after . If you need
+ /// to override the behavior, prefer overriding the virtual method in a subclass rather than
+ /// subscribing to this event.
+ ///
///
+ ///
+ ///
public event EventHandler? DrawComplete;
#endregion DrawComplete
diff --git a/Tests/TerminalGuiFluentTesting/GuiTestContext.Input.cs b/Tests/TerminalGuiFluentTesting/GuiTestContext.Input.cs
index 01bbdade83..9343561a1a 100644
--- a/Tests/TerminalGuiFluentTesting/GuiTestContext.Input.cs
+++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.Input.cs
@@ -64,7 +64,7 @@ private GuiTestContext EnqueueMouseEvent (MouseEventArgs mouseEvent)
{
mouseEvent.Position = mouseEvent.ScreenPosition;
- app.Driver.InputProcessor.EnqueueMouseEvent (app, mouseEvent);
+ app.Driver.GetInputProcessor ().EnqueueMouseEvent (app, mouseEvent);
}
else
{
diff --git a/Tests/UnitTests/DriverAssert.cs b/Tests/UnitTests/DriverAssert.cs
index b837e462de..0ec8b76231 100644
--- a/Tests/UnitTests/DriverAssert.cs
+++ b/Tests/UnitTests/DriverAssert.cs
@@ -47,7 +47,7 @@ params Attribute [] expectedAttributes
{
driver = Application.Driver;
}
- ArgumentNullException.ThrowIfNull(driver);
+ ArgumentNullException.ThrowIfNull (driver);
Cell [,] contents = driver!.Contents!;
@@ -193,6 +193,134 @@ public static void AssertDriverContentsAre (
Assert.Equal (expectedLook, actualLook);
}
+#pragma warning disable xUnit1013 // Public method should be marked as test
+ /// Asserts that the driver raw ANSI output matches the expected output.
+ /// Expected output with C# escape sequences (e.g., \x1b for ESC)
+ ///
+ /// The IDriver to use. If null will be used.
+ 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);
+ }
+
+ ///
+ /// Unescapes a C# string literal by processing escape sequences like \x1b, \n, \r, \t, etc.
+ ///
+ /// String with C# escape sequences
+ /// String with escape sequences converted to actual characters
+ 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 ();
+ }
///
/// Asserts that the driver contents are equal to the provided string.
///
diff --git a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs
index 2c2aa0aaa1..4ec35f7700 100644
--- a/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs
@@ -3,14 +3,10 @@
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 void AddRune_MovesToNextColumn_Wide ()
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 (
+ """
+ ๏ฟฝโโโ๐
+ """,
+ 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",
+ output, driver);
+ }
}
diff --git a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs
index a8a2a5531e..c07d4070d4 100644
--- a/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/ClipRegionTests.cs
@@ -1,4 +1,5 @@
๏ปฟ#nullable enable
+using System.Text;
using UnitTests;
using Xunit.Abstractions;
diff --git a/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs b/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs
index 38a40ab11a..9d88eb7306 100644
--- a/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/DriverTests.cs
@@ -92,6 +92,51 @@ public void All_Drivers_LayoutAndDraw_Cross_Platform (string driverName)
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 (
+ """
+ ๏ฟฝโโโ๐
+ """,
+ 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",
+ output, driver);
+ }
}
public class TestTop : Runnable
diff --git a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs
index 1553149ac0..6ac00a7395 100644
--- a/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/OutputBaseTests.cs
@@ -1,6 +1,4 @@
-๏ปฟ#nullable enable
-
-namespace DriverTests;
+๏ปฟnamespace DriverTests;
public class OutputBaseTests
{
@@ -9,7 +7,7 @@ public void ToAnsi_SingleCell_NoAttribute_ReturnsGraphemeAndNewline ()
{
// Arrange
var output = new FakeOutput ();
- IOutputBuffer buffer = output.LastBuffer!;
+ IOutputBuffer buffer = output.GetLastBuffer ()!;
buffer.SetSize (1, 1);
// Act
@@ -32,21 +30,21 @@ public void ToAnsi_WithAttribute_AppendsCorrectColorSequence_BasedOnIsLegacyCons
// 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 void ToAnsi_WithAttribute_AppendsCorrectColorSequence_BasedOnIsLegacyCons
}
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 void Write_WritesDirtyCellsAndClearsDirtyFlags ()
{
// 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 void Write_WritesDirtyCellsAndClearsDirtyFlags ()
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 void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla
// 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 void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla
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 void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla
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 void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla
// 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 ("๏ฟฝ", output.Output);
- Assert.Contains ("X", output.Output);
+ Assert.Contains ("๏ฟฝ", 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 void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
{
// 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 void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
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 void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
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 void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
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 void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
app.Dispose ();
}
-}
\ No newline at end of file
+}
diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs
index 220487dcd7..7774d1886f 100644
--- a/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs
+++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingClippingTests.cs
@@ -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 void GetClip_ReturnsDriverClip ()
[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 void SetClip_NullRegion_DoesNothing ()
[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 void SetClip_ValidRegion_SetsDriverClip ()
[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 void SetClipToScreen_ReturnsPreviousClip ()
[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 void SetClipToScreen_SetsClipToScreen ()
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 void ExcludeFromClip_Region_NullDriver_DoesNotThrow ()
{
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 void AddFrameToClip_NullDriver_ReturnsNull ()
[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 void AddFrameToClip_IntersectsWithFrame ()
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 void AddViewportToClip_NullDriver_ReturnsNull ()
[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 void AddViewportToClip_IntersectsWithViewport ()
[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 void AddViewportToClip_WithClipContentOnly_LimitsToVisibleContent ()
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 void ClipRegions_StackCorrectly_WithNestedViews ()
X = 5,
Y = 5,
Width = 30,
- Height = 30,
+ Height = 30
};
superView.Add (view);
superView.LayoutSubViews ();
@@ -296,14 +294,15 @@ public void ClipRegions_StackCorrectly_WithNestedViews ()
// 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 void ClipRegions_RespectPreviousClip ()
// 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 void ClipRegions_RespectPreviousClip ()
[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 void AddFrameToClip_EmptyFrame_WorksCorrectly ()
[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 void AddViewportToClip_EmptyViewport_WorksCorrectly ()
[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 void ClipRegions_OutOfBounds_HandledCorrectly ()
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 void ClipRegions_OutOfBounds_HandledCorrectly ()
[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 void Clip_Set_BeforeDraw_ClipsDrawing ()
[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 void Draw_UpdatesDriverClip ()
// 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 void Draw_WithSubViews_ClipsCorrectly ()
// 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
}
+ ///
+ /// 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 (๏ฟฝ)
+ /// 3. The recursive draw flow and clip exclusion mechanism
+ ///
+ /// For detailed draw flow documentation, see ViewDrawingClippingTests.DrawFlow.md
+ ///
+ [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โ๐๐๐
+ โโโโโโโโโโโโโโโโโโโโ๐๐๐
+ ๐๐๐๐๐๐๐๐๐๐๐๐๐
+ ๏ฟฝโโโโโโโโโโโโโโโโโโโโ ๐๐
+ ๏ฟฝโviewWithBorderAtX1โ ๐๐
+ ๏ฟฝโโโโโโโโโโโโโโโโโโโโ ๐๐
+ ๐๐๐๐๐๐๐๐๐๐๐๐๐
+ ๐โโโโโโโโโโโโโโโโโโโโ๐๐
+ ๐โ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๏ฟฝโโโโโโโโโโโโโโโโโโโโ ๐๐\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;
+ 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โ๐๐๐
+ โโโโโโโโโโโโโโโโโโโโ๐๐๐
+ ๐๐๐๐๐๐๐๐๐๐๐๐๐
+ ๏ฟฝโโโโโโโโโโโโโโโโโโโโ ๐๐
+ ๏ฟฝโviewWithBorderAtX1โ ๐๐
+ ๏ฟฝโโโโโโโโโโโโโโโโโโโโ ๐๐
+ ๐๐๐๐๐๐๐๐๐๐๐๐๐
+ ๐โโโโโโโโโโโโโโโโโโโโ๐๐
+ ๐โ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 (
+ """
+ ๏ฟฝโโโ๐
+ ๏ฟฝโXโ๐
+ ๏ฟฝโโโ๐
+ """,
+ output,
+ driver);
+
+ DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m๏ฟฝโโโ๐๏ฟฝโXโ๐๏ฟฝโโโ๐",
+ 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 (
+ """
+ ๐๏ฟฝโโโ
+ ๐๏ฟฝโXโ
+ ๐๏ฟฝโโโ
+ """,
+ output,
+ driver);
+
+ DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;95;158;160m\x1b[48;2;54;69;79m๐๏ฟฝโโโ๐๏ฟฝโXโ๐๏ฟฝโโโ",
+ 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 void Draw_NonVisibleView_DoesNotUpdateClip ()
[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 void ExcludeFromClip_ExcludesRegion ()
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 void ExcludeFromClip_WithNullClip_DoesNotThrow ()
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 void ExcludeFromClip_WithNullClip_DoesNotThrow ()
[Fact]
public void SetClip_SetsDriverClip ()
{
- IDriver driver = CreateFakeDriver (80, 25);
+ IDriver driver = CreateFakeDriver ();
var view = new View
{
@@ -584,7 +847,7 @@ public void SetClip_SetsDriverClip ()
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 void SetClip_SetsDriverClip ()
[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 void SetClip_WithNullClip_ClearsClip ()
[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 void Draw_Excludes_View_From_Clip ()
[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 void Draw_EmptyViewport_DoesNotCrash ()
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 void Draw_EmptyViewport_DoesNotCrash ()
[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 void Draw_VeryLargeView_HandlesClippingCorrectly ()
view.EndInit ();
view.LayoutSubViews ();
- var exception = Record.Exception (() => view.Draw ());
+ Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}
@@ -689,8 +952,8 @@ public void Draw_VeryLargeView_HandlesClippingCorrectly ()
[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 void Draw_NegativeCoordinates_HandlesClippingCorrectly ()
view.EndInit ();
view.LayoutSubViews ();
- var exception = Record.Exception (() => view.Draw ());
+ Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}
@@ -712,8 +975,8 @@ public void Draw_NegativeCoordinates_HandlesClippingCorrectly ()
[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 void Draw_OutOfScreenBounds_HandlesClippingCorrectly ()
view.EndInit ();
view.LayoutSubViews ();
- var exception = Record.Exception (() => view.Draw ());
+ Exception? exception = Record.Exception (() => view.Draw ());
Assert.Null (exception);
}