diff --git a/.gitattributes b/.gitattributes index 35720cb73d..bed4b064a7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,14 @@ -# Set the default behavior for all files. +# Set default behavior to automatically normalize line endings. * text=auto -# Normalized and converts to -# native line endings on checkout. -*.cs text - -# Convert to LF line endings on checkout. +# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout. +*.cs text eol=lf +*.txt text eol=lf +*.md text eol=lf *.sh text eol=lf +*.ps1 text eol=lf -# Binary files. +# Denote all files that are truly binary and should not be modified. *.png binary -*.jpg binary \ No newline at end of file +*.jpg binary +*.gif binary diff --git a/Terminal.Gui/Drawing/Region.cs b/Terminal.Gui/Drawing/Region.cs index 35e7e69dcc..a5b144f212 100644 --- a/Terminal.Gui/Drawing/Region.cs +++ b/Terminal.Gui/Drawing/Region.cs @@ -2,166 +2,407 @@ namespace Terminal.Gui; -using System.Buffers; - /// -/// Represents a region composed of one or more rectangles, providing methods for union, intersection, exclusion, and -/// complement operations. +/// Represents a region composed of one or more rectangles, providing methods for geometric set operations such as +/// union, +/// intersection, exclusion, and complement. This class is designed for use in graphical or terminal-based user +/// interfaces +/// where regions need to be manipulated to manage screen areas, clipping, or drawing boundaries. /// -public class Region : IDisposable +/// +/// +/// This class is thread-safe. All operations are synchronized to ensure consistent state when accessed concurrently. +/// +/// +/// The class adopts a philosophy of efficiency and flexibility, balancing performance with +/// usability for GUI applications. It maintains a list of objects, representing disjoint +/// (non-overlapping) rectangular areas, and supports operations inspired by set theory. These operations allow +/// combining regions in various ways, such as merging areas ( or +/// ), +/// finding common areas (), or removing portions ( +/// or +/// ). +/// +/// +/// To achieve high performance, the class employs a sweep-line algorithm for merging rectangles, which efficiently +/// processes large sets of rectangles in O(n log n) time by scanning along the x-axis and tracking active vertical +/// intervals. This approach ensures scalability for typical GUI scenarios with moderate numbers of rectangles. For +/// operations like and , an optional minimization +/// step ( +/// +/// ) is used to reduce the number of rectangles to a minimal set, producing the smallest +/// possible collection of non-overlapping rectangles that cover the same area. This minimization, while O(n²) in +/// worst-case complexity, is optimized for small-to-medium collections and provides a compact representation ideal +/// for drawing or logical operations. +/// +/// +/// The class is immutable in its operations (returning new regions or modifying in-place via methods like +/// ), supports nullability for robustness, and implements +/// to manage +/// resources by clearing internal state. Developers can choose between granular (detailed) or minimal (compact) +/// outputs for union operations via and , catering +/// to diverse use cases such as rendering optimization, event handling, or visualization. +/// +/// +public class Region { - private List _rectangles; + private readonly List _rectangles = []; + + // Add a single reusable list for temp operations + private readonly List _tempRectangles = new(); + + // Object used for synchronization + private readonly object _syncLock = new object(); /// /// Initializes a new instance of the class. /// - public Region () { _rectangles = new (); } + public Region () { } /// /// Initializes a new instance of the class with the specified rectangle. /// /// The initial rectangle for the region. - public Region (Rectangle rectangle) { _rectangles = new () { rectangle }; } + public Region (Rectangle rectangle) + { + lock (_syncLock) + { + _rectangles.Add (rectangle); + } + } /// - /// Adds the specified rectangle to the region. + /// Creates an exact copy of the region. /// - /// The rectangle to add to the region. - public void Union (Rectangle rectangle) + /// A new that is a copy of this instance. + public Region Clone () { - _rectangles.Add (rectangle); - _rectangles = MergeRectangles (_rectangles); + lock (_syncLock) + { + var clone = new Region (); + clone._rectangles.Capacity = _rectangles.Count; // Pre-allocate capacity + clone._rectangles.AddRange (_rectangles); + + return clone; + } } /// - /// Adds the specified region to this region. + /// Combines with the region using the specified operation. /// - /// The region to add to this region. - public void Union (Region region) + /// The rectangle to combine. + /// The operation to perform. + public void Combine (Rectangle rectangle, RegionOp operation) { - _rectangles.AddRange (region._rectangles); - _rectangles = MergeRectangles (_rectangles); + lock (_syncLock) + { + if (rectangle.IsEmpty && operation != RegionOp.Replace) + { + if (operation == RegionOp.Intersect) + { + _rectangles.Clear (); + } + + return; + } + + Combine (new Region (rectangle), operation); + } } /// - /// Updates the region to be the intersection of itself with the specified rectangle. + /// Combines with the region using the specified operation. /// - /// The rectangle to intersect with the region. - public void Intersect (Rectangle rectangle) + /// The region to combine. + /// The operation to perform. + public void Combine (Region? region, RegionOp operation) { - if (_rectangles.Count == 0) + lock (_syncLock) { + CombineInternal(region, operation); + } + } + + // Private method to implement the combine logic within a lock + private void CombineInternal(Region? region, RegionOp operation) + { + if (region is null || region._rectangles.Count == 0) + { + if (operation is RegionOp.Intersect or RegionOp.Replace) + { + _rectangles.Clear (); + } + return; } - // TODO: In-place swap within the original list. Does order of intersections matter? - // Rectangle = 4 * i32 = 16 B - // ~128 B stack allocation - const int maxStackallocLength = 8; - Rectangle []? rentedArray = null; - try + + switch (operation) { - Span rectBuffer = _rectangles.Count <= maxStackallocLength - ? stackalloc Rectangle[maxStackallocLength] - : (rentedArray = ArrayPool.Shared.Rent (_rectangles.Count)); + case RegionOp.Difference: - _rectangles.CopyTo (rectBuffer); - ReadOnlySpan rectangles = rectBuffer[.._rectangles.Count]; - _rectangles.Clear (); + // region is regionB + // We'll chain the difference: (regionA - rect1) - rect2 - rect3 ... + List newRectangles = new (_rectangles); - foreach (var rect in rectangles) - { - Rectangle intersection = Rectangle.Intersect (rect, rectangle); - if (!intersection.IsEmpty) + foreach (Rectangle rect in region._rectangles) + { + List temp = new (); + + foreach (Rectangle r in newRectangles) + { + temp.AddRange (SubtractRectangle (r, rect)); + } + + newRectangles = temp; + } + + _rectangles.Clear (); + _rectangles.AddRange (newRectangles); + + break; + + case RegionOp.Intersect: + List intersections = new (_rectangles.Count); // Pre-allocate + + // Null is same as empty region + region ??= new (); + + foreach (Rectangle rect1 in _rectangles) { - _rectangles.Add (intersection); + foreach (Rectangle rect2 in region!._rectangles) + { + Rectangle intersected = Rectangle.Intersect (rect1, rect2); + + if (!intersected.IsEmpty) + { + intersections.Add (intersected); + } + } } - } + + _rectangles.Clear (); + _rectangles.AddRange (intersections); + + break; + + case RegionOp.Union: + // Avoid collection initialization with spread operator + _tempRectangles.Clear(); + _tempRectangles.AddRange(_rectangles); + if (region != null) + { + // Get the region's rectangles safely + lock (region._syncLock) + { + _tempRectangles.AddRange(region._rectangles); + } + } + List mergedUnion = MergeRectangles(_tempRectangles, false); + _rectangles.Clear(); + _rectangles.AddRange(mergedUnion); + break; + + case RegionOp.MinimalUnion: + // Avoid collection initialization with spread operator + _tempRectangles.Clear(); + _tempRectangles.AddRange(_rectangles); + if (region != null) + { + // Get the region's rectangles safely + lock (region._syncLock) + { + _tempRectangles.AddRange(region._rectangles); + } + } + List mergedMinimalUnion = MergeRectangles(_tempRectangles, true); + _rectangles.Clear(); + _rectangles.AddRange(mergedMinimalUnion); + break; + + case RegionOp.XOR: + Exclude (region); + region.Combine (this, RegionOp.Difference); + _rectangles.AddRange (region._rectangles); + + break; + + case RegionOp.ReverseDifference: + region.Combine (this, RegionOp.Difference); + _rectangles.Clear (); + _rectangles.AddRange (region._rectangles); + + break; + + case RegionOp.Replace: + _rectangles.Clear (); + _rectangles.Capacity = region._rectangles.Count; // Pre-allocate + _rectangles.AddRange (region._rectangles); + + break; + } + } + + /// + /// Updates the region to be the complement of itself within the specified bounds. + /// + /// The bounding rectangle to use for complementing the region. + public void Complement (Rectangle bounds) + { + if (bounds.IsEmpty || _rectangles.Count == 0) + { + _rectangles.Clear (); + + return; } - finally + + List complementRectangles = new (4) { bounds }; // Typical max initial capacity + + foreach (Rectangle rect in _rectangles) { - if (rentedArray != null) - { - ArrayPool.Shared.Return (rentedArray); - } + complementRectangles = complementRectangles.SelectMany (r => SubtractRectangle (r, rect)).ToList (); } + + _rectangles.Clear (); + _rectangles.AddRange (complementRectangles); } /// - /// Updates the region to be the intersection of itself with the specified region. + /// Determines whether the specified point is contained within the region. /// - /// The region to intersect with this region. - public void Intersect (Region region) + /// The x-coordinate of the point. + /// The y-coordinate of the point. + /// true if the point is contained within the region; otherwise, false. + public bool Contains (int x, int y) { - List intersections = new List (); - - foreach (Rectangle rect1 in _rectangles) + lock (_syncLock) { - foreach (Rectangle rect2 in region._rectangles) + foreach (Rectangle r in _rectangles) { - Rectangle intersected = Rectangle.Intersect (rect1, rect2); - - if (!intersected.IsEmpty) + if (r.Contains (x, y)) { - intersections.Add (intersected); + return true; } } - } - _rectangles = intersections; + return false; + } } /// - /// Removes the specified rectangle from the region. + /// Determines whether the specified rectangle is contained within the region. /// - /// The rectangle to exclude from the region. - public void Exclude (Rectangle rectangle) { _rectangles = _rectangles.SelectMany (r => SubtractRectangle (r, rectangle)).ToList (); } + /// The rectangle to check for containment. + /// true if the rectangle is contained within the region; otherwise, false. + public bool Contains (Rectangle rectangle) + { + lock (_syncLock) + { + foreach (Rectangle r in _rectangles) + { + if (r.Contains (rectangle)) + { + return true; + } + } + + return false; + } + } /// - /// Removes the portion of the specified region from this region. + /// Determines whether the specified object is equal to this region. /// - /// The region to exclude from this region. - public void Exclude (Region region) + /// The object to compare with this region. + /// true if the objects are equal; otherwise, false. + public override bool Equals (object? obj) { return obj is Region other && Equals (other); } + + private static bool IsRegionEmpty (List rectangles) { - foreach (Rectangle rect in region._rectangles) + if (rectangles.Count == 0) { - _rectangles = _rectangles.SelectMany (r => SubtractRectangle (r, rect)).ToList (); + return true; + } + + foreach (Rectangle r in rectangles) + { + if (r is { IsEmpty: false, Width: > 0, Height: > 0 }) + { + return false; + } } + + return true; } /// - /// Updates the region to be the complement of itself within the specified bounds. + /// Determines whether the specified region is equal to this region. /// - /// The bounding rectangle to use for complementing the region. - public void Complement (Rectangle bounds) + /// The region to compare with this region. + /// true if the regions are equal; otherwise, false. + public bool Equals (Region? other) { - if (bounds.IsEmpty || _rectangles.Count == 0) + if (other is null) { - _rectangles.Clear (); + return false; + } - return; + if (ReferenceEquals (this, other)) + { + return true; } - List complementRectangles = new List { bounds }; + // Check if either region is empty + bool thisEmpty = IsRegionEmpty (_rectangles); + bool otherEmpty = IsRegionEmpty (other._rectangles); - foreach (Rectangle rect in _rectangles) + // If either is empty, they're equal only if both are empty + if (thisEmpty || otherEmpty) { - complementRectangles = complementRectangles.SelectMany (r => SubtractRectangle (r, rect)).ToList (); + return thisEmpty == otherEmpty; + } + + // For non-empty regions, compare rectangle counts + if (_rectangles.Count != other._rectangles.Count) + { + return false; + } + + // Compare all rectangles - order matters since we maintain canonical form + for (var i = 0; i < _rectangles.Count; i++) + { + if (!_rectangles [i].Equals (other._rectangles [i])) + { + return false; + } } - _rectangles = complementRectangles; + return true; } /// - /// Creates an exact copy of the region. + /// Removes the specified rectangle from the region. /// - /// A new that is a copy of this instance. - public Region Clone () - { - var clone = new Region (); - clone._rectangles = new (_rectangles); + /// + /// + /// This is a helper method that is equivalent to calling with + /// . + /// + /// + /// The rectangle to exclude from the region. + public void Exclude (Rectangle rectangle) { Combine (rectangle, RegionOp.Difference); } - return clone; - } + /// + /// Removes the portion of the specified region from this region. + /// + /// + /// + /// This is a helper method that is equivalent to calling with + /// . + /// + /// + /// The region to exclude from this region. + public void Exclude (Region? region) { Combine (region, RegionOp.Difference); } /// /// Gets a bounding rectangle for the entire region. @@ -174,114 +415,346 @@ public Rectangle GetBounds () return Rectangle.Empty; } - int left = _rectangles.Min (r => r.Left); - int top = _rectangles.Min (r => r.Top); - int right = _rectangles.Max (r => r.Right); - int bottom = _rectangles.Max (r => r.Bottom); + Rectangle first = _rectangles [0]; + int left = first.Left; + int top = first.Top; + int right = first.Right; + int bottom = first.Bottom; + + for (var i = 1; i < _rectangles.Count; i++) + { + Rectangle r = _rectangles [i]; + left = Math.Min (left, r.Left); + top = Math.Min (top, r.Top); + right = Math.Max (right, r.Right); + bottom = Math.Max (bottom, r.Bottom); + } return new (left, top, right - left, bottom - top); } /// - /// Determines whether the region is empty. + /// Returns a hash code for this region. /// - /// true if the region is empty; otherwise, false. - public bool IsEmpty () { return !_rectangles.Any (); } + /// A hash code for this region. + public override int GetHashCode () + { + var hash = new HashCode (); + + foreach (Rectangle rect in _rectangles) + { + hash.Add (rect); + } + + return hash.ToHashCode (); + } /// - /// Determines whether the specified point is contained within the region. + /// Returns an array of rectangles that represent the region. /// - /// The x-coordinate of the point. - /// The y-coordinate of the point. - /// true if the point is contained within the region; otherwise, false. - public bool Contains (int x, int y) + /// An array of objects that make up the region. + public Rectangle [] GetRectangles () { return _rectangles.ToArray (); } + + /// + /// Updates the region to be the intersection of itself with the specified rectangle. + /// + /// + /// + /// This is a helper method that is equivalent to calling with + /// . + /// + /// + /// The rectangle to intersect with the region. + public void Intersect (Rectangle rectangle) { Combine (rectangle, RegionOp.Intersect); } + + /// + /// Updates the region to be the intersection of itself with the specified region. + /// + /// + /// + /// This is a helper method that is equivalent to calling with + /// . + /// + /// + /// The region to intersect with this region. + public void Intersect (Region? region) { Combine (region, RegionOp.Intersect); } + + /// + /// Determines whether the region is empty. + /// + /// true if the region is empty; otherwise, false. + public bool IsEmpty () { - foreach (var rect in _rectangles) + if (_rectangles.Count == 0) { - if (rect.Contains (x, y)) + return true; + } + + foreach (Rectangle r in _rectangles) + { + if (r is { IsEmpty: false, Width: > 0, Height: > 0 }) { - return true; + return false; } } - return false; + + return true; } /// - /// Determines whether the specified rectangle is contained within the region. + /// Translates all rectangles in the region by the specified offsets. /// - /// The rectangle to check for containment. - /// true if the rectangle is contained within the region; otherwise, false. - public bool Contains (Rectangle rectangle) + /// The amount to offset along the x-axis. + /// The amount to offset along the y-axis. + public void Translate (int offsetX, int offsetY) { - foreach (var rect in _rectangles) + if (offsetX == 0 && offsetY == 0) { - if (rect.Contains (rectangle)) - { - return true; - } + return; + } + + for (var i = 0; i < _rectangles.Count; i++) + { + Rectangle rect = _rectangles [i]; + _rectangles [i] = rect with { X = rect.Left + offsetX, Y = rect.Top + offsetY }; } - return false; } /// - /// Returns an array of rectangles that represent the region. + /// Adds the specified rectangle to the region. Merges all rectangles into a minimal or granular bounding shape. /// - /// An array of objects that make up the region. - public Rectangle [] GetRegionScans () { return _rectangles.ToArray (); } + /// The rectangle to add to the region. + public void Union (Rectangle rectangle) { Combine (rectangle, RegionOp.Union); } /// - /// Offsets all rectangles in the region by the specified amounts. + /// Adds the specified region to this region. Merges all rectangles into a minimal or granular bounding shape. /// - /// The amount to offset along the x-axis. - /// The amount to offset along the y-axis. - public void Offset (int offsetX, int offsetY) + /// The region to add to this region. + public void Union (Region? region) { Combine (region, RegionOp.Union); } + + /// + /// Adds the specified rectangle to the region. Merges all rectangles into the smallest possible bounding shape. + /// + /// The rectangle to add to the region. + public void MinimalUnion (Rectangle rectangle) { Combine (rectangle, RegionOp.MinimalUnion); } + + /// + /// Adds the specified region to this region. Merges all rectangles into the smallest possible bounding shape. + /// + /// The region to add to this region. + public void MinimalUnion (Region? region) { Combine (region, RegionOp.MinimalUnion); } + + /// + /// Merges overlapping rectangles into a minimal or granular set of non-overlapping rectangles with a minimal bounding + /// shape. + /// + /// The list of rectangles to merge. + /// + /// If true, minimizes the set to the smallest possible number of rectangles; otherwise, + /// returns a granular set. + /// + /// A list of merged rectangles. + internal static List MergeRectangles (List rectangles, bool minimize) { - for (int i = 0; i < _rectangles.Count; i++) + if (rectangles.Count == 0) { - var rect = _rectangles [i]; - _rectangles [i] = new Rectangle (rect.Left + offsetX, rect.Top + offsetY, rect.Width, rect.Height); + return []; } + + // Sweep-line algorithm to merge rectangles + List<(int x, bool isStart, int yTop, int yBottom)> events = new (rectangles.Count * 2); // Pre-allocate + + foreach (Rectangle r in rectangles) + { + if (!r.IsEmpty) + { + events.Add ((r.Left, true, r.Top, r.Bottom)); // Start event + events.Add ((r.Right, false, r.Top, r.Bottom)); // End event + } + } + + if (events.Count == 0) + { + return []; // Return empty list if no non-empty rectangles exist + } + + events.Sort ( + (a, b) => + { + int cmp = a.x.CompareTo (b.x); + + if (cmp != 0) + { + return cmp; + } + + return a.isStart.CompareTo (b.isStart); // Start events before end events at same x + }); + + List merged = []; + + SortedSet<(int yTop, int yBottom)> active = new ( + Comparer<(int yTop, int yBottom)>.Create ( + (a, b) => + { + int cmp = a.yTop.CompareTo (b.yTop); + + return cmp != 0 ? cmp : a.yBottom.CompareTo (b.yBottom); + })); + int lastX = events [0].x; + + foreach ((int x, bool isStart, int yTop, int yBottom) evt in events) + { + // Output rectangles for the previous segment if there are active rectangles + if (active.Count > 0 && evt.x > lastX) + { + merged.AddRange (MergeVerticalIntervals (active, lastX, evt.x)); + } + + // Process the event + if (evt.isStart) + { + active.Add ((evt.yTop, evt.yBottom)); + } + else + { + active.Remove ((evt.yTop, evt.yBottom)); + } + + lastX = evt.x; + } + + return minimize ? MinimizeRectangles (merged) : merged; } /// - /// Merges overlapping rectangles into a minimal set of non-overlapping rectangles. + /// Merges overlapping vertical intervals into a minimal set of non-overlapping rectangles. /// - /// The list of rectangles to merge. + /// The set of active vertical intervals. + /// The starting x-coordinate for the rectangles. + /// The ending x-coordinate for the rectangles. /// A list of merged rectangles. - private List MergeRectangles (List rectangles) + internal static List MergeVerticalIntervals (SortedSet<(int yTop, int yBottom)> active, int startX, int endX) { - // Simplified merging logic: this does not handle all edge cases for merging overlapping rectangles. - // For a full implementation, a plane sweep algorithm or similar would be needed. - List merged = new List (rectangles); - bool mergedAny; + if (active.Count == 0) + { + return []; + } + + List result = new (active.Count); // Pre-allocate + int? currentTop = null; + int? currentBottom = null; + + foreach ((int yTop, int yBottom) in active) + { + if (currentTop == null) + { + currentTop = yTop; + currentBottom = yBottom; + } + else if (yTop <= currentBottom) + { + currentBottom = Math.Max (currentBottom.Value, yBottom); + } + else + { + result.Add (new (startX, currentTop.Value, endX - startX, currentBottom.Value - currentTop.Value)); + currentTop = yTop; + currentBottom = yBottom; + } + } + + if (currentTop != null) + { + result.Add (new (startX, currentTop.Value, endX - startX, currentBottom!.Value - currentTop.Value)); + } + + return result; + } + + /// + /// Minimizes a list of rectangles into the smallest possible set of non-overlapping rectangles + /// by merging adjacent rectangles where possible. + /// + /// The list of rectangles to minimize. + /// A list of minimized rectangles. + internal static List MinimizeRectangles (List rectangles) + { + if (rectangles.Count <= 1) + { + return rectangles.ToList (); + } + + List minimized = new (rectangles.Count); // Pre-allocate + List current = new (rectangles); // Work with a copy + + bool changed; do { - mergedAny = false; + changed = false; + minimized.Clear (); + + // Sort by Y then X for consistent processing + current.Sort ( + (a, b) => + { + int cmp = a.Top.CompareTo (b.Top); - for (var i = 0; i < merged.Count; i++) + return cmp != 0 ? cmp : a.Left.CompareTo (b.Left); + }); + + var i = 0; + + while (i < current.Count) { - for (int j = i + 1; j < merged.Count; j++) + Rectangle r = current [i]; + int j = i + 1; + + while (j < current.Count) { - if (merged [i].IntersectsWith (merged [j])) + Rectangle next = current [j]; + + // Check if rectangles can be merged horizontally (same Y range, adjacent X) + if (r.Top == next.Top && r.Bottom == next.Bottom && (r.Right == next.Left || next.Right == r.Left || r.IntersectsWith (next))) { - merged [i] = Rectangle.Union (merged [i], merged [j]); - merged.RemoveAt (j); - mergedAny = true; + r = new ( + Math.Min (r.Left, next.Left), + r.Top, + Math.Max (r.Right, next.Right) - Math.Min (r.Left, next.Left), + r.Height + ); + current.RemoveAt (j); + changed = true; + } - break; + // Check if rectangles can be merged vertically (same X range, adjacent Y) + else if (r.Left == next.Left && r.Right == next.Right && (r.Bottom == next.Top || next.Bottom == r.Top || r.IntersectsWith (next))) + { + r = new ( + r.Left, + Math.Min (r.Top, next.Top), + r.Width, + Math.Max (r.Bottom, next.Bottom) - Math.Min (r.Top, next.Top) + ); + current.RemoveAt (j); + changed = true; + } + else + { + j++; } } - if (mergedAny) - { - break; - } + minimized.Add (r); + i++; } + + current = minimized.ToList (); // Prepare for next iteration } - while (mergedAny); + while (changed); - return merged; + return minimized; } /// @@ -290,8 +763,28 @@ private List MergeRectangles (List rectangles) /// The original rectangle. /// The rectangle to subtract from the original. /// An enumerable collection of resulting rectangles after subtraction. - private IEnumerable SubtractRectangle (Rectangle original, Rectangle subtract) + internal static IEnumerable SubtractRectangle (Rectangle original, Rectangle subtract) { + // Handle empty or invalid rectangles + if (original.IsEmpty || original.Width <= 0 || original.Height <= 0) + { + yield break; // Return empty enumeration for empty or invalid original + } + + if (subtract.IsEmpty || subtract.Width <= 0 || subtract.Height <= 0) + { + yield return original; + + yield break; + } + + // Check for complete overlap (subtract fully contains or equals original) + if (subtract.Left <= original.Left && subtract.Top <= original.Top && subtract.Right >= original.Right && subtract.Bottom >= original.Bottom) + { + yield break; // Return empty if subtract completely overlaps original + } + + // Check for no overlap if (!original.IntersectsWith (subtract)) { yield return original; @@ -299,19 +792,29 @@ private IEnumerable SubtractRectangle (Rectangle original, Rectangle yield break; } - // Top segment + // Fragment the original rectangle into segments excluding the subtract rectangle + + // Top segment (above subtract) if (original.Top < subtract.Top) { - yield return new (original.Left, original.Top, original.Width, subtract.Top - original.Top); + yield return new ( + original.Left, + original.Top, + original.Width, + subtract.Top - original.Top); } - // Bottom segment + // Bottom segment (below subtract) if (original.Bottom > subtract.Bottom) { - yield return new (original.Left, subtract.Bottom, original.Width, original.Bottom - subtract.Bottom); + yield return new ( + original.Left, + subtract.Bottom, + original.Width, + original.Bottom - subtract.Bottom); } - // Left segment + // Left segment (to the left of subtract) if (original.Left < subtract.Left) { int top = Math.Max (original.Top, subtract.Top); @@ -319,11 +822,15 @@ private IEnumerable SubtractRectangle (Rectangle original, Rectangle if (bottom > top) { - yield return new (original.Left, top, subtract.Left - original.Left, bottom - top); + yield return new ( + original.Left, + top, + subtract.Left - original.Left, + bottom - top); } } - // Right segment + // Right segment (to the right of subtract) if (original.Right > subtract.Right) { int top = Math.Max (original.Top, subtract.Top); @@ -331,13 +838,302 @@ private IEnumerable SubtractRectangle (Rectangle original, Rectangle if (bottom > top) { - yield return new (subtract.Right, top, original.Right - subtract.Right, bottom - top); + yield return new ( + subtract.Right, + top, + original.Right - subtract.Right, + bottom - top); } } } /// - /// Releases all resources used by the . + /// Fills the interior of all rectangles in the region with the specified attribute and fill rune. /// - public void Dispose () { _rectangles.Clear (); } + /// The attribute (color/style) to use. + /// + /// The rune to fill the interior of the rectangles with. If space will be + /// used. + /// + public void FillRectangles (Attribute attribute, Rune? fillRune = null) + { + if (_rectangles.Count == 0) + { + return; + } + + foreach (Rectangle rect in _rectangles) + { + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) + { + continue; + } + + Application.Driver?.SetAttribute (attribute); + + for (int y = rect.Top; y < rect.Bottom; y++) + { + for (int x = rect.Left; x < rect.Right; x++) + { + Application.Driver?.Move (x, y); + Application.Driver?.AddRune (fillRune ?? (Rune)' '); + } + } + } + } + + + /// + /// Draws the boundaries of all rectangles in the region using the specified attributes, only if the rectangle is big + /// enough. + /// + /// The canvas to draw on. + /// The line style to use for drawing. + /// The attribute (color/style) to use for the lines. If null. + public void DrawBoundaries (LineCanvas canvas, LineStyle style, Attribute? attribute = null) + { + if (_rectangles.Count == 0) + { + return; + } + + foreach (Rectangle rect in _rectangles) + { + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) + { + continue; + } + + // Only draw boundaries if the rectangle is "big enough" (e.g., width and height > 1) + //if (rect.Width > 2 && rect.Height > 2) + { + if (rect.Width > 1) + { + // Add horizontal lines + canvas.AddLine (new (rect.Left, rect.Top), rect.Width, Orientation.Horizontal, style, attribute); + canvas.AddLine (new (rect.Left, rect.Bottom - 1), rect.Width, Orientation.Horizontal, style, attribute); + } + + if (rect.Height > 1) + { + // Add vertical lines + canvas.AddLine (new (rect.Left, rect.Top), rect.Height, Orientation.Vertical, style, attribute); + canvas.AddLine (new (rect.Right - 1, rect.Top), rect.Height, Orientation.Vertical, style, attribute); + } + } + } + } + + + // BUGBUG: DrawOuterBoundary does not work right. it draws all regions +1 too tall/wide. It should draw single width/height regions as just a line. + // + // Example: There are 3 regions here. the first is a rect (0,0,1,4). Second is (10, 0, 2, 4). + // This is how they should draw: + // + // |123456789|123456789|123456789 + // 1 │ ┌┐ ┌─┐ + // 2 │ ││ │ │ + // 3 │ ││ │ │ + // 4 │ └┘ └─┘ + // + // But this is what it draws: + // |123456789|123456789|123456789 + // 1┌┐ ┌─┐ ┌──┐ + // 2││ │ │ │ │ + // 3││ │ │ │ │ + // 4││ │ │ │ │ + // 5└┘ └─┘ └──┘ + // + // Example: There are two rectangles in this region. (0,0,3,3) and (3, 3, 3, 3). + // This is fill - correct: + // |123456789 + // 1░░░ + // 2░░░ + // 3░░░░░ + // 4 ░░░ + // 5 ░░░ + // 6 + // + // This is what DrawOuterBoundary should draw + // |123456789|123456789 + // 1┌─┐ + // 2│ │ + // 3└─┼─┐ + // 4 │ │ + // 5 └─┘ + // 6 + // + // This is what DrawOuterBoundary actually draws + // |123456789|123456789 + // 1┌──┐ + // 2│ │ + // 3│ └─┐ + // 4└─┐ │ + // 5 │ │ + // 6 └──┘ + + /// + /// Draws the outer perimeter of the region to using and + /// . + /// The outer perimeter follows the shape of the rectangles in the region, even if non-rectangular, by drawing + /// boundaries and excluding internal lines. + /// + /// The LineCanvas to draw on. + /// The line style to use for drawing. + /// The attribute (color/style) to use for the lines. If null. + public void DrawOuterBoundary (LineCanvas lineCanvas, LineStyle style, Attribute? attribute = null) + { + if (_rectangles.Count == 0) + { + return; + } + + // Get the bounds of the region + Rectangle bounds = GetBounds (); + + // Add protection against extremely large allocations + if (bounds.Width > 1000 || bounds.Height > 1000) + { + // Fall back to drawing each rectangle's boundary + DrawBoundaries(lineCanvas, style, attribute); + return; + } + + // Create a grid to track which cells are inside the region + var insideRegion = new bool [bounds.Width + 1, bounds.Height + 1]; + + // Fill the grid based on rectangles + foreach (Rectangle rect in _rectangles) + { + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) + { + continue; + } + + for (int x = rect.Left; x < rect.Right; x++) + { + for (int y = rect.Top; y < rect.Bottom; y++) + { + // Adjust coordinates to grid space + int gridX = x - bounds.Left; + int gridY = y - bounds.Top; + + if (gridX >= 0 && gridX < bounds.Width && gridY >= 0 && gridY < bounds.Height) + { + insideRegion [gridX, gridY] = true; + } + } + } + } + + // Find horizontal boundary lines + for (var y = 0; y <= bounds.Height; y++) + { + int startX = -1; + + for (var x = 0; x <= bounds.Width; x++) + { + bool above = y > 0 && insideRegion [x, y - 1]; + bool below = y < bounds.Height && insideRegion [x, y]; + + // A boundary exists where one side is inside and the other is outside + bool isBoundary = above != below; + + if (isBoundary) + { + // Start a new segment or continue the current one + if (startX == -1) + { + startX = x; + } + } + else + { + // End the current segment if one exists + if (startX != -1) + { + int length = x - startX + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine ( + new (startX + bounds.Left, y + bounds.Top), + length, + Orientation.Horizontal, + style, + attribute + ); + startX = -1; + } + } + } + + // End any segment that reaches the right edge + if (startX != -1) + { + int length = bounds.Width + 1 - startX + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine ( + new (startX + bounds.Left, y + bounds.Top), + length, + Orientation.Horizontal, + style, + attribute + ); + } + } + + // Find vertical boundary lines + for (var x = 0; x <= bounds.Width; x++) + { + int startY = -1; + + for (var y = 0; y <= bounds.Height; y++) + { + bool left = x > 0 && insideRegion [x - 1, y]; + bool right = x < bounds.Width && insideRegion [x, y]; + + // A boundary exists where one side is inside and the other is outside + bool isBoundary = left != right; + + if (isBoundary) + { + // Start a new segment or continue the current one + if (startY == -1) + { + startY = y; + } + } + else + { + // End the current segment if one exists + if (startY != -1) + { + int length = y - startY + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine ( + new (x + bounds.Left, startY + bounds.Top), + length, + Orientation.Vertical, + style, + attribute + ); + startY = -1; + } + } + } + + // End any segment that reaches the bottom edge + if (startY != -1) + { + int length = bounds.Height + 1 - startY + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine ( + new (x + bounds.Left, startY + bounds.Top), + length, + Orientation.Vertical, + style, + attribute + ); + } + } + } } diff --git a/Terminal.Gui/Drawing/RegionOp.cs b/Terminal.Gui/Drawing/RegionOp.cs new file mode 100644 index 0000000000..9bf31f285d --- /dev/null +++ b/Terminal.Gui/Drawing/RegionOp.cs @@ -0,0 +1,128 @@ +#nullable enable + +namespace Terminal.Gui; + +/// +/// Specifies the operation to perform when combining two regions or a with a +/// >, defining how their +/// rectangular areas are merged, intersected, or subtracted. +/// +/// +/// +/// Each operation modifies the first region's set of rectangles based on the second (op) region or rectangle, +/// producing a new set of non-overlapping rectangles. The operations align with set theory, enabling flexible +/// manipulation for TUI layout, clipping, or drawing. Developers can choose between granular outputs (e.g., +/// ) that preserve detailed rectangles or minimal outputs (e.g., ) +/// that reduce the rectangle count for compactness. +/// +/// +public enum RegionOp +{ + /// + /// Subtracts the second (op) region or rectangle from the first region, removing any areas where the op overlaps + /// the first region. The result includes only the portions of the first region that do not intersect with the op. + /// + /// For example, if the first region contains rectangle A = (0,0,10,10) and the op is B = (5,5,5,5), the result + /// would include rectangles covering A minus the overlapping part of B, such as (0,0,10,5), (0,5,5,5), and + /// (5,10,5,5). + /// + /// + /// If the op region is empty or null, the operation has no effect unless the first region is also empty, in + /// which case it clears the first region. + /// + /// + Difference = 0, + + /// + /// Intersects the first region with the second (op) region or rectangle, retaining only the areas where both + /// regions overlap. The result includes rectangles covering the common areas, excluding any parts unique to either + /// region. + /// + /// For example, if the first region contains A = (0,0,10,10) and the op is B = (5,5,5,5), the result would be + /// a single rectangle (5,5,5,5), representing the intersection. + /// + /// + /// If either region is empty or null, the result clears the first region, as there’s no intersection possible. + /// + /// + Intersect = 1, + + /// + /// Performs a union (inclusive-or) of the first region and the second (op) region or rectangle, combining all + /// areas covered by either region into a single contiguous region without holes (unless explicitly subtracted). + /// + /// The formal union (∪) includes all points in at least one rectangle, producing a granular set of + /// non-overlapping rectangles that cover the combined area. For example, if the first region contains A = + /// (0,0,5,5) and the op is B = (5,0,5,5), the result might include (0,0,5,5) and (5,0,5,5) unless minimized. + /// + /// + /// This operation uses granular output (preserving detailed rectangles). To minimize the result use + /// instead. + /// + /// + /// If the op region is empty or null, the first region remains unchanged. + /// + /// + Union = 2, + + /// + /// Performs a minimal union (inclusive-or) of the first region and the second (op) region or rectangle, merging adjacent or + /// overlapping rectangles into the smallest possible set of non-overlapping rectangles that cover the combined + /// area. + /// + /// This operation minimizes the number of rectangles, producing a more compact representation compared to + /// . For example, if the first region contains A = (0,0,5,5) and the op is B = (5,0,5,5), + /// the result would be a single rectangle (0,0,10,5), reducing redundancy. + /// + /// + /// This operation always minimizes the output and has lower performance than . + /// + /// + /// If the op region is empty or null, the first region remains unchanged. + /// + /// + MinimalUnion = 3, + + /// + /// Performs an exclusive-or (XOR) of the first region and the second (op) region or rectangle, retaining only the + /// areas that are unique to each region—i.e., areas present in one region but not both. + /// + /// For example, if the first region contains A = (0,0,10,10) and the op is B = (5,5,5,5), the result would + /// include rectangles covering (0,0,10,5), (0,5,5,5), (5,10,5,5), and (5,5,5,5), excluding the intersection + /// (5,5,5,5). + /// + /// + /// If the op region is empty or null, this operation excludes the first region from itself (clearing it) or + /// adds the first region to the op (if op is empty), depending on the logic. + /// + /// + XOR = 4, + + /// + /// Subtracts the first region from the second (op) region or rectangle, retaining only the areas of the op that do + /// not overlap with the first region. The result replaces the first region with these areas. + /// + /// For example, if the first region contains A = (5,5,5,5) and the op is B = (0,0,10,10), the result would + /// include rectangles covering B minus A, such as (0,0,10,5), (0,5,5,5), and (5,10,5,5). + /// + /// + /// If the first region is empty or null, the op region replaces the first region. If the op region is empty, + /// the first region is cleared. + /// + /// + ReverseDifference = 5, + + /// + /// Replaces the first region entirely with the second (op) region or rectangle, discarding the first region's + /// current rectangles and adopting the op's rectangles. + /// + /// For example, if the first region contains (0,0,5,5) and the op is (10,10,5,5), the first region will be + /// cleared and replaced with (10,10,5,5). + /// + /// + /// If the op region is empty or null, the first region is cleared. This operation is useful for resetting or + /// overwriting region state. + /// + /// + Replace = 6 +} diff --git a/Terminal.Gui/Drawing/Thickness.cs b/Terminal.Gui/Drawing/Thickness.cs index 6aa2088e5a..ed73b3913c 100644 --- a/Terminal.Gui/Drawing/Thickness.cs +++ b/Terminal.Gui/Drawing/Thickness.cs @@ -235,6 +235,19 @@ public Rectangle GetInside (Rectangle rect) return new (x, y, width, height); } + /// + /// Returns a region describing the thickness. + /// + /// The source rectangle + /// + public Region AsRegion (Rectangle rect) + { + Region region = new Region (rect); + region.Exclude (GetInside (rect)); + + return region; + } + /// /// Gets the total width of the left and right sides of the rectangle. Sets the width of the left and right sides /// of the rectangle to half the specified value. diff --git a/Terminal.Gui/Text/TextFormatter.cs b/Terminal.Gui/Text/TextFormatter.cs index d4c18bedc0..e576b83cf3 100644 --- a/Terminal.Gui/Text/TextFormatter.cs +++ b/Terminal.Gui/Text/TextFormatter.cs @@ -438,7 +438,7 @@ public void Draw ( } } } - + /// /// Determines if the viewport width will be used or only the text width will be used, /// If all the viewport area will be filled with whitespaces and the same background color @@ -864,6 +864,276 @@ private T EnableNeedsFormat (T value) return value; } + /// + /// Calculates and returns a describing the areas where text would be output, based on the + /// formatting rules of . + /// + /// + /// Uses the same formatting logic as , including alignment, direction, word wrap, and constraints, + /// but does not perform actual drawing to . + /// + /// Specifies the screen-relative location and maximum size for drawing the text. + /// Specifies the screen-relative location and maximum container size. + /// A representing the areas where text would be drawn. + public Region GetDrawRegion (Rectangle screen, Rectangle maximum = default) + { + Region drawnRegion = new Region (); + + // With this check, we protect against subclasses with overrides of Text (like Button) + if (string.IsNullOrEmpty (Text)) + { + return drawnRegion; + } + + List linesFormatted = GetLines (); + + bool isVertical = IsVerticalDirection (Direction); + Rectangle maxScreen = screen; + + // INTENT: What, exactly, is the intent of this? + maxScreen = maximum == default (Rectangle) + ? screen + : new ( + Math.Max (maximum.X, screen.X), + Math.Max (maximum.Y, screen.Y), + Math.Max ( + Math.Min (maximum.Width, maximum.Right - screen.Left), + 0 + ), + Math.Max ( + Math.Min ( + maximum.Height, + maximum.Bottom - screen.Top + ), + 0 + ) + ); + + if (maxScreen.Width == 0 || maxScreen.Height == 0) + { + return drawnRegion; + } + + int lineOffset = !isVertical && screen.Y < 0 ? Math.Abs (screen.Y) : 0; + + for (int line = lineOffset; line < linesFormatted.Count; line++) + { + if ((isVertical && line > screen.Width) || (!isVertical && line > screen.Height)) + { + continue; + } + + if ((isVertical && line >= maxScreen.Left + maxScreen.Width) + || (!isVertical && line >= maxScreen.Top + maxScreen.Height + lineOffset)) + { + break; + } + + Rune [] runes = linesFormatted [line].ToRunes (); + + // When text is justified, we lost left or right, so we use the direction to align. + int x = 0, y = 0; + + switch (Alignment) + { + // Horizontal Alignment + case Alignment.End when isVertical: + { + int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, line, linesFormatted.Count - line, TabWidth); + x = screen.Right - runesWidth; + + break; + } + case Alignment.End: + { + int runesWidth = StringExtensions.ToString (runes).GetColumns (); + x = screen.Right - runesWidth; + + break; + } + case Alignment.Start when isVertical: + { + int runesWidth = line > 0 + ? GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth) + : 0; + x = screen.Left + runesWidth; + + break; + } + case Alignment.Start: + x = screen.Left; + + break; + case Alignment.Fill when isVertical: + { + int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); + int prevLineWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, line - 1, 1, TabWidth) : 0; + int firstLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, 1, TabWidth); + int lastLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, linesFormatted.Count - 1, 1, TabWidth); + var interval = (int)Math.Round ((double)(screen.Width + firstLineWidth + lastLineWidth) / linesFormatted.Count); + + x = line == 0 + ? screen.Left + : line < linesFormatted.Count - 1 + ? screen.Width - runesWidth <= lastLineWidth ? screen.Left + prevLineWidth : screen.Left + line * interval + : screen.Right - lastLineWidth; + + break; + } + case Alignment.Fill: + x = screen.Left; + + break; + case Alignment.Center when isVertical: + { + int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); + int linesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth); + x = screen.Left + linesWidth + (screen.Width - runesWidth) / 2; + + break; + } + case Alignment.Center: + { + int runesWidth = StringExtensions.ToString (runes).GetColumns (); + x = screen.Left + (screen.Width - runesWidth) / 2; + + break; + } + default: + Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); + + return drawnRegion; + } + + switch (VerticalAlignment) + { + // Vertical Alignment + case Alignment.End when isVertical: + y = screen.Bottom - runes.Length; + + break; + case Alignment.End: + y = screen.Bottom - linesFormatted.Count + line; + + break; + case Alignment.Start when isVertical: + y = screen.Top; + + break; + case Alignment.Start: + y = screen.Top + line; + + break; + case Alignment.Fill when isVertical: + y = screen.Top; + + break; + case Alignment.Fill: + { + var interval = (int)Math.Round ((double)(screen.Height + 2) / linesFormatted.Count); + + y = line == 0 ? screen.Top : + line < linesFormatted.Count - 1 ? screen.Height - interval <= 1 ? screen.Top + 1 : screen.Top + line * interval : screen.Bottom - 1; + + break; + } + case Alignment.Center when isVertical: + { + int s = (screen.Height - runes.Length) / 2; + y = screen.Top + s; + + break; + } + case Alignment.Center: + { + int s = (screen.Height - linesFormatted.Count) / 2; + y = screen.Top + line + s; + + break; + } + default: + Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); + + return drawnRegion; + } + + int colOffset = screen.X < 0 ? Math.Abs (screen.X) : 0; + int start = isVertical ? screen.Top : screen.Left; + int size = isVertical ? screen.Height : screen.Width; + int current = start + colOffset; + int zeroLengthCount = isVertical ? runes.Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0; + + int lineX = x, lineY = y, lineWidth = 0, lineHeight = 1; + + for (int idx = (isVertical ? start - y : start - x) + colOffset; + current < start + size + zeroLengthCount; + idx++) + { + if (idx < 0 + || (isVertical + ? VerticalAlignment != Alignment.End && current < 0 + : Alignment != Alignment.End && x + current + colOffset < 0)) + { + current++; + + continue; + } + + if (!FillRemaining && idx > runes.Length - 1) + { + break; + } + + if ((!isVertical + && (current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset + || (idx < runes.Length && runes [idx].GetColumns () > screen.Width))) + || (isVertical + && ((current > start + size + zeroLengthCount && idx > maxScreen.Top + maxScreen.Height - screen.Y) + || (idx < runes.Length && runes [idx].GetColumns () > screen.Width)))) + { + break; + } + + Rune rune = idx >= 0 && idx < runes.Length ? runes [idx] : (Rune)' '; + int runeWidth = GetRuneWidth (rune, TabWidth); + + if (isVertical) + { + if (runeWidth > 0) + { + // Update line height for vertical text (each rune is a column) + lineHeight = Math.Max (lineHeight, current - y + 1); + lineWidth = Math.Max (lineWidth, 1); // Width is 1 per rune in vertical + } + } + else + { + // Update line width and position for horizontal text + lineWidth += runeWidth; + } + + current += isVertical && runeWidth > 0 ? 1 : runeWidth; + + int nextRuneWidth = idx + 1 > -1 && idx + 1 < runes.Length + ? runes [idx + 1].GetColumns () + : 0; + + if (!isVertical && idx + 1 < runes.Length && current + nextRuneWidth > start + size) + { + break; + } + } + + // Add the line's drawn region to the overall region + if (lineWidth > 0 && lineHeight > 0) + { + drawnRegion.Union (new Rectangle (lineX, lineY, lineWidth, lineHeight)); + } + } + + return drawnRegion; + } + #region Static Members /// Check if it is a horizontal direction diff --git a/Terminal.Gui/View/Adornment/Adornment.cs b/Terminal.Gui/View/Adornment/Adornment.cs index 3625506a35..d872db769c 100644 --- a/Terminal.Gui/View/Adornment/Adornment.cs +++ b/Terminal.Gui/View/Adornment/Adornment.cs @@ -182,6 +182,7 @@ protected override bool OnClearingViewport () /// protected override bool OnDrawingSubviews () { return Thickness == Thickness.Empty; } + /// Does nothing for Adornment /// protected override bool OnRenderingLineCanvas () { return true; } @@ -196,9 +197,6 @@ public override bool SuperViewRendersLineCanvas set => throw new InvalidOperationException (@"Adornment can only render to their Parent or Parent's Superview."); } - /// - protected override void OnDrawComplete () { } - /// /// Indicates whether the specified Parent's SuperView-relative coordinates are within the Adornment's Thickness. /// diff --git a/Terminal.Gui/View/Adornment/Border.cs b/Terminal.Gui/View/Adornment/Border.cs index 289de1e7b5..566559b2c9 100644 --- a/Terminal.Gui/View/Adornment/Border.cs +++ b/Terminal.Gui/View/Adornment/Border.cs @@ -313,7 +313,7 @@ protected override bool OnMouseEvent (MouseEventArgs mouseEvent) // Adornment.Contains takes Parent SuperView=relative coords. if (Contains (new (mouseEvent.Position.X + Parent.Frame.X + Frame.X, mouseEvent.Position.Y + Parent.Frame.Y + Frame.Y))) { - if (_arranging != ViewArrangement.Fixed) + if (Arranging != ViewArrangement.Fixed) { EndArrangeMode (); } @@ -480,7 +480,7 @@ protected override bool OnMouseEvent (MouseEventArgs mouseEvent) int minWidth = Thickness.Horizontal + Parent!.Margin!.Thickness.Right; // TODO: This code can be refactored to be more readable and maintainable. - switch (_arranging) + switch (Arranging) { case ViewArrangement.Movable: @@ -979,7 +979,7 @@ private static void GetAppealingGradientColors (out List stops, out List< steps = [15]; } - private ViewArrangement _arranging; + internal ViewArrangement Arranging { get; set; } private Button? _moveButton; // always top-left private Button? _allSizeButton; @@ -1000,7 +1000,7 @@ private static void GetAppealingGradientColors (out List stops, out List< /// public bool? EnterArrangeMode (ViewArrangement arrangement) { - Debug.Assert (_arranging == ViewArrangement.Fixed); + Debug.Assert (Arranging == ViewArrangement.Fixed); if (!Parent!.Arrangement.HasFlag (ViewArrangement.Movable) && !Parent!.Arrangement.HasFlag (ViewArrangement.BottomResizable) @@ -1163,16 +1163,16 @@ private static void GetAppealingGradientColors (out List stops, out List< _allSizeButton!.Visible = true; } - _arranging = ViewArrangement.Movable; + Arranging = ViewArrangement.Movable; CanFocus = true; SetFocus (); } else { // Mouse mode - _arranging = arrangement; + Arranging = arrangement; - switch (_arranging) + switch (Arranging) { case ViewArrangement.Movable: _moveButton!.Visible = true; @@ -1247,13 +1247,13 @@ private static void GetAppealingGradientColors (out List stops, out List< } } - if (_arranging != ViewArrangement.Fixed) + if (Arranging != ViewArrangement.Fixed) { if (arrangement == ViewArrangement.Fixed) { // Keyboard mode - enable nav // TODO: Keyboard mode only supports sizing from bottom/right. - _arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); + Arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); } return true; @@ -1278,12 +1278,12 @@ private void AddArrangeModeKeyBindings () return false; } - if (_arranging == ViewArrangement.Movable) + if (Arranging == ViewArrangement.Movable) { Parent!.Y = Parent.Y - 1; } - if (_arranging == ViewArrangement.Resizable) + if (Arranging == ViewArrangement.Resizable) { if (Parent!.Viewport.Height > 0) { @@ -1303,12 +1303,12 @@ private void AddArrangeModeKeyBindings () return false; } - if (_arranging == ViewArrangement.Movable) + if (Arranging == ViewArrangement.Movable) { Parent!.Y = Parent.Y + 1; } - if (_arranging == ViewArrangement.Resizable) + if (Arranging == ViewArrangement.Resizable) { Parent!.Height = Parent.Height! + 1; } @@ -1325,12 +1325,12 @@ private void AddArrangeModeKeyBindings () return false; } - if (_arranging == ViewArrangement.Movable) + if (Arranging == ViewArrangement.Movable) { Parent!.X = Parent.X - 1; } - if (_arranging == ViewArrangement.Resizable) + if (Arranging == ViewArrangement.Resizable) { if (Parent!.Viewport.Width > 0) { @@ -1350,12 +1350,12 @@ private void AddArrangeModeKeyBindings () return false; } - if (_arranging == ViewArrangement.Movable) + if (Arranging == ViewArrangement.Movable) { Parent!.X = Parent.X + 1; } - if (_arranging == ViewArrangement.Resizable) + if (Arranging == ViewArrangement.Resizable) { Parent!.Width = Parent.Width! + 1; } @@ -1373,7 +1373,7 @@ private void AddArrangeModeKeyBindings () // BUGBUG: the view hierachy. AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); - _arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); + Arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); return true; // Always eat }); @@ -1383,7 +1383,7 @@ private void AddArrangeModeKeyBindings () () => { AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop); - _arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); + Arranging = (ViewArrangement)(Focused?.Data ?? ViewArrangement.Fixed); return true; // Always eat }); @@ -1419,7 +1419,7 @@ private void ApplicationOnMouseEvent (object? sender, MouseEventArgs e) private bool? EndArrangeMode () { // Debug.Assert (_arranging != ViewArrangement.Fixed); - _arranging = ViewArrangement.Fixed; + Arranging = ViewArrangement.Fixed; Application.MouseEvent -= ApplicationOnMouseEvent; diff --git a/Terminal.Gui/View/DrawContext.cs b/Terminal.Gui/View/DrawContext.cs new file mode 100644 index 0000000000..36b60c4c4d --- /dev/null +++ b/Terminal.Gui/View/DrawContext.cs @@ -0,0 +1,54 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Tracks the region that has been drawn during . This is primarily +/// in support of . +/// +public class DrawContext +{ + private readonly Region _drawnRegion = new Region (); + + /// + /// Gets a copy of the region drawn so far in this context. + /// + public Region GetDrawnRegion () => _drawnRegion.Clone (); + + /// + /// Reports that a rectangle has been drawn. + /// + /// The rectangle that was drawn. + public void AddDrawnRectangle (Rectangle rect) + { + _drawnRegion.Combine (rect, RegionOp.Union); + } + + /// + /// Reports that a region has been drawn. + /// + /// The region that was drawn. + public void AddDrawnRegion (Region region) + { + _drawnRegion.Combine (region, RegionOp.Union); + } + + /// + /// Clips (intersects) the drawn region with the specified rectangle. + /// This modifies the internal drawn region directly. + /// + /// The clipping rectangle. + public void ClipDrawnRegion (Rectangle clipRect) + { + _drawnRegion.Intersect (clipRect); + } + + /// + /// Clips (intersects) the drawn region with the specified region. + /// This modifies the internal drawn region directly. + /// + /// The clipping region. + public void ClipDrawnRegion (Region clipRegion) + { + _drawnRegion.Intersect (clipRegion); + } +} diff --git a/Terminal.Gui/View/DrawEventArgs.cs b/Terminal.Gui/View/DrawEventArgs.cs index f3cfc7747a..5aabd2e660 100644 --- a/Terminal.Gui/View/DrawEventArgs.cs +++ b/Terminal.Gui/View/DrawEventArgs.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +#nullable enable +using System.ComponentModel; namespace Terminal.Gui; @@ -14,10 +15,16 @@ public class DrawEventArgs : CancelEventArgs /// The Content-relative rectangle describing the old visible viewport into the /// . /// - public DrawEventArgs (Rectangle newViewport, Rectangle oldViewport) + /// + /// Add any regions that have been drawn to during operations to this context. This is + /// primarily + /// in support of . + /// + public DrawEventArgs (Rectangle newViewport, Rectangle oldViewport, DrawContext? drawContext) { NewViewport = newViewport; OldViewport = oldViewport; + DrawContext = drawContext; } /// Gets the Content-relative rectangle describing the old visible viewport into the . @@ -25,4 +32,11 @@ public DrawEventArgs (Rectangle newViewport, Rectangle oldViewport) /// Gets the Content-relative rectangle describing the currently visible viewport into the . public Rectangle NewViewport { get; } + + /// + /// Add any regions that have been drawn to during operations to this context. This is + /// primarily + /// in support of . + /// + public DrawContext? DrawContext { get; } } diff --git a/Terminal.Gui/View/View.Content.cs b/Terminal.Gui/View/View.Content.cs index 3f3790dddd..b187e01407 100644 --- a/Terminal.Gui/View/View.Content.cs +++ b/Terminal.Gui/View/View.Content.cs @@ -385,7 +385,7 @@ void ApplySettings (ref Rectangle newViewport) private void RaiseViewportChangedEvent (Rectangle oldViewport) { - var args = new DrawEventArgs (IsInitialized ? Viewport : Rectangle.Empty, oldViewport); + var args = new DrawEventArgs (IsInitialized ? Viewport : Rectangle.Empty, oldViewport, null); OnViewportChanged (args); ViewportChanged?.Invoke (this, args); } @@ -430,6 +430,20 @@ public Point ViewportToScreen (in Point viewportLocation) return screen.Location; } + /// + /// Gets the Viewport rectangle with a screen-relative location. + /// + /// Screen-relative location and size. + public Rectangle ViewportToScreen () + { + // Translate bounds to Frame (our SuperView's Viewport-relative coordinates) + Rectangle screen = FrameToScreen (); + Point viewportOffset = GetViewportOffsetFromFrame (); + screen.Offset (viewportOffset.X, viewportOffset.Y); + + return screen; + } + /// Converts a screen-relative coordinate to a Viewport-relative coordinate. /// The coordinate relative to this view's . /// diff --git a/Terminal.Gui/View/View.Drawing.Clipping.cs b/Terminal.Gui/View/View.Drawing.Clipping.cs index 30f8b703c6..3b131a614c 100644 --- a/Terminal.Gui/View/View.Drawing.Clipping.cs +++ b/Terminal.Gui/View/View.Drawing.Clipping.cs @@ -11,8 +11,7 @@ public partial class View /// There is a single clip region for the entire application. /// /// - /// This method returns the current clip region, not a clone. If there is a need to modify the clip region, it is - /// recommended to clone it first. + /// This method returns the current clip region, not a clone. If there is a need to modify the clip region, clone it first. /// /// /// The current Clip. @@ -74,6 +73,17 @@ public static void SetClip (Region? region) /// public static void ExcludeFromClip (Rectangle rectangle) { Driver?.Clip?.Exclude (rectangle); } + /// + /// Removes the specified rectangle from the Clip. + /// + /// + /// + /// There is a single clip region for the entire application. + /// + /// + /// + public static void ExcludeFromClip (Region? region) { Driver?.Clip?.Exclude (region); } + /// /// Changes the Clip to the intersection of the current Clip and the of this View. /// @@ -86,7 +96,7 @@ public static void SetClip (Region? region) /// /// The current Clip, which can be then re-applied /// - internal Region? ClipFrame () + internal Region? AddFrameToClip () { if (Driver is null) { @@ -133,7 +143,7 @@ public static void SetClip (Region? region) /// /// The current Clip, which can be then re-applied /// - public Region? ClipViewport () + public Region? AddViewportToClip () { if (Driver is null) { diff --git a/Terminal.Gui/View/View.Drawing.Primitives.cs b/Terminal.Gui/View/View.Drawing.Primitives.cs index a7f1c8522d..153f12e644 100644 --- a/Terminal.Gui/View/View.Drawing.Primitives.cs +++ b/Terminal.Gui/View/View.Drawing.Primitives.cs @@ -137,7 +137,7 @@ public void FillRect (Rectangle rect, Color? color = null) return; } - Region prevClip = ClipViewport (); + Region prevClip = AddViewportToClip (); Rectangle toClear = ViewportToScreen (rect); Attribute prev = SetAttribute (new (color ?? GetNormalColor ().Background)); Driver.FillRect (toClear); @@ -155,7 +155,7 @@ public void FillRect (Rectangle rect, Rune rune) return; } - Region prevClip = ClipViewport (); + Region prevClip = AddViewportToClip (); Rectangle toClear = ViewportToScreen (rect); Driver.FillRect (toClear, rune); SetClip (prevClip); diff --git a/Terminal.Gui/View/View.Drawing.cs b/Terminal.Gui/View/View.Drawing.cs index 2e33706e14..aa77e3f05a 100644 --- a/Terminal.Gui/View/View.Drawing.cs +++ b/Terminal.Gui/View/View.Drawing.cs @@ -14,6 +14,9 @@ internal static void Draw (IEnumerable views, bool force) { IEnumerable viewsArray = views as View [] ?? views.ToArray (); + // The draw context is used to track the region drawn by each view. + DrawContext context = new DrawContext (); + foreach (View view in viewsArray) { if (force) @@ -21,7 +24,7 @@ internal static void Draw (IEnumerable views, bool force) view.SetNeedsDraw (); } - view.Draw (); + view.Draw (context); } Margin.DrawMargins (viewsArray); @@ -40,94 +43,90 @@ internal static void Draw (IEnumerable views, bool force) /// See the View Drawing Deep Dive for more information: . /// /// - public void Draw () + public void Draw (DrawContext? context = null) { if (!CanBeVisible (this)) { return; } + Region? originalClip = GetClip (); - Region? saved = GetClip (); - - // TODO: This can be further optimized by checking NeedsDraw below and only clearing, drawing text, drawing content, etc. if it is true. + // TODO: This can be further optimized by checking NeedsDraw below and only + // TODO: clearing, drawing text, drawing content, etc. if it is true. if (NeedsDraw || SubViewNeedsDraw) { + // ------------------------------------ // Draw the Border and Padding. - // We clip to the frame to prevent drawing outside the frame. - saved = ClipFrame (); - - DoDrawBorderAndPadding (); - SetClip (saved); + // Note Margin is special-cased and drawn in a separate pass to support + // transparent shadows. + DoDrawBorderAndPadding (originalClip); + SetClip (originalClip); - // Draw the content within the Viewport + // ------------------------------------ + // Clear the Viewport // By default, we clip to the viewport preventing drawing outside the viewport // We also clip to the content, but if a developer wants to draw outside the viewport, they can do // so via settings. SetClip honors the ViewportSettings.DisableVisibleContentClipping flag. // Get our Viewport in screen coordinates + originalClip = AddViewportToClip (); - saved = ClipViewport (); + // If no context ... + context ??= new DrawContext (); - // Clear the viewport // TODO: Simplify/optimize SetAttribute system. DoSetAttribute (); DoClearViewport (); - // Draw the subviews only if needed + // ------------------------------------ + // Draw the subviews first (order matters: Subviews, Text, Content) if (SubViewNeedsDraw) { DoSetAttribute (); - DoDrawSubviews (); + DoDrawSubviews (context); } + // ------------------------------------ // Draw the text DoSetAttribute (); - DoDrawText (); + DoDrawText (context); + // ------------------------------------ // Draw the content DoSetAttribute (); - DoDrawContent (); + DoDrawContent (context); + // ------------------------------------ + // Draw the line canvas // Restore the clip before rendering the line canvas and adornment subviews // because they may draw outside the viewport. - SetClip (saved); - - saved = ClipFrame (); - - // Draw the line canvas + SetClip (originalClip); + originalClip = AddFrameToClip (); DoRenderLineCanvas (); + // ------------------------------------ // 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. DoDrawBorderAndPaddingSubViews (); + // ------------------------------------ // Advance the diagnostics draw indicator Border?.AdvanceDrawIndicator (); ClearNeedsDraw (); } + // ------------------------------------ // This causes the Margin to be drawn in a second pass // PERFORMANCE: If there is a Margin, it will be redrawn each iteration of the main loop. Margin?.CacheClip (); - // We're done drawing - DoDrawComplete (); - - // QUESTION: Should this go before DoDrawComplete? What is more correct? - SetClip (saved); - - // Exclude this view (not including Margin) from the Clip - if (this is not Adornment) - { - Rectangle borderFrame = FrameToScreen (); - - if (Border is { }) - { - borderFrame = Border.FrameToScreen (); - } + // ------------------------------------ + // Reset the clip to what it was when we started + SetClip (originalClip); - ExcludeFromClip (borderFrame); - } + // ------------------------------------ + // We're done drawing - The Clip is reset to what it was before we started. + DoDrawComplete (context); } #region DrawAdornments @@ -144,10 +143,10 @@ private void DoDrawBorderAndPaddingSubViews () subview.SetNeedsDraw (); } - LineCanvas.Exclude (new (subview.FrameToScreen())); + LineCanvas.Exclude (new (subview.FrameToScreen ())); } - Region? saved = Border?.ClipFrame (); + Region? saved = Border?.AddFrameToClip (); Border?.DoDrawSubviews (); SetClip (saved); } @@ -159,17 +158,33 @@ private void DoDrawBorderAndPaddingSubViews () subview.SetNeedsDraw (); } - Region? saved = Padding?.ClipFrame (); + Region? saved = Padding?.AddFrameToClip (); Padding?.DoDrawSubviews (); SetClip (saved); } } - private void DoDrawBorderAndPadding () + private void DoDrawBorderAndPadding (Region? originalClip) { + if (this is Adornment) + { + AddFrameToClip (); + } + else + { + // Set the clip to be just the thicknesses of the adornments + // TODO: Put this union logic in a method on View? + Region? clipAdornments = Margin!.Thickness.AsRegion (Margin!.FrameToScreen ()); + clipAdornments?.Combine (Border!.Thickness.AsRegion (Border!.FrameToScreen ()), RegionOp.Union); + clipAdornments?.Combine (Padding!.Thickness.AsRegion (Padding!.FrameToScreen ()), RegionOp.Union); + clipAdornments?.Combine (originalClip, RegionOp.Intersect); + SetClip (clipAdornments); + } + if (Margin?.NeedsLayout == true) { Margin.NeedsLayout = false; + // BUGBUG: This should not use ClearFrame as that clears the insides too Margin?.ClearFrame (); Margin?.Parent?.SetSubViewNeedsDraw (); } @@ -291,27 +306,31 @@ public void SetNormalAttribute () #region ClearViewport - private void DoClearViewport () + internal void DoClearViewport () { - if (OnClearingViewport ()) + if (ViewportSettings.HasFlag (ViewportSettings.Transparent)) { return; } - var dev = new DrawEventArgs (Viewport, Rectangle.Empty); - ClearingViewport?.Invoke (this, dev); - - if (dev.Cancel) + if (OnClearingViewport ()) { return; } - if (!NeedsDraw) + var dev = new DrawEventArgs (Viewport, Rectangle.Empty, null); + ClearingViewport?.Invoke (this, dev); + + if (dev.Cancel) { + SetNeedsDraw (); return; } ClearViewport (); + + OnClearedViewport (); + ClearedViewport?.Invoke (this, new (Viewport, Viewport, null)); } /// @@ -320,7 +339,7 @@ private void DoClearViewport () /// to stop further clearing. protected virtual bool OnClearingViewport () { return false; } - /// Event invoked when the content area of the View is to be drawn. + /// Event invoked when the is to be cleared. /// /// Will be invoked before any subviews added with have been drawn. /// @@ -330,6 +349,14 @@ private void DoClearViewport () /// public event EventHandler? ClearingViewport; + /// + /// Called when the has been cleared + /// + protected virtual void OnClearedViewport () { } + + /// Event invoked when the has been cleared. + public event EventHandler? ClearedViewport; + /// Clears with the normal background. /// /// @@ -368,35 +395,41 @@ public void ClearViewport () #region DrawText - private void DoDrawText () + private void DoDrawText (DrawContext? context = null) { - if (OnDrawingText ()) + if (OnDrawingText (context)) { return; } - var dev = new DrawEventArgs (Viewport, Rectangle.Empty); - DrawingText?.Invoke (this, dev); - - if (dev.Cancel) + if (OnDrawingText ()) { return; } - if (!NeedsDraw) + var dev = new DrawEventArgs (Viewport, Rectangle.Empty, context); + DrawingText?.Invoke (this, dev); + + if (dev.Cancel) { return; } - DrawText (); + DrawText (context); } /// /// Called when the of the View is to be drawn. /// + /// The draw context to report drawn areas to. /// to stop further drawing of . - protected virtual bool OnDrawingText () { return false; } + protected virtual bool OnDrawingText (DrawContext? context) { return false; } + /// + /// Called when the of the View is to be drawn. + /// + /// to stop further drawing of . + protected virtual bool OnDrawingText () { return false; } /// Raised when the of the View is to be drawn. /// @@ -408,16 +441,27 @@ private void DoDrawText () /// /// Draws the of the View using the . /// - public void DrawText () + /// The draw context to report drawn areas to. + public void DrawText (DrawContext? context = null) { if (!string.IsNullOrEmpty (TextFormatter.Text)) { TextFormatter.NeedsFormat = true; } - // TODO: If the output is not in the Viewport, do nothing var drawRect = new Rectangle (ContentToScreen (Point.Empty), GetContentSize ()); + // Use GetDrawRegion to get precise drawn areas + Region textRegion = TextFormatter.GetDrawRegion (drawRect); + + // Report the drawn area to the context + context?.AddDrawnRegion (textRegion); + + if (!NeedsDraw) + { + return; + } + TextFormatter?.Draw ( drawRect, HasFocus ? GetFocusColor () : GetNormalColor (), @@ -430,34 +474,45 @@ public void DrawText () } #endregion DrawText - #region DrawContent - private void DoDrawContent () + private void DoDrawContent (DrawContext? context = null) { + if (OnDrawingContent (context)) + { + return; + } + if (OnDrawingContent ()) { return; } - var dev = new DrawEventArgs (Viewport, Rectangle.Empty); + var dev = new DrawEventArgs (Viewport, Rectangle.Empty, context); DrawingContent?.Invoke (this, dev); if (dev.Cancel) - { } + { + return; + } - // Do nothing. + // No default drawing; let event handlers or overrides handle it } /// /// Called when the View's content is to be drawn. The default implementation does nothing. /// - /// - /// + /// The draw context to report drawn areas to. + /// to stop further drawing content. + protected virtual bool OnDrawingContent (DrawContext? context = null) { return false; } + + /// + /// Called when the View's content is to be drawn. The default implementation does nothing. + /// /// to stop further drawing content. protected virtual bool OnDrawingContent () { return false; } - /// Raised when the View's content is to be drawn. + /// Raised when the View's content is to be drawn. /// /// Will be invoked before any subviews added with have been drawn. /// @@ -471,14 +526,19 @@ private void DoDrawContent () #region DrawSubviews - private void DoDrawSubviews () + private void DoDrawSubviews (DrawContext? context = null) { + if (OnDrawingSubviews (context)) + { + return; + } + if (OnDrawingSubviews ()) { return; } - var dev = new DrawEventArgs (Viewport, Rectangle.Empty); + var dev = new DrawEventArgs (Viewport, Rectangle.Empty, context); DrawingSubviews?.Invoke (this, dev); if (dev.Cancel) @@ -491,15 +551,21 @@ private void DoDrawSubviews () return; } - DrawSubviews (); + DrawSubviews (context); } /// /// Called when the are to be drawn. /// + /// The draw context to report drawn areas to, or null if not tracking. /// to stop further drawing of . - protected virtual bool OnDrawingSubviews () { return false; } + protected virtual bool OnDrawingSubviews (DrawContext? context) { return false; } + /// + /// Called when the are to be drawn. + /// + /// to stop further drawing of . + protected virtual bool OnDrawingSubviews () { return false; } /// Raised when the are to be drawn. /// @@ -513,7 +579,8 @@ private void DoDrawSubviews () /// /// Draws the . /// - public void DrawSubviews () + /// The draw context to report drawn areas to, or null if not tracking. + public void DrawSubviews (DrawContext? context = null) { if (_subviews is null) { @@ -523,12 +590,12 @@ public void DrawSubviews () // Draw the subviews in reverse order to leverage clipping. foreach (View view in _subviews.Where (view => view.Visible).Reverse ()) { - // TODO: HACK - This enables auto line join to work, but is brute force. - if (view.SuperViewRendersLineCanvas) + // TODO: HACK - This forcing of SetNeedsDraw with SuperViewRendersLineCanvas enables auto line join to work, but is brute force. + if (view.SuperViewRendersLineCanvas || view.ViewportSettings.HasFlag (ViewportSettings.Transparent)) { view.SetNeedsDraw (); } - view.Draw (); + view.Draw (context); if (view.SuperViewRendersLineCanvas) { @@ -609,19 +676,56 @@ public void RenderLineCanvas () #region DrawComplete - private void DoDrawComplete () + private void DoDrawComplete (DrawContext? context) { - OnDrawComplete (); + OnDrawComplete (context); + DrawComplete?.Invoke (this, new (Viewport, Viewport, context)); + + // Now, update the clip to exclude this view (not including Margin) + if (this is not Adornment) + { + if (ViewportSettings.HasFlag (ViewportSettings.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. + context!.ClipDrawnRegion (ViewportToScreen (Viewport)); - DrawComplete?.Invoke (this, new (Viewport, Viewport)); + // Exclude the drawn region from the clip + ExcludeFromClip (context!.GetDrawnRegion ()); - // Default implementation does nothing. + // Exclude the Border and Padding from the clip + ExcludeFromClip (Border?.Thickness.AsRegion (FrameToScreen ())); + ExcludeFromClip (Padding?.Thickness.AsRegion (FrameToScreen ())); + } + else + { + // Exclude this view (not including Margin) from the Clip + Rectangle borderFrame = FrameToScreen (); + + if (Border is { }) + { + borderFrame = Border.FrameToScreen (); + } + + // In the non-transparent (typical case), we want to exclude the entire view area (borderFrame) from the clip + 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. + context?.AddDrawnRectangle (borderFrame); + } + } + + // TODO: Determine if we need another event that conveys the FINAL DrawContext } /// /// Called when the View is completed drawing. /// - protected virtual void OnDrawComplete () { } + /// + /// The parameter provides the drawn region of the View. + /// + protected virtual void OnDrawComplete (DrawContext? context) { } /// Raised when the View is completed drawing. /// diff --git a/Terminal.Gui/View/View.Mouse.cs b/Terminal.Gui/View/View.Mouse.cs index f7d6d57a22..691c10d8a8 100644 --- a/Terminal.Gui/View/View.Mouse.cs +++ b/Terminal.Gui/View/View.Mouse.cs @@ -763,7 +763,7 @@ internal bool SetPressedHighlight (HighlightStyle newHighlightStyle) /// /// /// - internal static List GetViewsUnderMouse (in Point location) + internal static List GetViewsUnderMouse (in Point location, bool ignoreTransparent = false) { List viewsUnderMouse = new (); @@ -810,7 +810,8 @@ internal bool SetPressedHighlight (HighlightStyle newHighlightStyle) for (int i = start.InternalSubviews.Count - 1; i >= 0; i--) { if (start.InternalSubviews [i].Visible - && start.InternalSubviews [i].Contains (new (startOffsetX + start.Viewport.X, startOffsetY + start.Viewport.Y))) + && start.InternalSubviews [i].Contains (new (startOffsetX + start.Viewport.X, startOffsetY + start.Viewport.Y)) + && (!ignoreTransparent || !start.InternalSubviews [i].ViewportSettings.HasFlag (ViewportSettings.TransparentMouse))) { subview = start.InternalSubviews [i]; currentLocation.X = startOffsetX + start.Viewport.X; @@ -823,6 +824,15 @@ internal bool SetPressedHighlight (HighlightStyle newHighlightStyle) if (subview is null) { + if (start.ViewportSettings.HasFlag (ViewportSettings.TransparentMouse)) + { + viewsUnderMouse.AddRange (View.GetViewsUnderMouse (location, true)); + + // De-dupe viewsUnderMouse + HashSet dedupe = [..viewsUnderMouse]; + viewsUnderMouse = [..dedupe]; + } + // No subview was found that's under the mouse, so we're done return viewsUnderMouse; } diff --git a/Terminal.Gui/View/ViewportSettings.cs b/Terminal.Gui/View/ViewportSettings.cs index db2c23f4fe..bedc9a43ca 100644 --- a/Terminal.Gui/View/ViewportSettings.cs +++ b/Terminal.Gui/View/ViewportSettings.cs @@ -1,7 +1,7 @@ namespace Terminal.Gui; /// -/// Settings for how the behaves relative to the View's Content area. +/// Settings for how the behaves. /// /// /// See the Layout Deep Dive for more information: @@ -13,7 +13,7 @@ public enum ViewportSettings /// /// No settings. /// - None = 0, + None = 0b_0000, /// /// If set, .X can be set to negative values enabling scrolling beyond the left of @@ -23,7 +23,7 @@ public enum ViewportSettings /// When not set, .X is constrained to positive values. /// /// - AllowNegativeX = 1, + AllowNegativeX = 0b_0001, /// /// If set, .Y can be set to negative values enabling scrolling beyond the top of the @@ -32,7 +32,7 @@ public enum ViewportSettings /// When not set, .Y is constrained to positive values. /// /// - AllowNegativeY = 2, + AllowNegativeY = 0b_0010, /// /// If set, .Size can be set to negative coordinates enabling scrolling beyond the @@ -58,7 +58,7 @@ public enum ViewportSettings /// The practical effect of this is that the last column of the content will always be visible. /// /// - AllowXGreaterThanContentWidth = 4, + AllowXGreaterThanContentWidth = 0b_0100, /// /// If set, .Y can be set values greater than @@ -74,7 +74,7 @@ public enum ViewportSettings /// The practical effect of this is that the last row of the content will always be visible. /// /// - AllowYGreaterThanContentHeight = 8, + AllowYGreaterThanContentHeight = 0b_1000, /// /// If set, .Location can be set values greater than @@ -102,7 +102,7 @@ public enum ViewportSettings /// This can be useful in infinite scrolling scenarios. /// /// - AllowNegativeXWhenWidthGreaterThanContentWidth = 16, + AllowNegativeXWhenWidthGreaterThanContentWidth = 0b_0001_0000, /// /// If set and .Height is greater than @@ -117,7 +117,7 @@ public enum ViewportSettings /// This can be useful in infinite scrolling scenarios. /// /// - AllowNegativeYWhenHeightGreaterThanContentHeight = 32, + AllowNegativeYWhenHeightGreaterThanContentHeight = 0b_0010_0000, /// /// The combination of and @@ -129,7 +129,7 @@ public enum ViewportSettings /// By default, clipping is applied to the . Setting this flag will cause clipping to be /// applied to the visible content area. /// - ClipContentOnly = 64, + ClipContentOnly = 0b_0100_0000, /// /// If set will clear only the portion of the content @@ -138,5 +138,27 @@ public enum ViewportSettings /// must be set for this setting to work (clipping beyond the visible area must be /// disabled). /// - ClearContentOnly = 128 + ClearContentOnly = 0b_1000_0000, + + /// + /// If set the View will be transparent: The will not be cleared when the View is drawn and the clip region + /// will be set to clip the View's and . + /// + /// Only the topmost View in a Subview Hierarchy can be transparent. Any subviews of the topmost transparent view + /// will have indeterminate draw behavior. + /// + /// + /// Combine this with to get a view that is both visually transparent and transparent to the mouse. + /// + /// + Transparent = 0b_0001_0000_0000, + + /// + /// If set the View will be transparent to mouse events: Any mouse event that occurs over the View (and it's Subviews) will be passed to the + /// Views below it. + /// + /// Combine this with to get a view that is both visually transparent and transparent to the mouse. + /// + /// + TransparentMouse = 0b_0010_0000_0000, } diff --git a/Terminal.Gui/Views/CharMap/CharMap.cs b/Terminal.Gui/Views/CharMap/CharMap.cs index f3afab7596..e15a732dc2 100644 --- a/Terminal.Gui/Views/CharMap/CharMap.cs +++ b/Terminal.Gui/Views/CharMap/CharMap.cs @@ -96,8 +96,8 @@ public CharMap () }; // Set up the vertical scrollbar. Turn off AutoShow since it's always visible. - VerticalScrollBar.AutoShow = false; - VerticalScrollBar.Visible = true; // Force always visible + VerticalScrollBar.AutoShow = true; + VerticalScrollBar.Visible = false; VerticalScrollBar.X = Pos.AnchorEnd (); VerticalScrollBar.Y = HEADER_HEIGHT; // Header } diff --git a/Terminal.Gui/Views/TileView.cs b/Terminal.Gui/Views/TileView.cs index 2a834aa925..c2693f1914 100644 --- a/Terminal.Gui/Views/TileView.cs +++ b/Terminal.Gui/Views/TileView.cs @@ -171,8 +171,6 @@ public int IndexOf (View toFind, bool recursive = false) /// public bool IsRootTileView () { return _parentTileView == null; } - // BUG: v2 fix this hack - // QUESTION: Does this need to be fixed before events are refactored? /// Overridden so no Frames get drawn /// protected override bool OnDrawingBorderAndPadding () { return true; } @@ -181,7 +179,7 @@ public int IndexOf (View toFind, bool recursive = false) protected override bool OnRenderingLineCanvas () { return false; } /// - protected override void OnDrawComplete () + protected override void OnDrawComplete (DrawContext? context) { if (ColorScheme is { }) { diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 8b9178c393..07d849e84f 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -386,6 +386,7 @@ UI UL UR + XOR False <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> diff --git a/UICatalog/Scenarios/AllViewsTester.cs b/UICatalog/Scenarios/AllViewsTester.cs index d982b2b899..c6f9136796 100644 --- a/UICatalog/Scenarios/AllViewsTester.cs +++ b/UICatalog/Scenarios/AllViewsTester.cs @@ -18,10 +18,9 @@ public class AllViewsTester : Scenario private Dictionary? _viewClasses; private ListView? _classListView; private AdornmentsEditor? _adornmentsEditor; - private ArrangementEditor? _arrangementEditor; - private LayoutEditor? _layoutEditor; + private ViewportSettingsEditor? _viewportSettingsEditor; private FrameView? _settingsPane; private RadioGroup? _orientation; private string _demoText = "This, that, and the other thing."; @@ -133,13 +132,27 @@ public override void Main () AutoSelectAdornments = false, SuperViewRendersLineCanvas = true }; - _layoutEditor.Border!.Thickness = new (1); + _layoutEditor.Border!.Thickness = new (1, 1, 1, 0); + + _viewportSettingsEditor = new () + { + Title = "ViewportSettings [_5]", + X = Pos.Right (_arrangementEditor) - 1, + Y = Pos.Bottom (_layoutEditor) - Pos.Func (() => _layoutEditor.Frame.Height == 1 ? 0 : 1), + Width = Dim.Width (_layoutEditor), + Height = Dim.Auto (), + CanFocus = true, + AutoSelectViewToEdit = false, + AutoSelectAdornments = false, + SuperViewRendersLineCanvas = true + }; + _viewportSettingsEditor.Border!.Thickness = new (1, 1, 1, 1); _settingsPane = new () { - Title = "Settings [_5]", + Title = "Misc Settings [_6]", X = Pos.Right (_adornmentsEditor) - 1, - Y = Pos.Bottom (_layoutEditor) - Pos.Func (() => _layoutEditor.Frame.Height == 1 ? 0 : 1), + Y = Pos.Bottom (_viewportSettingsEditor) - Pos.Func (() => _viewportSettingsEditor.Frame.Height == 1 ? 0 : 1), Width = Dim.Width (_layoutEditor), Height = Dim.Auto (), CanFocus = true, @@ -237,7 +250,7 @@ public override void Main () _hostPane.Padding.Diagnostics = ViewDiagnosticFlags.Ruler; _hostPane.Padding.ColorScheme = app.ColorScheme; - app.Add (_classListView, _adornmentsEditor, _arrangementEditor, _layoutEditor, _settingsPane, _eventLog, _hostPane); + app.Add (_classListView, _adornmentsEditor, _arrangementEditor, _layoutEditor, _viewportSettingsEditor, _settingsPane, _eventLog, _hostPane); app.Initialized += App_Initialized; @@ -306,6 +319,7 @@ private void CreateCurrentView (Type type) _hostPane!.Add (_curView); _layoutEditor!.ViewToEdit = _curView; + _viewportSettingsEditor!.ViewToEdit = _curView; _arrangementEditor!.ViewToEdit = _curView; _curView.SetNeedsLayout (); } @@ -318,6 +332,7 @@ private void DisposeCurrentView () _curView.SubviewsLaidOut -= CurrentView_LayoutComplete; _hostPane!.Remove (_curView); _layoutEditor!.ViewToEdit = null; + _viewportSettingsEditor!.ViewToEdit = null; _arrangementEditor!.ViewToEdit = null; _curView.Dispose (); diff --git a/UICatalog/Scenarios/Arrangement.cs b/UICatalog/Scenarios/Arrangement.cs index 33ac9ee84c..403e6f5baf 100644 --- a/UICatalog/Scenarios/Arrangement.cs +++ b/UICatalog/Scenarios/Arrangement.cs @@ -49,11 +49,14 @@ public override void Main () FrameView testFrame = new () { Title = "_1 Test Frame", + Text = "This is the text of the Test Frame.\nLine 2.\nLine 3.", X = Pos.Right (arrangementEditor), Y = 0, Width = Dim.Fill (), Height = Dim.Fill () }; + testFrame.TextAlignment = Alignment.Center; + testFrame.VerticalTextAlignment = Alignment.Center; app.Add (testFrame); @@ -64,16 +67,15 @@ public override void Main () tiledView3.Height = Dim.Height (tiledView1); View tiledView4 = CreateTiledView (3, Pos.Left (tiledView1), Pos.Bottom (tiledView1) - 1); tiledView4.Width = Dim.Func (() => tiledView3.Frame.Width + tiledView2.Frame.Width + tiledView1.Frame.Width - 2); - testFrame.Add (tiledView4, tiledView3, tiledView2, tiledView1); - View overlappedView1 = CreateOverlappedView (2, 0, 13); - overlappedView1.Title = "Movable _& Sizable"; + View movableSizeableWithProgress = CreateOverlappedView (2, 10, 8); + movableSizeableWithProgress.Title = "Movable _& Sizable"; View tiledSubView = CreateTiledView (4, 0, 2); tiledSubView.Arrangement = ViewArrangement.Fixed; - overlappedView1.Add (tiledSubView); + movableSizeableWithProgress.Add (tiledSubView); tiledSubView = CreateTiledView (5, Pos.Right (tiledSubView), Pos.Top (tiledSubView)); tiledSubView.Arrangement = ViewArrangement.Fixed; - overlappedView1.Add (tiledSubView); + movableSizeableWithProgress.Add (tiledSubView); ProgressBar progressBar = new () { @@ -81,7 +83,7 @@ public override void Main () Width = Dim.Fill (), Id = "progressBar" }; - overlappedView1.Add (progressBar); + movableSizeableWithProgress.Add (progressBar); Timer timer = new (10) { @@ -168,9 +170,6 @@ public override void Main () overlappedView2.Add (colorPicker); overlappedView2.Width = 50; - testFrame.Add (overlappedView1); - testFrame.Add (overlappedView2); - DatePicker datePicker = new () { X = 30, @@ -183,12 +182,27 @@ public override void Main () TabStop = TabBehavior.TabGroup, Arrangement = ViewArrangement.Movable | ViewArrangement.Overlapped }; + + TransparentView transparentView = new () + { + Id = "transparentView", + X = 30, + Y = 5, + Width = 35, + Height = 15 + }; + + testFrame.Add (tiledView4, tiledView3, tiledView2, tiledView1); + testFrame.Add (overlappedView2); testFrame.Add (datePicker); + testFrame.Add (movableSizeableWithProgress); + testFrame.Add (transparentView); adornmentsEditor.AutoSelectSuperView = testFrame; arrangementEditor.AutoSelectSuperView = testFrame; testFrame.SetFocus (); + Application.Run (app); timer.Close (); app.Dispose (); @@ -299,3 +313,57 @@ public override List GetDemoKeyStrokes () return keys; } } + +public class TransparentView : FrameView +{ + public TransparentView () + { + Title = "Transparent View"; + base.Text = "View.Text.\nThis should be opaque.\nNote how clipping works?"; + TextFormatter.Alignment = Alignment.Center; + TextFormatter.VerticalAlignment = Alignment.Center; + Arrangement = ViewArrangement.Overlapped | ViewArrangement.Resizable | ViewArrangement.Movable; + ViewportSettings |= Terminal.Gui.ViewportSettings.Transparent | Terminal.Gui.ViewportSettings.TransparentMouse; + BorderStyle = LineStyle.RoundedDotted; + base.ColorScheme = Colors.ColorSchemes ["Menu"]; + + var transparentSubView = new View () + { + Text = "Sizable/Movable View with border. Should be opaque. The shadow should be semi-opaque.", + Id = "transparentSubView", + X = 4, + Y = 8, + Width = 20, + Height = 8, + BorderStyle = LineStyle.Dashed, + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + ShadowStyle = ShadowStyle.Transparent, + //ViewportSettings = Terminal.Gui.ViewportSettings.Transparent + }; + transparentSubView.Border.Thickness = new (1, 1, 1, 1); + transparentSubView.ColorScheme = Colors.ColorSchemes ["Dialog"]; + + Button button = new Button () + { + Title = "_Opaque Shadows No Worky", + X = Pos.Center (), + Y = 4, + ColorScheme = Colors.ColorSchemes ["Dialog"], + }; + + button.ClearingViewport += (sender, args) => + { + args.Cancel = true; + }; + + + base.Add (button); + base.Add (transparentSubView); + } + + /// + protected override bool OnClearingViewport () { return false; } + + /// + protected override bool OnMouseEvent (MouseEventArgs mouseEvent) { return false; } +} diff --git a/UICatalog/Scenarios/Editors/ViewportSettingsEditor.cs b/UICatalog/Scenarios/Editors/ViewportSettingsEditor.cs new file mode 100644 index 0000000000..011ca212fc --- /dev/null +++ b/UICatalog/Scenarios/Editors/ViewportSettingsEditor.cs @@ -0,0 +1,371 @@ +#nullable enable +using System; +using Terminal.Gui; + +namespace UICatalog.Scenarios; + +/// +/// Provides an editor UI for View.ViewportSettings. +/// +public sealed class ViewportSettingsEditor : EditorBase +{ + public ViewportSettingsEditor () + { + Title = "ViewportSettingsEditor"; + TabStop = TabBehavior.TabGroup; + + Initialized += ViewportSettingsEditor_Initialized; + } + + protected override void OnUpdateSettings () + { + foreach (View subview in Subviews) + { + subview.Enabled = ViewToEdit is not Adornment; + } + + if (ViewToEdit is null) + { } + } + + protected override void OnViewToEditChanged () + { + if (ViewToEdit is { } and not Adornment) + { + //ViewToEdit.VerticalScrollBar.AutoShow = true; + //ViewToEdit.HorizontalScrollBar.AutoShow = true; + + _contentSizeWidth!.Value = ViewToEdit.GetContentSize ().Width; + _contentSizeHeight!.Value = ViewToEdit.GetContentSize ().Height; + + _cbAllowNegativeX!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.AllowNegativeX) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbAllowNegativeY!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.AllowNegativeY) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbAllowXGreaterThanContentWidth!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.AllowXGreaterThanContentWidth) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbAllowYGreaterThanContentHeight!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.AllowYGreaterThanContentHeight) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbClearContentOnly!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.ClearContentOnly) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbClipContentOnly!.CheckedState = ViewToEdit.ViewportSettings.HasFlag (Terminal.Gui.ViewportSettings.ClipContentOnly) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbTransparent!.CheckedState = ViewToEdit.ViewportSettings.HasFlag(Terminal.Gui.ViewportSettings.Transparent) + ? CheckState.Checked + : CheckState.UnChecked; + + _cbVerticalScrollBar!.CheckedState = ViewToEdit.VerticalScrollBar.Visible ? CheckState.Checked : CheckState.UnChecked; + _cbAutoShowVerticalScrollBar!.CheckedState = ViewToEdit.VerticalScrollBar.AutoShow ? CheckState.Checked : CheckState.UnChecked; + _cbHorizontalScrollBar!.CheckedState = ViewToEdit.HorizontalScrollBar.Visible ? CheckState.Checked : CheckState.UnChecked; + _cbAutoShowHorizontalScrollBar!.CheckedState = ViewToEdit.HorizontalScrollBar.AutoShow ? CheckState.Checked : CheckState.UnChecked; + } + } + + private CheckBox? _cbAllowNegativeX; + private CheckBox? _cbAllowNegativeY; + private CheckBox? _cbAllowXGreaterThanContentWidth; + private CheckBox? _cbAllowYGreaterThanContentHeight; + private NumericUpDown? _contentSizeWidth; + private NumericUpDown? _contentSizeHeight; + private CheckBox? _cbClearContentOnly; + private CheckBox? _cbClipContentOnly; + private CheckBox? _cbTransparent; + private CheckBox? _cbVerticalScrollBar; + private CheckBox? _cbAutoShowVerticalScrollBar; + private CheckBox? _cbHorizontalScrollBar; + private CheckBox? _cbAutoShowHorizontalScrollBar; + + private void ViewportSettingsEditor_Initialized (object? s, EventArgs e) + { + _cbAllowNegativeX = new() + { + Title = "Allow X < 0", + CanFocus = true + }; + + Add (_cbAllowNegativeX); + + _cbAllowNegativeY = new() + { + Title = "Allow Y < 0", + CanFocus = true + }; + + Add (_cbAllowNegativeY); + + _cbAllowXGreaterThanContentWidth = new() + { + Title = "Allow X > Content Width", + Y = Pos.Bottom (_cbAllowNegativeX), + CanFocus = true + }; + + _cbAllowNegativeX.CheckedStateChanging += AllowNegativeXToggle; + _cbAllowXGreaterThanContentWidth.CheckedStateChanging += AllowXGreaterThanContentWidthToggle; + + Add (_cbAllowXGreaterThanContentWidth); + + void AllowNegativeXToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.AllowNegativeX; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.AllowNegativeX; + } + } + + void AllowXGreaterThanContentWidthToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.AllowXGreaterThanContentWidth; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.AllowXGreaterThanContentWidth; + } + } + + _cbAllowYGreaterThanContentHeight = new() + { + Title = "Allow Y > Content Height", + X = Pos.Right (_cbAllowXGreaterThanContentWidth) + 1, + Y = Pos.Bottom (_cbAllowNegativeX), + CanFocus = true + }; + + _cbAllowNegativeY.CheckedStateChanging += AllowNegativeYToggle; + + _cbAllowYGreaterThanContentHeight.CheckedStateChanging += AllowYGreaterThanContentHeightToggle; + + Add (_cbAllowYGreaterThanContentHeight); + + void AllowNegativeYToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.AllowNegativeY; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.AllowNegativeY; + } + } + + void AllowYGreaterThanContentHeightToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.AllowYGreaterThanContentHeight; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.AllowYGreaterThanContentHeight; + } + } + + _cbAllowNegativeY.X = Pos.Left (_cbAllowYGreaterThanContentHeight); + + var labelContentSize = new Label + { + Title = "ContentSize:", + Y = Pos.Bottom (_cbAllowYGreaterThanContentHeight) + }; + + _contentSizeWidth = new() + { + X = Pos.Right (labelContentSize) + 1, + Y = Pos.Top (labelContentSize), + CanFocus = true + }; + _contentSizeWidth.ValueChanging += ContentSizeWidthValueChanged; + + void ContentSizeWidthValueChanged (object? sender, CancelEventArgs e) + { + if (e.NewValue < 0) + { + e.Cancel = true; + + return; + } + + // BUGBUG: set_ContentSize is supposed to be `protected`. + ViewToEdit!.SetContentSize (ViewToEdit.GetContentSize () with { Width = e.NewValue }); + } + + var labelComma = new Label + { + Title = ",", + X = Pos.Right (_contentSizeWidth), + Y = Pos.Top (labelContentSize) + }; + + _contentSizeHeight = new() + { + X = Pos.Right (labelComma) + 1, + Y = Pos.Top (labelContentSize), + CanFocus = true + }; + _contentSizeHeight.ValueChanging += ContentSizeHeightValueChanged; + + void ContentSizeHeightValueChanged (object? sender, CancelEventArgs e) + { + if (e.NewValue < 0) + { + e.Cancel = true; + + return; + } + + // BUGBUG: set_ContentSize is supposed to be `protected`. + ViewToEdit?.SetContentSize (ViewToEdit.GetContentSize () with { Height = e.NewValue }); + } + + _cbClearContentOnly = new() + { + Title = "ClearContentOnly", + X = 0, + Y = Pos.Bottom (labelContentSize), + CanFocus = true + }; + _cbClearContentOnly.CheckedStateChanging += ClearContentOnlyToggle; + + void ClearContentOnlyToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.ClearContentOnly; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.ClearContentOnly; + } + } + + _cbClipContentOnly = new() + { + Title = "ClipContentOnly", + X = Pos.Right (_cbClearContentOnly) + 1, + Y = Pos.Bottom (labelContentSize), + CanFocus = true + }; + _cbClipContentOnly.CheckedStateChanging += ClipContentOnlyToggle; + + void ClipContentOnlyToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.ClipContentOnly; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.ClipContentOnly; + } + } + + _cbTransparent = new () + { + Title = "Transparent", + X = Pos.Right (_cbClipContentOnly) + 1, + Y = Pos.Bottom (labelContentSize), + CanFocus = true + }; + _cbTransparent.CheckedStateChanging += TransparentToggle; + + void TransparentToggle (object? sender, CancelEventArgs e) + { + if (e.NewValue == CheckState.Checked) + { + ViewToEdit!.ViewportSettings |= Terminal.Gui.ViewportSettings.Transparent; + } + else + { + ViewToEdit!.ViewportSettings &= ~Terminal.Gui.ViewportSettings.Transparent; + } + } + + _cbVerticalScrollBar = new() + { + Title = "VerticalScrollBar", + X = 0, + Y = Pos.Bottom (_cbClearContentOnly), + CanFocus = false + }; + _cbVerticalScrollBar.CheckedStateChanging += VerticalScrollBarToggle; + + void VerticalScrollBarToggle (object? sender, CancelEventArgs e) + { + ViewToEdit!.VerticalScrollBar.Visible = e.NewValue == CheckState.Checked; + } + + _cbAutoShowVerticalScrollBar = new() + { + Title = "AutoShow", + X = Pos.Right (_cbVerticalScrollBar) + 1, + Y = Pos.Top (_cbVerticalScrollBar), + CanFocus = false + }; + _cbAutoShowVerticalScrollBar.CheckedStateChanging += AutoShowVerticalScrollBarToggle; + + void AutoShowVerticalScrollBarToggle (object? sender, CancelEventArgs e) + { + ViewToEdit!.VerticalScrollBar.AutoShow = e.NewValue == CheckState.Checked; + } + + _cbHorizontalScrollBar = new() + { + Title = "HorizontalScrollBar", + X = 0, + Y = Pos.Bottom (_cbVerticalScrollBar), + CanFocus = false + }; + _cbHorizontalScrollBar.CheckedStateChanging += HorizontalScrollBarToggle; + + void HorizontalScrollBarToggle (object? sender, CancelEventArgs e) + { + ViewToEdit!.HorizontalScrollBar.Visible = e.NewValue == CheckState.Checked; + } + + _cbAutoShowHorizontalScrollBar = new() + { + Title = "AutoShow ", + X = Pos.Right (_cbHorizontalScrollBar) + 1, + Y = Pos.Top (_cbHorizontalScrollBar), + CanFocus = false + }; + _cbAutoShowHorizontalScrollBar.CheckedStateChanging += AutoShowHorizontalScrollBarToggle; + + void AutoShowHorizontalScrollBarToggle (object? sender, CancelEventArgs e) + { + ViewToEdit!.HorizontalScrollBar.AutoShow = e.NewValue == CheckState.Checked; + } + + Add ( + labelContentSize, + _contentSizeWidth, + labelComma, + _contentSizeHeight, + _cbClearContentOnly, + _cbClipContentOnly, + _cbTransparent, + _cbVerticalScrollBar, + _cbHorizontalScrollBar, + _cbAutoShowVerticalScrollBar, + _cbAutoShowHorizontalScrollBar); + } +} diff --git a/UICatalog/Scenarios/RegionScenario.cs b/UICatalog/Scenarios/RegionScenario.cs new file mode 100644 index 0000000000..9b2e29c1a0 --- /dev/null +++ b/UICatalog/Scenarios/RegionScenario.cs @@ -0,0 +1,397 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Terminal.Gui; +using UICatalog; +using UICatalog.Scenarios; + +/// +/// Demonstrates creating and drawing regions through mouse dragging. +/// +[ScenarioMetadata ("Regions", "Region Tester")] +[ScenarioCategory ("Mouse and Keyboard")] +[ScenarioCategory ("Drawing")] +public class RegionScenario : Scenario +{ + private readonly Region _region = new (); + private Point? _dragStart; + private bool _isDragging; + + + public Rune? _previewFillRune = Glyphs.Stipple; + + public Rune? _fillRune = Glyphs.Dot; + + private RegionDrawStyles _drawStyle; + private RegionOp _regionOp; + + public override void Main () + { + Application.Init (); + + Window app = new () + { + Title = GetQuitKeyAndName (), + TabStop = TabBehavior.TabGroup, + + }; + app.Padding.Thickness = new (1); + + var tools = new ToolsView { Title = "Tools", X = Pos.AnchorEnd (), Y = 2 }; + + tools.CurrentAttribute = app.ColorScheme.HotNormal; + + tools.SetStyle += b => + { + _drawStyle = (RegionDrawStyles)b; + app.SetNeedsDraw(); + }; + + tools.RegionOpChanged += (s, e) => + { + _regionOp = e; + }; + //tools.AddLayer += () => canvas.AddLayer (); + + app.Add (tools); + + // Add drag handling to window + app.MouseEvent += (s, e) => + { + if (e.Flags.HasFlag (MouseFlags.Button1Pressed)) + { + if (!e.Flags.HasFlag (MouseFlags.ReportMousePosition)) + { // Start drag + _dragStart = e.ScreenPosition; + _isDragging = true; + } + else + { + // Drag + if (_isDragging && _dragStart.HasValue) + { + app.SetNeedsDraw (); + } + } + } + + if (e.Flags.HasFlag (MouseFlags.Button1Released)) + { + if (_isDragging && _dragStart.HasValue) + { + // Add the new region + AddRectangleFromPoints (_dragStart.Value, e.ScreenPosition, _regionOp); + _isDragging = false; + _dragStart = null; + } + + app.SetNeedsDraw (); + } + }; + + // Draw the regions + app.DrawingContent += (s, e) => + { + // Draw all regions with single line style + //_region.FillRectangles (_attribute.Value, _fillRune); + switch (_drawStyle) + { + case RegionDrawStyles.FillOnly: + _region.FillRectangles (tools.CurrentAttribute!.Value, _previewFillRune); + + break; + + case RegionDrawStyles.InnerBoundaries: + _region.DrawBoundaries (app.LineCanvas, LineStyle.Single, tools.CurrentAttribute); + _region.FillRectangles (tools.CurrentAttribute!.Value, (Rune)' '); + + break; + + case RegionDrawStyles.OuterBoundary: + _region.DrawOuterBoundary (app.LineCanvas, LineStyle.Single, tools.CurrentAttribute); + _region.FillRectangles (tools.CurrentAttribute!.Value, (Rune)' '); + + break; + } + + // If currently dragging, draw preview rectangle + if (_isDragging && _dragStart.HasValue) + { + Point currentMousePos = Application.GetLastMousePosition ()!.Value; + var previewRect = GetRectFromPoints (_dragStart.Value, currentMousePos); + var previewRegion = new Region (previewRect); + + previewRegion.FillRectangles (tools.CurrentAttribute!.Value, (Rune)' '); + + previewRegion.DrawBoundaries (app.LineCanvas, LineStyle.Dashed, new (tools.CurrentAttribute!.Value.Foreground.GetHighlightColor(), tools.CurrentAttribute!.Value.Background)); + } + }; + + Application.Run (app); + + // Clean up + app.Dispose (); + Application.Shutdown (); + } + + private void AddRectangleFromPoints (Point start, Point end, RegionOp op) + { + var rect = GetRectFromPoints (start, end); + var region = new Region (rect); + _region.Combine (region, op); // Or RegionOp.MinimalUnion if you want minimal rectangles + } + + private Rectangle GetRectFromPoints (Point start, Point end) + { + int left = Math.Min (start.X, end.X); + int top = Math.Min (start.Y, end.Y); + int right = Math.Max (start.X, end.X); + int bottom = Math.Max (start.Y, end.Y); + + // Ensure minimum width and height of 1 + int width = Math.Max (1, right - left + 1); + int height = Math.Max (1, bottom - top + 1); + + return new Rectangle (left, top, width, height); + } +} + +public enum RegionDrawStyles +{ + FillOnly = 0, + + InnerBoundaries = 1, + + OuterBoundary = 2 + +} + +public class ToolsView : Window +{ + //private Button _addLayerBtn; + private readonly AttributeView _attributeView = new (); + private RadioGroup _stylePicker; + private RegionOpSelector _regionOpSelector; + + public Attribute? CurrentAttribute + { + get => _attributeView.Value; + set => _attributeView.Value = value; + } + + public ToolsView () + { + BorderStyle = LineStyle.Dotted; + Border!.Thickness = new (1, 2, 1, 1); + Height = Dim.Auto (); + Width = Dim.Auto (); + + } + + //public event Action AddLayer; + + public override void BeginInit () + { + base.BeginInit (); + + _attributeView.ValueChanged += (s, e) => AttributeChanged?.Invoke (this, e); + + _stylePicker = new () + { + Width=Dim.Fill(), + X = 0, Y = Pos.Bottom (_attributeView) + 1, RadioLabels = Enum.GetNames ().Select (n => n = "_" + n).ToArray() + }; + _stylePicker.BorderStyle = LineStyle.Single; + _stylePicker.Border.Thickness = new (0, 1, 0, 0); + _stylePicker.Title = "Draw Style"; + + _stylePicker.SelectedItemChanged += (s, a) => { SetStyle?.Invoke ((LineStyle)a.SelectedItem); }; + _stylePicker.SelectedItem = (int)RegionDrawStyles.FillOnly; + + _regionOpSelector = new () + { + X = 0, + Y = Pos.Bottom (_stylePicker) + 1 + }; + _regionOpSelector.SelectedItemChanged += (s, a) => { RegionOpChanged?.Invoke (this, a); }; + _regionOpSelector.SelectedItem = RegionOp.MinimalUnion; + + //_addLayerBtn = new () { Text = "New Layer", X = Pos.Center (), Y = Pos.Bottom (_stylePicker) }; + + //_addLayerBtn.Accepting += (s, a) => AddLayer?.Invoke (); + Add (_attributeView, _stylePicker, _regionOpSelector);//, _addLayerBtn); + } + + public event EventHandler? AttributeChanged; + public event EventHandler? RegionOpChanged; + public event Action? SetStyle; +} + +public class RegionOpSelector : View +{ + private RadioGroup _radioGroup; + public RegionOpSelector () + { + Width = Dim.Auto (); + Height = Dim.Auto (); + + BorderStyle = LineStyle.Single; + Border.Thickness = new (0, 1, 0, 0); + Title = "RegionOp"; + + _radioGroup = new () + { + X = 0, + Y = 0, + RadioLabels = Enum.GetNames ().Select (n => n = "_" + n).ToArray () + }; + _radioGroup.SelectedItemChanged += (s, a) => { SelectedItemChanged?.Invoke (this, (RegionOp)a.SelectedItem); }; + Add (_radioGroup); + } + public event EventHandler? SelectedItemChanged; + + public RegionOp SelectedItem + { + get => (RegionOp)_radioGroup.SelectedItem; + set => _radioGroup.SelectedItem = (int) value; + } + +} + +public class AttributeView : View +{ + public event EventHandler? ValueChanged; + private Attribute? _value; + + public Attribute? Value + { + get => _value; + set + { + _value = value; + ValueChanged?.Invoke (this, value); + } + } + + private static readonly HashSet<(int, int)> _foregroundPoints = + [ + (0, 0), (1, 0), (2, 0), + (0, 1), (1, 1), (2, 1) + ]; + + private static readonly HashSet<(int, int)> _backgroundPoints = + [ + (3, 1), + (1, 2), (2, 2), (3, 2) + ]; + + public AttributeView () + { + Width = Dim.Fill(); + Height = 4; + + BorderStyle = LineStyle.Single; + Border.Thickness = new (0, 1, 0, 0); + Title = "Attribute"; + } + + /// + protected override bool OnDrawingContent () + { + Color fg = Value?.Foreground ?? Color.Black; + Color bg = Value?.Background ?? Color.Black; + + bool isTransparentFg = fg == GetNormalColor ().Background; + bool isTransparentBg = bg == GetNormalColor ().Background; + + SetAttribute (new (fg, isTransparentFg ? Color.Gray : fg)); + + // Square of foreground color + foreach ((int, int) point in _foregroundPoints) + { + // Make pattern like this when it is same color as background of control + /*▓▒ + ▒▓*/ + Rune rune; + + if (isTransparentFg) + { + rune = (Rune)(point.Item1 % 2 == point.Item2 % 2 ? '▓' : '▒'); + } + else + { + rune = (Rune)'█'; + } + + AddRune (point.Item1, point.Item2, rune); + } + + SetAttribute (new (bg, isTransparentBg ? Color.Gray : bg)); + + // Square of background color + foreach ((int, int) point in _backgroundPoints) + { + // Make pattern like this when it is same color as background of control + /*▓▒ + ▒▓*/ + Rune rune; + + if (isTransparentBg) + { + rune = (Rune)(point.Item1 % 2 == point.Item2 % 2 ? '▓' : '▒'); + } + else + { + rune = (Rune)'█'; + } + + AddRune (point.Item1, point.Item2, rune); + } + + return true; + } + + /// + protected override bool OnMouseEvent (MouseEventArgs mouseEvent) + { + if (mouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) + { + if (IsForegroundPoint (mouseEvent.Position.X, mouseEvent.Position.Y)) + { + ClickedInForeground (); + } + else if (IsBackgroundPoint (mouseEvent.Position.X, mouseEvent.Position.Y)) + { + ClickedInBackground (); + } + + } + mouseEvent.Handled = true; + + return mouseEvent.Handled; + } + + private bool IsForegroundPoint (int x, int y) { return _foregroundPoints.Contains ((x, y)); } + + private bool IsBackgroundPoint (int x, int y) { return _backgroundPoints.Contains ((x, y)); } + + private void ClickedInBackground () + { + if (LineDrawing.PromptForColor ("Background", Value!.Value.Background, out Color newColor)) + { + Value = new (Value!.Value.Foreground, newColor); + SetNeedsDraw (); + } + } + + private void ClickedInForeground () + { + if (LineDrawing.PromptForColor ("Foreground", Value!.Value.Foreground, out Color newColor)) + { + Value = new (newColor, Value!.Value.Background); + SetNeedsDraw (); + } + } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/TextAlignmentAndDirection.cs b/UICatalog/Scenarios/TextAlignmentAndDirection.cs index 7f62072685..36a1adecbe 100644 --- a/UICatalog/Scenarios/TextAlignmentAndDirection.cs +++ b/UICatalog/Scenarios/TextAlignmentAndDirection.cs @@ -10,6 +10,16 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Text and Formatting")] public class TextAlignmentAndDirection : Scenario { + + internal class AlignmentAndDirectionView : View + { + public AlignmentAndDirectionView() + { + ViewportSettings = Terminal.Gui.ViewportSettings.Transparent; + BorderStyle = LineStyle.Dotted; + } + } + public override void Main () { Application.Init (); @@ -24,8 +34,8 @@ public override void Main () var color1 = new ColorScheme { Normal = new (Color.Black, Color.Gray) }; var color2 = new ColorScheme { Normal = new (Color.Black, Color.DarkGray) }; - List