diff --git a/Terminal.Gui/Drawing/Thickness.cs b/Terminal.Gui/Drawing/Thickness.cs index b2f4b0313b..aa025dc8bb 100644 --- a/Terminal.Gui/Drawing/Thickness.cs +++ b/Terminal.Gui/Drawing/Thickness.cs @@ -22,7 +22,7 @@ namespace Terminal.Gui.Drawing; public record struct Thickness { /// Initializes a new instance of the class with all widths set to 0. - public Thickness () { _sides = Vector4.Zero; } + public Thickness () => _sides = Vector4.Zero; /// Initializes a new instance of the class with a uniform width to each side. /// @@ -52,15 +52,11 @@ public Thickness (int left, int top, int right, int bottom) /// /// /// - public readonly Thickness Add (Thickness other) { return new (Left + other.Left, Top + other.Top, Right + other.Right, Bottom + other.Bottom); } + public readonly Thickness Add (Thickness other) => new (Left + other.Left, Top + other.Top, Right + other.Right, Bottom + other.Bottom); /// Gets or sets the width of the lower side of the rectangle. [JsonInclude] - public int Bottom - { - readonly get => (int)_sides.W; - set => _sides.W = value; - } + public int Bottom { readonly get => (int)_sides.W; set => _sides.W = value; } /// /// Gets whether the specified coordinates lie within the thickness (inside the bounding rectangle but outside @@ -120,39 +116,25 @@ public Rectangle Draw (IDriver? driver, Rectangle rect, ViewDiagnosticFlags diag // Draw the Top side if (Top > 0) { - driver?.FillRect (rect with { Height = Math.Min (rect.Height, Top) }, topChar); + driver.FillRect (rect with { Height = Math.Min (rect.Height, Top) }, topChar); } // Draw the Left side if (Left > 0) { - driver?.FillRect (rect with { Width = Math.Min (rect.Width, Left) }, leftChar); + driver.FillRect (rect with { Width = Math.Min (rect.Width, Left) }, leftChar); } // Draw the Right side if (Right > 0) { - driver?.FillRect ( - rect with - { - X = Math.Max (0, rect.X + rect.Width - Right), - Width = Math.Min (rect.Width, Right) - }, - rightChar - ); + driver.FillRect (rect with { X = Math.Max (0, rect.X + rect.Width - Right), Width = Math.Min (rect.Width, Right) }, rightChar); } // Draw the Bottom side if (Bottom > 0) { - driver?.FillRect ( - rect with - { - Y = rect.Y + Math.Max (0, rect.Height - Bottom), - Height = Bottom - }, - bottomChar - ); + driver.FillRect (rect with { Y = rect.Y + Math.Max (0, rect.Height - Bottom), Height = Bottom }, bottomChar); } if (diagnosticFlags.HasFlag (ViewDiagnosticFlags.Ruler)) @@ -163,7 +145,7 @@ rect with if (Top > 0) { - hRuler.Draw (driver: driver, location: rect.Location); + hRuler.Draw (driver, rect.Location); } //Left @@ -177,36 +159,35 @@ rect with // Bottom if (Bottom > 0) { - hRuler.Draw (driver: driver, location: rect.Location with { Y = rect.Y + rect.Height - 1 }); + hRuler.Draw (driver, rect.Location with { Y = rect.Y + rect.Height - 1 }); } // Right if (Right > 0) { - vRuler.Draw (driver, new (rect.X + rect.Width - 1, rect.Y + 1), 1); + vRuler.Draw (driver, new Point (rect.X + rect.Width - 1, rect.Y + 1), 1); } } - if (diagnosticFlags.HasFlag (ViewDiagnosticFlags.Thickness)) + if (!diagnosticFlags.HasFlag (ViewDiagnosticFlags.Thickness)) { - // Draw the diagnostics label on the bottom - string text = label is null ? string.Empty : $"{label} {this}"; - - TextFormatter tf = new () - { - Text = text, - Alignment = Alignment.Center, - VerticalAlignment = Alignment.End, - ConstrainToWidth = text.GetColumns (), - ConstrainToHeight = 1 - }; - - if (driver?.CurrentAttribute is { }) - { - tf.Draw (driver, rect, driver!.CurrentAttribute, driver!.CurrentAttribute, rect); - } + return GetInside (rect); } + // Draw the diagnostics label on the bottom + string text = label is null ? string.Empty : $"{label} {this}"; + + TextFormatter tf = new () + { + Text = text, + Alignment = Alignment.Center, + VerticalAlignment = Alignment.End, + ConstrainToWidth = text.GetColumns (), + ConstrainToHeight = 1 + }; + + tf.Draw (driver, rect, driver.CurrentAttribute, driver.CurrentAttribute, rect); + return GetInside (rect); } @@ -233,7 +214,7 @@ public Rectangle GetInside (Rectangle rect) int width = Math.Max (0, rect.Size.Width - Horizontal); int height = Math.Max (0, rect.Size.Height - Vertical); - return new (x, y, width, height); + return new Rectangle (x, y, width, height); } /// @@ -253,19 +234,11 @@ public Region AsRegion (Rectangle rect) /// 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. /// - public int Horizontal - { - get => Left + Right; - set => Left = Right = value / 2; - } + public int Horizontal { get => Left + Right; set => Left = Right = value / 2; } /// Gets or sets the width of the left side of the rectangle. [JsonInclude] - public int Left - { - readonly get => (int)_sides.X; - set => _sides.X = value; - } + public int Left { readonly get => (int)_sides.X; set => _sides.X = value; } /// /// Adds the thickness widths of another to another . @@ -273,35 +246,23 @@ public int Left /// /// /// - public static Thickness operator + (Thickness a, Thickness b) { return a.Add (b); } + public static Thickness operator + (Thickness a, Thickness b) => a.Add (b); /// Gets or sets the width of the right side of the rectangle. [JsonInclude] - public int Right - { - readonly get => (int)_sides.Z; - set => _sides.Z = value; - } + public int Right { readonly get => (int)_sides.Z; set => _sides.Z = value; } /// Gets or sets the width of the upper side of the rectangle. [JsonInclude] - public int Top - { - readonly get => (int)_sides.Y; - set => _sides.Y = value; - } + public int Top { readonly get => (int)_sides.Y; set => _sides.Y = value; } /// Returns the thickness widths of the Thickness formatted as a string. /// The thickness widths as a string. - public override string ToString () { return $"(Left={Left},Top={Top},Right={Right},Bottom={Bottom})"; } + public override string ToString () => $"(Left={Left},Top={Top},Right={Right},Bottom={Bottom})"; /// /// Gets the total height of the top and bottom sides of the rectangle. Sets the height of the top and bottom /// sides of the rectangle to half the specified value. /// - public int Vertical - { - get => Top + Bottom; - set => Top = Bottom = value / 2; - } + public int Vertical { get => Top + Bottom; set => Top = Bottom = value / 2; } } diff --git a/Terminal.Gui/ViewBase/View.Layout.cs b/Terminal.Gui/ViewBase/View.Layout.cs index fcabb72e03..5f9d53cf69 100644 --- a/Terminal.Gui/ViewBase/View.Layout.cs +++ b/Terminal.Gui/ViewBase/View.Layout.cs @@ -721,6 +721,10 @@ internal void LayoutSubViews () Size contentSize = GetContentSize (); OnSubViewLayout (new LayoutEventArgs (contentSize)); + + // Re-read content size — OnSubViewLayout handlers (e.g. Dialog.UpdateSizes) may have called SetContentSize. + contentSize = GetContentSize (); + SubViewLayout?.Invoke (this, new LayoutEventArgs (contentSize)); // The Adornments already have their Frame's set by SetRelativeLayout so we call LayoutSubViews vs. Layout here. diff --git a/Terminal.Gui/Views/DialogTResult.cs b/Terminal.Gui/Views/DialogTResult.cs index a8f3184618..2a984dac32 100644 --- a/Terminal.Gui/Views/DialogTResult.cs +++ b/Terminal.Gui/Views/DialogTResult.cs @@ -197,19 +197,12 @@ private void UpdateSizes () return; } - int subViewsWidth = _minimumSubViewsSize.Width; - - if (!Width.Has (out _)) - { - subViewsWidth = Math.Max (subViewsWidth, Viewport.Width); - } - - int subViewsHeight = _minimumSubViewsSize.Height; - - if (!Height.Has (out _)) - { - subViewsHeight = Math.Max (subViewsHeight, Viewport.Height); - } + // Always floor at Viewport size — the content area should never be smaller + // than what's visible. For DimAuto dialogs, the Frame may be larger than + // _minimumSubViewsSize (e.g. due to title width), so the content area + // should reflect the actual available space. + int subViewsWidth = Math.Max (_minimumSubViewsSize.Width, Viewport.Width); + int subViewsHeight = Math.Max (_minimumSubViewsSize.Height, Viewport.Height); SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, subViewsWidth), Math.Max (_minimumButtonsSize.Height, subViewsHeight))); } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Layout/StaleContentSizeCaptureTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/StaleContentSizeCaptureTests.cs new file mode 100644 index 0000000000..69a0c92169 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/StaleContentSizeCaptureTests.cs @@ -0,0 +1,407 @@ +// Copilot + +namespace ViewBaseTests.Layout; + +/// +/// Tests for the stale content size capture bug in . +/// See: https://github.com/gui-cs/Terminal.Gui/issues/4522 +/// +/// The core bug: LayoutSubViews captures contentSize once at the top, then fires +/// OnSubViewLayout. If a subclass (e.g. Dialog) calls SetContentSize from that callback, +/// the captured value diverges from the actual content size. +/// +/// Additionally, line 771 sets NeedsLayout directly instead of calling SetNeedsLayout(), +/// bypassing upward propagation. +/// +/// Some tests demonstrate bugs by failing; others serve as regression tests ensuring correct +/// behavior is preserved. +/// +public class StaleContentSizeCaptureTests +{ + #region Helper SubView classes + + /// + /// A View subclass that calls from . + /// + private class ContentSizeChangingOnSubViewLayoutView : View + { + public Size NewContentSize { get; set; } + + /// Gets the content size that was passed to OnSubViewLayout. + public Size? CapturedLayoutContentSize { get; private set; } + + protected override void OnSubViewLayout (LayoutEventArgs args) + { + CapturedLayoutContentSize = args.OldContentSize; + SetContentSize (NewContentSize); + base.OnSubViewLayout (args); + } + } + + /// + /// A View subclass that calls from the SubViewsLaidOut + /// event (simulating HexView behavior). + /// + private class ContentSizeChangingOnSubViewsLaidOutView : View + { + public Size NewContentSize { get; set; } + + public ContentSizeChangingOnSubViewsLaidOutView () + { + SubViewsLaidOut += (_, _) => { SetContentSize (NewContentSize); }; + } + } + + #endregion + + #region Test 1: Core stale capture + + /// + /// Proves the core bug: LayoutSubViews captures contentSize before + /// OnSubViewLayout fires, so SubViews are laid out with the wrong size. + /// + [Fact] + public void LayoutSubViews_Uses_Stale_ContentSize_When_OnSubViewLayout_Changes_It () + { + // Arrange: SuperView that changes content size in OnSubViewLayout + ContentSizeChangingOnSubViewLayoutView superView = new () + { + Width = 20, + Height = 10, + NewContentSize = new Size (50, 20) + }; + + View child = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + superView.Add (child); + superView.BeginInit (); + superView.EndInit (); + superView.Layout (); + + // Assert: The child should fill the NEW content size (50, 20), + // not the stale pre-callback value. + Assert.Equal (50, child.Frame.Width); + Assert.Equal (20, child.Frame.Height); + } + + #endregion + + #region Test 2: Dialog resize scenario + + /// + /// Proves the reported real-world scenario: a Dialog with Dim.Fill SubViews + /// gets the wrong layout after a screen size change (simulating maximize/restore). + /// + [Fact] + public void Dialog_Children_Use_Stale_ContentSize_After_Screen_Resize () + { + // Arrange: Dialog with explicit dimensions and a Dim.Fill child + Dialog dialog = new () + { + Width = 40, + Height = 15 + }; + + View child = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + dialog.Add (child); + dialog.BeginInit (); + dialog.EndInit (); + + // First layout at initial size + dialog.Layout (); + Size firstContentSize = dialog.GetContentSize (); + Rectangle firstChildFrame = child.Frame; + + // Resize the dialog (simulating maximize) + dialog.Width = 120; + dialog.Height = 40; + dialog.SetNeedsLayout (); + dialog.Layout (); + + Size secondContentSize = dialog.GetContentSize (); + + // Assert: content size should have grown + Assert.True (secondContentSize.Width > firstContentSize.Width, + $"Content width should have grown: was {firstContentSize.Width}, now {secondContentSize.Width}"); + + Assert.True (secondContentSize.Height > firstContentSize.Height, + $"Content height should have grown: was {firstContentSize.Height}, now {secondContentSize.Height}"); + + // Assert: child frame should match the new content size, not the old one + Assert.Equal (secondContentSize.Width, child.Frame.Width); + Assert.Equal (secondContentSize.Height, child.Frame.Height); + } + + #endregion + + #region Test 3: Dialog OnSubViewLayout divergence + + /// + /// Proves that Dialog.OnSubViewLayoutUpdateSizesSetContentSize + /// produces a content size that differs from what LayoutSubViews used to lay out SubViews. + /// + [Fact] + public void Dialog_OnSubViewLayout_SetContentSize_Diverges_From_Captured_Value () + { + // Arrange: Dialog with explicit (non-Auto) Width/Height + Dialog dialog = new () + { + Width = 60, + Height = 20 + }; + + // Add several subviews to create a _minimumSubViewsSize that differs from Viewport + View tall = new () + { + Width = 10, + Height = 30 // Taller than dialog viewport + }; + + View wide = new () + { + X = 0, + Y = Pos.Bottom (tall), + Width = 80, // Wider than dialog viewport + Height = 5 + }; + + dialog.Add (tall, wide); + dialog.BeginInit (); + dialog.EndInit (); + + // Capture the content size that exists before layout + Size? preLayoutContentSize = null; + Size? postLayoutContentSize = null; + + dialog.SubViewLayout += (_, args) => { preLayoutContentSize = args.OldContentSize; }; + dialog.SubViewsLaidOut += (_, _) => { postLayoutContentSize = dialog.GetContentSize (); }; + + dialog.Layout (); + + // Assert: If the bug exists, preLayoutContentSize (what children were laid out with) + // differs from postLayoutContentSize (the final content size after SetContentSize calls) + Assert.NotNull (preLayoutContentSize); + Assert.NotNull (postLayoutContentSize); + + // After layout, the child with Dim.Fill should be sized to the FINAL content size. + // This tests that the content size used for layout is consistent with the final value. + Assert.Equal (postLayoutContentSize, dialog.GetContentSize ()); + + // The key assertion: the content size should NOT have diverged. + // If it diverged, children were laid out with the wrong (stale) value. + Assert.Equal (postLayoutContentSize, preLayoutContentSize); + } + + #endregion + + #region Test 4: NeedsLayout propagation bypass + + /// + /// Proves that line 771 sets NeedsLayout = false directly, bypassing + /// SetNeedsLayout propagation. If content size changed during layout, + /// neither the view nor its SuperView know they need another pass. + /// + [Fact] + public void NeedsLayout_Direct_Set_Does_Not_Propagate_To_SuperView () + { + // Arrange: grandSuperView → customView → child + View grandSuperView = new () + { + Width = 80, + Height = 25 + }; + + ContentSizeChangingOnSubViewLayoutView customView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + NewContentSize = new Size (100, 50) // Will change content size during OnSubViewLayout + }; + + View child = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + customView.Add (child); + grandSuperView.Add (customView); + grandSuperView.BeginInit (); + grandSuperView.EndInit (); + + grandSuperView.Layout (); + + // After layout, SetContentSize was called from OnSubViewLayout which calls SetNeedsLayout(). + // But LayoutSubViews line 771 then sets NeedsLayout = false directly. + // The content size changed, so ideally NeedsLayout should still be true + // (or should have triggered a corrective layout pass). + + // The content size was changed to (100, 50). If a corrective pass happened, + // the child would be sized to (100, 50). If not, the child has the stale size. + Assert.Equal (100, child.Frame.Width); + Assert.Equal (50, child.Frame.Height); + } + + #endregion + + #region Test 5: SubViewsLaidOut SetContentSize is too late + + /// + /// Proves that views calling SetContentSize from SubViewsLaidOut + /// (like HexView) change content size after SubViews have already been laid out. + /// + [Fact] + public void LayoutSubViews_OnSubViewsLaidOut_SetContentSize_Is_Too_Late () + { + ContentSizeChangingOnSubViewsLaidOutView superView = new () + { + Width = 20, + Height = 10, + NewContentSize = new Size (80, 30) + }; + + View child = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + superView.Add (child); + superView.BeginInit (); + superView.EndInit (); + superView.Layout (); + + // Assert: The child should reflect the content size (80, 30) set in SubViewsLaidOut. + // On current code, the child was already laid out before SubViewsLaidOut fired. + Assert.Equal (80, child.Frame.Width); + Assert.Equal (30, child.Frame.Height); + } + + #endregion + + #region Test 6: ListView stale capture + + /// + /// Proves that ListView.OnViewportChanged calls SetContentSize during layout. + /// The parent's LayoutSubViews captures contentSize before the ListView's viewport + /// change triggers SetContentSize. A sibling using Dim.Fill should reflect the + /// updated content size, not the stale pre-callback value. + /// + [Fact] + public void ListView_OnViewportChanged_SetContentSize_Creates_Stale_Capture () + { + // Arrange: ListView with items, plus a sibling, in a container + ListView listView = new () + { + Width = Dim.Fill (), + Height = 5 + }; + + listView.Source = new ListWrapper (["Item 1", "Item 2", "Item 3", "Item 4", "Item 5", + "Item 6", "Item 7", "Item 8", "Item 9", "Item 10"]); + + View container = new () + { + Width = 30, + Height = 10 + }; + + container.Add (listView); + container.BeginInit (); + container.EndInit (); + + // Initial layout + container.Layout (); + Size firstContentSize = listView.GetContentSize (); + + // Resize container (simulating terminal resize) + container.Width = 60; + container.Height = 20; + container.SetNeedsLayout (); + container.Layout (); + + Size secondContentSize = listView.GetContentSize (); + + // Assert: ListView frame should match the new container + Assert.Equal (60, listView.Frame.Width); + Assert.Equal (5, listView.Frame.Height); + + // ListView content height = Source.Count (10 items), width = EffectiveMaxItemLength + Assert.Equal (10, secondContentSize.Height); + + // After resize, content size should have been recalculated + // (EffectiveMaxItemLength doesn't change, but height = Source.Count stays consistent) + Assert.Equal (firstContentSize.Height, secondContentSize.Height); + } + + #endregion + + #region Test 7: TableView stale capture + + /// + /// Proves that TableView.OnViewportChangedRefreshContentSize calls + /// SetContentSize during layout. + /// + [Fact] + public void TableView_RefreshContentSize_During_Layout_Creates_Stale_Capture () + { + // Arrange: TableView with data in a container + TableView tableView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + System.Data.DataTable dt = new (); + dt.Columns.Add ("Name"); + dt.Columns.Add ("Value"); + + for (var i = 0; i < 20; i++) + { + dt.Rows.Add ($"Row {i}", $"Val {i}"); + } + + tableView.Table = new DataTableSource (dt); + + View container = new () + { + Width = 30, + Height = 10 + }; + + container.Add (tableView); + container.BeginInit (); + container.EndInit (); + + // Initial layout + container.Layout (); + Size firstContentSize = tableView.GetContentSize (); + + // Resize container + container.Width = 80; + container.Height = 25; + container.SetNeedsLayout (); + container.Layout (); + + Size secondContentSize = tableView.GetContentSize (); + + // Assert: TableView frame should match the new container size + Assert.Equal (80, tableView.Frame.Width); + Assert.Equal (25, tableView.Frame.Height); + + // The content size should have been recalculated for the new viewport + Assert.True (secondContentSize.Width >= tableView.Viewport.Width, + $"Content width {secondContentSize.Width} should be >= viewport width {tableView.Viewport.Width}"); + } + + #endregion +} diff --git a/plans/border-padding-transparency-spec.md b/plans/border-padding-transparency-spec.md deleted file mode 100644 index 99bf9fc972..0000000000 --- a/plans/border-padding-transparency-spec.md +++ /dev/null @@ -1,475 +0,0 @@ -# Specification: Adornment Transparency Support (Border & Padding) - -## Issue - -**#4834**: `Border` (and `Padding`) should support `ViewportSettingsFlags.Transparent` and `ViewportSettingsFlags.TransparentMouse`. - -**Prerequisite for**: TabView redesign (#4183), where each Tab's Border renders a small header rectangle and the rest must be transparent. - -## Problem Statement - -Border draws **non-rectangular** content — an outline of line segments plus optional title text. The interior is empty. When transparency is enabled: - -- **Transparent (visual)**: Only border lines and title text should be drawn; the empty interior should show underlying views. -- **TransparentMouse**: Clicks on border lines/title go to the Border; clicks on the empty interior pass through to views beneath. - -Neither works on `v2_develop` today. - ---- - -## Root Causes (on v2_develop) - -### 1. Visual transparency fails for Border - -`DoDrawComplete` (in `View.Drawing.cs`) has an `if (this is not Adornment)` guard that skips **all** clip exclusion logic for adornments. Setting `Transparent` on a Border has no effect — the parent view's opaque path excludes the entire border frame from the clip regardless. - -### 2. Border writes to Parent.LineCanvas, not its own - -`Border.OnDrawingContent` writes all border lines to `Parent.LineCanvas`, not to Border's own LineCanvas. The parent renders these later in its own `DoRenderLineCanvas`, and the drawn region gets reported to the **parent's** DrawContext — not the Border's. So the Border's own DrawContext never knows what cells the border lines actually occupy. - -Additionally, `Adornment.SuperViewRendersLineCanvas` throws `InvalidOperationException` — adornments are blocked from using their own LineCanvas. - -### 3. TransparentMouse is blanket per-view - -`GetViewsUnderLocation` (in `View.Layout.cs`) does `RemoveAll(v => v!.ViewportSettings.HasFlag(excludeViewportSettingsFlags))` — blanket removal. There is no per-cell hit-testing. - -### 4. Parent clears viewport even when Border is transparent - -`DoClearViewport` clears the viewport unconditionally for opaque views. When the Border is transparent, the parent must NOT clear the border's interior area, otherwise the underlying content gets overwritten with blanks. - ---- - -## Architecture of the Fix - -The fix has two phases: - -1. **Phase 1 — Visual Transparency**: Make `Border.Transparent` actually work (show through to underlying content). -2. **Phase 2 — Per-Cell TransparentMouse**: Make `TransparentMouse` respect which cells were actually drawn vs. empty. - -Both phases apply to Padding as well, though Border is the primary use case. - ---- - -## Phase 1: Visual Transparency for Border - -### 1a. Fix Border LineCanvas Ownership - -**Files**: `Adornment.cs`, `Border.cs` - -**Problem**: Border writes to `Parent.LineCanvas`. The drawn region is tracked on the parent, not the Border. - -**Changes**: - -1. **`Adornment.cs`** — Change the `SuperViewRendersLineCanvas` property override. Remove the `InvalidOperationException` throw. Replace with a standard auto-property: - - ```csharp - /// Gets or sets whether this Adornment's LineCanvas will be merged into - /// its Parent view's LineCanvas for rendering. - public override bool SuperViewRendersLineCanvas { get; set; } - ``` - - Default is `false` (backward-compatible). When `true`, the adornment adds lines to its own LineCanvas and the parent merges and renders them. - -2. **`Border.cs` (`OnDrawingContent`)** — At the top of the method, set: - ```csharp - SuperViewRendersLineCanvas = true; - ``` - Then change all references from `Parent?.LineCanvas` / `Parent.LineCanvas` to `this.LineCanvas` / `LineCanvas`. Border now writes to its own LineCanvas. - -3. **`Border.cs` (`OnDrawingContent`)** — For the title text exclusion, change from `Parent?.LineCanvas.Exclude(...)` to `LineCanvas.Exclude(...)`. Also add `context?.AddDrawnRectangle(titleRect)` to report the title rectangle to the DrawContext for clip exclusion. - -**Effect**: Border adds lines to its own LineCanvas → parent's draw loop merges them (via existing `SuperViewRendersLineCanvas` merge in `DoDrawSubViews`) → parent's `DoRenderLineCanvas` renders all lines. The Border's own DrawContext tracks what IT drew (title text). - -### 1b. Merge Border LineCanvas in Parent Draw Cycle - -**File**: `View.Drawing.cs` (in `Draw` method, after `DoClearViewport`/`DoDrawSubViews` but before `DoRenderLineCanvas`) - -**Change**: Add a merge step for Border's LineCanvas: - -```csharp -if (Border is { SuperViewRendersLineCanvas: true }) -{ - LineCanvas.Merge (Border.LineCanvas); - Border.LineCanvas.Clear (); -} -``` - -This must happen **after** `ClearNeedsDraw` (which clears our LineCanvas) but **before** `DoRenderLineCanvas` — so border lines participate in line-join resolution with any lines SubViews added. - -**Note**: The existing merge in `DoDrawSubViews` handles SubViews with `SuperViewRendersLineCanvas`, but Border is an adornment (drawn via `DrawAdornments`), not a SubView. The merge for Border needs its own explicit step. - -### 1c. Make Border Participate in Clip Exclusion When Transparent - -**File**: `View.Drawing.cs` (`DoDrawComplete` method) - -**Change**: Relax the adornment guard. The current code: -```csharp -if (this is not Adornment) { /* clip exclusion */ } -``` - -Must become: -```csharp -if (this is Adornment && !ViewportSettings.HasFlag(ViewportSettingsFlags.Transparent)) -{ - return; // Opaque adornments skip — their parent handles them. -} -``` - -This lets transparent adornments participate in the drawn-region clip exclusion path. - -### 1d. Prevent Viewport Clearing for Views with Transparent Border - -**File**: `View.Drawing.cs` (`DoClearViewport` method) - -**Change**: Skip clearing if the view's Border has `Transparent` set: - -```csharp -if (ViewportSettings.HasFlag(ViewportSettingsFlags.Transparent) - || (Border is { } && Border.ViewportSettings.HasFlag(ViewportSettingsFlags.Transparent))) -{ - return; // Don't clear — transparent content should show through. -} - -ClearViewport(context); -OnClearedViewport(); -ClearedViewport?.Invoke(this, new DrawEventArgs(Viewport, Viewport, null)); -``` - -### 1e. "Effectively Transparent" Concept in Clip Exclusion - -**File**: `View.Drawing.cs` (`DoDrawComplete`) - -A view is treated as "effectively transparent" if: -- It has `ViewportSettingsFlags.Transparent` set directly, **OR** -- Its `Border` has `Transparent` set (the border interior should show through). - -In both cases, the **transparent path** is used for clip exclusion: -- Only actually-drawn cells are excluded from `Driver.Clip`. -- Padding is always excluded (it draws fills that fully occupy its thickness area). -- Border is NOT excluded from clip if it's transparent — its drawn cells are already in the context's drawn region via LineCanvas rendering. -- The drawn region is clamped to the Border's frame (for transparent Border) or the Viewport (for directly-transparent views). - -**Opaque path** (default, unchanged): -- Exclude the entire view frame (Border frame inward). Margin is never included here. -- Record the rectangle in DrawContext so the SuperView knows what area this subview occupied. - -### 1f. Pass DrawContext Through to Adornments - -**File**: `View.Drawing.cs` - -**Changes**: -1. `DoDrawAdornments` signature: add `DrawContext? drawContext` parameter. -2. `DrawAdornments` signature: add `DrawContext? drawContext` parameter. -3. Pass `drawContext` through to `Margin?.Draw(drawContext)`, `Border?.Draw(drawContext)`, `Padding?.Draw(drawContext)`. - -This enables adornments to receive and use the DrawContext for region tracking. - ---- - -## Phase 2: Per-Cell TransparentMouse Hit-Testing - -### 2a. Add `CachedDrawnRegion` Property to View - -**File**: `View.Drawing.cs` - -```csharp -/// Gets the cached drawn region from the last draw pass. Populated during -/// DoDrawComplete for views with TransparentMouse set. Used by mouse -/// hit-testing to determine which cells should receive mouse events. -/// Returns null if not drawn yet or TransparentMouse not set. -/// Invalidated by SetNeedsDraw(). -internal Region? CachedDrawnRegion { get; set; } -``` - -### 2b. Add `_lastLineCanvasRegion` to Track LineCanvas Cells - -**File**: `View.Drawing.cs` (in `DoRenderLineCanvas`) - -```csharp -private Region? _lastLineCanvasRegion; -``` - -After rendering the line canvas and reporting to context, cache the region: -```csharp -_lastLineCanvasRegion = cellMap.Count > 0 ? lineRegion : null; -``` - -This captures the exact cells where lines were rendered, including merged Border lines. - -### 2c. Cache Drawn Region in `DoDrawComplete` - -**File**: `View.Drawing.cs` (`DoDrawComplete`) - -**Before** clip exclusion (so we get fine-grained drawn regions, not the full frame rectangle), add caching logic for adornments and the view itself: - -**For adornments** (only when `this is not Adornment` — i.e., from the parent view's perspective): - -```csharp -// Border: Build from LineCanvas region intersected with Border frame + title rect -if (Border is { } && Border.ViewportSettings.HasFlag(TransparentMouse)) -{ - Region borderDrawnRegion = new(); - - if (_lastLineCanvasRegion is { }) - { - Region lineRegion = _lastLineCanvasRegion.Clone(); - lineRegion.Intersect(Border.FrameToScreen()); - borderDrawnRegion.Union(lineRegion); - } - - if (Border.LastTitleRect is { } titleRect) - { - borderDrawnRegion.Union(titleRect); - } - - Border.CachedDrawnRegion = borderDrawnRegion; -} - -// Margin: Build from context drawn region intersected with Margin frame -if (Margin is { } && Margin.ViewportSettings.HasFlag(TransparentMouse)) -{ - Region marginDrawnRegion = context.GetDrawnRegion().Clone(); - marginDrawnRegion.Intersect(Margin.FrameToScreen()); - Margin.CachedDrawnRegion = marginDrawnRegion; -} - -// Padding: Same approach as Margin -if (Padding is { } && Padding.ViewportSettings.HasFlag(TransparentMouse)) -{ - Region paddingDrawnRegion = context.GetDrawnRegion().Clone(); - paddingDrawnRegion.Intersect(Padding.FrameToScreen()); - Padding.CachedDrawnRegion = paddingDrawnRegion; -} -``` - -**For the view itself** (after clip exclusion): -```csharp -if (ViewportSettings.HasFlag(TransparentMouse)) -{ - if (isEffectivelyTransparent) - { - CachedDrawnRegion = context!.GetDrawnRegion().Clone(); - } - else - { - // Opaque view with TransparentMouse — cache the entire border frame. - Rectangle frame = Border is { } ? Border.FrameToScreen() : FrameToScreen(); - CachedDrawnRegion = new Region(frame); - } -} -``` - -### 2d. Add `LastTitleRect` to Border - -**File**: `Border.cs` - -```csharp -/// Gets the screen-coordinate rectangle of the title text from the last draw pass. -/// Used by the parent view to build CachedDrawnRegion for TransparentMouse hit-testing. -internal Rectangle? LastTitleRect { get; set; } -``` - -Set it in `OnDrawingContent` after drawing the title: -```csharp -LastTitleRect = titleRect; -``` - -This is needed because the title is drawn directly (not via LineCanvas), so it won't appear in `_lastLineCanvasRegion`. - -### 2e. Invalidate Cache in `SetNeedsDraw` - -**File**: `View.NeedsDraw.cs` (at the start of `SetNeedsDraw(Rectangle)`) - -```csharp -// Invalidate the cached drawn region for TransparentMouse hit-testing. -CachedDrawnRegion = null; -``` - -### 2f. Report Drawn Region in ShadowView - -**File**: `ShadowView.cs` (at the end of `OnDrawingContent`) - -```csharp -context?.AddDrawnRectangle(ViewportToScreen(Viewport)); -``` - -This ensures shadow cells are included in the Margin's CachedDrawnRegion. - -### 2g. Build Margin CachedDrawnRegion in `DrawShadows` - -**File**: `Margin.cs` (in `DrawShadows`, after drawing and clearing clip) - -Since Margin with shadows is drawn in a separate pass (not through the normal `Draw` flow), we build its `CachedDrawnRegion` here by iterating visible SubViews (the ShadowViews): - -```csharp -if (margin.ViewportSettings.HasFlag(TransparentMouse)) -{ - Region marginDrawnRegion = new(); - foreach (View subView in margin.InternalSubViews) - { - if (subView.Visible) - { - marginDrawnRegion.Union(subView.FrameToScreen()); - } - } - margin.CachedDrawnRegion = marginDrawnRegion.IsEmpty() ? null : marginDrawnRegion; -} -``` - -### 2h. Modify Hit-Testing in `GetViewsUnderLocation` - -**File**: `View.Layout.cs` (in `GetViewsUnderLocation`) - -**Stage 1 — Adornment filtering**: For each adornment (Margin, Border, Padding), change from blanket removal to per-cell check: - -```csharp -if (viewsUnderLocation.Contains(v.Margin) && v.Margin!.ViewportSettings.HasFlag(excludeFlags)) -{ - if (v.Margin.CachedDrawnRegion is null - || !v.Margin.CachedDrawnRegion.Contains(location.X, location.Y)) - { - ret = true; - } -} -// Same pattern for Border and Padding -``` - -**Stage 2 — Direct view filtering**: Change from `RemoveAll(v => v!.ViewportSettings.HasFlag(...))` to: - -```csharp -viewsUnderLocation.RemoveAll(v => -{ - if (!v!.ViewportSettings.HasFlag(excludeFlags)) - return false; - - if (v.CachedDrawnRegion is { } drawnRegion) - return !drawnRegion.Contains(location.X, location.Y); - - return true; // null cache = blanket removal (fallback) -}); -``` - -**Note**: `screenLocation` is an `in` parameter, so it must be captured into a local variable for use inside the lambda. - ---- - -## Rename: `DrawMargins` → `DrawShadows` - -**File**: `Margin.cs`, `View.Drawing.cs` - -The method `DrawMargins` is renamed to `DrawShadows` because it only draws Margins that have shadows. All call sites are updated. Comments referencing "Transparent margins" are updated to reference "shadows". - ---- - -## Files Changed Summary - -| File | Changes | -|------|---------| -| `Adornment.cs` | Remove `SuperViewRendersLineCanvas` throw; make it a standard auto-property | -| `Border.cs` | Write to own `LineCanvas`; set `SuperViewRendersLineCanvas = true`; add `LastTitleRect`; report title to DrawContext | -| `View.Drawing.cs` | Add `CachedDrawnRegion`; add `_lastLineCanvasRegion`; merge Border LineCanvas; relax adornment guard in `DoDrawComplete`; cache drawn regions; skip viewport clear for transparent Border; pass `DrawContext` to adornments | -| `View.NeedsDraw.cs` | Clear `CachedDrawnRegion` in `SetNeedsDraw` | -| `View.Layout.cs` | Per-cell hit-testing in `GetViewsUnderLocation` | -| `ShadowView.cs` | Report drawn region to DrawContext | -| `Margin.cs` | Rename `DrawMargins`→`DrawShadows`; build `CachedDrawnRegion` for shadow Margins | -| `Padding.cs` | *(Minor early-return refactoring only — no transparency-specific changes)* | -| `View.Adornments.cs` | *(Pass-through only — `DrawAdornments` signature gains `DrawContext?` parameter)* | - ---- - -## Edge Cases - -| Case | Expected Behavior | -|------|-------------------| -| Before first draw | `CachedDrawnRegion` is `null`; TransparentMouse falls back to blanket removal (no regression) | -| View never reports drawn regions | Same — null cache, blanket behavior | -| Stale cache between draws | Invalidated by `SetNeedsDraw`; between draws, cache reflects last visual state | -| Layout changes | Trigger `SetNeedsDraw`, which clears cache | -| Border with `Thickness = 0` | No drawn region, fully transparent to mouse | -| Border with no title and no lines | Empty drawn region — all mouse events pass through | -| Thick border (e.g., `Thickness(2)`) | Only the actual line cells are drawn; gap cells between lines pass through | -| Opaque view with `TransparentMouse` | Cache is the entire border frame (all cells receive clicks) | -| Transparent adornment drawn without context (Margin via `DrawShadows`) | Cannot participate in clip exclusion — early return | - ---- - -## Tests - -Two new test files were created (can be copied as-is from this branch): - -### `Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTransparentTests.cs` - -Tests for Border visual transparency and TransparentMouse: - -| Test | What It Proves | -|------|----------------| -| `Border_Transparent_Shows_Underlying_Content_In_Interior` | Interior of transparent border shows underlying view content | -| `Border_TransparentMouse_Interior_Clicks_Pass_Through` | Mouse clicks on empty interior pass through | -| `Border_TransparentMouse_BorderLine_Clicks_Are_Captured` | Mouse clicks on border lines are received by the border | -| `Border_TransparentMouse_Title_Clicks_Are_Captured` | Mouse clicks on title text are received by the border | -| `View_TransparentMouse_DrawnCells_Captured_UndrawnCells_PassThrough` | Per-cell hit-testing works for regular views | -| `View_TransparentMouse_NullCache_FallsBackToBlanketRemoval` | Null cache = blanket fallback (backward compat) | -| `Border_TransparentMouse_ThickBorder_EmptyCells_PassThrough` | Thick border gap cells pass through | -| `Margin_TransparentMouse_Shadow_Clicks_Are_Captured` | Shadow cells in Margin receive clicks | - -### `Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs` - -Baseline tests for the clip exclusion logic in `DoDrawComplete`: - -| Test | What It Proves | -|------|----------------| -| `OpaqueView_ExcludesEntireFrameFromClip` | Opaque view punches out its entire borderFrame | -| `OpaqueView_UsesBorderFrameNotViewFrame` | Uses `Border.FrameToScreen()` when Border exists | -| `OpaqueView_UpdatesDrawContext` | Opaque view calls `context.AddDrawnRectangle(borderFrame)` | -| `TransparentView_ExcludesOnlyDrawnRegion` | Transparent view excludes only drawn cells | -| `TransparentView_ClampsDrawnRegionToViewport` | Drawn region is clipped to Viewport bounds | -| `TransparentView_ExcludesBorderAndPadding` | Border/Padding thickness areas always excluded for transparent views | -| `Adornment_SkipsClipExclusion` | Opaque adornments skip clip exclusion | -| `TransparentParent_OpaqueChild_ContextFlows` | Opaque subview's frame added to parent's DrawContext | -| `CachedDrawnRegion_PopulatedAfterDraw_WhenTransparentMouse` | Cache populated after Draw() | -| `CachedDrawnRegion_Null_WhenNotTransparentMouse` | No caching without flag | -| `CachedDrawnRegion_TransparentView_ContainsOnlyDrawnCells` | Only drawn cells cached | -| `CachedDrawnRegion_BorderAdornment_PopulatedAfterDraw` | Border adornment gets cache | -| `CachedDrawnRegion_Border_IncludesTitleAndLines` | Cache has lines + title | -| `CachedDrawnRegion_ClearedBySetNeedsDraw` | SetNeedsDraw invalidates cache | -| `CachedDrawnRegion_RepopulatedAfterRedraw` | Full invalidate-redraw cycle | -| `ShadowView_ReportsDrawnRegionToContext` | Shadow cells in DrawContext | - -### Other modified test files (incidental changes) - -These files had changes in the branch but primarily for adapting to signature changes or refactoring. Review before copying: - -- `Tests/UnitTestsParallelizable/ViewBase/Adornment/AdornmentTests.cs` -- `Tests/UnitTestsParallelizable/ViewBase/Adornment/MarginTests.cs` -- `Tests/UnitTestsParallelizable/ViewBase/Draw/StaticDrawTests.cs` -- `Tests/UnitTestsParallelizable/ViewBase/Draw/ViewDrawingFlowTests.cs` -- `Tests/UnitTests/View/Adornment/BorderTests.cs` -- `Tests/UnitTests/View/Adornment/AdornmentTests.cs` -- `Tests/UnitTests/View/Draw/TransparentTests.cs` - ---- - -## Code Style Changes (Non-Functional) - -The branch also made several code style fixes that are **not related to transparency** but will appear in the diff. These are independent refactorings applied to files being modified: - -1. **Early-return refactoring** — Nested `if/else` blocks converted to guard clauses (in `Margin.cs`, `Padding.cs`, `Adornment.cs`, `View.Drawing.cs`) -2. **Target-typed `new`** — `new()` replaced with `new TypeName()` per project conventions (throughout `Border.cs`, `Margin.cs`, `Adornment.cs`) -3. **`field` keyword** — Backing field `_shadowSize` replaced with C# 12 `field` keyword (in `Margin.cs`, `View.Adornments.cs`) -4. **Null-conditional simplification** — `if (x is { }) { x.Visible = true; }` → `x?.Visible = true` (in `Margin.cs`) -5. **Removed unused `ClearFrame` method** (in `View.Drawing.cs`) - -These should be applied in a separate commit or alongside the transparency work. - ---- - -## Verification Checklist - -1. `dotnet build --no-restore` — zero errors, zero new warnings -2. `dotnet test --project Tests/UnitTestsParallelizable --no-build` — all pass -3. `dotnet test --project Tests/UnitTests --no-build` — all pass -4. `Border_Transparent_Shows_Underlying_Content_In_Interior` — passes (interior shows underlying content) -5. `Border_TransparentMouse_Interior_Clicks_Pass_Through` — passes (correct reason: per-cell, not blanket) -6. `Border_TransparentMouse_BorderLine_Clicks_Are_Captured` — passes -7. UICatalog `Transparent` scenario — transparent views still work -8. UICatalog scenarios with borders/windows — no visual regressions diff --git a/plans/per-cell-transparent-mouse.md b/plans/per-cell-transparent-mouse.md deleted file mode 100644 index 410360f9fd..0000000000 --- a/plans/per-cell-transparent-mouse.md +++ /dev/null @@ -1,232 +0,0 @@ -# Plan: Border Transparent & TransparentMouse Support (Issue #4834) - -## Status - -| Item | Status | -|------|--------| -| Border transparency failing tests | Done | -| DoDrawComplete comment rewrite | Done | -| DoDrawComplete baseline tests | Done — 8 tests, all passing | -| Phase 1: Visual transparency for Border | **DONE** | -| Phase 1a: Fix Border LineCanvas ownership | Done | -| Phase 1b: Make Border participate in clip exclusion when Transparent | Done | -| Phase 1 extras: title occlusion, clip clamping, AnchorEnd tests | Done | -| Phase 2: Drawn-region-aware TransparentMouse | **DONE** — all 28 tests passing | - -## Context - -Issue #4834: `Border` should support `ViewportSettingsFlags.Transparent` and `ViewportSettingsFlags.TransparentMouse`. Prerequisite for TabView redesign (#4183), where each Tab's Border renders a small header rectangle and the rest must be transparent. - -Border draws **non-rectangular** content — an outline of line segments plus optional title text. The interior is empty: - -- **Transparent**: Only border lines and title text are drawn; the empty interior shows underlying views. -- **TransparentMouse**: Clicks on border lines/title go to the Border; clicks on the empty interior pass through. - -## Failing Tests - -Three tests in `Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTransparentTests.cs`: - -| Test | Status | Why | -|------|--------|-----| -| `Border_Transparent_Shows_Underlying_Content_In_Interior` | **Skip** (not yet implemented) | Border doesn't honor `Transparent` — interior shows spaces, not underlying content | -| `Border_TransparentMouse_Interior_Clicks_Pass_Through` | passes (wrong reason) | Blanket `TransparentMouse` removes Border entirely | -| `Border_TransparentMouse_BorderLine_Clicks_Are_Captured` | **Skip** (not yet implemented) | Blanket `TransparentMouse` removes Border from ALL hits, including border line cells | - -## Baseline Tests for DoDrawComplete - -Eight tests in `Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs` — all passing: - -| Test | What it verifies | -|------|-----------------| -| `OpaqueView_ExcludesEntireFrameFromClip` | Opaque view punches out its entire borderFrame from Driver.Clip | -| `OpaqueView_UsesBorderFrameNotViewFrame` | When Border exists, uses Border.FrameToScreen() not View.FrameToScreen() | -| `OpaqueView_UpdatesDrawContext` | Opaque view calls `context.AddDrawnRectangle(borderFrame)` so SuperView knows | -| `TransparentView_ExcludesOnlyDrawnRegion` | Transparent view excludes only the actually-drawn cells, not the full frame | -| `TransparentView_ClampsDrawnRegionToViewport` | DrawnRegion is clipped to Viewport bounds before exclusion | -| `TransparentView_ExcludesBorderAndPadding` | Border and Padding thickness areas are always excluded, even for transparent views | -| `Adornment_SkipsClipExclusion` | Adornments (Margin/Border/Padding) do NOT modify Driver.Clip in DoDrawComplete | -| `TransparentParent_OpaqueChild_ContextFlows` | Opaque child's frame is added to parent's DrawContext via AddDrawnRectangle | - -## Root Cause Analysis - -### Problem 1: Visual Transparency doesn't work for Border - -`DoDrawComplete` (View.Drawing.cs:821) has an `if (this is not Adornment)` guard that skips ALL clip exclusion logic for adornments. This means setting `Transparent` on a Border has no effect — the parent view's opaque path excludes the entire border frame from the clip regardless. - -### Problem 2: Border draws to Parent.LineCanvas, not its own - -Border.cs `OnDrawingContent` writes all border lines to `Parent.LineCanvas` (line 309), not to Border's own LineCanvas. The parent renders these later in its own `DoRenderLineCanvas` (View.Drawing.cs:144), and the drawn region gets reported to the **parent's** DrawContext — not the Border's. So the Border's own DrawContext never knows what cells the border lines occupy. - -This was the user's observation: "I suspect [writing to Parent.LineCanvas] was a poor decision back when I did it." The fix should make Border use its own LineCanvas with `SuperViewRendersLineCanvas = true`, so lines are drawn by Border but merged into the parent for rendering. However, `Adornment.SuperViewRendersLineCanvas` currently throws `InvalidOperationException` (Adornment.cs:215-218). - -### Problem 3: TransparentMouse is blanket per-view - -`GetViewsUnderLocation` (View.Layout.cs:1303) does `RemoveAll(v => v!.ViewportSettings.HasFlag(excludeViewportSettingsFlags))` — blanket removal. No per-cell checking. - -## Implementation - -### Phase 1: Visual Transparency for Border - -#### 1a. Fix Border LineCanvas ownership - DONE - -**Problem**: Border writes to `Parent.LineCanvas`. This means the drawn region is tracked on the parent, not the Border. - -**Fix in `Adornment.cs` (~line 215)**: Change `SuperViewRendersLineCanvas` override to allow setting it (remove the throw). Or better: have Adornment return `Parent?.SuperViewRendersLineCanvas ?? false` — meaning adornments' line canvases get merged into their Parent view's LineCanvas (which is where Border already writes today, but through the proper merge mechanism). - -**Fix in `Border.cs` (`OnDrawingContent`, ~line 309)**: Change from writing to `Parent?.LineCanvas` directly to writing to `this.LineCanvas` (Border's own). Set Border's `SuperViewRendersLineCanvas = true` so the parent merges and renders it. - -**Fix in `View.Drawing.cs` (`DoDrawSubViews`, ~line 703-718)**: The merge logic at line 715 (`LineCanvas.Merge(view.LineCanvas)`) uses `SuperView.LineCanvas`. For adornments, the "SuperView" in this context is actually the `Parent` view. Need to ensure the merge target is correct — when the view being drawn is an Adornment, merge into `Parent.LineCanvas` instead of `SuperView.LineCanvas`. - -This change means: -- Border adds lines to its own LineCanvas -- Border's `RenderLineCanvas` is skipped (because `SuperViewRendersLineCanvas = true`) -- Parent's draw loop merges Border's LineCanvas into its own -- Parent's `RenderLineCanvas` renders all lines and reports the drawn region to the parent's context -- Border's own DrawContext tracks what IT drew (title text, etc.) - -#### 1b. Make Border participate in clip exclusion when Transparent - DONE - -**Fix in `View.Drawing.cs` (`DoDrawComplete`, ~line 821)**: Relax the Adornment guard. When an Adornment has `Transparent` set, it should participate in the drawn-region clip exclusion path: - -```csharp -if (this is not Adornment || ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)) -{ - if (ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)) - { - // Transparent path: only exclude drawn regions - context!.ClipDrawnRegion (ViewportToScreen (Viewport)); - ExcludeFromClip (context.GetDrawnRegion ()); - } - else if (this is not Adornment) - { - // Opaque non-adornment path (existing code) - ... - } -} -``` - -#### 1c. Prevent viewport clearing for Transparent Border - DONE - -When Border has `Transparent` set, its viewport should NOT be cleared during `DoClearViewport`. The existing `Transparent` flag logic in `ClearViewport` (View.Drawing.cs:414) should handle this — but need to verify it works for adornments too. - -### Phase 2: Drawn-Region-Aware TransparentMouse - -**FIRST**: Plan tests. For each sub-phase, create the tests that will prove functionality. If they will fail until a later sub-phase is completed, mark them with Skip= with an explanaition. **DONE** — 14 tests total (see below). - -**Phase 2 Tests** (all Skip'd until implementation): - -| Test | File | Requires | What it proves | -|------|------|----------|---------------| -| `CachedDrawnRegion_PopulatedAfterDraw_WhenTransparentMouse` | DoDrawCompleteTests | 2a/2b | Cache populated after Draw() | -| `CachedDrawnRegion_Null_WhenNotTransparentMouse` | DoDrawCompleteTests | 2a/2b | No caching without flag | -| `CachedDrawnRegion_TransparentView_ContainsOnlyDrawnCells` | DoDrawCompleteTests | 2a/2b | Only drawn cells cached | -| `CachedDrawnRegion_BorderAdornment_PopulatedAfterDraw` | DoDrawCompleteTests | 2a/2b | Border adornment gets cache | -| `CachedDrawnRegion_Border_IncludesTitleAndLines` | DoDrawCompleteTests | 2a/2b | Cache has lines + title | -| `CachedDrawnRegion_ClearedBySetNeedsDraw` | DoDrawCompleteTests | 2c | SetNeedsDraw invalidates cache | -| `CachedDrawnRegion_RepopulatedAfterRedraw` | DoDrawCompleteTests | 2a/2b/2c | Full invalidate-redraw cycle | -| `ShadowView_ReportsDrawnRegionToContext` | DoDrawCompleteTests | 2d | Shadow cells in DrawContext | -| `Border_TransparentMouse_BorderLine_Clicks_Are_Captured` | BorderTransparentTests | 2e | Border lines receive clicks | -| `Border_TransparentMouse_Title_Clicks_Are_Captured` | BorderTransparentTests | 2e | Title text receives clicks | -| `View_TransparentMouse_DrawnCells_Captured_UndrawnCells_PassThrough` | BorderTransparentTests | 2a/2b/2e | Per-cell hit-testing works | -| `View_TransparentMouse_NullCache_FallsBackToBlanketRemoval` | BorderTransparentTests | 2e | Null cache = blanket fallback | -| `Border_TransparentMouse_ThickBorder_EmptyCells_PassThrough` | BorderTransparentTests | 2a/2b/2e | Thick border gap cells pass through | -| `Margin_TransparentMouse_Shadow_Clicks_Are_Captured` | BorderTransparentTests | 2d/2e | Shadow cells receive clicks | - -#### 2a. Add `_cachedDrawnRegion` to View (`View.Drawing.cs`) - -```csharp -private Region? _cachedDrawnRegion; -internal Region? CachedDrawnRegion => _cachedDrawnRegion; -``` - -#### 2b. Cache drawn region in `DoDrawComplete` (`View.Drawing.cs` ~line 810) - -**Only if `TransparentMouse` is set**: - -In the Transparent path (after clip exclusion), cache `context.GetDrawnRegion()`. For adornments with `TransparentMouse`, add caching after the Adornment guard. - -For Border specifically, the cached region must include both: -- Lines drawn via LineCanvas (reported to context by `RenderLineCanvas`) -- Title text (reported to context by `DrawText`) - -After Phase 1 fixes, the Border's own DrawContext should have the title text region. The LineCanvas region is in the parent's context. We need to ensure the Border's cached region includes both — this may mean the parent passes the line region back to the Border, or the Border caches its region from the parent's context after merge. - -**Alternative approach**: Cache the region at the parent level for all adornments. When hit-testing asks "is this point on the Border?", check the parent view's line canvas drawn region intersected with the Border's frame. This avoids the cross-context issue. - -**Simplest approach**: After the parent renders its LineCanvas (which includes Border's lines), have the parent set `_cachedDrawnRegion` on its Border adornment by intersecting the line canvas region with the Border's frame. This way the Border's cached region accurately reflects the cells where border lines were drawn. - -#### 2c. Invalidate cache in `SetNeedsDraw` (`View.NeedsDraw.cs` ~line 58) - -Clear `_cachedDrawnRegion = null` at the start of `SetNeedsDraw(Rectangle)`. - -#### 2d. Report drawn region in ShadowView (`ShadowView.cs` ~line 47) - -Add `context?.AddDrawnRectangle (ViewportToScreen (Viewport));` at the end of `OnDrawingContent`. - -#### 2e. Modify hit-testing (`View.Layout.cs` ~line 1262) - -In `GetViewsUnderLocation`, change both filtering stages: - -**Stage 1 — Adornment filtering (lines ~1275-1300)**: -```csharp -if (viewsUnderLocation.Contains (v.Margin) && v.Margin!.ViewportSettings.HasFlag (excludeViewportSettingsFlags)) -{ - // Per-cell check: if the adornment has a cached drawn region, check it - if (v.Margin.CachedDrawnRegion is null || !v.Margin.CachedDrawnRegion.Contains (screenLocation.X, screenLocation.Y)) - { - ret = true; - } -} -``` - -Same pattern for Border and Padding checks. - -**Stage 2 — Direct view filtering (line ~1303)**: -```csharp -viewsUnderLocation.RemoveAll (v => -{ - if (!v!.ViewportSettings.HasFlag (excludeViewportSettingsFlags)) - { - return false; - } - - if (v.CachedDrawnRegion is { } drawnRegion) - { - return !drawnRegion.Contains (screenLocation.X, screenLocation.Y); - } - - return true; // null cache = blanket removal (fallback) -}); -``` - -## Files to Modify - -| File | Change | -|------|--------| -| `Terminal.Gui/ViewBase/Adornment/Adornment.cs` | Allow `SuperViewRendersLineCanvas` to be set (remove throw) | -| `Terminal.Gui/ViewBase/Adornment/Border.cs` | Write to own LineCanvas instead of `Parent.LineCanvas`; set `SuperViewRendersLineCanvas = true` | -| `Terminal.Gui/ViewBase/View.Drawing.cs` | Add `_cachedDrawnRegion`; relax Adornment guard in `DoDrawComplete`; cache drawn region; ensure LineCanvas merge works for adornments | -| `Terminal.Gui/ViewBase/View.NeedsDraw.cs` | Clear `_cachedDrawnRegion` in `SetNeedsDraw` | -| `Terminal.Gui/ViewBase/View.Layout.cs` | Drawn-region check in `GetViewsUnderLocation` | -| `Terminal.Gui/ViewBase/Adornment/ShadowView.cs` | Report drawn region to DrawContext | - -## Edge Cases - -| Case | Behavior | -|------|----------| -| Before first draw | `CachedDrawnRegion` is null; falls back to blanket `TransparentMouse` (no regression) | -| View doesn't report drawn regions | Same — null cache, blanket behavior | -| Stale cache between draws | Invalidated by `SetNeedsDraw`; between draws, cache reflects last visual state | -| Layout changes | Trigger `SetNeedsDraw`, which clears cache | -| Border with no lines (Thickness=0) | No drawn region, fully transparent to mouse | -| Existing code that reads `Parent.LineCanvas` from Border | Must be updated to use `this.LineCanvas` or verified to still work after merge | - -## Verification - -1. `dotnet build --no-restore` — zero errors -2. `dotnet test --project Tests/UnitTestsParallelizable --no-build` — no regressions -3. `Border_Transparent_Shows_Underlying_Content_In_Interior` — passes (interior shows underlying content) -4. `Border_TransparentMouse_Interior_Clicks_Pass_Through` — passes (for the right reason now) -5. `Border_TransparentMouse_BorderLine_Clicks_Are_Captured` — passes (border lines receive mouse events) -6. UICatalog `Transparent` scenario — transparent views still work -7. UICatalog scenarios with borders/windows — no visual regressions diff --git a/plans/unified-adornment-transparency.md b/plans/unified-adornment-transparency.md deleted file mode 100644 index fd9dbad7ed..0000000000 --- a/plans/unified-adornment-transparency.md +++ /dev/null @@ -1,107 +0,0 @@ -# Unified Adornment Transparency Plan - -## Problem - -`DoDrawComplete` special-cases Border (uses `_lastLineCanvasRegion` + `LastTitleRect`) while -Margin/Padding get a generic `context.GetDrawnRegion()` intersected with their frame. This is -wrong — all three adornment types should be treated identically: - -**Any adornment that draws content (text, subviews, line canvas) within its thickness should have -those drawn cells be opaque. Undrawn cells in a transparent adornment should be transparent.** - -`LastTitleRect` is just one example of an adornment drawing text. Padding and Margin can draw -text too (`Padding.View.Text = "Pad"`). The current code doesn't handle Padding/Margin text as -opaque cells within a transparent adornment. - -### Current Bugs - -1. **Padding visual transparency doesn't work** — Transparent Padding fills with spaces instead - of showing underlying content. Test `Padding_Transparent_Text_Is_Opaque_Over_Peer_SubViews` - fails: actual shows the underlying window's Text where the padding Text should occlude it. - -2. **Border special-casing** — `DoDrawComplete` builds Border's `CachedDrawnRegion` from - `_lastLineCanvasRegion` + `LastTitleRect`, but Padding/Margin get `context.GetDrawnRegion()` - intersected with their frame. This asymmetry means Border drawn cells are tracked precisely, - but Padding/Margin get the cumulative drawn region of ALL layers. - -3. **Missing test coverage** — No Padding or Margin visual transparency tests. No Padding or - Margin TransparentMouse drawn-cell tests. - -## Design Principle - -Each adornment tracks its OWN drawn region. The parent's `DoDrawComplete` uses each adornment's -individual drawn region uniformly — no special cases for Border vs Padding vs Margin. - -## Approach - -1. **Each AdornmentView reports its drawn cells** during its own draw cycle. BorderView already - does this for `LastTitleRect`. The LineCanvas is rendered by the parent (`DoRenderLineCanvas`) - and stored in `_lastLineCanvasRegion`. Padding/Margin text rendering needs to similarly report - drawn regions. - -2. **DoDrawComplete uses a uniform approach** for all three adornments — no Border special-casing. - For each transparent adornment, build CachedDrawnRegion the same way. - -3. **Per-adornment drawn region tracking** — each AdornmentView (or AdornmentImpl) accumulates - what it drew. Options: - - AdornmentView stores a `DrawnRegion` property set during Draw() - - Parent passes per-adornment DrawContext - - AdornmentImpl stores the drawn region populated by AdornmentView - -## Todos - -### Phase A: Fix Padding/Margin visual transparency - -- [ ] **A1: Investigate why transparent Padding clears to spaces** — Verify PaddingView respects - `ViewportSettings.Transparent` in DoClearViewport. If clearing happens in the parent's draw - cycle (not PaddingView's own Draw), that's the bug. - -- [ ] **A2: Fix transparent Padding/Margin to not clear their area** — When - `Padding.ViewportSettings.Transparent` is set, undrawn cells remain untouched. - -- [ ] **A3: Make Padding/Margin text opaque within transparent adornment** — Drawn text cells - should be excluded from clip, same as Border's title/lines are. - -### Phase B: Unify CachedDrawnRegion logic - -- [ ] **B1: Remove Border-specific CachedDrawnRegion logic** — Replace the special - `_lastLineCanvasRegion` + `LastTitleRect` composition with a uniform approach. - -- [ ] **B2: Implement per-adornment drawn region tracking** — Each AdornmentView accumulates - what it drew during Draw(). Parent's DoDrawComplete reads this. - -- [ ] **B3: Update DoDrawComplete** — Replace three separate code blocks with one uniform - helper that processes all three adornments identically. - -### Phase C: Comprehensive tests - -- [ ] **C1: Padding visual transparency tests** - - `Padding_Transparent_Undrawn_Cells_Show_Underlying_Content` - - `Padding_Transparent_Text_Is_Opaque_Over_Peer_SubViews` (existing, currently failing) - - `Padding_Transparent_SubViews_Are_Opaque` - -- [ ] **C2: Margin visual transparency tests** - - `Margin_Transparent_Undrawn_Cells_Show_Underlying_Content` - - `Margin_Transparent_Text_Is_Opaque` - -- [ ] **C3: Padding TransparentMouse drawn-cell tests** - - `Padding_TransparentMouse_DrawnText_Clicks_Are_Captured` - - `Padding_TransparentMouse_UndrawnCells_PassThrough` - -- [ ] **C4: Margin TransparentMouse drawn-cell tests** - - `Margin_TransparentMouse_DrawnText_Clicks_Are_Captured` - - `Margin_TransparentMouse_UndrawnCells_PassThrough` - -- [ ] **C5: Verify Border tests still pass** — Existing 8 Border tests must continue passing. - -- [ ] **C6: Cross-adornment test** — All three adornments transparent, each with text. Verify - each adornment's text is opaque while undrawn cells are transparent. - -## Key Code Locations - -- `View.Drawing.cs:DoDrawComplete` (~line 846) — clip exclusion + CachedDrawnRegion -- `View.Drawing.cs:DoDrawAdornments` (~line 300) — draws adornment views -- `View.Drawing.cs:DoRenderLineCanvas` (~line 807) — caches `_lastLineCanvasRegion` -- `BorderView.cs:OnDrawingContent` — draws title, reports LastTitleRect -- `PaddingView.cs` / `MarginView.cs` — draw text but don't report drawn regions -- `View.Layout.cs:GetViewsUnderLocation` (~line 1260) — per-cell hit-testing