From 3f004de13d789350e4a9f813a293ebdc3688fd36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:15:28 +0000 Subject: [PATCH 1/3] Initial plan From f01eaf48e039d0750e36d4e99296caec171d9718 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:36:00 +0000 Subject: [PATCH 2/3] Implement BorderSettings.Tab feature for tab-style borders - Add Tab = 4 flag to BorderSettings enum - Add TabSide, TabOffset, TabLength properties and SettingsChanged event to Border - Create TabTitleView: focused-aware text renderer for tab headers - Add tab mode rendering to BorderView: - ConfigureForTabMode sets transparent viewport for tab borders - UpdateTabTitleViewLayout positions the tab title during layout - DrawTabBorder handles complete tab border rendering - AddTabSideContentBorder draws border segments around the tab gap - Helper methods: GetTabBorderBounds, ComputeHeaderRect, ComputeViewBounds, ComputeTabLabelThickness, GetTabDepth, ComputeTabContentArea - Update View.Drawing.cs: draw adornment SubViews BEFORE LineCanvas so SubView border lines (via SuperViewRendersLineCanvas) participate in auto-join; merge SubView LineCan vases from Border/Padding into parent after SubView draw - Add LineCanvas.Merge(LineCanvas, Region?) overload for clipped merging that splits lines at exclusion region boundaries, preventing lower-Z subview lines from overwriting higher-Z subview areas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs | 120 +++- Terminal.Gui/ViewBase/Adornment/Border.cs | 87 ++- .../ViewBase/Adornment/BorderSettings.cs | 8 + Terminal.Gui/ViewBase/Adornment/BorderView.cs | 640 ++++++++++++++++-- .../ViewBase/Adornment/TabTitleView.cs | 42 ++ Terminal.Gui/ViewBase/View.Drawing.cs | 73 +- 6 files changed, 888 insertions(+), 82 deletions(-) create mode 100644 Terminal.Gui/ViewBase/Adornment/TabTitleView.cs diff --git a/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs b/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs index ed3e529ec4..7093985121 100644 --- a/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs +++ b/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs @@ -394,10 +394,124 @@ public void Merge (LineCanvas lineCanvas) AddLine (line); } - if (lineCanvas._exclusionRegion is { }) + if (lineCanvas._exclusionRegion is null) { - _exclusionRegion ??= new (); - _exclusionRegion.Union (lineCanvas._exclusionRegion); + return; + } + + _exclusionRegion ??= new Region (); + _exclusionRegion.Union (lineCanvas._exclusionRegion); + } + + /// + /// Merges one line canvas into this one, excluding lines (or portions of lines) that fall + /// within . Lines that partially overlap the exclusion region are + /// split into segments that skip the excluded cells. The exclusion is applied at the line level + /// so that excluded cells do not participate in auto-join intersection resolution. + /// + /// The source canvas to merge from. + /// + /// The region to exclude. Cells of incoming lines that fall within this region will not be merged. + /// Pass for default merge behavior (no exclusion). + /// + public void Merge (LineCanvas lineCanvas, Region? exclude) + { + if (exclude is null || exclude.IsEmpty ()) + { + Merge (lineCanvas); + + return; + } + + Rectangle excludeBounds = exclude.GetBounds (); + Rectangle [] excludeRects = exclude.GetRectangles (); + + foreach (StraightLine line in lineCanvas._lines) + { + AddLineExcluding (line, excludeBounds, excludeRects); + } + + if (lineCanvas._exclusionRegion is null) + { + return; + } + + _exclusionRegion ??= new Region (); + _exclusionRegion.Union (lineCanvas._exclusionRegion); + + return; + + // Adds segments of `line` that do not overlap with the exclusion rectangles. + void AddLineExcluding (StraightLine line, Rectangle exBounds, Rectangle [] exRects) + { + Rectangle bounds = line.Bounds; + + // Fast path: if the line doesn't intersect the exclusion bounds at all, add it whole. + if (!bounds.IntersectsWith (exBounds)) + { + AddLine (line); + + return; + } + + // Walk cells along the line's axis, building non-excluded segments. + bool isHorizontal = line.Orientation == Orientation.Horizontal; + int axisStart = isHorizontal ? bounds.X : bounds.Y; + int axisEnd = axisStart + (isHorizontal ? bounds.Width : bounds.Height); + int fixedCoord = isHorizontal ? bounds.Y : bounds.X; + + int segStart = -1; + + for (int i = axisStart; i < axisEnd; i++) + { + int x = isHorizontal ? i : fixedCoord; + int y = isHorizontal ? fixedCoord : i; + + if (!ContainedInAny (exRects, x, y)) + { + if (segStart < 0) + { + segStart = i; + } + + continue; + } + + // Cell is excluded — flush any pending segment. + if (segStart < 0) + { + continue; + } + + EmitSegment (line, isHorizontal, fixedCoord, segStart, i - segStart); + segStart = -1; + } + + // Flush trailing segment. + if (segStart >= 0) + { + EmitSegment (line, isHorizontal, fixedCoord, segStart, axisEnd - segStart); + } + } + + static bool ContainedInAny (Rectangle [] rects, int x, int y) + { + for (var i = 0; i < rects.Length; i++) + { + if (rects [i].Contains (x, y)) + { + return true; + } + } + + return false; + } + + void EmitSegment (StraightLine original, bool isHorizontal, int fixedCoord, int segAxisStart, int segLength) + { + Point start = isHorizontal ? new Point (segAxisStart, fixedCoord) : new Point (fixedCoord, segAxisStart); + + AddLine (new StraightLine (start, segLength, original.Orientation, original.Style, original.Attribute)); } } diff --git a/Terminal.Gui/ViewBase/Adornment/Border.cs b/Terminal.Gui/ViewBase/Adornment/Border.cs index 189ae57dea..1b5a0cc976 100644 --- a/Terminal.Gui/ViewBase/Adornment/Border.cs +++ b/Terminal.Gui/ViewBase/Adornment/Border.cs @@ -25,10 +25,10 @@ protected override AdornmentView CreateView () return bv; } - /// + /// public override Rectangle GetFrame () => Parent is { } ? Parent.Margin.Thickness.GetInside (Parent!.Margin.GetFrame ()) : Rectangle.Empty; - /// + /// protected override void OnThicknessChanged () { base.OnThicknessChanged (); @@ -48,7 +48,8 @@ protected override void OnThicknessChanged () /// /// Sets the style of the lines drawn in the . If not set, will inherit the style from - /// the 's 's . If set, will cause + /// the 's 's . If set, will + /// cause /// to be created. /// public LineStyle? LineStyle @@ -63,7 +64,7 @@ public LineStyle? LineStyle field = value; - if (field is not null) + if (field is { }) { GetOrCreateView (); } @@ -82,9 +83,87 @@ public BorderSettings Settings { return; } + field = value; + if (field.HasFlag (BorderSettings.Tab)) + { + GetOrCreateView (); + } + + SettingsChanged?.Invoke (this, EventArgs.Empty); Parent?.SetNeedsLayout (); } } = BorderSettings.Title; + + /// Fired when changes. + public event EventHandler? SettingsChanged; + + /// + /// Gets or sets which side the Tab protrudes from. Only used when is set. + /// + public Side TabSide + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + Parent?.SetNeedsLayout (); + } + } = Side.Top; + + /// + /// Gets or sets the offset along the border edge where the Tab starts (columns for Top/Bottom, + /// rows for Left/Right). Only used when is set. + /// + public int TabOffset + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + Parent?.SetNeedsLayout (); + } + } + + /// + /// Gets or sets the total length of the tab parallel to the border edge (including border cells). + /// If the length will be determined based on . + /// Only used when is set. + /// + public int? TabLength + { + get + { + if (field is { } || !Settings.HasFlag (BorderSettings.Tab)) + { + return field; + } + + int titleColumns = Settings.HasFlag (BorderSettings.Title) ? Parent?.TitleTextFormatter.FormatAndGetSize ().Width ?? 0 : 0; + + // Two vertical border lines + title text width (2 when no title) + return titleColumns + 2; + } + set + { + if (field == value) + { + return; + } + + field = value; + Parent?.SetNeedsLayout (); + } + } } diff --git a/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs b/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs index 4921446aec..0acc9bd481 100644 --- a/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs +++ b/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs @@ -20,4 +20,12 @@ public enum BorderSettings /// Use to draw the border. /// Gradient = 2, + + /// + /// Draw a Tab on one side of the border. The will be displayed in the Tab. Configure with + /// , , + /// . + /// + Tab = 4, } + diff --git a/Terminal.Gui/ViewBase/Adornment/BorderView.cs b/Terminal.Gui/ViewBase/Adornment/BorderView.cs index 423d3586c5..fc9c4fa70f 100644 --- a/Terminal.Gui/ViewBase/Adornment/BorderView.cs +++ b/Terminal.Gui/ViewBase/Adornment/BorderView.cs @@ -61,6 +61,7 @@ public BorderView (Border border) : base (border) } border.ThicknessChanged += OnThicknessChanged; border.Parent?.Margin.ThicknessChanged += OnThicknessChanged; + border.SettingsChanged += OnSettingsChanged; } /// @@ -84,6 +85,122 @@ private void OnThicknessChanged (object? sender, EventArgs e) { ShowHideDrawIndicator (); } + + ConfigureForTabMode (); + } + + private void OnSettingsChanged (object? sender, EventArgs e) => ConfigureForTabMode (); + + private bool _tabModeSetTransparent; + + /// + /// Configures persistent state for tab mode. Called when or + /// changes. Sets and + /// ensures the SubView exists with the correct static properties. + /// + private void ConfigureForTabMode () + { + if (Adornment is not Border border) + { + return; + } + + if (border.Settings.FastHasFlags (BorderSettings.Tab)) + { + ViewportSettings |= ViewportSettingsFlags.Transparent | ViewportSettingsFlags.TransparentMouse; + _tabModeSetTransparent = true; + + TabTitleView label = EnsureTabTitleView (); + + if (border.LineStyle is { } ls) + { + label.BorderStyle = ls; + } + + label.TextFormatter.Direction = border.TabSide is Side.Left or Side.Right ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom; + } + else + { + // Only clear flags if we set them for tab mode + if (_tabModeSetTransparent) + { + ViewportSettings &= ~(ViewportSettingsFlags.Transparent | ViewportSettingsFlags.TransparentMouse); + _tabModeSetTransparent = false; + } + + if (_tabTitleView is { }) + { + _tabTitleView.Visible = false; + } + } + } + + /// + protected override void OnSubViewLayout (LayoutEventArgs args) => UpdateTabTitleViewLayout (); + + /// + /// Computes and sets the 's frame, size, border thickness, + /// text, and visibility during the layout pass. Called via . + /// + private void UpdateTabTitleViewLayout () + { + if (Adornment is not Border border || !border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return; + } + + if (_tabTitleView is null) + { + return; + } + + Rectangle borderBounds = GetTabBorderBounds (border); + + if (borderBounds is not { Width: > 0, Height: > 0 }) + { + _tabTitleView.Visible = false; + + return; + } + + int tabDepth = GetTabDepth (border); + int tabLength = border.TabLength!.Value; + bool hasFocus = border.Parent?.HasFocus ?? false; + + Rectangle headerRect = ComputeHeaderRect (borderBounds, border.TabSide, border.TabOffset, tabLength, tabDepth); + Rectangle viewBounds = ComputeViewBounds (borderBounds, border.TabSide, tabDepth); + Rectangle clipped = Rectangle.Intersect (headerRect, viewBounds); + bool tabVisible = !clipped.IsEmpty; + + if (!tabVisible) + { + _tabTitleView.Visible = false; + + return; + } + + _tabTitleView.Visible = true; + _tabTitleView.HotKeySpecifier = Adornment.Parent?.HotKeySpecifier ?? default (Rune); + _tabTitleView.Text = Adornment.Parent?.Title ?? string.Empty; + + if (border.LineStyle is { } ls) + { + _tabTitleView.BorderStyle = ls; + } + + // Configure the label's border thickness based on depth and focus + Thickness labelBorderThickness = ComputeTabLabelThickness (border.TabSide, tabDepth, hasFocus); + _tabTitleView.Border.Thickness = labelBorderThickness; + + // For Left/Right, render text vertically + _tabTitleView.TextFormatter.Direction = border.TabSide is Side.Left or Side.Right + ? TextDirection.TopBottom_LeftRight + : TextDirection.LeftRight_TopBottom; + + // Convert header rect from screen to BorderView viewport coords + Point screenOrigin = ViewportToScreen (Point.Empty); + Rectangle labelFrame = headerRect with { X = headerRect.X - screenOrigin.X, Y = headerRect.Y - screenOrigin.Y }; + _tabTitleView.Frame = labelFrame; } private void ShowHideDrawIndicator () @@ -125,15 +242,6 @@ internal void AdvanceDrawIndicator () DrawIndicator.Render (); } -#if SUBVIEW_BASED_BORDER - private Line _left; - - /// - /// The close button for the border. Set to , to to enable. - /// - public Button CloseButton { get; internal set; } -#endif - /// public override void BeginInit () { @@ -145,53 +253,11 @@ public override void BeginInit () } ShowHideDrawIndicator (); + ConfigureForTabMode (); MouseHighlightStates |= Adornment.Parent.Arrangement != ViewArrangement.Fixed ? MouseState.Pressed : MouseState.None; - -#if SUBVIEW_BASED_BORDER - if (Adornment.Parent is { }) - { - // Left - _left = new () - { - Orientation = Orientation.Vertical, - }; - Add (_left); - - CloseButton = new Button () - { - Text = "X", - CanFocus = true, - Visible = false, - }; - CloseButton.Accept += (s, e) => - { - e.Handled = Parent.InvokeCommand (Command.Quit) == true; - }; - Add (CloseButton); - - LayoutStarted += OnLayoutStarted; - } -#endif } -#if SUBVIEW_BASED_BORDER - private void OnLayoutStarted (object sender, LayoutEventArgs e) - { - _left.Border.LineStyle = LineStyle; - - _left.X = Adornment!.Thickness.Left - 1; - _left.Y = Adornment!.Thickness.Top - 1; - _left.Width = 1; - _left.Height = Height; - - CloseButton.X = Pos.AnchorEnd (Adornment!.Thickness.Right / 2 + 1) - - (Pos.Right (CloseButton) - - Pos.Left (CloseButton)); - CloseButton.Y = 0; -} -#endif - private Rectangle GetBorderBounds () { Rectangle screenRect = ViewportToScreen (Viewport); @@ -206,6 +272,469 @@ private Rectangle GetBorderBounds () - Math.Max (0, Math.Max (0, Adornment!.Thickness.Top - 1) + Math.Max (0, Adornment!.Thickness.Bottom - 1)))); } + /// + /// Computes the content border rectangle when is set. + /// Non-title sides use the outer edge of the thickness. The title side uses thickness - 1 + /// from the outer edge, leaving a tab header region between the outer edge and the content border line. + /// + private Rectangle GetTabBorderBounds (Border border) + { + Rectangle screenRect = ViewportToScreen (Viewport); + + int left = screenRect.X; + int top = screenRect.Y; + int right = screenRect.Right; + int bottom = screenRect.Bottom; + + // Title side: content border at thickness - 1 from outer edge + switch (border.TabSide) + { + case Side.Top: + top += Math.Max (0, Adornment!.Thickness.Top - 1); + + break; + + case Side.Bottom: + bottom -= Math.Max (0, Adornment!.Thickness.Bottom - 1); + + break; + + case Side.Left: + left += Math.Max (0, Adornment!.Thickness.Left - 1); + + break; + + case Side.Right: + right -= Math.Max (0, Adornment!.Thickness.Right - 1); + + break; + } + + return new Rectangle (left, top, Math.Max (0, right - left), Math.Max (0, bottom - top)); + } + + private TabTitleView? _tabTitleView; + + /// Gets the tab title , or if not yet created. + public View? TabTitleView => _tabTitleView; + + /// + /// Gets or lazily creates the SubView used to render the tab header. + /// The view has its own border with = true, + /// so its border lines auto-join with the View's content border via . + /// + private TabTitleView EnsureTabTitleView () + { + if (_tabTitleView is { }) + { + return _tabTitleView; + } + + _tabTitleView = new TabTitleView + { +#if DEBUG + Id = "TabTitleView", +#endif + CanFocus = false, + TabStop = TabBehavior.NoStop, + SuperViewRendersLineCanvas = true, + OwnerView = Adornment?.Parent + }; + _tabTitleView.Border.Settings = BorderSettings.None; + Add (_tabTitleView); + + return _tabTitleView; + } + + /// + /// Computes the unclipped header rectangle for the given side, offset, length, and depth. In content coordinates. + /// + private static Rectangle ComputeHeaderRect (Rectangle contentBorderRect, Side side, int offset, int length, int depth) => + side switch + { + Side.Top => new Rectangle (contentBorderRect.X + offset, contentBorderRect.Y - (depth - 1), length, depth), + Side.Bottom => new Rectangle (contentBorderRect.X + offset, contentBorderRect.Bottom - 1, length, depth), + Side.Left => new Rectangle (contentBorderRect.X - (depth - 1), contentBorderRect.Y + offset, depth, length), + Side.Right => new Rectangle (contentBorderRect.Right - 1, contentBorderRect.Y + offset, depth, length), + _ => Rectangle.Empty + }; + + /// + /// Computes the full view bounds (content border + header protrusion area). In content coordinates. + /// + private static Rectangle ComputeViewBounds (Rectangle contentBorderRect, Side side, int depth) => + side switch + { + Side.Top => contentBorderRect with { Y = contentBorderRect.Y - (depth - 1), Height = contentBorderRect.Height + (depth - 1) }, + Side.Bottom => contentBorderRect with { Height = contentBorderRect.Height + (depth - 1) }, + Side.Left => contentBorderRect with { X = contentBorderRect.X - (depth - 1), Width = contentBorderRect.Width + (depth - 1) }, + Side.Right => contentBorderRect with { Width = contentBorderRect.Width + (depth - 1) }, + _ => contentBorderRect + }; + + /// + /// Computes the for the tab title Label's border based on + /// depth, focus state, and which side the tab is on. + /// + /// + /// + /// "Cap" is the outward edge (away from content). "Content" is the inward edge (toward content area). + /// For depth ≥ 3, the content-side thickness toggles with focus to create the open gap / separator. + /// For depth < 3, no focus distinction in border lines. + /// + /// + private static Thickness ComputeTabLabelThickness (Side tabSide, int depth, bool hasFocus) + { + int cap = depth >= 2 ? 1 : 0; + int contentSide = depth >= 3 && !hasFocus ? 1 : 0; + + return tabSide switch + { + Side.Top => new Thickness (1, cap, 1, contentSide), + Side.Bottom => new Thickness (1, contentSide, 1, cap), + Side.Left => new Thickness (cap, 1, contentSide, 1), + Side.Right => new Thickness (contentSide, 1, cap, 1), + _ => Thickness.Empty + }; + } + + private int GetTabDepth (Border border) + { + int thickness = border.TabSide switch + { + Side.Top => Adornment!.Thickness.Top, + Side.Bottom => Adornment!.Thickness.Bottom, + Side.Left => Adornment!.Thickness.Left, + Side.Right => Adornment!.Thickness.Right, + _ => 3 + }; + + return Math.Min (thickness, 3); + } + + /// + /// Draws the border and tab header when is set. + /// Uses a SubView with its own border and + /// = true for the tab header. + /// The TabTitleView's border lines auto-join with the content border via . + /// + private bool DrawTabBorder (Border border) + { + if (Adornment?.Parent is null || Driver is null) + { + return true; + } + + Rectangle screenBounds = ViewportToScreen (Viewport); + Rectangle borderBounds = GetTabBorderBounds (border); + + if (borderBounds is not { Width: > 0, Height: > 0 }) + { + return true; + } + + if (border.LineStyle is null or LineStyle.None) + { + return true; + } + + Attribute normalAttribute = GetAttributeForRole (VisualRole.Normal); + + if (MouseState.HasFlag (MouseState.Pressed)) + { + normalAttribute = GetAttributeForRole (VisualRole.Highlight); + } + + SetAttribute (normalAttribute); + + LineCanvas? lc = Adornment.Parent?.LineCanvas; + + if (lc is null) + { + return true; + } + + int tabDepth = GetTabDepth (border); + int tabLength = border.TabLength!.Value; + LineStyle lineStyle = border.LineStyle.Value; + bool hasFocus = border.Parent!.HasFocus; + + // Compute tab header geometry + Rectangle headerRect = ComputeHeaderRect (borderBounds, border.TabSide, border.TabOffset, tabLength, tabDepth); + Rectangle viewBounds = ComputeViewBounds (borderBounds, border.TabSide, tabDepth); + Rectangle clipped = Rectangle.Intersect (headerRect, viewBounds); + bool tabVisible = !clipped.IsEmpty; + + // Draw the 3 non-tab-side content border lines (always drawn). + // The tab-side line is handled below - conditionally, based on whether the tab is visible. + if (Adornment!.Thickness.Top > 0 && (border.TabSide != Side.Top || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Y), borderBounds.Width, Orientation.Horizontal, lineStyle, normalAttribute); + } + + if (Adornment!.Thickness.Bottom > 0 && (border.TabSide != Side.Bottom || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Bottom - 1), borderBounds.Width, Orientation.Horizontal, lineStyle, normalAttribute); + } + + if (Adornment!.Thickness.Left > 0 && (border.TabSide != Side.Left || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Y), borderBounds.Height, Orientation.Vertical, lineStyle, normalAttribute); + } + + if (Adornment!.Thickness.Right > 0 && (border.TabSide != Side.Right || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.Right - 1, borderBounds.Y), borderBounds.Height, Orientation.Vertical, lineStyle, normalAttribute); + } + + // Draw the tab-side content border (gap segments around the tab) + if (tabVisible) + { + AddTabSideContentBorder (lc, + clipped, + headerRect, + borderBounds, + border.TabSide, + hasFocus, + tabDepth, + lineStyle, + normalAttribute); + } + + // Update LastTitleRect for click handling (uses draw-time geometry) + if (tabVisible && _tabTitleView is { Visible: true }) + { + Thickness effectiveThickness = ComputeTabLabelThickness (border.TabSide, tabDepth, hasFocus); + Rectangle visibleContent = ComputeTabContentArea (clipped, headerRect, border.TabSide, effectiveThickness, tabDepth); + LastTitleRect = visibleContent.IsEmpty ? null : visibleContent; + } + else + { + LastTitleRect = null; + } + + // Gradient support + if (border.Settings.FastHasFlags (BorderSettings.Gradient)) + { + if (_cachedGradientFill is null || _cachedGradientRect != screenBounds) + { + SetupGradientLineCanvas (lc, screenBounds); + } + else + { + lc.Fill = _cachedGradientFill; + } + } + else + { + lc.Fill = null; + _cachedGradientFill = null; + } + + return true; + } + + /// + /// Draws the tab-side content border line. For focused depth ≥ 3, draws split segments + /// around the gap. For unfocused depth ≥ 3, draws the full line (auto-join creates junctions). + /// For depth < 3, draws the full line. + /// + private static void AddTabSideContentBorder (LineCanvas lc, + Rectangle clipped, + Rectangle headerRect, + Rectangle contentBorderRect, + Side side, + bool hasFocus, + int depth, + LineStyle lineStyle, + Attribute? attribute) + { + // Open gap when: focused at depth >= 3 (no content-side border on tab), or + // depth < 3 (content border coincides with tab title row - must not overwrite it). + bool openGap = (hasFocus && depth >= 3) || depth < 3; + + switch (side) + { + case Side.Top: + { + int borderY = contentBorderRect.Y; + + if (!openGap) + { + lc.AddLine (new Point (contentBorderRect.X, borderY), contentBorderRect.Width, Orientation.Horizontal, lineStyle, attribute); + } + else + { + if (clipped.X > contentBorderRect.X) + { + lc.AddLine (new Point (contentBorderRect.X, borderY), + clipped.X - contentBorderRect.X + 1, + Orientation.Horizontal, + lineStyle, + attribute); + } + + if (clipped.Right - 1 < contentBorderRect.Right - 1) + { + lc.AddLine (new Point (clipped.Right - 1, borderY), + contentBorderRect.Right - (clipped.Right - 1), + Orientation.Horizontal, + lineStyle, + attribute); + } + } + + break; + } + + case Side.Bottom: + { + int borderY = contentBorderRect.Bottom - 1; + + if (!openGap) + { + lc.AddLine (new Point (contentBorderRect.X, borderY), contentBorderRect.Width, Orientation.Horizontal, lineStyle, attribute); + } + else + { + if (clipped.X > contentBorderRect.X) + { + lc.AddLine (new Point (contentBorderRect.X, borderY), + clipped.X - contentBorderRect.X + 1, + Orientation.Horizontal, + lineStyle, + attribute); + } + + if (clipped.Right - 1 < contentBorderRect.Right - 1) + { + lc.AddLine (new Point (clipped.Right - 1, borderY), + contentBorderRect.Right - (clipped.Right - 1), + Orientation.Horizontal, + lineStyle, + attribute); + } + } + + break; + } + + case Side.Left: + { + int borderX = contentBorderRect.X; + + if (!openGap) + { + lc.AddLine (new Point (borderX, contentBorderRect.Y), contentBorderRect.Height, Orientation.Vertical, lineStyle, attribute); + } + else + { + if (clipped.Y > contentBorderRect.Y) + { + lc.AddLine (new Point (borderX, contentBorderRect.Y), clipped.Y - contentBorderRect.Y + 1, Orientation.Vertical, lineStyle, attribute); + } + else if (clipped.Y > headerRect.Y) + { + // Header clipped at top (overflow) - suppress corner glyph + lc.Exclude (new Region (new Rectangle (borderX, contentBorderRect.Y, 1, 1))); + } + + if (clipped.Bottom - 1 < contentBorderRect.Bottom - 1) + { + lc.AddLine (new Point (borderX, clipped.Bottom - 1), + contentBorderRect.Bottom - (clipped.Bottom - 1), + Orientation.Vertical, + lineStyle, + attribute); + } + else if (clipped.Bottom < headerRect.Bottom) + { + // Header clipped at bottom (overflow) - suppress corner glyph + lc.Exclude (new Region (new Rectangle (borderX, contentBorderRect.Bottom - 1, 1, 1))); + } + } + + break; + } + + case Side.Right: + { + int borderX = contentBorderRect.Right - 1; + + if (!openGap) + { + lc.AddLine (new Point (borderX, contentBorderRect.Y), contentBorderRect.Height, Orientation.Vertical, lineStyle, attribute); + } + else + { + if (clipped.Y > contentBorderRect.Y) + { + lc.AddLine (new Point (borderX, contentBorderRect.Y), clipped.Y - contentBorderRect.Y + 1, Orientation.Vertical, lineStyle, attribute); + } + else if (clipped.Y > headerRect.Y) + { + // Header clipped at top (overflow) - suppress corner glyph + lc.Exclude (new Region (new Rectangle (borderX, contentBorderRect.Y, 1, 1))); + } + + if (clipped.Bottom - 1 < contentBorderRect.Bottom - 1) + { + lc.AddLine (new Point (borderX, clipped.Bottom - 1), + contentBorderRect.Bottom - (clipped.Bottom - 1), + Orientation.Vertical, + lineStyle, + attribute); + } + else if (clipped.Bottom < headerRect.Bottom) + { + // Header clipped at bottom (overflow) - suppress corner glyph + lc.Exclude (new Region (new Rectangle (borderX, contentBorderRect.Bottom - 1, 1, 1))); + } + } + + break; + } + + default: throw new ArgumentOutOfRangeException (nameof (side), side, null); + } + } + + /// + /// Computes the content area within the tab header where the title text is drawn. + /// For depth ≥ 3, always reserves 1 cell on each side (cap, content-side closing edge, + /// side edges) - even when the content-side thickness is 0 (focused gap). + /// For depth < 3, uses the actual tab thickness. + /// + private static Rectangle ComputeTabContentArea (Rectangle clipped, Rectangle headerRect, Side side, Thickness tabThickness, int depth) + { + // For depth >= 3, always reserve 1 cell on the content side for the closing edge/gap + Thickness effectiveThickness = depth >= 3 + ? side switch + { + Side.Top => tabThickness with { Bottom = 1 }, + Side.Bottom => tabThickness with { Top = 1 }, + Side.Left => tabThickness with { Right = 1 }, + Side.Right => tabThickness with { Left = 1 }, + _ => tabThickness + } + : tabThickness; + + int left = clipped.X + (clipped.X == headerRect.X ? effectiveThickness.Left : 0); + int top = clipped.Y + (clipped.Y == headerRect.Y ? effectiveThickness.Top : 0); + int right = clipped.Right - (clipped.Right == headerRect.Right ? effectiveThickness.Right : 0); + int bottom = clipped.Bottom - (clipped.Bottom == headerRect.Bottom ? effectiveThickness.Bottom : 0); + + int w = right - left; + int h = bottom - top; + + if (w <= 0 || h <= 0) + { + return Rectangle.Empty; + } + + return new Rectangle (left, top, w, h); + } + /// protected override bool OnDrawingContent (DrawContext? context) { @@ -219,6 +748,12 @@ protected override bool OnDrawingContent (DrawContext? context) throw new InvalidOperationException ("Adornment must be of type Border"); } + // Tab mode: completely separate codepath with edge-based border positioning + if (border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return DrawTabBorder (border); + } + Rectangle screenBounds = ViewportToScreen (Viewport); Rectangle borderBounds = GetBorderBounds (); @@ -453,7 +988,6 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } - /// /// /// Gets the screen-coordinate rectangle of the title text from the last draw pass. /// Used by the parent view to build drawn region for transparent border clip exclusion. diff --git a/Terminal.Gui/ViewBase/Adornment/TabTitleView.cs b/Terminal.Gui/ViewBase/Adornment/TabTitleView.cs new file mode 100644 index 0000000000..30dd45e674 --- /dev/null +++ b/Terminal.Gui/ViewBase/Adornment/TabTitleView.cs @@ -0,0 +1,42 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// A lightweight View that renders tab title text using the parent View's focus-appropriate +/// attributes. Because this View never has focus itself, the base +/// would always use Normal/HotNormal. This override uses the owning View's +/// to select Focus/HotFocus when appropriate. +/// +internal sealed class TabTitleView : View +{ + /// The View whose focus state determines which attributes to use. + internal View? OwnerView { get; init; } + + /// Sync to (same as Label). + public override Rune HotKeySpecifier { get => base.HotKeySpecifier; set => TextFormatter.HotKeySpecifier = base.HotKeySpecifier = value; } + + /// + protected override bool OnDrawingText (DrawContext? context) + { + if (Driver is null) + { + return false; + } + + bool ownerHasFocus = OwnerView?.HasFocus ?? false; + + Rectangle drawRect = ViewportToScreen (); + + // Add the entire content area to the drawn region so that it is not transparent + context?.AddDrawnRectangle (drawRect); + + TextFormatter.Draw (Driver, + drawRect, + ownerHasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal), + ownerHasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal), + Rectangle.Empty); + + SetSubViewNeedsDrawDownHierarchy (); + + return true; + } +} diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index d7b4bf3c35..ca5de6bba9 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -137,18 +137,18 @@ public void Draw (DrawContext? context = null) DoDrawContent (context); // ------------------------------------ - // Draw the line canvas - // Restore the clip before rendering the line canvas and adornment subviews - // because they may draw outside the viewport. + // Draw adornment SubViews BEFORE rendering LineCanvas so their lines + // (merged via LineCanvas.Merge) participate in auto-join. + // Restore the clip because adornment subviews may draw outside the viewport. SetClip (originalClip); originalClip = AddFrameToClip (); - Trace.Draw (this.ToIdentifyingString (), "LineCanvas"); - DoRenderLineCanvas (context); + Trace.Draw (this.ToIdentifyingString (), "AdornmentSubViews"); + DoDrawAdornmentsSubViews (context); // ------------------------------------ - // Re-draw the Border and Padding Adornment SubViews - // HACK: This is a hack to ensure that the Border and Padding Adornment SubViews are drawn after the line canvas. - DoDrawAdornmentsSubViews (context); + // Draw the line canvas (includes merged lines from adornment SubViews) + Trace.Draw (this.ToIdentifyingString (), "LineCanvas"); + DoRenderLineCanvas (context); // ------------------------------------ // Advance the diagnostics draw indicator @@ -208,11 +208,26 @@ private void DoDrawAdornmentsSubViews (DrawContext? context) subview.SetNeedsDraw (); } - LineCanvas.Exclude (new Region (subview.FrameToScreen ())); + // Only Exclude SubViews that don't merge their LC into the parent. + // SuperViewRendersLineCanvas SubViews contribute LC lines via Merge, and + // excluding them would prevent those merged lines from rendering. + if (!subview.SuperViewRendersLineCanvas) + { + LineCanvas.Exclude (new Region (subview.FrameToScreen ())); + } } Region? saved = borderView.AddFrameToClip (); borderView.DoDrawSubViews (); + + // Merge any LineCanvas lines from Border's SubViews into this View's LineCanvas. + // This ensures auto-join works between adornment subview borders and the view's own border. + if (borderView.LineCanvas.Bounds != Rectangle.Empty) + { + LineCanvas.Merge (borderView.LineCanvas); + borderView.LineCanvas.Clear (); + } + SetClip (saved); // Track drawn subview areas so DoDrawComplete can exclude them from clip @@ -237,6 +252,16 @@ private void DoDrawAdornmentsSubViews (DrawContext? context) Region? savedPadding = paddingView.AddFrameToClip (); paddingView.DoDrawSubViews (); + + // Merge any LineCanvas lines from Padding's SubViews (e.g., TabView's tab headers) + // into this View's LineCanvas. This ensures auto-join works between adornment subview + // borders and the view's own border. + if (paddingView.LineCanvas.Bounds != Rectangle.Empty) + { + LineCanvas.Merge (paddingView.LineCanvas); + paddingView.LineCanvas.Clear (); + } + SetClip (savedPadding); // Track drawn subview areas for Padding transparency support. @@ -734,24 +759,34 @@ public void DrawSubViews (DrawContext? context = null) return; } + // Track the cumulative drawn region from higher-Z subviews so that when merging + // lower-Z subviews LineCanvas, their lines can be clipped against areas already drawn. + Region? priorDrawnRegion = null; + // Draw the SubViews in reverse Z-order to leverage clipping. // SubViews earlier in the collection are drawn last (on top). foreach (View view in InternalSubViews.Snapshot ().Where (v => v.Visible).Reverse ()) { - // TODO: HACK - This forcing of SetNeedsDraw with SuperViewRendersLineCanvas enables auto line join to work, but is brute force. - if (view.SuperViewRendersLineCanvas || view.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)) - { - //view.SetNeedsDraw (); - } - view.Draw (context); if (!view.SuperViewRendersLineCanvas) { continue; } - LineCanvas.Merge (view.LineCanvas); + + // Merge with clipping: exclude areas already drawn by higher-Z subviews. + // This prevents a lower-Z subview's border lines from rendering where a higher-Z + // subview already drew (e.g., a focused tab's open gap must not be filled by an + // unfocused tab's border). Lines are split at the boundary so auto-join only sees + // the higher-Z subview's lines at those cells. + LineCanvas.Merge (view.LineCanvas, priorDrawnRegion); view.LineCanvas.Clear (); + + // Snapshot the drawn region after this subview for the next iteration. + if (context is { }) + { + priorDrawnRegion = context.GetDrawnRegion (); + } } } @@ -981,12 +1016,6 @@ void cacheAdornmentDrawnRegion (AdornmentImpl adornment, Region? lastLineCanvasR exclusion.Combine (ViewportToScreen (Viewport), RegionOp.Union); } - // Add title rect (drawn directly, not via LineCanvas) to context. - if (Border.View is BorderView { LastTitleRect: { } titleRect }) - { - context?.AddDrawnRectangle (titleRect); - } - // For transparent layers, also include context drawn regions (text, content, subviews) // clipped to the border frame. This ensures transparent view/adornment drawn cells are // excluded from the clip so they don't get overdrawn by the SuperView. From 47ef4fbbca5aa35f2f42c2faa8b7299670949c9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 03:04:54 +0000 Subject: [PATCH 3/3] Add TabCompositionTests covering all 4 sides and thicknesses 1, 2, 3 Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/12d48976-fba9-4cf2-bf13-d6583df6ee01 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/ViewBase/View.Drawing.cs | 2 +- .../ViewBase/Adornment/TabCompositionTests.cs | 643 ++++++++++++++++++ 2 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index ca5de6bba9..9b70f06849 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -760,7 +760,7 @@ public void DrawSubViews (DrawContext? context = null) } // Track the cumulative drawn region from higher-Z subviews so that when merging - // lower-Z subviews LineCanvas, their lines can be clipped against areas already drawn. + // lower-Z subviews' LineCanvas, their lines can be clipped against areas already drawn. Region? priorDrawnRegion = null; // Draw the SubViews in reverse Z-order to leverage clipping. diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs new file mode 100644 index 0000000000..4599e21de8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs @@ -0,0 +1,643 @@ +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Tests that multiple Views with tab-style borders compose correctly when sharing +/// a parent's LineCanvas via . +/// Covers all four sides (Top, Bottom, Left, Right) and tab thicknesses 1, 2, and 3. +/// + +// Copilot +public class TabCompositionTests (ITestOutputHelper output) : TestDriverBase +{ + // ───────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────── + + private static View CreateTabView (IDriver driver, Side side, int thickness, string title, int tabOffset) + { + View view = new () + { + Driver = driver, + CanFocus = true, + SuperViewRendersLineCanvas = true, + BorderStyle = LineStyle.Rounded, + Title = title, + Text = $"{title} content", + Arrangement = ViewArrangement.Overlapped + }; + + view.Border.Thickness = side switch + { + Side.Top => new Thickness (1, thickness, 1, 1), + Side.Bottom => new Thickness (1, 1, 1, thickness), + Side.Left => new Thickness (thickness, 1, 1, 1), + Side.Right => new Thickness (1, 1, thickness, 1), + _ => throw new ArgumentOutOfRangeException (nameof (side)) + }; + + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + view.Border.TabSide = side; + view.Border.TabOffset = tabOffset; + + // Fix the dimension perpendicular to the tab edge so that all tab views + // share the same extent and composition tests produce stable output. + if (side is Side.Top or Side.Bottom) + { + view.Width = 12; + view.Height = Dim.Auto (); + } + else + { + view.Width = Dim.Auto (); + view.Height = 6; + } + + return view; + } + + private void DrawAndAssert (View view, IDriver driver, string expected) + { + view.Layout (); + view.Draw (); + DriverAssert.AssertDriverContentsAre (expected, output, driver); + view.Dispose (); + } + + // ═════════════════════════════════════════════════════════════════════ + // Side.Top — composition (kept from original PR tests) + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public void Top_Thickness3_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 3, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─╮ ╭─╮ + │A│ │B│ + │ ╰─┴─┴────╮ + │A content │ + ╰──────────╯ + """); + } + + [Fact] + public void Top_Thickness3_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 3, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─╮ ╭─╮ + │A│ │B│ + ├─┴─╯ ╰────╮ + │B content │ + ╰──────────╯ + """); + } + + // ───────────────────────────────────────────────────────────────────── + // Side.Top — thickness 1 + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Top_Thickness1_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 1, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + │A╭─┬─┬────╮ + │A content │ + ╰──────────╯ + """); + } + + [Fact] + public void Top_Thickness1_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 1, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─┬─╮B╭────╮ + │B content │ + ╰──────────╯ + """); + } + + // ───────────────────────────────────────────────────────────────────── + // Side.Top — thickness 2 + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Top_Thickness2_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 2, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─╮ ╭─╮ + │A╰─┴─┴────╮ + │A content │ + ╰──────────╯ + """); + } + + [Fact] + public void Top_Thickness2_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Top, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Top, 2, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─╮ ╭─╮ + ├─┴─╯B╰────╮ + │B content │ + ╰──────────╯ + """); + } + + // ═════════════════════════════════════════════════════════════════════ + // Side.Bottom — thickness 1, 2, 3 + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public void Bottom_Thickness1_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 1, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │A content │ + │A╭─┬─┬────╯ + """); + } + + [Fact] + public void Bottom_Thickness1_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 1, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │B content │ + ├─┬─╮B╭────╯ + """); + } + + [Fact] + public void Bottom_Thickness2_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 2, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │A content │ + │A╭─┬─┬────╯ + ╰─╯ ╰─╯ + """); + } + + [Fact] + public void Bottom_Thickness2_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 2, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │B content │ + ├─┬─╮B╭────╯ + ╰─╯ ╰─╯ + """); + } + + [Fact] + public void Bottom_Thickness3_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 3, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │A content │ + │A╭─┬─┬────╯ + │ │ │B│ + ╰─╯ ╰─╯ + """); + } + + [Fact] + public void Bottom_Thickness3_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Bottom, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Bottom, 3, "B", 4); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │B content │ + ├─┬─╮B╭────╯ + │A│ │ │ + ╰─╯ ╰─╯ + """); + } + + // ═════════════════════════════════════════════════════════════════════ + // Side.Left — thickness 1, 2, 3 + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public void Left_Thickness1_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 1, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ──────────╮ + AA content│ + │ │ + │ │ + │ │ + ╰─────────╯ + """); + } + + [Fact] + public void Left_Thickness1_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 1, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─────────╮ + │B content│ + │ │ + │ │ + B │ + ──────────╯ + """); + } + + [Fact] + public void Left_Thickness2_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 2, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │AA content│ + ╰╮ │ + ╭┤ │ + ││ │ + ╰┴─────────╯ + """); + } + + [Fact] + public void Left_Thickness2_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 2, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭┬─────────╮ + ││B content│ + ╰┤ │ + ╭╯ │ + │B │ + ╰──────────╯ + """); + } + + [Fact] + public void Left_Thickness3_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 3, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭───────────╮ + │A A content│ + ╰─╮ │ + ╭─┤ │ + │B│ │ + ╰─┴─────────╯ + """); + } + + [Fact] + public void Left_Thickness3_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Left, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Left, 3, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─┬─────────╮ + │A│B content│ + ╰─┤ │ + ╭─╯ │ + │B │ + ╰───────────╯ + """); + } + + // ═════════════════════════════════════════════════════════════════════ + // Side.Right — thickness 1, 2, 3 + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public void Right_Thickness1_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 1, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────────── + │A contentA + │ │ + │ │ + │ │ + ╰─────────╯ + """); + } + + [Fact] + public void Right_Thickness1_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 1, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 1, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─────────╮ + │B content│ + │ │ + │ │ + │ B + ╰────────── + """); + } + + [Fact] + public void Right_Thickness2_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 2, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────╮ + │A contentA│ + │ ╭╯ + │ ├╮ + │ ││ + ╰─────────┴╯ + """); + } + + [Fact] + public void Right_Thickness2_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 2, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 2, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─────────┬╮ + │B content││ + │ ├╯ + │ ╰╮ + │ B│ + ╰──────────╯ + """); + } + + [Fact] + public void Right_Thickness3_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 3, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭───────────╮ + │A contentA │ + │ ╭─╯ + │ ├─╮ + │ │B│ + ╰─────────┴─╯ + """); + } + + [Fact] + public void Right_Thickness3_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (25, 12); + + View tab1 = CreateTabView (driver, Side.Right, 3, "A", 0); + View tab2 = CreateTabView (driver, Side.Right, 3, "B", 3); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─────────┬─╮ + │B content│A│ + │ ├─╯ + │ ╰─╮ + │ B │ + ╰───────────╯ + """); + } +}