diff --git a/AGENTS.md b/AGENTS.md index 6ff06344aa..e4152dac92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,24 @@ dotnet run **Test:** `dotnet test --project Tests/UnitTests --no-build && dotnet test --project Tests/UnitTestsParallelizable --no-build` **Details:** [Build & Test Workflow](.claude/workflows/build-test-workflow.md) +### xUnit v3 Test Filtering (Microsoft Testing Platform) + +This project uses **xUnit v3** with Microsoft Testing Platform. The old `--filter "FullyQualifiedName~Foo"` syntax does **NOT** work. Use these instead: + +```bash +# Run a single test by method name +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter-method "*MyTestMethod" + +# Run all tests in a class +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter-class "*MyTestClass" + +# Query filter language (xUnit v3 native): //// +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter "/*/*/MyTestClass/MyTestMethod" + +# Show live test output (ITestOutputHelper) +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter-method "*MyTest" -- --show-live-output on +``` + ## Quick Rules **⚠️ READ THIS BEFORE MODIFYING ANY FILE - These are Terminal.Gui-specific conventions:** @@ -68,7 +86,7 @@ dotnet run 3. **Use `[...]`** - Collection expressions, not `new () { ... }` 4. **SubView/SuperView** - Never say "child", "parent", or "container" 5. **Unused lambda params** - Use `_` discard: `(_, _) => { }` -6. **Local functions** - Use camelCase: `void myLocalFunc ()` +6. **Local functions** - Use PascalCase: `void MyLocalFunc ()` 7. **Backing fields** - Place immediately before their property 8. **Early return** - Prefer guard clauses over nested `if`/`else` 9. **One type per file** - Public and internal types each get their own file diff --git a/Examples/UICatalog/Properties/launchSettings.json b/Examples/UICatalog/Properties/launchSettings.json index 04953ab54a..e6a0b0275e 100644 --- a/Examples/UICatalog/Properties/launchSettings.json +++ b/Examples/UICatalog/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "UICatalog": { "commandName": "Project", - "commandLineArgs": "--debug-log-level Debug" + "commandLineArgs": "--debug-log-level Trace" }, "UICatalog --driver windows": { "commandName": "Project", diff --git a/Examples/UICatalog/Scenarios/Adornments.cs b/Examples/UICatalog/Scenarios/Adornments.cs index 8e4a616e53..324ec862d4 100644 --- a/Examples/UICatalog/Scenarios/Adornments.cs +++ b/Examples/UICatalog/Scenarios/Adornments.cs @@ -50,10 +50,12 @@ public override void Main () Window window = new () { - Title = "The _Window", + Title = "The _Window - The Title is long", Arrangement = ViewArrangement.Overlapped | ViewArrangement.Movable | ViewArrangement.Resizable, - Width = Dim.Fill (adornmentsEditor), - Height = Dim.Fill (viewportSettingsEditor) + X = 5, + Y = 5, + Width = Dim.Fill (adornmentsEditor) - 10, + Height = Dim.Fill (viewportSettingsEditor) - 10 }; appWindow.Add (window); @@ -100,14 +102,80 @@ public override void Main () window.Margin.GetOrCreateView (); window.Margin.View?.Text = "Margin Text"; - window.Margin.Thickness = new Thickness (2); + window.Margin.Thickness = new Thickness (0); window.Border.GetOrCreateView (); + //window.Border.View.Text = "Border Text"; window.Border.Thickness = new Thickness (3); - window.Border.View?.SetScheme (SchemeManager.GetScheme (Schemes.Dialog)); - window.Border.GetOrCreateView (); + //window.Border.View?.SetScheme (SchemeManager.GetScheme (Schemes.Dialog)); + + window.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + window.BorderStyle = LineStyle.Rounded; + + // Enable dragging the tab title to slide it by changing TabOffset. + // Hook into TitleView.MouseEvent — it fires before the Arranger, so no conflict. + Point? dragStart = null; + var dragStartOffset = 0; + var tabDragHooked = false; + + window.DrawComplete += (_, _) => + { + if (tabDragHooked || ((BorderView)window.Border.View!).TitleView is not { } tabTitle) + { + return; + } + + tabDragHooked = true; + var bv = (BorderView)window.Border.View!; + + tabTitle.MouseEvent += (_, mouse) => + { + // Start drag + if (!dragStart.HasValue && mouse.Flags.HasFlag (MouseFlags.LeftButtonPressed)) + { + dragStart = mouse.ScreenPosition; + dragStartOffset = bv.TabOffset; + window.App?.Mouse.GrabMouse (tabTitle); + mouse.Handled = true; + } + + // Dragging + if (dragStart.HasValue + && mouse.Flags is (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) + { + int delta = bv.TabSide is Side.Top or Side.Bottom + ? mouse.ScreenPosition.X - dragStart.Value.X + : mouse.ScreenPosition.Y - dragStart.Value.Y; + + int tabLen = bv.TabLength ?? 0; + + // Content border edge length (the side the tab slides along) + int edgeLen = bv.TabSide is Side.Top or Side.Bottom + ? window.Frame.Width + - window.Border.Thickness.Left + - window.Border.Thickness.Right + : window.Frame.Height + - window.Border.Thickness.Top + - window.Border.Thickness.Bottom; + + int newOffset = Math.Clamp (dragStartOffset + delta, 1 - tabLen, edgeLen - 1); + + bv.TabOffset = newOffset; + mouse.Handled = true; + } + + // Release + if (!mouse.Flags.HasFlag (MouseFlags.LeftButtonReleased) || !dragStart.HasValue) + { + return; + } + dragStart = null; + window.App?.Mouse.UngrabMouse (); + mouse.Handled = true; + }; + }; window.Padding.View?.Text = "Padding Text line 1\nPadding Text line 3\nPadding Text line 3\nPadding Text line 4\nPadding Text line 5"; window.Padding.Thickness = new Thickness (1); window.Padding.View?.SetScheme (SchemeManager.GetScheme (Schemes.Menu)); @@ -180,6 +248,12 @@ public override void Main () appWindow.Add (adornmentsEditor, viewportSettingsEditor); + appWindow.DrawingContent += (_, e) => + { + appWindow.FillRect (appWindow.Viewport, Glyphs.Diamond); + e.Cancel = true; + }; + app.Run (appWindow); } } diff --git a/Examples/UICatalog/Scenarios/AnsiRequestsScenario.cs b/Examples/UICatalog/Scenarios/AnsiRequestsScenario.cs index 5839a2ea68..60bf0227c0 100644 --- a/Examples/UICatalog/Scenarios/AnsiRequestsScenario.cs +++ b/Examples/UICatalog/Scenarios/AnsiRequestsScenario.cs @@ -31,29 +31,38 @@ public override void Main () app.Init (); _app = app; - TabView tv = new () { Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true }; - - Tab single = new (); - single.DisplayText = "Single"; - single.View = BuildSingleTab (); - - Tab bulk = new (); - bulk.DisplayText = "Multi"; - bulk.View = BuildBulkTab (); - - tv.AddTab (single, true); - tv.AddTab (bulk, false); + // Restored: Tabs with Single and Bulk tabs (#4183) + View singleView = BuildSingleTab (); + View bulkView = BuildBulkTab (); // Setup - Create a top-level application window and configure it. using Window appWindow = new (); appWindow.Title = GetQuitKeyAndName (); - appWindow.Add (tv); + Tabs tabs = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + View singleTab = new () { Title = "_Single" }; + singleView.Width = Dim.Fill (); + singleView.Height = Dim.Fill (); + singleTab.Add (singleView); + + View bulkTab = new () { Title = "_Bulk" }; + bulkView.Width = Dim.Fill (); + bulkView.Height = Dim.Fill (); + bulkTab.Add (bulkView); + + tabs.Add (singleTab, bulkTab); + tabs.Value = singleTab; + + appWindow.Add (tabs); // Run - Start the application. app.Run (appWindow); - bulk.View.Dispose (); - single.View.Dispose (); + tabs.Dispose (); _app.RemoveTimeout (_updateTimeoutToken!); _app.RemoveTimeout (_sendDarTimeoutToken!); diff --git a/Examples/UICatalog/Scenarios/Arrangement.cs b/Examples/UICatalog/Scenarios/Arrangement.cs index 666d02c116..d53ec89bd1 100644 --- a/Examples/UICatalog/Scenarios/Arrangement.cs +++ b/Examples/UICatalog/Scenarios/Arrangement.cs @@ -18,6 +18,7 @@ public override void Main () app.Init (); using Window mainWindow = new (); + mainWindow.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog); mainWindow.Title = GetQuitKeyAndName (); mainWindow.TabStop = TabBehavior.TabGroup; mainWindow.ShadowStyle = null; diff --git a/Examples/UICatalog/Scenarios/ConfigurationEditor.cs b/Examples/UICatalog/Scenarios/ConfigurationEditor.cs index 310c7fbcdd..e0942a481c 100644 --- a/Examples/UICatalog/Scenarios/ConfigurationEditor.cs +++ b/Examples/UICatalog/Scenarios/ConfigurationEditor.cs @@ -4,14 +4,14 @@ namespace UICatalog.Scenarios; [ScenarioMetadata ("Configuration Editor", "Edits of Terminal.Gui Config Files")] -[ScenarioCategory ("TabView")] +[ScenarioCategory ("Tabs")] [ScenarioCategory ("Colors")] [ScenarioCategory ("Files and IO")] [ScenarioCategory ("TextView")] [ScenarioCategory ("Configuration")] public class ConfigurationEditor : Scenario { - private TabView? _tabView; + private Tabs? _tabs; private Shortcut? _lenShortcut; private IApplication? _app; @@ -41,14 +41,12 @@ public override void Main () StatusBar statusBar = new ([quitShortcut, reloadShortcut, saveShortcut, _lenShortcut]); - _tabView = new TabView { Width = Dim.Fill (), Height = Dim.Fill (to: statusBar) }; + _tabs = new Tabs { Width = Dim.Fill (), Height = Dim.Fill (statusBar) }; - win.Add (_tabView, statusBar); - - win.IsModalChanged += (_, _) => { Open (); }; + win.Add (_tabs, statusBar); ConfigurationManager.Applied += ConfigurationManagerOnApplied; - + Open (); app.Run (win); return; @@ -84,40 +82,43 @@ private void Open () editor.Title = "HardCoded"; } - Tab tab = new () { View = editor, DisplayText = config.Key.ToString () }; + View tab = new () { Title = config.Key.ToString () }; + tab.Add (editor); - _tabView!.AddTab (tab, false); + _tabs?.Add (tab); editor.Read (); - editor.ContentsChanged += (_, _) => - { - _lenShortcut!.Title = _lenShortcut!.Title.Replace ("*", ""); - - if (editor.IsDirty) - { - _lenShortcut!.Title += "*"; - } - }; + //editor.ContentsChanged += OnEditorOnContentsChanged; - _lenShortcut!.Title = $"{editor.Title}"; + _lenShortcut?.Title = $"{editor.Title}"; } - _tabView!.SelectedTabChanged += (_, args) => { _lenShortcut!.Title = $"{args.NewTab.View!.Title}"; }; + _tabs?.ValueChanged += (_, args) => + { + ConfigTextView? editor = args.NewValue?.SubViews.OfType ().FirstOrDefault (); + + if (editor is { }) + { + _lenShortcut!.Title = $"{editor.Title}"; + } + }; + } + + private void OnEditorOnContentsChanged (object? o, ContentsChangedEventArgs contentsChangedEventArgs) + { + var editor = (ConfigTextView)o!; + _lenShortcut?.Title = _lenShortcut.Title.Replace ("*", ""); + + if (editor.IsDirty) + { + _lenShortcut?.Title += "*"; + } } private void Quit () { - foreach (ConfigTextView editor in _tabView!.Tabs.Select (v => - { - if (v.View is ConfigTextView ctv) - { - return ctv; - } - - return null; - }) - .Cast ()) + foreach (ConfigTextView editor in _tabs?.TabCollection.SelectMany (t => t.SubViews.OfType ()) ?? []) { if (!editor.IsDirty) { @@ -139,7 +140,7 @@ private void Quit () } } - _tabView?.App?.RequestStop (); + _tabs?.App?.RequestStop (); } private void Reload () diff --git a/Examples/UICatalog/Scenarios/Editor.cs b/Examples/UICatalog/Scenarios/Editor.cs index fd8bfe7556..2eaf82f423 100644 --- a/Examples/UICatalog/Scenarios/Editor.cs +++ b/Examples/UICatalog/Scenarios/Editor.cs @@ -27,7 +27,6 @@ public class Editor : Scenario private CheckBox? _miForceMinimumPosToZeroCheckBox; private byte []? _originalText; private bool _saved = true; - private TabView? _tabView; private string _textToFind = string.Empty; private string _textToReplace = string.Empty; private TextView? _textView; @@ -935,16 +934,14 @@ private void SetFindText () private void ShowFindReplace (bool isFind = true) { - if (_findReplaceWindow is null || _tabView is null) + if (_findReplaceWindow is null) { return; } _findReplaceWindow.Visible = true; _findReplaceWindow.SuperView?.MoveSubViewToStart (_findReplaceWindow); - _tabView.SetFocus (); - _tabView.SelectedTab = isFind ? _tabView.Tabs.ToArray () [0] : _tabView.Tabs.ToArray () [1]; - _tabView.SelectedTab?.View?.FocusDeepest (NavigationDirection.Forward, null); + _findReplaceWindow.FocusDeepest (NavigationDirection.Forward, null); } private void CreateFindReplace () @@ -956,14 +953,29 @@ private void CreateFindReplace () _findReplaceWindow = new FindReplaceWindow (_textView); - _tabView = new TabView { X = 0, Y = 0, Width = Dim.Fill (), Height = Dim.Fill (0) }; + // Restored: Tabs with Find and Replace tabs (#4183) + Tabs tabs = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + View findTab = new () { Title = "_Find" }; + View findView = CreateFindTab (); + findView.Width = Dim.Fill (); + findView.Height = Dim.Fill (); + findTab.Add (findView); - _tabView.AddTab (new Tab { DisplayText = "Find", View = CreateFindTab () }, true); - _tabView.AddTab (new Tab { DisplayText = "Replace", View = CreateReplaceTab () }, false); + View replaceTab = new () { Title = "_Replace" }; + View replaceView = CreateReplaceTab (); + replaceView.Width = Dim.Fill (); + replaceView.Height = Dim.Fill (); + replaceTab.Add (replaceView); - _tabView.SelectedTabChanged += (s, e) => { _tabView.SelectedTab?.View?.FocusDeepest (NavigationDirection.Forward, null); }; + tabs.Add (findTab, replaceTab); + tabs.Value = findTab; - _findReplaceWindow.Add (_tabView); + _findReplaceWindow.Add (tabs); _findReplaceWindow.Visible = false; _appWindow.Add (_findReplaceWindow); } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs index 9fcd6eb136..eb81cc8a48 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AdornmentsEditor.cs @@ -15,8 +15,18 @@ public AdornmentsEditor () Initialized += AdornmentsEditor_Initialized; SchemeName = "Dialog"; + + Add (_tabs); + + Height = Dim.Auto (); + Width = Dim.Auto (); } + private Tabs _tabs = new Tabs () + { + TabSide = Side.Left, + }; + public MarginEditor? MarginEditor { get; set; } public BorderEditor? BorderEditor { get; private set; } public PaddingEditor? PaddingEditor { get; private set; } @@ -36,29 +46,37 @@ private void AdornmentsEditor_Initialized (object? sender, EventArgs e) { ExpanderButton?.Orientation = Orientation.Horizontal; - MarginEditor = new MarginEditor { X = -1, Y = 0, SuperViewRendersLineCanvas = true, BorderStyle = BorderStyle }; - MarginEditor.Border.Thickness = MarginEditor.Border.Thickness with { Bottom = 0 }; - Add (MarginEditor); - - BorderEditor = new BorderEditor - { - X = Pos.Left (MarginEditor), Y = Pos.Bottom (MarginEditor), SuperViewRendersLineCanvas = true, BorderStyle = BorderStyle - }; - BorderEditor.Border.Thickness = BorderEditor.Border.Thickness with { Bottom = 0 }; - Add (BorderEditor); - - PaddingEditor = new PaddingEditor - { - X = Pos.Left (BorderEditor), Y = Pos.Bottom (BorderEditor), SuperViewRendersLineCanvas = true, BorderStyle = BorderStyle - }; - PaddingEditor.Border.Thickness = PaddingEditor.Border.Thickness with { Bottom = 0 }; - Add (PaddingEditor); - - Width = Dim.Auto (maximumContentDim: Dim.Func (_ => MarginEditor.Frame.Width - 2)); - - MarginEditor.ExpanderButton!.Collapsed = true; - BorderEditor.ExpanderButton!.Collapsed = true; - PaddingEditor.ExpanderButton!.Collapsed = true; + MarginEditor = new MarginEditor { }; + _tabs.Add (MarginEditor); + + BorderEditor = new BorderEditor { }; + _tabs.Add (BorderEditor); + + PaddingEditor = new PaddingEditor { }; + _tabs.Add (PaddingEditor); + + // Set all tabs to Dim.Auto + MarginEditor.Width = Dim.Auto (); + BorderEditor.Width = Dim.Auto (); + PaddingEditor.Width = Dim.Auto (); + MarginEditor.Height = Dim.Auto (); + BorderEditor.Height = Dim.Auto (); + PaddingEditor.Height = Dim.Auto (); + Layout (); + + // Get the largest + int max = new [] { MarginEditor.Frame.Width, BorderEditor.Frame.Width, PaddingEditor.Frame.Width }.Max (); + _tabs.Width = Dim.Auto (minimumContentDim: max); + + max = new [] { MarginEditor.Frame.Height, BorderEditor.Frame.Height, PaddingEditor.Frame.Height }.Max (); + _tabs.Height = Dim.Auto (minimumContentDim: max); + + MarginEditor.Width = Dim.Fill (); + BorderEditor.Width = Dim.Fill (); + PaddingEditor.Width = Dim.Fill (); + MarginEditor.Height = Dim.Fill (); + BorderEditor.Height = Dim.Fill (); + PaddingEditor.Height = Dim.Fill (); MarginEditor.AdornmentToEdit = ViewToEdit?.Margin ?? null; BorderEditor.AdornmentToEdit = ViewToEdit?.Border ?? null; diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs index 14527d832b..debbe12734 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs @@ -1,11 +1,14 @@ -#nullable enable +#nullable enable +using System.Collections.ObjectModel; + namespace UICatalog.Scenarios; public class BorderEditor : AdornmentEditor { - private CheckBox? _ckbTitle; - private OptionSelector? _osBorderStyle; - private CheckBox? _ckbGradient; + private DropDownList? _osBorderStyle; + private FlagSelector? _osBorderSettings; + private OptionSelector? _osTabSide; + private NumericUpDown? _nudTabOffset; public BorderEditor () { @@ -16,105 +19,139 @@ public BorderEditor () private void BorderEditor_AdornmentChanged (object? sender, EventArgs e) { - _ckbTitle!.Value = ((Border)AdornmentToEdit!).Settings.FastHasFlags (BorderSettings.Title) ? CheckState.Checked : CheckState.UnChecked; - _osBorderStyle!.Value = ((Border)AdornmentToEdit).LineStyle; - _ckbGradient!.Value = ((Border)AdornmentToEdit).Settings.FastHasFlags (BorderSettings.Gradient) ? CheckState.Checked : CheckState.UnChecked; + if (AdornmentToEdit is null) + { + return; + } + _osBorderStyle?.Value = ((Border)AdornmentToEdit).LineStyle.ToString (); + _osBorderSettings?.Value = ((Border)AdornmentToEdit).Settings; + + if (AdornmentToEdit.View is null) + { + return; + } + _osTabSide?.Value = (AdornmentToEdit.View as BorderView)?.TabSide; + + if (AdornmentToEdit.View is BorderView bv) + { + _nudTabOffset?.Value = bv.TabOffset; + } } private void BorderEditor_Initialized (object? sender, EventArgs e) { - _osBorderStyle = new OptionSelector + _osBorderStyle = new DropDownList { - X = 0, Y = Pos.Bottom (SubViews.ToArray () [^1]), - Width = Dim.Fill (), - Value = (AdornmentToEdit as Border)?.LineStyle ?? LineStyle.None, + Width = 10, + Source = new ListWrapper (new ObservableCollection (Enum.GetNames ())), + Value = $"{(AdornmentToEdit as Border)?.LineStyle ?? LineStyle.None}", BorderStyle = LineStyle.Single, - Title = "Border St_yle", - SuperViewRendersLineCanvas = true + Title = "St_yle" }; Add (_osBorderStyle); _osBorderStyle.ValueChanged += OnRbBorderStyleOnValueChanged; - _ckbTitle = new CheckBox + _osBorderSettings = new FlagSelector { - X = 0, Y = Pos.Bottom (_osBorderStyle), - Value = CheckState.Checked, - SuperViewRendersLineCanvas = true, - Text = "Title" + Value = (AdornmentToEdit as Border)?.Settings ?? BorderSettings.Default, + Width = Dim.Auto (), + BorderStyle = LineStyle.Single, + Title = "S_ettings" + }; + + Add (_osBorderSettings); + + _osBorderSettings.ValueChanged += OnRbBorderSettingsOnValueChanged; + + _osTabSide = new OptionSelector + { + Y = Pos.Bottom (_osBorderSettings), + Width = Dim.Width (_osBorderStyle), + Value = (AdornmentToEdit?.View as BorderView)?.TabSide ?? Side.Top, + BorderStyle = LineStyle.Single, + Title = "_Side", + Enabled = (AdornmentToEdit as Border)?.Settings.HasFlag (BorderSettings.Tab) ?? false }; - _ckbTitle.ValueChanging += OnCkbTitleOnToggle; - Add (_ckbTitle); + _osTabSide.ValueChanged += OnHeaderSideChanged; + Add (_osTabSide); - _ckbGradient = new CheckBox + Label labelOffset = new () { Title = "Tab _Offset:", Y = Pos.Bottom (_osTabSide) }; + + _nudTabOffset = new NumericUpDown { - X = 0, - Y = Pos.Bottom (_ckbTitle), - Value = CheckState.Checked, - SuperViewRendersLineCanvas = true, - Text = "Gradient" + X = Pos.Right (labelOffset) + 1, + Y = Pos.Top (labelOffset), + Value = (AdornmentToEdit?.View as BorderView)?.TabOffset ?? 0, + Enabled = (AdornmentToEdit as Border)?.Settings.HasFlag (BorderSettings.Tab) ?? false }; - _ckbGradient.ValueChanging += OnCkbGradientOnToggle; - Add (_ckbGradient); + _nudTabOffset.ValueChanged += OnHeaderOffsetChanged; + Add (labelOffset, _nudTabOffset); return; - void OnRbBorderStyleOnValueChanged (object? s, EventArgs args) + void OnRbBorderStyleOnValueChanged (object? s, ValueChangedEventArgs args) { if (AdornmentToEdit is not Border border) { return; } - if (args.Value is { }) + if (args.NewValue is { }) { - border.LineStyle = (LineStyle)args.Value; + if (Enum.TryParse (args.NewValue, out LineStyle style)) + { + border.LineStyle = style; + } } border.View?.SetNeedsDraw (); SetNeedsLayout (); } - void OnCkbTitleOnToggle (object? _, ValueChangingEventArgs args) + void OnRbBorderSettingsOnValueChanged (object? s, EventArgs args) { if (AdornmentToEdit is not Border border) { return; } - if (args.NewValue == CheckState.Checked) - + if (args.Value is { }) { - border.Settings |= BorderSettings.Title; + border.Settings = (BorderSettings)args.Value; } - else - { - border.Settings &= ~BorderSettings.Title; - } + _nudTabOffset!.Enabled = border.Settings.HasFlag (BorderSettings.Tab); + _osTabSide!.Enabled = border.Settings.HasFlag (BorderSettings.Tab); + + border.View?.SetNeedsDraw (); + SetNeedsLayout (); } - void OnCkbGradientOnToggle (object? _, ValueChangingEventArgs args) + void OnHeaderSideChanged (object? _, EventArgs args) { - if (AdornmentToEdit is not Border border) + if (AdornmentToEdit is not Border border || args.Value is null) { return; } - if (args.NewValue == CheckState.Checked) + ((BorderView)border.View!).TabSide = args.Value.Value; + border.View?.SetNeedsLayout (); + } + void OnHeaderOffsetChanged (object? _, ValueChangedEventArgs args) + { + if (AdornmentToEdit is not Border border) { - border.Settings |= BorderSettings.Gradient; + return; } - else - { - border.Settings &= ~BorderSettings.Gradient; - } + ((BorderView)border.View!).TabOffset = args.NewValue; + border.View?.SetNeedsLayout (); } } } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs index 54fcb2ff2f..b3a41d137c 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/EditorBase.cs @@ -144,12 +144,17 @@ private void NavigationOnFocusedChanged (object? sender, EventArgs e) return; } + if (!AutoSelectViewToEdit || !AutoSelectAdornments) + { + return; + } + ViewToEdit = App!.Navigation!.GetFocused (); } private void ApplicationOnMouseEvent (object? sender, Mouse mouse) { - if (mouse.Flags != MouseFlags.LeftButtonClicked || !AutoSelectViewToEdit) + if (mouse.Flags != MouseFlags.LeftButtonClicked || !AutoSelectViewToEdit || !AutoSelectAdornments) { return; } @@ -167,7 +172,7 @@ private void ApplicationOnMouseEvent (object? sender, Mouse mouse) return; } - if (view is AdornmentView adornment) + if (AutoSelectAdornments && view is AdornmentView adornment) { ViewToEdit = AutoSelectAdornments ? adornment : adornment.Adornment?.Parent; } diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs index 42a0b0efae..e51978a2c9 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/MarginEditor.cs @@ -24,22 +24,23 @@ private void MarginEditor_AdornmentChanged (object? sender, EventArgs e) { if (AdornmentToEdit is { }) { - _optionsShadow!.Value = ShadowStyleToInt (((Margin)AdornmentToEdit).ShadowStyle); + _optionsShadow?.Value = ShadowStyleToInt (((Margin)AdornmentToEdit).ShadowStyle); } if (AdornmentToEdit is { }) { - _flagSelectorTransparent!.Value = (int)((Margin)AdornmentToEdit).ViewportSettings; + _flagSelectorTransparent?.Value = (int)((Margin)AdornmentToEdit).ViewportSettings; } } private void MarginEditor_Initialized (object? sender, EventArgs e) { + ExpanderButton?.Collapsed = false; + _optionsShadow = new OptionSelector { X = 0, Y = Pos.Bottom (SubViews.ElementAt (SubViews.Count - 1)), - SuperViewRendersLineCanvas = true, Title = "_Shadow", BorderStyle = LineStyle.Single, AssignHotKeys = true, @@ -58,11 +59,7 @@ private void MarginEditor_Initialized (object? sender, EventArgs e) _flagSelectorTransparent = new FlagSelector { - X = 0, - Y = Pos.Bottom (_optionsShadow), - SuperViewRendersLineCanvas = true, - Title = "_ViewportSettings", - BorderStyle = LineStyle.Single + X = 0, Y = Pos.Bottom (_optionsShadow), Title = "_ViewportSettings", BorderStyle = LineStyle.Single }; _flagSelectorTransparent.Values = [(int)ViewportSettingsFlags.Transparent, (int)ViewportSettingsFlags.TransparentMouse]; _flagSelectorTransparent.Labels = ["Transparent", "TransparentMouse"]; diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs index 279efb98c0..fe52bbc72a 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/ViewportSettingsEditor.cs @@ -67,6 +67,7 @@ protected override void OnViewToEditChanged () _cbClearContentOnly?.Value = ViewToEdit!.ViewportSettings.HasFlag (ViewportSettingsFlags.ClearContentOnly) ? CheckState.Checked : CheckState.UnChecked; _cbClipContentOnly?.Value = ViewToEdit!.ViewportSettings.HasFlag (ViewportSettingsFlags.ClipContentOnly) ? CheckState.Checked : CheckState.UnChecked; + _cbTransparent?.Value = ViewToEdit!.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) ? CheckState.Checked : CheckState.UnChecked; _cbTransparentMouse?.Value = ViewToEdit!.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse) ? CheckState.Checked : CheckState.UnChecked; diff --git a/Examples/UICatalog/Scenarios/GraphViewExample.cs b/Examples/UICatalog/Scenarios/GraphViewExample.cs index 4e39c802f7..1f96998021 100644 --- a/Examples/UICatalog/Scenarios/GraphViewExample.cs +++ b/Examples/UICatalog/Scenarios/GraphViewExample.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Text; @@ -7,17 +7,21 @@ namespace UICatalog.Scenarios; [ScenarioMetadata ("Graph View", "Demos the GraphView control.")] [ScenarioCategory ("Controls")] [ScenarioCategory ("Drawing")] +[ScenarioCategory ("Tabs")] public class GraphViewExample : Scenario { private readonly Thickness _thickness = new (1, 1, 1, 1); - private TextView? _about; - private int _currentGraph; - private Action []? _graphs; - private GraphView? _graphView; private CheckBox? _diagCheckBox; private CheckBox? _showBorderCheckBox; private ViewDiagnosticFlags _viewDiagnostics; private IApplication? _app; + private Tabs? _tabs; + private FrameView? _aboutTextView; + + /// + /// Gets the from the currently selected tab. + /// + private GraphView? CurrentGraphView => _tabs?.Value?.SubViews.OfType ().FirstOrDefault (); public override void Main () { @@ -29,200 +33,113 @@ public override void Main () using Window window = new (); window.BorderStyle = LineStyle.None; - _graphs = - [ - SetupPeriodicTableScatterPlot, - () => SetupLifeExpectancyBarGraph (true), - () => SetupLifeExpectancyBarGraph (false), - SetupPopulationPyramid, - SetupLineGraph, - SetupSineWave, - SetupDisco, - MultiBarGraph - ]; - // MenuBar MenuBar menu = new (); - // GraphView - _graphView = new () - { - X = 0, - Y = Pos.Bottom (menu), - Width = Dim.Percent (70), - Height = Dim.Fill (1), - BorderStyle = LineStyle.Single - }; - _graphView.Border.Thickness = _thickness; - _graphView.Margin.Thickness = _thickness; - _graphView.Padding.Thickness = _thickness; + // Tabs + _tabs = new Tabs { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill () }; - // About TextView - FrameView frameRight = new () - { - X = Pos.Right (_graphView), - Y = Pos.Top (_graphView), - Width = Dim.Fill (), - Height = Dim.Height (_graphView), - Title = "About" - }; + // Create tabs for each graph type + CreateGraphTab ("Scatter _Plot", "Scatter Plot", SetupPeriodicTableScatterPlot); + CreateGraphTab ("_Vertical Bar", "Vertical Bar Graph", gv => SetupLifeExpectancyBarGraph (gv, true)); + CreateGraphTab ("_Horizontal Bar", "Horizontal Bar Graph", gv => SetupLifeExpectancyBarGraph (gv, false)); + CreateGraphTab ("P_yramid", "Population Pyramid", SetupPopulationPyramid); + CreateGraphTab ("_Line", "Line Graph", SetupLineGraph); + CreateGraphTab ("Sine _Wave", "Sine Wave", SetupSineWave); + CreateGraphTab ("_Disco", "Graphic Equalizer", SetupDisco); + CreateGraphTab ("_Multi Bar", "Multi Bar", MultiBarGraph); + + _tabs.Value = _tabs.SubViews.ElementAt (0); - _about = new () + // About + _aboutTextView = new FrameView { + Title = "About", + X = 0, + Y = Pos.AnchorEnd () - 1, + Height = 5, Width = Dim.Fill (), - Height = Dim.Fill (), - ReadOnly = true + BorderStyle = LineStyle.Dotted }; - frameRight.Add (_about); + + _tabs.Height = Dim.Fill (_aboutTextView); + + _tabs.ValueChanged += (_, args) => { _aboutTextView.Text = args.NewValue?.SubViews.OfType ().FirstOrDefault ()?.Text ?? string.Empty; }; // StatusBar - StatusBar statusBar = new ( - [ - new (Key.G.WithCtrl, "Next Graph", () => _graphs! [_currentGraph++ % _graphs.Length] ()), - new (Key.PageUp, "Zoom In", () => Zoom (0.5f)), - new (Key.PageDown, "Zoom Out", () => Zoom (2f)) - ] - ); + StatusBar statusBar = new ([new Shortcut (Key.PageUp, "Zoom In", () => Zoom (0.5f)), new Shortcut (Key.PageDown, "Zoom Out", () => Zoom (2f))]); - Shortcut diagShortcut = new () - { - Key = Key.F10, - CommandView = new CheckBox - { - Title = "Diagnostics", - CanFocus = false - } - }; + Shortcut diagShortcut = new () { Key = Key.F7, CommandView = new CheckBox { Title = "Diagnostics", CanFocus = false } }; statusBar.Add (diagShortcut); diagShortcut.Accepting += DiagShortcut_Accept; // Menu setup - _showBorderCheckBox = new () - { - Title = "_Enable Margin, Border, and Padding", - Value = CheckState.Checked - }; + _showBorderCheckBox = new CheckBox { Title = "_Enable Margin, Border, and Padding", Value = CheckState.Checked }; _showBorderCheckBox.ValueChanged += (_, _) => ShowBorder (); - _diagCheckBox = new () + _diagCheckBox = new CheckBox { Title = "_Diagnostics", - Value = View.Diagnostics == (ViewDiagnosticFlags.Thickness | ViewDiagnosticFlags.Ruler) - ? CheckState.Checked - : CheckState.UnChecked + Value = View.Diagnostics == (ViewDiagnosticFlags.Thickness | ViewDiagnosticFlags.Ruler) ? CheckState.Checked : CheckState.UnChecked }; _diagCheckBox.ValueChanged += (_, _) => ToggleDiagnostics (); - menu.Add ( - new MenuBarItem ( - Strings.menuFile, + menu.Add (new MenuBarItem ("_View", [ - new MenuItem - { - Title = "Scatter _Plot", - Action = () => _graphs [_currentGraph = 0] () - }, - new MenuItem - { - Title = "_V Bar Graph", - Action = () => _graphs [_currentGraph = 1] () - }, - new MenuItem - { - Title = "_H Bar Graph", - Action = () => _graphs [_currentGraph = 2] () - }, - new MenuItem - { - Title = "P_opulation Pyramid", - Action = () => _graphs [_currentGraph = 3] () - }, - new MenuItem - { - Title = "_Line Graph", - Action = () => _graphs [_currentGraph = 4] () - }, - new MenuItem - { - Title = "Sine _Wave", - Action = () => _graphs [_currentGraph = 5] () - }, - new MenuItem - { - Title = "Silent _Disco", - Action = () => _graphs [_currentGraph = 6] () - }, - new MenuItem - { - Title = "_Multi Bar Graph", - Action = () => _graphs [_currentGraph = 7] () - }, - new MenuItem - { - Title = Strings.cmdQuit, - Action = Quit - } - ] - ) - ); - - menu.Add ( - new MenuBarItem ( - "_View", - [ - new MenuItem - { - Title = "Zoom _In", - Action = () => Zoom (0.5f) - }, - new MenuItem - { - Title = "Zoom _Out", - Action = () => Zoom (2f) - }, - new MenuItem - { - Title = "MarginLeft++", - Action = () => Margin (true, true) - }, - new MenuItem - { - Title = "MarginLeft--", - Action = () => Margin (true, false) - }, - new MenuItem - { - Title = "MarginBottom++", - Action = () => Margin (false, true) - }, - new MenuItem - { - Title = "MarginBottom--", - Action = () => Margin (false, false) - }, - new MenuItem - { - CommandView = _showBorderCheckBox - }, - new MenuItem - { - CommandView = _diagCheckBox - } - ] - ) - ); + new MenuItem { Title = "Zoom _In", Action = () => Zoom (0.5f) }, + new MenuItem { Title = "Zoom _Out", Action = () => Zoom (2f) }, + new MenuItem { Title = "MarginLeft++", Action = () => Margin (true, true) }, + new MenuItem { Title = "MarginLeft--", Action = () => Margin (true, false) }, + new MenuItem { Title = "MarginBottom++", Action = () => Margin (false, true) }, + new MenuItem { Title = "MarginBottom--", Action = () => Margin (false, false) }, + new MenuItem { CommandView = _showBorderCheckBox }, + new MenuItem { CommandView = _diagCheckBox } + ])); // Add views in order of visual appearance - window.Add (menu, _graphView, frameRight, statusBar); - - _graphs [_currentGraph++ % _graphs.Length] (); + window.Add (menu, _tabs, _aboutTextView, statusBar); _viewDiagnostics = View.Diagnostics; app.Run (window); View.Diagnostics = _viewDiagnostics; } + /// + /// Creates a tab containing a and an about , + /// then invokes the setup action to configure the graph. + /// + private void CreateGraphTab (string tabTitle, string graphTitle, Action setupAction) + { + View tab = new () { Title = tabTitle }; + + GraphView graphView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.HeavyDotted, + Title = graphTitle, + Arrangement = ViewArrangement.Resizable | ViewArrangement.Overlapped + }; + graphView.Border.Thickness = _thickness; + graphView.Margin.Thickness = _thickness; + graphView.Padding.Thickness = _thickness; + + tab.Add (graphView); + _tabs?.Add (tab); + + // Defer setup to when the tab is first laid out so Viewport is valid + graphView.Initialized += GraphViewOnInitialized; + + return; + + void GraphViewOnInitialized (object? sender, EventArgs e) + { + graphView.Initialized -= GraphViewOnInitialized; + setupAction (graphView); + } + } + private void DiagShortcut_Accept (object? sender, CommandEventArgs e) { ToggleDiagnostics (); @@ -235,53 +152,46 @@ private void DiagShortcut_Accept (object? sender, CommandEventArgs e) private void ToggleDiagnostics () { - View.Diagnostics = _diagCheckBox?.Value == CheckState.Checked - ? ViewDiagnosticFlags.Thickness | ViewDiagnosticFlags.Ruler - : ViewDiagnosticFlags.Off; + View.Diagnostics = _diagCheckBox?.Value == CheckState.Checked ? ViewDiagnosticFlags.Thickness | ViewDiagnosticFlags.Ruler : ViewDiagnosticFlags.Off; _app?.LayoutAndDraw (); } private void Margin (bool left, bool increase) { - if (_graphView is null) + GraphView? graphView = CurrentGraphView; + + if (graphView is null) { return; } if (left) { - _graphView.MarginLeft = (uint)Math.Max (0, _graphView.MarginLeft + (increase ? 1 : -1)); + graphView.MarginLeft = (uint)Math.Max (0, (int)graphView.MarginLeft + (increase ? 1 : -1)); } else { - _graphView.MarginBottom = (uint)Math.Max (0, _graphView.MarginBottom + (increase ? 1 : -1)); + graphView.MarginBottom = (uint)Math.Max (0, (int)graphView.MarginBottom + (increase ? 1 : -1)); } - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } - private void MultiBarGraph () + private void MultiBarGraph (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } - - _graphView.Reset (); + graphView.Reset (); - _graphView.Title = "Multi Bar"; + graphView.Text = "Housing Expenditures by income thirds 1996-2003"; - _about.Text = "Housing Expenditures by income thirds 1996-2003"; - - Color fore = _graphView.GetAttributeForRole (VisualRole.Normal).Foreground == Color.Black + Color fore = graphView.GetAttributeForRole (VisualRole.Normal).Foreground == Color.Black ? Color.White - : _graphView.GetAttributeForRole (VisualRole.Normal).Foreground; + : graphView.GetAttributeForRole (VisualRole.Normal).Foreground; Attribute black = new (fore, Color.Black); Attribute cyan = new (Color.BrightCyan, Color.Black); Attribute magenta = new (Color.BrightMagenta, Color.Black); Attribute red = new (Color.BrightRed, Color.Black); - _graphView.GraphColor = black; + graphView.GraphColor = black; MultiBarSeries series = new (3, 1, 0.25f, [magenta, cyan, red]); @@ -296,57 +206,39 @@ private void MultiBarGraph () series.AddBars ("'02", stiple, 6600, 11000, 16700); series.AddBars ("'03", stiple, 7000, 12000, 17000); - _graphView.CellSize = new (0.25f, 1000); - _graphView.Series.Add (series); - _graphView.SetNeedsDraw (); + graphView.CellSize = new PointF (0.25f, 1000); + graphView.Series.Add (series); + graphView.SetNeedsDraw (); - _graphView.MarginLeft = 3; - _graphView.MarginBottom = 1; + graphView.MarginLeft = 3; + graphView.MarginBottom = 1; - _graphView.AxisY.LabelGetter = v => '$' + (v.Value / 1000f).ToString ("N0") + 'k'; + graphView.AxisY.LabelGetter = v => '$' + (v.Value / 1000f).ToString ("N0") + 'k'; // Do not show x-axis labels (bars draw their own labels) - _graphView.AxisX.Increment = 0; - _graphView.AxisX.ShowLabelsEvery = 0; - _graphView.AxisX.Minimum = 0; + graphView.AxisX.Increment = 0; + graphView.AxisX.ShowLabelsEvery = 0; + graphView.AxisX.Minimum = 0; - _graphView.AxisY.Minimum = 0; + graphView.AxisY.Minimum = 0; - LegendAnnotation legend = new (new (_graphView.Viewport.Width - 20, 0, 20, 5)); + LegendAnnotation legend = new (new Rectangle (graphView.Viewport.Width - 20, 0, 20, 5)); - legend.AddEntry ( - new (stiple, series.SubSeries.ElementAt (0).OverrideBarColor ?? black), - "Lower Third" - ); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (0).OverrideBarColor ?? black), "Lower Third"); - legend.AddEntry ( - new (stiple, series.SubSeries.ElementAt (1).OverrideBarColor ?? cyan), - "Middle Third" - ); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (1).OverrideBarColor ?? cyan), "Middle Third"); - legend.AddEntry ( - new (stiple, series.SubSeries.ElementAt (2).OverrideBarColor ?? red), - "Upper Third" - ); - _graphView.Annotations.Add (legend); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (2).OverrideBarColor ?? red), "Upper Third"); + graphView.Annotations.Add (legend); } - private void Quit () { _graphView?.App?.RequestStop (); } - - private void SetupDisco () + private void SetupDisco (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } + graphView.Reset (); - _graphView.Reset (); + graphView.Text = "This graph shows a graphic equalizer for an imaginary song"; - _graphView.Title = "Graphic Equalizer"; - - _about.Text = "This graph shows a graphic equalizer for an imaginary song"; - - _graphView.GraphColor = new Attribute (Color.White, Color.Black); + graphView.GraphColor = new Attribute (Color.White, Color.Black); GraphCellToRender stiple = new ((Rune)'\u2593'); @@ -358,17 +250,17 @@ private void SetupDisco () series.Bars = bars; - _graphView.Series.Add (series); + graphView.Series.Add (series); // How much graph space each cell of the console depicts - _graphView.CellSize = new (1, 10); - _graphView.AxisX.Increment = 0; // No graph ticks - _graphView.AxisX.ShowLabelsEvery = 0; // no labels + graphView.CellSize = new PointF (1, 10); + graphView.AxisX.Increment = 0; // No graph ticks + graphView.AxisX.ShowLabelsEvery = 0; // no labels - _graphView.AxisX.Visible = false; - _graphView.AxisY.Visible = false; + graphView.AxisX.Visible = false; + graphView.AxisY.Visible = false; - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); return; @@ -379,28 +271,21 @@ bool GenSample () // generate an imaginary sample for (var i = 0; i < 31; i++) { - bars.Add (new (string.Empty, stiple, r.Next (0, 100))); + bars.Add (new BarSeriesBar (string.Empty, stiple, r.Next (0, 100))); } - _graphView?.SetNeedsDraw (); + graphView.SetNeedsDraw (); // while the equaliser is showing - return _graphView is { } && _graphView.Series.Contains (series); + return graphView.Series.Contains (series); } } - private void SetupLifeExpectancyBarGraph (bool verticalBars) + private void SetupLifeExpectancyBarGraph (GraphView graphView, bool verticalBars) { - if (_graphView is null || _about is null) - { - return; - } - - _graphView.Reset (); + graphView.Reset (); - _graphView.Title = $"Life Expectancy - {(verticalBars ? "Vertical" : "Horizontal")}"; - - _about.Text = "This graph shows the life expectancy at birth of a range of countries"; + graphView.Text = "This graph shows the life expectancy at birth of a range of countries"; GraphCellToRender softStiple = new ((Rune)'\u2591'); GraphCellToRender mediumStiple = new ((Rune)'\u2592'); @@ -409,105 +294,97 @@ private void SetupLifeExpectancyBarGraph (bool verticalBars) { Bars = [ - new ("Switzerland", softStiple, 83.4f), - new ("South Korea", !verticalBars ? mediumStiple : softStiple, 83.3f), - new ("Singapore", softStiple, 83.2f), - new ("Spain", !verticalBars ? mediumStiple : softStiple, 83.2f), - new ("Cyprus", softStiple, 83.1f), - new ("Australia", !verticalBars ? mediumStiple : softStiple, 83), - new ("Italy", softStiple, 83), - new ("Norway", !verticalBars ? mediumStiple : softStiple, 83), - new ("Israel", softStiple, 82.6f), - new ("France", !verticalBars ? mediumStiple : softStiple, 82.5f), - new ("Luxembourg", softStiple, 82.4f), - new ("Sweden", !verticalBars ? mediumStiple : softStiple, 82.4f), - new ("Iceland", softStiple, 82.3f), - new ("Canada", !verticalBars ? mediumStiple : softStiple, 82.2f), - new ("New Zealand", softStiple, 82), - new ("Malta", !verticalBars ? mediumStiple : softStiple, 81.9f), - new ("Ireland", softStiple, 81.8f) + new BarSeriesBar ("Switzerland", softStiple, 83.4f), + new BarSeriesBar ("South Korea", !verticalBars ? mediumStiple : softStiple, 83.3f), + new BarSeriesBar ("Singapore", softStiple, 83.2f), + new BarSeriesBar ("Spain", !verticalBars ? mediumStiple : softStiple, 83.2f), + new BarSeriesBar ("Cyprus", softStiple, 83.1f), + new BarSeriesBar ("Australia", !verticalBars ? mediumStiple : softStiple, 83), + new BarSeriesBar ("Italy", softStiple, 83), + new BarSeriesBar ("Norway", !verticalBars ? mediumStiple : softStiple, 83), + new BarSeriesBar ("Israel", softStiple, 82.6f), + new BarSeriesBar ("France", !verticalBars ? mediumStiple : softStiple, 82.5f), + new BarSeriesBar ("Luxembourg", softStiple, 82.4f), + new BarSeriesBar ("Sweden", !verticalBars ? mediumStiple : softStiple, 82.4f), + new BarSeriesBar ("Iceland", softStiple, 82.3f), + new BarSeriesBar ("Canada", !verticalBars ? mediumStiple : softStiple, 82.2f), + new BarSeriesBar ("New Zealand", softStiple, 82), + new BarSeriesBar ("Malta", !verticalBars ? mediumStiple : softStiple, 81.9f), + new BarSeriesBar ("Ireland", softStiple, 81.8f) ] }; - _graphView.Series.Add (barSeries); + graphView.Series.Add (barSeries); if (verticalBars) { barSeries.Orientation = Orientation.Vertical; // How much graph space each cell of the console depicts - _graphView.CellSize = new (0.1f, 0.25f); + graphView.CellSize = new PointF (0.1f, 0.25f); // No axis marks since Bar will add its own categorical marks - _graphView.AxisX.Increment = 0f; - _graphView.AxisX.Text = "Country"; - _graphView.AxisX.Minimum = 0; + graphView.AxisX.Increment = 0f; + graphView.AxisX.Text = "Country"; + graphView.AxisX.Minimum = 0; - _graphView.AxisY.Increment = 1f; - _graphView.AxisY.ShowLabelsEvery = 1; - _graphView.AxisY.LabelGetter = v => v.Value.ToString ("N2"); - _graphView.AxisY.Minimum = 0; - _graphView.AxisY.Text = "Age"; + graphView.AxisY.Increment = 1f; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisY.Minimum = 0; + graphView.AxisY.Text = "Age"; // leave space for axis labels and title - _graphView.MarginBottom = 2; - _graphView.MarginLeft = 6; + graphView.MarginBottom = 2; + graphView.MarginLeft = 6; // Start the graph at 80 years because that is where most of our data is - _graphView.ScrollOffset = new (0, 80); + graphView.ScrollOffset = new PointF (0, 80); } else { barSeries.Orientation = Orientation.Horizontal; // How much graph space each cell of the console depicts - _graphView.CellSize = new (0.1f, 1f); + graphView.CellSize = new PointF (0.1f, 1f); // No axis marks since Bar will add its own categorical marks - _graphView.AxisY.Increment = 0f; - _graphView.AxisY.ShowLabelsEvery = 1; - _graphView.AxisY.Text = "Country"; - _graphView.AxisY.Minimum = 0; + graphView.AxisY.Increment = 0f; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Text = "Country"; + graphView.AxisY.Minimum = 0; - _graphView.AxisX.Increment = 1f; - _graphView.AxisX.ShowLabelsEvery = 1; - _graphView.AxisX.LabelGetter = v => v.Value.ToString ("N2"); - _graphView.AxisX.Text = "Age"; - _graphView.AxisX.Minimum = 0; + graphView.AxisX.Increment = 1f; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisX.Text = "Age"; + graphView.AxisX.Minimum = 0; // leave space for axis labels and title - _graphView.MarginBottom = 2; - _graphView.MarginLeft = (uint)barSeries.Bars.Max (b => b.Text.Length) + 2; + graphView.MarginBottom = 2; + graphView.MarginLeft = (uint)barSeries.Bars.Max (b => b.Text.Length) + 2; // Start the graph at 80 years because that is where most of our data is - _graphView.ScrollOffset = new (80, 0); + graphView.ScrollOffset = new PointF (80, 0); } - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } - private void SetupLineGraph () + private void SetupLineGraph (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } - - _graphView.Reset (); + graphView.Reset (); - _graphView.Title = "Line"; + graphView.Text = "This graph shows random points"; - _about.Text = "This graph shows random points"; - - Attribute black = new ( - _graphView.GetAttributeForRole (VisualRole.Normal).Foreground, + Attribute black = new (graphView.GetAttributeForRole (VisualRole.Normal).Foreground, Color.Black, - _graphView.GetAttributeForRole (VisualRole.Normal).Style); + graphView.GetAttributeForRole (VisualRole.Normal).Style); Attribute cyan = new (Color.BrightCyan, Color.Black); Attribute magenta = new (Color.BrightMagenta, Color.Black); Attribute red = new (Color.BrightRed, Color.Black); - _graphView.GraphColor = black; + graphView.GraphColor = black; List randomPoints = []; @@ -515,263 +392,231 @@ private void SetupLineGraph () for (var i = 0; i < 10; i++) { - randomPoints.Add (new (r.Next (100), r.Next (100))); + randomPoints.Add (new PointF (r.Next (100), r.Next (100))); } ScatterSeries points = new () { Points = randomPoints }; - PathAnnotation line = new () - { - LineColor = cyan, - Points = randomPoints.OrderBy (p => p.X).ToList (), - BeforeSeries = true - }; + PathAnnotation line = new () { LineColor = cyan, Points = randomPoints.OrderBy (p => p.X).ToList (), BeforeSeries = true }; - _graphView.Series.Add (points); - _graphView.Annotations.Add (line); + graphView.Series.Add (points); + graphView.Annotations.Add (line); randomPoints = []; for (var i = 0; i < 10; i++) { - randomPoints.Add (new (r.Next (100), r.Next (100))); + randomPoints.Add (new PointF (r.Next (100), r.Next (100))); } - ScatterSeries points2 = new () { Points = randomPoints, Fill = new ((Rune)'x', red) }; + ScatterSeries points2 = new () { Points = randomPoints, Fill = new GraphCellToRender ((Rune)'x', red) }; - PathAnnotation line2 = new () - { - LineColor = magenta, - Points = randomPoints.OrderBy (p => p.X).ToList (), - BeforeSeries = true - }; + PathAnnotation line2 = new () { LineColor = magenta, Points = randomPoints.OrderBy (p => p.X).ToList (), BeforeSeries = true }; - _graphView.Series.Add (points2); - _graphView.Annotations.Add (line2); + graphView.Series.Add (points2); + graphView.Annotations.Add (line2); // How much graph space each cell of the console depicts - _graphView.CellSize = new (2, 5); + graphView.CellSize = new PointF (2, 5); // leave space for axis labels - _graphView.MarginBottom = 2; - _graphView.MarginLeft = 3; + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; // One axis tick/label per - _graphView.AxisX.Increment = 20; - _graphView.AxisX.ShowLabelsEvery = 1; - _graphView.AxisX.Text = "X →"; + graphView.AxisX.Increment = 20; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.Text = "X →"; - _graphView.AxisY.Increment = 20; - _graphView.AxisY.ShowLabelsEvery = 1; - _graphView.AxisY.Text = "↑Y"; + graphView.AxisY.Increment = 20; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Text = "↑Y"; PointF max = line.Points.Union (line2.Points).OrderByDescending (p => p.Y).First (); - _graphView.Annotations.Add ( - new TextAnnotation - { - Text = "(Max)", - GraphPosition = max with { X = max.X + 2 * _graphView.CellSize.X } - } - ); + graphView.Annotations.Add (new TextAnnotation { Text = "(Max)", GraphPosition = max with { X = max.X + 2 * graphView.CellSize.X } }); - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } - private void SetupPeriodicTableScatterPlot () + private void SetupPeriodicTableScatterPlot (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } + graphView.Reset (); - _graphView.Reset (); - - _graphView.Title = "Scatter Plot"; - - _about.Text = + graphView.Text = "This graph shows the atomic weight of each element in the periodic table.\nStarting with Hydrogen (atomic Number 1 with a weight of 1.007)"; //AtomicNumber and AtomicMass of all elements in the periodic table - _graphView.Series.Add ( - new ScatterSeries - { - Points = - [ - new (1, 1.007f), - new (2, 4.002f), - new (3, 6.941f), - new (4, 9.012f), - new (5, 10.811f), - new (6, 12.011f), - new (7, 14.007f), - new (8, 15.999f), - new (9, 18.998f), - new (10, 20.18f), - new (11, 22.99f), - new (12, 24.305f), - new (13, 26.982f), - new (14, 28.086f), - new (15, 30.974f), - new (16, 32.065f), - new (17, 35.453f), - new (18, 39.948f), - new (19, 39.098f), - new (20, 40.078f), - new (21, 44.956f), - new (22, 47.867f), - new (23, 50.942f), - new (24, 51.996f), - new (25, 54.938f), - new (26, 55.845f), - new (27, 58.933f), - new (28, 58.693f), - new (29, 63.546f), - new (30, 65.38f), - new (31, 69.723f), - new (32, 72.64f), - new (33, 74.922f), - new (34, 78.96f), - new (35, 79.904f), - new (36, 83.798f), - new (37, 85.468f), - new (38, 87.62f), - new (39, 88.906f), - new (40, 91.224f), - new (41, 92.906f), - new (42, 95.96f), - new (43, 98f), - new (44, 101.07f), - new (45, 102.906f), - new (46, 106.42f), - new (47, 107.868f), - new (48, 112.411f), - new (49, 114.818f), - new (50, 118.71f), - new (51, 121.76f), - new (52, 127.6f), - new (53, 126.904f), - new (54, 131.293f), - new (55, 132.905f), - new (56, 137.327f), - new (57, 138.905f), - new (58, 140.116f), - new (59, 140.908f), - new (60, 144.242f), - new (61, 145), - new (62, 150.36f), - new (63, 151.964f), - new (64, 157.25f), - new (65, 158.925f), - new (66, 162.5f), - new (67, 164.93f), - new (68, 167.259f), - new (69, 168.934f), - new (70, 173.054f), - new (71, 174.967f), - new (72, 178.49f), - new (73, 180.948f), - new (74, 183.84f), - new (75, 186.207f), - new (76, 190.23f), - new (77, 192.217f), - new (78, 195.084f), - new (79, 196.967f), - new (80, 200.59f), - new (81, 204.383f), - new (82, 207.2f), - new (83, 208.98f), - new (84, 210), - new (85, 210), - new (86, 222), - new (87, 223), - new (88, 226), - new (89, 227), - new (90, 232.038f), - new (91, 231.036f), - new (92, 238.029f), - new (93, 237), - new (94, 244), - new (95, 243), - new (96, 247), - new (97, 247), - new (98, 251), - new (99, 252), - new (100, 257), - new (101, 258), - new (102, 259), - new (103, 262), - new (104, 261), - new (105, 262), - new (106, 266), - new (107, 264), - new (108, 267), - new (109, 268), - new (113, 284), - new (114, 289), - new (115, 288), - new (116, 292), - new (117, 295), - new (118, 294) - ] - } - ); + graphView.Series.Add (new ScatterSeries + { + Points = + [ + new PointF (1, 1.007f), + new PointF (2, 4.002f), + new PointF (3, 6.941f), + new PointF (4, 9.012f), + new PointF (5, 10.811f), + new PointF (6, 12.011f), + new PointF (7, 14.007f), + new PointF (8, 15.999f), + new PointF (9, 18.998f), + new PointF (10, 20.18f), + new PointF (11, 22.99f), + new PointF (12, 24.305f), + new PointF (13, 26.982f), + new PointF (14, 28.086f), + new PointF (15, 30.974f), + new PointF (16, 32.065f), + new PointF (17, 35.453f), + new PointF (18, 39.948f), + new PointF (19, 39.098f), + new PointF (20, 40.078f), + new PointF (21, 44.956f), + new PointF (22, 47.867f), + new PointF (23, 50.942f), + new PointF (24, 51.996f), + new PointF (25, 54.938f), + new PointF (26, 55.845f), + new PointF (27, 58.933f), + new PointF (28, 58.693f), + new PointF (29, 63.546f), + new PointF (30, 65.38f), + new PointF (31, 69.723f), + new PointF (32, 72.64f), + new PointF (33, 74.922f), + new PointF (34, 78.96f), + new PointF (35, 79.904f), + new PointF (36, 83.798f), + new PointF (37, 85.468f), + new PointF (38, 87.62f), + new PointF (39, 88.906f), + new PointF (40, 91.224f), + new PointF (41, 92.906f), + new PointF (42, 95.96f), + new PointF (43, 98f), + new PointF (44, 101.07f), + new PointF (45, 102.906f), + new PointF (46, 106.42f), + new PointF (47, 107.868f), + new PointF (48, 112.411f), + new PointF (49, 114.818f), + new PointF (50, 118.71f), + new PointF (51, 121.76f), + new PointF (52, 127.6f), + new PointF (53, 126.904f), + new PointF (54, 131.293f), + new PointF (55, 132.905f), + new PointF (56, 137.327f), + new PointF (57, 138.905f), + new PointF (58, 140.116f), + new PointF (59, 140.908f), + new PointF (60, 144.242f), + new PointF (61, 145), + new PointF (62, 150.36f), + new PointF (63, 151.964f), + new PointF (64, 157.25f), + new PointF (65, 158.925f), + new PointF (66, 162.5f), + new PointF (67, 164.93f), + new PointF (68, 167.259f), + new PointF (69, 168.934f), + new PointF (70, 173.054f), + new PointF (71, 174.967f), + new PointF (72, 178.49f), + new PointF (73, 180.948f), + new PointF (74, 183.84f), + new PointF (75, 186.207f), + new PointF (76, 190.23f), + new PointF (77, 192.217f), + new PointF (78, 195.084f), + new PointF (79, 196.967f), + new PointF (80, 200.59f), + new PointF (81, 204.383f), + new PointF (82, 207.2f), + new PointF (83, 208.98f), + new PointF (84, 210), + new PointF (85, 210), + new PointF (86, 222), + new PointF (87, 223), + new PointF (88, 226), + new PointF (89, 227), + new PointF (90, 232.038f), + new PointF (91, 231.036f), + new PointF (92, 238.029f), + new PointF (93, 237), + new PointF (94, 244), + new PointF (95, 243), + new PointF (96, 247), + new PointF (97, 247), + new PointF (98, 251), + new PointF (99, 252), + new PointF (100, 257), + new PointF (101, 258), + new PointF (102, 259), + new PointF (103, 262), + new PointF (104, 261), + new PointF (105, 262), + new PointF (106, 266), + new PointF (107, 264), + new PointF (108, 267), + new PointF (109, 268), + new PointF (113, 284), + new PointF (114, 289), + new PointF (115, 288), + new PointF (116, 292), + new PointF (117, 295), + new PointF (118, 294) + ] + }); // How much graph space each cell of the console depicts - _graphView.CellSize = new (1, 5); + graphView.CellSize = new PointF (1, 5); // leave space for axis labels - _graphView.MarginBottom = 2; - _graphView.MarginLeft = 3; + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; // One axis tick/label per 5 atomic numbers - _graphView.AxisX.Increment = 5; - _graphView.AxisX.ShowLabelsEvery = 1; - _graphView.AxisX.Text = "Atomic Number"; - _graphView.AxisX.Minimum = 0; + graphView.AxisX.Increment = 5; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.Text = "Atomic Number"; + graphView.AxisX.Minimum = 0; // One label every 5 atomic weight - _graphView.AxisY.Increment = 5; - _graphView.AxisY.ShowLabelsEvery = 1; - _graphView.AxisY.Minimum = 0; + graphView.AxisY.Increment = 5; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Minimum = 0; - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } - private void SetupPopulationPyramid () + private void SetupPopulationPyramid (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } - - _about.Text = "This graph shows population of each age divided by gender"; - - _graphView.Title = "Population Pyramid"; + graphView.Reset (); - _graphView.Reset (); + graphView.Text = "This graph shows population of each age divided by gender"; // How much graph space each cell of the console depicts - _graphView.CellSize = new (100_000, 1); + graphView.CellSize = new PointF (100_000, 1); //center the x-axis in middle of screen to show both sides - _graphView.ScrollOffset = new (-3_000_000, 0); + graphView.ScrollOffset = new PointF (-3_000_000, 0); - _graphView.AxisX.Text = "Number Of People"; - _graphView.AxisX.Increment = 500_000; - _graphView.AxisX.ShowLabelsEvery = 2; + graphView.AxisX.Text = "Number Of People"; + graphView.AxisX.Increment = 500_000; + graphView.AxisX.ShowLabelsEvery = 2; // use Abs to make negative axis labels positive - _graphView.AxisX.LabelGetter = v => Math.Abs (v.Value / 1_000_000).ToString ("N2") + "M"; + graphView.AxisX.LabelGetter = v => Math.Abs (v.Value / 1_000_000).ToString ("N2") + "M"; // leave space for axis labels - _graphView.MarginBottom = 2; - _graphView.MarginLeft = 1; + graphView.MarginBottom = 2; + graphView.MarginLeft = 1; // do not show axis titles (bars have their own categories) - _graphView.AxisY.Increment = 0; - _graphView.AxisY.ShowLabelsEvery = 0; - _graphView.AxisY.Minimum = 0; + graphView.AxisY.Increment = 0; + graphView.AxisY.ShowLabelsEvery = 0; + graphView.AxisY.Minimum = 0; GraphCellToRender stiple = new (Glyphs.Stipple); @@ -783,30 +628,30 @@ private void SetupPopulationPyramid () Orientation = Orientation.Horizontal, Bars = [ - new ("0-4", stiple, -2009363), - new ("5-9", stiple, -2108550), - new ("10-14", stiple, -2022370), - new ("15-19", stiple, -1880611), - new ("20-24", stiple, -2072674), - new ("25-29", stiple, -2275138), - new ("30-34", stiple, -2361054), - new ("35-39", stiple, -2279836), - new ("40-44", stiple, -2148253), - new ("45-49", stiple, -2128343), - new ("50-54", stiple, -2281421), - new ("55-59", stiple, -2232388), - new ("60-64", stiple, -1919839), - new ("65-69", stiple, -1647391), - new ("70-74", stiple, -1624635), - new ("75-79", stiple, -1137438), - new ("80-84", stiple, -766956), - new ("85-89", stiple, -438663), - new ("90-94", stiple, -169952), - new ("95-99", stiple, -34524), - new ("100+", stiple, -3016) + new BarSeriesBar ("0-4", stiple, -2009363), + new BarSeriesBar ("5-9", stiple, -2108550), + new BarSeriesBar ("10-14", stiple, -2022370), + new BarSeriesBar ("15-19", stiple, -1880611), + new BarSeriesBar ("20-24", stiple, -2072674), + new BarSeriesBar ("25-29", stiple, -2275138), + new BarSeriesBar ("30-34", stiple, -2361054), + new BarSeriesBar ("35-39", stiple, -2279836), + new BarSeriesBar ("40-44", stiple, -2148253), + new BarSeriesBar ("45-49", stiple, -2128343), + new BarSeriesBar ("50-54", stiple, -2281421), + new BarSeriesBar ("55-59", stiple, -2232388), + new BarSeriesBar ("60-64", stiple, -1919839), + new BarSeriesBar ("65-69", stiple, -1647391), + new BarSeriesBar ("70-74", stiple, -1624635), + new BarSeriesBar ("75-79", stiple, -1137438), + new BarSeriesBar ("80-84", stiple, -766956), + new BarSeriesBar ("85-89", stiple, -438663), + new BarSeriesBar ("90-94", stiple, -169952), + new BarSeriesBar ("95-99", stiple, -34524), + new BarSeriesBar ("100+", stiple, -3016) ] }; - _graphView.Series.Add (malesSeries); + graphView.Series.Add (malesSeries); // Females BarSeries femalesSeries = new () @@ -814,27 +659,27 @@ private void SetupPopulationPyramid () Orientation = Orientation.Horizontal, Bars = [ - new ("0-4", stiple, 1915127), - new ("5-9", stiple, 2011016), - new ("10-14", stiple, 1933970), - new ("15-19", stiple, 1805522), - new ("20-24", stiple, 2001966), - new ("25-29", stiple, 2208929), - new ("30-34", stiple, 2345774), - new ("35-39", stiple, 2308360), - new ("40-44", stiple, 2159877), - new ("45-49", stiple, 2167778), - new ("50-54", stiple, 2353119), - new ("55-59", stiple, 2306537), - new ("60-64", stiple, 1985177), - new ("65-69", stiple, 1734370), - new ("70-74", stiple, 1763853), - new ("75-79", stiple, 1304709), - new ("80-84", stiple, 969611), - new ("85-89", stiple, 638892), - new ("90-94", stiple, 320625), - new ("95-99", stiple, 95559), - new ("100+", stiple, 12818) + new BarSeriesBar ("0-4", stiple, 1915127), + new BarSeriesBar ("5-9", stiple, 2011016), + new BarSeriesBar ("10-14", stiple, 1933970), + new BarSeriesBar ("15-19", stiple, 1805522), + new BarSeriesBar ("20-24", stiple, 2001966), + new BarSeriesBar ("25-29", stiple, 2208929), + new BarSeriesBar ("30-34", stiple, 2345774), + new BarSeriesBar ("35-39", stiple, 2308360), + new BarSeriesBar ("40-44", stiple, 2159877), + new BarSeriesBar ("45-49", stiple, 2167778), + new BarSeriesBar ("50-54", stiple, 2353119), + new BarSeriesBar ("55-59", stiple, 2306537), + new BarSeriesBar ("60-64", stiple, 1985177), + new BarSeriesBar ("65-69", stiple, 1734370), + new BarSeriesBar ("70-74", stiple, 1763853), + new BarSeriesBar ("75-79", stiple, 1304709), + new BarSeriesBar ("80-84", stiple, 969611), + new BarSeriesBar ("85-89", stiple, 638892), + new BarSeriesBar ("90-94", stiple, 320625), + new BarSeriesBar ("95-99", stiple, 95559), + new BarSeriesBar ("100+", stiple, 12818) ] }; @@ -847,31 +692,23 @@ private void SetupPopulationPyramid () femalesSeries.Bars [i].Fill = i % 2 == 0 ? softStiple : mediumStiple; } - _graphView.Series.Add (femalesSeries); + graphView.Series.Add (femalesSeries); - _graphView.Annotations.Add (new TextAnnotation { Text = "M", ScreenPosition = new Point (0, 10) }); + graphView.Annotations.Add (new TextAnnotation { Text = "M", ScreenPosition = new Point (0, 10) }); - _graphView.Annotations.Add ( - new TextAnnotation { Text = "F", ScreenPosition = new Point (_graphView.Viewport.Width - 1, 10) } - ); + graphView.Annotations.Add (new TextAnnotation { Text = "F", ScreenPosition = new Point (graphView.Viewport.Width - 1, 10) }); - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } - private void SetupSineWave () + private void SetupSineWave (GraphView graphView) { - if (_graphView is null || _about is null) - { - return; - } + graphView.Reset (); - _graphView.Reset (); - - _graphView.Title = "Sine Wave"; - - _about.Text = "This graph shows a sine wave"; + graphView.Text = "This graph shows a sine wave"; ScatterSeries points = new (); + PathAnnotation line = new () { // Draw line first so it does not draw over top of points or axis labels @@ -881,74 +718,75 @@ private void SetupSineWave () // Generate line graph with 2,000 points for (float x = -500; x < 500; x += 0.5f) { - points.Points.Add (new (x, (float)Math.Sin (x))); - line.Points.Add (new (x, (float)Math.Sin (x))); + points.Points.Add (new PointF (x, (float)Math.Sin (x))); + line.Points.Add (new PointF (x, (float)Math.Sin (x))); } - _graphView.Series.Add (points); - _graphView.Annotations.Add (line); + graphView.Series.Add (points); + graphView.Annotations.Add (line); // How much graph space each cell of the console depicts - _graphView.CellSize = new (0.1f, 0.1f); + graphView.CellSize = new PointF (0.1f, 0.1f); // leave space for axis labels - _graphView.MarginBottom = 2; - _graphView.MarginLeft = 3; + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; // One axis tick/label per - _graphView.AxisX.Increment = 0.5f; - _graphView.AxisX.ShowLabelsEvery = 2; - _graphView.AxisX.Text = "X →"; - _graphView.AxisX.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisX.Increment = 0.5f; + graphView.AxisX.ShowLabelsEvery = 2; + graphView.AxisX.Text = "X →"; + graphView.AxisX.LabelGetter = v => v.Value.ToString ("N2"); - _graphView.AxisY.Increment = 0.2f; - _graphView.AxisY.ShowLabelsEvery = 2; - _graphView.AxisY.Text = "↑Y"; - _graphView.AxisY.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisY.Increment = 0.2f; + graphView.AxisY.ShowLabelsEvery = 2; + graphView.AxisY.Text = "↑Y"; + graphView.AxisY.LabelGetter = v => v.Value.ToString ("N2"); - _graphView.ScrollOffset = new (-2.5f, -1); + graphView.ScrollOffset = new PointF (-2.5f, -1); - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } private void ShowBorder () { - if (_graphView is null) + GraphView? graphView = CurrentGraphView; + + if (graphView is null) { return; } if (_showBorderCheckBox?.Value == CheckState.Checked) { - _graphView.BorderStyle = LineStyle.Single; - _graphView.Border.Thickness = _thickness; - _graphView.Margin.Thickness = _thickness; - _graphView.Padding.Thickness = _thickness; + graphView.BorderStyle = LineStyle.Single; + graphView.Border.Thickness = _thickness; + graphView.Margin.Thickness = _thickness; + graphView.Padding.Thickness = _thickness; } else { - _graphView.BorderStyle = LineStyle.None; - _graphView.Margin.Thickness = Thickness.Empty; - _graphView.Padding.Thickness = Thickness.Empty; + graphView.BorderStyle = LineStyle.None; + graphView.Margin.Thickness = Thickness.Empty; + graphView.Padding.Thickness = Thickness.Empty; } } private void Zoom (float factor) { - if (_graphView is null) + GraphView? graphView = CurrentGraphView; + + if (graphView is null) { return; } - _graphView.CellSize = new ( - _graphView.CellSize.X * factor, - _graphView.CellSize.Y * factor - ); + graphView.CellSize = new PointF (graphView.CellSize.X * factor, graphView.CellSize.Y * factor); - _graphView.AxisX.Increment *= factor; - _graphView.AxisY.Increment *= factor; + graphView.AxisX.Increment *= factor; + graphView.AxisY.Increment *= factor; - _graphView.SetNeedsDraw (); + graphView.SetNeedsDraw (); } private sealed class DiscoBarSeries : BarSeries @@ -968,13 +806,13 @@ protected override void DrawBarLine (GraphView graph, Point start, Point end, Ba float height = graph.ViewportToGraphSpace (x, y).Y; Attribute attr = height switch - { - >= 85 => _red, - >= 66 => _brightRed, - >= 45 => _brightYellow, - >= 25 => _brightGreen, - _ => _green - }; + { + >= 85 => _red, + >= 66 => _brightRed, + >= 45 => _brightYellow, + >= 25 => _brightGreen, + _ => _green + }; graph.SetAttribute (attr); graph.AddRune (x, y, beingDrawn.Fill.Rune); diff --git a/Examples/UICatalog/Scenarios/Images.cs b/Examples/UICatalog/Scenarios/Images.cs index a4f3ad297d..5bf55bacb3 100644 --- a/Examples/UICatalog/Scenarios/Images.cs +++ b/Examples/UICatalog/Scenarios/Images.cs @@ -37,8 +37,7 @@ public class Images : Scenario /// private View _sixelNotSupported; - private Tab _tabSixel; - private TabView _tabView; + private View _tabSixel; /// /// The view into which the currently opened sixel image is bounded @@ -71,14 +70,18 @@ public override void Main () bool canTrueColor = app.Driver?.SupportsTrueColor ?? false; - Tab tabBasic = new () + View tabBasic = new () { - DisplayText = "Basic" + Title = "Basic", + Width = Dim.Fill (), + Height = Dim.Fill () }; - _tabSixel = new Tab + _tabSixel = new () { - DisplayText = "Sixel" + Title = "Sixel", + Width = Dim.Fill (), + Height = Dim.Fill () }; Label lblDriverName = new () { X = 0, Y = 0, Text = $"Driver is {app.Driver?.GetType ().Name}" }; @@ -136,13 +139,8 @@ public override void Main () Button btnOpenImage = new () { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; _win.Add (btnOpenImage); - _tabView = new TabView - { - Y = Pos.Bottom (lblSupportsSixel), Width = Dim.Fill (), Height = Dim.Fill () - }; - - _tabView.AddTab (tabBasic, true); - _tabView.AddTab (_tabSixel, false); + tabBasic.Y = Pos.Bottom (lblSupportsSixel); + _tabSixel.Y = Pos.Bottom (lblSupportsSixel); BuildBasicTab (tabBasic); BuildSixelTab (); @@ -152,7 +150,8 @@ public override void Main () btnOpenImage.Accepting += OpenImage; _win.Add (lblSupportsSixel); - _win.Add (_tabView); + _win.Add (tabBasic); + _win.Add (_tabSixel); // Start trying to detect sixel support SixelSupportDetector sixelSupportDetector = new (app.Driver); @@ -209,8 +208,9 @@ private void UpdateSixelSupportState (SixelSupportResult newResult) private void SetupSixelSupported (bool isSupported) { - _tabSixel.View = isSupported ? _sixelSupported : _sixelNotSupported; - _tabView.SetNeedsDraw (); + _tabSixel.RemoveAll (); + _tabSixel.Add (isSupported ? _sixelSupported : _sixelNotSupported); + _tabSixel.SetNeedsDraw (); } private void BtnStartFireOnAccept (object sender, CommandEventArgs e) @@ -346,14 +346,10 @@ private void OpenImage (object sender, CommandEventArgs e) private void ApplyShowTabViewHack () { - // TODO HACK: This hack seems to be required to make tabview actually refresh itself - _tabView.SetNeedsDraw (); - Tab orig = _tabView.SelectedTab; - _tabView.SelectedTab = _tabView.Tabs.Except ([orig]).ElementAt (0); - _tabView.SelectedTab = orig; + _win.SetNeedsDraw (); } - private void BuildBasicTab (Tab tabBasic) + private void BuildBasicTab (View tabBasic) { _imageView = new ImageView { @@ -362,7 +358,7 @@ private void BuildBasicTab (Tab tabBasic) CanFocus = true }; - tabBasic.View = _imageView; + tabBasic.Add (_imageView); } private void BuildSixelTab () diff --git a/Examples/UICatalog/Scenarios/Navigation.cs b/Examples/UICatalog/Scenarios/Navigation.cs index d443aad0a8..32ab482b5b 100644 --- a/Examples/UICatalog/Scenarios/Navigation.cs +++ b/Examples/UICatalog/Scenarios/Navigation.cs @@ -18,6 +18,7 @@ public override void Main () app.Init (); using Window window = new (); + window.SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog); window.Title = GetQuitKeyAndName (); window.TabStop = TabBehavior.TabGroup; diff --git a/Examples/UICatalog/Scenarios/Notepad.cs b/Examples/UICatalog/Scenarios/Notepad.cs index e8fca0ab44..e8664ff9fb 100644 --- a/Examples/UICatalog/Scenarios/Notepad.cs +++ b/Examples/UICatalog/Scenarios/Notepad.cs @@ -2,16 +2,16 @@ namespace UICatalog.Scenarios; -[ScenarioMetadata ("Notepad", "Multi-tab text editor using the TabView control.")] +[ScenarioMetadata ("Notepad", "Multi-tab text editor using the Tabs control.")] [ScenarioCategory ("Controls")] -[ScenarioCategory ("TabView")] +[ScenarioCategory ("Tabs")] [ScenarioCategory ("TextView")] public class Notepad : Scenario { private IApplication? _app; - private TabView? _focusedTabView; + private Tabs? _focusedTabs; private int _numNewTabs = 1; - private TabView? _tabView; + private Tabs? _tabs; private Window? _topWindow; public Shortcut? LenShortcut { get; private set; } @@ -39,15 +39,7 @@ public override void Main () menu.Add (new MenuBarItem ("_About", [new MenuItem { Title = "_About", Action = () => MessageBox.Query (app, "Notepad", "About Notepad...", "Ok") }])); - _tabView = CreateNewTabView (); - - _tabView.Style.ShowBorder = true; - _tabView.ApplyStyleChanges (); - - _tabView.X = 0; - _tabView.Y = Pos.Bottom (menu); - _tabView.Width = Dim.Fill (); - _tabView.Height = Dim.Fill (1); + _tabs = new Tabs { X = 0, Y = Pos.Bottom (menu), Width = Dim.Fill (), Height = Dim.Fill (1) }; LenShortcut = new Shortcut (Key.Empty, "Len: ", null); @@ -62,18 +54,22 @@ public override void Main () LenShortcut ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; - _topWindow.Add (menu, _tabView, statusBar); + _topWindow.Add (menu, _tabs, statusBar); - _focusedTabView = _tabView; - _tabView.SelectedTabChanged += TabView_SelectedTabChanged; - _tabView.HasFocusChanging += (_, _) => _focusedTabView = _tabView; + _focusedTabs = _tabs; + _tabs.ValueChanged += Tabs_ValueChanged; + _tabs.HasFocusChanging += (_, _) => _focusedTabs = _tabs; _topWindow.IsModalChanged += (_, e) => { if (e.Value) { New (); - LenShortcut.Title = $"Len:{_focusedTabView?.Text.Length ?? 0}"; + LenShortcut.Title = $"Len:{GetSelectedTextLength ()}"; + } + else + { + _tabs.ValueChanged -= Tabs_ValueChanged; } }; @@ -83,34 +79,29 @@ public override void Main () public void Save () { - if (_focusedTabView?.SelectedTab is { }) + if (_focusedTabs?.Value is OpenedFile tab) { - Save (_focusedTabView, _focusedTabView.SelectedTab); + Save (_focusedTabs, tab); } } - public void Save (TabView tabViewToSave, Tab tabToSave) + private void Save (Tabs tabsToSave, OpenedFile tabToSave) { - if (tabToSave is not OpenedFile tab) - { - return; - } - - if (tab.File is null) + if (tabToSave.File is null) { SaveAs (); } else { - tab.Save (); + tabToSave.Save (); } - tabViewToSave.SetNeedsDraw (); + tabsToSave.SetNeedsDraw (); } public bool SaveAs () { - if (_focusedTabView?.SelectedTab is not OpenedFile tab) + if (_focusedTabs?.Value is not OpenedFile tab) { return false; } @@ -126,7 +117,7 @@ public bool SaveAs () } tab.File = new FileInfo (fd.Path); - tab.Text = fd.FileName; + tab.Title = fd.FileName; tab.Save (); fd.Dispose (); @@ -136,24 +127,19 @@ public bool SaveAs () private void Close () { - if (_focusedTabView?.SelectedTab is { }) + if (_focusedTabs?.Value is OpenedFile tab) { - Close (_focusedTabView, _focusedTabView.SelectedTab); + Close (_focusedTabs, tab); } } - private void Close (TabView tv, Tab tabToClose) + private void Close (Tabs tabs, OpenedFile tabToClose) { - if (tabToClose is not OpenedFile tab) - { - return; - } - - _focusedTabView = tv; + _focusedTabs = tabs; - if (tab.UnsavedChanges) + if (tabToClose.UnsavedChanges) { - int? result = MessageBox.Query (tv.App!, "Save Changes", $"Save changes to {tab.Text.TrimEnd ('*')}", "Yes", "No", "Cancel"); + int? result = MessageBox.Query (tabs.App!, "Save Changes", $"Save changes to {tabToClose.Title.TrimEnd ('*')}", "Yes", "No", "Cancel"); if (result is null or 2) { @@ -163,40 +149,29 @@ private void Close (TabView tv, Tab tabToClose) if (result == 0) { - if (tab.File is null) + if (tabToClose.File is null) { SaveAs (); } else { - tab.Save (); + tabToClose.Save (); } } } // close and dispose the tab - tv.RemoveTab (tab); - tab.View?.Dispose (); - _focusedTabView = tv; + tabs.Remove (tabToClose); + tabToClose.Dispose (); + _focusedTabs = tabs; // If last tab is closed, open a new one - if (tv.Tabs.Count == 0) + if (!tabs.TabCollection.Any ()) { New (); } } - private TabView CreateNewTabView () - { - TabView tv = new () { X = 0, Y = 0, Width = Dim.Fill (), Height = Dim.Fill () }; - - tv.TabClicked += TabView_TabClicked; - tv.SelectedTabChanged += TabView_SelectedTabChanged; - tv.HasFocusChanging += (_, _) => _focusedTabView = tv; - - return tv; - } - private void New () => Open (null!, $"new {_numNewTabs++}"); private void Open () @@ -216,7 +191,6 @@ private void Open () break; } - // TODO should open in focused TabView Open (new FileInfo (path), Path.GetFileName (path)); } } @@ -224,91 +198,70 @@ private void Open () open.Dispose (); } - /// Creates a new tab with initial text - /// File that was read or null if a new blank document - /// + /// Creates a new tab with initial text. + /// File that was read or null if a new blank document. + /// Display name for the tab. private void Open (FileInfo? fileInfo, string tabName) { - if (_focusedTabView is null) + if (_focusedTabs is null) { return; } - OpenedFile tab = new (this) { DisplayText = tabName, File = fileInfo }; - tab.View = tab.CreateTextView (fileInfo); - tab.SavedText = tab.View.Text; - tab.RegisterTextViewEvents (_focusedTabView); + OpenedFile tab = new (this) { Title = tabName, File = fileInfo }; + tab.CreateAndAddTextView (fileInfo); + tab.RegisterTextViewEvents (); - _focusedTabView.AddTab (tab, true); + _focusedTabs.Add (tab); + _focusedTabs.Value = tab; } private void Quit () => _topWindow?.RequestStop (); - private void TabView_SelectedTabChanged (object? sender, TabChangedEventArgs e) + private int GetSelectedTextLength () { - if (LenShortcut is { }) + if (_focusedTabs?.Value is OpenedFile tab) { - LenShortcut.Title = $"Len:{e.NewTab?.View?.Text.Length ?? 0}"; + return tab.TextView?.Text.Length ?? 0; } - e.NewTab?.View?.SetFocus (); + return 0; } - private void TabView_TabClicked (object? sender, TabMouseEventArgs e) + private void Tabs_ValueChanged (object? sender, ValueChangedEventArgs e) { - // we are only interested in right clicks - if (!e.MouseEvent.Flags.HasFlag (MouseFlags.RightButtonClicked)) - { - return; - } - - View [] items; - - if (e.Tab is null) - { - items = [new MenuItem { Title = "Open", Action = Open }]; - } - else + if (LenShortcut is { }) { - var tv = (TabView)sender!; - - items = - [ - new MenuItem { Title = "Save", Action = () => Save (_focusedTabView!, e.Tab) }, - new MenuItem { Title = "Close", Action = () => Close (tv, e.Tab) } - ]; - } + var len = 0; - PopoverMenu contextMenu = new (items); + if (e.NewValue is OpenedFile tab) + { + len = tab.TextView?.Text.Length ?? 0; + } - // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused - // and the context menu is disposed when it is closed. - if (sender is TabView tabView && tabView.App?.Popovers is { }) - { - tabView.App.Popovers.Register (contextMenu); + LenShortcut.Title = $"Len:{len}"; } - contextMenu.MakeVisible (e.MouseEvent.ScreenPosition); - - e.MouseEvent.Handled = true; + //if (e.NewValue is OpenedFile openedTab) + //{ + // openedTab.TextView?.SetFocus (); + //} } - private class OpenedFile (Notepad notepad) : Tab + private class OpenedFile (Notepad notepad) : View { private readonly Notepad _notepad = notepad; - public OpenedFile CloneTo (TabView other) - { - OpenedFile newTab = new (_notepad) { DisplayText = Text, File = File }; - newTab.View = newTab.CreateTextView (newTab.File); - newTab.SavedText = newTab.View.Text; - newTab.RegisterTextViewEvents (other); - other.AddTab (newTab, true); + public FileInfo? File { get; set; } - return newTab; - } + public TextView? TextView { get; private set; } + + /// The text of the tab the last time it was saved. + public string? SavedText { get; set; } - public View CreateTextView (FileInfo? file) + public bool UnsavedChanges => TextView is { } && !string.Equals (SavedText, TextView.Text); + + public void CreateAndAddTextView (FileInfo? file) { var initialText = string.Empty; @@ -317,7 +270,7 @@ public View CreateTextView (FileInfo? file) initialText = System.IO.File.ReadAllText (file.FullName); } - return new TextView + TextView = new TextView { X = 0, Y = 0, @@ -326,63 +279,60 @@ public View CreateTextView (FileInfo? file) Text = initialText, TabKeyAddsTab = false }; - } - public FileInfo? File { get; set; } + SavedText = initialText; - public void RegisterTextViewEvents (TabView parent) + Add (TextView); + } + + public void RegisterTextViewEvents () { - if (View is not TextView textView) + if (TextView is null) { return; } // when user makes changes rename tab to indicate unsaved - textView.ContentsChanged += (_, _) => + TextView.ContentsChanged += (_, _) => { // if current text doesn't match saved text bool areDiff = UnsavedChanges; if (areDiff) { - if (!DisplayText.EndsWith ('*')) + if (!Title.EndsWith ('*')) { - DisplayText = Text + '*'; + Title = Title + "*"; } } else { - if (DisplayText.EndsWith ('*')) + if (Title.EndsWith ('*')) { - DisplayText = Text.TrimEnd ('*'); + Title = Title.TrimEnd ('*'); } } if (_notepad.LenShortcut is { }) { - _notepad.LenShortcut.Title = $"Len:{textView.Text.Length}"; + _notepad.LenShortcut.Title = $"Len:{TextView.Text.Length}"; } }; } - /// The text of the tab the last time it was saved - public string? SavedText { get; set; } - - public bool UnsavedChanges => View is { } && !string.Equals (SavedText, View.Text); - internal void Save () { - if (View is null || File is null || string.IsNullOrWhiteSpace (File.FullName)) + if (TextView is null || File is null || string.IsNullOrWhiteSpace (File.FullName)) { return; } - string newText = View.Text; + string newText = TextView.Text; System.IO.File.WriteAllText (File.FullName, newText); SavedText = newText; - DisplayText = DisplayText.TrimEnd ('*'); + Title = Title.TrimEnd ('*'); } } } diff --git a/Examples/UICatalog/Scenarios/Popovers.cs b/Examples/UICatalog/Scenarios/Popovers.cs index c61b4b3901..95cf1ffba3 100644 --- a/Examples/UICatalog/Scenarios/Popovers.cs +++ b/Examples/UICatalog/Scenarios/Popovers.cs @@ -137,8 +137,8 @@ private void ShowPopover (object? sender, CommandEventArgs args) try { - Point idealPosition = _resultTextField!.FrameToScreen ().Location; - idealPosition.Y += _resultTextField!.Frame.Height; + Point idealPosition = _resultTextField?.FrameToScreen ().Location ?? Point.Empty; + idealPosition.Y += _resultTextField?.Frame.Height ?? 0; popover.MakeVisible (idealPosition); _eventLog?.Log ($"Showed: {_viewClasses.Keys.ElementAt (selectedIndex)}"); } diff --git a/Examples/UICatalog/Scenarios/TabViewExample.cs b/Examples/UICatalog/Scenarios/TabViewExample.cs deleted file mode 100644 index cdfc27e99d..0000000000 --- a/Examples/UICatalog/Scenarios/TabViewExample.cs +++ /dev/null @@ -1,326 +0,0 @@ -#nullable enable - -using System.Linq; -using System.Text; - -namespace UICatalog.Scenarios; - -[ScenarioMetadata ("Tab View", "Demos TabView control with limited screen space in Absolute layout.")] -[ScenarioCategory ("Controls")] -[ScenarioCategory ("TabView")] -public class TabViewExample : Scenario -{ - private CheckBox? _miShowBorderCheckBox; - private CheckBox? _miShowTabViewBorderCheckBox; - private CheckBox? _miShowTopLineCheckBox; - private CheckBox? _miTabsOnBottomCheckBox; - private TabView? _tabView; - - public override void Main () - { - ConfigurationManager.Enable (ConfigLocations.All); - - using IApplication app = Application.Create (); - app.Init (); - - using Window appWindow = new () - { - BorderStyle = LineStyle.None - }; - - // MenuBar - MenuBar menu = new (); - - _tabView = new () - { - Title = "_Tab View", - X = 0, - Y = Pos.Bottom (menu), - Width = 60, - Height = 20, - BorderStyle = LineStyle.Single - }; - - _tabView.AddTab (new () { DisplayText = "Tab_1", View = new Label { Text = "hodor!" } }, false); - _tabView.AddTab (new () { DisplayText = "Tab_2", View = new TextField { Text = "durdur", Width = 10 } }, false); - _tabView.AddTab (new () { DisplayText = "_Interactive Tab", View = GetInteractiveTab () }, false); - _tabView.AddTab (new () { DisplayText = "Big Text", View = GetBigTextFileTab () }, false); - - _tabView.AddTab ( - new () - { - DisplayText = - "Long name Tab, I mean seriously long. Like you would not believe how long this tab's name is its just too much really woooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooowwww thats long", - View = new Label - { - Text = - "This tab has a very long name which should be truncated. See TabView.MaxTabTextWidth" - } - }, - false - ); - - _tabView.AddTab ( - new () - { - DisplayText = "Les Mise" + '\u0301' + "rables", - View = new Label { Text = "This tab name is unicode" } - }, - false - ); - - _tabView.AddTab ( - new () - { - DisplayText = "Les Mise" + '\u0328' + '\u0301' + "rables", - View = new Label - { - Text = - "This tab name has two combining marks. Only one will show due to Issue #2616." - } - }, - false - ); - - for (var i = 0; i < 100; i++) - { - _tabView.AddTab ( - new () { DisplayText = $"Tab{i}", View = new Label { Text = $"Welcome to tab {i}" } }, - false - ); - } - - _tabView.SelectedTab = _tabView.Tabs.First (); - - View frameRight = new () - { - X = Pos.Right (_tabView), - Y = Pos.Top (_tabView), - Width = Dim.Fill (), - Height = Dim.Fill (1), - Title = "_About", - BorderStyle = LineStyle.Single, - TabStop = TabBehavior.TabStop, - CanFocus = true - }; - - frameRight.Add ( - new TextView - { - Text = - "This demos the tabs control\nSwitch between tabs using cursor keys.\nThis TextView has AllowsTab = false, so tab should nav too.", - Width = Dim.Fill (), - Height = Dim.Fill (), - TabKeyAddsTab = false - } - ); - - View frameBelow = new () - { - X = 0, - Y = Pos.Bottom (_tabView), - Width = _tabView.Width, - Height = Dim.Fill (1), - Title = "B_ottom Frame", - BorderStyle = LineStyle.Single, - TabStop = TabBehavior.TabStop, - CanFocus = true - }; - - frameBelow.Add ( - new TextView - { - Text = - "This frame exists to check that you can still tab here\nand that the tab control doesn't overspill it's bounds\nAllowsTab is true.", - Width = Dim.Fill (), - Height = Dim.Fill () - } - ); - - // StatusBar - StatusBar statusBar = new ( - [ - new (Application.GetDefaultKey (Command.Quit), "Quit", Quit) - ] - ); - - // Setup menu checkboxes - _miShowTopLineCheckBox = new () - { - Title = "_Show Top Line", - Value = CheckState.Checked - }; - _miShowTopLineCheckBox.ValueChanged += (_, _) => ShowTopLine (); - - _miShowBorderCheckBox = new () - { - Title = "_Show Border", - Value = CheckState.Checked - }; - _miShowBorderCheckBox.ValueChanged += (_, _) => ShowBorder (); - - _miTabsOnBottomCheckBox = new () - { - Title = "_Tabs On Bottom" - }; - _miTabsOnBottomCheckBox.ValueChanged += (_, _) => SetTabsOnBottom (); - - _miShowTabViewBorderCheckBox = new () - { - Title = "_Show TabView Border", - Value = CheckState.Checked - }; - _miShowTabViewBorderCheckBox.ValueChanged += (_, _) => ShowTabViewBorder (); - - menu.Add ( - new MenuBarItem ( - Strings.menuFile, - [ - new MenuItem - { - Title = "_Add Blank Tab", - Action = AddBlankTab - }, - new MenuItem - { - Title = "_Clear SelectedTab", - Action = () => - { - if (_tabView is not null) - { - _tabView.SelectedTab = null; - } - } - }, - new MenuItem - { - Title = Strings.cmdQuit, - Action = Quit - } - ] - ) - ); - - menu.Add ( - new MenuBarItem ( - "_View", - [ - new MenuItem - { - CommandView = _miShowTopLineCheckBox - }, - new MenuItem - { - CommandView = _miShowBorderCheckBox - }, - new MenuItem - { - CommandView = _miTabsOnBottomCheckBox - }, - new MenuItem - { - CommandView = _miShowTabViewBorderCheckBox - } - ] - ) - ); - - appWindow.Add (menu, _tabView, frameRight, frameBelow, statusBar); - - app.Run (appWindow); - } - - private void AddBlankTab () { _tabView?.AddTab (new (), false); } - - private View GetBigTextFileTab () - { - TextView text = new () { Width = Dim.Fill (), Height = Dim.Fill () }; - - StringBuilder sb = new (); - - for (var y = 0; y < 300; y++) - { - for (var x = 0; x < 500; x++) - { - sb.Append ((x + y) % 2 == 0 ? '1' : '0'); - } - - sb.AppendLine (); - } - - text.Text = sb.ToString (); - - return text; - } - - private View GetInteractiveTab () - { - View interactiveTab = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - CanFocus = true - }; - Label lblName = new () { Text = "Name:" }; - interactiveTab.Add (lblName); - - TextField tbName = new () { X = Pos.Right (lblName), Width = 10 }; - interactiveTab.Add (tbName); - - Label lblAddr = new () { Y = 1, Text = "Address:" }; - interactiveTab.Add (lblAddr); - - TextField tbAddr = new () { X = Pos.Right (lblAddr), Y = 1, Width = 10 }; - interactiveTab.Add (tbAddr); - - return interactiveTab; - } - - private void Quit () { _tabView?.App?.RequestStop (); } - - private void SetTabsOnBottom () - { - if (_tabView is null || _miTabsOnBottomCheckBox is null) - { - return; - } - - _tabView.Style.TabsOnBottom = _miTabsOnBottomCheckBox.Value == CheckState.Checked; - _tabView.ApplyStyleChanges (); - } - - private void ShowBorder () - { - if (_tabView is null || _miShowBorderCheckBox is null) - { - return; - } - - _tabView.Style.ShowBorder = _miShowBorderCheckBox.Value == CheckState.Checked; - _tabView.ApplyStyleChanges (); - } - - private void ShowTabViewBorder () - { - if (_tabView is null || _miShowTabViewBorderCheckBox is null) - { - return; - } - - _tabView.BorderStyle = _miShowTabViewBorderCheckBox.Value == CheckState.Checked - ? LineStyle.Single - : LineStyle.None; - _tabView.ApplyStyleChanges (); - } - - private void ShowTopLine () - { - if (_tabView is null || _miShowTopLineCheckBox is null) - { - return; - } - - _tabView.Style.ShowTopLine = _miShowTopLineCheckBox.Value == CheckState.Checked; - _tabView.ApplyStyleChanges (); - } -} diff --git a/Examples/UICatalog/Scenarios/TabsExample.cs b/Examples/UICatalog/Scenarios/TabsExample.cs new file mode 100644 index 0000000000..c96c5d7818 --- /dev/null +++ b/Examples/UICatalog/Scenarios/TabsExample.cs @@ -0,0 +1,57 @@ +#nullable enable + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Tabs Example", "Demonstrates the Tabs View (a TabView replacement).")] +[ScenarioCategory ("Controls")] +[ScenarioCategory ("Layout")] +[ScenarioCategory ("Tabs")] + +public sealed class TabsExample : Scenario +{ + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + using IApplication app = Application.Create (); + app.Init (); + + using Window appWindow = new (); + appWindow.Title = GetQuitKeyAndName (); + appWindow.BorderStyle = LineStyle.None; + + // ── Main Tabs control ── + Tabs tabs = new () + { + X = 2, + Y = 3, + Title = "_Tabs", + Width = Dim.Percent (70), + Height = Dim.Percent (70) + }; + + tabs.EnableForDesign (); + + // ── Editors ── + AdornmentsEditor adornmentsEditor = new () + { + BorderStyle = LineStyle.Single, AutoSelectViewToEdit = true, Arrangement = ViewArrangement.Movable, X = Pos.AnchorEnd () + }; + adornmentsEditor.Border.Thickness = new Thickness (1, 2, 1, 1); + adornmentsEditor.AutoSelectSuperView = appWindow; + adornmentsEditor.AutoSelectAdornments = true; + adornmentsEditor.ShowViewIdentifier = true; + + ViewportSettingsEditor viewportSettingsEditor = new () + { + BorderStyle = LineStyle.Single, AutoSelectViewToEdit = true, Arrangement = ViewArrangement.Movable, Y = Pos.AnchorEnd () + }; + viewportSettingsEditor.Border.Thickness = new Thickness (1, 2, 1, 1); + viewportSettingsEditor.AutoSelectSuperView = appWindow; + viewportSettingsEditor.AutoSelectAdornments = true; + viewportSettingsEditor.ShowViewIdentifier = true; + + appWindow.Add (tabs, adornmentsEditor, viewportSettingsEditor); + + app.Run (appWindow); + } +} diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index cd9d6c8247..bcf9bfc4ea 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -540,6 +540,10 @@ private TableView CreateScenarioList () SuperViewRendersLineCanvas = true }; + //scenarioList.Border.Settings = BorderSettings.Title | BorderSettings.Tab; + //scenarioList.Border.TabSide = Side.Top; + //scenarioList.Border.Thickness = new Thickness (1, 2, 1, 1); + // TableView provides many options for table headers. For simplicity, we turn all // of these off. By enabling FullRowSelect and turning off headers, TableView looks just // like a ListView @@ -637,6 +641,9 @@ private ListView CreateCategoryList () SuperViewRendersLineCanvas = true, Source = new ListWrapper (CachedCategories) }; + //categoryList.Border.Settings = BorderSettings.Title | BorderSettings.Tab; + //categoryList.Border.TabSide = Side.Top; + //categoryList.Border.Thickness = new Thickness (1, 2, 1, 1); categoryList.Accepting += (_, e) => { diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index 8a0178196c..f2e9915336 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using Wcwidth; using Trace = Terminal.Gui.Tracing.Trace; namespace Terminal.Gui.App; @@ -31,6 +32,11 @@ public IApplication Init (string? driverName = null) Trace.Lifecycle (MainThreadId?.ToString (), "Init", $"driverName: {driverName}"); + // In Application.Init(), before starting the input thread: + // Pre-warm the Wcwidth static cache to prevent ZeroTable._lookup race condition + // See: https://github.com/spectreconsole/wcwidth/issues/11 + _ = UnicodeCalculator.GetWidth (new Rune ('A')); + // Thread-safe fence check: Ensure we're not mixing application models // Use lock to make check-and-set atomic lock (_modelUsageLock) diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs index f9c12956fd..e21ce2593b 100644 --- a/Terminal.Gui/App/ApplicationImpl.Screen.cs +++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs @@ -10,7 +10,7 @@ internal partial class ApplicationImpl /// public Rectangle Screen { - get => Driver?.Screen ?? new (new (0, 0), new (2048, 2048)); + get => Driver?.Screen ?? new Rectangle (new Point (0, 0), new Size (2048, 2048)); set { if (value is { } && (value.X != 0 || value.Y != 0)) @@ -34,7 +34,7 @@ private void RaiseScreenChangedEvent (Rectangle screen) { //Screen = new (Point.Empty, screen.Size); - ScreenChanged?.Invoke (this, new (screen)); + ScreenChanged?.Invoke (this, new EventArgs (screen)); foreach (SessionToken t in SessionStack!) { @@ -45,7 +45,7 @@ private void RaiseScreenChangedEvent (Rectangle screen) } } - private void Driver_SizeChanged (object? sender, SizeChangedEventArgs e) { RaiseScreenChangedEvent (new (new (0, 0), e.Size!.Value)); } + private void Driver_SizeChanged (object? sender, SizeChangedEventArgs e) => RaiseScreenChangedEvent (new Rectangle (new Point (0, 0), e.Size ?? Size.Empty)); /// public void LayoutAndDraw (bool forceRedraw = false) @@ -63,7 +63,11 @@ public void LayoutAndDraw (bool forceRedraw = false) Driver?.ClearContents (); } - List views = [.. SessionStack!.Select (r => r.Runnable! as View)!]; + if (SessionStack is null) + { + return; + } + List views = [.. SessionStack.Select (r => r.Runnable! as View)!]; if (Popovers?.GetActivePopover () is { Visible: true } visiblePopover) { @@ -87,13 +91,13 @@ public void LayoutAndDraw (bool forceRedraw = false) { Logging.Redraws.Add (1); - Driver.Clip = new (Screen); + Driver.Clip = new Region (Screen); // Only force a complete redraw if needed (needsLayout or forceRedraw). // Otherwise, just redraw views that need it. View.Draw (views: views.ToArray ().Cast (), neededLayout || forceRedraw); - Driver.Clip = new (Screen); + Driver.Clip = new Region (Screen); // Cause the driver to flush any pending updates to the terminal Driver?.Refresh (); diff --git a/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs b/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs index 53eb607337..be29bac3f4 100644 --- a/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs +++ b/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs @@ -73,7 +73,7 @@ public bool RaiseKeyUpEvent (Key key) return true; } - if (runnable!.IsModal) + if (runnable is { IsModal: true }) { break; } @@ -121,7 +121,7 @@ public bool RaiseKeyDownEvent (Key key) return true; } - if (runnable!.IsModal) + if (runnable is { IsModal: true }) { break; } diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index b945a690c3..e3d7c11f6e 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -142,7 +142,7 @@ private void BuildDriverIfPossible (IApplication? app) // - Console events (WindowsDriver) _loop.SizeMonitor.Initialize (_driver); - app!.Driver = _driver; + app?.Driver = _driver; // Detect terminal color capabilities from environment variables TerminalColorCapabilities caps = TerminalEnvironmentDetector.DetectColorCapabilities (); @@ -209,7 +209,7 @@ private void BuildDriverIfPossible (IApplication? app) } _startupSemaphore.Release (); - Logging.Trace ($"app: {app.MainThreadId} Driver: _input: {_input}, _output: {_output}"); + Logging.Trace ($"app: {app?.MainThreadId} Driver: _input: {_input}, _output: {_output}"); } /// diff --git a/Terminal.Gui/App/Popovers/PopoverImpl.cs b/Terminal.Gui/App/Popovers/PopoverImpl.cs index 86c02d14bc..18462aab32 100644 --- a/Terminal.Gui/App/Popovers/PopoverImpl.cs +++ b/Terminal.Gui/App/Popovers/PopoverImpl.cs @@ -180,7 +180,7 @@ public virtual void MakeVisible (Point? idealScreenPosition = null, Rectangle? a } Layout (); - App!.Popovers?.Show (this); + App?.Popovers?.Show (this); } /// diff --git a/Terminal.Gui/Drawing/Color/Color.cs b/Terminal.Gui/Drawing/Color/Color.cs index 3472a1d0b5..65c31143ac 100644 --- a/Terminal.Gui/Drawing/Color/Color.cs +++ b/Terminal.Gui/Drawing/Color/Color.cs @@ -27,9 +27,15 @@ namespace Terminal.Gui.Drawing; /// While Terminal.Gui does not currently support alpha blending during rendering, the alpha channel /// is used to indicate rendering intent: /// -/// Alpha = 0: Fully transparent (don't render) -/// Alpha = 255: Fully opaque (normal rendering) -/// Other values: Reserved for future alpha blending support +/// +/// Alpha = 0: Fully transparent (don't render) +/// +/// +/// Alpha = 255: Fully opaque (normal rendering) +/// +/// +/// Other values: Reserved for future alpha blending support +/// /// /// /// @@ -38,8 +44,7 @@ namespace Terminal.Gui.Drawing; /// [JsonConverter (typeof (ColorJsonConverter))] [StructLayout (LayoutKind.Explicit)] -public readonly partial record struct Color : ISpanParsable, IUtf8SpanParsable, ISpanFormattable, - IUtf8SpanFormattable, IMinMaxValue +public readonly partial record struct Color : ISpanParsable, IUtf8SpanParsable, ISpanFormattable, IUtf8SpanFormattable, IMinMaxValue { /// /// No color (alpha = 0). When used in an , the terminal's default @@ -51,6 +56,7 @@ namespace Terminal.Gui.Drawing; /// default(Color) which has all bytes zeroed. /// public static readonly Color None = new (255, 255, 255, 0); + /// The value of the alpha channel component /// /// @@ -110,10 +116,10 @@ namespace Terminal.Gui.Drawing; /// If the value of any parameter is negative. public Color (int red = 0, int green = 0, int blue = 0, int alpha = byte.MaxValue) { - ArgumentOutOfRangeException.ThrowIfNegative (red, nameof (red)); - ArgumentOutOfRangeException.ThrowIfNegative (green, nameof (green)); - ArgumentOutOfRangeException.ThrowIfNegative (blue, nameof (blue)); - ArgumentOutOfRangeException.ThrowIfNegative (alpha, nameof (alpha)); + ArgumentOutOfRangeException.ThrowIfNegative (red); + ArgumentOutOfRangeException.ThrowIfNegative (green); + ArgumentOutOfRangeException.ThrowIfNegative (blue); + ArgumentOutOfRangeException.ThrowIfNegative (alpha); A = Convert.ToByte (alpha); R = Convert.ToByte (red); @@ -130,7 +136,7 @@ public Color (int red = 0, int green = 0, int blue = 0, int alpha = byte.MaxValu /// The alpha channel is not currently supported, so the value of the alpha channel bits will not affect /// rendering. /// - public Color (int rgba) { Rgba = rgba; } + public Color (int rgba) => Rgba = rgba; /// /// Initializes a new instance of the class with an encoded unsigned 32-bit color value in @@ -141,14 +147,16 @@ public Color (int red = 0, int green = 0, int blue = 0, int alpha = byte.MaxValu /// The alpha channel is not currently supported, so the value of the alpha channel bits will not affect /// rendering. /// - public Color (uint argb) { Argb = argb; } + public Color (uint argb) => Argb = argb; /// Initializes a new instance of the color from a legacy 16-color named value. /// The 16-color value. - public Color (in ColorName16 colorName) { this = ColorExtensions.ColorName16ToColorMap! [colorName]; } - + public Color (in ColorName16 colorName) => this = ColorExtensions.ColorName16ToColorMap! [colorName]; - /// Initializes a new instance of the color from a value in the enum. + /// + /// Initializes a new instance of the color from a value in the + /// enum. + /// /// The 16-color value. public Color (in StandardColor colorName) : this (StandardColors.GetArgb (colorName)) { } @@ -165,12 +173,12 @@ public Color (in StandardColor colorName) : this (StandardColors.GetArgb (colorN /// If thrown by public Color (string colorString) { - ArgumentException.ThrowIfNullOrWhiteSpace (colorString, nameof (colorString)); + ArgumentException.ThrowIfNullOrWhiteSpace (colorString); this = Parse (colorString, CultureInfo.InvariantCulture); } /// Initializes a new instance of the with all channels set to 0. - public Color () { Argb = 0u; } + public Color () => Argb = 0u; /// Gets or sets the 3-byte/6-character hexadecimal value for each of the legacy 16-color values. [ConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] @@ -187,14 +195,12 @@ public static Dictionary Colors16 return; - static Color GetColorToNameMapKey (KeyValuePair kvp) { return new (kvp.Value); } + static Color GetColorToNameMapKey (KeyValuePair kvp) => new (kvp.Value); - static ColorName16 GetColorToNameMapValue (KeyValuePair kvp) - { - return Enum.TryParse (kvp.Key.ToString (), true, out ColorName16 colorName) - ? colorName - : throw new ArgumentException ($"Invalid color name: {kvp.Key}"); - } + static ColorName16 GetColorToNameMapValue (KeyValuePair kvp) => + Enum.TryParse (kvp.Key.ToString (), true, out ColorName16 colorName) + ? colorName + : throw new ArgumentException ($"Invalid color name: {kvp.Key}"); } } @@ -206,7 +212,7 @@ static ColorName16 GetColorToNameMapValue (KeyValuePair kvp /// Get returns the of the closest 24-bit color value. Set sets the RGB /// value using a hard-coded map. /// - public AnsiColorCode GetAnsiColorCode () { return ColorExtensions.ColorName16ToAnsiColorMap [GetClosestNamedColor16 ()]; } + public AnsiColorCode GetAnsiColorCode () => ColorExtensions.ColorName16ToAnsiColorMap [GetClosestNamedColor16 ()]; /// /// Gets the using a legacy 16-color value. @@ -217,7 +223,7 @@ static ColorName16 GetColorToNameMapValue (KeyValuePair kvp /// sets the RGB /// value using a hard-coded map. /// - public ColorName16 GetClosestNamedColor16 () { return GetClosestNamedColor16 (this); } + public ColorName16 GetClosestNamedColor16 () => GetClosestNamedColor16 (this); /// /// Determines if the closest named to is the provided @@ -237,7 +243,7 @@ static ColorName16 GetColorToNameMapValue (KeyValuePair kvp /// [Pure] [MethodImpl (MethodImplOptions.AggressiveInlining)] - public bool IsClosestToNamedColor16 (in ColorName16 namedColor) { return GetClosestNamedColor16 () == namedColor; } + public bool IsClosestToNamedColor16 (in ColorName16 namedColor) => GetClosestNamedColor16 () == namedColor; /// Gets the "closest" named color to this value. /// @@ -246,13 +252,11 @@ static ColorName16 GetColorToNameMapValue (KeyValuePair kvp /// /// [SkipLocalsInit] - internal static ColorName16 GetClosestNamedColor16 (Color inputColor) - { - return ColorExtensions.ColorToName16Map!.MinBy (pair => CalculateColorDistance (inputColor, pair.Key)).Value; - } + internal static ColorName16 GetClosestNamedColor16 (Color inputColor) => + ColorExtensions.ColorToName16Map!.MinBy (pair => CalculateColorDistance (inputColor, pair.Key)).Value; [SkipLocalsInit] - private static float CalculateColorDistance (in Vector4 color1, in Vector4 color2) { return Vector4.Distance (color1, color2); } + private static float CalculateColorDistance (in Vector4 color1, in Vector4 color2) => Vector4.Distance (color1, color2); /// /// Returns a "highlighted" version of this color — visually more prominent against @@ -287,12 +291,12 @@ internal static ColorName16 GetClosestNamedColor16 (Color inputColor) /// public Color GetBrighterColor (double brightenAmount = 0.2, bool? isDarkBackground = null) { - HSL hsl = ColorConverter.RgbToHsl (new (R, G, B)); + HSL hsl = ColorConverter.RgbToHsl (new RGB (R, G, B)); double lNorm = hsl.L / 100.0; // Determine direction: on dark bg, brighten (increase L); on light bg, darken (decrease L) - bool shouldIncrease = isDarkBackground ?? (lNorm < 0.5); + bool shouldIncrease = isDarkBackground ?? lNorm < 0.5; double newL = shouldIncrease ? Math.Min (1.0, lNorm + brightenAmount) : Math.Max (0.0, lNorm - brightenAmount); @@ -304,7 +308,7 @@ public Color GetBrighterColor (double brightenAmount = 0.2, bool? isDarkBackgrou HSL newHsl = new (hsl.H, hsl.S, (byte)(newL * 100)); RGB rgb = ColorConverter.HslToRgb (newHsl); - return new (rgb.R, rgb.G, rgb.B); + return new Color (rgb.R, rgb.G, rgb.B); } /// @@ -343,7 +347,7 @@ public Color GetBrighterColor (double brightenAmount = 0.2, bool? isDarkBackgrou /// public Color GetDimmerColor (double dimAmount = 0.2, bool? isDarkBackground = null) { - HSL hsl = ColorConverter.RgbToHsl (new (R, G, B)); + HSL hsl = ColorConverter.RgbToHsl (new RGB (R, G, B)); double lNorm = hsl.L / 100.0; @@ -354,12 +358,12 @@ public Color GetDimmerColor (double dimAmount = 0.2, bool? isDarkBackground = nu // Note: ColorHelper's HSL uses L in range 0-100. if (shouldDecrease && hsl.L <= 10) { - return new (ColorName16.DarkGray); + return new Color (ColorName16.DarkGray); } if (!shouldDecrease && hsl.L >= 90) { - return new (ColorName16.Gray); + return new Color (ColorName16.Gray); } double newL = shouldDecrease ? Math.Max (0.0, lNorm - dimAmount) : Math.Min (1.0, lNorm + dimAmount); @@ -373,7 +377,7 @@ public Color GetDimmerColor (double dimAmount = 0.2, bool? isDarkBackground = nu HSL newHsl = new (hsl.H, hsl.S, (byte)(newL * 100)); RGB rgb = ColorConverter.HslToRgb (newHsl); - return new (rgb.R, rgb.G, rgb.B); + return new Color (rgb.R, rgb.G, rgb.B); } /// @@ -384,7 +388,7 @@ public Color GetDimmerColor (double dimAmount = 0.2, bool? isDarkBackground = nu /// public bool IsDarkColor () { - HSL hsl = ColorConverter.RgbToHsl (new (R, G, B)); + HSL hsl = ColorConverter.RgbToHsl (new RGB (R, G, B)); // ColorHelper's HSL uses L in range 0-100 return hsl.L < 50; diff --git a/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs b/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs index ed3e529ec4..0929ab9564 100644 --- a/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs +++ b/Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs @@ -1,25 +1,77 @@ - using System.Runtime.InteropServices; namespace Terminal.Gui.Drawing; -/// Facilitates box drawing and line intersection detection and rendering. Does not support diagonal lines. +/// +/// A canvas for composing box-drawing and line-art characters with automatic intersection resolution. See +/// Drawing Deep Dive for an in-depth look at the design and usage of this class. +/// +/// +/// +/// is the core rendering primitive for borders, frames, and any box-drawing art +/// in Terminal.Gui. Lines are added via +/// and the canvas automatically resolves intersections — where two lines cross or meet, the correct Unicode +/// junction glyph (T, cross, corner, etc.) is produced. This makes it trivial to compose complex bordered +/// layouts without manually computing junction characters. +/// +/// +/// Merging and SuperViewRendersLineCanvas. When is +/// on a SubView, the SubView's is merged into the +/// SuperView's canvas via . All lines then participate in a single +/// intersection-resolution pass, producing seamless junctions across view boundaries. This is how +/// adjacent tab headers, nested frames, and other multi-view border compositions achieve connected +/// line art. +/// +/// +/// Exclusion regions (). Prevents resolved cells from appearing in +/// output. Lines still exist in the canvas and still participate in +/// intersection resolution (auto-join), but excluded positions are filtered out of the final +/// output. Use this when something else has already been drawn at a position — for example, +/// a title label on a border, or a SubView that renders its own +/// independently. +/// +/// +/// Reserved cells (). Marks positions as intentionally empty — +/// no line exists here and none should be rendered by other canvases either. Unlike +/// , reserved cells have no effect on this canvas's resolution or +/// output. They are metadata consumed during multi-canvas compositing +/// (see ): when multiple independently-resolved canvases +/// are layered, reserved cells claim their positions so that cells from canvases composited +/// later do not show through. Use this for intentional gaps in borders, such as the opening +/// where a focused tab header connects to the content area. +/// +/// +/// Clipped merge. The Merge(LineCanvas, Region?) overload supports merging with +/// an exclusion region that clips incoming lines at the line level — before intersection resolution. +/// Excluded cells are not added as lines and therefore do not participate in auto-join. Note that this +/// can fragment lines and produce incorrect junction glyphs; prefer per-canvas resolution with +/// and compositing for overlapped views. +/// +/// +/// Output. Call (or ) to resolve all intersections +/// and produce a dictionary mapping screen coordinates to the glyphs (with attributes) to render. +/// additionally returns a covering the drawn +/// cells, which is used for transparency tracking. +/// +/// +/// Does not support diagonal lines. All lines are axis-aligned (horizontal or vertical). +/// +/// public class LineCanvas : IDisposable { /// Creates a new instance. - public LineCanvas () - { + public LineCanvas () => + // TODO: Refactor ConfigurationManager to not use an event handler for this. // Instead, have it call a method on any class appropriately attributed // to update the cached values. See Issue #2871 ConfigurationManager.Applied += ConfigurationManager_Applied; - } private readonly List _lines = []; /// Creates a new instance with the given . /// Initial lines for the canvas. - public LineCanvas (IEnumerable lines) : this () { _lines = lines.ToList (); } + public LineCanvas (IEnumerable lines) : this () => _lines = lines.ToList (); /// /// Optional which when present overrides the @@ -38,32 +90,25 @@ public Rectangle Bounds { get { - if (_cachedBounds.IsEmpty) + if (!_cachedBounds.IsEmpty || _lines.Count == 0) { - if (_lines.Count == 0) - { - return _cachedBounds; - } - - Rectangle bounds = _lines [0].Bounds; + return _cachedBounds; + } - for (var i = 1; i < _lines.Count; i++) - { - bounds = Rectangle.Union (bounds, _lines [i].Bounds); - } + Rectangle bounds = _lines [0].Bounds; - if (bounds is { Width: 0 } or { Height: 0 }) - { - bounds = bounds with - { - Width = Math.Clamp (bounds.Width, 1, short.MaxValue), - Height = Math.Clamp (bounds.Height, 1, short.MaxValue) - }; - } + for (var i = 1; i < _lines.Count; i++) + { + bounds = Rectangle.Union (bounds, _lines [i].Bounds); + } - _cachedBounds = bounds; + if (bounds is { Width: 0 } or { Height: 0 }) + { + bounds = bounds with { Width = Math.Clamp (bounds.Width, 1, short.MaxValue), Height = Math.Clamp (bounds.Height, 1, short.MaxValue) }; } + _cachedBounds = bounds; + return _cachedBounds; } } @@ -82,24 +127,38 @@ public Rectangle Bounds /// is . /// /// + /// + /// + /// has no special handling inside . A line + /// added with is stored and participates in intersection resolution + /// like any other line; because does not match any styled-glyph check, + /// it falls through to the default glyphs and renders identically to . + /// + /// + /// To erase geometry, do not add a line. Instead, use + /// + /// to physically split or remove overlapping lines from the collection. To + /// suppress output without removing geometry (e.g., for a title label), use + /// . To claim positions during multi-canvas compositing, use + /// . + /// + /// + /// See the Drawing Deep Dive for a detailed comparison of + /// , , and . + /// + /// /// Starting point. /// /// The length of line. 0 for an intersection (cross or T). Positive for Down/Right. Negative for /// Up/Left. /// /// The direction of the line. - /// The style of line to use - /// - public void AddLine ( - Point start, - int length, - Orientation orientation, - LineStyle style, - Attribute? attribute = null - ) + /// The style of line to use. + /// The color attribute for the line, or to inherit. + public void AddLine (Point start, int length, Orientation orientation, LineStyle style, Attribute? attribute = null) { _cachedBounds = Rectangle.Empty; - _lines.Add (new (start, length, orientation, style, attribute)); + _lines.Add (new StraightLine (start, length, orientation, style, attribute)); } /// Adds a new line to the canvas @@ -112,18 +171,77 @@ public void AddLine (StraightLine line) private Region? _exclusionRegion; + /// + /// Positions marked as intentionally empty by . These have no effect on + /// this canvas's resolution or output — they are metadata consumed during multi-canvas + /// compositing in . + /// + private HashSet? _reservedCells; + + /// + /// Reserves a rectangular region of cells. Reserved cells do not produce visible output and + /// do not affect this canvas's intersection resolution or output. + /// They are metadata consumed during multi-canvas compositing + /// (see ): reserved cells claim their positions so that + /// cells from canvases composited later do not show through. + /// + /// + /// + /// Use this for intentional gaps in borders — positions where a line is deliberately + /// absent and lines from other canvases should not show through. For example, the open + /// gap in a focused tab's border where the tab header connects to the content area. + /// + /// + /// Compare with : Exclude filters this canvas's own resolved output + /// (lines still auto-join through excluded positions). Reserve has no effect on this + /// canvas — it only affects how multiple canvases are layered during compositing. + /// + /// + /// Reserved cells are cleared when is called. + /// + /// + /// The rectangle of cells to reserve, in canvas coordinates. + public void Reserve (Rectangle rect) + { + _reservedCells ??= []; + + for (int y = rect.Y; y < rect.Bottom; y++) + { + for (int x = rect.X; x < rect.Right; x++) + { + _reservedCells.Add (new Point (x, y)); + } + } + } + + /// + /// Gets the set of reserved cells, or if none have been reserved. + /// + public HashSet? GetReservedCells () => _reservedCells; + /// /// Causes the provided region to be excluded from and . + /// Lines at excluded positions still exist in the canvas and still participate in intersection + /// resolution (auto-join), but the resolved cells are filtered out of the output. /// /// /// + /// Use this when something else has already been drawn at a position and the line-art glyph + /// should not overwrite it — for example, a title label drawn on a border line, or a SubView + /// that renders its own independently. + /// + /// /// Each call to this method will add to the exclusion region. To clear the exclusion region, call /// . /// + /// + /// Compare with : Exclude filters this canvas's own output; Reserve marks + /// positions as claimed during multi-canvas compositing (see ). + /// /// public void Exclude (Region region) { - _exclusionRegion ??= new (); + _exclusionRegion ??= new Region (); _exclusionRegion.Union (region); } @@ -131,7 +249,7 @@ public void Exclude (Region region) /// Clears the exclusion region. After calling this method, and will /// return all points in the canvas. /// - public void ClearExclusions () { _exclusionRegion = null; } + public void ClearExclusions () => _exclusionRegion = null; /// Clears all lines from the LineCanvas. public void Clear () @@ -139,13 +257,14 @@ public void Clear () _cachedBounds = Rectangle.Empty; _lines.Clear (); ClearExclusions (); + _reservedCells = null; } /// /// Clears any cached states from the canvas. Call this method if you make changes to lines that have already been /// added. /// - public void ClearCache () { _cachedBounds = Rectangle.Empty; } + public void ClearCache () => _cachedBounds = Rectangle.Empty; /// /// Evaluates the lines that have been added to the canvas and returns a map containing the glyphs and their @@ -171,20 +290,23 @@ public void Clear () for (int x = Bounds.X; x < Bounds.X + Bounds.Width; x++) { intersectionsBufferList.Clear (); - foreach (var line in _lines) + + foreach (StraightLine line in _lines) { if (line.Intersects (x, y) is { } intersect) { intersectionsBufferList.Add (intersect); } } + // Safe as long as the list is not modified while the span is in use. ReadOnlySpan intersects = CollectionsMarshal.AsSpan (intersectionsBufferList); Cell? cell = GetCellForIntersects (intersects); + // TODO: Can we skip the whole nested looping if _exclusionRegion is null? if (cell is { } && _exclusionRegion?.Contains (x, y) is null or false) { - map.Add (new (x, y), cell); + map.Add (new Point (x, y), cell); } } } @@ -214,6 +336,7 @@ public void Clear () for (int x = Bounds.X; x < Bounds.X + Bounds.Width; x++) { intersectionsBufferList.Clear (); + foreach (StraightLine line in _lines) { if (line.Intersects (x, y) is { } intersect) @@ -221,15 +344,17 @@ public void Clear () intersectionsBufferList.Add (intersect); } } + // Safe as long as the list is not modified while the span is in use. ReadOnlySpan intersects = CollectionsMarshal.AsSpan (intersectionsBufferList); Cell? cell = GetCellForIntersects (intersects); - if (cell is { } && _exclusionRegion?.Contains (x, y) is null or false) + if (cell is null || _exclusionRegion?.Contains (x, y) is not (null or false)) { - map.Add (new (x, y), cell); - rowXValues.Add (x); + continue; } + map.Add (new Point (x, y), cell); + rowXValues.Add (x); } // Build Region spans for this completed row @@ -242,20 +367,15 @@ public void Clear () int spanStart = rowXValues [0]; int spanEnd = rowXValues [0]; - for (int i = 1; i < rowXValues.Count; i++) + for (var i = 1; i < rowXValues.Count; i++) { - if (rowXValues [i] == spanEnd + 1) - { - // Continue the span - spanEnd = rowXValues [i]; - } - else + if (rowXValues [i] != spanEnd + 1) { // End the current span and add it to the region region.Combine (new Rectangle (spanStart, y, spanEnd - spanStart + 1, 1), RegionOp.Union); spanStart = rowXValues [i]; - spanEnd = rowXValues [i]; } + spanEnd = rowXValues [i]; } // Add the final span for this row @@ -276,16 +396,14 @@ public static Region GetRegion (Dictionary cellMap) { // Group cells by row for efficient horizontal span detection // Sort by Y then X so that within each row group, X values are in order - IEnumerable> rowGroups = cellMap.Keys - .OrderBy (p => p.Y) - .ThenBy (p => p.X) - .GroupBy (p => p.Y); + IEnumerable> rowGroups = cellMap.Keys.OrderBy (p => p.Y).ThenBy (p => p.X).GroupBy (p => p.Y); Region region = new (); foreach (IGrouping row in rowGroups) { int y = row.Key; + // X values are sorted due to ThenBy above List xValues = row.Select (p => p.X).ToList (); @@ -299,7 +417,7 @@ public static Region GetRegion (Dictionary cellMap) int spanStart = xValues [0]; int spanEnd = xValues [0]; - for (int i = 1; i < xValues.Count; i++) + for (var i = 1; i < xValues.Count; i++) { if (xValues [i] == spanEnd + 1) { @@ -349,13 +467,15 @@ public Dictionary GetMap (Rectangle inArea) for (int x = inArea.X; x < inArea.X + inArea.Width; x++) { intersectionsBufferList.Clear (); - foreach (var line in _lines) + + foreach (StraightLine line in _lines) { if (line.Intersects (x, y) is { } intersect) { intersectionsBufferList.Add (intersect); } } + // Safe as long as the list is not modified while the span is in use. ReadOnlySpan intersects = CollectionsMarshal.AsSpan (intersectionsBufferList); @@ -363,7 +483,7 @@ public Dictionary GetMap (Rectangle inArea) if (rune is { } && _exclusionRegion?.Contains (x, y) is null or false) { - map.Add (new (x, y), rune.Value); + map.Add (new Point (x, y), rune.Value); } } } @@ -383,7 +503,7 @@ public Dictionary GetMap (Rectangle inArea) /// /// /// A map of all the points within the canvas. - public Dictionary GetMap () { return GetMap (Bounds); } + public Dictionary GetMap () => GetMap (Bounds); /// Merges one line canvas into this one. /// @@ -394,10 +514,116 @@ public void Merge (LineCanvas lineCanvas) AddLine (line); } - if (lineCanvas._exclusionRegion is { }) + if (lineCanvas._exclusionRegion is null) + { + return; + } + + _exclusionRegion ??= new Region (); + _exclusionRegion.Union (lineCanvas._exclusionRegion); + } + + /// Merges one line canvas into this one, clipping all lines to the specified bounds. + /// + /// Lines that fall entirely outside are discarded. + /// Lines that partially overlap are trimmed to fit within the bounds. + /// + /// The source canvas whose lines will be merged. + /// The screen-relative rectangle to clip incoming lines to. + public void Merge (LineCanvas lineCanvas, Rectangle clipBounds) + { + foreach (StraightLine line in lineCanvas._lines) + { + StraightLine? clipped = ClipLine (line, clipBounds); + + if (clipped is { }) + { + AddLine (clipped); + } + } + + if (lineCanvas._exclusionRegion is null) + { + return; + } + + Region clippedExclusion = lineCanvas._exclusionRegion.Clone (); + clippedExclusion.Intersect (clipBounds); + _exclusionRegion ??= new Region (); + _exclusionRegion.Union (clippedExclusion); + } + + /// Clips a to the specified bounds rectangle. + /// A new clipped line, or if the line falls entirely outside bounds. + private static StraightLine? ClipLine (StraightLine line, Rectangle bounds) + { + Rectangle lineBounds = line.Bounds; + + if (line.Orientation == Orientation.Horizontal) + { + // Line is at a fixed Y. If Y is outside bounds, discard. + if (lineBounds.Y < bounds.Y || lineBounds.Y >= bounds.Bottom) + { + return null; + } + + // Clamp horizontal extent to bounds + int clippedLeft = Math.Max (lineBounds.Left, bounds.Left); + int clippedRight = Math.Min (lineBounds.Right, bounds.Right); + + if (clippedLeft >= clippedRight) + { + return null; + } + + // Determine new start and length, preserving the sign convention + int newLength = clippedRight - clippedLeft; + Point newStart; + + if (line.Length >= 0) + { + newStart = new Point (clippedLeft, lineBounds.Y); + } + else + { + // Negative length: start is at the right end, length is negative + newStart = new Point (clippedRight - 1, lineBounds.Y); + newLength = -newLength; + } + + return new StraightLine (newStart, newLength, Orientation.Horizontal, line.Style, line.Attribute); + } + else { - _exclusionRegion ??= new (); - _exclusionRegion.Union (lineCanvas._exclusionRegion); + // Vertical line at a fixed X. If X is outside bounds, discard. + if (lineBounds.X < bounds.X || lineBounds.X >= bounds.Right) + { + return null; + } + + // Clamp vertical extent to bounds + int clippedTop = Math.Max (lineBounds.Top, bounds.Top); + int clippedBottom = Math.Min (lineBounds.Bottom, bounds.Bottom); + + if (clippedTop >= clippedBottom) + { + return null; + } + + int newLength = clippedBottom - clippedTop; + Point newStart; + + if (line.Length >= 0) + { + newStart = new Point (lineBounds.X, clippedTop); + } + else + { + newStart = new Point (lineBounds.X, clippedBottom - 1); + newLength = -newLength; + } + + return new StraightLine (newStart, newLength, Orientation.Vertical, line.Style, line.Attribute); } } @@ -464,13 +690,14 @@ public override string ToString () private static bool All (ReadOnlySpan intersects, Orientation orientation) { - foreach (var intersect in intersects) + foreach (IntersectionDefinition intersect in intersects) { if (intersect.Line.Orientation != orientation) { return false; } } + return true; } @@ -489,51 +716,22 @@ private void ConfigurationManager_Applied (object? sender, ConfigurationManagerE /// /// /// - private static bool Exactly (HashSet intersects, params IntersectionType [] types) { return intersects.SetEquals (types); } + private static bool Exactly (HashSet intersects, params IntersectionType [] types) => intersects.SetEquals (types); - private Attribute? GetAttributeForIntersects (ReadOnlySpan intersects) - { - return Fill?.GetAttribute (intersects [0].Point) ?? intersects [0].Line.Attribute; - } + private Attribute? GetAttributeForIntersects (ReadOnlySpan intersects) => + Fill?.GetAttribute (intersects [0].Point) ?? intersects [0].Line.Attribute; private readonly Dictionary _runeResolvers = new () { - { - IntersectionRuneType.ULCorner, - new ULIntersectionRuneResolver () - }, - { - IntersectionRuneType.URCorner, - new URIntersectionRuneResolver () - }, - { - IntersectionRuneType.LLCorner, - new LLIntersectionRuneResolver () - }, - { - IntersectionRuneType.LRCorner, - new LRIntersectionRuneResolver () - }, - { - IntersectionRuneType.TopTee, - new TopTeeIntersectionRuneResolver () - }, - { - IntersectionRuneType.LeftTee, - new LeftTeeIntersectionRuneResolver () - }, - { - IntersectionRuneType.RightTee, - new RightTeeIntersectionRuneResolver () - }, - { - IntersectionRuneType.BottomTee, - new BottomTeeIntersectionRuneResolver () - }, - { - IntersectionRuneType.Cross, - new CrossIntersectionRuneResolver () - } + { IntersectionRuneType.ULCorner, new ULIntersectionRuneResolver () }, + { IntersectionRuneType.URCorner, new URIntersectionRuneResolver () }, + { IntersectionRuneType.LLCorner, new LLIntersectionRuneResolver () }, + { IntersectionRuneType.LRCorner, new LRIntersectionRuneResolver () }, + { IntersectionRuneType.TopTee, new TopTeeIntersectionRuneResolver () }, + { IntersectionRuneType.LeftTee, new LeftTeeIntersectionRuneResolver () }, + { IntersectionRuneType.RightTee, new RightTeeIntersectionRuneResolver () }, + { IntersectionRuneType.BottomTee, new BottomTeeIntersectionRuneResolver () }, + { IntersectionRuneType.Cross, new CrossIntersectionRuneResolver () } // TODO: Add other resolvers }; @@ -566,6 +764,7 @@ private void ConfigurationManager_Applied (object? sender, ConfigurationManagerE } IntersectionRuneType runeType = GetRuneTypeForIntersects (intersects); + if (_runeResolvers.TryGetValue (runeType, out IntersectionRuneResolver? resolver)) { return resolver.GetRuneForIntersects (intersects); @@ -589,8 +788,10 @@ private void ConfigurationManager_Applied (object? sender, ConfigurationManagerE { case IntersectionRuneType.None: return null; + case IntersectionRuneType.Dot: return Glyphs.Dot; + case IntersectionRuneType.HLine: if (useDouble) { @@ -607,9 +808,8 @@ private void ConfigurationManager_Applied (object? sender, ConfigurationManagerE return Glyphs.HLineDa3; } - return useThick ? Glyphs.HLineHv : - useThickDashed ? Glyphs.HLineHvDa2 : - useThickDotted ? Glyphs.HLineHvDa3 : Glyphs.HLine; + return useThick ? Glyphs.HLineHv : useThickDashed ? Glyphs.HLineHvDa2 : useThickDotted ? Glyphs.HLineHvDa3 : Glyphs.HLine; + case IntersectionRuneType.VLine: if (useDouble) { @@ -626,20 +826,12 @@ private void ConfigurationManager_Applied (object? sender, ConfigurationManagerE return Glyphs.VLineDa4; } - return useThick ? Glyphs.VLineHv : - useThickDashed ? Glyphs.VLineHvDa3 : - useThickDotted ? Glyphs.VLineHvDa4 : Glyphs.VLine; + return useThick ? Glyphs.VLineHv : useThickDashed ? Glyphs.VLineHvDa3 : useThickDotted ? Glyphs.VLineHvDa4 : Glyphs.VLine; default: - throw new ( - "Could not find resolver or switch case for " - + nameof (runeType) - + ":" - + runeType - ); + throw new Exception ("Could not find resolver or switch case for " + nameof (runeType) + ":" + runeType); } - static bool AnyLineStyles (ReadOnlySpan intersects, ReadOnlySpan lineStyles) { foreach (IntersectionDefinition intersect in intersects) @@ -652,56 +844,38 @@ static bool AnyLineStyles (ReadOnlySpan intersects, Read } } } + return false; } } private IntersectionRuneType GetRuneTypeForIntersects (ReadOnlySpan intersects) { - HashSet set = new (capacity: intersects.Length); - foreach (var intersect in intersects) + HashSet set = new (intersects.Length); + + foreach (IntersectionDefinition intersect in intersects) { set.Add (intersect.Type); } #region Cross Conditions - if (Has ( - set, - [IntersectionType.PassOverHorizontal, - IntersectionType.PassOverVertical] - )) + if (Has (set, [IntersectionType.PassOverHorizontal, IntersectionType.PassOverVertical])) { return IntersectionRuneType.Cross; } - if (Has ( - set, - [IntersectionType.PassOverVertical, - IntersectionType.StartLeft, - IntersectionType.StartRight] - )) + if (Has (set, [IntersectionType.PassOverVertical, IntersectionType.StartLeft, IntersectionType.StartRight])) { return IntersectionRuneType.Cross; } - if (Has ( - set, - [IntersectionType.PassOverHorizontal, - IntersectionType.StartUp, - IntersectionType.StartDown] - )) + if (Has (set, [IntersectionType.PassOverHorizontal, IntersectionType.StartUp, IntersectionType.StartDown])) { return IntersectionRuneType.Cross; } - if (Has ( - set, - [IntersectionType.StartLeft, - IntersectionType.StartRight, - IntersectionType.StartUp, - IntersectionType.StartDown] - )) + if (Has (set, [IntersectionType.StartLeft, IntersectionType.StartRight, IntersectionType.StartUp, IntersectionType.StartDown])) { return IntersectionRuneType.Cross; } @@ -734,78 +908,26 @@ private IntersectionRuneType GetRuneTypeForIntersects (ReadOnlySpan @@ -834,25 +951,25 @@ private IntersectionRuneType GetRuneTypeForIntersects (ReadOnlySpan private bool Has (HashSet intersects, ReadOnlySpan types) { - foreach (var type in types) + foreach (IntersectionType type in types) { if (!intersects.Contains (type)) { return false; } } + return true; } - /// - /// Preallocated arrays for calls to . + /// Preallocated arrays for calls to . /// /// - /// Optimization to avoid array allocation for each call from array params. Please do not edit the arrays at runtime. :) - /// - /// More ideal solution would be to change to take ReadOnlySpan instead of an array - /// but that would require replacing the HashSet.SetEquals call. + /// Optimization to avoid array allocation for each call from array params. Please do not edit the arrays at runtime. + /// :) + /// More ideal solution would be to change to take ReadOnlySpan instead of an array + /// but that would require replacing the HashSet.SetEquals call. /// private static class CornerIntersections { @@ -904,10 +1021,7 @@ private abstract class IntersectionRuneResolver internal Rune _thickH; internal Rune _thickV; - protected IntersectionRuneResolver () - { - SetGlyphs (); - } + protected IntersectionRuneResolver () => SetGlyphs (); public Rune? GetRuneForIntersects (ReadOnlySpan intersects) { @@ -926,10 +1040,11 @@ protected IntersectionRuneResolver () return _doubleV; } - bool thickHorizontal = AnyWithOrientationAndAnyLineStyle (intersects, Orientation.Horizontal, - [LineStyle.Heavy, LineStyle.HeavyDashed, LineStyle.HeavyDotted]); - bool thickVertical = AnyWithOrientationAndAnyLineStyle (intersects, Orientation.Vertical, - [LineStyle.Heavy, LineStyle.HeavyDashed, LineStyle.HeavyDotted]); + bool thickHorizontal = + AnyWithOrientationAndAnyLineStyle (intersects, Orientation.Horizontal, [LineStyle.Heavy, LineStyle.HeavyDashed, LineStyle.HeavyDotted]); + + bool thickVertical = + AnyWithOrientationAndAnyLineStyle (intersects, Orientation.Vertical, [LineStyle.Heavy, LineStyle.HeavyDashed, LineStyle.HeavyDotted]); if (thickHorizontal) { @@ -945,30 +1060,27 @@ protected IntersectionRuneResolver () static bool UseRounded (ReadOnlySpan intersects) { - foreach (var intersect in intersects) + foreach (IntersectionDefinition intersect in intersects) { if (intersect.Line.Length == 0) { continue; } - if (intersect.Line.Style is - LineStyle.Rounded or - LineStyle.RoundedDashed or - LineStyle.RoundedDotted) + if (intersect.Line.Style is LineStyle.Rounded or LineStyle.RoundedDashed or LineStyle.RoundedDotted) { return true; } } + return false; } - static bool AnyWithOrientationAndAnyLineStyle ( - ReadOnlySpan intersects, - Orientation orientation, - ReadOnlySpan lineStyles) + static bool AnyWithOrientationAndAnyLineStyle (ReadOnlySpan intersects, + Orientation orientation, + ReadOnlySpan lineStyles) { - foreach (var i in intersects) + foreach (IntersectionDefinition i in intersects) { if (i.Line.Orientation != orientation) { @@ -976,7 +1088,7 @@ static bool AnyWithOrientationAndAnyLineStyle ( } // Any line style - foreach (var style in lineStyles) + foreach (LineStyle style in lineStyles) { if (i.Line.Style == style) { @@ -984,6 +1096,7 @@ static bool AnyWithOrientationAndAnyLineStyle ( } } } + return false; } } @@ -1100,6 +1213,46 @@ public override void SetGlyphs () } } + /// + /// Maps a box-drawing grapheme to its line directions. Used during overlapped LC compositing + /// to determine whether a lower-Z cell adds directions that don't point toward reserved gaps. + /// + public static LineDirections GetLineDirections (string? grapheme) + { + if (string.IsNullOrEmpty (grapheme) || grapheme.Length == 0) + { + return LineDirections.None; + } + + char ch = grapheme [0]; + + return ch switch + { + // Horizontal lines + '─' or '━' or '═' => LineDirections.Left | LineDirections.Right, + + // Vertical lines + '│' or '┃' or '║' => LineDirections.Up | LineDirections.Down, + + // Corners (single, rounded, double, heavy) + '┌' or '╭' or '╔' or '┏' => LineDirections.Right | LineDirections.Down, + '┐' or '╮' or '╗' or '┓' => LineDirections.Left | LineDirections.Down, + '└' or '╰' or '╚' or '┗' => LineDirections.Right | LineDirections.Up, + '┘' or '╯' or '╝' or '┛' => LineDirections.Left | LineDirections.Up, + + // T-junctions (single, double, heavy) + '├' or '╠' or '┣' => LineDirections.Up | LineDirections.Down | LineDirections.Right, + '┤' or '╣' or '┫' => LineDirections.Up | LineDirections.Down | LineDirections.Left, + '┬' or '╦' or '┳' => LineDirections.Left | LineDirections.Right | LineDirections.Down, + '┴' or '╩' or '┻' => LineDirections.Left | LineDirections.Right | LineDirections.Up, + + // Cross (single, double, heavy) + '┼' or '╬' or '╋' => LineDirections.Up | LineDirections.Down | LineDirections.Left | LineDirections.Right, + + _ => LineDirections.None + }; + } + /// public void Dispose () { diff --git a/Terminal.Gui/Drawing/LineCanvas/LineDirections.cs b/Terminal.Gui/Drawing/LineCanvas/LineDirections.cs new file mode 100644 index 0000000000..1c690fc5c7 --- /dev/null +++ b/Terminal.Gui/Drawing/LineCanvas/LineDirections.cs @@ -0,0 +1,31 @@ +namespace Terminal.Gui.Drawing; + +/// Direction flags for box-drawing character analysis during overlapped compositing. +[Flags] +public enum LineDirections +{ + /// + /// No lines in any direction. + /// + None = 0, + + /// + /// Line(s) extending up from the cell. + /// + Up = 1, + + /// + /// Line(s) extending down from the cell. + /// + Down = 2, + + /// + /// Line(s) extending left from the cell. + /// + Left = 4, + + /// + /// Line(s) extending right from the cell. + /// + Right = 8 +} diff --git a/Terminal.Gui/Drawing/LineCanvas/LineStyle.cs b/Terminal.Gui/Drawing/LineCanvas/LineStyle.cs index 9b93708dba..6ef880dec4 100644 --- a/Terminal.Gui/Drawing/LineCanvas/LineStyle.cs +++ b/Terminal.Gui/Drawing/LineCanvas/LineStyle.cs @@ -6,7 +6,14 @@ namespace Terminal.Gui.Drawing; [JsonConverter (typeof (JsonStringEnumConverter))] public enum LineStyle { - /// No border is drawn. + /// + /// No border is drawn. When used with , + /// does not treat this value specially — the line is stored and participates in + /// intersection resolution, rendering with default (single-line) glyphs. Callers that want eraser + /// semantics should use + /// + /// to remove overlapping geometry from the line collection. + /// None, /// The border is drawn using thin line Glyphs. diff --git a/Terminal.Gui/Drawing/Region.cs b/Terminal.Gui/Drawing/Region.cs index 597801a6af..21718b0503 100644 --- a/Terminal.Gui/Drawing/Region.cs +++ b/Terminal.Gui/Drawing/Region.cs @@ -1,6 +1,4 @@ - - -namespace Terminal.Gui.Drawing; +namespace Terminal.Gui.Drawing; /// /// Represents a region composed of one or more rectangles, providing methods for geometric set operations such as @@ -11,7 +9,8 @@ namespace Terminal.Gui.Drawing; /// /// /// -/// This class is thread-safe. All operations are synchronized to ensure consistent state when accessed concurrently. +/// This class is thread-safe. All operations are synchronized to ensure consistent state when accessed +/// concurrently. /// /// /// The class adopts a philosophy of efficiency and flexibility, balancing performance with @@ -53,7 +52,7 @@ public class Region private readonly List _tempRectangles = new (); // Object used for synchronization - private readonly object _syncLock = new object (); + private readonly object _syncLock = new (); /// /// Initializes a new instance of the class. @@ -80,7 +79,7 @@ public Region Clone () { lock (_syncLock) { - var clone = new Region (); + Region clone = new (); clone._rectangles.Capacity = _rectangles.Count; // Pre-allocate capacity clone._rectangles.AddRange (_rectangles); @@ -147,7 +146,7 @@ private void CombineInternal (Region? region, RegionOp operation) foreach (Rectangle rect in region._rectangles) { - List temp = new (); + List temp = []; foreach (Rectangle r in newRectangles) { @@ -165,16 +164,13 @@ private void CombineInternal (Region? region, RegionOp operation) case RegionOp.Intersect: List intersections = new (_rectangles.Count); // Pre-allocate - // Null is same as empty region - region ??= new (); - foreach (Rectangle rect1 in _rectangles) { - foreach (Rectangle rect2 in region!._rectangles) + foreach (Rectangle rect2 in region._rectangles) { Rectangle intersected = Rectangle.Intersect (rect1, rect2); - if (!intersected.IsEmpty) + if (intersected.Width > 0 && intersected.Height > 0) { intersections.Add (intersected); } @@ -190,34 +186,32 @@ private void CombineInternal (Region? region, RegionOp operation) // Avoid collection initialization with spread operator _tempRectangles.Clear (); _tempRectangles.AddRange (_rectangles); - if (region != null) + + // Get the region's rectangles safely + lock (region._syncLock) { - // Get the region's rectangles safely - lock (region._syncLock) - { - _tempRectangles.AddRange (region._rectangles); - } + _tempRectangles.AddRange (region._rectangles); } List mergedUnion = MergeRectangles (_tempRectangles, false); _rectangles.Clear (); _rectangles.AddRange (mergedUnion); + break; case RegionOp.MinimalUnion: // Avoid collection initialization with spread operator _tempRectangles.Clear (); _tempRectangles.AddRange (_rectangles); - if (region != null) + + // Get the region's rectangles safely + lock (region._syncLock) { - // Get the region's rectangles safely - lock (region._syncLock) - { - _tempRectangles.AddRange (region._rectangles); - } + _tempRectangles.AddRange (region._rectangles); } List mergedMinimalUnion = MergeRectangles (_tempRectangles, true); _rectangles.Clear (); _rectangles.AddRange (mergedMinimalUnion); + break; case RegionOp.XOR: @@ -240,6 +234,8 @@ private void CombineInternal (Region? region, RegionOp operation) _rectangles.AddRange (region._rectangles); break; + + default: throw new ArgumentOutOfRangeException (nameof (operation), operation, null); } } @@ -315,7 +311,7 @@ public bool Contains (Rectangle rectangle) /// /// The object to compare with this region. /// true if the objects are equal; otherwise, false. - public override bool Equals (object? obj) { return obj is Region other && Equals (other); } + public override bool Equals (object? obj) => obj is Region other && Equals (other); private static bool IsRegionEmpty (List rectangles) { @@ -390,7 +386,7 @@ public bool Equals (Region? other) /// /// /// The rectangle to exclude from the region. - public void Exclude (Rectangle rectangle) { Combine (rectangle, RegionOp.Difference); } + public void Exclude (Rectangle rectangle) => Combine (rectangle, RegionOp.Difference); /// /// Removes the portion of the specified region from this region. @@ -402,7 +398,7 @@ public bool Equals (Region? other) /// /// /// The region to exclude from this region. - public void Exclude (Region? region) { Combine (region, RegionOp.Difference); } + public void Exclude (Region? region) => Combine (region, RegionOp.Difference); /// /// Gets a bounding rectangle for the entire region. @@ -430,7 +426,7 @@ public Rectangle GetBounds () bottom = Math.Max (bottom, r.Bottom); } - return new (left, top, right - left, bottom - top); + return new Rectangle (left, top, right - left, bottom - top); } /// @@ -453,7 +449,7 @@ public override int GetHashCode () /// Returns an array of rectangles that represent the region. /// /// An array of objects that make up the region. - public Rectangle [] GetRectangles () { return _rectangles.ToArray (); } + public Rectangle [] GetRectangles () => _rectangles.ToArray (); /// /// Updates the region to be the intersection of itself with the specified rectangle. @@ -465,7 +461,7 @@ public override int GetHashCode () /// /// /// The rectangle to intersect with the region. - public void Intersect (Rectangle rectangle) { Combine (rectangle, RegionOp.Intersect); } + public void Intersect (Rectangle rectangle) => Combine (rectangle, RegionOp.Intersect); /// /// Updates the region to be the intersection of itself with the specified region. @@ -477,7 +473,7 @@ public override int GetHashCode () /// /// /// The region to intersect with this region. - public void Intersect (Region? region) { Combine (region, RegionOp.Intersect); } + public void Intersect (Region? region) => Combine (region, RegionOp.Intersect); /// /// Determines whether the region is empty. @@ -524,25 +520,25 @@ public void Translate (int offsetX, int offsetY) /// Adds the specified rectangle to the region. Merges all rectangles into a minimal or granular bounding shape. /// /// The rectangle to add to the region. - public void Union (Rectangle rectangle) { Combine (rectangle, RegionOp.Union); } + public void Union (Rectangle rectangle) => Combine (rectangle, RegionOp.Union); /// /// Adds the specified region to this region. Merges all rectangles into a minimal or granular bounding shape. /// /// The region to add to this region. - public void Union (Region? region) { Combine (region, RegionOp.Union); } + public void Union (Region? region) => Combine (region, RegionOp.Union); /// /// Adds the specified rectangle to the region. Merges all rectangles into the smallest possible bounding shape. /// /// The rectangle to add to the region. - public void MinimalUnion (Rectangle rectangle) { Combine (rectangle, RegionOp.MinimalUnion); } + public void MinimalUnion (Rectangle rectangle) => Combine (rectangle, RegionOp.MinimalUnion); /// /// Adds the specified region to this region. Merges all rectangles into the smallest possible bounding shape. /// /// The region to add to this region. - public void MinimalUnion (Region? region) { Combine (region, RegionOp.MinimalUnion); } + public void MinimalUnion (Region? region) => Combine (region, RegionOp.MinimalUnion); /// /// Merges overlapping rectangles into a minimal or granular set of non-overlapping rectangles with a minimal bounding @@ -563,13 +559,16 @@ internal static List MergeRectangles (List rectangles, boo // Generate events List<(int x, bool isStart, int yTop, int yBottom)> events = new (rectangles.Count * 2); + foreach (Rectangle r in rectangles) { - if (!r.IsEmpty) + if (r.Width <= 0 || r.Height <= 0) { - events.Add ((r.Left, true, r.Top, r.Bottom)); - events.Add ((r.Right, false, r.Top, r.Bottom)); + continue; } + + events.Add ((r.Left, true, r.Top, r.Bottom)); + events.Add ((r.Right, false, r.Top, r.Bottom)); } if (events.Count == 0) @@ -582,11 +581,11 @@ internal static List MergeRectangles (List rectangles, boo // 2. Secondary: End events before Start events at the same x. // 3. Tertiary: By yTop coordinate as a tie-breaker. // 4. Quaternary: By yBottom coordinate as a final tie-breaker. - events.Sort ( - (a, b) => + events.Sort ((a, b) => { // 1. Sort by X int cmp = a.x.CompareTo (b.x); + if (cmp != 0) { return cmp; @@ -596,6 +595,7 @@ internal static List MergeRectangles (List rectangles, boo bool aIsEnd = !a.isStart; bool bIsEnd = !b.isStart; cmp = aIsEnd.CompareTo (bIsEnd); // True (End) comes after False (Start) + if (cmp != 0) { return -cmp; // Reverse: End (true) should come before Start (false) @@ -603,6 +603,7 @@ internal static List MergeRectangles (List rectangles, boo // 3. Tie-breaker: Sort by yTop cmp = a.yTop.CompareTo (b.yTop); + if (cmp != 0) { return cmp; @@ -613,39 +614,30 @@ internal static List MergeRectangles (List rectangles, boo }); List merged = []; + // Use a dictionary to track active intervals and their overlap counts Dictionary<(int yTop, int yBottom), int> activeCounts = new (); + // Comparer for sorting intervals when needed - var intervalComparer = Comparer<(int yTop, int yBottom)>.Create ( - (a, b) => - { - int cmp = a.yTop.CompareTo (b.yTop); - return cmp != 0 ? cmp : a.yBottom.CompareTo (b.yBottom); - }); + Comparer<(int yTop, int yBottom)> intervalComparer = Comparer<(int yTop, int yBottom)>.Create ((a, b) => + { + int cmp = a.yTop.CompareTo (b.yTop); - // Helper to get the current active intervals (where count > 0) as a SortedSet - SortedSet<(int yTop, int yBottom)> GetActiveIntervals () - { - var set = new SortedSet<(int yTop, int yBottom)> (intervalComparer); - foreach (var kvp in activeCounts) - { - if (kvp.Value > 0) - { - set.Add (kvp.Key); - } - } - return set; - } + return cmp != 0 + ? cmp + : a.yBottom.CompareTo (b.yBottom); + }); // Group events by x-coordinate to process all events at a given x together - var groupedEvents = events.GroupBy (e => e.x).OrderBy (g => g.Key); + IOrderedEnumerable> groupedEvents = events.GroupBy (e => e.x).OrderBy (g => g.Key); int lastX = groupedEvents.First ().Key; // Initialize with the first event's x - foreach (var group in groupedEvents) + foreach (IGrouping group in groupedEvents) { int currentX = group.Key; + // Get active intervals based on state *before* processing events at currentX - var currentActiveIntervals = GetActiveIntervals (); + SortedSet<(int yTop, int yBottom)> currentActiveIntervals = GetActiveIntervals (); // 1. Output rectangles for the segment ending *before* this x coordinate if (currentX > lastX && currentActiveIntervals.Count > 0) @@ -654,9 +646,10 @@ internal static List MergeRectangles (List rectangles, boo } // 2. Process all events *at* this x coordinate to update counts - foreach (var evt in group) + foreach ((int x, bool isStart, int yTop, int yBottom) evt in group) { - var interval = (evt.yTop, evt.yBottom); + (int yTop, int yBottom) interval = (evt.yTop, evt.yBottom); + if (evt.isStart) { activeCounts.TryGetValue (interval, out int count); @@ -665,16 +658,18 @@ internal static List MergeRectangles (List rectangles, boo else { // Only decrement/remove if the interval exists - if (activeCounts.TryGetValue (interval, out int count)) + if (!activeCounts.TryGetValue (interval, out int count)) { - if (count - 1 <= 0) - { - activeCounts.Remove (interval); - } - else - { - activeCounts [interval] = count - 1; - } + continue; + } + + if (count - 1 <= 0) + { + activeCounts.Remove (interval); + } + else + { + activeCounts [interval] = count - 1; } } } @@ -684,6 +679,22 @@ internal static List MergeRectangles (List rectangles, boo } return minimize ? MinimizeRectangles (merged) : merged; + + // Helper to get the current active intervals (where count > 0) as a SortedSet + SortedSet<(int yTop, int yBottom)> GetActiveIntervals () + { + SortedSet<(int yTop, int yBottom)> set = new (intervalComparer); + + foreach (KeyValuePair<(int yTop, int yBottom), int> kvp in activeCounts) + { + if (kvp.Value > 0) + { + set.Add (kvp.Key); + } + } + + return set; + } } /// @@ -706,7 +717,7 @@ internal static List MergeVerticalIntervals (SortedSet<(int yTop, int foreach ((int yTop, int yBottom) in active) { - if (currentTop == null) + if (currentTop is null) { currentTop = yTop; currentBottom = yBottom; @@ -717,15 +728,23 @@ internal static List MergeVerticalIntervals (SortedSet<(int yTop, int } else { - result.Add (new (startX, currentTop.Value, endX - startX, currentBottom!.Value - currentTop.Value)); - currentTop = yTop; + if (currentBottom is { }) + { + result.Add (new Rectangle (startX, currentTop.Value, endX - startX, currentBottom.Value - currentTop.Value)); + currentTop = yTop; + } currentBottom = yBottom; } } - if (currentTop != null) + if (currentTop is null) { - result.Add (new (startX, currentTop.Value, endX - startX, currentBottom!.Value - currentTop.Value)); + return result; + } + + if (currentBottom is { }) + { + result.Add (new Rectangle (startX, currentTop.Value, endX - startX, currentBottom.Value - currentTop.Value)); } return result; @@ -755,8 +774,7 @@ internal static List MinimizeRectangles (List rectangles) minimized.Clear (); // Sort by Y then X for consistent processing - current.Sort ( - (a, b) => + current.Sort ((a, b) => { int cmp = a.Top.CompareTo (b.Top); @@ -777,12 +795,7 @@ internal static List MinimizeRectangles (List rectangles) // Check if rectangles can be merged horizontally (same Y range, adjacent X) if (r.Top == next.Top && r.Bottom == next.Bottom && (r.Right == next.Left || next.Right == r.Left || r.IntersectsWith (next))) { - r = new ( - Math.Min (r.Left, next.Left), - r.Top, - Math.Max (r.Right, next.Right) - Math.Min (r.Left, next.Left), - r.Height - ); + r = new Rectangle (Math.Min (r.Left, next.Left), r.Top, Math.Max (r.Right, next.Right) - Math.Min (r.Left, next.Left), r.Height); current.RemoveAt (j); changed = true; } @@ -790,12 +803,7 @@ internal static List MinimizeRectangles (List rectangles) // Check if rectangles can be merged vertically (same X range, adjacent Y) else if (r.Left == next.Left && r.Right == next.Right && (r.Bottom == next.Top || next.Bottom == r.Top || r.IntersectsWith (next))) { - r = new ( - r.Left, - Math.Min (r.Top, next.Top), - r.Width, - Math.Max (r.Bottom, next.Bottom) - Math.Min (r.Top, next.Top) - ); + r = new Rectangle (r.Left, Math.Min (r.Top, next.Top), r.Width, Math.Max (r.Bottom, next.Bottom) - Math.Min (r.Top, next.Top)); current.RemoveAt (j); changed = true; } @@ -856,21 +864,13 @@ internal static IEnumerable SubtractRectangle (Rectangle original, Re // Top segment (above subtract) if (original.Top < subtract.Top) { - yield return new ( - original.Left, - original.Top, - original.Width, - subtract.Top - original.Top); + yield return new Rectangle (original.Left, original.Top, original.Width, subtract.Top - original.Top); } // Bottom segment (below subtract) if (original.Bottom > subtract.Bottom) { - yield return new ( - original.Left, - subtract.Bottom, - original.Width, - original.Bottom - subtract.Bottom); + yield return new Rectangle (original.Left, subtract.Bottom, original.Width, original.Bottom - subtract.Bottom); } // Left segment (to the left of subtract) @@ -881,27 +881,23 @@ internal static IEnumerable SubtractRectangle (Rectangle original, Re if (bottom > top) { - yield return new ( - original.Left, - top, - subtract.Left - original.Left, - bottom - top); + yield return new Rectangle (original.Left, top, subtract.Left - original.Left, bottom - top); } } // Right segment (to the right of subtract) - if (original.Right > subtract.Right) + if (original.Right <= subtract.Right) + { + yield break; + } + { int top = Math.Max (original.Top, subtract.Top); int bottom = Math.Min (original.Bottom, subtract.Bottom); if (bottom > top) { - yield return new ( - subtract.Right, - top, - original.Right - subtract.Right, - bottom - top); + yield return new Rectangle (subtract.Right, top, original.Right - subtract.Right, bottom - top); } } } @@ -919,7 +915,7 @@ public void FillRectangles (IDriver? driver, Attribute? attribute, Rune? fillRun { ArgumentNullException.ThrowIfNull (driver); - if (_rectangles.Count == 0) + if (_rectangles.Count == 0 || attribute is null) { return; } @@ -931,7 +927,7 @@ public void FillRectangles (IDriver? driver, Attribute? attribute, Rune? fillRun continue; } - driver?.SetAttribute (attribute!.Value); + driver?.SetAttribute (attribute.Value); for (int y = rect.Top; y < rect.Bottom; y++) { @@ -944,7 +940,6 @@ public void FillRectangles (IDriver? driver, Attribute? attribute, Rune? fillRun } } - /// /// Draws the boundaries of all rectangles in the region using the specified attributes, only if the rectangle is big /// enough. @@ -972,67 +967,68 @@ public void DrawBoundaries (LineCanvas canvas, LineStyle style, Attribute? attri if (rect.Width > 1) { // Add horizontal lines - canvas.AddLine (new (rect.Left, rect.Top), rect.Width, Orientation.Horizontal, style, attribute); - canvas.AddLine (new (rect.Left, rect.Bottom - 1), rect.Width, Orientation.Horizontal, style, attribute); + canvas.AddLine (new Point (rect.Left, rect.Top), rect.Width, Orientation.Horizontal, style, attribute); + canvas.AddLine (new Point (rect.Left, rect.Bottom - 1), rect.Width, Orientation.Horizontal, style, attribute); } - if (rect.Height > 1) + if (rect.Height <= 1) { - // Add vertical lines - canvas.AddLine (new (rect.Left, rect.Top), rect.Height, Orientation.Vertical, style, attribute); - canvas.AddLine (new (rect.Right - 1, rect.Top), rect.Height, Orientation.Vertical, style, attribute); + continue; } + + // Add vertical lines + canvas.AddLine (new Point (rect.Left, rect.Top), rect.Height, Orientation.Vertical, style, attribute); + canvas.AddLine (new Point (rect.Right - 1, rect.Top), rect.Height, Orientation.Vertical, style, attribute); } } } - // BUGBUG: DrawOuterBoundary does not work right. it draws all regions +1 too tall/wide. It should draw single width/height regions as just a line. // - // Example: There are 3 regions here. the first is a rect (0,0,1,4). Second is (10, 0, 2, 4). + // Example: There are 3 regions here. the first is a rect (0,0,1,4). Second is (10, 0, 2, 4). // This is how they should draw: // // |123456789|123456789|123456789 - // 1 │ ┌┐ ┌─┐ - // 2 │ ││ │ │ - // 3 │ ││ │ │ + // 1 │ ┌┐ ┌─┐ + // 2 │ ││ │ │ + // 3 │ ││ │ │ // 4 │ └┘ └─┘ // // But this is what it draws: // |123456789|123456789|123456789 - // 1┌┐ ┌─┐ ┌──┐ - // 2││ │ │ │ │ - // 3││ │ │ │ │ - // 4││ │ │ │ │ - // 5└┘ └─┘ └──┘ + // 1┌┐ ┌─┐ ┌──┐ + // 2││ │ │ │ │ + // 3││ │ │ │ │ + // 4││ │ │ │ │ + // 5└┘ └─┘ └──┘ // // Example: There are two rectangles in this region. (0,0,3,3) and (3, 3, 3, 3). // This is fill - correct: // |123456789 - // 1░░░ - // 2░░░ - // 3░░░░░ - // 4 ░░░ - // 5 ░░░ - // 6 + // 1░░░ + // 2░░░ + // 3░░░░░ + // 4 ░░░ + // 5 ░░░ + // 6 // // This is what DrawOuterBoundary should draw // |123456789|123456789 - // 1┌─┐ - // 2│ │ - // 3└─┼─┐ - // 4 │ │ - // 5 └─┘ + // 1┌─┐ + // 2│ │ + // 3└─┼─┐ + // 4 │ │ + // 5 └─┘ // 6 // // This is what DrawOuterBoundary actually draws // |123456789|123456789 - // 1┌──┐ - // 2│ │ - // 3│ └─┐ - // 4└─┐ │ - // 5 │ │ - // 6 └──┘ + // 1┌──┐ + // 2│ │ + // 3│ └─┐ + // 4└─┐ │ + // 5 │ │ + // 6 └──┘ /// /// Draws the outer perimeter of the region to using and @@ -1058,6 +1054,7 @@ public void DrawOuterBoundary (LineCanvas lineCanvas, LineStyle style, Attribute { // Fall back to drawing each rectangle's boundary DrawBoundaries (lineCanvas, style, attribute); + return; } @@ -1112,34 +1109,27 @@ public void DrawOuterBoundary (LineCanvas lineCanvas, LineStyle style, Attribute else { // End the current segment if one exists - if (startX != -1) + if (startX == -1) { - int length = x - startX + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine ( - new (startX + bounds.Left, y + bounds.Top), - length, - Orientation.Horizontal, - style, - attribute - ); - startX = -1; + continue; } + int length = x - startX + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine (new Point (startX + bounds.Left, y + bounds.Top), length, Orientation.Horizontal, style, attribute); + startX = -1; } } // End any segment that reaches the right edge - if (startX != -1) + if (startX == -1) + { + continue; + } + { int length = bounds.Width + 1 - startX + 1; // Add 1 to make sure lines connect - lineCanvas.AddLine ( - new (startX + bounds.Left, y + bounds.Top), - length, - Orientation.Horizontal, - style, - attribute - ); + lineCanvas.AddLine (new Point (startX + bounds.Left, y + bounds.Top), length, Orientation.Horizontal, style, attribute); } } @@ -1167,34 +1157,27 @@ public void DrawOuterBoundary (LineCanvas lineCanvas, LineStyle style, Attribute else { // End the current segment if one exists - if (startY != -1) + if (startY == -1) { - int length = y - startY + 1; // Add 1 to make sure lines connect - - lineCanvas.AddLine ( - new (x + bounds.Left, startY + bounds.Top), - length, - Orientation.Vertical, - style, - attribute - ); - startY = -1; + continue; } + int length = y - startY + 1; // Add 1 to make sure lines connect + + lineCanvas.AddLine (new Point (x + bounds.Left, startY + bounds.Top), length, Orientation.Vertical, style, attribute); + startY = -1; } } // End any segment that reaches the bottom edge - if (startY != -1) + if (startY == -1) + { + continue; + } + { int length = bounds.Height + 1 - startY + 1; // Add 1 to make sure lines connect - lineCanvas.AddLine ( - new (x + bounds.Left, startY + bounds.Top), - length, - Orientation.Vertical, - style, - attribute - ); + lineCanvas.AddLine (new Point (x + bounds.Left, startY + bounds.Top), length, Orientation.Vertical, style, attribute); } } } diff --git a/Terminal.Gui/Drawing/Scheme.cs b/Terminal.Gui/Drawing/Scheme.cs index 23c7f244e6..976a75d4ba 100644 --- a/Terminal.Gui/Drawing/Scheme.cs +++ b/Terminal.Gui/Drawing/Scheme.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics; using System.Numerics; using System.Text.Json.Serialization; @@ -291,7 +292,9 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet { stack.Remove (role); - return attr!.Value; + Debug.Assert (attr != null, nameof (attr) + " != null"); + + return attr.Value; } // TODO: Provide an API that lets devs override this algo? @@ -353,11 +356,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet bool isDark = normalBg.IsDarkColor (); Color resolvedFg = ResolveNone (normal.Foreground, defaultTerminalColors, true); - result = normal with - { - Foreground = resolvedFg, - Background = resolvedFg.GetDimmerColor (0.5, isDark) - }; + result = normal with { Foreground = resolvedFg, Background = resolvedFg.GetDimmerColor (0.5, isDark) }; break; } @@ -367,10 +366,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet Attribute editable = GetAttributeForRoleCore (VisualRole.Editable, stack, defaultTerminalColors); bool isDark = ResolveNone (editable.Background, defaultTerminalColors).IsDarkColor (); - result = editable with - { - Foreground = editable.Foreground.GetDimmerColor (0.05, isDark) - }; + result = editable with { Foreground = editable.Foreground.GetDimmerColor (0.05, isDark) }; break; } @@ -381,10 +377,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet Color normalBg = ResolveNone (normal.Background, defaultTerminalColors); bool isDark = normalBg.IsDarkColor (); - result = normal with - { - Foreground = ResolveNone (normal.Foreground, defaultTerminalColors, true).GetDimmerColor (0.05, isDark) - }; + result = normal with { Foreground = ResolveNone (normal.Foreground, defaultTerminalColors, true).GetDimmerColor (0.05, isDark) }; break; } @@ -393,10 +386,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet { Attribute normal = GetAttributeForRoleCore (VisualRole.Normal, stack, defaultTerminalColors); - result = normal with - { - Style = normal.Style | TextStyle.Underline - }; + result = normal with { Style = normal.Style | TextStyle.Underline }; break; } @@ -405,10 +395,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet { Attribute focus = GetAttributeForRoleCore (VisualRole.Focus, stack, defaultTerminalColors); - result = focus with - { - Style = focus.Style | TextStyle.Underline - }; + result = focus with { Style = focus.Style | TextStyle.Underline }; break; } @@ -417,10 +404,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet { Attribute active = GetAttributeForRoleCore (VisualRole.Active, stack, defaultTerminalColors); - result = active with - { - Style = active.Style | TextStyle.Underline - }; + result = active with { Style = active.Style | TextStyle.Underline }; break; } @@ -466,7 +450,7 @@ private Attribute GetAttributeForRoleCore (VisualRole role, HashSet /// set, will be automatically generated. See the description for for details on the /// algorithm used. /// - public Attribute Normal { get => _normal!.Value; init => _normal = value; } + public Attribute Normal { get => _normal ?? Attribute.Default; init => _normal = value; } private readonly Attribute? _hotNormal; @@ -512,7 +496,7 @@ public Attribute HotFocus /// /// The visual role for elements that are active or selected (e.g., selected item in a ). Also /// used - /// for headers in, , and . + /// for headers in, , . /// If not explicitly set, will be a derived value. See the description for for details on the /// algorithm used. /// diff --git a/Terminal.Gui/Drawing/VisualRole.cs b/Terminal.Gui/Drawing/VisualRole.cs index abef07420c..acd61023bf 100644 --- a/Terminal.Gui/Drawing/VisualRole.cs +++ b/Terminal.Gui/Drawing/VisualRole.cs @@ -33,7 +33,7 @@ public enum VisualRole /// /// The visual role for elements that are active or selected (e.g., selected item in a ). Also /// used - /// for headers in, , and . + /// for headers in, , . /// Active, diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 9fe6d12eb8..a66ddb445a 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -26,7 +26,8 @@ public class OutputBufferImpl : IOutputBuffer public Attribute CurrentAttribute { get; set; } /// - /// Gets or sets the URL that will be associated with cells added via or . + /// Gets or sets the URL that will be associated with cells added via or + /// . /// When set, subsequent cells will include this URL for OSC 8 hyperlink rendering. /// public string? CurrentUrl { get; set; } @@ -43,10 +44,7 @@ public class OutputBufferImpl : IOutputBuffer /// The column. /// The row. /// The URL if one exists, otherwise null. - public string? GetCellUrl (int col, int row) - { - return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; - } + public string? GetCellUrl (int col, int row) => _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; /// /// Sets the URL for the cell at the specified position. @@ -102,11 +100,8 @@ public int Cols private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; - /// - public void SetWideGlyphReplacement (Rune column1ReplacementChar) - { - _column1ReplacementChar = column1ReplacementChar; - } + /// + public void SetWideGlyphReplacement (Rune column1ReplacementChar) => _column1ReplacementChar = column1ReplacementChar; /// /// Indicates which lines have been modified and need to be redrawn. @@ -155,14 +150,14 @@ public Region? Clip /// /// /// Text to add. - public void AddRune (Rune rune) { AddStr (rune.ToString ()); } + public void AddRune (Rune rune) => AddStr (rune.ToString ()); /// /// Adds the specified to the display at the current cursor position. This method is a /// convenience method that calls with the constructor. /// /// Character to add. - public void AddRune (char c) { AddRune (new Rune (c)); } + public void AddRune (char c) => AddRune (new Rune (c)); /// Adds the to the display at the cursor position. /// @@ -193,7 +188,7 @@ private void AddGrapheme (string grapheme) return; } - Clip ??= new (Screen); + Clip ??= new Region (Screen); Rectangle clipRect = Clip!.GetBounds (); int printableGraphemeWidth = -1; @@ -332,6 +327,7 @@ private void WriteGrapheme (int col, int row, string grapheme, Rectangle clipRec private void WriteWideGrapheme (int col, int row, string grapheme) { Debug.Assert (grapheme.GetColumns () == 2); + if (!Clip!.Contains (col + 1, row)) { // Second column is outside clip - can't fit wide char here @@ -358,7 +354,7 @@ public void ClearContents () // CONCURRENCY: Unsynchronized access to Clip isn't safe. // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. - Clip = new (Screen); + Clip = new Region (Screen); DirtyLines = new bool [Rows]; @@ -371,12 +367,7 @@ public void ClearContents () { for (var c = 0; c < Cols; c++) { - Contents [row, c] = new () - { - Grapheme = " ", - Attribute = new Attribute (Color.White, Color.Black), - IsDirty = true - }; + Contents [row, c] = new Cell { Grapheme = " ", Attribute = new Attribute (Color.White, Color.Black), IsDirty = true }; } DirtyLines [row] = true; @@ -411,6 +402,7 @@ public void SetSize (int cols, int rows) public void FillRect (Rectangle rect, Rune rune) { Rectangle clipBounds = Clip?.GetBounds () ?? Screen; + // BUGBUG: This should be a method on Region rect = Rectangle.Intersect (rect, clipBounds); diff --git a/Terminal.Gui/Input/CommandBindingsBase.cs b/Terminal.Gui/Input/CommandBindingsBase.cs index d0402c1253..ad9fb608d0 100644 --- a/Terminal.Gui/Input/CommandBindingsBase.cs +++ b/Terminal.Gui/Input/CommandBindingsBase.cs @@ -149,7 +149,7 @@ public Command [] GetCommands (TEvent eventArgs) { if (TryGet (eventArgs, out TBinding? bindings)) { - return bindings!.Commands; + return bindings?.Commands ?? []; } return []; diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index 996b52782a..5d13d3c4e9 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -17,7 +17,7 @@ "ConfigurationManager.ThrowOnJsonErrors": false, // --------------- Tracing ---------------------------- - "Trace.EnabledCategories": "Lifecycle", + //"Trace.EnabledCategories": "Lifecycle", // --------------- Application Settings --------------- "Key.Separator": "+", diff --git a/Terminal.Gui/ViewBase/Adornment/AdornmentImpl.cs b/Terminal.Gui/ViewBase/Adornment/AdornmentImpl.cs index 159746402f..763961ec10 100644 --- a/Terminal.Gui/ViewBase/Adornment/AdornmentImpl.cs +++ b/Terminal.Gui/ViewBase/Adornment/AdornmentImpl.cs @@ -16,7 +16,7 @@ public abstract class AdornmentImpl : IAdornment /// public View? Parent { - get => field; + get; set { if (field == value) @@ -167,7 +167,7 @@ public ViewDiagnosticFlags Diagnostics internal Region? CachedDrawnRegion { get; set; } /// - /// Gets the drawn region from this adornment's last pass. + /// Gets the drawn region from this adornment's last Draw pass. /// Populated by using a per-adornment . /// Used by for both visual transparency clip exclusion /// and computation — uniformly for all adornment types. @@ -189,6 +189,76 @@ public ViewportSettingsFlags ViewportSettings } } + /// + /// Updates (and the backing 's + /// ) from and an + /// optional line-canvas region. Only acts when + /// is set. + /// + /// + /// The parent view's rendered region, or . + /// When non-null, the portion within this adornment's frame is included in the cached region. + /// + internal void UpdateCachedDrawnRegion (Region? lineCanvasRegion) + { + if (!ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse)) + { + return; + } + + Region adornmentDrawnRegion = new (); + + if (LastDrawnRegion is { }) + { + adornmentDrawnRegion.Combine (LastDrawnRegion, RegionOp.Union); + } + + // The parent's LineCanvas includes border lines rendered in DoRenderLineCanvas. + // Intersect with this adornment's frame to get only the lines within it. + if (lineCanvasRegion is { }) + { + Region lineRegion = lineCanvasRegion.Clone (); + lineRegion.Intersect (FrameToScreen ()); + adornmentDrawnRegion.Combine (lineRegion, RegionOp.Union); + } + + CachedDrawnRegion = adornmentDrawnRegion; + + if (View is { } adornmentView) + { + adornmentView.CachedDrawnRegion = adornmentDrawnRegion; + } + } + + /// + /// Adds this adornment's drawn region (from and an optional + /// line-canvas region) to the provided region. + /// Used during transparent-layer clip exclusion in DoDrawComplete. + /// + /// The exclusion region to add drawn cells to. + /// + /// The parent view's rendered region, or . + /// + internal void AddDrawnRegionTo (Region exclusion, Region? lineCanvasRegion) + { + if (LastDrawnRegion is { }) + { + Region clipped = LastDrawnRegion.Clone (); + clipped.Intersect (FrameToScreen ()); + exclusion.Combine (clipped, RegionOp.Union); + } + + // The parent's LineCanvas includes border lines rendered in DoRenderLineCanvas. + if (lineCanvasRegion is null) + { + return; + } + + Region lineRegion = lineCanvasRegion.Clone (); + lineRegion.Intersect (FrameToScreen ()); + exclusion.Combine (lineRegion, RegionOp.Union); + } + /// /// Indicates whether the specified SuperView-relative coordinates are within this adornment's /// . Works even when no has been created. diff --git a/Terminal.Gui/ViewBase/Adornment/AdornmentView.cs b/Terminal.Gui/ViewBase/Adornment/AdornmentView.cs index 5ec5c43a6c..61f1fdebc5 100644 --- a/Terminal.Gui/ViewBase/Adornment/AdornmentView.cs +++ b/Terminal.Gui/ViewBase/Adornment/AdornmentView.cs @@ -5,7 +5,8 @@ namespace Terminal.Gui.ViewBase; /// or ). /// Implements — i.e., it knows its /// and its settings owner. -/// Created lazily by when -level functionality is needed. +/// Created lazily by when -level functionality is +/// needed. /// /// /// @@ -41,7 +42,7 @@ public AdornmentView (IAdornment adornment) KeyBindings.Clear (); } - /// + /// public virtual void OnParentFrameChanged (Rectangle newParentFrame) => throw new NotImplementedException (); /// @@ -55,7 +56,8 @@ public AdornmentView (IAdornment adornment) public new ViewDiagnosticFlags Diagnostics { get; set; } = View.Diagnostics; /// - public override string ToDebugString () => $"{this.ToIdentifyingString ()} Parent={(Adornment?.Parent is { } ? Adornment.Parent.ToDebugString () : "null")}"; + public override string ToDebugString () => + $"{this.ToIdentifyingString ()} Parent={(Adornment?.Parent is { } ? Adornment.Parent.ToDebugString () : "null")}"; /// protected override IApplication? GetApp () => Adornment?.Parent?.App; @@ -146,7 +148,7 @@ protected override bool OnClearingViewport () if (Driver is { }) { - Adornment!.Thickness.Draw (Driver, ViewportToScreen (Viewport), Diagnostics, ToString ()); + Adornment.Thickness.Draw (Driver, ViewportToScreen (Viewport), Diagnostics, ToString ()); } SetNeedsDraw (); @@ -198,7 +200,7 @@ public override bool Contains (in Point location) Rectangle outside = Frame; outside.Offset (parentOrSuperView.Frame.Location); - return Adornment.Thickness.Contains (outside, location); + return Adornment?.Thickness.Contains (outside, location) ?? false; } #endregion View Overrides diff --git a/Terminal.Gui/ViewBase/Adornment/Arranger.cs b/Terminal.Gui/ViewBase/Adornment/Arranger.cs index 1325d81eee..fdd185e6b8 100644 --- a/Terminal.Gui/ViewBase/Adornment/Arranger.cs +++ b/Terminal.Gui/ViewBase/Adornment/Arranger.cs @@ -6,10 +6,6 @@ namespace Terminal.Gui.ViewBase; /// internal sealed class Arranger : IDisposable { - // NOTE: _border stays as BorderView because Arranger needs extensive View-level access - // (App, HotKeyBindings, CanFocus, SetFocus, Add, Remove, Frame, Focused, AdvanceFocus, - // MouseState, ScreenToFrame, Contains). Changing to Border would require .GetOrCreateView()/.View! - // on ~50 call sites — more complex, not simpler. Settings are accessed via _border.Adornment!. private readonly BorderView _border; /// @@ -60,7 +56,8 @@ internal bool EnterArrangeMode (ViewArrangement arrangement) bool mouseMode = _border.App is { } && _border.App.Mouse.IsGrabbed (_border); - _border.HotKeyBindings.Add (Key.Esc, Command.Quit); + // Quit: Register Command.Quit to both the Arrange key and Escape to allow exiting Arrange Mode via keyboard regardless of the user's keybindings + _border.HotKeyBindings.Add (Application.GetDefaultKey (Command.Quit), Command.Quit); Key arrangeKey = Application.GetDefaultKey (Command.Arrange); @@ -68,12 +65,6 @@ internal bool EnterArrangeMode (ViewArrangement arrangement) { _border.HotKeyBindings.Add (arrangeKey, Command.Quit); } - _border.HotKeyBindings.Add (Key.CursorUp, Command.Up); - _border.HotKeyBindings.Add (Key.CursorDown, Command.Down); - _border.HotKeyBindings.Add (Key.CursorLeft, Command.Left); - _border.HotKeyBindings.Add (Key.CursorRight, Command.Right); - _border.HotKeyBindings.Add (Key.Tab, Command.NextTabStop); - _border.HotKeyBindings.Add (Key.Tab.WithShift, Command.PreviousTabStop); CreateArrangementButtons (); @@ -91,7 +82,7 @@ internal bool EnterArrangeMode (ViewArrangement arrangement) _border.SetFocus (); // Strip off overlapped - Arranging = _border.Adornment!.Parent!.Arrangement & ~ViewArrangement.Overlapped; + Arranging = _border.Adornment?.Parent?.Arrangement & ~ViewArrangement.Overlapped ?? ViewArrangement.Fixed; } return true; @@ -107,7 +98,7 @@ private void ApplicationOnMouseEvent (object? sender, Mouse mouse) // If mouse click is outside of Border.Thickness then exit Arrange Mode Point framePos = _border.ScreenToFrame (mouse.ScreenPosition); - if (!_border.Adornment!.Thickness.Contains (_border.Frame, framePos)) + if (!_border.Adornment?.Thickness.Contains (_border.Frame, framePos) ?? true) { ExitArrangeMode (); } @@ -137,6 +128,11 @@ private void ApplicationOnGrabbingMouse (object? sender, GrabMouseEventArgs e) /// internal bool? ExitArrangeMode () { + if (!HasAnyArrangementOptions ()) + { + return false; + } + if (_border.App is { }) { _border.App.Mouse.MouseEvent -= ApplicationOnMouseEvent; @@ -207,7 +203,7 @@ internal bool HasAnyArrangementOptions () /// private void CreateArrangementButtons () { - ViewArrangement parentArrangement = _border.Adornment!.Parent!.Arrangement; + ViewArrangement parentArrangement = _border.Adornment?.Parent?.Arrangement ?? ViewArrangement.Fixed; if (parentArrangement.HasFlag (ViewArrangement.Movable)) { @@ -221,27 +217,63 @@ private void CreateArrangementButtons () if (parentArrangement.HasFlag (ViewArrangement.TopResizable)) { - _topSizeButton = CreateArrangerButton (ArrangeButtons.TopSize, Pos.Center () + _border.Adornment.Parent!.Margin.Thickness.Horizontal, 0); + _topSizeButton = CreateArrangerButton (ArrangeButtons.TopSize, Pos.Center () + (_border.Adornment?.Parent?.Margin.Thickness.Horizontal ?? 0), 0); } if (parentArrangement.HasFlag (ViewArrangement.RightResizable)) { _rightSizeButton = CreateArrangerButton (ArrangeButtons.RightSize, Pos.AnchorEnd (), - Pos.Center () + _border.Adornment.Parent!.Margin.Thickness.Vertical / 2); + Pos.Center () + (_border.Adornment?.Parent?.Margin.Thickness.Vertical ?? 0) / 2); } if (parentArrangement.HasFlag (ViewArrangement.LeftResizable)) { - _leftSizeButton = CreateArrangerButton (ArrangeButtons.LeftSize, 0, Pos.Center () + _border.Adornment.Parent!.Margin.Thickness.Vertical / 2); + _leftSizeButton = + CreateArrangerButton (ArrangeButtons.LeftSize, 0, Pos.Center () + (_border.Adornment?.Parent?.Margin.Thickness.Vertical ?? 0) / 2); } if (parentArrangement.HasFlag (ViewArrangement.BottomResizable)) { _bottomSizeButton = CreateArrangerButton (ArrangeButtons.BottomSize, - Pos.Center () + _border.Adornment.Parent!.Margin.Thickness.Horizontal / 2, + Pos.Center () + (_border.Adornment?.Parent?.Margin.Thickness.Horizontal ?? 0) / 2, Pos.AnchorEnd ()); } + + // Set buttons to bubble up arrow key commands for keyboard arrangement + _border.CommandsToBubbleUp = [Command.Up, Command.Down, Command.Left, Command.Right]; + _border.CommandNotBound += BorderOnCommandNotBound; + } + + private void BorderOnCommandNotBound (object? sender, CommandEventArgs e) + { + if (e.Context?.TryGetSource (out View? source) is not true) + { + return; + } + + switch (e.Context.Command) + { + case Command.Up: + e.Handled = HandleArrangeModeUp (); + + break; + + case Command.Down: + e.Handled = HandleArrangeModeDown (); + + break; + + case Command.Left: + e.Handled = HandleArrangeModeLeft (); + + break; + + case Command.Right: + e.Handled = HandleArrangeModeRight (); + + break; + } } /// @@ -260,9 +292,6 @@ private ArrangerButton CreateArrangerButton (ArrangeButtons buttonType, Pos x, P Visible = false }; - button.KeyBindings.Remove (Key.Space); - button.KeyBindings.Remove (Key.Enter); - _border.Add (button); return button; @@ -273,7 +302,7 @@ private ArrangerButton CreateArrangerButton (ArrangeButtons buttonType, Pos x, P /// private void SetVisibilityForKeyboardMode () { - ViewArrangement parentArrangement = _border.Adornment!.Parent!.Arrangement; + ViewArrangement parentArrangement = _border.Adornment?.Parent?.Arrangement ?? ViewArrangement.Fixed; if (parentArrangement.HasFlag (ViewArrangement.Movable)) { @@ -453,8 +482,8 @@ internal bool HandleArrangeModeUp () return false; } - int minHeight = _border.Adornment!.Thickness.Vertical + parent.Margin.Thickness.Bottom; - int minWidth = _border.Adornment!.Thickness.Horizontal + parent.Margin.Thickness.Right; + int minHeight = _border.Adornment?.Thickness.Vertical ?? 0 + parent.Margin.Thickness.Bottom; + int minWidth = _border.Adornment?.Thickness.Horizontal ?? 0 + parent.Margin.Thickness.Right; ViewManipulator manipulator = new (parent, minWidth, minHeight); var handled = false; @@ -489,8 +518,8 @@ internal bool HandleArrangeModeDown () return false; } - int minHeight = _border.Adornment!.Thickness.Vertical + parent.Margin.Thickness.Bottom; - int minWidth = _border.Adornment!.Thickness.Horizontal + parent.Margin.Thickness.Right; + int minHeight = (_border.Adornment?.Thickness.Vertical ?? 0) + parent.Margin.Thickness.Bottom; + int minWidth = (_border.Adornment?.Thickness.Horizontal ?? 0) + parent.Margin.Thickness.Right; ViewManipulator manipulator = new (parent, minWidth, minHeight); var handled = false; @@ -525,8 +554,8 @@ internal bool HandleArrangeModeLeft () return false; } - int minHeight = _border.Adornment!.Thickness.Vertical + parent.Margin.Thickness.Bottom; - int minWidth = _border.Adornment!.Thickness.Horizontal + parent.Margin.Thickness.Right; + int minHeight = (_border.Adornment?.Thickness.Vertical ?? 0) + parent.Margin.Thickness.Bottom; + int minWidth = (_border.Adornment?.Thickness.Horizontal ?? 0) + parent.Margin.Thickness.Right; ViewManipulator manipulator = new (parent, minWidth, minHeight); var handled = false; @@ -561,8 +590,8 @@ internal bool HandleArrangeModeRight () return false; } - int minHeight = _border.Adornment!.Thickness.Vertical + parent.Margin.Thickness.Bottom; - int minWidth = _border.Adornment!.Thickness.Horizontal + parent.Margin.Thickness.Right; + int minHeight = (_border.Adornment?.Thickness.Vertical ?? 0) + parent.Margin.Thickness.Bottom; + int minWidth = (_border.Adornment?.Thickness.Horizontal ?? 0) + parent.Margin.Thickness.Right; ViewManipulator manipulator = new (parent, minWidth, minHeight); var handled = false; @@ -650,7 +679,7 @@ private bool HandleMousePressed (Mouse mouseEvent) { View? parent = _border.Adornment?.Parent; - if (parent is null) + if (parent is null || mouseEvent.Position is null) { return false; } @@ -664,8 +693,7 @@ private bool HandleMousePressed (Mouse mouseEvent) // Only start grabbing if the user clicks in the Thickness area // Adornment.Contains takes Parent SuperView=relative coords. - Point clickPoint = new (mouseEvent.Position!.Value.X + parent.Frame.X + _border.Frame.X, - mouseEvent.Position!.Value.Y + parent.Frame.Y + _border.Frame.Y); + Point clickPoint = new (mouseEvent.Position.Value.X + parent.Frame.X + _border.Frame.X, mouseEvent.Position.Value.Y + parent.Frame.Y + _border.Frame.Y); if (!_border.Contains (clickPoint)) { @@ -679,7 +707,7 @@ private bool HandleMousePressed (Mouse mouseEvent) } // Set the start grab point to the Frame coords - GrabPoint = new Point (mouseEvent.Position!.Value.X + _border.Frame.X, mouseEvent.Position!.Value.Y + _border.Frame.Y); + GrabPoint = new Point (mouseEvent.Position.Value.X + _border.Frame.X, mouseEvent.Position.Value.Y + _border.Frame.Y); _dragPosition = mouseEvent.Position; // Grab mouse @@ -748,7 +776,7 @@ internal ViewArrangement DetermineArrangeModeFromClick (Point clickPoint) ViewArrangement parentArrangement = parent.Arrangement; Rectangle frame = _border.Frame; - Thickness thickness = _border.Adornment!.Thickness; + Thickness thickness = _border.Adornment?.Thickness ?? Thickness.Empty; // Check edges first (larger hit areas) // Left edge @@ -862,7 +890,7 @@ internal ViewArrangement DetermineArrangeModeFromClick (Point clickPoint) /// The mouse event containing screen position information. internal void HandleDragOperation (Mouse mouseEvent) { - Point targetLocation = _border.Adornment?.Parent!.SuperView?.ScreenToViewport (new Point (mouseEvent.ScreenPosition.X, mouseEvent.ScreenPosition.Y)) + Point targetLocation = _border.Adornment?.Parent?.SuperView?.ScreenToViewport (new Point (mouseEvent.ScreenPosition.X, mouseEvent.ScreenPosition.Y)) ?? mouseEvent.ScreenPosition; HandleDragOperation (targetLocation); @@ -887,8 +915,8 @@ internal void HandleDragOperation (Point targetLocation) return; } - int minHeight = _border.Adornment!.Thickness.Vertical + parent.Margin.Thickness.Bottom; - int minWidth = _border.Adornment!.Thickness.Horizontal + parent.Margin.Thickness.Right; + int minHeight = (_border.Adornment?.Thickness.Vertical ?? 0) + parent.Margin.Thickness.Bottom; + int minWidth = (_border.Adornment?.Thickness.Horizontal ?? 0) + parent.Margin.Thickness.Right; ViewManipulator manipulator = new (parent, GrabPoint, minWidth, minHeight); @@ -953,6 +981,7 @@ public void Dispose () // Ungrab mouse if we're still holding it if (IsDragging && _border.App is { } && _border.App.Mouse.IsGrabbed (_border)) { + _border.CommandNotBound -= BorderOnCommandNotBound; _border.App.Mouse.UngrabMouse (); } diff --git a/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs b/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs index ae1851e2ee..cd9b1fe775 100644 --- a/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs +++ b/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs @@ -1,7 +1,72 @@ namespace Terminal.Gui.ViewBase; -internal class ArrangerButton : Button +/// +/// A used in Arrange Mode to indicate and control arrangement operations. +/// Each button represents a specific arrangement action (move or resize from a particular edge). +/// +/// +/// +/// implements and uses a +/// to determine which arrow keys are bound to their directional s +/// (, , , +/// ): +/// +/// +/// +/// ButtonTypeOrientationDirectionBound Keys +/// +/// +/// +/// +/// +/// n/an/a +/// All four arrow keys +/// +/// +/// +/// +/// +/// n/an/a +/// All four arrow keys +/// +/// +/// +/// +/// +/// HorizontalBackward +/// Left, Right +/// +/// +/// +/// +/// +/// HorizontalForward +/// Left, Right +/// +/// +/// +/// +/// +/// VerticalBackward +/// Up, Down +/// +/// +/// +/// +/// +/// VerticalForward +/// Up, Down +/// +/// +/// +/// The subscribes to each button's directional commands to drive +/// keyboard-based arrangement. +/// +/// +internal class ArrangerButton : Button, IOrientation { + private readonly OrientationHelper _orientationHelper; + public ArrangerButton () { CanFocus = true; @@ -11,9 +76,45 @@ public ArrangerButton () NoPadding = true; base.ShadowStyle = null; base.Visible = false; + + AddCommand (Command.Up, DefaultAcceptHandler); + AddCommand (Command.Down, DefaultAcceptHandler); + AddCommand (Command.Left, DefaultAcceptHandler); + AddCommand (Command.Right, DefaultAcceptHandler); + + _orientationHelper = new OrientationHelper (this); } - public ArrangeButtons ButtonType { get; set; } + private ArrangeButtons _buttonType = (ArrangeButtons)(-1); + + /// + /// Gets or sets the type of arrangement button. Setting this property updates + /// and and rebinds arrow keys accordingly. + /// + public ArrangeButtons ButtonType + { + get => _buttonType; + set + { + if (_buttonType == value) + { + return; + } + + _buttonType = value; + ApplyOrientationAndDirection (); + SetupKeyBindings (); + } + } + + /// + /// Gets or sets the navigation direction for this button. + /// + /// + /// for buttons that represent the start/top/left edge. + /// for buttons that represent the end/bottom/right edge. + /// + public NavigationDirection Direction { get; set; } /// public override string Text @@ -29,4 +130,109 @@ public override string Text }; set => base.Text = value; } + + /// + /// Sets and based on . + /// + private void ApplyOrientationAndDirection () + { + switch (_buttonType) + { + case ArrangeButtons.LeftSize: + Orientation = Orientation.Horizontal; + Direction = NavigationDirection.Backward; + + break; + + case ArrangeButtons.RightSize: + Orientation = Orientation.Horizontal; + Direction = NavigationDirection.Forward; + + break; + + case ArrangeButtons.TopSize: + Orientation = Orientation.Vertical; + Direction = NavigationDirection.Backward; + + break; + + case ArrangeButtons.BottomSize: + Orientation = Orientation.Vertical; + Direction = NavigationDirection.Forward; + + break; + + case ArrangeButtons.Move: + case ArrangeButtons.AllSize: + // Both orientations apply; handled in SetupKeyBindings + Orientation = Orientation.Vertical; + Direction = NavigationDirection.Forward; + + break; + } + } + + /// + /// Binds the appropriate arrow keys to their directional based on + /// the button's and . + /// + private void SetupKeyBindings () + { + // Remove any previously bound arrow keys + KeyBindings.Remove (Key.CursorUp); + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Remove (Key.CursorRight); + + // Remove Enter and Space — arrangement buttons should not respond to Enter or Space + KeyBindings.Remove (Key.Enter); + KeyBindings.Remove (Key.Space); + + switch (_buttonType) + { + case ArrangeButtons.Move: + case ArrangeButtons.AllSize: + // All four arrow keys bound to their directional commands + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Add (Key.CursorRight, Command.Right); + + break; + + case ArrangeButtons.LeftSize: + case ArrangeButtons.RightSize: + // Horizontal: Left and Right + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Add (Key.CursorRight, Command.Right); + + break; + + case ArrangeButtons.TopSize: + case ArrangeButtons.BottomSize: + // Vertical: Up and Down + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); + + break; + } + } + + #region IOrientation members + + /// + public Orientation Orientation { get => _orientationHelper.Orientation; set => _orientationHelper.Orientation = value; } + +#pragma warning disable CS0067 // The event is never used + /// + public event EventHandler>? OrientationChanging; + + /// + public event EventHandler>? OrientationChanged; +#pragma warning restore CS0067 // The event is never used + + /// + public void OnOrientationChanged (Orientation newOrientation) { } + + #endregion } diff --git a/Terminal.Gui/ViewBase/Adornment/Border.cs b/Terminal.Gui/ViewBase/Adornment/Border.cs index 189ae57dea..88c74bf48a 100644 --- a/Terminal.Gui/ViewBase/Adornment/Border.cs +++ b/Terminal.Gui/ViewBase/Adornment/Border.cs @@ -7,13 +7,52 @@ namespace Terminal.Gui.ViewBase; /// /// /// -/// Renders a border around the view with the . A border using -/// will be drawn on the sides of that are greater than zero. +/// Border is one of three adornment layers (Margin → Border → Padding) that surround a View's content area. +/// It renders a border frame around the view using line-drawing glyphs, and can display +/// the either inline on the border or in a tab header. /// /// -/// The Border provides keyboard and mouse support for moving and resizing the View. See -/// . +/// The rendering layer () is created lazily via +/// when , , or is set. /// +/// +/// The Border also provides keyboard and mouse support for moving and resizing the View via +/// . See the +/// Arrangement Deep Dive. +/// +/// +/// is a convenience helper that sets and +/// atomically; use directly for advanced configuration. +/// +/// +/// See for the full deep dive. +/// +/// +/// Standard border with title (BorderStyle = LineStyle.Single, Thickness.Top == 1): +/// +/// ┌┤Title├──┐ +/// │ │ +/// └─────────┘ +/// +/// Rounded border with thick top (BorderStyle = LineStyle.Rounded, Thickness.Top == 3): +/// +/// ╭─────╮ +/// ╭┤Title├──╮ +/// │╰─────╯ │ +/// │ │ +/// ╰─────────╯ +/// +/// Tab-style border (Settings = BorderSettings.Tab | BorderSettings.Title, +/// TabSide = Side.Top, Thickness = new (1, 3, 1, 1)): +/// +/// ╭───╮ ╭───╮ +/// │Tab│ │Tab│ +/// ├───┴───╮ │ ╰───╮ +/// │content│ │content│ +/// ╰───────╯ ╰───────╯ +/// (unfocused) (focused) +/// +/// /// public class Border : AdornmentImpl { @@ -25,10 +64,10 @@ protected override AdornmentView CreateView () return bv; } - /// - public override Rectangle GetFrame () => Parent is { } ? Parent.Margin.Thickness.GetInside (Parent!.Margin.GetFrame ()) : Rectangle.Empty; + /// + public override Rectangle GetFrame () => Parent is { } ? Parent.Margin.Thickness.GetInside (Parent.Margin.GetFrame ()) : Rectangle.Empty; - /// + /// protected override void OnThicknessChanged () { base.OnThicknessChanged (); @@ -47,10 +86,26 @@ 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 - /// to be created. + /// Gets or sets the style of the lines drawn in the . /// + /// + /// + /// If not explicitly set, inherits from the 's + /// 's . Returns + /// if neither this Border nor any ancestor has a style set. + /// + /// + /// Setting this property to a non-null value causes the to be created + /// (via ). + /// + /// + /// Available styles: (┌─┐│└─┘), + /// (╔═╗║╚═╝), + /// (╭─╮│╰─╯), + /// (┏━┓┃┗━┛), and + /// dashed/dotted variants. + /// + /// public LineStyle? LineStyle { get => field ?? Parent?.SuperView?.BorderStyle ?? null; @@ -63,7 +118,7 @@ public LineStyle? LineStyle field = value; - if (field is not null) + if (field is { }) { GetOrCreateView (); } @@ -71,8 +126,18 @@ public LineStyle? LineStyle } /// - /// Gets or sets the settings for the border. + /// Gets or sets the flags that control rendering behavior. /// + /// + /// + /// Defaults to . Set to | + /// to enable tab-style headers. Setting + /// causes the to be created and configures it for transparent rendering. + /// + /// + /// enables gradient-filled borders using . + /// + /// public BorderSettings Settings { get; @@ -82,8 +147,15 @@ public BorderSettings Settings { return; } + field = value; + if (field.HasFlag (BorderSettings.Tab)) + { + GetOrCreateView (); + } + + (View as BorderView)?.OnSettingsChanged (); Parent?.SetNeedsLayout (); } } = BorderSettings.Title; diff --git a/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs b/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs index 4921446aec..99c8fbdb56 100644 --- a/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs +++ b/Terminal.Gui/ViewBase/Adornment/BorderSettings.cs @@ -1,23 +1,31 @@ namespace Terminal.Gui.ViewBase; /// -/// Determines the settings for . +/// Determines the settings for . /// [Flags] public enum BorderSettings { /// - /// No settings. + /// The default settings, which uses line-drawing glyphs and draws the border flush with + /// the content (i.e., no title or tab, and of 1 on all sides). /// - None = 0, + Default = 0, /// - /// Show the title. + /// Show the title. /// Title = 1, /// - /// Use to draw the border. + /// 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.Arrangement.cs b/Terminal.Gui/ViewBase/Adornment/BorderView.Arrangement.cs index b478d60794..2359c6b760 100644 --- a/Terminal.Gui/ViewBase/Adornment/BorderView.Arrangement.cs +++ b/Terminal.Gui/ViewBase/Adornment/BorderView.Arrangement.cs @@ -8,27 +8,17 @@ public partial class BorderView /// /// INTERNAL: Gets the responsible for handling Arrange Mode for this . + /// The Arranger manages mouse hit-testing on border edges, drag operations for move/resize, and + /// keyboard-based arrangement via Ctrl+F5. /// - internal Arranger Arranger => _arranger ??= CreateArranger (); + /// + /// See the Arrangement Deep Dive. + /// + internal Arranger Arranger => _arranger ??= new Arranger (this); /// protected override bool OnMouseEvent (Mouse mouseEvent) => Arranger.HandleMouseEvent (mouseEvent); - private Arranger CreateArranger () - { - var arranger = new Arranger (this); - - AddCommand (Command.Quit, () => _arranger?.ExitArrangeMode ()); - AddCommand (Command.Up, () => _arranger?.HandleArrangeModeUp ()); - AddCommand (Command.Down, () => _arranger?.HandleArrangeModeDown ()); - AddCommand (Command.Left, () => _arranger?.HandleArrangeModeLeft ()); - AddCommand (Command.Right, () => _arranger?.HandleArrangeModeRight ()); - AddCommand (Command.NextTabStop, () => _arranger?.HandleArrangeModeTab ()); - AddCommand (Command.PreviousTabStop, () => _arranger?.HandleArrangeModeBackTab ()); - - return arranger; - } - /// protected override void Dispose (bool disposing) { diff --git a/Terminal.Gui/ViewBase/Adornment/BorderView.cs b/Terminal.Gui/ViewBase/Adornment/BorderView.cs index ada3f7edf4..db7ecb381a 100644 --- a/Terminal.Gui/ViewBase/Adornment/BorderView.cs +++ b/Terminal.Gui/ViewBase/Adornment/BorderView.cs @@ -1,41 +1,46 @@ +using TitleViewType = Terminal.Gui.ViewBase.TitleView; + namespace Terminal.Gui.ViewBase; /// -/// The View-backed rendering layer for the Border adornment. +/// The View-backed rendering, navigation, and arrangement layer for the adornment. /// Created lazily by (via ) /// when rendering, arrangement, or other View-level functionality is needed. /// /// /// -/// Renders a border around the view with the . A border using -/// will be drawn on the sides of that are greater than zero. +/// has two rendering code paths selected by : /// +/// +/// +/// +/// Legacy mode ( not set): Draws the border frame and +/// inline title using . Title position is determined by +/// on the title side (1 = inline, 2 = cap line, 3+ = enclosed +/// rectangle). +/// +/// +/// +/// +/// Tab mode ( set): Draws a content border frame and a separate +/// tab header via a SubView. The TitleView has +/// = , so its border lines +/// auto-join with the content border via . +/// +/// +/// /// -/// The of will be drawn based on the value of -/// : -/// -/// // If Thickness.Top is 1: -/// ┌┤1234├──┐ -/// │ │ -/// └────────┘ -/// // If Thickness.Top is 2: -/// ┌────┐ -/// ┌┤1234├──┐ -/// │ │ -/// └────────┘ -/// If Thickness.Top is 3: -/// ┌────┐ -/// ┌┤1234├──┐ -/// │└────┘ │ -/// │ │ -/// └────────┘ -/// +/// Mouse and Keyboard-driven move/resize is handled by the (see +/// +/// and the Arrangement Deep Dive). /// /// -/// The Border provides keyboard and mouse support for moving and resizing the View. See -/// . +/// See for the full deep dive. /// /// +/// +/// +/// public partial class BorderView : AdornmentView { /// @@ -53,7 +58,7 @@ public BorderView (Border border) : base (border) return; } CanFocus = false; - TabStop = TabBehavior.TabGroup; + TabStop = TabBehavior.TabStop; if (border.Parent is { }) { @@ -63,6 +68,22 @@ public BorderView (Border border) : base (border) border.Parent?.Margin.ThicknessChanged += OnThicknessChanged; } + /// + public override void BeginInit () + { + base.BeginInit (); + + if (Adornment?.Parent is null) + { + return; + } + + ShowHideDrawIndicator (); + ConfigureForTabMode (); + + MouseHighlightStates |= Adornment.Parent.Arrangement != ViewArrangement.Fixed ? MouseState.Pressed : MouseState.None; + } + /// public override void OnParentFrameChanged (Rectangle newParentFrame) { @@ -84,126 +105,33 @@ private void OnThicknessChanged (object? sender, EventArgs e) { ShowHideDrawIndicator (); } - } - - private void ShowHideDrawIndicator () - { - if (View.Diagnostics.HasFlag (ViewDiagnosticFlags.DrawIndicator) && Adornment!.Thickness != Thickness.Empty) - { - if (DrawIndicator is { }) - { - return; - } - - DrawIndicator = new SpinnerView - { -#if DEBUG - Id = "DrawIndicator", -#endif - X = 1, - Style = new SpinnerStyle.Dots2 (), - SpinDelay = 0, - Visible = false - }; - Add (DrawIndicator); - } - else if (DrawIndicator is { }) - { - Remove (DrawIndicator); - DrawIndicator!.Dispose (); - DrawIndicator = null; - } - } - internal void AdvanceDrawIndicator () - { - if (!View.Diagnostics.HasFlag (ViewDiagnosticFlags.DrawIndicator) || DrawIndicator is null) - { - return; - } - DrawIndicator.AdvanceAnimation (false); - DrawIndicator.Render (); + ConfigureForTabMode (); } -#if SUBVIEW_BASED_BORDER - private Line _left; - /// - /// The close button for the border. Set to , to to enable. + /// Called by setter when settings change. + /// Reconfigures tab mode state. /// - public Button CloseButton { get; internal set; } -#endif + internal void OnSettingsChanged () => ConfigureForTabMode (); - /// - public override void BeginInit () + private Rectangle GetBorderBounds () { - base.BeginInit (); - - if (Adornment?.Parent is null) + if (Adornment is null) { - return; + return ViewportToScreen (Viewport); } - ShowHideDrawIndicator (); - - 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); - return new Rectangle (screenRect.X + Math.Max (0, Adornment!.Thickness.Left - 1), - screenRect.Y + Math.Max (0, Adornment!.Thickness.Top - 1), + return new Rectangle (screenRect.X + Math.Max (0, Adornment.Thickness.Left - 1), + screenRect.Y + Math.Max (0, Adornment.Thickness.Top - 1), Math.Max (0, screenRect.Width - - Math.Max (0, Math.Max (0, Adornment!.Thickness.Left - 1) + Math.Max (0, Adornment!.Thickness.Right - 1))), + - Math.Max (0, Math.Max (0, Adornment.Thickness.Left - 1) + Math.Max (0, Adornment.Thickness.Right - 1))), Math.Max (0, screenRect.Height - - Math.Max (0, Math.Max (0, Adornment!.Thickness.Top - 1) + Math.Max (0, Adornment!.Thickness.Bottom - 1)))); + - Math.Max (0, Math.Max (0, Adornment.Thickness.Top - 1) + Math.Max (0, Adornment.Thickness.Bottom - 1)))); } /// @@ -219,6 +147,21 @@ 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); + } + + return DrawLegacyBorder (context, border); + } + + /// + /// INTERNAL: Draws the border and title when is not set. + /// Retained for backward compatibility and for use when tab mode is not desired. + /// + private bool DrawLegacyBorder (DrawContext? context, Border border) + { Rectangle screenBounds = ViewportToScreen (Viewport); Rectangle borderBounds = GetBorderBounds (); @@ -227,17 +170,18 @@ protected override bool OnDrawingContent (DrawContext? context) var titleBarsLength = 0; int maxTitleWidth = Math.Max (0, - Math.Min (Adornment.Parent?.TitleTextFormatter.FormatAndGetSize ().Width ?? 0, + Math.Min (Adornment?.Parent?.TitleTextFormatter.FormatAndGetSize ().Width ?? 0, Math.Min (screenBounds.Width - 4, borderBounds.Width - 4))); - Adornment.Parent?.TitleTextFormatter.ConstrainToSize = new Size (maxTitleWidth, 1); + Adornment?.Parent?.TitleTextFormatter.ConstrainToSize = new Size (maxTitleWidth, 1); int sideLineLength = borderBounds.Height; bool canDrawBorder = borderBounds is { Width: > 0, Height: > 0 }; + bool hasTitle = border.Settings.FastHasFlags (BorderSettings.Title); - if (border.Settings.FastHasFlags (BorderSettings.Title)) + if (hasTitle) { - switch (Adornment!.Thickness.Top) + switch (Adornment?.Thickness.Top) { case 2: topTitleLineY = borderBounds.Y - 1; @@ -247,7 +191,7 @@ protected override bool OnDrawingContent (DrawContext? context) break; case 3: - topTitleLineY = borderBounds.Y - (Adornment!.Thickness.Top - 1); + topTitleLineY = borderBounds.Y - (Adornment.Thickness.Top - 1); titleY = topTitleLineY + 1; titleBarsLength = 3; sideLineLength++; @@ -264,13 +208,14 @@ protected override bool OnDrawingContent (DrawContext? context) } } + // Draw title text if (Driver is { } - && Adornment.Parent is { } + && Adornment?.Parent is { } && canDrawBorder - && Adornment!.Thickness.Top > 0 && maxTitleWidth > 0 - && border.Settings.FastHasFlags (BorderSettings.Title) - && !string.IsNullOrEmpty (Adornment.Parent?.Title)) + && hasTitle + && !string.IsNullOrEmpty (Adornment.Parent?.Title) + && Adornment.Thickness.Top > 0) { Rectangle titleRect = new (borderBounds.X + 2, titleY, maxTitleWidth, 1); @@ -279,13 +224,6 @@ protected override bool OnDrawingContent (DrawContext? context) GetAttributeForRole (Adornment.Parent.HasFocus ? VisualRole.Focus : VisualRole.Normal), GetAttributeForRole (Adornment.Parent.HasFocus ? VisualRole.HotFocus : VisualRole.HotNormal)); - // Cache the title rect for the parent's DoDrawComplete to use when building - // drawn region for transparent borders. The title is drawn directly (not via LineCanvas), - // so it won't be captured by RenderLineCanvas's region reporting. - LastTitleRect = titleRect; - - // Report the title rect to the DrawContext so it participates in clip exclusion. - // This ensures the title occludes peer subviews when the Border is transparent. context?.AddDrawnRectangle (titleRect); Adornment.Parent?.LineCanvas.Exclude (new Region (titleRect)); } @@ -294,29 +232,37 @@ protected override bool OnDrawingContent (DrawContext? context) { return true; } + LineCanvas? lc = Adornment.Parent?.LineCanvas; - bool drawTop = Adornment!.Thickness.Top > 0 && Frame is { Width: > 1, Height: >= 1 }; - bool drawLeft = Adornment!.Thickness.Left > 0 && (Frame.Height > 1 || Adornment!.Thickness.Top == 0); - bool drawBottom = Adornment!.Thickness.Bottom > 0 && Frame is { Width: > 1, Height: > 1 }; - bool drawRight = Adornment!.Thickness.Right > 0 && (Frame.Height > 1 || Adornment!.Thickness.Top == 0); + bool drawTop = Adornment.Thickness.Top > 0 && Frame is { Width: > 1, Height: >= 1 }; + bool drawLeft = Adornment.Thickness.Left > 0 && (Frame.Height > 1 || Adornment.Thickness.Top == 0); + bool drawBottom = Adornment.Thickness.Bottom > 0 && Frame is { Width: > 1, Height: > 1 }; + bool drawRight = Adornment.Thickness.Right > 0 && (Frame.Height > 1 || Adornment.Thickness.Top == 0); Attribute normalAttribute = GetAttributeForRole (VisualRole.Normal); +#if TAB_COLOR_PROTOTYPE + if (Adornment.Parent is TitleView titleView && Adornment.Parent is { }) + { + normalAttribute = Adornment.Parent.GetAttributeForRole (VisualRole.Normal); + } + if (MouseState.HasFlag (MouseState.Pressed)) { normalAttribute = GetAttributeForRole (VisualRole.Highlight); } +#endif SetAttribute (normalAttribute); if (drawTop) { - if (borderBounds.Width < 4 || !border.Settings.FastHasFlags (BorderSettings.Title) || string.IsNullOrEmpty (Adornment.Parent?.Title)) + if (borderBounds.Width < 4 || !hasTitle || string.IsNullOrEmpty (Adornment.Parent?.Title)) { if (border.LineStyle is { }) { - lc?.AddLine (new Point (borderBounds.Location.X, titleY), + lc?.AddLine (borderBounds.Location with { Y = borderBounds.Y }, borderBounds.Width, Orientation.Horizontal, border.LineStyle.Value, @@ -325,31 +271,32 @@ protected override bool OnDrawingContent (DrawContext? context) } else { - if (Adornment!.Thickness.Top == 2) + // Title bar decoration + if (Adornment.Thickness.Top == 2) { lc?.AddLine (new Point (borderBounds.X + 1, topTitleLineY), Math.Min (borderBounds.Width - 2, maxTitleWidth + 2), Orientation.Horizontal, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); } - if (borderBounds.Width >= 4 && Adornment!.Thickness.Top > 2) + if (borderBounds.Width >= 4 && Adornment.Thickness.Top > 2) { lc?.AddLine (new Point (borderBounds.X + 1, topTitleLineY), Math.Min (borderBounds.Width - 2, maxTitleWidth + 2), Orientation.Horizontal, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); lc?.AddLine (new Point (borderBounds.X + 1, topTitleLineY + 2), Math.Min (borderBounds.Width - 2, maxTitleWidth + 2), Orientation.Horizontal, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); } - lc?.AddLine (borderBounds.Location with { Y = titleY }, 2, Orientation.Horizontal, border.LineStyle!.Value, normalAttribute); + lc?.AddLine (borderBounds.Location with { Y = titleY }, 2, Orientation.Horizontal, border.LineStyle ?? LineStyle.None, normalAttribute); lc?.AddLine (new Point (borderBounds.X + 1, topTitleLineY), titleBarsLength, Orientation.Vertical, LineStyle.Single, normalAttribute); @@ -362,25 +309,22 @@ protected override bool OnDrawingContent (DrawContext? context) lc?.AddLine (new Point (borderBounds.X + 1 + Math.Min (borderBounds.Width - 2, maxTitleWidth + 2) - 1, titleY), borderBounds.Width - Math.Min (borderBounds.Width - 2, maxTitleWidth + 2), Orientation.Horizontal, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); } } -#if !SUBVIEW_BASED_BORDER - if (drawLeft) { - lc?.AddLine (borderBounds.Location with { Y = titleY }, sideLineLength, Orientation.Vertical, border.LineStyle!.Value, normalAttribute); + lc?.AddLine (borderBounds.Location with { Y = titleY }, sideLineLength, Orientation.Vertical, border.LineStyle ?? LineStyle.None, normalAttribute); } -#endif if (drawBottom) { lc?.AddLine (new Point (borderBounds.X, borderBounds.Y + borderBounds.Height - 1), borderBounds.Width, Orientation.Horizontal, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); } @@ -389,7 +333,7 @@ protected override bool OnDrawingContent (DrawContext? context) lc?.AddLine (new Point (borderBounds.X + borderBounds.Width - 1, titleY), sideLineLength, Orientation.Vertical, - border.LineStyle!.Value, + border.LineStyle ?? LineStyle.None, normalAttribute); } @@ -405,7 +349,7 @@ protected override bool OnDrawingContent (DrawContext? context) if (drawTop && maxTitleWidth > 0 && border.Settings.FastHasFlags (BorderSettings.Title)) { - Adornment.Parent!.TitleTextFormatter.Draw (Driver, + Adornment.Parent?.TitleTextFormatter.Draw (Driver, new Rectangle (borderBounds.X + 2, titleY, maxTitleWidth, 1), Adornment.Parent.HasFocus ? Adornment.Parent.GetAttributeForRole (VisualRole.Focus) @@ -433,15 +377,15 @@ protected override bool OnDrawingContent (DrawContext? context) } } - if (border.Settings.FastHasFlags (BorderSettings.Gradient)) + if (lc is { } && border.Settings.FastHasFlags (BorderSettings.Gradient)) { if (_cachedGradientFill is null || _cachedGradientRect != screenBounds) { - SetupGradientLineCanvas (lc!, screenBounds); + SetupGradientLineCanvas (lc, screenBounds); } else { - lc!.Fill = _cachedGradientFill; + lc.Fill = _cachedGradientFill; } } else @@ -453,17 +397,716 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } + #region Border.Settings.Tab Support + + /// + /// Gets or sets which side the tab header protrudes from. Defaults to . + /// Only meaningful when includes . + /// + /// + /// For and , the title text renders vertically. + /// + public Side TabSide + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } + } = Side.Top; + + /// + /// Gets or sets the offset along the border edge where the tab header starts (columns for + /// /, rows for /). + /// Only meaningful when includes . + /// + /// + /// Can be positive (shifted right/down), zero (at the start), or negative (shifted left/up, + /// partially off-screen). The is clipped automatically by the View system's + /// natural viewport clipping. + /// + public int TabOffset + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } + } + + /// + /// Gets or sets the total length of the tab header parallel to the border edge (including border cells). + /// If , the length is auto-computed from the width plus the + /// 's border cells. Only meaningful when includes + /// . + /// + public int? TabLength + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } + } + + 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; + + EnsureTitleView (); + + if (TitleView is not ITitleView itv) + { + return; + } + itv.TabSide = TabSide; + itv.TabDepth = GetTabDepth (); + } + else + { + // Only clear flags if we set them for tab mode + if (_tabModeSetTransparent) + { + ViewportSettings &= ~(ViewportSettingsFlags.Transparent | ViewportSettingsFlags.TransparentMouse); + _tabModeSetTransparent = false; + } + + TitleView?.Visible = false; + } + } + +#if TAB_COLOR_PROTOTYPE + /// + protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attribute currentAttribute) + { + if (base.OnGettingAttributeForRole (in role, ref currentAttribute)) + { + return true; + } + + if (Adornment is not Border border || !border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return false; + } + + return false; + } +#endif + + /// + protected override void OnSubViewLayout (LayoutEventArgs args) => UpdateTitleViewLayout (); + + /// + /// Delegates layout of the to the implementation. + /// Called via . + /// + private void UpdateTitleViewLayout () + { + if (Adornment is not Border border || !border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return; + } + + if (_titleView is not ITitleView itv) + { + return; + } + + // Ensure stored state is current before layout + itv.TabSide = TabSide; + itv.TabDepth = GetTabDepth (); + + itv.UpdateLayout (new TabLayoutContext + { + BorderBounds = GetTabBorderBounds (), + TabOffset = TabOffset, + TabLengthOverride = TabLength, + HasFocus = IsFocusedOrLastTab (), + LineStyle = border.LineStyle, + Title = Adornment.Parent?.Title ?? string.Empty, + ScreenOrigin = ViewportToScreen (Point.Empty) + }); + } + + /// + /// Computes the content border rectangle in screen coordinates 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 () + { + Rectangle screenRect = ViewportToScreen (Viewport); + + if (Adornment is null) + { + return screenRect; + } + + 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 (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 TitleView? _titleView; + + /// + /// Gets the tab header SubView, or if + /// is not set or the view has not yet been created. + /// + /// + /// The is created lazily by when + /// is first set. It can be used to hook mouse events for + /// custom behaviors such as drag-to-slide tab reordering. + /// + public View? TitleView => _titleView; + + /// + /// Gets the effective tab length — either the explicit or + /// the from the laid-out . + /// Returns 0 if no exists yet. + /// + public int EffectiveTabLength + { + get + { + if (Adornment is not Border _) + { + return 0; + } + + if (TabLength is { } explicitLength) + { + return explicitLength; + } + + if (TitleView is not (ITitleView itv and View tv)) + { + return 0; + } + + if (itv.MeasuredTabLength > 0) + { + return itv.MeasuredTabLength; + } + + // TitleView hasn't been laid out yet — set text and orientation, then measure. + tv.Text = Adornment?.Parent?.Title ?? string.Empty; + itv.Orientation = TabSide is Side.Left or Side.Right ? Orientation.Vertical : Orientation.Horizontal; + + int measured = TabSide is Side.Top or Side.Bottom ? tv.GetAutoWidth () : tv.GetAutoHeight (); + itv.MeasuredTabLength = measured; + + return measured; + } + } + + /// + /// 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 void EnsureTitleView () + { + if (TitleView is { }) + { + return; + } + + _titleView = new TitleView + { +#if DEBUG + Id = "TitleView", +#endif + }; + Add (TitleView); + } + + private int GetTabDepth () + { + if (Adornment is null) + { + return 0; + } + + return TabSide switch + { + Side.Top => Adornment.Thickness.Top, + Side.Bottom => Adornment.Thickness.Bottom, + Side.Left => Adornment.Thickness.Left, + Side.Right => Adornment.Thickness.Right, + _ => 3 + }; + } + + /// + /// Draws the border and tab header when is set. + /// Uses a SubView with its own border and + /// = true for the tab header. + /// The TitleView'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 (); + + 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 (); + + int effectiveTabLength = EffectiveTabLength; + + if (effectiveTabLength > 0) + { + LineStyle lineStyle = border.LineStyle.Value; + bool hasFocus = IsFocusedOrLastTab (); + + // Compute tab header geometry + Rectangle headerRect = TitleViewType.ComputeHeaderRect (borderBounds, TabSide, TabOffset, effectiveTabLength, tabDepth); + Rectangle viewBounds = TitleViewType.ComputeViewBounds (borderBounds, 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 && (TabSide != Side.Top || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Y), borderBounds.Width, Orientation.Horizontal, lineStyle, normalAttribute); + } + + if (Adornment.Thickness.Bottom > 0 && (TabSide != Side.Bottom || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Bottom - 1), borderBounds.Width, Orientation.Horizontal, lineStyle, normalAttribute); + } + + if (Adornment.Thickness.Left > 0 && (TabSide != Side.Left || !tabVisible)) + { + lc.AddLine (new Point (borderBounds.X, borderBounds.Y), borderBounds.Height, Orientation.Vertical, lineStyle, normalAttribute); + } + + if (Adornment.Thickness.Right > 0 && (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, + TabSide, + hasFocus, + tabDepth, + lineStyle, + normalAttribute); + } + } + + // 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; + } + + private bool IsFocusedOrLastTab () + { + if (Adornment is not Border border || !border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return false; + } + + // If the Parent is in a Tabs container, and it is the last subview of its SuperView, treat it as though it is focused + if (border.Parent is { SuperView: Tabs } tab && tab.SuperView?.SubViews.LastOrDefault () == tab) + { + return true; + } + + return border.Parent?.HasFocus ?? false; + } + /// - /// 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. + /// When in tab mode, if a command is not handled by the TitleView, bubble it to the SuperView (e.g. Tabs); + /// this enables keyboard navigation commands to be handled by the Tabs container when the TitleView has focus. /// - internal Rectangle? LastTitleRect { get; set; } + /// + /// + protected override bool OnCommandNotBound (CommandEventArgs args) + { + if (base.OnCommandNotBound (args)) + { + return true; + } + + if (Adornment is not Border border || !border.Settings.FastHasFlags (BorderSettings.Tab)) + { + return false; + } + + if (args.Context.TryGetSource (out View? view) && view is TitleView && args.Context is { }) + { + return border.Parent?.SuperView?.InvokeCommand (args.Context.Command, args.Context) is true; + } + + return false; + } /// - /// Gets the subview used to render . + /// 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 + { + // Reserve the gap cells so overlapped compositing suppresses + // lower-Z views' content border lines at these positions. + int gapStart = clipped.X + 1; + int gapEnd = clipped.Right - 1; + + if (gapEnd > gapStart) + { + lc.Reserve (new Rectangle (gapStart, borderY, gapEnd - gapStart, 1)); + } + + 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 + { + int gapStart = clipped.X + 1; + int gapEnd = clipped.Right - 1; + + if (gapEnd > gapStart) + { + lc.Reserve (new Rectangle (gapStart, borderY, gapEnd - gapStart, 1)); + } + + 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 + { + int gapStart = clipped.Y + 1; + int gapEnd = clipped.Bottom - 1; + + if (gapEnd > gapStart) + { + lc.Reserve (new Rectangle (borderX, gapStart, 1, gapEnd - gapStart)); + } + + 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 + { + int gapStart = clipped.Y + 1; + int gapEnd = clipped.Bottom - 1; + + if (gapEnd > gapStart) + { + lc.Reserve (new Rectangle (borderX, gapStart, 1, gapEnd - gapStart)); + } + + 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); + } + } + + #endregion Border.Settings.Tab Support + + #region DrawIndicator Support + + /// + /// Gets the SubView used to render , + /// or if the diagnostic flag is not set or the border has zero thickness. /// public SpinnerView? DrawIndicator { get; private set; } + private void ShowHideDrawIndicator () + { + if (View.Diagnostics.HasFlag (ViewDiagnosticFlags.DrawIndicator) && Adornment?.Thickness != Thickness.Empty) + { + if (DrawIndicator is { }) + { + return; + } + + DrawIndicator = new SpinnerView + { +#if DEBUG + Id = "DrawIndicator", +#endif + X = 1, + Style = new SpinnerStyle.Dots2 (), + SpinDelay = 0, + Visible = false + }; + Add (DrawIndicator); + } + else if (DrawIndicator is { }) + { + Remove (DrawIndicator); + DrawIndicator?.Dispose (); + DrawIndicator = null; + } + } + + internal void AdvanceDrawIndicator () + { + if (!View.Diagnostics.HasFlag (ViewDiagnosticFlags.DrawIndicator) || DrawIndicator is null) + { + return; + } + DrawIndicator.AdvanceAnimation (false); + DrawIndicator.Render (); + } + + #endregion DrawIndicator Support + + #region Gradient Support + private FillPair? _cachedGradientFill; private Rectangle _cachedGradientRect; @@ -494,4 +1137,6 @@ private static void GetAppealingGradientColors (out List stops, out List< steps = [15]; } + + #endregion Gradient Support } diff --git a/Terminal.Gui/ViewBase/Adornment/ITitleView.cs b/Terminal.Gui/ViewBase/Adornment/ITitleView.cs new file mode 100644 index 0000000000..2d4221cfcd --- /dev/null +++ b/Terminal.Gui/ViewBase/Adornment/ITitleView.cs @@ -0,0 +1,29 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// Defines the contract for a replaceable tab header view used by . +/// Implementations must also derive from so they can be added as SubViews. +/// +public interface ITitleView : IOrientation +{ + /// + /// Gets or sets the tab depth — the number of rows (or columns) the tab header occupies + /// on its . + /// + int TabDepth { get; set; } + + /// + /// Gets the measured tab length (in cells) after has auto-sized the title view. + /// Returns 0 if has not yet been called. + /// + int MeasuredTabLength { get; set; } + + /// Gets or sets which side of the content border the tab header sits on. + Side TabSide { get; set; } + + /// + /// Updates this title view's frame, border thickness, text, orientation, padding, + /// and visibility based on the tab layout context provided by the owning . + /// + void UpdateLayout (in TabLayoutContext context); +} diff --git a/Terminal.Gui/ViewBase/Adornment/MarginView.cs b/Terminal.Gui/ViewBase/Adornment/MarginView.cs index 809b560821..ed854d212c 100644 --- a/Terminal.Gui/ViewBase/Adornment/MarginView.cs +++ b/Terminal.Gui/ViewBase/Adornment/MarginView.cs @@ -62,11 +62,11 @@ public MarginView (Margin margin) : base (margin) private void OnThicknessChanged (object? sender, EventArgs e) { - if (_isThicknessChanging) + if (_isThicknessChanging || Adornment is null) { return; } - _originalThickness = new Thickness (Adornment!.Thickness.Left, Adornment!.Thickness.Top, Adornment!.Thickness.Right, Adornment!.Thickness.Bottom); + _originalThickness = new Thickness (Adornment.Thickness.Left, Adornment.Thickness.Top, Adornment.Thickness.Right, Adornment.Thickness.Bottom); if (ShadowStyle is { }) { @@ -83,7 +83,7 @@ private void OnThicknessChanged (object? sender, EventArgs e) internal void CacheClip () { - if (Adornment!.Thickness != Thickness.Empty && ShadowStyle != ShadowStyles.None) + if (Adornment?.Thickness != Thickness.Empty && ShadowStyle != ShadowStyles.None) { _cachedClip = GetClip ()?.Clone (); } @@ -357,10 +357,10 @@ private void OnParentOnMouseStateChanged (object? sender, EventArgs // Note, for visual effects reasons, we only move horizontally. _isThicknessChanging = true; - Adornment!.Thickness = new Thickness (Adornment!.Thickness.Left - PRESS_MOVE_HORIZONTAL, - Adornment!.Thickness.Top - PRESS_MOVE_VERTICAL, - Adornment!.Thickness.Right + PRESS_MOVE_HORIZONTAL, - Adornment!.Thickness.Bottom + PRESS_MOVE_VERTICAL); + Adornment.Thickness = new Thickness (Adornment.Thickness.Left - PRESS_MOVE_HORIZONTAL, + Adornment.Thickness.Top - PRESS_MOVE_VERTICAL, + Adornment.Thickness.Right + PRESS_MOVE_HORIZONTAL, + Adornment.Thickness.Bottom + PRESS_MOVE_VERTICAL); _isThicknessChanging = false; _rightShadow?.Visible = true; @@ -381,10 +381,10 @@ private void OnParentOnMouseStateChanged (object? sender, EventArgs // Note, for visual effects reasons, we only move horizontally. _isThicknessChanging = true; - Adornment!.Thickness = new Thickness (Adornment!.Thickness.Left + PRESS_MOVE_HORIZONTAL, - Adornment!.Thickness.Top + PRESS_MOVE_VERTICAL, - Adornment!.Thickness.Right - PRESS_MOVE_HORIZONTAL, - Adornment!.Thickness.Bottom - PRESS_MOVE_VERTICAL); + Adornment.Thickness = new Thickness (Adornment.Thickness.Left + PRESS_MOVE_HORIZONTAL, + Adornment.Thickness.Top + PRESS_MOVE_VERTICAL, + Adornment.Thickness.Right - PRESS_MOVE_HORIZONTAL, + Adornment.Thickness.Bottom - PRESS_MOVE_VERTICAL); _isThicknessChanging = false; MouseState |= MouseState.Pressed; @@ -405,13 +405,13 @@ private void MarginView_LayoutStarted (object? sender, LayoutEventArgs e) switch (ShadowStyle) { case ShadowStyles.Transparent: - _rightShadow.Y = Adornment.Parent!.Border.Thickness.Top > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).Y + 1 : 0; + _rightShadow.Y = Adornment.Parent?.Border.Thickness.Top > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).Y + 1 : 0; break; case ShadowStyles.Opaque: - _rightShadow.Y = Adornment.Parent!.Border.Thickness.Top > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).Y + 1 : 0; - _bottomShadow.X = Adornment.Parent.Border.Thickness.Left > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).X + 1 : 0; + _rightShadow.Y = Adornment.Parent?.Border.Thickness.Top > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).Y + 1 : 0; + _bottomShadow.X = Adornment.Parent?.Border.Thickness.Left > 0 ? ScreenToViewport (Adornment.Parent.Border.FrameToScreen ().Location).X + 1 : 0; break; diff --git a/Terminal.Gui/ViewBase/Adornment/ShadowView.cs b/Terminal.Gui/ViewBase/Adornment/ShadowView.cs index e59a87e840..191fc2081f 100644 --- a/Terminal.Gui/ViewBase/Adornment/ShadowView.cs +++ b/Terminal.Gui/ViewBase/Adornment/ShadowView.cs @@ -175,7 +175,13 @@ private Attribute GetAttributeUnderLocation (Point location) return Attribute.Default; } - Attribute attr = ScreenContents [location.Y, location.X].Attribute!.Value; + Attribute? attribute = ScreenContents [location.Y, location.X].Attribute; + + if (attribute is null) + { + return Attribute.Default; + } + Attribute attr = attribute.Value; var newAttribute = new Attribute (ShadowStyle == ShadowStyles.Opaque ? Color.Black : attr.Foreground.GetDimmerColor (), ShadowStyle == ShadowStyles.Opaque ? attr.Background : attr.Background.GetDimmerColor (0.05), diff --git a/Terminal.Gui/ViewBase/Adornment/TabLayoutContext.cs b/Terminal.Gui/ViewBase/Adornment/TabLayoutContext.cs new file mode 100644 index 0000000000..ea8c252d28 --- /dev/null +++ b/Terminal.Gui/ViewBase/Adornment/TabLayoutContext.cs @@ -0,0 +1,32 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// Parameters passed from to an during layout. +/// Captures the border/tab state that the title view cannot derive from its own stored properties. +/// +public readonly record struct TabLayoutContext +{ + /// Gets the content border rectangle in screen coordinates. + public required Rectangle BorderBounds { get; init; } + + /// Gets the tab offset along the tab side (in cells from the content border origin). + public required int TabOffset { get; init; } + + /// + /// Gets an explicit tab length override, or to auto-size from the title text. + /// When set, the title view uses this length instead of auto-sizing. + /// + public int? TabLengthOverride { get; init; } + + /// Gets whether this tab is focused or is the last tab (open-gap state). + public required bool HasFocus { get; init; } + + /// Gets the to apply to the title view's border. + public required LineStyle? LineStyle { get; init; } + + /// Gets the title text to display in the header. + public required string Title { get; init; } + + /// Gets the screen origin of the owning 's viewport, used for coordinate conversion. + public required Point ScreenOrigin { get; init; } +} diff --git a/Terminal.Gui/ViewBase/Adornment/TitleView.cs b/Terminal.Gui/ViewBase/Adornment/TitleView.cs new file mode 100644 index 0000000000..bb3a78fa23 --- /dev/null +++ b/Terminal.Gui/ViewBase/Adornment/TitleView.cs @@ -0,0 +1,337 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// A lightweight that renders title text with focus-appropriate attributes +/// and raises directional s based on its and +/// . +/// +/// +/// +/// implements . When is +/// , is set to +/// and Left/Right arrow keys are bound to +/// /. +/// When , text renders top-to-bottom and Up/Down arrow keys +/// are bound to /. +/// +/// +/// The owning view subscribes to 's directional commands (via +/// ) to handle navigation. +/// +/// +public sealed class TitleView : View, ITitleView, IDesignable +{ + private readonly OrientationHelper _orientationHelper; + + /// + /// Initializes a new instance of the class. + /// + public TitleView () + { + CanFocus = true; + + Width = Dim.Auto (); + Height = Dim.Auto (); + + TabStop = TabBehavior.TabStop; + Border.Settings = BorderSettings.Default; + SuperViewRendersLineCanvas = true; + + AddCommand (Command.HotKey, + ctx => + { + if (RaiseHandlingHotKey (ctx) is true) + { + return true; + } + + SetFocus (); + + return true; + }); + + // Remove Enter — title views should not respond to Enter + KeyBindings.Remove (Key.Enter); + + KeyBindings.Add (Key.CursorRight, Command.Right); + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); + + MouseBindings.Clear (); + MouseBindings.Add (MouseFlags.LeftButtonClicked, Command.HotKey); + + _orientationHelper = new OrientationHelper (this); + + // Default to horizontal — call SetupKeyBindings directly because OrientationHelper's + // default is also Horizontal, so setting it won't trigger OnOrientationChanged. + SetupKeyBindings (); + + TextFormatter.Alignment = Alignment.Center; + TextFormatter.VerticalAlignment = Alignment.Center; + + // Setup defaults + BorderStyle = LineStyle.Rounded; + TabSide = Side.Top; + } + +#if TAB_COLOR_PROTOTYPE + /// + protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attribute currentAttribute) + { + if (base.OnGettingAttributeForRole (in role, ref currentAttribute)) + { + return true; + } + + if (role == VisualRole.Normal) + { + currentAttribute = new Attribute (Color.Red, currentAttribute.Background); + + return true; + } + + if (role == VisualRole.HotNormal) + { + currentAttribute = new Attribute (Color.Red, currentAttribute.Background); + + return true; + } + + return false; + } +#endif + + /// + /// Gets or sets the navigation direction for this title view. + /// + /// + /// for titles on the top or left edge. + /// for titles on the bottom or right edge. + /// + public NavigationDirection Direction { get; set; } + + /// + public override string Text { get => base.Text; set => base.Text = Title = value; } + + /// + /// Binds the appropriate arrow keys to their directional based on + /// the current . + /// + private void SetupKeyBindings () + { + KeyBindings.Remove (Key.CursorUp); + KeyBindings.Remove (Key.CursorDown); + KeyBindings.Remove (Key.CursorLeft); + KeyBindings.Remove (Key.CursorRight); + + if (Orientation == Orientation.Horizontal) + { + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Add (Key.CursorRight, Command.Right); + } + else + { + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); + } + } + + #region IOrientation members + + /// + public Orientation Orientation { get => _orientationHelper.Orientation; set => _orientationHelper.Orientation = value; } + +#pragma warning disable CS0067 // The event is never used + /// + public event EventHandler>? OrientationChanging; + + /// + public event EventHandler>? OrientationChanged; +#pragma warning restore CS0067 // The event is never used + + /// + public void OnOrientationChanged (Orientation newOrientation) + { + // Update TextFormatter direction to match the new orientation + TextFormatter.Direction = newOrientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom; + + // Ensure GetAutoWidth/GetAutoHeight will recalculate based on the new orientation. + TextFormatter.ConstrainToSize = null; + + SetupKeyBindings (); + } + + #endregion + + #region ITitleView members + + /// + public Side TabSide + { + get; + set + { + field = value; + MeasuredTabLength = 0; + ApplyThickness (); + } + } + + private int _tabDepth = 3; + + /// + public int TabDepth + { + get => _tabDepth; + set + { + _tabDepth = value; + ApplyThickness (); + } + } + + /// + /// Recomputes and applies thickness from + /// and . + /// + private void ApplyThickness () => Border.Thickness = ComputeTitleViewThickness (TabSide, TabDepth, true); + + /// + /// + public int MeasuredTabLength { get; set; } + + /// + public void UpdateLayout (in TabLayoutContext context) + { + if (context.BorderBounds is not { Width: > 0, Height: > 0 }) + { + Visible = false; + + return; + } + + int tabDepth = TabDepth; + bool hasFocus = context.HasFocus; + + // 1. Set text, style, and orientation FIRST so auto-sizing measures correctly. + Text = context.Title; + + if (context.LineStyle is { } ls) + { + BorderStyle = ls; + } + + Border.Thickness = ComputeTitleViewThickness (TabSide, tabDepth, hasFocus); + Orientation = TabSide is Side.Left or Side.Right ? Orientation.Vertical : Orientation.Horizontal; + + Thickness padding = hasFocus && tabDepth > 2 + ? TabSide switch + { + Side.Top => new Thickness (0, 0, 0, 1), + Side.Bottom => new Thickness (0, 1, 0, 0), + Side.Right => new Thickness (1, 0, 0, 0), + Side.Left => new Thickness (0, 0, 1, 0), + _ => new Thickness (0) + } + : new Thickness (0); + + Padding.Thickness = padding; + + // 2. Clear stale TextFormatter constraints so GetAutoWidth/GetAutoHeight + // recalculate fresh (orientation may have changed since last layout). + TextFormatter.ConstrainToSize = null; + + // 3. Measure the natural tab header size via Dim.Auto. + // Use the explicit override if provided, otherwise auto-size. + int tabLength = context.TabLengthOverride ?? (TabSide is Side.Top or Side.Bottom ? GetAutoWidth () : GetAutoHeight ()); + + MeasuredTabLength = tabLength; + + Rectangle headerRect = ComputeHeaderRect (context.BorderBounds, TabSide, context.TabOffset, tabLength, tabDepth); + Rectangle viewBounds = ComputeViewBounds (context.BorderBounds, TabSide, tabDepth); + Rectangle clipped = Rectangle.Intersect (headerRect, viewBounds); + + if (clipped.IsEmpty) + { + Visible = false; + + return; + } + + Visible = true; + + // 4. Position the frame using the computed header rect, converted to BorderView viewport coords. + Frame = headerRect with { X = headerRect.X - context.ScreenOrigin.X, Y = headerRect.Y - context.ScreenOrigin.Y }; + } + + #endregion + + #region Static geometry helpers + + /// + /// Computes the unclipped header rectangle for the given side, offset, length, and depth. In content coordinates. + /// + internal 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. + /// + internal 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 TitleView'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. + /// + /// + internal static Thickness ComputeTitleViewThickness (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 + }; + } + + #endregion + + #region IDesignable + + /// + public bool EnableForDesign () + { + Text = "_Title"; + + return true; + } + + #endregion +} diff --git a/Terminal.Gui/ViewBase/Layout/DimView.cs b/Terminal.Gui/ViewBase/Layout/DimView.cs index c4ee798e77..66e4ed43df 100644 --- a/Terminal.Gui/ViewBase/Layout/DimView.cs +++ b/Terminal.Gui/ViewBase/Layout/DimView.cs @@ -44,8 +44,8 @@ public override string ToString () internal override int GetAnchor (int size) => Dimension switch { - Dimension.Height => Target!.Frame.Height, - Dimension.Width => Target!.Frame.Width, + Dimension.Height => Target?.Frame.Height ?? 0, + Dimension.Width => Target?.Frame.Width ?? 0, _ => 0 }; diff --git a/Terminal.Gui/ViewBase/Layout/Side.cs b/Terminal.Gui/ViewBase/Layout/Side.cs index a34cb68a9c..759043de75 100644 --- a/Terminal.Gui/ViewBase/Layout/Side.cs +++ b/Terminal.Gui/ViewBase/Layout/Side.cs @@ -1,7 +1,8 @@ namespace Terminal.Gui.ViewBase; /// -/// Indicates the side for operations. +/// Indicates the side of a . Used by and to specify which side +/// of the view to use for layout calculations. /// public enum Side { diff --git a/Terminal.Gui/ViewBase/Mouse/MouseHoldRepeaterImpl.cs b/Terminal.Gui/ViewBase/Mouse/MouseHoldRepeaterImpl.cs index a37370e2d6..fb98647589 100644 --- a/Terminal.Gui/ViewBase/Mouse/MouseHoldRepeaterImpl.cs +++ b/Terminal.Gui/ViewBase/Mouse/MouseHoldRepeaterImpl.cs @@ -153,6 +153,14 @@ private void RaiseMouseIsHeldDownTick () { Mouse currentMouseEventArgs = _mouseEvent ?? new Mouse (); Mouse newMouseEventArgs = _mouseEvent ?? new Mouse (); + + if (_mouseEvent?.View?.Visible is false || _mouseEvent?.View?.Enabled is false) + { + Stop (); + + return; + } + CancelEventArgs args = new (ref currentMouseEventArgs, ref newMouseEventArgs); MouseIsHeldDownTick?.Invoke (this, args); diff --git a/Terminal.Gui/ViewBase/Mouse/View.Mouse.cs b/Terminal.Gui/ViewBase/Mouse/View.Mouse.cs index ff51c68d37..001f694149 100644 --- a/Terminal.Gui/ViewBase/Mouse/View.Mouse.cs +++ b/Terminal.Gui/ViewBase/Mouse/View.Mouse.cs @@ -387,12 +387,16 @@ protected virtual void OnMouseHoldRepeatChanged (ValueChangedEventArgs - /// - /// provides a simple helper for turning a simple border frame on or off. /// /// The adornments (, , and ) are not part of the /// View's content and are not clipped by the View's Clip Area. /// /// - /// Changing the size of an adornment (, , or ) will - /// change the size of which will call to update the layout of the - /// and its . + /// Changing the size of an adornment will change the size of which will call + /// to update the layout of the and its + /// . /// + /// + /// Simple border: + /// + /// view.BorderStyle = LineStyle.Single; + /// // Result: + /// // ┌┤Title├──┐ + /// // │ │ + /// // └─────────┘ + /// + /// Tab-style border: + /// + /// view.BorderStyle = LineStyle.Rounded; + /// view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + /// view.Border.TabSide = Side.Top; + /// view.Border.Thickness = new Thickness (1, 3, 1, 1); + /// // Result (focused): + /// // ╭───╮ + /// // │Tab│ + /// // │ ╰───╮ + /// // │content│ + /// // ╰───────╯ + /// + /// /// public Border Border { get; } = new (); - /// Gets or sets whether the view has a one row/col thick border. + /// + /// Gets or sets the used to draw a one-row/column-thick border around the view. + /// /// /// - /// This is a helper for manipulating the view's . Setting this property to any value other - /// than is equivalent to setting 's - /// to `1` and to the value. + /// This is a convenience helper for manipulating . Setting this property to any value + /// other than sets . + /// to 1 (if currently zero) and . to the value. /// /// - /// Setting this property to is equivalent to setting 's - /// to `0` and to . + /// Setting this property to (or ) sets + /// . to 0. /// /// - /// Raises and raises , which allows change - /// to be cancelled. + /// Raises and . /// - /// For more advanced customization of the view's border, manipulate see directly. + /// + /// For tab-style headers, gradient borders, or per-side thickness, configure directly. + /// + /// + /// + /// // Single-line border: ┌┤Title├──┐ + /// view.BorderStyle = LineStyle.Single; + /// + /// // Rounded border: ╭┤Title├──╮ + /// view.BorderStyle = LineStyle.Rounded; + /// + /// // Double-line border: ╔═Title═══╗ + /// view.BorderStyle = LineStyle.Double; + /// + /// // Remove border: + /// view.BorderStyle = LineStyle.None; + /// + /// /// public LineStyle? BorderStyle { diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs index 2df0b4db07..cd89419c87 100644 --- a/Terminal.Gui/ViewBase/View.Command.cs +++ b/Terminal.Gui/ViewBase/View.Command.cs @@ -313,7 +313,12 @@ private void SetupCommands () #region Accept - internal bool? DefaultAcceptHandler (ICommandContext? ctx) + /// + /// Called when the user is accepting the state of the View and has been invoked. + /// + /// The command context. + /// if the command was handled; otherwise, . + public bool? DefaultAcceptHandler (ICommandContext? ctx) { Trace.Command (this, ctx, "Entry"); @@ -545,7 +550,12 @@ protected virtual void OnAccepted (ICommandContext? ctx) { } #region Activate - internal bool? DefaultActivateHandler (ICommandContext? ctx) + /// + /// Called when the user is activating the View and has been invoked. + /// + /// The command context. + /// if the command was handled; otherwise, . + public bool? DefaultActivateHandler (ICommandContext? ctx) { Trace.Command (this, ctx, "Entry"); @@ -675,17 +685,12 @@ private bool CommandWillBubbleToAncestor (Command command) return true; } - if (SuperView is PaddingView padding && padding.Adornment?.Parent?.CommandsToBubbleUp.Contains (command) == true) - { - return true; - } - - if (this is PaddingView selfPadding && selfPadding.Adornment?.Parent?.CommandsToBubbleUp.Contains (command) == true) + if (SuperView is AdornmentView adornment && adornment.Adornment?.Parent?.CommandsToBubbleUp.Contains (command) == true) { return true; } - return false; + return this is AdornmentView selfAdornment && selfAdornment.Adornment?.Parent?.CommandsToBubbleUp.Contains (command) == true; } /// @@ -734,19 +739,19 @@ private void BubbleActivatedUp (ICommandContext? ctx, bool compositeOnly = false return; - // Resolves the next ancestor, handling Padding → Parent traversal (mirrors TryBubbleUp). + // Resolves the next ancestor, handling AdornmentView → Parent traversal (mirrors TryBubbleUp). static View? GetBubbleAncestor (View current) { View? next = current.SuperView; - if (next is PaddingView padding) + if (next is AdornmentView adornment) { - return padding.Adornment?.Parent; + return adornment.Adornment?.Parent; } - if (current is PaddingView selfPadding) + if (current is AdornmentView selfAdornment) { - return selfPadding.Adornment?.Parent; + return selfAdornment.Adornment?.Parent; } return next; @@ -756,7 +761,7 @@ private void BubbleActivatedUp (ICommandContext? ctx, bool compositeOnly = false /// /// Called when the user has performed an action (e.g. ) causing the View to change state /// or preparing it for interaction. - /// Calls which can be cancelled; if not cancelled raises . + /// Calls which can be cancelled; if not cancelled raises /// event. The default handler calls this method. /// /// @@ -914,7 +919,12 @@ protected virtual void OnActivated (ICommandContext? ctx) { } #region HotKey - internal bool? DefaultHotKeyHandler (ICommandContext? ctx) + /// + /// Called when the user has pressed the View's and has been invoked. + /// + /// The command context. + /// if the command was handled; otherwise, . + public bool? DefaultHotKeyHandler (ICommandContext? ctx) { Trace.Command (this, ctx, "Entry"); @@ -1197,15 +1207,22 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx) public View? DefaultAcceptView { get => field ?? GetSubViews (includePadding: true).FirstOrDefault (v => v is IAcceptTarget { IsDefault: true }); set; } /// - /// Gets or sets the list of commands that should bubble up to this View from unhandled SubViews. + /// Gets or sets the list of commands that should bubble up to this View from unhandled SubViews + /// or from SubViews within this View's adornments (Padding, Border). /// When a SubView raises a command that is not handled, and the command is in the SuperView's /// list, the command will be invoked on the SuperView. /// /// - /// e.g. to enable bubbling for hierarchical views: - /// - /// menuBar.CommandsToBubbleUp = [Command.Activate]; - /// + /// + /// For SubViews inside an (e.g., a button in Padding or Border), + /// the bubble target is rather than . + /// + /// + /// e.g. to enable bubbling for hierarchical views: + /// + /// menuBar.CommandsToBubbleUp = [Command.Activate]; + /// + /// /// public IReadOnlyList CommandsToBubbleUp { get; set; } = []; @@ -1230,7 +1247,8 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx) } /// - /// Bubbles a command to the SuperView if the command is in SuperView's list. + /// Bubbles a command to the SuperView (or to for adornment SubViews) + /// if the target's list contains the command. /// Handles the special case of invoking on a peer IsDefault button. /// /// @@ -1315,25 +1333,37 @@ private static bool IsSourceWithinView (View target, ICommandContext? ctx) return SuperView.InvokeCommand (refreshed.Command, upCtx); } - if (SuperView is PaddingView padding && padding.Adornment?.Parent?.CommandsToBubbleUp.Contains (ctx.Command) == true) + // SubView of an AdornmentView: bubble to the adornment's Parent (the owning View) + if (SuperView is AdornmentView adornment && adornment.Adornment?.Parent?.CommandsToBubbleUp.Contains (ctx.Command) == true) { - // Check if Padding's Parent wants this command bubbled up to it - Trace.Command (this, ctx, "Routing", $"BubblingUp to Padding.Parent {padding.Adornment.Parent.ToIdentifyingString ()}"); - upCtx = new CommandContext (ctx.Command, ctx.Source, ctx.Binding) { Routing = CommandRouting.BubblingUp, Values = ctx.Values }; + ICommandContext? refreshed = RefreshValue (ctx); + + Trace.Command (this, refreshed, "Routing", $"BubblingUp to Adornment.Parent {adornment.Adornment.Parent.ToIdentifyingString ()}"); + + upCtx = new CommandContext (refreshed!.Command, refreshed.Source, refreshed.Binding) + { + Routing = CommandRouting.BubblingUp, Values = refreshed.Values + }; - return padding.Adornment.Parent.InvokeCommand (ctx.Command, upCtx); + return adornment.Adornment.Parent.InvokeCommand (refreshed.Command, upCtx); } - if (this is not PaddingView selfPadding || selfPadding.Adornment?.Parent?.CommandsToBubbleUp.Contains (ctx.Command) != true) + // THIS view is an AdornmentView: bubble to its own Parent + if (this is not AdornmentView selfAdornment || selfAdornment.Adornment?.Parent?.CommandsToBubbleUp.Contains (ctx.Command) != true) { return handled; } - // Handle when THIS view is a Padding - Trace.Command (this, ctx, "Routing", $"BubblingUp from Padding to {selfPadding.Adornment.Parent.ToIdentifyingString ()}"); - upCtx = new CommandContext (ctx.Command, ctx.Source, ctx.Binding) { Routing = CommandRouting.BubblingUp, Values = ctx.Values }; + ICommandContext? selfRefreshed = RefreshValue (ctx); + + Trace.Command (this, selfRefreshed, "Routing", $"BubblingUp from Adornment to {selfAdornment.Adornment.Parent.ToIdentifyingString ()}"); + + upCtx = new CommandContext (selfRefreshed!.Command, selfRefreshed.Source, selfRefreshed.Binding) + { + Routing = CommandRouting.BubblingUp, Values = selfRefreshed.Values + }; - return selfPadding.Adornment.Parent.InvokeCommand (ctx.Command, upCtx); + return selfAdornment.Adornment.Parent.InvokeCommand (selfRefreshed.Command, upCtx); } #endregion Command Bubbling diff --git a/Terminal.Gui/ViewBase/View.Content.cs b/Terminal.Gui/ViewBase/View.Content.cs index c0d1e4d633..b681f1d258 100644 --- a/Terminal.Gui/ViewBase/View.Content.cs +++ b/Terminal.Gui/ViewBase/View.Content.cs @@ -551,7 +551,7 @@ public Point ScreenToViewport (in Point location) /// Helper to get the X and Y offset of the Viewport from the Frame. This is the sum of the Left and Top properties /// of , and . /// - public Point GetViewportOffsetFromFrame () => Padding is null ? Point.Empty : Padding.Thickness.GetInside (Padding.GetFrame ()).Location; + public Point GetViewportOffsetFromFrame () => Padding.Thickness.GetInside (Padding.GetFrame ()).Location; /// /// Scrolls the view vertically by the specified number of rows. diff --git a/Terminal.Gui/ViewBase/View.Drawing.Adornments.cs b/Terminal.Gui/ViewBase/View.Drawing.Adornments.cs new file mode 100644 index 0000000000..8b209c3efb --- /dev/null +++ b/Terminal.Gui/ViewBase/View.Drawing.Adornments.cs @@ -0,0 +1,240 @@ +namespace Terminal.Gui.ViewBase; + +public partial class View +{ + private void DoDrawAdornmentsSubViews (DrawContext? context) + { + // Only process Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass in the static View.Draw + // via Margin.DrawTransparentMargins. + if (Margin.View is { } marginView && !Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) + { + marginView.SetNeedsDraw (); + + foreach (View subview in marginView.SubViews) + { + subview.SetNeedsDraw (); + } + + // NOTE: We do not support arbitrary SubViews of Margin (only ShadowView) + // NOTE: so we do not call DoDrawSubViews on Margin. + } + + if (Border.View is { SubViews.Count: > 0 } borderView && Border.Thickness != Thickness.Empty) + { + borderView.SetNeedsDraw (); + + // PERFORMANCE: Get the check for DrawIndicator out of this somehow. + foreach (View subview in borderView.SubViews.Where (v => v.Visible || v.Id == "DrawIndicator")) + { + if (subview.Id != "DrawIndicator") + { + subview.SetNeedsDraw (); + } + + // 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. + // In Tab mode, lines from the TitleView are allowed to extend past the border frame + // for correct auto-join at boundary junctions (e.g., ┬ where a tab meets the content 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 + // even when Border is transparent. + foreach (View subview in borderView.SubViews.Where (v => v.Visible && v.Id != "DrawIndicator")) + { + context?.AddDrawnRectangle (subview.FrameToScreen ()); + } + } + + if (Padding.View is not { SubViews.Count: > 0 } paddingView || Padding.Thickness == Thickness.Empty) + { + return; + } + + paddingView.SetNeedsDraw (); + + foreach (View subview in paddingView.SubViews) + { + subview.SetNeedsDraw (); + } + + Region? savedPadding = paddingView.AddFrameToClip (); + paddingView.DoDrawSubViews (); + + // Merge any LineCanvas lines from Padding's SubViews (e.g., Tabs tab headers) + // into this View's LineCanvas. This ensures auto-join works between adornment subview + // borders and the view's own border. + // Clip to the padding view's frame to prevent SubView lines from bleeding. + if (paddingView.LineCanvas.Bounds != Rectangle.Empty) + { + Rectangle paddingClip = paddingView.FrameToScreen (); + LineCanvas.Merge (paddingView.LineCanvas, paddingClip); + paddingView.LineCanvas.Clear (); + } + + SetClip (savedPadding); + + // Track drawn subview areas for Padding transparency support. + foreach (View subview in paddingView.SubViews.Where (v => v.Visible)) + { + context?.AddDrawnRectangle (subview.FrameToScreen ()); + } + } + + internal void DoDrawAdornments (Region? originalClip) + { + if (this is AdornmentView) + { + AddFrameToClip (); + + return; + } + + // Set the clip to be just the thicknesses of the adornments + // TODO: Put this union logic in a method on View? + Region clipAdornments = Margin.Thickness.AsRegion (Margin.FrameToScreen ()); + clipAdornments.Combine (Border.Thickness.AsRegion (Border.FrameToScreen ()), RegionOp.Union); + clipAdornments.Combine (Padding.Thickness.AsRegion (Padding.FrameToScreen ()), RegionOp.Union); + clipAdornments.Combine (originalClip, RegionOp.Intersect); + SetClip (clipAdornments); + + if (Margin.View is { NeedsLayout: true } marginView) + { + marginView.NeedsLayout = false; + + if (Driver is { }) + { + Margin.Thickness.Draw (Driver, FrameToScreen ()); + } + + SetSubViewNeedsDrawDownHierarchy (); + } + + // When parent is drawing, always ensure adornment Views are marked for redraw. + Border.View?.SetNeedsDraw (); + Padding.View?.SetNeedsDraw (); + Margin.View?.SetNeedsDraw (); + + // Ensure NeedsDraw is true for the rest of the draw pipeline (DoClearViewport, DoDrawText, etc.) + // When adornment Views are null (lightweight), their NeedsDraw doesn't contribute to the parent's + // NeedsDraw property. But if we're here, the parent IS drawing, so we must set NeedsDrawRect. + if (NeedsDrawRect == Rectangle.Empty) + { + NeedsDrawRect = Viewport; + } + + if (OnDrawingAdornments ()) + { + return; + } + + // TODO: add event. + + DrawAdornments (); + } + + /// + /// Causes , , and to be drawn. + /// + /// + /// + /// is drawn in a separate pass if is set. + /// + /// + public void DrawAdornments () + { + // Only draw Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass + // in the static View.Draw via MarginView.DrawMargins (designed for shadow compositing). + // Non-shadow transparent margin rendering is not yet supported in the first pass. + if (!Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) + { + if (Margin.View is { } marginView) + { + DrawContext marginContext = new (); + marginView.Draw (marginContext); + Margin.LastDrawnRegion = marginContext.GetDrawnRegion ().Clone (); + } + else if (Margin.Thickness != Thickness.Empty) + { + Margin.Thickness.Draw (Driver, Margin.FrameToScreen (), Margin.Diagnostics); + Margin.LastDrawnRegion = null; + } + } + else + { + Margin.LastDrawnRegion = null; + } + + // Each of these renders lines to this View's LineCanvas + // Those lines will be finally rendered in OnRenderLineCanvas + if (Border.Thickness != Thickness.Empty) + { + if (Border.View is { } borderView) + { + DrawContext borderContext = new (); + borderView.Draw (borderContext); + Border.LastDrawnRegion = borderContext.GetDrawnRegion ().Clone (); + } + else if (Border.Thickness != Thickness.Empty) + { + Border.Thickness.Draw (Driver, Border.FrameToScreen (), Border.Diagnostics); + Border.LastDrawnRegion = null; + } + } + else + { + Border.LastDrawnRegion = null; + } + + if (Padding.Thickness != Thickness.Empty) + { + if (Padding.View is { } paddingView) + { + DrawContext paddingContext = new (); + paddingView.Draw (paddingContext); + Padding.LastDrawnRegion = paddingContext.GetDrawnRegion ().Clone (); + } + else if (Padding.Thickness != Thickness.Empty) + { + Padding.Thickness.Draw (Driver, Padding.FrameToScreen (), Padding.Diagnostics); + Padding.LastDrawnRegion = null; + } + } + else + { + Padding.LastDrawnRegion = null; + } + + if (Margin.Thickness != Thickness.Empty /* && Margin.ShadowStyle == ShadowStyle.None*/) + { + //Margin.Draw (); + } + } + + /// + /// Called when the View's Adornments are to be drawn. Prepares . If + /// is true, only the + /// of this view's SubViews will be rendered. If is + /// false (the default), this method will cause the be prepared to be rendered. + /// + /// to stop further drawing of the Adornments. + protected virtual bool OnDrawingAdornments () => false; +} diff --git a/Terminal.Gui/ViewBase/View.Drawing.LineCanvas.cs b/Terminal.Gui/ViewBase/View.Drawing.LineCanvas.cs new file mode 100644 index 0000000000..0b830af8c6 --- /dev/null +++ b/Terminal.Gui/ViewBase/View.Drawing.LineCanvas.cs @@ -0,0 +1,195 @@ +namespace Terminal.Gui.ViewBase; + +public partial class View +{ + /// The canvas that any line drawing that is to be shared by SubViews of this view should add lines to. + /// adds lines to this LineCanvas. + public LineCanvas LineCanvas { get; } = new (); + + /// + /// Gets or sets whether this View will use its SuperView's for rendering any + /// lines. If the rendering of any borders drawn by this view will be done by its + /// SuperView. If (the default) this View's method will + /// be called to render the borders. + /// + public virtual bool SuperViewRendersLineCanvas { get; set; } = false; + + /// + /// Called when the is to be rendered. See . + /// + /// to stop further drawing of . + protected virtual bool OnRenderingLineCanvas () => false; + + /// + /// Causes the contents of to be drawn. + /// If is true, only the + /// of this view's SubViews will be rendered. If is + /// false (the default), this method will cause the to be rendered. + /// + /// + public void RenderLineCanvas (DrawContext? context) + { + if (Driver is null) + { + return; + } + + bool hasOverlapped = _pendingOverlappedCellMaps is { Count: > 0 }; + + if (SuperViewRendersLineCanvas || (LineCanvas.Bounds == Rectangle.Empty && !hasOverlapped)) + { + return; + } + + // Resolve the parent's own LineCanvas (includes tiled SubViews' merged lines). + (Dictionary cellMap, Region lineRegion) = LineCanvas.GetCellMapWithRegion (); + + // Render the parent's resolved cell map (base layer). + foreach (KeyValuePair p in cellMap) + { + if (p.Value is null) + { + continue; + } + + SetAttribute (p.Value.Value.Attribute ?? GetAttributeForRole (VisualRole.Normal)); + Driver.Move (p.Key.X, p.Key.Y); + + // TODO: #2616 - Support combining sequences that don't normalize + AddStr (p.Value.Value.Grapheme); + } + + // Composite overlapped SubViews' cell maps via painters' algorithm. + // The list is ordered highest-Z first. We iterate from index 0 (highest Z) + // to the end (lowest Z). A higher-Z LC cell at a given position suppresses + // all lower-Z LC cells at that same position, UNLESS the lower-Z cell is a + // richer junction (more line directions) and the additional directions don't + // point toward reserved (gap) cells of any higher-Z view. + if (hasOverlapped) + { + // Track cells already rendered by higher-Z views and the cell value at each position. + Dictionary renderedCells = new (); + + // Collect all reserved cells from all views for adjacency checks. + HashSet allReserved = []; + + if (_pendingOverlappedCellMaps is { }) + { + for (var i = 0; i < _pendingOverlappedCellMaps.Count; i++) + { + HashSet? reservedCells = _pendingOverlappedCellMaps [i].Reserved; + + if (reservedCells is { Count: > 0 }) + { + allReserved.UnionWith (reservedCells); + } + } + + foreach ((Dictionary overlapCellMap, HashSet? reservedCells) in _pendingOverlappedCellMaps) + { + if (reservedCells is { Count: > 0 }) + { + foreach (Point rp in reservedCells) + { + renderedCells.TryAdd (rp, default (Cell)); + } + } + + foreach (KeyValuePair p in overlapCellMap) + { + if (p.Value is null) + { + continue; + } + + if (renderedCells.TryGetValue (p.Key, out Cell existingCell)) + { + // Position already claimed. Check if this lower-Z cell should upgrade. + if (existingCell.Grapheme is null or "") + { + // Reserved cell — never upgrade. + continue; + } + + LineDirections existingDirs = LineCanvas.GetLineDirections (existingCell.Grapheme); + LineDirections newDirs = LineCanvas.GetLineDirections (p.Value.Value.Grapheme); + + // Lower-Z cell must be a strict superset of the higher-Z cell's directions: + // it must contain ALL existing directions plus at least one more. + if ((newDirs & existingDirs) != existingDirs) + { + // Not a superset — lower-Z cell removes some directions. Skip. + continue; + } + + LineDirections additionalDirs = newDirs & ~existingDirs; + + if (additionalDirs == LineDirections.None) + { + // Lower-Z cell doesn't add any directions — skip. + continue; + } + + // Check if any additional direction points toward a reserved cell. + var pointsToReserved = false; + + if (additionalDirs.HasFlag (LineDirections.Up) && allReserved.Contains (p.Key with { Y = p.Key.Y - 1 })) + { + pointsToReserved = true; + } + + if (!pointsToReserved && additionalDirs.HasFlag (LineDirections.Down) && allReserved.Contains (p.Key with { Y = p.Key.Y + 1 })) + { + pointsToReserved = true; + } + + if (!pointsToReserved && additionalDirs.HasFlag (LineDirections.Left) && allReserved.Contains (p.Key with { X = p.Key.X - 1 })) + { + pointsToReserved = true; + } + + if (!pointsToReserved && additionalDirs.HasFlag (LineDirections.Right) && allReserved.Contains (p.Key with { X = p.Key.X + 1 })) + { + pointsToReserved = true; + } + + if (pointsToReserved) + { + // Additional direction points into a gap — keep higher-Z cell. + continue; + } + + // Upgrade to the richer junction from the lower-Z view. + renderedCells [p.Key] = p.Value.Value; + SetAttribute (p.Value.Value.Attribute ?? GetAttributeForRole (VisualRole.Normal)); + Driver.Move (p.Key.X, p.Key.Y); + AddStr (p.Value.Value.Grapheme); + + continue; + } + + SetAttribute (p.Value.Value.Attribute ?? GetAttributeForRole (VisualRole.Normal)); + Driver.Move (p.Key.X, p.Key.Y); + AddStr (p.Value.Value.Grapheme); + + renderedCells [p.Key] = p.Value.Value; + lineRegion.Union (new Rectangle (p.Key.X, p.Key.Y, 1, 1)); + } + } + } + + _pendingOverlappedCellMaps = null; + } + + // Report the drawn region for transparency support. + if (context is { } && (cellMap.Count > 0 || hasOverlapped)) + { + context.AddDrawnRegion (lineRegion); + } + + // Cache the line canvas region for use by Border's CachedDrawnRegion. + _lastLineCanvasRegion = cellMap.Count > 0 || hasOverlapped ? lineRegion : null; + + LineCanvas.Clear (); + } +} \ No newline at end of file diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index 409adc0f1a..80a6f48d6d 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -133,6 +133,15 @@ public void Draw (DrawContext? context = null) DoDrawSubViews (context); } + // Add the ClearViewport rect to the shared context AFTER SubViews have drawn. + // This ensures SubViews' DoDrawComplete doesn't see the SuperView's cleared area + // in the context (which would over-exclude for overlapping views like Tabs). + if (_lastClearedViewport is { } clearedRect) + { + context.AddDrawnRectangle (clearedRect); + _lastClearedViewport = null; + } + // ------------------------------------ // Draw the text — tracked in both shared (clip exclusion) and local (hit-testing) contexts Trace.Draw (this.ToIdentifyingString (), "Text"); @@ -149,18 +158,18 @@ public void Draw (DrawContext? context = null) context.AddDrawnRegion (_localDrawContext.GetDrawnRegion ()); // ------------------------------------ - // 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 @@ -189,215 +198,8 @@ public void Draw (DrawContext? context = null) // a clip with "holes" where this view (and any SubViews drawn before it) are located. } - #region DrawAdornments - - private void DoDrawAdornmentsSubViews (DrawContext? context) - { - // Only process Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass in the static View.Draw - // via Margin.DrawTransparentMargins. - if (Margin.View is { } marginView && !Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) - { - marginView.SetNeedsDraw (); - - foreach (View subview in marginView.SubViews) - { - subview.SetNeedsDraw (); - } - - // NOTE: We do not support arbitrary SubViews of Margin (only ShadowView) - // NOTE: so we do not call DoDrawSubViews on Margin. - } - - if (Border.View is { SubViews.Count: > 0 } borderView && Border.Thickness != Thickness.Empty) - { - borderView.SetNeedsDraw (); - - // PERFORMANCE: Get the check for DrawIndicator out of this somehow. - foreach (View subview in borderView.SubViews.Where (v => v.Visible || v.Id == "DrawIndicator")) - { - if (subview.Id != "DrawIndicator") - { - subview.SetNeedsDraw (); - } - - LineCanvas.Exclude (new Region (subview.FrameToScreen ())); - } - - Region? saved = borderView.AddFrameToClip (); - borderView.DoDrawSubViews (); - SetClip (saved); - - // Track drawn subview areas so DoDrawComplete can exclude them from clip - // even when Border is transparent. - foreach (View subview in borderView.SubViews.Where (v => v.Visible && v.Id != "DrawIndicator")) - { - context?.AddDrawnRectangle (subview.FrameToScreen ()); - } - } - - if (Padding.View is not { SubViews.Count: > 0 } paddingView || Padding.Thickness == Thickness.Empty) - { - return; - } - - paddingView.SetNeedsDraw (); - - foreach (View subview in paddingView.SubViews) - { - subview.SetNeedsDraw (); - } - - Region? savedPadding = paddingView.AddFrameToClip (); - paddingView.DoDrawSubViews (); - SetClip (savedPadding); - - // Track drawn subview areas for Padding transparency support. - foreach (View subview in paddingView.SubViews.Where (v => v.Visible)) - { - context?.AddDrawnRectangle (subview.FrameToScreen ()); - } - } - - internal void DoDrawAdornments (Region? originalClip) - { - if (this is AdornmentView) - { - AddFrameToClip (); - - return; - } - - // Set the clip to be just the thicknesses of the adornments - // TODO: Put this union logic in a method on View? - Region clipAdornments = Margin.Thickness.AsRegion (Margin.FrameToScreen ()); - clipAdornments.Combine (Border.Thickness.AsRegion (Border.FrameToScreen ()), RegionOp.Union); - clipAdornments.Combine (Padding.Thickness.AsRegion (Padding.FrameToScreen ()), RegionOp.Union); - clipAdornments.Combine (originalClip, RegionOp.Intersect); - SetClip (clipAdornments); - - if (Margin.View is { NeedsLayout: true } marginView) - { - marginView.NeedsLayout = false; - - if (Driver is { }) - { - Margin.Thickness.Draw (Driver, FrameToScreen ()); - } - - SetSubViewNeedsDrawDownHierarchy (); - } - - // When parent is drawing, always ensure adornment Views are marked for redraw. - Border.View?.SetNeedsDraw (); - Padding.View?.SetNeedsDraw (); - Margin.View?.SetNeedsDraw (); - - // Ensure NeedsDraw is true for the rest of the draw pipeline (DoClearViewport, DoDrawText, etc.) - // When adornment Views are null (lightweight), their NeedsDraw doesn't contribute to the parent's - // NeedsDraw property. But if we're here, the parent IS drawing, so we must set NeedsDrawRect. - if (NeedsDrawRect == Rectangle.Empty) - { - NeedsDrawRect = Viewport; - } - - if (OnDrawingAdornments ()) - { - return; - } - - // TODO: add event. - - DrawAdornments (); - } - - /// - /// Causes , , and to be drawn. - /// - /// - /// - /// is drawn in a separate pass if is set. - /// - /// - public void DrawAdornments () - { - // Only draw Margin here if it is not Transparent. Transparent Margins are drawn in a separate pass - // in the static View.Draw via MarginView.DrawMargins (designed for shadow compositing). - // Non-shadow transparent margin rendering is not yet supported in the first pass. - if (!Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && Margin.Thickness != Thickness.Empty) - { - if (Margin.View is { } marginView) - { - DrawContext marginContext = new (); - marginView.Draw (marginContext); - Margin.LastDrawnRegion = marginContext.GetDrawnRegion ().Clone (); - } - else if (Margin.Thickness != Thickness.Empty) - { - Margin.Thickness.Draw (Driver, Margin.FrameToScreen (), Margin.Diagnostics); - Margin.LastDrawnRegion = null; - } - } - else - { - Margin.LastDrawnRegion = null; - } - - // Each of these renders lines to this View's LineCanvas - // Those lines will be finally rendered in OnRenderLineCanvas - if (Border.Thickness != Thickness.Empty) - { - if (Border.View is { } borderView) - { - DrawContext borderContext = new (); - borderView.Draw (borderContext); - Border.LastDrawnRegion = borderContext.GetDrawnRegion ().Clone (); - } - else if (Border.Thickness != Thickness.Empty) - { - Border.Thickness.Draw (Driver, Border.FrameToScreen (), Border.Diagnostics); - Border.LastDrawnRegion = null; - } - } - else - { - Border.LastDrawnRegion = null; - } - - if (Padding.Thickness != Thickness.Empty) - { - if (Padding.View is { } paddingView) - { - DrawContext paddingContext = new (); - paddingView.Draw (paddingContext); - Padding.LastDrawnRegion = paddingContext.GetDrawnRegion ().Clone (); - } - else if (Padding.Thickness != Thickness.Empty) - { - Padding.Thickness.Draw (Driver, Padding.FrameToScreen (), Padding.Diagnostics); - Padding.LastDrawnRegion = null; - } - } - else - { - Padding.LastDrawnRegion = null; - } - - if (Margin.Thickness != Thickness.Empty /* && Margin.ShadowStyle == ShadowStyle.None*/) - { - //Margin.Draw (); - } - } - - /// - /// Called when the View's Adornments are to be drawn. Prepares . If - /// is true, only the - /// of this view's SubViews will be rendered. If is - /// false (the default), this method will cause the be prepared to be rendered. - /// - /// to stop further drawing of the Adornments. - protected virtual bool OnDrawingAdornments () => false; - - #endregion DrawAdornments + // DrawAdornments region (DoDrawAdornmentsSubViews, DoDrawAdornments, DrawAdornments, + // OnDrawingAdornments) is in View.Drawing.Adornments.cs. #region ClearViewport @@ -478,11 +280,23 @@ public void ClearViewport (DrawContext? context = null) Driver.FillRect (toClear); - context?.AddDrawnRectangle (toClear); + // NOTE: ClearViewport does NOT add to the context here. The cleared viewport rect is + // added to the shared context in Draw() AFTER DoDrawSubViews completes. This prevents + // the SuperView's ClearViewport from leaking into SubViews' DoDrawComplete exclusion + // calculations — which would cause overlapping SubViews (e.g., Tab views that all use + // Dim.Fill) to exclude the entire frame and prevent peer SubViews from drawing. + _lastClearedViewport = toClear; SetNeedsDraw (); } + /// + /// Stores the last viewport rectangle cleared by . Added to the shared + /// in after SubViews have drawn, so that + /// SubViews' doesn't see the SuperView's cleared area in the context. + /// + private Rectangle? _lastClearedViewport; + #endregion ClearViewport #region DrawText @@ -550,6 +364,7 @@ private void DoDrawText (DrawContext? context = null) /// The draw context to report drawn areas to. public void DrawText (DrawContext? context = null) { + // BUGBUG: This excludes the draw region even if there's no text var drawRect = new Rectangle (ContentToScreen (Point.Empty), GetContentSize ()); // Use GetDrawRegion to get precise drawn areas @@ -746,25 +561,47 @@ public void DrawSubViews (DrawContext? context = null) return; } + List<(Dictionary CellMap, HashSet? Reserved)>? overlappedCellMaps = null; + // Draw the SubViews in reverse Z-order to leverage clipping. // SubViews earlier in the collection are drawn last (on top). + // NOTE: Do not use SubViews or GetSubViews() here as GetSubViews can be overridden to return a different set of views or ordering. + // NOTE: We need to draw exactly the views in InternalSubViews. 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); + + if (view.Arrangement.HasFlag (ViewArrangement.Overlapped)) + { + // Overlapped views: resolve independently so their lines don't + // auto-join with other Z-levels. Deferred for painters' algorithm + // compositing in RenderLineCanvas. + Dictionary? resolvedCells = null; + + if (view.LineCanvas.Bounds != Rectangle.Empty) + { + resolvedCells = view.LineCanvas.GetCellMap (); + } + + overlappedCellMaps ??= []; + overlappedCellMaps.Add ((resolvedCells ?? [], view.LineCanvas.GetReservedCells ())); + } + else + { + // Tiled views: flat merge preserves cross-view auto-join. + LineCanvas.Merge (view.LineCanvas); + } view.LineCanvas.Clear (); } + + // Store for compositing during RenderLineCanvas. + // List is ordered highest-Z first (matching the iteration order above). + _pendingOverlappedCellMaps = overlappedCellMaps; } #endregion DrawSubViews @@ -784,73 +621,6 @@ private void DoRenderLineCanvas (DrawContext? context) RenderLineCanvas (context); } - /// - /// Called when the is to be rendered. See . - /// - /// to stop further drawing of . - protected virtual bool OnRenderingLineCanvas () => false; - - /// The canvas that any line drawing that is to be shared by subviews of this view should add lines to. - /// adds lines to this LineCanvas. - public LineCanvas LineCanvas { get; } = new (); - - /// - /// Gets or sets whether this View will use its SuperView's for rendering any - /// lines. If the rendering of any borders drawn by this view will be done by its - /// SuperView. If (the default) this View's method will - /// be called to render the borders. - /// - public virtual bool SuperViewRendersLineCanvas { get; set; } = false; - - /// - /// Causes the contents of to be drawn. - /// If is true, only the - /// of this view's SubViews will be rendered. If is - /// false (the default), this method will cause the to be rendered. - /// - /// - public void RenderLineCanvas (DrawContext? context) - { - if (Driver is null) - { - return; - } - - if (SuperViewRendersLineCanvas || LineCanvas.Bounds == Rectangle.Empty) - { - return; - } - - // Get both cell map and Region in a single pass through the canvas - (Dictionary cellMap, Region lineRegion) = LineCanvas.GetCellMapWithRegion (); - - foreach (KeyValuePair p in cellMap) - { - // Get the entire map - if (p.Value is null) - { - continue; - } - SetAttribute (p.Value.Value.Attribute ?? GetAttributeForRole (VisualRole.Normal)); - Driver.Move (p.Key.X, p.Key.Y); - - // TODO: #2616 - Support combining sequences that don't normalize - AddStr (p.Value.Value.Grapheme); - } - - // Report the drawn region for transparency support - // Region was built during the GetCellMapWithRegion() call above - if (context is { } && cellMap.Count > 0) - { - context.AddDrawnRegion (lineRegion); - } - - // Cache the line canvas region for use by Border's CachedDrawnRegion. - _lastLineCanvasRegion = cellMap.Count > 0 ? lineRegion : null; - - LineCanvas.Clear (); - } - #endregion DrawLineCanvas #region DrawComplete @@ -870,6 +640,16 @@ public void RenderLineCanvas (DrawContext? context) /// private Region? _lastLineCanvasRegion; + /// + /// Cell maps from overlapped SubViews' independently-resolved es, + /// collected during . Composited via painters' algorithm + /// in : highest-Z cells take priority over lower-Z cells + /// at the same screen position. Each entry also includes reserved cells (intentional gaps) + /// that suppress lower-Z cells without rendering anything. + /// Ordered highest-Z first (matching DrawSubViews iteration order). + /// + private List<(Dictionary CellMap, HashSet? Reserved)>? _pendingOverlappedCellMaps; + /// /// Per-view that tracks only what THIS view drew (text + content), /// isolated from the shared context. Used to compute for @@ -904,9 +684,9 @@ private void DoDrawComplete (DrawContext? context) // Each adornment's LastDrawnRegion was populated during DrawAdornments() using per-adornment // DrawContexts. We combine with _lastLineCanvasRegion (rendered by the parent) for Border. // All three adornment types are handled uniformly. - CacheAdornmentDrawnRegion (Border, _lastLineCanvasRegion); - CacheAdornmentDrawnRegion (Margin, null); - CacheAdornmentDrawnRegion (Padding, null); + Border.UpdateCachedDrawnRegion (_lastLineCanvasRegion); + Margin.UpdateCachedDrawnRegion (null); + Padding.UpdateCachedDrawnRegion (null); bool marginTransparent = Margin.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent); bool borderTransparent = Border.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent); @@ -943,7 +723,7 @@ private void DoDrawComplete (DrawContext? context) } else { - AddDrawnRegionForAdornment (Margin, null); + Margin.AddDrawnRegionTo (exclusion, null); } if (!borderTransparent) @@ -952,7 +732,7 @@ private void DoDrawComplete (DrawContext? context) } else { - AddDrawnRegionForAdornment (Border, _lastLineCanvasRegion); + Border.AddDrawnRegionTo (exclusion, _lastLineCanvasRegion); } if (!paddingTransparent) @@ -961,7 +741,7 @@ private void DoDrawComplete (DrawContext? context) } else { - AddDrawnRegionForAdornment (Padding, null); + Padding.AddDrawnRegionTo (exclusion, null); } if (!viewTransparent) @@ -969,12 +749,6 @@ private void DoDrawComplete (DrawContext? context) 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. @@ -1006,58 +780,6 @@ private void DoDrawComplete (DrawContext? context) // Report the exclusion to the parent's DrawContext so SuperViews can track what we covered. context?.AddDrawnRegion (exclusion); - - return; - - void AddDrawnRegionForAdornment (AdornmentImpl adornment, Region? lastLineCanvasRegion) - { - if (adornment.LastDrawnRegion is { }) - { - Region clipped = adornment.LastDrawnRegion.Clone (); - clipped.Intersect (adornment.FrameToScreen ()); - exclusion.Combine (clipped, RegionOp.Union); - } - - // The parent's LineCanvas includes border lines rendered in DoRenderLineCanvas. - if (lastLineCanvasRegion is null) - { - return; - } - Region lineRegion = lastLineCanvasRegion.Clone (); - lineRegion.Intersect (adornment.FrameToScreen ()); - exclusion.Combine (lineRegion, RegionOp.Union); - } - - void CacheAdornmentDrawnRegion (AdornmentImpl adornment, Region? lastLineCanvasRegion) - { - if (!adornment.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse)) - { - return; - } - - Region adornmentDrawnRegion = new (); - - if (adornment.LastDrawnRegion is { }) - { - adornmentDrawnRegion.Combine (adornment.LastDrawnRegion, RegionOp.Union); - } - - // The parent's LineCanvas includes border lines rendered in DoRenderLineCanvas. - // Intersect with this adornment's frame to get only the lines within it. - if (lastLineCanvasRegion is { }) - { - Region lineRegion = lastLineCanvasRegion.Clone (); - lineRegion.Intersect (adornment.FrameToScreen ()); - adornmentDrawnRegion.Combine (lineRegion, RegionOp.Union); - } - - adornment.CachedDrawnRegion = adornmentDrawnRegion; - - if (adornment.View is { } adornmentView) - { - adornmentView.CachedDrawnRegion = adornmentDrawnRegion; - } - } } /// diff --git a/Terminal.Gui/ViewBase/View.Hierarchy.cs b/Terminal.Gui/ViewBase/View.Hierarchy.cs index 094a410d93..e925ade838 100644 --- a/Terminal.Gui/ViewBase/View.Hierarchy.cs +++ b/Terminal.Gui/ViewBase/View.Hierarchy.cs @@ -8,19 +8,24 @@ public partial class View // SuperView/SubView hierarchy management (SuperView, private readonly List? _subviews = []; - // Internally, we use InternalSubViews rather than subviews, as we do not expect us - // to make the same mistakes our users make when they poke at the SubViews. + /// + /// INTERNAL: Gets the list of SubViews precisely as they were added/ordered. + /// internal IList InternalSubViews => _subviews ?? []; - /// Gets the list of SubViews. + /// Gets the list of SubViews as they were added/ordered. /// /// Use and to add or remove subviews. /// + /// public IReadOnlyCollection SubViews => InternalSubViews?.AsReadOnly () ?? _empty; /// /// Gets all SubViews of this View, optionally including SubViews of the View's Adornments - /// (Margin, Border, and Padding). + /// (Margin, Border, and Padding). This method is used for navigation and focus management and can be overridden by + /// subclasses to include additional SubViews as needed + /// or to change the order in which SubViews are returned. By default, this method returns only the direct SubViews of + /// this View. /// /// /// If , includes SubViews from . If (default), @@ -167,7 +172,8 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { /// the lifecycle of the subviews to be transferred to this View. /// /// - /// Calls/Raises the / event. + /// Calls/Raises the / event before adding, which can + /// cancel the addition. Calls/Raises the / event after adding. /// /// /// The / event will be raised on the added View. @@ -178,6 +184,8 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { /// The view that was added. /// /// + /// + /// /// /// /// @@ -206,7 +214,6 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { Logging.Warning ($"{view} has already been Added to {this}."); } - // TODO: Add AddingSubView event if (this is MarginView) { if (view is not ShadowView) @@ -215,6 +222,11 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { } } + if (!RaiseSubViewAdding (view)) + { + return null; + } + // TODO: Make this thread safe InternalSubViews.Insert (index, view); @@ -273,7 +285,8 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { /// the lifecycle of the subviews to be transferred to this View. /// /// - /// Calls/Raises the / event. + /// Calls/Raises the / event before adding, which can + /// cancel the addition. Calls/Raises the / event after adding. /// /// /// The / event will be raised on the added View. @@ -283,6 +296,8 @@ protected virtual void OnSuperViewChanged (ValueChangedEventArgs args) { /// The view that was added. /// /// + /// + /// /// /// /// @@ -316,6 +331,36 @@ public void Add (params View []? views) } } + internal bool RaiseSubViewAdding (View view) + { + EventArgs args = new (view); + + if (OnSubViewAdding (args)) + { + return false; + } + + SubViewAdding?.Invoke (this, args); + + return true; + } + + /// + /// Called before a SubView is added to this View. Return to cancel the addition. + /// + /// Arguments describing the pending addition. is the SubView being added. + /// to cancel the addition; to allow it. + protected virtual bool OnSubViewAdding (EventArgs args) => false; + + /// Raised before a SubView is added to this View. + /// + /// + /// This event follows the Cancellable Work Pattern (CWP). Override and return + /// to cancel the addition. + /// + /// + public event EventHandler>? SubViewAdding; + internal void RaiseSubViewAdded (View view) { // If auto-hotkey assignment is enabled, assign a hotkey to the new subview diff --git a/Terminal.Gui/ViewBase/View.Layout.cs b/Terminal.Gui/ViewBase/View.Layout.cs index 5f9d53cf69..a9c17c7375 100644 --- a/Terminal.Gui/ViewBase/View.Layout.cs +++ b/Terminal.Gui/ViewBase/View.Layout.cs @@ -1077,17 +1077,25 @@ internal static List TopologicalSort (View superView, IEnumerable no #region Utilities /// - /// INTERNAL API - Gets the size of the SuperView's content (nominally the same as + /// Gets the size of the SuperView's content (nominally the same as /// the SuperView's ) or the screen size if there's no SuperView. /// - /// + /// + /// This method provides fallback logic to ensure that a size is always returned, even if the SuperView is not set or + /// not initialized. The order of precedence is: + /// 1. SuperView's content size (if SuperView is set and initialized) + /// 2. TopRunnableView's content size (if TopRunnableView is set, not the current view, and initialized) + /// 3. Application's screen size (if Application is set) + /// 4. Driver's screen size (if Driver is set) + /// 5. Fallback to a default size of 2048x2048 + /// public Size GetContainerSize () { // TODO: Get rid of refs to Top Size superViewContentSize = SuperView?.GetContentSize () ?? (App?.TopRunnableView is { } && App?.TopRunnableView != this && App!.TopRunnableView.IsInitialized ? App.TopRunnableView.GetContentSize () - : App?.Screen.Size ?? new Size (2048, 2048)); + : App?.Screen.Size ?? Driver?.Screen.Size ?? new Size (2048, 2048)); return superViewContentSize; } @@ -1294,8 +1302,7 @@ public Size GetContainerSize () && v.Margin.Thickness != Thickness.Empty && v.Margin.Thickness.Contains (v.Margin.FrameToScreen (), location)))) { - if (v.Margin.CachedDrawnRegion is null - || !v.Margin.CachedDrawnRegion.Contains (location.X, location.Y)) + if (v.Margin.CachedDrawnRegion is null || !v.Margin.CachedDrawnRegion.Contains (location.X, location.Y)) { ret = true; } @@ -1307,8 +1314,7 @@ public Size GetContainerSize () && v.Border.Thickness != Thickness.Empty && v.Border.Thickness.Contains (v.Border.FrameToScreen (), location)))) { - if (v.Border.CachedDrawnRegion is null - || !v.Border.CachedDrawnRegion.Contains (location.X, location.Y)) + if (v.Border.CachedDrawnRegion is null || !v.Border.CachedDrawnRegion.Contains (location.X, location.Y)) { ret = true; } @@ -1320,8 +1326,7 @@ public Size GetContainerSize () && v.Padding.Thickness != Thickness.Empty && v.Padding.Thickness.Contains (v.Padding.FrameToScreen (), location)))) { - if (v.Padding.CachedDrawnRegion is null - || !v.Padding.CachedDrawnRegion.Contains (location.X, location.Y)) + if (v.Padding.CachedDrawnRegion is null || !v.Padding.CachedDrawnRegion.Contains (location.X, location.Y)) { ret = true; } diff --git a/Terminal.Gui/ViewBase/View.Navigation.cs b/Terminal.Gui/ViewBase/View.Navigation.cs index 9710df1c44..15d526c1a0 100644 --- a/Terminal.Gui/ViewBase/View.Navigation.cs +++ b/Terminal.Gui/ViewBase/View.Navigation.cs @@ -336,7 +336,7 @@ public View? Focused { get { - View? focused = GetSubViews (includePadding: true).FirstOrDefault (v => v.HasFocus); + View? focused = GetSubViews ( /*includeBorder: true,*/ includePadding: true).FirstOrDefault (v => v.HasFocus); if (focused is { }) { @@ -925,6 +925,11 @@ private void SetHasFocusFalse (View? newFocusedView, bool traversingDown = false throw new InvalidOperationException ("SetHasFocusFalse and the HasFocus value did not change."); } + //if (SuperView is AdornmentView { HasFocus: true } superAsAdornment) + //{ + // superAsAdornment.SetHasFocusFalse (newFocusedView, true); + //} + SetNeedsDraw (); } @@ -992,19 +997,30 @@ internal View [] GetFocusChain (NavigationDirection direction, TabBehavior? beha if (behavior.HasValue) { - filteredSubViews = GetSubViews (includePadding: true).Where (v => v.TabStop == behavior && v is { CanFocus: true, Visible: true, Enabled: true }); + filteredSubViews = GetSubViews (includeBorder: true, includePadding: true).Where (v => v.TabStop == behavior && v is { CanFocus: true, Visible: true, Enabled: true }); } else { - filteredSubViews = GetSubViews (includePadding: true).Where (v => v is { CanFocus: true, Visible: true, Enabled: true }); + filteredSubViews = GetSubViews (includeBorder: true, includePadding: true).Where (v => v is { CanFocus: true, Visible: true, Enabled: true }); } - if (this is not IAdornmentView - && Padding.View is { CanFocus: true, Visible: true, Enabled: true } - && Padding.View?.TabStop == behavior - && Padding.Thickness != Thickness.Empty) + if (this is not IAdornmentView) { - filteredSubViews = filteredSubViews.Append (Padding.View!); + + if (Padding.View is { CanFocus: true, Visible: true, Enabled: true } && (Padding.View?.TabStop == null || Padding.View?.TabStop == behavior) && Padding.Thickness != Thickness.Empty) + { + if (Padding.View is { }) + { + filteredSubViews = filteredSubViews.Append (Padding.View); + } + } + //else if (Border.View is { CanFocus: true, Visible: true, Enabled: true } && (Border.View?.TabStop == null || Border.View?.TabStop == behavior) && Border.Thickness != Thickness.Empty) + //{ + // if (Border.View is { }) + // { + // filteredSubViews = filteredSubViews.Append (Border.View); + // } + //} } // Border and Margin do not participate in focus chain navigation. @@ -1014,7 +1030,7 @@ internal View [] GetFocusChain (NavigationDirection direction, TabBehavior? beha filteredSubViews = filteredSubViews.Reverse (); } - return filteredSubViews.ToArray () ?? []; + return filteredSubViews.ToArray (); } /// diff --git a/Terminal.Gui/ViewBase/View.NeedsDraw.cs b/Terminal.Gui/ViewBase/View.NeedsDraw.cs index b3e44435a1..fc2e123b5d 100644 --- a/Terminal.Gui/ViewBase/View.NeedsDraw.cs +++ b/Terminal.Gui/ViewBase/View.NeedsDraw.cs @@ -144,8 +144,11 @@ internal void ClearNeedsDraw () SubViewNeedsDraw = false; - // This ensures LineCanvas' get redrawn - if (!SuperViewRendersLineCanvas) + // This ensures LineCanvas' get redrawn. + // AdornmentViews skip this because their LC may hold merged SubView lines + // that haven't been consumed by the parent's DoDrawAdornmentsSubViews yet. + // Those lines are cleared in DoDrawAdornmentsSubViews after merging into the parent's LC. + if (!SuperViewRendersLineCanvas && this is not AdornmentView) { LineCanvas.Clear (); } diff --git a/Terminal.Gui/ViewBase/View.ScrollBars.cs b/Terminal.Gui/ViewBase/View.ScrollBars.cs index 18c674b138..b083f8c95a 100644 --- a/Terminal.Gui/ViewBase/View.ScrollBars.cs +++ b/Terminal.Gui/ViewBase/View.ScrollBars.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Terminal.Gui.ViewBase; public partial class View @@ -20,6 +22,7 @@ public partial class View /// /// /// + [DebuggerBrowsable (DebuggerBrowsableState.Never)] public ScrollBar HorizontalScrollBar => _horizontalScrollBar.Value; private Lazy _verticalScrollBar = null!; @@ -39,8 +42,8 @@ public partial class View /// See the Layout Deep Dive for more information: /// /// - /// /// /// + [DebuggerBrowsable (DebuggerBrowsableState.Never)] public ScrollBar VerticalScrollBar => _verticalScrollBar.Value; /// @@ -53,8 +56,9 @@ private void SetupScrollBars () return; } - _verticalScrollBar = new Lazy (() => CreateScrollBar (Orientation.Vertical)); - _horizontalScrollBar = new Lazy (() => CreateScrollBar (Orientation.Horizontal)); + // Terminal.Gui is single-threaded (UI thread only), so None is appropriate + _verticalScrollBar = new Lazy (() => CreateScrollBar (Orientation.Vertical), LazyThreadSafetyMode.None); + _horizontalScrollBar = new Lazy (() => CreateScrollBar (Orientation.Horizontal), LazyThreadSafetyMode.None); } private ScrollBar CreateScrollBar (Orientation orientation) diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index f169828273..d14926f51e 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -91,10 +91,7 @@ public DateTime Value _date = value; // Propagate value to embedded editor - if (_dateEditor is { }) - { - _dateEditor.Value = value; - } + _dateEditor?.Value = value; ValueChangedEventArgs changedArgs = new (oldValue, _date); OnValueChanged (changedArgs); @@ -104,7 +101,7 @@ public DateTime Value } /// - object? IValue.GetValue () => _date; + object IValue.GetValue () => _date; /// public event EventHandler>? ValueChangedUntyped; @@ -237,21 +234,19 @@ private void GenerateCalendarLabels () _calendar.Width = Dim.Auto (minimumContentDim: _calendar.Style.ColumnStyles.Sum (c => c.Value.MinWidth)); } - private static string GetBackButtonText () => Glyphs.LeftArrow + Glyphs.LeftArrow.ToString (); - private static string GetForwardButtonText () => Glyphs.RightArrow + Glyphs.RightArrow.ToString (); - private void SelectDayOnCalendar (int day) { for (var i = 0; i < _table!.Rows.Count; i++) { for (var j = 0; j < _table.Columns.Count; j++) { - if (_table.Rows [i] [j].ToString () == day.ToString ()) + if (_table.Rows [i] [j].ToString () != day.ToString ()) { - _calendar!.SetSelection (j, i, false); - - return; + continue; } + _calendar!.SetSelection (j, i, false); + + return; } } } @@ -284,34 +279,27 @@ private void SetInitialProperties (DateTime date) Value = date }; - _previousMonthButton = new Button + _previousMonthButton = new ScrollButton () { Id = "_previousMonthButton", X = Pos.Center () - 2, Y = Pos.Bottom (_calendar) - 1, - Width = 2, - Text = GetBackButtonText (), MouseHoldRepeat = MouseFlags.LeftButtonReleased, - NoPadding = true, - NoDecorations = true, - ShadowStyle = null + Direction = NavigationDirection.Backward }; - _previousMonthButton.Accepting += (_, _) => AdjustMonth (-1); + _previousMonthButton.Title = $"{_previousMonthButton.Title}{_previousMonthButton.Title}"; + _previousMonthButton.Accepted += (_, _) => AdjustMonth (-1); - _nextMonthButton = new Button + _nextMonthButton = new ScrollButton { Id = "_nextMonthButton", X = Pos.Right (_previousMonthButton) + 2, Y = Pos.Bottom (_calendar) - 1, - Width = 2, - Text = GetForwardButtonText (), - MouseHoldRepeat = MouseFlags.LeftButtonReleased, - NoPadding = true, - NoDecorations = true, - ShadowStyle = null + Direction = NavigationDirection.Forward }; + _nextMonthButton.Title = $"{_nextMonthButton.Title}{_nextMonthButton.Title}"; - _nextMonthButton.Accepting += (_, _) => AdjustMonth (1); + _nextMonthButton.Accepted += (_, _) => AdjustMonth (1); CreateCalendar (); SelectDayOnCalendar (Value.Day); diff --git a/Terminal.Gui/Views/NumericUpDown.cs b/Terminal.Gui/Views/NumericUpDown.cs index 699cf7a7c6..bf5a5bf66d 100644 --- a/Terminal.Gui/Views/NumericUpDown.cs +++ b/Terminal.Gui/Views/NumericUpDown.cs @@ -42,11 +42,11 @@ public class NumericUpDown : View, IValue where T : notnull /// public new static Dictionary? DefaultKeyBindings { get; set; } = new (); - private readonly Button _down; + private readonly ScrollButton _down; // TODO: Use a TextField instead of a Label private readonly View _number; - private readonly Button _up; + private readonly ScrollButton _up; /// /// Initializes a new instance of the class. @@ -76,16 +76,10 @@ public NumericUpDown () Width = Dim.Auto (DimAutoStyle.Content); Height = Dim.Auto (DimAutoStyle.Content); - _down = new Button + _down = new ScrollButton { - Height = 1, - Width = 1, - NoPadding = true, - NoDecorations = true, - Title = $"{Glyphs.DownArrow}", - MouseHoldRepeat = MouseFlags.LeftButtonReleased, - CanFocus = false, - ShadowStyle = null + Orientation = Orientation.Vertical, + Direction = NavigationDirection.Forward }; _number = new View @@ -99,18 +93,12 @@ public NumericUpDown () CanFocus = true }; - _up = new Button + _up = new ScrollButton { X = Pos.Right (_number), Y = Pos.Top (_number), - Height = 1, - Width = 1, - NoPadding = true, - NoDecorations = true, - Title = $"{Glyphs.UpArrow}", - MouseHoldRepeat = MouseFlags.LeftButtonReleased, - CanFocus = false, - ShadowStyle = null + Orientation = Orientation.Vertical, + Direction = NavigationDirection.Backward }; _down.Accepting += OnDownButtonOnAccept; diff --git a/Terminal.Gui/Views/ScrollBar/ScrollBar.cs b/Terminal.Gui/Views/ScrollBar/ScrollBar.cs index ec375af1d5..6b266f3e9e 100644 --- a/Terminal.Gui/Views/ScrollBar/ScrollBar.cs +++ b/Terminal.Gui/Views/ScrollBar/ScrollBar.cs @@ -26,9 +26,13 @@ namespace Terminal.Gui.Views; /// public class ScrollBar : View, IOrientation, IDesignable, IValue { - private readonly Button _decreaseButton; - private readonly ScrollSlider _slider; - private readonly Button _increaseButton; + private readonly ScrollButton _decreaseButton; + private readonly ScrollButton _increaseButton; + + /// + /// Gets the used by this . + /// + public ScrollSlider Slider { get; } /// public ScrollBar () @@ -38,33 +42,19 @@ public ScrollBar () Height = Dim.Auto (DimAutoStyle.Content, Dim.Func (_ => Orientation == Orientation.Vertical ? SuperView?.Viewport.Height ?? 0 : 1)); - _decreaseButton = new Button - { - CanFocus = false, - NoDecorations = true, - NoPadding = true, - ShadowStyle = null, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + _decreaseButton = new ScrollButton (); _decreaseButton.Accepting += OnDecreaseButtonOnAccept; - _slider = new ScrollSlider + Slider = new ScrollSlider { SliderPadding = 2 // For the buttons }; - _slider.Scrolled += SliderOnScroll; - _slider.PositionChanged += SliderOnPositionChanged; + Slider.Scrolled += SliderOnScroll; + Slider.PositionChanged += SliderOnPositionChanged; - _increaseButton = new Button - { - CanFocus = false, - NoDecorations = true, - NoPadding = true, - ShadowStyle = null, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + _increaseButton = new ScrollButton (); _increaseButton.Accepting += OnIncreaseButtonOnAccept; - Add (_decreaseButton, _slider, _increaseButton); + Add (_decreaseButton, Slider, _increaseButton); CanFocus = false; @@ -102,7 +92,8 @@ private void ShowHide () case ScrollBarVisibilityMode.Auto: // If this scrollbar lives in a View's Padding, respect the View's // ViewportSettings as the authority on whether it should be enabled. - if (SuperView is PaddingView { Adornment.Parent: { } ownerView }) + if (SuperView is PaddingView { Adornment.Parent: { } ownerView } + && (this == ownerView.VerticalScrollBar || this == ownerView.HorizontalScrollBar)) { ViewportSettingsFlags requiredFlag = Orientation == Orientation.Vertical ? ViewportSettingsFlags.HasVerticalScrollBar @@ -135,14 +126,17 @@ private void ShowHide () break; } - _slider.VisibleContentSize = VisibleContentSize; - _slider.Size = CalculateSliderSize (); + Slider.VisibleContentSize = VisibleContentSize; + Slider.Size = CalculateSliderSize (); _sliderPosition = CalculateSliderPositionFromContentPosition (_value); - _slider.Position = _sliderPosition.Value; + Slider.Position = _sliderPosition.Value; } private void PositionSubViews () { + _decreaseButton.Orientation = Orientation; + _increaseButton.Orientation = Orientation; + if (Orientation == Orientation.Vertical) { _decreaseButton.Y = 0; @@ -151,15 +145,14 @@ private void PositionSubViews () _decreaseButton.Height = 1; _decreaseButton.Title = Glyphs.UpArrow.ToString (); - _slider.X = 0; - _slider.Y = 1; - _slider.Width = Dim.Fill (); + Slider.X = 0; + Slider.Y = 1; + Slider.Width = Dim.Fill (); _increaseButton.Y = Pos.AnchorEnd (); _increaseButton.X = 0; _increaseButton.Width = Dim.Fill (); _increaseButton.Height = 1; - _increaseButton.Title = Glyphs.DownArrow.ToString (); } else { @@ -167,17 +160,15 @@ private void PositionSubViews () _decreaseButton.X = 0; _decreaseButton.Width = 1; _decreaseButton.Height = Dim.Fill (); - _decreaseButton.Title = Glyphs.LeftArrow.ToString (); - _slider.Y = 0; - _slider.X = 1; - _slider.Height = Dim.Fill (); + Slider.Y = 0; + Slider.X = 1; + Slider.Height = Dim.Fill (); _increaseButton.Y = 0; _increaseButton.X = Pos.AnchorEnd (); _increaseButton.Width = 1; _increaseButton.Height = Dim.Fill (); - _increaseButton.Title = Glyphs.RightArrow.ToString (); } } @@ -203,7 +194,7 @@ public void OnOrientationChanged (Orientation newOrientation) TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom; TextAlignment = Alignment.Center; VerticalTextAlignment = Alignment.Center; - _slider.Orientation = newOrientation; + Slider.Orientation = newOrientation; PositionSubViews (); OrientationChanged?.Invoke (this, new EventArgs (newOrientation)); @@ -273,7 +264,7 @@ public int VisibleContentSize set { _visibleContentSize = value; - _slider.Size = CalculateSliderSize (); + Slider.Size = CalculateSliderSize (); ShowHide (); } } @@ -303,7 +294,7 @@ public int ScrollableContentSize } _scrollableContentSize = value; - _slider.Size = CalculateSliderSize (); + Slider.Size = CalculateSliderSize (); ShowHide (); if (!Visible) @@ -381,9 +372,9 @@ public int Value _sliderPosition = CalculateSliderPositionFromContentPosition (_value, direction); - if (_slider.Position != _sliderPosition) + if (Slider.Position != _sliderPosition) { - _slider.Position = _sliderPosition.Value; + Slider.Position = _sliderPosition.Value; } ValueChangedEventArgs changedArgs = new (oldValue, _value); @@ -439,7 +430,7 @@ internal int CalculatePositionFromSliderPosition (int sliderPosition) { int scrollBarSize = Orientation == Orientation.Vertical ? Viewport.Height : Viewport.Width; - return ScrollSlider.CalculateContentPosition (ScrollableContentSize, VisibleContentSize, sliderPosition, scrollBarSize - _slider.SliderPadding); + return ScrollSlider.CalculateContentPosition (ScrollableContentSize, VisibleContentSize, sliderPosition, scrollBarSize - Slider.SliderPadding); } #region Slider Management @@ -531,6 +522,11 @@ internal int CalculateSliderPositionFromContentPosition (int contentPosition, Na /// protected override bool OnClearingViewport () { + if (!Slider.Visible) + { + return true; + } + if (Orientation == Orientation.Vertical) { FillRect (Viewport with { Y = Viewport.Y + 1, Height = Viewport.Height - 2 }, Glyphs.Stipple); @@ -559,12 +555,12 @@ protected override bool OnActivating (CommandEventArgs args) if (Orientation == Orientation.Vertical) { - sliderCenter = 1 + _slider.Frame.Y + _slider.Frame.Height / 2; + sliderCenter = 1 + Slider.Frame.Y + Slider.Frame.Height / 2; distanceFromCenter = mouse.Position!.Value.Y - sliderCenter; } else { - sliderCenter = 1 + _slider.Frame.X + _slider.Frame.Width / 2; + sliderCenter = 1 + Slider.Frame.X + Slider.Frame.Width / 2; distanceFromCenter = mouse.Position!.Value.X - sliderCenter; } @@ -587,7 +583,7 @@ protected override bool OnActivating (CommandEventArgs args) } else { - Value = Math.Min (ScrollableContentSize - _slider.VisibleContentSize, Value + jump); + Value = Math.Min (ScrollableContentSize - Slider.VisibleContentSize, Value + jump); } return true; diff --git a/Terminal.Gui/Views/ScrollBar/ScrollButton.cs b/Terminal.Gui/Views/ScrollBar/ScrollButton.cs new file mode 100644 index 0000000000..283af27cec --- /dev/null +++ b/Terminal.Gui/Views/ScrollBar/ScrollButton.cs @@ -0,0 +1,146 @@ +namespace Terminal.Gui.Views; + +/// +/// A used to scroll content forward or backward. It enables mouse hold-repeat for continuous +/// scrolling when the mouse button is held down. +/// The button displays an arrow glyph determined by the combination of and +/// : +/// +/// +/// OrientationDirectionGlyph +/// +/// +/// HorizontalBackward +/// +/// +/// +/// +/// +/// HorizontalForward +/// +/// +/// +/// +/// +/// VerticalBackward +/// +/// +/// +/// +/// +/// VerticalForward +/// +/// +/// +/// +/// +/// +/// +/// +/// By default, cannot receive focus and does not participate in keyboard navigation; +/// override this by setting to if desired. +/// +/// +public class ScrollButton : Button, IOrientation +{ + private readonly OrientationHelper _orientationHelper; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// The button defaults to orientation and no + /// (glyph will be updated once both + /// and are set). + /// + /// + public ScrollButton () + { + CanFocus = false; + NoDecorations = true; + NoPadding = true; + base.ShadowStyle = null; + MouseHoldRepeat = MouseFlags.LeftButtonReleased; + + // ReSharper disable once UseObjectOrCollectionInitializer + _orientationHelper = new OrientationHelper (this); + SetGlyph (); + } + + /// + /// Gets or sets the direction this scrolls. + /// + /// + /// renders an up-arrow (vertical) or left-arrow (horizontal). + /// renders a down-arrow (vertical) or right-arrow (horizontal). + /// + /// + /// + /// Changing this property automatically updates the button's glyph via . + /// + /// + public NavigationDirection Direction + { + get; + set + { + if (field == value) + { + return; + } + field = value; + SetGlyph (); + } + } + + private void SetGlyph () + { + if (Orientation == Orientation.Horizontal) + { + Title = Direction switch + { + NavigationDirection.Backward => Glyphs.LeftArrow.ToString (), + NavigationDirection.Forward => Glyphs.RightArrow.ToString (), + _ => Title + }; + } + else + { + Title = Direction switch + { + NavigationDirection.Backward => Glyphs.UpArrow.ToString (), + NavigationDirection.Forward => Glyphs.DownArrow.ToString (), + _ => Title + }; + } + } + + #region IOrientation members + + /// + /// Gets or sets the for this . The default is + /// . + /// + /// + /// + /// Changing automatically updates the button's glyph to match the new + /// orientation and the current . + /// + /// + public Orientation Orientation { get => _orientationHelper.Orientation; set => _orientationHelper.Orientation = value; } + +#pragma warning disable CS0067 // The event is never used + /// + public event EventHandler>? OrientationChanging; + + /// + public event EventHandler>? OrientationChanged; +#pragma warning restore CS0067 // The event is never used + + /// Called when has changed. + /// The new value. + public void OnOrientationChanged (Orientation newOrientation) => SetGlyph (); + + #endregion +} diff --git a/Terminal.Gui/Views/TabView/Tab.cs b/Terminal.Gui/Views/TabView/Tab.cs deleted file mode 100644 index 30562810d7..0000000000 --- a/Terminal.Gui/Views/TabView/Tab.cs +++ /dev/null @@ -1,33 +0,0 @@ - - -namespace Terminal.Gui.Views; - -/// A single tab in a . -public class Tab : View -{ - private string? _displayText; - - /// Creates a new unnamed tab with no controls inside. - public Tab () - { - BorderStyle = LineStyle.Rounded; - CanFocus = true; - TabStop = TabBehavior.NoStop; - } - - /// The text to display in a . - /// - public string DisplayText - { - get => _displayText ?? "Unnamed"; - set - { - _displayText = value; - SetNeedsLayout (); - } - } - - /// The control to display when the tab is selected. - /// - public View? View { get; set; } -} diff --git a/Terminal.Gui/Views/TabView/TabChangedEventArgs.cs b/Terminal.Gui/Views/TabView/TabChangedEventArgs.cs deleted file mode 100644 index 4a769c6880..0000000000 --- a/Terminal.Gui/Views/TabView/TabChangedEventArgs.cs +++ /dev/null @@ -1,21 +0,0 @@ -#nullable disable -namespace Terminal.Gui.Views; - -/// Describes a change in -public class TabChangedEventArgs : EventArgs -{ - /// Documents a tab change - /// - /// - public TabChangedEventArgs (Tab oldTab, Tab newTab) - { - OldTab = oldTab; - NewTab = newTab; - } - - /// The currently selected tab. May be null - public Tab NewTab { get; } - - /// The previously selected tab. May be null - public Tab OldTab { get; } -} diff --git a/Terminal.Gui/Views/TabView/TabMouseEventArgs.cs b/Terminal.Gui/Views/TabView/TabMouseEventArgs.cs deleted file mode 100644 index 6a6bf98713..0000000000 --- a/Terminal.Gui/Views/TabView/TabMouseEventArgs.cs +++ /dev/null @@ -1,27 +0,0 @@ - -using System.ComponentModel; - -namespace Terminal.Gui.Views; - -/// Describes a mouse event over a specific in a . -public class TabMouseEventArgs : HandledEventArgs -{ - /// Creates a new instance of the class. - /// that the mouse was over when the event occurred. - /// The mouse activity being reported - public TabMouseEventArgs (Tab? tab, Mouse mouse) - { - Tab = tab; - MouseEvent = mouse; - } - - /// - /// Gets the actual mouse event. Use to cancel this event and perform custom - /// behavior (e.g. show a context menu). - /// - public Mouse MouseEvent { get; } - - /// Gets the (if any) that the mouse was over when the occurred. - /// This will be null if the click is after last tab or before first. - public Tab? Tab { get; } -} diff --git a/Terminal.Gui/Views/TabView/TabRow.cs b/Terminal.Gui/Views/TabView/TabRow.cs deleted file mode 100644 index c7d926670e..0000000000 --- a/Terminal.Gui/Views/TabView/TabRow.cs +++ /dev/null @@ -1,801 +0,0 @@ - -#pragma warning disable CS8629 // Nullable value type may be null. -namespace Terminal.Gui.Views; - -internal class TabRow : View -{ - private readonly TabView _host; - private readonly View _leftScrollIndicator; - private readonly View _rightScrollIndicator; - - public TabRow (TabView host) - { - _host = host; - Id = "tabRow"; - - CanFocus = true; - TabStop = TabBehavior.TabGroup; - Width = Dim.Fill (); - - _rightScrollIndicator = new View - { - Id = "rightScrollIndicator", - Width = 1, - Height = 1, - Visible = false, - Text = Glyphs.RightArrow.ToString () - }; - _rightScrollIndicator.Activating += (s, e) => - { - _host.Tab_Selecting (s, e); - }; - - _leftScrollIndicator = new View - { - Id = "leftScrollIndicator", - Width = 1, - Height = 1, - Visible = false, - Text = Glyphs.LeftArrow.ToString () - }; - _leftScrollIndicator.Activating += (s, e) => - { - _host.Tab_Selecting (s, e); - }; - - Add (_rightScrollIndicator, _leftScrollIndicator); - } - - protected override bool OnMouseEvent (Mouse me) - { - View? parent = me.View is AdornmentView adornment ? adornment.Adornment?.Parent : me.View; - Tab? hit = parent as Tab; - - if (me.IsPressed) - { - _host.OnTabClicked (new TabMouseEventArgs (hit!, me)); - - // user canceled click - if (me.Handled) - { - return true; - } - - if (parent == _host.SelectedTab) - { - _host.SelectedTab?.SetFocus (); - } - } - - if (me.IsWheel && !HasFocus && CanFocus) - { - SetFocus (); - } - - if (me is { IsPressed: false, IsWheel: false }) - { - return false; - } - - if (me.IsPressed || me.IsWheel) - { - var scrollIndicatorHit = 0; - - if (me.View is { Id: "rightScrollIndicator" } || me.Flags.HasFlag (MouseFlags.WheeledDown) || me.Flags.HasFlag (MouseFlags.WheeledRight)) - { - scrollIndicatorHit = 1; - } - else if (me.View is { Id: "leftScrollIndicator" } || me.Flags.HasFlag (MouseFlags.WheeledUp) || me.Flags.HasFlag (MouseFlags.WheeledLeft)) - { - scrollIndicatorHit = -1; - } - - if (scrollIndicatorHit != 0) - { - _host.SwitchTabBy (scrollIndicatorHit); - - return true; - } - - if (hit is { }) - { - _host.SelectedTab = hit; - - return true; - } - } - - return false; - } - - /// - protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) - { - if (_host.SelectedTab is { HasFocus: false, CanFocus: true } && focusedView == this) - { - _host.SelectedTab?.SetFocus (); - - return; - } - - base.OnHasFocusChanged (newHasFocus, previousFocusedView, focusedView); - } - - /// - protected override void OnSubViewLayout (LayoutEventArgs args) - { - _host._tabLocations = _host.CalculateViewport (Viewport).ToArray (); - - RenderTabLine (); - - RenderUnderline (); - - base.OnSubViewLayout (args); - } - - /// - protected override bool OnRenderingLineCanvas () - { - RenderTabLineCanvas (); - - return false; - } - - private void RenderTabLineCanvas () - { - if (_host._tabLocations is null) - { - return; - } - - Tab [] tabLocations = _host._tabLocations; - int selectedTab = -1; - var lc = new LineCanvas (); - - for (var i = 0; i < tabLocations.Length; i++) - { - View tab = tabLocations [i]; - Rectangle vts = tab.ViewportToScreen (tab.Viewport); - int selectedOffset = _host.Style.ShowTopLine && tabLocations [i] == _host.SelectedTab ? 0 : 1; - - if (tabLocations [i] == _host.SelectedTab) - { - selectedTab = i; - - if (i == 0 && _host.TabScrollOffset == 0) - { - if (_host.Style.TabsOnBottom) - { - // Upper left vertical line - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - } - else - { - // Lower left vertical line - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom - selectedOffset), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - } - } - else if (i > 0 && i <= tabLocations.Length - 1) - { - if (_host.Style.TabsOnBottom) - { - // URCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - -1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // LRCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom - selectedOffset), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom - selectedOffset), - -1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - if (_host.Style.ShowTopLine) - { - if (_host.Style.TabsOnBottom) - { - // Lower left tee - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // Upper left tee - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - } - - if (i < tabLocations.Length - 1) - { - if (_host.Style.ShowTopLine) - { - if (_host.Style.TabsOnBottom) - { - // Lower right tee - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // Upper right tee - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - } - - if (_host.Style.TabsOnBottom) - { - //URCorner - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - //LLCorner - lc.AddLine ( - new Point (vts.Right, vts.Bottom - selectedOffset), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Bottom - selectedOffset), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - else if (selectedTab == -1) - { - if (i == 0 && string.IsNullOrEmpty (tab.Text)) - { - if (_host.Style.TabsOnBottom) - { - if (_host.Style.ShowTopLine) - { - // LLCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - // ULCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - if (_host.Style.ShowTopLine) - { - // ULCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - // LLCorner - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - else if (i > 0) - { - if (_host.Style.ShowTopLine || _host.Style.TabsOnBottom) - { - // Upper left tee - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - // Lower left tee - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - else if (i < tabLocations.Length - 1) - { - if (_host.Style.ShowTopLine) - { - // Upper right tee - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - if (_host.Style.ShowTopLine || !_host.Style.TabsOnBottom) - { - // Lower right tee - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // Upper right tee - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - - if (i == 0 && i != selectedTab && _host is { TabScrollOffset: 0, Style.ShowBorder: true }) - { - if (_host.Style.TabsOnBottom) - { - // Upper left vertical line - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 0, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Y - 1), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // Lower left vertical line - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 0, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.X - 1, vts.Bottom), - 1, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - - if (i == tabLocations.Length - 1 && i != selectedTab) - { - if (_host.Style.TabsOnBottom) - { - // Upper right tee - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Y - 1), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - // Lower right tee - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - - lc.AddLine ( - new Point (vts.Right, vts.Bottom), - 0, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - - if (i == tabLocations.Length - 1) - { - var arrowOffset = 1; - - int lastSelectedTab = !_host.Style.ShowTopLine && i == selectedTab ? 1 : - _host.Style.TabsOnBottom ? 1 : 0; - Rectangle tabsBarVts = ViewportToScreen (Viewport); - int lineLength = tabsBarVts.Right - vts.Right; - - // Right horizontal line - if (ShouldDrawRightScrollIndicator ()) - { - if (lineLength - arrowOffset > 0) - { - if (_host.Style.TabsOnBottom) - { - lc.AddLine ( - new Point (vts.Right, vts.Y - lastSelectedTab), - lineLength - arrowOffset, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - lc.AddLine ( - new Point ( - vts.Right, - vts.Bottom - lastSelectedTab - ), - lineLength - arrowOffset, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - } - } - else - { - // Right corner - if (_host.Style.TabsOnBottom) - { - lc.AddLine ( - new Point (vts.Right, vts.Y - lastSelectedTab), - lineLength, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - else - { - lc.AddLine ( - new Point (vts.Right, vts.Bottom - lastSelectedTab), - lineLength, - Orientation.Horizontal, - tab.BorderStyle.Value - ); - } - - if (_host.Style.ShowBorder) - { - if (_host.Style.TabsOnBottom) - { - // More LRCorner - lc.AddLine ( - new Point ( - tabsBarVts.Right - 1, - vts.Y - lastSelectedTab - ), - -1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - } - else - { - // More URCorner - lc.AddLine ( - new Point ( - tabsBarVts.Right - 1, - vts.Bottom - lastSelectedTab - ), - 1, - Orientation.Vertical, - tab.BorderStyle.Value - ); - } - } - } - } - } - - _host.LineCanvas.Merge (lc); - } - - private int GetUnderlineYPosition () - { - if (_host.Style.TabsOnBottom) - { - return 0; - } - - return _host.Style.ShowTopLine ? 2 : 1; - } - - /// Renders the line with the tab names in it. - private void RenderTabLine () - { - if (_host._tabLocations is null) - { - return; - } - - View? selected = null; - int topLine = _host.Style.ShowTopLine ? 1 : 0; - - foreach (Tab toRender in _host._tabLocations) - { - Tab tab = toRender; - - if (toRender == _host.SelectedTab) - { - selected = tab; - - if (_host.Style.TabsOnBottom) - { - tab.Border.Thickness = new (1, 0, 1, topLine); - tab.Margin.Thickness = new (0, 1, 0, 0); - } - else - { - tab.Border.Thickness = new (1, topLine, 1, 0); - tab.Margin.Thickness = new (0, 0, 0, topLine); - } - } - else if (selected is null) - { - if (_host.Style.TabsOnBottom) - { - tab.Border.Thickness = new (1, 1, 1, topLine); - tab.Margin.Thickness = new (0, 0, 0, 0); - } - else - { - tab.Border.Thickness = new (1, topLine, 1, 1); - tab.Margin.Thickness = new (0, 0, 0, 0); - } - } - else - { - if (_host.Style.TabsOnBottom) - { - tab.Border.Thickness = new (1, 1, 1, topLine); - tab.Margin.Thickness = new (0, 0, 0, 0); - } - else - { - tab.Border.Thickness = new (1, topLine, 1, 1); - tab.Margin.Thickness = new (0, 0, 0, 0); - } - } - - // Ensures updating TextFormatter constrains - tab.TextFormatter.ConstrainToWidth = tab.GetContentSize ().Width; - tab.TextFormatter.ConstrainToHeight = tab.GetContentSize ().Height; - } - } - - /// Renders the line of the tab that adjoins the content of the tab. - private void RenderUnderline () - { - int y = GetUnderlineYPosition (); - - Tab? selected = _host._tabLocations?.FirstOrDefault (t => t == _host.SelectedTab); - - if (selected is null) - { - return; - } - - // draw scroll indicators - - // if there are more tabs to the left not visible - if (_host.TabScrollOffset > 0) - { - _leftScrollIndicator.X = 0; - _leftScrollIndicator.Y = y; - - // indicate that - _leftScrollIndicator.Visible = true; - - // Ensures this is clicked instead of the first tab - MoveSubViewToEnd (_leftScrollIndicator); - } - else - { - _leftScrollIndicator.Visible = false; - } - - // if there are more tabs to the right not visible - if (ShouldDrawRightScrollIndicator ()) - { - _rightScrollIndicator.X = Viewport.Width - 1; - _rightScrollIndicator.Y = y; - - // indicate that - _rightScrollIndicator.Visible = true; - - // Ensures this is clicked instead of the last tab if under this - MoveSubViewToStart (_rightScrollIndicator); - } - else - { - _rightScrollIndicator.Visible = false; - } - } - - private bool ShouldDrawRightScrollIndicator () { return _host._tabLocations!.LastOrDefault () != _host.Tabs.LastOrDefault (); } -} diff --git a/Terminal.Gui/Views/TabView/TabStyle.cs b/Terminal.Gui/Views/TabView/TabStyle.cs deleted file mode 100644 index caf059ba04..0000000000 --- a/Terminal.Gui/Views/TabView/TabStyle.cs +++ /dev/null @@ -1,19 +0,0 @@ -#nullable disable -namespace Terminal.Gui.Views; - -/// Describes render stylistic selections of a -public class TabStyle -{ - /// True to show a solid box around the edge of the control. Defaults to true. - public bool ShowBorder { get; set; } = true; - - /// - /// True to show the top lip of tabs. False to directly begin with tab text during rendering. When true header - /// line occupies 3 rows, when false only 2. Defaults to true. - /// When is enabled this instead applies to the bottommost line of the control - /// - public bool ShowTopLine { get; set; } = true; - - /// True to render tabs at the bottom of the view instead of the top - public bool TabsOnBottom { get; set; } = false; -} diff --git a/Terminal.Gui/Views/TabView/TabView.cs b/Terminal.Gui/Views/TabView/TabView.cs deleted file mode 100644 index ec84bef9c2..0000000000 --- a/Terminal.Gui/Views/TabView/TabView.cs +++ /dev/null @@ -1,710 +0,0 @@ -namespace Terminal.Gui.Views; - -/// Control that hosts multiple sub views, presenting a single one at once. -/// -/// Default key bindings: -/// -/// -/// Key Action -/// -/// -/// Left / Right Moves to the previous or next tab. -/// -/// -/// Home / End Moves to the first or last tab. -/// -/// -/// PageUp / PageDown Scrolls the tab strip one page. -/// -/// -/// Up Moves focus into the tab content area. -/// -/// -/// Down Moves focus back to the tab strip. -/// -/// -/// -public class TabView : View -{ - /// The default to set on new controls. - public const uint DefaultMaxTabTextWidth = 30; - - /// - /// Gets or sets the default key bindings for . All standard navigation bindings are - /// inherited from , so this dictionary is empty by default. - /// - /// IMPORTANT: This is a process-wide static property. Change with care. - /// Do not set in parallelizable unit tests. - /// - /// - public new static Dictionary? DefaultKeyBindings { get; set; } = new (); - - /// - /// This sub view is the main client area of the current tab. It hosts the of the tab, the - /// . - /// - private readonly View _containerView; - - private readonly List _tabs = new (); - - /// This sub view is the 2 or 3 line control that represents the actual tabs themselves. - private readonly TabRow _tabsBar; - - private Tab? _selectedTab; - - internal Tab []? _tabLocations; - private int _tabScrollOffset; - - /// Initializes a class. - public TabView () - { - CanFocus = true; - TabStop = TabBehavior.TabStop; // Because TabView has focusable subviews, it must be a TabGroup - _tabsBar = new TabRow (this); - _containerView = new View (); - ApplyStyleChanges (); - - Add (_tabsBar); - Add (_containerView); - - // Things this view knows how to do - AddCommand (Command.Left, () => SwitchTabBy (-1)); - - AddCommand (Command.Right, () => SwitchTabBy (1)); - - AddCommand (Command.LeftStart, - () => - { - TabScrollOffset = 0; - SelectedTab = Tabs.FirstOrDefault ()!; - - return true; - }); - - AddCommand (Command.RightEnd, - () => - { - TabScrollOffset = Tabs.Count - 1; - SelectedTab = Tabs.LastOrDefault ()!; - - return true; - }); - - AddCommand (Command.PageDown, - () => - { - TabScrollOffset += _tabLocations!.Length; - SelectedTab = Tabs.ElementAt (TabScrollOffset); - - return true; - }); - - AddCommand (Command.PageUp, - () => - { - TabScrollOffset -= _tabLocations!.Length; - SelectedTab = Tabs.ElementAt (TabScrollOffset); - - return true; - }); - - AddCommand (Command.Up, - () => - { - if (_style.TabsOnBottom) - { - if (_tabsBar is { HasFocus: true } && _containerView.CanFocus) - { - _containerView.SetFocus (); - - return true; - } - } - else - { - if (_containerView is { HasFocus: true }) - { - View? mostFocused = _containerView.MostFocused; - - if (mostFocused is { }) - { - for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) - 1; i > -1; i--) - { - View? view = mostFocused.SuperView?.SubViews.ElementAt ((int)i); - - if (view is { CanFocus: true, Enabled: true, Visible: true }) - { - // Let runnable handle it - return false; - } - } - } - - SelectedTab?.SetFocus (); - - return true; - } - } - - return false; - }); - - AddCommand (Command.Down, - () => - { - if (_style.TabsOnBottom) - { - if (_containerView is { HasFocus: true }) - { - View? mostFocused = _containerView.MostFocused; - - if (mostFocused is { }) - { - for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) + 1; i < mostFocused.SuperView?.SubViews.Count; i++) - { - View? view = mostFocused.SuperView?.SubViews.ElementAt ((int)i); - - if (view is { CanFocus: true, Enabled: true, Visible: true }) - { - // Let runnable handle it - return false; - } - } - } - - SelectedTab?.SetFocus (); - - return true; - } - } - else - { - if (_tabsBar is { HasFocus: true } && _containerView.CanFocus) - { - _containerView.SetFocus (); - - return true; - } - } - - return false; - }); - - // Apply layered key bindings (base View layer + TabView-specific layer) - ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); - } - - /// - /// The maximum number of characters to render in a Tab header. This prevents one long tab from pushing out all - /// the others. - /// - public uint MaxTabTextWidth { get; set; } = DefaultMaxTabTextWidth; - - // This is needed to hold initial value because it may change during the setter process - private bool _selectedTabHasFocus; - - /// The currently selected member of chosen by the user. - /// - public Tab? SelectedTab - { - get => _selectedTab; - set - { - if (value == _selectedTab) - { - return; - } - - Tab? old = _selectedTab; - _selectedTabHasFocus = old is { } && (old.HasFocus || !_containerView.CanFocus); - - if (_selectedTab is { }) - { - if (_selectedTab.View is { }) - { - _selectedTab.View.CanFocusChanged -= ContainerViewCanFocus!; - - // remove old content - _containerView.Remove (_selectedTab.View); - } - } - - _selectedTab = value; - - // add new content - if (_selectedTab?.View != null) - { - _selectedTab.View.CanFocusChanged += ContainerViewCanFocus!; - _containerView.Add (_selectedTab.View); - } - - ContainerViewCanFocus (null!, null!); - - EnsureSelectedTabIsVisible (); - - if (old != _selectedTab) - { - if (TabCanSetFocus ()) - { - SelectedTab?.SetFocus (); - } - - OnSelectedTabChanged (old!, _selectedTab!); - } - SetNeedsLayout (); - } - } - - private bool TabCanSetFocus () - { -#pragma warning disable CS8629 // Nullable value type may be null. - return IsInitialized && SelectedTab is { } && (HasFocus || (bool)_containerView?.HasFocus) && (_selectedTabHasFocus || !_containerView.CanFocus); -#pragma warning restore CS8629 // Nullable value type may be null. - } - - private void ContainerViewCanFocus (object sender, EventArgs eventArgs) => _containerView.CanFocus = _containerView.SubViews.Count (v => v.CanFocus) > 0; - - private TabStyle _style = new (); - - /// Render choices for how to display tabs. After making changes, call . - /// - public TabStyle Style - { - get => _style; - set - { - if (_style == value) - { - return; - } - _style = value; - SetNeedsLayout (); - } - } - - /// All tabs currently hosted by the control. - /// - public IReadOnlyCollection Tabs => _tabs.AsReadOnly (); - - /// When there are too many tabs to render, this indicates the first tab to render on the screen. - /// - public int TabScrollOffset - { - get => _tabScrollOffset; - set - { - _tabScrollOffset = EnsureValidScrollOffsets (value); - SetNeedsLayout (); - } - } - - /// Adds the given to . - /// - /// True to make the newly added Tab the . - public void AddTab (Tab tab, bool andSelect) - { - if (_tabs.Contains (tab)) - { - return; - } - - _tabs.Add (tab); - _tabsBar.Add (tab); - - if (SelectedTab is null || andSelect) - { - SelectedTab = tab; - - EnsureSelectedTabIsVisible (); - - tab.View?.SetFocus (); - } - - SetNeedsLayout (); - } - - /// - /// Updates the control to use the latest state settings in . This can change the size of the - /// client area of the tab (for rendering the selected tab's content). This method includes a call to - /// . - /// - public void ApplyStyleChanges () - { - _containerView.BorderStyle = Style.ShowBorder ? LineStyle.Single : LineStyle.None; - _containerView.Width = Dim.Fill (); - - if (Style.TabsOnBottom) - { - // Tabs are along the bottom so just dodge the border - if (Style.ShowBorder) - { - _containerView.Border.Thickness = new Thickness (1, 1, 1, 0); - } - - _containerView.Y = 0; - - int tabHeight = GetTabHeight (false); - - // Fill client area leaving space at bottom for tabs - _containerView.Height = Dim.Fill (tabHeight); - - _tabsBar.Height = tabHeight; - - _tabsBar.Y = Pos.Bottom (_containerView); - } - else - { - // Tabs are along the top - if (Style.ShowBorder) - { - _containerView.Border.Thickness = new Thickness (1, 0, 1, 1); - } - - _tabsBar.Y = 0; - - int tabHeight = GetTabHeight (true); - - //move content down to make space for tabs - _containerView.Y = Pos.Bottom (_tabsBar); - - // Fill client area leaving space at bottom for border - _containerView.Height = Dim.Fill (); - - // The top tab should be 2 or 3 rows high and on the top - - _tabsBar.Height = tabHeight; - - // Should be able to just use 0 but switching between top/bottom tabs repeatedly breaks in ValidatePosDim if just using the absolute value 0 - } - - SetNeedsLayout (); - } - - /// - protected override void OnViewportChanged (DrawEventArgs e) - { - _tabLocations = CalculateViewport (Viewport).ToArray (); - - base.OnViewportChanged (e); - } - - /// Updates to ensure that is visible. - public void EnsureSelectedTabIsVisible () - { - if (!IsInitialized || SelectedTab is null) - { - return; - } - - // if current viewport does not include the selected tab - if (!CalculateViewport (Viewport).Any (t => Equals (SelectedTab, t))) - { - // Set scroll offset so the first tab rendered is the - TabScrollOffset = Math.Max (0, Tabs.IndexOf (SelectedTab)); - } - } - - /// Updates to be a valid index of . - /// The value to validate. - /// Changes will not be immediately visible in the display until you call . - /// The valid for the given value. - public int EnsureValidScrollOffsets (int value) => Math.Max (Math.Min (value, Tabs.Count - 1), 0); - - /// - protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) - { - if (SelectedTab is { HasFocus: false } && !_containerView.CanFocus && focusedView == this) - { - SelectedTab?.SetFocus (); - - return; - } - - base.OnHasFocusChanged (newHasFocus, previousFocusedView, focusedView); - } - - /// - /// Removes the given from . Caller is responsible for disposing the - /// tab's hosted if appropriate. - /// - /// - public void RemoveTab (Tab? tab) - { - if (tab is null || !_tabs.Contains (tab)) - { - return; - } - - // what tab was selected before closing - int idx = _tabs.IndexOf (tab); - - _tabs.Remove (tab); - - // if the currently selected tab is no longer a member of Tabs - if (SelectedTab is null || !Tabs.Contains (SelectedTab)) - { - // select the tab closest to the one that disappeared - int toSelect = Math.Max (idx - 1, 0); - - if (toSelect < Tabs.Count) - { - SelectedTab = Tabs.ElementAt (toSelect); - } - else - { - SelectedTab = Tabs.LastOrDefault (); - } - } - - EnsureSelectedTabIsVisible (); - SetNeedsLayout (); - } - - /// Event for when changes. - public event EventHandler? SelectedTabChanged; - - /// - /// Changes the by the given . Positive for right, negative for - /// left. If no tab is currently selected then the first tab will become selected. - /// - /// - public bool SwitchTabBy (int amount) - { - if (Tabs.Count == 0) - { - return false; - } - - // if there is only one tab anyway or nothing is selected - if (Tabs.Count == 1 || SelectedTab is null) - { - SelectedTab = Tabs.ElementAt (0); - - return SelectedTab is { }; - } - - int currentIdx = Tabs.IndexOf (SelectedTab); - - // Currently selected tab has vanished! - if (currentIdx == -1) - { - SelectedTab = Tabs.ElementAt (0); - - return true; - } - - int newIdx = Math.Max (0, Math.Min (currentIdx + amount, Tabs.Count - 1)); - - if (newIdx == currentIdx) - { - return false; - } - - SelectedTab = _tabs [newIdx]; - - EnsureSelectedTabIsVisible (); - - return true; - } - - /// - /// Event fired when a is clicked. Can be used to cancel navigation, show context menu (e.g. on - /// right click) etc. - /// - public event EventHandler? TabClicked; - - /// Disposes the control and all . - /// - protected override void Dispose (bool disposing) - { - base.Dispose (disposing); - - // The selected tab will automatically be disposed but - // any tabs not visible will need to be manually disposed - - foreach (Tab tab in Tabs) - { - if (!Equals (SelectedTab, tab)) - { - tab.View?.Dispose (); - } - } - } - - /// Raises the event. - protected virtual void OnSelectedTabChanged (Tab oldTab, Tab newTab) => SelectedTabChanged?.Invoke (this, new TabChangedEventArgs (oldTab, newTab)); - - /// Returns which tabs to render at each x location. - /// - internal IEnumerable CalculateViewport (Rectangle bounds) - { - UnSetCurrentTabs (); - - var i = 1; - View? prevTab = null; - - // Starting at the first or scrolled to tab - foreach (Tab tab in Tabs.Skip (TabScrollOffset)) - { - if (prevTab is { }) - { - tab.X = Pos.Right (prevTab) - 1; - } - else - { - tab.X = 0; - } - - tab.Y = 0; - - // while there is space for the tab - int tabTextWidth = tab.DisplayText.GetColumns (); - - // The maximum number of characters to use for the tab name as specified - // by the user (MaxTabTextWidth). But not more than the width of the view - // or we won't even be able to render a single tab! - long maxWidth = Math.Max (0, Math.Min (bounds.Width - 3, MaxTabTextWidth)); - - tab.Width = 2; - tab.Height = Style.ShowTopLine ? 3 : 2; - - // if tab view is width <= 3 don't render any tabs - if (maxWidth == 0) - { - tab.Visible = true; - tab.Activating += Tab_Selecting!; - tab.Border.View!.Activating += Tab_Selecting!; - - yield return tab; - - break; - } - - if (tabTextWidth > maxWidth) - { - tab.Text = tab.DisplayText.Substring (0, (int)maxWidth); - tabTextWidth = (int)maxWidth; - } - else - { - tab.Text = tab.DisplayText; - } - - tab.Width = Math.Max (tabTextWidth + 2, 1); - tab.Height = Style.ShowTopLine ? 3 : 2; - - // if there is not enough space for this tab - if (i + tabTextWidth >= bounds.Width) - { - tab.Visible = false; - - break; - } - - // there is enough space! - tab.Visible = true; - tab.Activating += Tab_Selecting!; - tab.Border.View!.Activating += Tab_Selecting!; - - yield return tab; - - prevTab = tab; - - i += tabTextWidth + 1; - } - - if (TabCanSetFocus ()) - { - SelectedTab?.SetFocus (); - } - else if (HasFocus) - { - SelectedTab?.View?.SetFocus (); - } - } - - /// - /// Returns the number of rows occupied by rendering the tabs, this depends on - /// and can be 0 (e.g. if and you ask for ). - /// - /// True to measure the space required at the top of the control, false to measure space at the bottom. - /// . - /// - private int GetTabHeight (bool top) - { - if (top && Style.TabsOnBottom) - { - return 0; - } - - if (!top && !Style.TabsOnBottom) - { - return 0; - } - - return Style.ShowTopLine ? 3 : 2; - } - - internal void Tab_Selecting (object? sender, CommandEventArgs e) - { - // When a tab is activated (via mouse or keyboard), select it. - // Don't forward the mouse event back to _tabsBar.NewMouseEvent as that causes infinite recursion. - var tab = sender as Tab; - - // If sender is a Border, get the parent Tab - if (sender is BorderView border) - { - tab = border.Adornment?.Parent as Tab; - } - - if (tab is { } && tab != SelectedTab) - { - SelectedTab = tab; - e.Handled = true; - } - else if (sender is View { Id: "leftScrollIndicator" }) - { - SwitchTabBy (-1); - e.Handled = true; - } - else if (sender is View { Id: "rightScrollIndicator" }) - { - SwitchTabBy (1); - e.Handled = true; - } - } - - private void UnSetCurrentTabs () - { - if (_tabLocations is null) - { - // Ensures unset any visible tab prior to TabScrollOffset - for (var i = 0; i < TabScrollOffset; i++) - { - Tab tab = Tabs.ElementAt (i); - - if (tab.Visible) - { - tab.Activating -= Tab_Selecting!; - tab.Border.View!.Activating -= Tab_Selecting!; - tab.Visible = false; - } - } - } - else if (_tabLocations is { }) - { - foreach (Tab tabToRender in _tabLocations) - { - tabToRender.Activating -= Tab_Selecting!; - tabToRender.Border.View!.Activating -= Tab_Selecting!; - tabToRender.Visible = false; - } - - _tabLocations = null; - } - } - - /// Raises the event. - /// - internal virtual void OnTabClicked (TabMouseEventArgs tabMouseEventArgs) => TabClicked?.Invoke (this, tabMouseEventArgs); -} diff --git a/Terminal.Gui/Views/Tabs.cs b/Terminal.Gui/Views/Tabs.cs new file mode 100644 index 0000000000..f857100c17 --- /dev/null +++ b/Terminal.Gui/Views/Tabs.cs @@ -0,0 +1,1110 @@ +using System.Collections.ObjectModel; + +namespace Terminal.Gui.Views; + +/// +/// A tabbed container that renders each SubView as a selectable tab with a header drawn by +/// . The currently focused SubView is the selected (front-most) tab. +/// +/// +/// +/// Add any instances via . Each added view is automatically +/// configured with , arrangement, +/// and a derived from and . +/// +/// +/// The selected tab is determined by focus — whichever SubView has focus is drawn on top +/// and reported as the . Set programmatically to switch tabs. +/// +/// +/// Logical tab order is maintained separately from the draw order (). +/// Use to enumerate tabs in logical order, and +/// to add a tab at a specific position. +/// +/// +/// When tabs overflow the available space, scroll indicator buttons appear on the separator line +/// of each tab's border. Use to scroll programmatically. +/// +/// +/// In earlier versions of Terminal.Gui, `TabView` provided similar functionality. +/// +/// +public class Tabs : View, IValue, IDesignable +{ + /// + /// Initializes a new instance of the class. + /// + public Tabs () + { + CanFocus = true; + TabStop = TabBehavior.TabGroup; + + Width = Dim.Fill (); + Height = Dim.Fill (); + + AddCommand (Command.Up, NavCommandHandler); + AddCommand (Command.Down, NavCommandHandler); + AddCommand (Command.Left, NavCommandHandler); + AddCommand (Command.Right, NavCommandHandler); + + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); + KeyBindings.Add (Key.CursorLeft, Command.Left); + KeyBindings.Add (Key.CursorRight, Command.Right); + + CommandsToBubbleUp = [Command.Up, Command.Down, Command.Left, Command.Right]; + } + + private readonly List> _tabList = []; + + /// + /// Gets the logical index of the specified view within this container. + /// + /// The view to find. + /// The zero-based index, or -1 if the view is not a tab in this container. + public int IndexOf (View view) + { + var i = 0; + + foreach (WeakReference wr in _tabList) + { + if (wr.TryGetTarget (out View? target) && target == view) + { + return i; + } + + i++; + } + + return -1; + } + + /// + /// Gets the tabs in logical order. This may differ from order because + /// the focused tab is moved to the end of the draw list to render on top. + /// + public IEnumerable TabCollection => ResolveTabCollection (); + + /// + /// Gets or sets the depth of the tab header in rows (for /) + /// or columns (for /). The default is 3, which provides room + /// for the outside border, title text, and a 1-character separator line. + /// + /// + /// Changing this value updates the of all tab SubViews. The depth determines + /// how much of the border is allocated to the tab header vs. the content area. + /// + public int TabDepth + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + UpdateTabBorderThickness (); + } + } = 3; + + /// + /// Gets or sets the spacing between adjacent tab headers. Negative values cause tabs to + /// overlap (sharing border cells). The default is -1, which shares one border edge + /// between adjacent tabs. A value of 0 places tabs edge-to-edge. Positive values + /// insert a gap between tabs. + /// + public int TabSpacing + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + SetNeedsLayout (); + } + } = -1; + + private LineStyle _tabLineStyle = LineStyle.Rounded; + + /// + /// Gets or sets the used for tab borders. + /// When set, updates the of all existing tab SubViews. + /// + public LineStyle TabLineStyle + { + get => _tabLineStyle; + set + { + if (_tabLineStyle == value) + { + return; + } + + _tabLineStyle = value; + + foreach (View tab in TabCollection) + { + tab.BorderStyle = _tabLineStyle; + } + + UpdateTabBorderThickness (); + SetNeedsLayout (); + } + } + + private Side _tabSide = Side.Top; + + /// + /// Gets or sets which side the tab headers are displayed on. + /// + /// + /// Changing this property updates the , + /// , scroll button positions, tab offsets, + /// and z-order for all tab SubViews. + /// + public Side TabSide + { + get => _tabSide; + set + { + if (_tabSide == value) + { + return; + } + + _tabSide = value; + + UpdateTabBorderThickness (); + UpdateScrollButtonPositions (); + UpdateTabOffsets (); + UpdateZOrder (); + SetNeedsLayout (); + } + } + + /// + /// Resolves the tabs from the internal weak reference list, preserving logical order. + /// + private IEnumerable ResolveTabCollection () + { + foreach (WeakReference wr in _tabList) + { + if (wr.TryGetTarget (out View? view)) + { + yield return view; + } + } + } + + #region IValue Implementation + + private View? _value; + + /// + /// Gets or sets the currently selected tab view. Setting this focuses the specified view, + /// scrolls the tab headers to ensure it is visible, and updates the z-order so it draws on top. + /// + public View? Value { get => _value; set => ChangeValue (value); } + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + public event EventHandler>? ValueChangedUntyped; + + /// + /// Called when is changing. Override to cancel the change. + /// + /// The event arguments containing old and new values. + /// to cancel the change; otherwise . + protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; + + /// + /// Called when has changed. + /// + /// The event arguments containing old and new values. + protected virtual void OnValueChanged (ValueChangedEventArgs args) { } + + private void ChangeValue (View? newValue) + { + if (_value == newValue) + { + return; + } + + View? oldValue = _value; + + ValueChangingEventArgs changingArgs = new (oldValue, newValue); + + if (OnValueChanging (changingArgs) || changingArgs.Handled) + { + return; + } + + ValueChanging?.Invoke (this, changingArgs); + + if (changingArgs.Handled) + { + return; + } + + _value = newValue; + + if (_value is { HasFocus: false }) + { + _value.SetFocus (); + } + + if (_value is { }) + { + EnsureTabVisible (_value); + } + + UpdateZOrder (); + SetNeedsLayout (); + + ValueChangedEventArgs changedArgs = new (oldValue, _value); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, _value)); + } + + #endregion + + #region SubView Management + + /// + protected override void OnFocusedChanged (View? previousFocused, View? focused) + { + base.OnFocusedChanged (previousFocused, focused); + + if (focused is TitleView) + { + return; + } + + // Find which tab view now has focus (using logical order) + View? focusedTab = TabCollection.FirstOrDefault (t => t.HasFocus); + + if (focusedTab is { }) + { + Value = focusedTab; + } + } + + /// + /// Reorders the SubViews to match the logical tab order defined by . This ensures that + /// navigation and other operations that rely on SubView order function according to the logical tab order, rather than + /// the draw order which may differ due to focused tab being drawn last. + /// + /// + /// + /// + /// + public override IReadOnlyCollection GetSubViews (bool includeMargin = false, bool includeBorder = false, bool includePadding = false) + { + List subViewsOfThisTabs = new (base.GetSubViews (includeMargin, includeBorder, includePadding)); + + // Reorder according to TabsCollection + subViewsOfThisTabs.Sort ((v1, v2) => + { + int index1 = TabCollection.TakeWhile (t => t != v1).Count (); + int index2 = TabCollection.TakeWhile (t => t != v2).Count (); + + return index1.CompareTo (index2); + }); + + return subViewsOfThisTabs; + } + + /// + protected override bool OnAdvancingFocus (NavigationDirection direction, TabBehavior? behavior) + { + if (base.OnAdvancingFocus (direction, behavior)) + { + return true; + } + + return false; + } + + /// + /// Handles adding the new tab View to an internal tracking list, and ensuring is + /// set to so that focus will not change during the flow. + /// + protected override bool OnSubViewAdding (EventArgs args) + { + if (base.OnSubViewAdding (args)) + { + return true; + } + + // Add to internal tracking list + _tabList.Add (new WeakReference (args.Value)); + + // Ensure CanFocus is false; otherwise Add will try to set focus on it, which can interfere + // with the expected flow of focus being set to the first tab added, and then not changing on subsequent adds + args.Value.CanFocus = false; + + return false; + } + + /// + /// Configures a subview when it is added to the tab view, applying tab-specific layout, border, and focus settings. + /// + /// + /// This method customizes the appearance and behavior of the subview to match the tabbed + /// interface, including setting border styles, layout, and focus properties. It also adds scroll indicator buttons + /// to the tab's border to provide visual affordance when scrolling is available. + /// + /// The subview being added to the tab view. Cannot be null. + protected override void OnSubViewAdded (View view) + { + base.OnSubViewAdded (view); + + view.BorderStyle = _tabLineStyle; + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = _tabSide; + view.Arrangement = ViewArrangement.Overlapped; + view.Width = Dim.Fill (); + view.Height = Dim.Fill (); + view.SuperViewRendersLineCanvas = true; + + // Add scroll indicator buttons to the tab's border. + // They occlude the separator line when visible, providing scroll affordance. + AddScrollButtonsToBorder (view); + + UpdateZOrder (); + UpdateTabBorderThickness (); + UpdateTabOffsets (); + + // Re-enable focus on the view now that it has been configured as a tab. + // Setting CanFocus causes the View to become focused. + view.CanFocus = true; + view.TabStop = TabBehavior.TabStop; + view.Border.View?.CanFocus = true; + + // Setting the BorderView to NoStop ensures that the only way the TitleTabView will + // get focus is if the user explicitly sets focus to it + // or, if the users uses the Next/PreviousTabGroup key. + view.Border.View?.TabStop = TabBehavior.NoStop; + + view.HotKeySpecifier = (Rune)'\xffff'; + + view.Border.View?.CommandsToBubbleUp = [Command.Up, Command.Down, Command.Left, Command.Right]; + + //view.CommandsToBubbleUp = [Command.Up, Command.Down, Command.Left, Command.Right]; + } + + /// + /// Handles removal of a SubView by removing it from the logical tab list. + /// If the removed view was the selected tab, the first remaining tab is selected. + /// + protected override void OnSubViewRemoved (View view) + { + base.OnSubViewRemoved (view); + + if (_disposing) + { + return; + } + + // Remove the view (and any dead refs) from the tracking list + _tabList.RemoveAll (wr => !wr.TryGetTarget (out View? target) || target == view); + + // If the removed view was the selected one, select the first tab + if (Value == view) + { + _value = null; + View? firstTab = TabCollection.FirstOrDefault (); + + if (firstTab is { }) + { + Value = firstTab; + } + } + + UpdateZOrder (); + UpdateTabBorderThickness (); + UpdateTabOffsets (); + } + + /// + /// Inserts a view as a tab at the specified logical index. The view is added as a SubView, + /// configured with tab border settings (same as ), and placed at + /// in the order. + /// + /// The zero-based index at which to insert the tab. Clamped to the valid range. + /// The view to insert. + public void InsertTab (int index, View view) + { + // Add will trigger OnSubViewAdded, which appends to _tabList + Add (view); + + // Move from the end (where OnSubViewAdded appended it) to the requested index + WeakReference lastRef = _tabList [^1]; + _tabList.RemoveAt (_tabList.Count - 1); + _tabList.Insert (Math.Clamp (index, 0, _tabList.Count), lastRef); + + UpdateTabOffsets (); + UpdateZOrder (); + } + + #endregion + + #region Layout Helpers + + /// + /// Updates the z-order of tab SubViews so the focused tab is drawn last (on top). The tabs before the focused tab are + /// drawn in the order they were added (first added at back). + /// The tabs after the focused tab are drawn in reverse order they were added (last added at back). + /// + /// + /// Z-ordering uses (draw order). Logical tab order is maintained + /// separately in the internal tab list. + /// + private void UpdateZOrder () + { + View? focusedTab = TabCollection.FirstOrDefault (t => t.HasFocus); + + if (focusedTab is { }) + { + // Tabs before the focused tab are drawn in the order they were added (first added at back) + foreach (View tab in TabCollection.TakeWhile (t => t != focusedTab)) + { + MoveSubViewToEnd (tab); + } + + // Focused tab is drawn on top of all others + MoveSubViewToEnd (focusedTab); + + // Tabs after the focused tab are drawn in reverse order they were added (last added at back) + foreach (View tab in TabCollection.SkipWhile (t => t != focusedTab).Skip (1)) + { + MoveSubViewToStart (tab); + } + } + else + { + // No focused tab - draw in reverse logical order (first tab at front) + foreach (View tab in TabCollection.Reverse ()) + { + MoveSubViewToStart (tab); + } + } + } + + /// + /// Updates for all tabs based on + /// and the cumulative widths/heights of preceding tabs, adjusted by the current scroll offset. + /// + internal void UpdateTabOffsets () + { + var offset = 0; + + foreach (View tab in TabCollection) + { + if (tab.Border.View is not BorderView bv) + { + continue; + } + + bv.TabOffset = offset - ScrollOffset; + + int tabLength = bv.EffectiveTabLength; + + if (tabLength > 0) + { + // TabSpacing controls overlap (-1 = shared edge) or gap (0+ = space between) + offset += tabLength + TabSpacing; + } + } + + UpdateScrollButtonVisibility (); + } + + /// + /// Computes the total width (or height, for vertical tabs) of all tab headers, accounting for shared + /// borders between adjacent tabs. + /// + /// The total header span in cells, or 0 if there are no tabs. + private int GetTotalHeaderSpan () + { + var span = 0; + + foreach (View tab in TabCollection) + { + int tabLength = (tab.Border.View as BorderView)?.EffectiveTabLength ?? 0; + + if (tabLength > 0) + { + // TabSpacing controls overlap (-1 = shared edge) or gap (0+ = space between) + span += tabLength + TabSpacing; + } + } + + // Remove the trailing spacing that was added after the last tab + return span > 0 ? span - TabSpacing : 0; + } + + /// + /// Updates and for all tabs + /// based on the current and . + /// + private void UpdateTabBorderThickness () + { + foreach (View tab in TabCollection) + { + if (tab.Border.View is BorderView bv) + { + bv.TabSide = _tabSide; + } + + tab.Border.Thickness = _tabSide switch + { + Side.Top => new Thickness (1, TabDepth, 1, 1), + Side.Bottom => new Thickness (1, 1, 1, TabDepth), + Side.Left => new Thickness (TabDepth, 1, 1, 1), + Side.Right => new Thickness (1, 1, TabDepth, 1), + _ => new Thickness (1, TabDepth, 1, 1) + }; + } + } + + #endregion + + #region Scrolling + + private bool? NavCommandHandler (ICommandContext? ctx) + { + if (ctx is null) + { + return false; + } + + return TabSide switch + { + Side.Top or Side.Bottom when ctx.Command == Command.Right => SelectNextTab (), + Side.Top or Side.Bottom when ctx.Command == Command.Left => SelectPreviousTab (), + + Side.Top when ctx.Command == Command.Down => FocusContent (), + Side.Top when ctx.Command == Command.Up => SelectPreviousTab (), + + Side.Bottom when ctx.Command == Command.Up => FocusContent (), + Side.Bottom when ctx.Command == Command.Down => SelectNextTab (), + + Side.Left or Side.Right when ctx.Command == Command.Down => SelectNextTab (), + Side.Left or Side.Right when ctx.Command == Command.Up => SelectPreviousTab (), + + Side.Left when ctx.Command == Command.Right => FocusContent (), + Side.Left when ctx.Command == Command.Left => SelectPreviousTab (), + + Side.Right when ctx.Command == Command.Left => FocusContent (), + Side.Right when ctx.Command == Command.Right => SelectNextTab (), + _ => false + }; + } + + private bool? SelectNextTab () + { + View? nextTab = TabCollection.SkipWhile (t => !t.HasFocus).Skip (1).FirstOrDefault () ?? TabCollection.FirstOrDefault (); + + return (nextTab?.Border.View as BorderView)?.TitleView?.SetFocus () ?? true; + } + + private bool? SelectPreviousTab () + { + View? previousTab = TabCollection.TakeWhile (t => !t.HasFocus).LastOrDefault () ?? TabCollection.LastOrDefault (); + + return (previousTab?.Border.View as BorderView)?.TitleView?.SetFocus () ?? true; + } + + private bool? FocusContent () + { + if (!(Value?.Border.View?.HasFocus ?? false)) + { + return false; + } + Value?.Border.View?.HasFocus = false; + Value?.RestoreFocus (); + + return true; + } + + /// + /// Adds scroll indicator buttons to a tab's border. The buttons are positioned at the + /// start and end of the separator line and occlude it when visible. + /// + /// The tab whose border receives the scroll buttons. + private void AddScrollButtonsToBorder (View tab) + { + ScrollButton scrollBack = new (); + + scrollBack.Accepting += (_, _) => { ScrollOffset--; }; + + ScrollButton scrollForward = new (); + + scrollForward.Accepting += (_, _) => { ScrollOffset++; }; + + PositionScrollButtons (scrollBack, scrollForward); + + tab.Border.View?.Add (scrollBack, scrollForward); + } + + /// + /// Positions scroll buttons within a tab's border based on . + /// + private void PositionScrollButtons (ScrollButton scrollBack, ScrollButton scrollForward) + { + bool isHorizontal = _tabSide is Side.Top or Side.Bottom; + + if (isHorizontal) + { + Pos separatorY = _tabSide == Side.Top ? TabDepth - 1 : Pos.AnchorEnd (TabDepth); + scrollBack.Orientation = Orientation.Horizontal; + scrollBack.Direction = NavigationDirection.Backward; + scrollBack.X = 0; + scrollBack.Y = separatorY; + scrollForward.Orientation = Orientation.Horizontal; + scrollForward.Direction = NavigationDirection.Forward; + scrollForward.X = Pos.AnchorEnd (); + scrollForward.Y = separatorY; + } + else + { + Pos separatorX = _tabSide == Side.Left ? TabDepth - 1 : Pos.AnchorEnd (TabDepth); + scrollBack.Orientation = Orientation.Vertical; + scrollBack.Direction = NavigationDirection.Backward; + scrollBack.X = separatorX; + scrollBack.Y = 0; + scrollForward.Orientation = Orientation.Vertical; + scrollForward.Direction = NavigationDirection.Forward; + scrollForward.X = separatorX; + scrollForward.Y = Pos.AnchorEnd (); + } + } + + /// + /// Repositions all scroll indicator buttons when or changes. + /// + private void UpdateScrollButtonPositions () + { + foreach (View tab in TabCollection) + { + if (tab.Border.View is null) + { + continue; + } + + ScrollButton? back = tab.Border.View.SubViews.OfType ().FirstOrDefault (b => b.Direction == NavigationDirection.Backward); + ScrollButton? forward = tab.Border.View.SubViews.OfType ().FirstOrDefault (b => b.Direction == NavigationDirection.Forward); + + if (back is { } && forward is { }) + { + PositionScrollButtons (back, forward); + } + } + } + + /// + /// Updates the visibility of all scroll indicator buttons across all tabs + /// based on the current and total header span. + /// + private void UpdateScrollButtonVisibility () + { + int totalSpan = GetTotalHeaderSpan (); + int visibleSize = _tabSide is Side.Top or Side.Bottom ? Viewport.Width : Viewport.Height; + bool canScrollBack = ScrollOffset > 0; + bool canScrollForward = totalSpan > ScrollOffset + visibleSize; + + foreach (View tab in TabCollection) + { + if (tab.Border.View is null) + { + continue; + } + + foreach (ScrollButton btn in tab.Border.View.SubViews.OfType ()) + { + btn.Visible = btn.Direction == NavigationDirection.Backward ? canScrollBack : canScrollForward; + } + } + } + + /// + /// Gets or sets the current scroll offset for the tab headers. Adjusting this value scrolls the tab headers + /// along the edge. + /// + /// + /// + /// The value is clamped to a valid range: negative values are clamped to 0, and values exceeding + /// the total header span are clamped so that the last tab remains visible. + /// + /// + /// Setting this property updates all tab values, updates scroll button + /// visibility, and triggers a layout pass. + /// + /// + public int ScrollOffset + { + get; + set + { + if (field == value) + { + return; + } + + if (value > GetTotalHeaderSpan ()) + { + // If value is greater than the maximum scroll offset, clamp it such that the last tab is flush with the edge of the viewport + View? last = TabCollection.LastOrDefault (); + + if (last is { }) + { + field = GetTotalHeaderSpan () - (((last.Border.View as BorderView)?.TabOffset ?? 0) - 2); + } + } + else if (value < 0) + { + // If value is less than 0, clamp it to 0 + field = 0; + } + else + { + field = value; + } + + UpdateTabOffsets (); + + //UpdateZOrder (); + SetNeedsLayout (); + } + } + + /// + protected override void OnViewportChanged (DrawEventArgs e) + { + base.OnViewportChanged (e); + + if (e.OldViewport.Size != Size.Empty) + { + return; + } + + // On initial layout, ensure the selected tab is visible (in case it's not the first tab or tabs exceed viewport size) + if (Value is null) + { + return; + } + + EnsureTabVisible (Value); + } + + /// + protected override void OnSubViewLayout (LayoutEventArgs args) + { + base.OnSubViewLayout (args); + + UpdateTabOffsets (); + } + + /// + /// Adjusts to ensure the specified tab's header is fully visible. + /// + /// The tab whose header should be scrolled into view. + private void EnsureTabVisible (View tab) + { + int visibleSize = _tabSide is Side.Top or Side.Bottom ? Viewport.Width : Viewport.Height; + + // Don't adjust scroll before layout has determined the viewport size + if (visibleSize <= 0) + { + return; + } + + // Compute the absolute (unscrolled) offset for this tab + int absOffset = TabCollection.TakeWhile (t => t != tab).Sum (t => ((t.Border.View as BorderView)?.EffectiveTabLength ?? 0) + TabSpacing); + + int tabLength = (tab.Border.View as BorderView)?.EffectiveTabLength ?? 0; + int tabEnd = absOffset + tabLength; + + if (absOffset < ScrollOffset) + { + ScrollOffset = absOffset; + } + else if (tabEnd > ScrollOffset + visibleSize) + { + ScrollOffset = tabEnd - visibleSize; + } + + UpdateTabOffsets (); + } + + #endregion + + #region IDesignable + + /// + public bool EnableForDesign () + { + // BUGBUG: AttributePicker sets SuperViewRendersLineCanvas on it's subviews. In order to + // BUGBUG: Prevent Tabs from being that superview, we must add via an intermediary View + View attributesTab = new () { Id = "attributesTab", Title = "_Attribute" }; + AttributePicker attributePicker = new () { Y = 1, BorderStyle = LineStyle.Single }; + attributesTab.Add (attributePicker); + + // Add an OptionSelector directly + OptionSelector lineStyleTab = new () { Id = "lineStyleTab", Title = "_Line Style" }; + + // Create an intermediary tab to hold multiple subviews + View settingsTab = new () { Id = "settingsTab", Title = "Tab _Settings" }; + OptionSelector tabSideSelector = new () { Y = 1, BorderStyle = LineStyle.Single, Title = "S_ide" }; + tabSideSelector.Value = (settingsTab.Border.View as BorderView)?.TabSide ?? Side.Top; + + NumericUpDown tabDepthNumericUpDown = new () + { + Y = Pos.Top (tabSideSelector), + X = Pos.Right (tabSideSelector) + 1, + Width = 10, + BorderStyle = LineStyle.Single, + Title = "_Depth", + Value = TabDepth + }; + + tabDepthNumericUpDown.ValueChanging += (_, e) => + { + if (e.NewValue < 0) + { + e.Handled = true; + + return; + } + + TabDepth = e.NewValue; + }; + + NumericUpDown tabLengthNumericUpDown = new () + { + Y = Pos.Top (tabDepthNumericUpDown), + X = Pos.Right (tabDepthNumericUpDown) + 1, + Width = 10, + BorderStyle = LineStyle.Single, + Title = "_Length", + Value = 0 //null//Value?.Border.TabLength ?? 0 + }; + + tabLengthNumericUpDown.ValueChanging += (_, e) => + { + if (Value is null) + { + e.Handled = true; + + return; + } + + if (e.NewValue <= 0) + { + foreach (View tab in TabCollection) + { + ((BorderView)tab.Border.View!).TabLength = null; + } + e.Handled = true; + } + else + { + foreach (View tab in TabCollection) + { + ((BorderView)tab.Border.View!).TabLength = e.NewValue; + } + } + }; + + ViewportChanged += (_, _) => + { + // only the first time + if (tabLengthNumericUpDown.Value > 0 && Value?.Border.View is BorderView { TabLength: { } } bv) + { + tabLengthNumericUpDown.Value = bv.TabLength ?? 0; + } + }; + + NumericUpDown tabSpacingNumericUpDown = new () + { + Y = Pos.Top (tabLengthNumericUpDown), + X = Pos.Right (tabLengthNumericUpDown) + 1, + Width = 10, + BorderStyle = LineStyle.Single, + Title = "S_pacing", + Value = TabSpacing + }; + + tabSpacingNumericUpDown.ValueChanging += (_, e) => { TabSpacing = e.NewValue; }; + + NumericUpDown scrollOffsetNumericUpDown = new () + { + Y = Pos.Bottom (tabSideSelector), + Width = 20, + BorderStyle = LineStyle.Single, + Title = "Scroll _Offset", + Value = ScrollOffset + }; + + scrollOffsetNumericUpDown.ValueChanging += (_, e) => { ScrollOffset = e.NewValue; }; + + settingsTab.Add (tabSideSelector, tabDepthNumericUpDown, tabLengthNumericUpDown, tabSpacingNumericUpDown, scrollOffsetNumericUpDown); + + View addRemoveTab = new () { Id = "addRemoveTab", Title = "Add_/Remove" }; + + // Tab list showing all tabs in logical order + ObservableCollection tabListSource = new (TabCollection.Select (t => t.Title)); + + ListView tabListView = new () { Width = Dim.Auto (), Height = Dim.Fill (), BorderStyle = LineStyle.Single, Title = "Ta_bs" }; + tabListView.SetSource (tabListSource); + + // Title input for new tabs + Label titleLabel = new () { X = Pos.Right (tabListView) + 1, Text = "Title:" }; + + TextField titleTextField = new () { X = Pos.Right (titleLabel) + 1, Y = Pos.Top (titleLabel), Text = "New Tab" }; + + var setTitleButton = new Button { X = Pos.AnchorEnd (), Y = Pos.Top (titleTextField), Title = "Set _Title" }; + + setTitleButton.Accepted += (_, _) => + { + int? selectedIndex = tabListView.SelectedItem; + + if (selectedIndex is null) + { + return; + } + View tab = TabCollection.ElementAt (selectedIndex.Value); + tab.Title = titleTextField.Text; + RefreshList (); + }; + + titleTextField.Width = Dim.Fill (setTitleButton) - 1; + + // Add Before button + Button addBeforeButton = new () { X = Pos.Right (tabListView) + 1, Y = Pos.Bottom (titleTextField), Text = "Add _Before" }; + + addBeforeButton.Accepted += (_, _) => + { + string title = titleTextField.Text; + View newTab = new () { Title = title }; + int selectedIndex = tabListView.SelectedItem ?? 0; + InsertTab (selectedIndex, newTab); + RefreshList (); + Value = addRemoveTab; + }; + + // Add After button + Button addAfterButton = new () { X = Pos.Right (addBeforeButton) + 1, Y = Pos.Top (addBeforeButton), Text = "Add _After" }; + + addAfterButton.Accepted += (_, _) => + { + string title = titleTextField.Text; + View newTab = new () { Title = title }; + int selectedIndex = (tabListView.SelectedItem ?? 0) + 1; + InsertTab (selectedIndex, newTab); + RefreshList (); + Value = addRemoveTab; + }; + + // Remove button + Button removeButton = new () { X = Pos.Right (addAfterButton) + 1, Y = Pos.Top (addAfterButton), Text = "_Remove" }; + + removeButton.Accepted += (_, _) => + { + int? selectedIndex = tabListView.SelectedItem; + + if (selectedIndex is null) + { + return; + } + + List tabs = TabCollection.ToList (); + + if (selectedIndex.Value >= tabs.Count) + { + return; + } + Remove (tabs [selectedIndex.Value]); + tabs [selectedIndex.Value].Dispose (); + RefreshList (); + Value = addRemoveTab; + }; + + addRemoveTab.Add (tabListView, titleLabel, titleTextField, setTitleButton, addBeforeButton, addAfterButton, removeButton); + + // Refresh the list whenever Value changes (tab added/removed/selected) + ValueChanged += (_, _) => RefreshList (); + + attributePicker.ValueChanged += (_, e) => + { + if (e.NewValue is { }) + { + SetScheme (GetScheme () with + { + Normal = new Attribute (e.NewValue.Value.Foreground, e.NewValue.Value.Background), + Focus = new Attribute (e.NewValue.Value.Foreground, e.NewValue.Value.Background) + }); + } + }; + + lineStyleTab.ValueChanged += (_, e) => + { + if (e.Value is { }) + { + TabLineStyle = e.Value.Value; + } + }; + + tabSideSelector.ValueChanged += (_, e) => + { + if (e.Value is { }) + { + TabSide = e.Value.Value; + } + }; + + Add (attributesTab, lineStyleTab, settingsTab, addRemoveTab); + + return true; + + // Helper to refresh the list from current TabCollection + void RefreshList () + { + tabListSource.Clear (); + + foreach (View t in TabCollection) + { + tabListSource.Add (t.Title); + } + } + } + + #endregion + + private bool _disposing; + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + _disposing = true; + } + + base.Dispose (disposing); + } +} diff --git a/Terminal.sln b/Terminal.sln index 30911810be..b4fa8dd844 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -12,6 +12,9 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example", "Examples\Example\Example.csproj", "{B0A602CD-E176-449D-8663-64238D54F857}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E143FB1F-0B88-48CB-9086-72CDCECFCD22}" + ProjectSection(SolutionItems) = preProject + docfx\docs\borders.md = docfx\docs\borders.md + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkitExample", "Examples\CommunityToolkitExample\CommunityToolkitExample.csproj", "{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}" EndProject diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 4e9a80da34..ddb5e2e6b8 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -545,8 +545,12 @@ True True True + DisabledByUser + True + True True True + True True True True diff --git a/Tests/StressTests/ScenariosStressTests.cs b/Tests/StressTests/ScenariosStressTests.cs index 1c6d7ba097..5ca64300ca 100644 --- a/Tests/StressTests/ScenariosStressTests.cs +++ b/Tests/StressTests/ScenariosStressTests.cs @@ -29,9 +29,9 @@ public ScenariosStressTests (ITestOutputHelper output) public void All_Scenarios_Benchmark (Type scenarioType) { Assert.Null (_timeoutLock); - _timeoutLock = new (); + _timeoutLock = new object (); - ConfigurationManager.Disable(true); + ConfigurationManager.Disable (true); // If a previous test failed, this will ensure that the Application is in a clean state Application.ResetState (true); @@ -51,7 +51,7 @@ public void All_Scenarios_Benchmark (Type scenarioType) var laidOutCount = 0; _output.WriteLine ($"Running Scenario '{scenarioType}'"); - Scenario scenario = (Scenario)Activator.CreateInstance (scenarioType)!; + var scenario = (Scenario)Activator.CreateInstance (scenarioType)!; string scenarioName = scenario.GetName (); Stopwatch? stopwatch = null; @@ -92,30 +92,30 @@ public void All_Scenarios_Benchmark (Type scenarioType) void OnApplicationInstanceInitialized (object? s, EventArgs a) { app = a.Value; - + lock (_timeoutLock) { timeout = app.AddTimeout (TimeSpan.FromMilliseconds (abortTime), ForceCloseCallback); } app.Iteration += OnApplicationOnIteration; - + if (app.Driver is { }) { app.Driver.ClearedContents += OnClearedContents; } - + app.SessionBegun += OnApplicationSessionBegun; stopwatch = Stopwatch.StartNew (); - _output.WriteLine ($"Application instance initialized"); + _output.WriteLine ("Application instance initialized"); } - void OnClearedContents (object? sender, EventArgs args) { clearedContentCount++; } + void OnClearedContents (object? sender, EventArgs args) => clearedContentCount++; void OnApplicationInstanceDisposed (object? s, EventArgs a) { - if (a.Value is null || app is null) + if (app is null) { return; } @@ -124,50 +124,51 @@ void OnApplicationInstanceDisposed (object? s, EventArgs a) { app.Driver.ClearedContents -= OnClearedContents; } - + app.SessionBegun -= OnApplicationSessionBegun; app.Iteration -= OnApplicationOnIteration; - - if (stopwatch is { }) - { - stopwatch.Stop (); - } - - _output.WriteLine ($"Application instance disposed"); + + stopwatch?.Stop (); + + _output.WriteLine ("Application instance disposed"); } void OnApplicationOnIteration (object? s, EventArgs a) { iterationCount++; - if (iterationCount > maxIterations) + if (iterationCount <= maxIterations) { - // Press QuitKey - _output.WriteLine ("Attempting to quit scenario with RequestStop"); - app?.RequestStop (); + return; } + + // Press QuitKey + _output.WriteLine ("Attempting to quit scenario with RequestStop"); + app?.RequestStop (); } void OnApplicationSessionBegun (object? sender, SessionTokenEventArgs e) { + if (app?.TopRunnableView is { }) + { + SubscribeAllSubViews (app.TopRunnableView); + } + + return; + // Get a list of all subviews under Application.TopRunnable (and their subviews, etc.) // and subscribe to their DrawComplete event void SubscribeAllSubViews (View view) { - view.DrawComplete += (s, a) => drawCompleteCount++; - view.SubViewsLaidOut += (s, a) => laidOutCount++; - view.SuperViewChanged += (s, a) => addedCount++; + view.DrawComplete += (_, _) => drawCompleteCount++; + view.SubViewsLaidOut += (_, _) => laidOutCount++; + view.SuperViewChanged += (_, _) => addedCount++; foreach (View subview in view.SubViews) { SubscribeAllSubViews (subview); } } - - if (app?.TopRunnableView is { }) - { - SubscribeAllSubViews (app.TopRunnableView); - } } // If the scenario doesn't close within the abort time, this will force it to quit @@ -181,8 +182,15 @@ bool ForceCloseCallback () } } - _output.WriteLine ( - $"'{scenarioName}' failed to Quit with {Application.GetDefaultKey (Command.Quit)} after {abortTime}ms and {iterationCount} iterations. Force quit."); + _output.WriteLine ($"'{ + scenarioName + }' failed to Quit with { + Application.GetDefaultKey (Command.Quit) + } after { + abortTime + }ms and { + iterationCount + } iterations. Force quit."); app?.RequestStop (); @@ -191,8 +199,7 @@ bool ForceCloseCallback () } public static IEnumerable AllScenarioTypes => - typeof (Scenario).Assembly - .GetTypes () - .Where (type => type.IsClass && !type.IsAbstract && type.IsSubclassOf (typeof (Scenario))) + typeof (Scenario).Assembly.GetTypes () + .Where (type => type is { IsClass: true, IsAbstract: false } && type.IsSubclassOf (typeof (Scenario))) .Select (type => new object [] { type }); } diff --git a/Tests/UnitTestsParallelizable/Drawing/DrawContextTests.cs b/Tests/UnitTestsParallelizable/Drawing/DrawContextTests.cs deleted file mode 100644 index d33aa0cd3a..0000000000 --- a/Tests/UnitTestsParallelizable/Drawing/DrawContextTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace DrawingTests; - -public class DrawContextTests -{ - [Fact (Skip = "Region Union is broken")] - public void AddDrawnRectangle_Unions () - { - DrawContext drawContext = new DrawContext (); - - drawContext.AddDrawnRectangle (new (0, 0, 1, 1)); - drawContext.AddDrawnRectangle (new (1, 0, 1, 1)); - - Assert.Equal (new Rectangle (0, 0, 2, 1), drawContext.GetDrawnRegion ().GetBounds ()); - Assert.Equal (2, drawContext.GetDrawnRegion ().GetRectangles ().Length); - - drawContext.AddDrawnRectangle (new (0, 0, 4, 1)); - Assert.Equal (new Rectangle (0, 1, 4, 1), drawContext.GetDrawnRegion ().GetBounds ()); - Assert.Single (drawContext.GetDrawnRegion ().GetRectangles ()); - } -} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs b/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs index e3c035d7b9..602685a8d5 100644 --- a/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs +++ b/Tests/UnitTestsParallelizable/Drawing/Region/RegionClassTests.cs @@ -920,12 +920,11 @@ public void Intersect_DeferredNormalization_PreservesSegments () Rectangle [] result = region.GetRectangles (); - // Original & Updated (with normalization disabled) behavior: - // Produces [(0,0,1,1), (1,0,1,2), (2,0,0,1)] - Assert.Equal (3, result.Length); + // The clip produces (0,0,1,1) and (1,0,1,2). The third rect from the Union, + // (2,0,1,1), clips to (2,0,0,1) — zero width — which is correctly excluded. + Assert.Equal (2, result.Length); Assert.Contains (new Rectangle (0, 0, 1, 1), result); Assert.Contains (new Rectangle (1, 0, 1, 2), result); - Assert.Contains (new Rectangle (2, 0, 0, 1), result); } [Fact] diff --git a/Tests/UnitTestsParallelizable/Drawing/Region/ZeroAreaRectangleTests.cs b/Tests/UnitTestsParallelizable/Drawing/Region/ZeroAreaRectangleTests.cs new file mode 100644 index 0000000000..c113315a76 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drawing/Region/ZeroAreaRectangleTests.cs @@ -0,0 +1,205 @@ +// Copilot + +namespace DrawingTests.RegionTests; + +/// +/// Tests that verify correct handling of zero-width and zero-height rectangles +/// in operations. These degenerate rectangles represent no area +/// and must not affect region computations. +/// +public class ZeroAreaRectangleTests +{ + [Fact] + public void MergeRectangles_ZeroWidthRect_IsIgnored () + { + /* + INPUT: Three valid rectangles plus one zero-width rectangle. + The zero-width rect {0,0,0,2} has Left == Right == 0, so it covers no area. + + x=0 1 2 3 + y=0 A B + y=1 A B C C + + Rectangles: + Zero: (0,0,0,2) // zero width — should be ignored + A: (0,0,1,2) + B: (1,0,1,2) + C: (2,1,2,1) + + EXPECTED: The zero-width rect should not affect the merge. + Without it, the result should be: + (0,0,1,2) (1,0,1,2) (2,1,2,1) + */ + + List rectangles = + [ + new (0, 0, 0, 2), // zero width — degenerate + new (0, 0, 1, 2), // A + new (1, 0, 1, 2), // B + new (2, 1, 2, 1) // C + ]; + + List merged = Region.MergeRectangles (rectangles, false); + + // The zero-width rect should have no effect. C should remain at Y=1, Height=1. + Assert.Equal (3, merged.Count); + Assert.Contains (new Rectangle (0, 0, 1, 2), merged); // A + Assert.Contains (new Rectangle (1, 0, 1, 2), merged); // B + Assert.Contains (new Rectangle (2, 1, 2, 1), merged); // C + } + + [Fact] + public void MergeRectangles_ZeroHeightRect_IsIgnored () + { + /* + INPUT: Two non-adjacent rectangles bridged by a zero-height rectangle. + The zero-height rect {1,0,2,0} has Top == Bottom == 0, so it covers no area. + + x=0 1 2 + y=0 A B + y=1 A B + + Rectangles: + A: (0,0,1,2) + Zero: (1,0,2,0) // zero height — should be ignored + B: (2,0,1,2) + + EXPECTED: The zero-height rect should not appear in the output. + Without it, the result should be just A and B. + */ + + List rectangles = + [ + new (0, 0, 1, 2), // A + new (1, 0, 2, 0), // zero height — degenerate + new (2, 0, 1, 2) // B + ]; + + List merged = Region.MergeRectangles (rectangles, false); + + // Zero-height rect should produce no output rect. + Assert.Equal (2, merged.Count); + Assert.Contains (new Rectangle (0, 0, 1, 2), merged); // A + Assert.Contains (new Rectangle (2, 0, 1, 2), merged); // B + } + + [Fact] + public void Intersect_ProducingZeroWidthRect_ExcludesItFromResult () + { + /* + Region has a rect at (-1,0,1,2) — one column to the left of the origin. + Intersecting with (0,0,4,2) clips it to {0,0,0,2} — zero width. + This zero-width result should NOT be kept in the region. + */ + + Region region = new (new Rectangle (-1, 0, 1, 2)); + region.Combine (new Rectangle (0, 0, 4, 2), RegionOp.Intersect); + + Rectangle [] rects = region.GetRectangles (); + Assert.Empty (rects); + } + + [Fact] + public void Intersect_ProducingZeroHeightRect_ExcludesItFromResult () + { + /* + Region has a rect at (0,-1,2,1) — one row above the origin. + Intersecting with (0,0,4,4) clips it to {0,0,2,0} — zero height. + This zero-height result should NOT be kept in the region. + */ + + Region region = new (new Rectangle (0, -1, 2, 1)); + region.Combine (new Rectangle (0, 0, 4, 4), RegionOp.Intersect); + + Rectangle [] rects = region.GetRectangles (); + Assert.Empty (rects); + } + + [Fact] + public void Intersect_MixedResults_OnlyKeepsPositiveAreaRects () + { + /* + Region has two rects: one that will clip to zero-width, + and one that will produce a valid intersection. + + Rect A: (-1,0,1,2) — entirely left of the clip area + Rect B: (1,0,2,2) — fully inside the clip area + + Clip region: (0,0,4,2) + + After intersect: + A clips to (0,0,0,2) — zero width, should be excluded + B clips to (1,0,2,2) — valid, should be kept + */ + + Region region = new (new Rectangle (-1, 0, 1, 2)); + region.Combine (new Rectangle (1, 0, 2, 2), RegionOp.Union); + + region.Combine (new Rectangle (0, 0, 4, 2), RegionOp.Intersect); + + Rectangle [] rects = region.GetRectangles (); + Assert.Single (rects); + Assert.Equal (new Rectangle (1, 0, 2, 2), rects [0]); + } + + [Fact] + public void Union_WithZeroWidthRectFromIntersect_DoesNotCorruptResult () + { + /* + This reproduces the exact bug observed during debugging of + View.Drawing.AddDrawnRegionForAdornment. + + A TabView subview with TabOffset = -1 causes its Border LineCanvas + to render at X = -1. The raw line canvas region (lastLineCanvasRegion) + contains a rect at {-1,0,1,2}. + + When intersected with the adornment frame {0,0,4,2}, this clips to + {0,0,0,2} — a zero-width rect that remains in _rectangles because + Rectangle.IsEmpty returns false for it. + + This zero-width rect then poisons the Union sweep-line merge, + causing rect {2,1,2,1} to inflate to {2,0,2,2}. + + Step 1 — Build the raw line canvas region: + (-1,0,1,2) (0,0,1,1) (1,0,1,2) (2,1,2,1) + + Step 2 — Intersect with adornment frame (0,0,4,2): + {-1,0,1,2} clips to {0,0,0,2} (zero-width, kept by buggy IsEmpty check) + {0,0,1,1} {1,0,1,2} {2,1,2,1} unchanged + + Step 3 — Build exclusion from LastDrawnRegion: + (0,0,1,2) (1,0,1,2) + + Step 4 — Union lineRegion into exclusion: + exclusion[2] must be (2,1,2,1), NOT (2,0,2,2). + */ + + // Step 1: Build lineRegion matching the raw line canvas output + Region lineRegion = new (new Rectangle (-1, 0, 1, 2)); + lineRegion.Combine (new Rectangle (0, 0, 1, 1), RegionOp.Union); + lineRegion.Combine (new Rectangle (1, 0, 1, 2), RegionOp.Union); + lineRegion.Combine (new Rectangle (2, 1, 2, 1), RegionOp.Union); + + // Step 2: Intersect with adornment frame — this creates the zero-width rect + lineRegion.Combine (new Rectangle (0, 0, 4, 2), RegionOp.Intersect); + + // Step 3: Build exclusion region + Region exclusion = new (); + exclusion.Combine (new Rectangle (0, 0, 1, 2), RegionOp.Union); + exclusion.Combine (new Rectangle (1, 0, 1, 2), RegionOp.Union); + + // Step 4: Union lineRegion into exclusion — this is where the bug manifests + exclusion.Combine (lineRegion, RegionOp.Union); + + // The region should cover: + // x=0..1, y=0..1 (columns 0-1, full height) + // x=2..3, y=1 (columns 2-3, only row 1) + // It must NOT cover x=2..3, y=0. + Assert.False (exclusion.Contains (2, 0), "Cell (2,0) should NOT be in the exclusion region."); + Assert.False (exclusion.Contains (3, 0), "Cell (3,0) should NOT be in the exclusion region."); + Assert.True (exclusion.Contains (2, 1), "Cell (2,1) should be in the exclusion region."); + Assert.True (exclusion.Contains (3, 1), "Cell (3,1) should be in the exclusion region."); + Assert.True (exclusion.Contains (0, 0), "Cell (0,0) should be in the exclusion region."); + Assert.True (exclusion.Contains (1, 0), "Cell (1,0) should be in the exclusion region."); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/AutoLineJoinTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/AutoLineJoinTests.cs new file mode 100644 index 0000000000..413b6ec6dd --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/AutoLineJoinTests.cs @@ -0,0 +1,1067 @@ +// Copilot - Opus 4.6 + +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Tests that auto-line joining works correctly when SubViews with borders +/// and = true are merged into +/// the SuperView's . +/// +public class AutoLineJoinTests (ITestOutputHelper output) : TestDriverBase +{ + #region Side-by-side (horizontal) peers + + /// + /// Two bordered SubViews side by side with overlapping borders. + /// The shared border column should auto-join with ┬ at top and ┴ at bottom. + /// + [Fact] + public void SideBySide_Overlapping_Peers_Join_Correctly () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├┬┤B├┐ + │ │ │ + └───┴───┘ + """, + output, + driver); + } + + /// + /// Three bordered SubViews side by side, each overlapping by 1 column. + /// Tests that auto-join works across a longer chain of peers. + /// + [Fact] + public void Three_SideBySide_Overlapping_Peers_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (15, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewC = new () + { + Title = "C", + X = Pos.Right (viewB) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB, viewC); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├┬┤B├┬┤C├┐ + │ │ │ │ + └───┴───┴───┘ + """, + output, + driver); + } + + /// + /// Two bordered SubViews side by side without overlap (gap of 0, adjacent). + /// Borders should NOT join since they don't share columns. + /// + [Fact] + public void SideBySide_Adjacent_No_Overlap_No_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + X = Pos.Right (viewA), + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├┐┌┤B├┐ + │ ││ │ + └───┘└───┘ + """, + output, + driver); + } + + #endregion Side-by-side (horizontal) peers + + #region Stacked (vertical) peers + + /// + /// Two bordered SubViews stacked vertically with overlapping borders. + /// The shared border row should auto-join with ├ on left and ┤ on right. + /// + [Fact] + public void Stacked_Overlapping_Peers_Join_Correctly () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (7, 6); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 7, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + Y = Pos.Bottom (viewA) - 1, + Width = 7, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├──┐ + │ │ + ├┼B┼──┤ + │ │ + └─────┘ + """, + output, + driver); + } + + /// + /// Two bordered SubViews stacked vertically with no overlap (adjacent). + /// Borders should not join. + /// + [Fact] + public void Stacked_Adjacent_No_Overlap_No_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (7, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 7, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + Y = Pos.Bottom (viewA), + Width = 7, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├──┐ + │ │ + └─────┘ + ┌┤B├──┐ + │ │ + └─────┘ + """, + output, + driver); + } + + #endregion Stacked (vertical) peers + + #region Grid-like arrangements + + /// + /// Four bordered SubViews in a 2×2 grid, all overlapping at boundaries. + /// Tests that corners auto-join at the center intersection (┼). + /// + [Fact] + public void Grid_2x2_Overlapping_Peers_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (11, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View topLeft = new () { Width = 6, Height = 4, BorderStyle = LineStyle.Single, SuperViewRendersLineCanvas = true }; + + View topRight = new () + { + X = Pos.Right (topLeft) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View bottomLeft = new () + { + Y = Pos.Bottom (topLeft) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View bottomRight = new () + { + X = Pos.Right (bottomLeft) - 1, + Y = Pos.Bottom (topRight) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (topLeft, topRight, bottomLeft, bottomRight); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Center intersection should be ┼, edges should have ┬, ┴, ├, ┤ + DriverAssert.AssertDriverContentsAre (""" + ┌────┬────┐ + │ │ │ + │ │ │ + ├────┼────┤ + │ │ │ + │ │ │ + └────┴────┘ + """, + output, + driver); + } + + #endregion Grid-like arrangements + + #region SubView with SuperView border (nested hierarchy) + + /// + /// A SuperView has a border, and a SubView with SuperViewRendersLineCanvas = true + /// has a border that shares a side with the SuperView's border. + /// The shared side should auto-join. + /// + [Fact] + public void SubView_Border_Joins_SuperView_Border_Top () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 5); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + superView.BorderStyle = LineStyle.Single; + + View subView = new () + { + X = 1, + Y = -1, + Width = 6, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (subView); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // SubView's top border line should join with SuperView's top border + // ┬ where subview's sides meet SuperView's top line + DriverAssert.AssertDriverContentsAre (""" + ┌─┬────┬───┐ + │ │ │ │ + │ └────┘ │ + │ │ + └──────────┘ + """, + output, + driver); + } + + /// + /// A SubView touches the left border of the SuperView. + /// + [Fact] + public void SubView_Border_Joins_SuperView_Border_Left () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (10, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + superView.BorderStyle = LineStyle.Single; + + View subView = new () + { + X = -1, + Y = 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (subView); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // SubView's left side should join with SuperView's left border + DriverAssert.AssertDriverContentsAre (""" + ┌────────┐ + │ │ + ├───┐ │ + │ │ │ + ├───┘ │ + │ │ + └────────┘ + """, + output, + driver); + } + + #endregion SubView with SuperView border (nested hierarchy) + + #region Mixed SuperViewRendersLineCanvas true/false + + /// + /// Two SubViews side by side: one with SuperViewRendersLineCanvas = true, + /// the other with false. Only the one with true should participate in auto-join. + /// + [Fact] + public void Mixed_SuperViewRendersLineCanvas_Only_True_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () { Width = 5, Height = 3, BorderStyle = LineStyle.Single, SuperViewRendersLineCanvas = true }; + + View viewB = new () + { + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = false // Not merged into SuperView + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // viewA's border is merged into SuperView's LineCanvas. + // viewB renders its own border independently. + // Since they overlap at column 4, the rendering may vary but both should show borders. + // viewB (rendered independently) draws on top of viewA's merged lines. + // The key assertion: viewA's border should be rendered (not lost). + var actual = driver.ToString (); + Assert.Contains ("┌", actual); + Assert.Contains ("└", actual); + } + + #endregion Mixed SuperViewRendersLineCanvas true/false + + #region Line SubView joins + + /// + /// A Line view with SuperViewRendersLineCanvas = true inside a bordered SuperView + /// should auto-join at intersections with the border. + /// + [Fact] + public void Line_Inside_BorderedSuperView_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (10, 5); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 10; + superView.Height = 5; + superView.BorderStyle = LineStyle.Single; + + // Horizontal line across the content area + Line hLine = new () + { + X = -1, + Y = 1, + Width = Dim.Fill () + 1, + Height = 1, + Orientation = Orientation.Horizontal, + Style = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (hLine); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Line should auto-join with left and right borders: ├ and ┤ + DriverAssert.AssertDriverContentsAre (""" + ┌────────┐ + │ │ + ├────────┤ + │ │ + └────────┘ + """, + output, + driver); + } + + /// + /// A vertical Line inside a bordered SuperView should produce ┬ at the top and ┴ at the bottom. + /// + [Fact] + public void VerticalLine_Inside_BorderedSuperView_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (7, 3); + + using View superView = new (); + superView.Driver = driver; + superView.BorderStyle = LineStyle.Single; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + Line vLine = new () + { + X = 2, + Y = -1, + Width = 1, + Height = Dim.Fill () + 1, + Orientation = Orientation.Vertical, + Style = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (vLine); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Vertical line should auto-join with top and bottom borders + DriverAssert.AssertDriverContentsAre (""" + ┌──┬──┐ + │ │ │ + └──┴──┘ + """, + output, + driver); + } + + /// + /// Crossing horizontal and vertical Lines inside a bordered SuperView + /// should produce ┼ at the intersection. + /// + [Fact] + public void CrossingLines_Inside_BorderedSuperView_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (7, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + superView.BorderStyle = LineStyle.Single; + + Line hLine = new () + { + X = -1, + Y = 2, + Width = Dim.Fill () + 1, + Height = 1, + Orientation = Orientation.Horizontal, + Style = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + Line vLine = new () + { + X = 2, + Y = -1, + Width = 1, + Height = Dim.Fill () + 1, + Orientation = Orientation.Vertical, + Style = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (hLine, vLine); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌──┬──┐ + │ │ │ + │ │ │ + ├──┼──┤ + │ │ │ + │ │ │ + └──┴──┘ + """, + output, + driver); + } + + #endregion Line SubView joins + + #region FrameView-like nesting + + /// + /// A FrameView (bordered container) with a bordered SubView + /// where the SubView uses SuperViewRendersLineCanvas = true. + /// Lines from the SubView should merge into the FrameView's LineCanvas. + /// + [Fact] + public void FrameView_With_Bordered_SubView_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (15, 7); + + using FrameView frameView = new (); + frameView.Driver = driver; + frameView.Title = "Frame"; + frameView.Width = 15; + frameView.Height = 7; + + View innerView = new () + { + X = 1, + Y = 0, + Width = 8, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + frameView.Add (innerView); + + frameView.Layout (); + frameView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // The inner view's border should be visible within the FrameView. + // Since SuperViewRendersLineCanvas = true, the inner view's border lines + // are merged with the FrameView's content. The FrameView renders its own + // border independently. + var actual = driver.ToString (); + + // FrameView's border should be present + Assert.Contains ("Frame", actual); + + // Inner view's border should be present + Assert.Contains ("┌", actual); + Assert.Contains ("└", actual); + } + + #endregion FrameView-like nesting + + #region Same-origin overlapping peers + + /// + /// Two SubViews at the same position but with different sizes, both with + /// SuperViewRendersLineCanvas = true. The larger view's border should show + /// through where the smaller doesn't overlap. + /// + [Fact] + public void Overlapping_Same_Origin_Different_Size () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (10, 6); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View large = new () { Width = 10, Height = 6, BorderStyle = LineStyle.Single, SuperViewRendersLineCanvas = true }; + + View small = new () + { + X = 1, + Y = 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (large, small); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Both borders should be visible; the small one is inset within the large one. + var actual = driver.ToString (); + Assert.Contains ("┌", actual); + Assert.Contains ("┘", actual); + } + + #endregion Same-origin overlapping peers + + #region Double-line style joins + + /// + /// Two SubViews side by side with Double line style should still auto-join. + /// + [Fact] + public void SideBySide_Double_Style_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () { Width = 5, Height = 3, BorderStyle = LineStyle.Double, SuperViewRendersLineCanvas = true }; + + View viewB = new () + { + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Double, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Double-style ╔═╦═╗ top, ╚═╩═╝ bottom, ║ sides + DriverAssert.AssertDriverContentsAre (""" + ╔═══╦═══╗ + ║ ║ ║ + ╚═══╩═══╝ + """, + output, + driver); + } + + #endregion Double-line style joins + + #region Rounded style joins + + /// + /// Two SubViews side by side with Rounded line style should auto-join. + /// + [Fact] + public void SideBySide_Rounded_Style_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () { Width = 5, Height = 3, BorderStyle = LineStyle.Rounded, SuperViewRendersLineCanvas = true }; + + View viewB = new () + { + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Rounded, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Rounded uses ╭╮╰╯ for corners but ┬┴ for T-junctions (they share the thin intersection) + var actual = driver.ToString (); + + // Should have rounded corners on the outer edges + Assert.Contains ("╭", actual); + Assert.Contains ("╯", actual); + Assert.Contains ("╰", actual); + Assert.Contains ("╮", actual); + } + + #endregion Rounded style joins + + #region Single view - no joining needed + + /// + /// A single bordered SubView with SuperViewRendersLineCanvas = true. + /// Its border should render normally (no auto-join needed, but merge shouldn't break it). + /// + [Fact] + public void Single_Bordered_SubView_Renders_Correctly () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (8, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "Hi", + Width = 8, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤Hi├──┐ + │ │ + └──────┘ + """, + output, + driver); + } + + #endregion Single view - no joining needed + + #region Multiple rows of side-by-side views + + /// + /// Two rows of side-by-side bordered SubViews, the rows also overlapping vertically. + /// Tests a complex grid-like pattern. + /// + [Fact] + public void Two_Rows_Of_SideBySide_Overlapping () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (11, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View tl = new () { Width = 6, Height = 4, BorderStyle = LineStyle.Single, SuperViewRendersLineCanvas = true }; + + View tr = new () + { + X = Pos.Right (tl) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View bl = new () + { + Y = Pos.Bottom (tl) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View br = new () + { + X = Pos.Right (bl) - 1, + Y = Pos.Bottom (tr) - 1, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (tl, tr, bl, br); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Same as Grid_2x2 but with explicit layout + DriverAssert.AssertDriverContentsAre (""" + ┌────┬────┐ + │ │ │ + │ │ │ + ├────┼────┤ + │ │ │ + │ │ │ + └────┴────┘ + """, + output, + driver); + } + + #endregion Multiple rows of side-by-side views + + #region Non-overlapping with SuperViewRendersLineCanvas + + /// + /// SubViews with SuperViewRendersLineCanvas = true but positioned far apart. + /// No auto-join should happen; each should render its own complete border. + /// + [Fact] + public void NonOverlapping_SubViews_No_Join () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (15, 5); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () { Width = 5, Height = 3, BorderStyle = LineStyle.Single, SuperViewRendersLineCanvas = true }; + + View viewB = new () + { + X = 8, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ┌───┐ ┌───┐ + │ │ │ │ + └───┘ └───┘ + """, + output, + driver); + } + + #endregion Non-overlapping with SuperViewRendersLineCanvas + + #region Heavy line style + + /// + /// Two SubViews side by side with Heavy line style should auto-join. + /// + [Fact] + public void SideBySide_Heavy_Style_Joins () + { + // Copilot + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () { Width = 5, Height = 3, BorderStyle = LineStyle.Heavy, SuperViewRendersLineCanvas = true }; + + View viewB = new () + { + X = Pos.Right (viewA) - 1, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Heavy, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Heavy uses ┏┓┗┛┃━ and ┳┻ for joins + DriverAssert.AssertDriverContentsAre (""" + ┏━━━┳━━━┓ + ┃ ┃ ┃ + ┗━━━┻━━━┛ + """, + output, + driver); + } + + #endregion Heavy line style +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderDrawTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderDrawTests.cs index c933d60f0e..7c4c12e896 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderDrawTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderDrawTests.cs @@ -3,7 +3,7 @@ namespace ViewBaseTests.Adornments; // Claude - Opus 4.6 -public class BorderDrawTests (ITestOutputHelper output) +public class BorderDrawTests (ITestOutputHelper output) : TestDriverBase { [Fact] public void TransparentView_With_Border_Draws_Title () @@ -144,4 +144,46 @@ public void TransparentView_With_Border_And_Padding_NoPeers_Draws_Title () output, app.Driver); } + + [Fact] + public void AutoLineJoin_SideBySide_Overlapping_Peers_Join_Correctly () + { + IDriver driver = CreateTestDriver (); + driver.SetScreenSize (12, 4); + + using View superView = new () { Driver = driver }; + superView.Width = Dim.Fill (); + superView.Height = Dim.Fill (); + + View viewA = new () + { + Title = "A", + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + + View viewB = new () + { + Title = "B", + X = Pos.Right (viewA) - 1, // Cause overlap + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┤A├┬┤B├┐ + │ │ │ + └───┴───┘ + """, + output, + driver); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTests.cs index d967915b0b..902aaa7f0f 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderTests.cs @@ -22,7 +22,7 @@ public void View_Constructor_Defaults () view.Border.GetOrCreateView (); Assert.NotNull (view.Border.View); Assert.False (view.Border.View?.CanFocus); - Assert.Equal (TabBehavior.TabGroup, view.Border.View?.TabStop); + Assert.Equal (TabBehavior.TabStop, view.Border.View?.TabStop); Assert.Empty (view.Border.View?.KeyBindings.GetBindings ()!); Assert.Null (view.Border.View?.ShadowStyle); } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderViewTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderViewTests.cs new file mode 100644 index 0000000000..0d290e6b27 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/BorderViewTests.cs @@ -0,0 +1,2770 @@ +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Visual tests for tab header rendering via properties. +/// These replicate scenarios at the higher BorderView level, +/// covering all four values and important variations of offset and Title. +/// + +// Copilot +public class BorderViewTests (ITestOutputHelper output) : TestDriverBase +{ + [Fact] + public void Bottom_Focused_Depth1_WithTitle () + { + IDriver driver = CreateTestDriver (9, 4); + + View view = CreateTabView (driver, + 9, + 4, + Side.Bottom, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │Tab╭───╯ + """); + } + + [Fact] + public void Bottom_Focused_Depth2_WithTitle () + { + IDriver driver = CreateTestDriver (9, 5); + + View view = CreateTabView (driver, + 9, + 5, + Side.Bottom, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 2)); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │Tab╭───╯ + ╰───╯ + """); + } + + [Fact] + public void Bottom_Focused_Depth4_WithTitle () + { + IDriver driver = CreateTestDriver (9, 7); + + View view = CreateTabView (driver, + 9, + 7, + Side.Bottom, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 4)); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │ ╭───╯ + │Tab│ + │ │ + ╰───╯ + """); + } + + [Fact] + public void Bottom_Focused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Bottom, + 0, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │ ╭───╯ + │Tab│ + ╰───╯ + """); + } + + [Fact] + public void Bottom_Unfocused_Depth1_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 4); + + View view = CreateTabView (driver, + 9, + 4, + Side.Bottom, + 0, + null, + false, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │Tab╭───╯ + """); + } + + [Fact] + public void Bottom_Unfocused_Depth2_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 5); + + View view = CreateTabView (driver, + 9, + 5, + Side.Bottom, + 0, + null, + false, + "T_ab", + true, + new Thickness (1, 1, 1, 2)); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │Tab╭───╯ + ╰───╯ + """); + } + + [Fact] + public void Bottom_Unfocused_NegativeOffset_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Bottom, + -1, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + ┴──┬────╯ + Tab│ + ───╯ + """); + } + + [Fact] + public void Bottom_Unfocused_Offset0_NoTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Bottom, + 0, + null, + false, + null, + false); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + ├┬──────╯ + ││ + ╰╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Bottom — View 9×6, Thickness(1,1,1,3), borderBounds=(0,0,9,4) + // Content border: 9 wide, 4 tall. Tab protrudes below. + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Bottom_Unfocused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Bottom, + 0, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + ├───┬───╯ + │Tab│ + ╰───╯ + """); + } + + [Fact] + public void Bottom_Unfocused_Offset2_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Bottom, + 2, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + ╰─┬───┬─╯ + │Tab│ + ╰───╯ + """); + } + + // Copilot + [Fact] + public void Clearing_Tab_Flag_Hides_TitleView () + { + IDriver driver = CreateTestDriver (10, 6); + + View view = new () { Driver = driver, Width = 10, Height = 6, BorderStyle = LineStyle.Rounded }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Title = "Tab"; + view.Layout (); + + var bv = (BorderView)view.Border.View!; + Assert.NotNull (bv.TitleView); + + // Clear the Tab flag + view.Border.Settings = BorderSettings.Title; + view.Layout (); + + // TitleView should be hidden (Visible = false) + Assert.False (bv.TitleView!.Visible, "TitleView should be hidden when Tab flag is cleared"); + + view.Dispose (); + } + + [Fact] + public void Left_Focused_Depth1_WithTitle () + { + IDriver driver = CreateTestDriver (9, 9); + + View view = CreateTabView (driver, + 9, + 9, + Side.Left, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + // Depth=1: no cap line, no tab edges. Title at rows 1-3 (between top/bottom edges). + // (0,0) is excluded by AddTabSideContentBorder (tab starts at content border edge). + // After Trim(), leading space removed from row 0. + DrawAndAssert (view, + driver, + """ + ────────╮ + T │ + a │ + b │ + │ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Left_Focused_Depth2_WithTitle () + { + IDriver driver = CreateTestDriver (10, 9); + + View view = CreateTabView (driver, + 10, + 9, + Side.Left, + 0, + null, + true, + "T_ab", + true, + new Thickness (2, 1, 1, 1)); + + // Depth=2: cap at col 0, closing edge at col 1. Title on closing edge. + // (1,0) excluded by AddTabSideContentBorder → space at col 1 row 0. + // (1,4) has ╮ from header bottom edge + content border vertical auto-join. + DrawAndAssert (view, + driver, + """ + ╭────────╮ + │T │ + │a │ + │b │ + ╰╮ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Left_Focused_Depth4_WithTitle () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = CreateTabView (driver, + 12, + 9, + Side.Left, + 0, + null, + true, + "T_ab", + true, + new Thickness (4, 1, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭──────────╮ + │T │ + │a │ + │b │ + ╰──╮ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Left_Focused_Overflow_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Left, + 6, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + ╭─╯ │ + │T │ + │a ───────╯ + """); + } + + [Fact] + public void Left_Unfocused_Offset0_NoTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Left, + 0, + null, + false, + null, + false); + + DrawAndAssert (view, + driver, + """ + ╭─┬───────╮ + ╰─┤ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Left — View 11×9, Thickness(3,1,1,1), borderBounds=(2,0,9,9) + // Content border: 9 wide (cols 2–10), 9 tall. Tab protrudes left. + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Left_Unfocused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Left, + 0, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭─┬───────╮ + │T│ │ + │a│ │ + │b│ │ + ╰─┤ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Left_Unfocused_Offset2_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Left, + 2, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + ╭─┤ │ + │T│ │ + │a│ │ + │b│ │ + ╰─┤ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Right_Focused_Depth1_WithTitle () + { + IDriver driver = CreateTestDriver (9, 9); + + View view = CreateTabView (driver, + 9, + 9, + Side.Right, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + // Depth=1: no cap line, no tab edges. Title at rows 0-2. + // The top-right corner shows ─ (horizontal border continues; no vertical at the gap). + DrawAndAssert (view, + driver, + """ + ╭──────── + │ T + │ a + │ b + │ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Right_Focused_Depth2_WithTitle () + { + IDriver driver = CreateTestDriver (10, 9); + + View view = CreateTabView (driver, + 10, + 9, + Side.Right, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 2, 1)); + + // Depth=2: cap at col 9, closing edge at col 8. Title on closing edge. + // (8,0) excluded → space at col 8 row 0. (8,4) has ╭ from auto-join. + DrawAndAssert (view, + driver, + """ + ╭────────╮ + │ T│ + │ a│ + │ b│ + │ ╭╯ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Right_Focused_Depth4_WithTitle () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = CreateTabView (driver, + 12, + 9, + Side.Right, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 4, 1)); + + DrawAndAssert (view, + driver, + """ + ╭──────────╮ + │ T │ + │ a │ + │ b │ + │ ╭──╯ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Right_Focused_Overflow_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Right, + 6, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╰─╮ + │ T│ + ╰─────── a│ + """); + } + + [Fact] + public void Right_Unfocused_Offset0_NoTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Right, + 0, + null, + false, + null, + false); + + DrawAndAssert (view, + driver, + """ + ╭───────┬─╮ + │ ├─╯ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Right — View 11×9, Thickness(1,1,3,1), borderBounds=(0,0,9,9) + // Content border: 9 wide (cols 0–8), 9 tall. Tab protrudes right. + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Right_Unfocused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Right, + 0, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────┬─╮ + │ │T│ + │ │a│ + │ │b│ + │ ├─╯ + │ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Right_Unfocused_Offset2_WithTitle () + { + IDriver driver = CreateTestDriver (11, 9); + + View view = CreateTabView (driver, + 11, + 9, + Side.Right, + 2, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ ├─╮ + │ │T│ + │ │a│ + │ │b│ + │ ├─╯ + │ │ + ╰───────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Setup-trigger tests — verify configuration happens at property-change + // time, NOT deferred to Draw. + // ════════════════════════════════════════════════════════════════════ + + // Copilot + [Fact] + public void Settings_Tab_Creates_BorderView () + { + // Setting Border.Settings to include Tab should cause GetOrCreateView + View view = new () { Width = 10, Height = 6, BorderStyle = LineStyle.Rounded }; + + // Before setting Tab, View may or may not exist depending on LineStyle + // but TitleView should not exist + + if (view.Border.View is BorderView bv) + { + Assert.Null (bv.TitleView); + } + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + + // After setting Tab, the BorderView must exist + bv = (view.Border.View as BorderView)!; + Assert.NotNull (bv); + + view.Dispose (); + } + + // Copilot + [Fact] + public void Settings_Tab_Creates_TitleView_Before_Draw () + { + IDriver driver = CreateTestDriver (10, 6); + + View view = new () { Driver = driver, Width = 10, Height = 6, BorderStyle = LineStyle.Rounded }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Title = "Tab"; + + // Layout but do NOT draw + view.Layout (); + + var bv = (BorderView)view.Border.View!; + + // TitleView should already exist after layout, before any Draw call + Assert.NotNull (bv.TitleView); + + view.Dispose (); + } + + // Copilot + [Fact] + public void Settings_Tab_Sets_ViewportSettings_Transparent_Before_Draw () + { + IDriver driver = CreateTestDriver (10, 6); + + View view = new () { Driver = driver, Width = 10, Height = 6, BorderStyle = LineStyle.Rounded }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Title = "Tab"; + + // Layout but do NOT draw + view.Layout (); + + var bv = (BorderView)view.Border.View!; + + // ViewportSettings should already include Transparent before any Draw call + Assert.True (bv.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent), "Transparent should be set after Settings change, not deferred to Draw"); + + Assert.True (bv.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse), + "TransparentMouse should be set after Settings change, not deferred to Draw"); + + view.Dispose (); + } + + // Copilot + [Fact] + public void Settings_Tab_TitleView_Has_Correct_Frame_Before_Draw () + { + IDriver driver = CreateTestDriver (10, 7); + + View view = new () + { + Driver = driver, + Width = 10, + Height = 7, + CanFocus = true, + HasFocus = false, + BorderStyle = LineStyle.Rounded + }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + ((BorderView)view.Border.View!).TabOffset = 0; + view.Title = "T_ab"; + + // Layout but do NOT draw + view.Layout (); + + var bv = (BorderView)view.Border.View!; + View? ttv = bv.TitleView; + Assert.NotNull (ttv); + + // TitleView should have non-empty Frame set by layout, before Draw + Assert.NotEqual (Rectangle.Empty, ttv.Frame); + + // Width should match TabLength (auto-computed: "Tab".GetColumns() + 2 = 5) + Assert.Equal (5, ttv.Frame.Width); + + view.Dispose (); + } + + // Copilot + [Fact] + public void Settings_Tab_TitleView_Has_SuperViewRendersLineCanvas () + { + View view = new () { Width = 10, Height = 6, BorderStyle = LineStyle.Rounded }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Title = "Tab"; + + view.Layout (); + + var bv = (BorderView)view.Border.View!; + View? ttv = bv.TitleView; + Assert.NotNull (ttv); + Assert.True (ttv.SuperViewRendersLineCanvas, "TitleView must have SuperViewRendersLineCanvas = true for auto-join"); + + view.Dispose (); + } + + [Fact] + public void SuperView_Left_NegativeOffset2_WithTitle () // Copilot + { + //using (TestLogging.Verbose (output, TraceCategory.Draw)) + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Left, + -2, + false, + "T_ab", + true); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊│a ─────╮◊│ + │◊│b │◊│ + │◊╰─╮ │◊│ + │◊◊◊│ │◊│ + │◊◊◊│ │◊│ + │◊◊◊╰─────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + } + + [Fact] + public void SuperView_Top_Depth1_Focused () // Copilot + { + // Thickness(1,1,1,1) → depth=1. Subview 9×4. + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 6, + 9, + 4, + Side.Top, + 0, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + output.WriteLine (app.Driver!.ToString ()); + + // Per spec: Thickness.Top = 1, focused → title inline on content border, open gap + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊│Tab╭───╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_Depth2_Focused () // Copilot + { + // Thickness(1,2,1,1) → depth=2. Subview 9×5. + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 7, + 9, + 5, + Side.Top, + 0, + true, + "T_ab", + true, + new Thickness (1, 2, 1, 1)); + + output.WriteLine (app.Driver!.ToString ()); + + // Per spec: Thickness.Top = 2, focused → cap line + title on closing edge, open gap + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊╭───╮◊◊◊◊◊│ + │◊│Tab╰───╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_NegativeOffset1_Min () // Copilot + { + //using (TestLogging.Verbose (output, TraceCategory.Draw)) + { + View subview = new () { Driver = CreateTestDriver (4, 3), Height = 2, Width = 4 }; + subview.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + subview.Border.Thickness = new Thickness (0, 2, 0, 0); + subview.Border.LineStyle = LineStyle.Single; + subview.Title = "t"; + + subview.Driver.FillRect (subview.Driver.Screen, Glyphs.Diamond); + subview.Layout (); + + //subview.Draw (); + + //DriverAssert.AssertDriverContentsWithFrameAre (""" + // ┌─┐◊ + // │t└─ + // """, + // output, + // subview.Driver!); + + ((BorderView)subview.Border.View!).TabOffset = -1; + subview.Driver.ClearContents (); + subview.Driver.FillRect (subview.Driver.Screen, Glyphs.Diamond); + + subview.Layout (); + + //subview.Draw (); + + //DriverAssert.AssertDriverContentsWithFrameAre (""" + // ─┐◊◊ + // t└── + // """, + // output, + // subview.Driver!); + + View superView = new () { Driver = subview.Driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Diamond); + e.DrawContext?.AddDrawnRectangle (superView.Viewport); + }; + superView.Add (subview); + + superView.Layout (); + subview.Driver.ClearContents (); + var context = new DrawContext (); + superView.Draw (context); + + Assert.True (subview.Border.View!.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)); + Assert.True (subview.Border!.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)); + + DriverAssert.AssertDriverContentsWithFrameAre (""" + ─┐◊◊ + t└── + ◊◊◊◊ + """, + output, + subview.Driver!); + } + } + + [Fact] + public void SuperView_Top_NegativeOffset1_WithTitle () // Copilot + { + //using (TestLogging.Verbose (output, TraceCategory.Draw)) + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + -1, + false, + "T_ab", + true); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊───╮◊◊◊◊◊◊│ + │◊Tab│◊◊◊◊◊◊│ + │◊│ ╰────╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + } + + [Fact] + public void SuperView_Top_NegativeOffset1_WithTitle_X0 () // Copilot + { + //using (TestLogging.Verbose (output, TraceCategory.Draw)) + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + -1, + false, + "T_ab", + true); + + subview.X = 0; + subview.SuperView!.Layout (); + subview.SuperView!.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │───╮◊◊◊◊◊◊◊│ + │Tab│◊◊◊◊◊◊◊│ + ││ ╰────╮◊◊│ + ││ │◊◊│ + ││ │◊◊│ + │╰───────╯◊◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + } + + [Fact] + public void SuperView_Top_NegativeOffset2_WithTitle () // Copilot + { + //using (TestLogging.Verbose (output, TraceCategory.Draw)) + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + -2, + false, + "T_ab", + true); + + // Header at offset=-2: left edge and 'T' clipped. Visible: cap ──╮, title ab│. + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊──╮◊◊◊◊◊◊◊│ + │◊ab│◊◊◊◊◊◊◊│ + │◊│ ╰─────╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + } + + [Fact] + public void SuperView_Top_NegativeOffset2_WithTitle_With_Margin () // Copilot + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + -2, + false, + "T_ab", + true); + + // Bug #4853: cap-line extension bleeds into Margin when Margin has thickness. + // The diamond fill masks it here, but the issue is filed. + subview.Margin.Thickness = new Thickness (1, 0, 0, 0); + subview.SetNeedsLayout (); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊◊──╮◊◊◊◊◊◊│ + │◊◊ab│◊◊◊◊◊◊│ + │◊◊│ ╰────╮◊│ + │◊◊│ │◊│ + │◊◊│ │◊│ + │◊◊╰──────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_NegativeOffset5_FullyOffscreen () // Copilot + { + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + -5, + false, + "T_ab", + true); + + output.WriteLine (app.Driver!.ToString ()); + + // Header completely off-screen. Content border drawn normally. + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊┬───────╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_Offset0_WithTitle_Focused () // Copilot + { + // Window: screen 13×10, border=1 → viewport 11×8. + // Child at (1,1) is 9×6, Thickness(1,3,1,1), tab on Top, HasFocus=true. + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + 0, + true, + "T_ab", + true); + + output.WriteLine (app.Driver!.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊╭───╮◊◊◊◊◊│ + │◊│Tab│◊◊◊◊◊│ + │◊│ ╰───╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_Offset0_WithTitle_Unfocused () // Copilot + { + // Note: In Application context, the subview always gets focus (only focusable view), + // so this renders the same as focused. Unfocused rendering is tested in standalone tests. + (IApplication app, View subview) = CreateSuperViewWithTabChild (11, + 8, + 9, + 6, + Side.Top, + 0, + false, + "T_ab", + true); + + output.WriteLine (app.Driver!.ToString ()); + + DriverAssert.AssertDriverContentsAre (""" + ╭───────────╮ + │◊◊◊◊◊◊◊◊◊◊◊│ + │◊╭───╮◊◊◊◊◊│ + │◊│Tab│◊◊◊◊◊│ + │◊│ ╰───╮◊│ + │◊│ │◊│ + │◊│ │◊│ + │◊╰───────╯◊│ + │◊◊◊◊◊◊◊◊◊◊◊│ + ╰───────────╯ + """, + output, + app.Driver!); + + subview.Dispose (); + app.Dispose (); + } + + [Fact] + public void SuperView_Top_ThickBorder_Offset0_WithTitle () // Copilot + { + // Thick border: Thickness(3,3,3,3). Child 11×8. + (IApplication app, View subview) = CreateSuperViewWithTabChild (15, + 12, + 11, + 8, + Side.Top, + 0, + false, + "T_ab", + true, + new Thickness (3, 3, 3, 3)); + + output.WriteLine (app.Driver!.ToString ()); + + // This test documents current behavior. Expected string will be updated + // when edge-based positioning is implemented. + subview.Dispose (); + app.Dispose (); + } + + // Copilot + [Fact] + public void Thickness_Change_Updates_TitleView_Layout () + { + IDriver driver = CreateTestDriver (10, 7); + + View view = new () + { + Driver = driver, + Width = 10, + Height = 7, + CanFocus = true, + HasFocus = false, + BorderStyle = LineStyle.Rounded + }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Title = "Tab"; + + view.Layout (); + + var bv = (BorderView)view.Border.View!; + View? ttv = bv.TitleView; + Assert.NotNull (ttv); + int originalHeight = ttv.Frame.Height; + + // Change thickness — TitleView should get updated frame after re-layout + view.Border.Thickness = new Thickness (1, 2, 1, 1); + view.Layout (); + + int newHeight = ttv.Frame.Height; + Assert.NotEqual (originalHeight, newHeight); + + view.Dispose (); + } + + [Fact] + public void Top_Focused_Depth1_LongTitle () // Copilot + { + // Title wider than content area → tab header spans full width. + IDriver driver = CreateTestDriver (15, 4); + + View view = CreateTabView (driver, + 15, + 4, + Side.Top, + 0, + null, + true, + "Long Title", + true, + new Thickness (1, 1, 1, 1)); + + // Depth=1: title on content border line, tab header = 12 wide (10+2 borders). + // Focused → open gap. Title fills the header interior. + DrawAndAssert (view, + driver, + """ + │Long Title╭──╮ + │ │ + │ │ + ╰─────────────╯ + """); + } + + // ──── Thickness = 1 (depth 1) ──── + // 1-row tab: title inline on the content border line. + + [Fact] + public void Top_Focused_Depth1_WithTitle () + { + IDriver driver = CreateTestDriver (9, 4); + + View view = CreateTabView (driver, + 9, + 4, + Side.Top, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + DrawAndAssert (view, + driver, + """ + │Tab╭───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Depth2_With2LineTitle () + { + IDriver driver = CreateTestDriver (9, 5); + + View view = CreateTabView (driver, + 9, + 5, + Side.Top, + 0, + 4, + true, + "T_ab", + true, + new Thickness (1, 2, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭──╮ + │Ta╰────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + // ──── Thickness = 2 (depth 2) ──── + // 2-row tab: cap line + title on closing edge. No bottom line. + + [Fact] + public void Top_Focused_Depth2_WithTitle () + { + IDriver driver = CreateTestDriver (9, 5); + + View view = CreateTabView (driver, + 9, + 5, + Side.Top, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 2, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab╰───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Depth4_With2LineTitle () + { + IDriver driver = CreateTestDriver (9, 7); + + View view = CreateTabView (driver, + 9, + 7, + Side.Top, + 0, + null, + true, + "T_a\nb", + true, + new Thickness (1, 4, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭──╮ + │Ta│ + │b │ + │ ╰────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Thickness Variants — Depth equals thickness on the tab side. + // Depth > 3 adds extra padding rows between the title and the + // content-side border join. + // ════════════════════════════════════════════════════════════════════ + + // ──── Thickness = 4 (depth 4: cap + title + 1 padding + join) ──── + + [Fact] + public void Top_Focused_Depth4_WithTitle () + { + IDriver driver = CreateTestDriver (9, 7); + + View view = CreateTabView (driver, + 9, + 7, + Side.Top, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 4, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab│ + │ │ + │ ╰───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Depth5_With2LineTitle () + { + IDriver driver = CreateTestDriver (9, 7); + + View view = CreateTabView (driver, + 9, + 7, + Side.Top, + 0, + null, + true, + "T_a\nb", + true, + new Thickness (1, 5, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭──╮ + │Ta│ + │b │ + │ │ + │ ╰────╮ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Depth5_WithTitle () + { + IDriver driver = CreateTestDriver (9, 7); + + View view = CreateTabView (driver, + 9, + 7, + Side.Top, + 0, + null, + true, + "T_ab", + true, + new Thickness (1, 5, 1, 1)); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │ │ + │Tab│ + │ │ + │ ╰───╮ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 0, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab│ + │ ╰───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_Offset0_WithTitle_Thick_Border () // Copilot + { + IDriver driver = CreateTestDriver (); + + View view = CreateTabView (driver, + 17, + 12, + Side.Top, + 0, + null, + true, + "T_ab", + true); + + view.Border.Thickness = new Thickness (5, 5, 5, 5); + + // Edge-based: borderBounds=(0,4,17,8). Header depth=5 at offset=0. + // Focused → open gap (no separator line). Extra padding rows for depth > 3. + DrawAndAssert (view, + driver, + """ + ╭───╮ + │ │ + │Tab│ + │ │ + │ ╰───────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────╯ + """); + } + + [Fact] + public void Top_Focused_Offset2_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 2, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab│ + ╭─╯ ╰─╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_OverflowRight_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 5, + null, + true, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭─── + │Tab + ╭────╯ │ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Focused_TitleText_Uses_Normal_Attributes () + { + // When a View has focus, the tab title text ("Tab") should render + // with Focus attributes, and the hotkey character with HotFocus. + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (9, 6); + app.Driver!.Clipboard = new FakeClipboard (); + + Runnable runnable = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + View view = new () + { + X = 0, + Y = 0, + CanFocus = true, + Width = 9, + Height = 6, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + // Set a scheme with distinct attributes for each role so we can verify + Scheme scheme = new () + { + Normal = new Attribute (Color.White, Color.Black), + Focus = new Attribute (Color.BrightGreen, Color.DarkGray), + HotNormal = new Attribute (Color.BrightRed, Color.Black), + HotFocus = new Attribute (Color.BrightYellow, Color.DarkGray) + }; + view.SetScheme (scheme); + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + ((BorderView)view.Border.View!).TabOffset = 0; + + runnable.Add (view); + app.Begin (runnable); + + // Give focus to our view + view.SetFocus (); + Assert.True (view.HasFocus); + + app.LayoutAndDraw (); + + output.WriteLine (app.Driver!.ToString ()); + + // Attribute map: + // 0 = Normal (border lines when unfocused — but LineCanvas uses Normal) + // 1 = Focus (title text) + // 2 = HotFocus (hotkey char 'a') + Attribute normalAttribute = view.GetAttributeForRole (VisualRole.Normal); + Attribute hotNormalAttr = view.GetAttributeForRole (VisualRole.HotNormal); + Attribute focusAttr = view.GetAttributeForRole (VisualRole.Focus); + Attribute hotFocusAttr = view.GetAttributeForRole (VisualRole.HotFocus); + + // Row 1 is "│Tab│" — columns 1,2,3 are the title text "Tab" + // 'T' at [1,1] should be Focus, 'a' at [1,2] should be HotFocus, 'b' at [1,3] should be Focus + Cell [,] contents = app.Driver!.Contents!; + Attribute actualT = contents [1, 1].Attribute!.Value; + Attribute actualA = contents [1, 2].Attribute!.Value; + Attribute actualB = contents [1, 3].Attribute!.Value; + + output.WriteLine ($"Expected Focus: {focusAttr}"); + output.WriteLine ($"Expected HotFocus: {hotFocusAttr}"); + output.WriteLine ($"Actual 'T' [1,1]: {actualT}"); + output.WriteLine ($"Actual 'a' [1,2]: {actualA}"); + output.WriteLine ($"Actual 'b' [1,3]: {actualB}"); + + Assert.Equal (normalAttribute, actualT); + Assert.Equal (hotNormalAttr, actualA); + Assert.Equal (normalAttribute, actualB); + + view.Dispose (); + app.Dispose (); + } + + [Fact] + public void Top_Focused_TitleView_Uses_Focused_Attributes () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (9, 6); + app.Driver!.Clipboard = new FakeClipboard (); + + Runnable runnable = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + View view = new () + { + X = 0, + Y = 0, + CanFocus = true, + Width = 9, + Height = 6, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + // Set a scheme with distinct attributes for each role so we can verify + Scheme scheme = new () + { + Normal = new Attribute (Color.White, Color.Black), + Focus = new Attribute (Color.BrightGreen, Color.DarkGray), + HotNormal = new Attribute (Color.BrightRed, Color.Black), + HotFocus = new Attribute (Color.BrightYellow, Color.DarkGray) + }; + view.SetScheme (scheme); + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + ((BorderView)view.Border.View!).TabOffset = 0; + + runnable.Add (view); + app.Begin (runnable); + + // Give focus to our view + view.SetFocus (); + Assert.True (view.HasFocus); + view.Border.View?.CanFocus = true; + view.Border.View?.SetFocus (); // TitleView should have focus, not the main view + Assert.True (view.Border.View?.HasFocus); + Assert.True (view.Border.View?.SubViews.OfType ().FirstOrDefault ()?.HasFocus); + + app.LayoutAndDraw (); + + output.WriteLine (app.Driver!.ToString ()); + + // Attribute map: + // 0 = Normal (border lines when unfocused — but LineCanvas uses Normal) + // 1 = Focus (title text) + // 2 = HotFocus (hotkey char 'a') + Attribute focusAttr = view.GetAttributeForRole (VisualRole.Focus); + Attribute hotFocusAttr = view.GetAttributeForRole (VisualRole.HotFocus); + + // Row 1 is "│Tab│" — columns 1,2,3 are the title text "Tab" + // 'T' at [1,1] should be Focus, 'a' at [1,2] should be HotFocus, 'b' at [1,3] should be Focus + Cell [,] contents = app.Driver!.Contents!; + Attribute actualT = contents [1, 1].Attribute!.Value; + Attribute actualA = contents [1, 2].Attribute!.Value; + Attribute actualB = contents [1, 3].Attribute!.Value; + + output.WriteLine ($"Expected Focus: {focusAttr}"); + output.WriteLine ($"Expected HotFocus: {hotFocusAttr}"); + output.WriteLine ($"Actual 'T' [1,1]: {actualT}"); + output.WriteLine ($"Actual 'a' [1,2]: {actualA}"); + output.WriteLine ($"Actual 'b' [1,3]: {actualB}"); + + Assert.Equal (focusAttr, actualT); + Assert.Equal (hotFocusAttr, actualA); + Assert.Equal (focusAttr, actualB); + + view.Dispose (); + app.Dispose (); + } + + [Fact] + public void Top_Unfocused_Depth1_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 4); + + View view = CreateTabView (driver, + 9, + 4, + Side.Top, + 0, + null, + false, + "T_ab", + true, + new Thickness (1, 1, 1, 1)); + + // Depth=1: separator coincides with content border line. + DrawAndAssert (view, + driver, + """ + │Tab╭───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_Depth2_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 5); + + View view = CreateTabView (driver, + 9, + 5, + Side.Top, + 0, + null, + false, + "T_ab", + true, + new Thickness (1, 2, 1, 1)); + + // Depth=2: cap line + closing edge with title. Separator on closing edge. + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab╰───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + -1, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ───╮ + Tab│ + ┬──┴────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset_WithTitle_Thick_Border () + { + IDriver driver = CreateTestDriver (); + + View view = CreateTabView (driver, + 17, + 12, + Side.Top, + -1, + null, + false, + "T_ab", + true); + + view.Border.Thickness = new Thickness (5, 5, 5, 5); + + // Edge-based positioning: non-title sides at outer edge → 17 wide, 8 tall content border. + // Tab header at offset=-1 is partially clipped on the left. + DrawAndAssert (view, + driver, + """ + ───╮ + │ + Tab│ + │ + ┬──┴────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset2_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + -2, + null, + false, + "T_ab", + true); + + // Header at X=-2. Left edge and 'T' clipped. Visible: cap ──╮, title ab│. + DrawAndAssert (view, + driver, + """ + ──╮ + ab│ + ┬─┴─────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset4_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + -4, + null, + false, + "T_ab", + true); + + // Header at X=-4. Only right edge visible at col 0. No title visible. + DrawAndAssert (view, + driver, + """ + ╮ + │ + ┼───────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset5_Thick_Border () // Copilot + { + IDriver driver = CreateTestDriver (); + + View view = CreateTabView (driver, + 17, + 12, + Side.Top, + -5, + null, + false, + "T_ab", + true); + + view.Border.Thickness = new Thickness (5, 5, 5, 5); + + // Edge-based: header completely off-screen. Full content border drawn. + DrawAndAssert (view, + driver, + """ + ╭───────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────╯ + """); + } + + [Fact] + public void Top_Unfocused_NegativeOffset5_WithTitle () // Copilot + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + -5, + null, + false, + "T_ab", + true); + + // Header completely off-screen. Content border drawn normally. + DrawAndAssert (view, + driver, + """ + ╭───────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_Offset0_NoTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 0, + null, + false, + null, + false); + + DrawAndAssert (view, + driver, + """ + ╭╮ + ││ + ├┴──────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_Offset0_With2LineTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 0, + null, + false, + "T_a\nb", + true); + + DrawAndAssert (view, + driver, + """ + ╭──╮ + │a │ + ├──┴────╮ + │ │ + │ │ + ╰───────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Top — View 9×6, Thickness(1,3,1,1), borderBounds=(0,2,9,4) + // Content border: 9 wide, 4 tall. Interior: 7 cols × 2 rows. + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Top_Unfocused_Offset0_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 0, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab│ + ├───┴───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_Offset0_WithTitle_Thick_Border () // Copilot + { + IDriver driver = CreateTestDriver (); + + View view = CreateTabView (driver, + 17, + 12, + Side.Top, + 0, + null, + false, + "T_ab", + true); + + view.Border.Thickness = new Thickness (5, 5, 5, 5); + + // Edge-based: borderBounds=(0,4,17,8). Header depth=5 at offset=0. + // Unfocused → separator line (closed). Extra padding rows for depth > 3. + DrawAndAssert (view, + driver, + """ + ╭───╮ + │ │ + │Tab│ + │ │ + ├───┴───────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────╯ + """); + } + + [Fact] + public void Top_Unfocused_Offset2_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 2, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭───╮ + │Tab│ + ╭─┴───┴─╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_OverflowRight_WithTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 5, + null, + false, + "T_ab", + true); + + DrawAndAssert (view, + driver, + """ + ╭─── + │Tab + ╭────┴──┬ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_SingleCharTitle () + { + IDriver driver = CreateTestDriver (9, 6); + + View view = CreateTabView (driver, + 9, + 6, + Side.Top, + 2, + null, + false, + "X", + true); + + DrawAndAssert (view, + driver, + """ + ╭─╮ + │X│ + ╭─┴─┴───╮ + │ │ + │ │ + ╰───────╯ + """); + } + + [Fact] + public void Top_Unfocused_TitleText_Uses_Normal_Attributes () // Copilot + { + // When a View does NOT have focus, the tab title text should render + // with Normal attributes, and the hotkey character with HotNormal. + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (9, 6); + app.Driver!.Clipboard = new FakeClipboard (); + + Runnable runnable = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + + View view = new () + { + X = 0, + Y = 0, + CanFocus = true, + Width = 9, + Height = 6, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + Scheme scheme = new () + { + Normal = new Attribute (Color.White, Color.Black), + Focus = new Attribute (Color.BrightGreen, Color.DarkGray), + HotNormal = new Attribute (Color.BrightRed, Color.Black), + HotFocus = new Attribute (Color.BrightYellow, Color.DarkGray) + }; + view.SetScheme (scheme); + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + ((BorderView)view.Border.View!).TabOffset = 0; + + // Add a second focusable view so focus goes there, not to `view` + View other = new () + { + X = 0, + Y = 0, + Width = 1, + Height = 1, + CanFocus = true + }; + runnable.Add (other); + runnable.Add (view); + app.Begin (runnable); + + // Ensure the other view has focus, not our tab view + other.SetFocus (); + Assert.False (view.HasFocus); + + app.LayoutAndDraw (); + + output.WriteLine (app.Driver!.ToString ()); + + Attribute normalAttr = view.GetAttributeForRole (VisualRole.Normal); + Attribute hotNormalAttr = view.GetAttributeForRole (VisualRole.HotNormal); + + // Row 1 "│Tab│" — 'T' at [1,1] Normal, 'a' at [1,2] HotNormal, 'b' at [1,3] Normal + Cell [,] contents = app.Driver!.Contents!; + Attribute actualT = contents [1, 1].Attribute!.Value; + Attribute actualA = contents [1, 2].Attribute!.Value; + Attribute actualB = contents [1, 3].Attribute!.Value; + + output.WriteLine ($"Expected Normal: {normalAttr}"); + output.WriteLine ($"Expected HotNormal: {hotNormalAttr}"); + output.WriteLine ($"Actual 'T' [1,1]: {actualT}"); + output.WriteLine ($"Actual 'a' [1,2]: {actualA}"); + output.WriteLine ($"Actual 'b' [1,3]: {actualB}"); + + Assert.Equal (normalAttr, actualT); + Assert.Equal (hotNormalAttr, actualA); + Assert.Equal (normalAttr, actualB); + + view.Dispose (); + app.Dispose (); + } + + // ════════════════════════════════════════════════════════════════════ + // SuperView Integration Tests + // View with Tab border placed inside a Window (border=Rounded) that + // fills its viewport with ◊ (diamond). The diamond background proves + // that transparent areas of the tab header let content show through. + // ════════════════════════════════════════════════════════════════════ + + /// + /// Creates a Window-like SuperView with diamond-filled background, containing a tab-border subview. + /// The subview is positioned at (1,1) so there's at least 1 row/col of diamonds around it. + /// + private static (IApplication app, View subview) CreateSuperViewWithTabChild (int superWidth, + int superHeight, + int subviewWidth, + int subviewHeight, + Side side, + int tabOffset, + bool hasFocus, + string? title, + bool titleFlag, + Thickness? thickness = null) // Copilot + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (superWidth + 2, superHeight + 2); + app.Driver!.Clipboard = new FakeClipboard (); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.Rounded }; + + // Fill window viewport with diamonds + window.DrawingContent += (_, e) => + { + window.FillRect (window.Viewport, Glyphs.Diamond); + e.DrawContext?.AddDrawnRectangle (window.Viewport); + }; + + View subview = new () + { + X = 1, + Y = 1, + CanFocus = true, + HasFocus = hasFocus, + Width = subviewWidth, + Height = subviewHeight, + BorderStyle = LineStyle.Rounded + }; + + if (title is { }) + { + subview.Title = title; + } + + subview.Border.Thickness = thickness + ?? side switch + { + Side.Top => new Thickness (1, 3, 1, 1), + Side.Bottom => new Thickness (1, 1, 1, 3), + Side.Left => new Thickness (3, 1, 1, 1), + Side.Right => new Thickness (1, 1, 3, 1), + _ => throw new ArgumentOutOfRangeException (nameof (side)) + }; + + var settings = BorderSettings.Tab; + + if (titleFlag) + { + settings |= BorderSettings.Title; + } + + subview.Border.Settings = settings; + ((BorderView)subview.Border.View!).TabSide = side; + ((BorderView)subview.Border.View!).TabOffset = tabOffset; + + window.Add (subview); + app.Begin (window); + + return (app, subview); + } + + // ──────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────── + + private static View CreateTabView (IDriver driver, + int width, + int height, + Side side, + int tabOffset, + int? tabLength, + bool hasFocus, + string? title, + bool titleFlag, + Thickness? thickness = null) + { + View view = new () + { + Driver = driver, + CanFocus = true, + HasFocus = hasFocus, + Width = width, + Height = height, + BorderStyle = LineStyle.Rounded + }; + + if (title is { }) + { + view.Title = title; + } + + view.Border.Thickness = thickness + ?? side switch + { + Side.Top => new Thickness (1, 3, 1, 1), + Side.Bottom => new Thickness (1, 1, 1, 3), + Side.Left => new Thickness (3, 1, 1, 1), + Side.Right => new Thickness (1, 1, 3, 1), + _ => throw new ArgumentOutOfRangeException (nameof (side)) + }; + + var settings = BorderSettings.Tab; + + if (titleFlag) + { + settings |= BorderSettings.Title; + } + + view.Border.Settings = settings; + ((BorderView)view.Border.View!).TabSide = side; + ((BorderView)view.Border.View!).TabOffset = tabOffset; + + if (tabLength.HasValue) + { + ((BorderView)view.Border.View!).TabLength = tabLength.Value; + } + + return view; + } + + // ──────────────────────────────────────────────────────────────────── + // TabSide switching — EffectiveTabLength must recalculate when + // the tab side changes from horizontal to vertical or vice versa. + // ──────────────────────────────────────────────────────────────────── + + // Claude - Opus 4.6 + [Fact] + public void TabSide_TopToLeft_EffectiveTabLength_Recalculates () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = new () + { + Driver = driver, + CanFocus = true, + Width = 12, + Height = 9, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Layout (); + + // Top: "Tab" = 3 cols + 2 border = 5 (width) + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + // Switch to Left — tab length should now be height-based + ((BorderView)view.Border.View!).TabSide = Side.Left; + view.Border.Thickness = new Thickness (3, 1, 1, 1); + view.Layout (); + + // Left: "Tab" vertical = 3 rows + 2 border = 5 (height) + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + view.Dispose (); + } + + [Fact] + public void TabSide_TopToLeft_Renders_Correctly () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = CreateTabView (driver, + 12, + 9, + Side.Top, + 0, + null, + true, + "T_ab", + true); + + // First render as Top + view.Layout (); + view.Draw (); + + // Verify top rendering is correct + DriverAssert.AssertDriverContentsAre (""" + ╭───╮ + │Tab│ + │ ╰──────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────╯ + """, + output, + driver); + + // Switch to Left + ((BorderView)view.Border.View!).TabSide = Side.Left; + view.Border.Thickness = new Thickness (3, 1, 1, 1); + + driver.ClearContents (); + view.Layout (); + view.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭──────────╮ + │T │ + │a │ + │b │ + ╰─╮ │ + │ │ + │ │ + │ │ + ╰────────╯ + """, + output, + driver); + + view.Dispose (); + } + + [Fact] + public void TabSide_LeftToTop_EffectiveTabLength_Recalculates () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = new () + { + Driver = driver, + CanFocus = true, + Width = 12, + Height = 9, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + view.Border.Thickness = new Thickness (3, 1, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Left; + view.Layout (); + + // Left: "Tab" vertical = 3 rows + 2 border = 5 (height) + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + // Switch to Top + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Layout (); + + // Top: "Tab" = 3 cols + 2 border = 5 (width) + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + view.Dispose (); + } + + [Fact] + public void TabSide_TopToBottom_EffectiveTabLength_Unchanged () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = new () + { + Driver = driver, + CanFocus = true, + Width = 12, + Height = 9, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Layout (); + + int topLength = ((BorderView)view.Border.View!).EffectiveTabLength; + + // Switch to Bottom — still horizontal, length should be the same + ((BorderView)view.Border.View!).TabSide = Side.Bottom; + view.Border.Thickness = new Thickness (1, 1, 1, 3); + view.Layout (); + + Assert.Equal (topLength, ((BorderView)view.Border.View!).EffectiveTabLength); + + view.Dispose (); + } + + [Fact] + public void TabSide_TopToRight_EffectiveTabLength_Recalculates () + { + IDriver driver = CreateTestDriver (12, 9); + + View view = new () + { + Driver = driver, + CanFocus = true, + Width = 12, + Height = 9, + BorderStyle = LineStyle.Rounded, + Title = "T_ab" + }; + + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = Side.Top; + view.Layout (); + + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + // Switch to Right + ((BorderView)view.Border.View!).TabSide = Side.Right; + view.Border.Thickness = new Thickness (1, 1, 3, 1); + view.Layout (); + + // Right: "Tab" vertical = 3 rows + 2 border = 5 (height) + Assert.Equal (5, ((BorderView)view.Border.View!).EffectiveTabLength); + + view.Dispose (); + } + + // ──────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────── + + private void DrawAndAssert (View view, IDriver driver, string expected) + { + view.Layout (); + view.Draw (); + output.WriteLine (driver.ToString ()); + DriverAssert.AssertDriverContentsAre (expected, output, driver); + view.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/OverlappedLineCanvasTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/OverlappedLineCanvasTests.cs new file mode 100644 index 0000000000..5760353c9b --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/OverlappedLineCanvasTests.cs @@ -0,0 +1,418 @@ +// Copilot - Opus 4.6 + +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Tests demonstrating that overlapped SubViews with = true +/// produce incorrect junction glyphs when a higher-Z view intentionally omits border lines (a "gap") +/// but a lower-Z view's lines fill that gap, causing auto-join to create wrong intersections. +/// +/// These tests are independent of and demonstrate the general issue +/// with the flat LineCanvas merge strategy for overlapped views. +/// +public class OverlappedLineCanvasTests (ITestOutputHelper output) : TestDriverBase +{ + /// + /// Two overlapping bordered views where the higher-Z view has a partial border (gap on bottom). + /// The lower-Z view's full border should NOT fill the higher-Z view's intentional gap. + /// + /// Layout (10x6): + /// + /// viewBack: (0,1) 8x4 — full border, lower Z (drawn first/behind) + /// viewFront: (0,0) 5x3 — border with NO bottom line, higher Z (drawn last/on top) + /// + /// + /// Expected: viewFront's gap remains open; viewBack's top line is occluded under viewFront. + /// Actual (bug): auto-join creates T-junctions where viewBack's top meets viewFront's sides. + /// + [Fact] + public void HigherZ_Gap_Not_Filled_By_LowerZ_Border () + { + // Copilot + IDriver driver = CreateTestDriver (10, 6); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 10; + superView.Height = 6; + + // Lower-Z view: full border, behind + View viewBack = new () + { + X = 0, + Y = 1, + Width = 8, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + // Higher-Z view: border with intentional gap on bottom (no bottom line) + // We simulate a gap by using OnDrawingContent to add lines manually + GapBorderView viewFront = new () + { + X = 0, + Y = 0, + Width = 5, + Height = 3, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + // Add in Z-order: first added = lowest Z (drawn behind) + superView.Add (viewBack, viewFront); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // viewFront occupies rows 0-2, cols 0-4 with an open bottom. + // viewBack occupies rows 1-4, cols 0-7 with full border. + // The gap (row 2, cols 1-3) should be empty/spaces, NOT a line. + // + // Expected rendering: + // Row 0: ┌───┐ viewFront top + // Row 1: │ │──────┐ viewFront sides + viewBack top (occluded under viewFront) + // Row 2: │ │ │ viewFront gap (open) + viewBack sides + // Row 3: │ └──────┘ viewBack sides/bottom + // Row 4: └──────┘ viewBack bottom (but viewFront doesn't reach here) + // + // With the BUG, row 2 gets ├───┤ because viewBack's top line at y=1 + // auto-joins with viewFront's left/right lines, creating T-junctions. + + // This test documents the EXPECTED behavior after the fix. + // For now, verify the glyph at the junction point to detect the bug. + string actual = driver.ToString ()!; + string [] lines = actual.Split ('\n'); + + // Row 1, Col 0: should be │ (viewFront's left side, higher-Z) + // With the bug it becomes ├ (T-junction from viewBack's top line auto-joining with viewFront's side) + Assert.Equal ('│', lines [1] [0]); + + // Row 1, Col 4: should be │ (viewFront's right side, higher-Z) + // With the bug it becomes ┤ (T-junction from viewBack's top) + Assert.Equal ('│', lines [1] [4]); + } + + /// + /// Two overlapping bordered views at the same X but different Y, where the front view's + /// border partially overlaps the back view's border. Tests that the front view's lines + /// take priority at shared cells. + /// + /// This uses standard bordered views (no custom gap) to show the simplest case: + /// when two overlapped views share a border cell, the higher-Z view's glyph should win. + /// + [Fact] + public void Overlapped_Views_HigherZ_Lines_Win_At_Shared_Cells () + { + // Copilot + IDriver driver = CreateTestDriver (10, 7); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 10; + superView.Height = 7; + + // Lower-Z: full single-line border + View viewBack = new () + { + X = 0, + Y = 2, + Width = 8, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + // Higher-Z: full single-line border, overlapping viewBack by 1 row at bottom + View viewFront = new () + { + X = 0, + Y = 0, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + superView.Add (viewBack, viewFront); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // viewFront: rows 0-3, cols 0-5 + // viewBack: rows 2-5, cols 0-7 + // Overlap at rows 2-3, cols 0-5 + // + // Row 2: viewFront has │ │ (sides), viewBack has ┌──────┐ (top) + // Row 3: viewFront has └───┘ (bottom), viewBack has │ │ (sides) + // + // At (0,2): viewFront has │, viewBack has ┌ → should be │ (viewFront wins) or ├ if auto-join desired + // At (0,3): viewFront has └, viewBack has │ → junction depends on design + // + // The key issue: with flat merge, glyphs at these cells are determined by BOTH views' + // lines, which may not be what the user intended for overlapped views. + + string actual = driver.ToString ()!; + string [] lines = actual.Split ('\n'); + + // At minimum, verify that the rendering is deterministic and doesn't crash + Assert.NotNull (actual); + Assert.True (lines.Length >= 6, $"Expected at least 6 lines but got {lines.Length}"); + + // Document the actual rendering for design validation + output.WriteLine ("\nRendered output for design review:"); + + for (var i = 0; i < lines.Length; i++) + { + output.WriteLine ($" Row {i}: '{lines [i]}'"); + } + } + + /// + /// Three overlapping bordered views in a staircase pattern. The highest-Z view should + /// fully occlude lower views where they overlap, and junction glyphs should only reflect + /// the highest-Z view's lines at those cells. + /// + /// This simulates the Tab scenario generically: multiple overlapped views where only one + /// (the "focused" one) should dominate the visual at shared border cells. + /// + [Fact] + public void Three_Overlapped_Staircase_HighestZ_Dominates () + { + // Copilot + IDriver driver = CreateTestDriver (12, 8); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 12; + superView.Height = 8; + + View view1 = new () + { + X = 0, + Y = 0, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + View view2 = new () + { + X = 2, + Y = 2, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + View view3 = new () + { + X = 4, + Y = 4, + Width = 6, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + // Z-order: view1 lowest, view3 highest (last added = highest Z) + superView.Add (view1, view2, view3); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + string actual = driver.ToString ()!; + string [] lines = actual.Split ('\n'); + + // The overlap regions should show the highest-Z view's border glyphs. + // At (2,2): view1 has │ (right side interior), view2 has ┌ (top-left corner) + // With flat merge, auto-join produces an incorrect glyph. + // After fix, view2's ┌ should appear (or the correct junction for view2 alone). + + // Verify rendering is complete + Assert.True (lines.Length >= 8, $"Expected at least 8 lines but got {lines.Length}"); + + // Document for design review + output.WriteLine ("\nRendered output for design review:"); + + for (var i = 0; i < Math.Min (lines.Length, 8); i++) + { + output.WriteLine ($" Row {i}: '{lines [i]}'"); + } + } + + /// + /// Verifies that same-Z (non-overlapped) sibling views still get correct auto-join + /// when the overlapped drawing fix is in place. This is a regression guard. + /// + [Fact] + public void SameZ_SideBySide_AutoJoin_Still_Works () + { + // Copilot + IDriver driver = CreateTestDriver (11, 4); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 11; + superView.Height = 4; + + View viewA = new () + { + X = 0, + Y = 0, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + }; + + View viewB = new () + { + X = Pos.Right (viewA) - 1, + Y = 0, + Width = 5, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + }; + + superView.Add (viewA, viewB); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + // Same-Z, non-overlapped views sharing a column should auto-join correctly. + // This MUST continue to work after any overlapped drawing fix. + DriverAssert.AssertDriverContentsAre (""" + ┌───┬───┐ + │ │ │ + └───┴───┘ + """, + output, + driver); + } + + /// + /// A view with a full border (lower-Z) and a view with a partial border gap (higher-Z) + /// using Margin adornment. Demonstrates the issue is not specific to Border but applies + /// to any adornment SubView composition. + /// + [Fact] + public void Overlapped_With_Padding_SubViews_HigherZ_Wins () + { + // Copilot + IDriver driver = CreateTestDriver (10, 6); + + using View superView = new (); + superView.Driver = driver; + superView.Width = 10; + superView.Height = 6; + + // A full-bordered view at the back + View viewBack = new () + { + X = 0, + Y = 1, + Width = 8, + Height = 4, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + // A full-bordered view at the front that overlaps viewBack + View viewFront = new () + { + X = 0, + Y = 0, + Width = 6, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true, + Arrangement = ViewArrangement.Overlapped, + }; + + superView.Add (viewBack, viewFront); + + superView.Layout (); + superView.Draw (); + + output.WriteLine ("Actual:"); + output.WriteLine (driver.ToString ()); + + string actual = driver.ToString ()!; + string [] lines = actual.Split ('\n'); + + // viewFront: rows 0-2, cols 0-5 (full border) + // viewBack: rows 1-4, cols 0-7 (full border) + // Overlap at row 1-2, cols 0-5 + // + // Row 0: ┌────┐ (viewFront top) + // Row 1: │ │─────┐ (viewFront sides; viewBack top partially visible) + // Row 2: └────┘ │ (viewFront bottom; viewBack sides) + // Row 3: │ │ (viewBack sides) + // Row 4: └──────────┘ (viewBack bottom) + // + // At (0,1): viewFront has │, viewBack has ┌ → flat merge auto-joins to ├ + // After fix: viewFront's │ should win (higher Z) + + // Row 1, Col 0: should be │ (viewFront's side, higher Z) not ├ (junction) + // This assertion will fail with the current flat merge, demonstrating the bug. + char glyphAtOverlap = lines [1] [0]; + output.WriteLine ($"\nGlyph at (0,1): '{glyphAtOverlap}' (expected '│' for higher-Z wins)"); + + // NOTE: This assertion documents the DESIRED behavior. + // It will FAIL with the current implementation, proving the bug. + Assert.Equal ('│', glyphAtOverlap); + } +} + +/// +/// A view that draws a border with an intentional gap on the bottom side. +/// Simulates what a "focused tab" does — 3 sides drawn, bottom open. +/// +internal class GapBorderView : View +{ + public GapBorderView () => + + // Don't use the standard border — we draw manually via LineCanvas + Border.Settings = BorderSettings.Default; + + protected override bool OnDrawingContent (DrawContext? context) + { + LineCanvas lc = LineCanvas; + Rectangle bounds = ViewportToScreen (); + Attribute attr = GetAttributeForRole (VisualRole.Normal); + + // Draw 3 sides: top, left, right — leave bottom open (the "gap") + lc.AddLine (new Point (bounds.X, bounds.Y), bounds.Width, Orientation.Horizontal, LineStyle.Single, attr); // top + lc.AddLine (new Point (bounds.X, bounds.Y), bounds.Height, Orientation.Vertical, LineStyle.Single, attr); // left + lc.AddLine (new Point (bounds.Right - 1, bounds.Y), bounds.Height, Orientation.Vertical, LineStyle.Single, attr); // right + + // No bottom line — this is the "gap" + + return true; + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs index 4e1cb3bdad..55cb627370 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/ShadowTests.cs @@ -368,7 +368,7 @@ void DecrementValue (int count, Key key) { for (; i > count; i--) { - Assert.True (app.Keyboard.RaiseKeyDownEvent (key)); + app.Keyboard.RaiseKeyDownEvent (key); app.LayoutAndDraw (); CheckAssertion (new Point (i - 1, 0), new Point (0, i - 1), key); @@ -379,7 +379,7 @@ void IncrementValue (int count, Key key) { for (; i < count; i++) { - Assert.True (app.Keyboard.RaiseKeyDownEvent (key)); + app.Keyboard.RaiseKeyDownEvent (key); app.LayoutAndDraw (); CheckAssertion (new Point (i + 1, 0), new Point (0, i + 1), key); diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs new file mode 100644 index 0000000000..8efc7efc4e --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TabCompositionTests.cs @@ -0,0 +1,455 @@ +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Tests that multiple Views with tab-style borders compose correctly when sharing +/// a parent's LineCanvas via . +/// + +// Copilot +public class TabCompositionTests (ITestOutputHelper output) : TestDriverBase +{ + // ──────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────── + + private static View CreateTabView (IDriver driver, Side side, int tabOffset, bool hasFocus, string title) + { + View view = new () + { + Driver = driver, + CanFocus = true, + HasFocus = hasFocus, + SuperViewRendersLineCanvas = true, + Width = Dim.Auto (), + Height = Dim.Auto (), + BorderStyle = LineStyle.Rounded, + Title = title, + Text = $"{title} content", + Arrangement = ViewArrangement.Overlapped + }; + + view.Border.Thickness = side switch + { + Side.Top => new Thickness (1, 3, 1, 1), + Side.Bottom => new Thickness (1, 1, 1, 3), + Side.Left => new Thickness (3, 1, 1, 1), + Side.Right => new Thickness (1, 1, 3, 1), + _ => throw new ArgumentOutOfRangeException (nameof (side)) + }; + + view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + ((BorderView)view.Border.View!).TabSide = side; + ((BorderView)view.Border.View!).TabOffset = tabOffset; + + if (side is Side.Left or Side.Right) + { + view.Height = view.Text.GetColumns () + 2; // +2 for top and bottom border thickness + } + + view.Layout (); + driver.SetScreenSize (view.Frame.Width, view.Frame.Height); + + return view; + } + + private void DrawAndAssert (View view, IDriver driver, string expected) + { + view.Layout (); + view.Draw (); + DriverAssert.AssertDriverContentsWithFrameAre (expected, output, driver); + view.Dispose (); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Top — Two tabs, Tab1 focused + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Top_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (); + + // Tab1: focused, offset 0. Title "Tab1" → TabLength = 6. + View tab1 = CreateTabView (driver, Side.Top, 0, false, "Tab1"); + + // Tab2: unfocused, offset 6 (right after Tab1). Title "Tab2" → TabLength = 6. + View tab2 = CreateTabView (driver, Side.Top, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + Assert.Equal (tab1, superView.SubViews.ElementAt (1)); + + DrawAndAssert (superView, + driver, + """ + ╭────╮────╮∙∙∙ + │Tab1│Tab2│∙∙∙ + │ ╰────┴──╮ + │Tab1 content│ + ╰────────────╯ + """); + } + + [Fact] + public void Top_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (); + + // Tab1: focused, offset 0. Title "Tab1" → TabLength = 6. + View tab1 = CreateTabView (driver, Side.Top, 0, false, "Tab1"); + + // Tab2: unfocused, offset 6 (right after Tab1). Title "Tab2" → TabLength = 6. + View tab2 = CreateTabView (driver, Side.Top, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────╭────╮∙∙∙ + │Tab1│Tab2│∙∙∙ + ├────╯ ╰──╮ + │Tab2 content│ + ╰────────────╯ + """); + } + + [Fact] + public void Top_ThreeTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (); + + View tab1 = CreateTabView (driver, Side.Top, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Top, 5, true, "Tab2"); + View tab3 = CreateTabView (driver, Side.Top, 10, false, "Tab3"); + + tab1.Width = tab2.Width = tab3.Width = 20; + tab3.Layout (); + driver.SetScreenSize (tab3.Frame.Width, tab3.Frame.Height); + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab3, tab2, tab1); + + DrawAndAssert (superView, + driver, + """ + ╭────╮────╮────╮∙∙∙∙ + │Tab1│Tab2│Tab3│∙∙∙∙ + │ ╰────┴────┴───╮ + │Tab1 content │ + ╰──────────────────╯ + """); + } + + [Fact] + public void Top_ThreeTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (); + + View tab1 = CreateTabView (driver, Side.Top, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Top, 5, true, "Tab2"); + View tab3 = CreateTabView (driver, Side.Top, 10, false, "Tab3"); + + tab1.Width = tab2.Width = tab3.Width = 20; + tab3.Layout (); + driver.SetScreenSize (tab3.Frame.Width, tab3.Frame.Height); + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab3, tab2, tab1); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────╭────╮────╮∙∙∙∙ + │Tab1│Tab2│Tab3│∙∙∙∙ + ├────╯ ╰────┴───╮ + │Tab2 content │ + ╰──────────────────╯ + """); + } + + [Fact] + public void Top_ThreeTabs_Tab3Focused () + { + IDriver driver = CreateTestDriver (); + + View tab1 = CreateTabView (driver, Side.Top, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Top, 5, true, "Tab2"); + View tab3 = CreateTabView (driver, Side.Top, 10, false, "Tab3"); + + tab1.Width = tab2.Width = tab3.Width = 20; + tab3.Layout (); + driver.SetScreenSize (tab3.Frame.Width, tab3.Frame.Height); + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab3, tab2, tab1); + tab1.SetFocus (); + tab2.SetFocus (); + tab3.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────╭────╭────╮∙∙∙∙ + │Tab1│Tab2│Tab3│∙∙∙∙ + ├────┴────╯ ╰───╮ + │Tab3 content │ + ╰──────────────────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Bottom — Two tabs + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Bottom_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (); + + View tab1 = CreateTabView (driver, Side.Bottom, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Bottom, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────────────╮ + │Tab1 content│ + │ ╭────┬──╯ + │Tab1│Tab2│∙∙∙ + ╰────╯────╯∙∙∙ + """); + } + + [Fact] + public void Bottom_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (); + + View tab1 = CreateTabView (driver, Side.Bottom, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Bottom, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────────────╮ + │Tab2 content│ + ├────╮ ╭──╯ + │Tab1│Tab2│∙∙∙ + ╰────╰────╯∙∙∙ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Left — Two tabs + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Left_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (20, 12); + + View tab1 = CreateTabView (driver, Side.Left, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Left, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────────╮ + │T Tab1 content│ + │a │ + │b │ + │1 │ + ╰─╮ │ + │T│ │ + │a│ │ + │b│ │ + │2│ │ + ╰─┤ │ + ∙∙│ │ + ∙∙│ │ + ∙∙╰────────────╯ + """); + } + + [Fact] + public void Left_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (20, 12); + + View tab1 = CreateTabView (driver, Side.Left, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Left, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭─┬────────────╮ + │T│Tab2 content│ + │a│ │ + │b│ │ + │1│ │ + ╭─╯ │ + │T │ + │a │ + │b │ + │2 │ + ╰─╮ │ + ∙∙│ │ + ∙∙│ │ + ∙∙╰────────────╯ + """); + } + + // ════════════════════════════════════════════════════════════════════ + // Side.Right — Two tabs + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void Right_TwoTabs_Tab1Focused () + { + IDriver driver = CreateTestDriver (20, 12); + + View tab1 = CreateTabView (driver, Side.Right, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Right, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab1.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭──────────────╮ + │Tab1 content T│ + │ a│ + │ b│ + │ 1│ + │ ╭─╯ + │ │T│ + │ │a│ + │ │b│ + │ │2│ + │ ├─╯ + │ │∙∙ + │ │∙∙ + ╰────────────╯∙∙ + """); + } + + [Fact] + public void Right_TwoTabs_Tab2Focused () + { + IDriver driver = CreateTestDriver (20, 12); + + View tab1 = CreateTabView (driver, Side.Right, 0, false, "Tab1"); + View tab2 = CreateTabView (driver, Side.Right, 5, false, "Tab2"); + + View superView = new () { CanFocus = true, Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + superView.DrawingContent += (_, e) => + { + superView.FillRect (superView.Viewport, Glyphs.Dot); + e.Cancel = true; + }; + superView.Add (tab1, tab2); + tab2.SetFocus (); + + DrawAndAssert (superView, + driver, + """ + ╭────────────┬─╮ + │Tab2 content│T│ + │ │a│ + │ │b│ + │ │1│ + │ ╰─╮ + │ T│ + │ a│ + │ b│ + │ 2│ + │ ╭─╯ + │ │∙∙ + │ │∙∙ + ╰────────────╯∙∙ + """); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Adornment/TitleViewTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TitleViewTests.cs new file mode 100644 index 0000000000..0cb423fb9d --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Adornment/TitleViewTests.cs @@ -0,0 +1,837 @@ +// Claude - Opus 4.6 + +using UnitTests; + +namespace ViewBaseTests.Adornments; + +/// +/// Tests for orientation, direction, key bindings, and TextFormatter behavior. +/// +[Trait ("Category", "Adornment")] +public class TitleViewTests (ITestOutputHelper output) : TestDriverBase +{ + #region Constructor Defaults + + [Fact] + public void Constructor_SetsExpectedDefaults () + { + TitleView tv = new (); + + Assert.True (tv.CanFocus); + Assert.Equal (TabBehavior.TabStop, tv.TabStop); + Assert.True (tv.SuperViewRendersLineCanvas); + Assert.Equal (Orientation.Horizontal, tv.Orientation); + + tv.Dispose (); + } + + [Fact] + public void Constructor_DefaultOrientation_IsHorizontal () + { + TitleView tv = new (); + + Assert.Equal (Orientation.Horizontal, tv.Orientation); + Assert.Equal (TextDirection.LeftRight_TopBottom, tv.TextFormatter.Direction); + + tv.Dispose (); + } + + #endregion + + #region Orientation and TextFormatter.Direction + + [Fact] + public void Orientation_Horizontal_SetsTextDirectionLeftRight () + { + TitleView tv = new () { Orientation = Orientation.Horizontal }; + + Assert.Equal (TextDirection.LeftRight_TopBottom, tv.TextFormatter.Direction); + + tv.Dispose (); + } + + [Fact] + public void Orientation_Vertical_SetsTextDirectionTopBottom () + { + TitleView tv = new () { Orientation = Orientation.Vertical }; + + Assert.Equal (TextDirection.TopBottom_LeftRight, tv.TextFormatter.Direction); + + tv.Dispose (); + } + + [Fact] + public void ChangingOrientation_UpdatesTextDirection () + { + TitleView tv = new (); + + Assert.Equal (TextDirection.LeftRight_TopBottom, tv.TextFormatter.Direction); + + tv.Orientation = Orientation.Vertical; + Assert.Equal (TextDirection.TopBottom_LeftRight, tv.TextFormatter.Direction); + + tv.Orientation = Orientation.Horizontal; + Assert.Equal (TextDirection.LeftRight_TopBottom, tv.TextFormatter.Direction); + + tv.Dispose (); + } + + #endregion + + #region KeyBindings — Directional Commands + + [Fact] + public void Horizontal_BindsLeftRight_ToDirectionalCommands () + { + TitleView tv = new () { Orientation = Orientation.Horizontal }; + + AssertKeyBoundToCommand (tv, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (tv, Key.CursorRight, Command.Right); + + tv.Dispose (); + } + + [Fact] + public void Horizontal_DoesNotBindUpDown () + { + TitleView tv = new () { Orientation = Orientation.Horizontal }; + + Assert.False (tv.KeyBindings.TryGet (Key.CursorUp, out _)); + Assert.False (tv.KeyBindings.TryGet (Key.CursorDown, out _)); + + tv.Dispose (); + } + + [Fact] + public void Vertical_BindsUpDown_ToDirectionalCommands () + { + TitleView tv = new () { Orientation = Orientation.Vertical }; + + AssertKeyBoundToCommand (tv, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (tv, Key.CursorDown, Command.Down); + + tv.Dispose (); + } + + [Fact] + public void Vertical_DoesNotBindLeftRight () + { + TitleView tv = new () { Orientation = Orientation.Vertical }; + + Assert.False (tv.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (tv.KeyBindings.TryGet (Key.CursorRight, out _)); + + tv.Dispose (); + } + + [Fact] + public void ChangingOrientation_RebindsKeys () + { + TitleView tv = new () { Orientation = Orientation.Horizontal }; + + // Initially: Left/Right bound + AssertKeyBoundToCommand (tv, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (tv, Key.CursorRight, Command.Right); + Assert.False (tv.KeyBindings.TryGet (Key.CursorUp, out _)); + + // Change to Vertical: Up/Down bound, Left/Right removed + tv.Orientation = Orientation.Vertical; + + AssertKeyBoundToCommand (tv, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (tv, Key.CursorDown, Command.Down); + Assert.False (tv.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (tv.KeyBindings.TryGet (Key.CursorRight, out _)); + + tv.Dispose (); + } + + #endregion + + #region KeyBindings — Enter Removed + + [Fact] + public void Enter_IsNotBound () + { + TitleView tv = new (); + + IEnumerable> bindings = tv.KeyBindings.GetBindings (); + bool hasEnter = bindings.Any (kvp => kvp.Key.KeyCode == KeyCode.Enter); + + Assert.False (hasEnter, "TitleView should not have Enter bound"); + + tv.Dispose (); + } + + #endregion + + #region Direction Property + + [Fact] + public void Direction_CanBeSet () + { + TitleView tv = new (); + + tv.Direction = NavigationDirection.Backward; + Assert.Equal (NavigationDirection.Backward, tv.Direction); + + tv.Direction = NavigationDirection.Forward; + Assert.Equal (NavigationDirection.Forward, tv.Direction); + + tv.Dispose (); + } + + #endregion + + #region IOrientation Interface + + [Fact] + public void ImplementsIOrientation () + { + TitleView tv = new (); + + Assert.IsAssignableFrom (tv); + + tv.Dispose (); + } + + #endregion + + #region Helpers + + private static void AssertKeyBoundToCommand (TitleView tv, Key key, Command expectedCommand) + { + Assert.True (tv.KeyBindings.TryGet (key, out KeyBinding binding), $"Expected key {key} to be bound"); + Assert.Contains (expectedCommand, binding.Commands); + } + + #endregion + + #region ITitleView — TabDepth Property + + [Fact] + public void TabDepth_DefaultIs3 () + { + TitleView tv = new (); + + Assert.Equal (3, tv.TabDepth); + + tv.Dispose (); + } + + [Fact] + public void TabDepth_CanBeSet () + { + TitleView tv = new () { TabDepth = 5 }; + + Assert.Equal (5, tv.TabDepth); + + tv.TabDepth = 7; + Assert.Equal (7, tv.TabDepth); + + tv.Dispose (); + } + + [Fact] + public void Constructor_DefaultTabSide_IsTop () + { + TitleView tv = new (); + + Assert.Equal (Side.Top, tv.TabSide); + + tv.Dispose (); + } + + [Fact] + public void Constructor_DefaultBorderStyle_IsRounded () + { + TitleView tv = new (); + + Assert.Equal (LineStyle.Rounded, tv.BorderStyle); + + tv.Dispose (); + } + + [Fact] + public void Constructor_DefaultThickness_MatchesTopFocused () + { + TitleView tv = new (); + + // Default: Side.Top, depth 3, focused → (1, 1, 1, 0) + Assert.Equal (new Thickness (1, 1, 1, 0), tv.Border.Thickness); + + tv.Dispose (); + } + + [Theory] + [InlineData (Side.Top, 3, 1, 1, 1, 0)] + [InlineData (Side.Bottom, 3, 1, 0, 1, 1)] + [InlineData (Side.Left, 3, 1, 1, 0, 1)] + [InlineData (Side.Right, 3, 0, 1, 1, 1)] + [InlineData (Side.Top, 2, 1, 1, 1, 0)] + [InlineData (Side.Top, 1, 1, 0, 1, 0)] + public void TabSide_Set_AppliesThickness (Side side, int depth, int expectedLeft, int expectedTop, int expectedRight, int expectedBottom) + { + TitleView tv = new () { TabDepth = depth, TabSide = side }; + + Assert.Equal (new Thickness (expectedLeft, expectedTop, expectedRight, expectedBottom), tv.Border.Thickness); + + tv.Dispose (); + } + + [Fact] + public void TabDepth_Set_AppliesThickness () + { + TitleView tv = new () { TabSide = Side.Top }; + + // Default depth 3 top focused → (1, 1, 1, 0) + Assert.Equal (new Thickness (1, 1, 1, 0), tv.Border.Thickness); + + tv.TabDepth = 1; + + // Depth 1 top: cap=0, contentSide=0 → (1, 0, 1, 0) + Assert.Equal (new Thickness (1, 0, 1, 0), tv.Border.Thickness); + + tv.Dispose (); + } + + [Fact] + public void TabSide_Change_UpdatesThickness () + { + TitleView tv = new () { TabSide = Side.Top }; + + Assert.Equal (new Thickness (1, 1, 1, 0), tv.Border.Thickness); + + tv.TabSide = Side.Left; + + // Left, depth 3, focused → (1, 1, 0, 1) + Assert.Equal (new Thickness (1, 1, 0, 1), tv.Border.Thickness); + + tv.Dispose (); + } + + [Fact] + public void Implements_ITitleView () + { + TitleView tv = new (); + + Assert.IsAssignableFrom (tv); + + tv.Dispose (); + } + + #endregion + + #region ITitleView — UpdateLayout + + [Fact] + public void UpdateLayout_HidesTitleView_WhenBorderBoundsEmpty () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = Rectangle.Empty, + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.False (tv.Visible); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsTextFromContext () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "MyTab", + ScreenOrigin = Point.Empty + }); + + Assert.Equal ("MyTab", tv.Text); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsOrientation_Horizontal_ForTopSide () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.Equal (Orientation.Horizontal, tv.Orientation); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsOrientation_Vertical_ForLeftSide () + { + TitleView tv = new () { TabSide = Side.Left, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (2, 0, 5, 10), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.Equal (Orientation.Vertical, tv.Orientation); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsBorderThickness_ForDepth3_Focused () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + // Focused depth 3 top: cap=1, contentSide=0 → (1, 1, 1, 0) + Assert.Equal (new Thickness (1, 1, 1, 0), tv.Border.Thickness); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsBorderThickness_ForDepth3_Unfocused () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = false, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + // Unfocused depth 3 top: cap=1, contentSide=1 → (1, 1, 1, 1) + Assert.Equal (new Thickness (1, 1, 1, 1), tv.Border.Thickness); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsBorderStyle_FromContext () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Double, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.Equal (LineStyle.Double, tv.BorderStyle); + + tv.Dispose (); + } + + [Fact] + public void UpdateLayout_SetsVisible_True_WhenTabIsVisible () + { + TitleView tv = new () { TabSide = Side.Top, TabDepth = 3 }; + + tv.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.True (tv.Visible); + + tv.Dispose (); + } + + #endregion + + #region Static Geometry Helpers + + [Theory] + [InlineData (Side.Top, 0, 6, 3, 0, -2, 6, 3)] + [InlineData (Side.Top, 2, 6, 3, 2, -2, 6, 3)] + [InlineData (Side.Bottom, 0, 6, 3, 0, 4, 6, 3)] + [InlineData (Side.Left, 0, 6, 3, -2, 0, 3, 6)] + [InlineData (Side.Right, 0, 6, 3, 9, 0, 3, 6)] + public void ComputeHeaderRect_ReturnsCorrectRect (Side side, int offset, int length, int depth, int expectedX, int expectedY, int expectedW, int expectedH) + { + Rectangle contentBorder = new (0, 0, 10, 5); + + Rectangle result = TitleView.ComputeHeaderRect (contentBorder, side, offset, length, depth); + + Assert.Equal (new Rectangle (expectedX, expectedY, expectedW, expectedH), result); + } + + [Theory] + [InlineData (Side.Top, 3, 0, -2, 10, 7)] + [InlineData (Side.Bottom, 3, 0, 0, 10, 7)] + [InlineData (Side.Left, 3, -2, 0, 12, 5)] + [InlineData (Side.Right, 3, 0, 0, 12, 5)] + public void ComputeViewBounds_ReturnsCorrectRect (Side side, int depth, int expectedX, int expectedY, int expectedW, int expectedH) + { + Rectangle contentBorder = new (0, 0, 10, 5); + + Rectangle result = TitleView.ComputeViewBounds (contentBorder, side, depth); + + Assert.Equal (new Rectangle (expectedX, expectedY, expectedW, expectedH), result); + } + + [Theory] + [InlineData (Side.Top, 1, true, 1, 0, 1, 0)] + [InlineData (Side.Top, 2, true, 1, 1, 1, 0)] + [InlineData (Side.Top, 3, true, 1, 1, 1, 0)] + [InlineData (Side.Top, 3, false, 1, 1, 1, 1)] + [InlineData (Side.Bottom, 3, true, 1, 0, 1, 1)] + [InlineData (Side.Bottom, 3, false, 1, 1, 1, 1)] + [InlineData (Side.Left, 3, true, 1, 1, 0, 1)] + [InlineData (Side.Left, 3, false, 1, 1, 1, 1)] + [InlineData (Side.Right, 3, true, 0, 1, 1, 1)] + [InlineData (Side.Right, 3, false, 1, 1, 1, 1)] + public void ComputeTitleViewThickness_ReturnsExpected (Side side, + int depth, + bool hasFocus, + int expectedLeft, + int expectedTop, + int expectedRight, + int expectedBottom) + { + Thickness result = TitleView.ComputeTitleViewThickness (side, depth, hasFocus); + + Assert.Equal (new Thickness (expectedLeft, expectedTop, expectedRight, expectedBottom), result); + } + + #endregion + + #region Visual Tests + + [Fact] + public void Standalone_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (8, 5); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + + TitleView titleView = new () { Text = "Tab1" }; + superView.Add (titleView); + titleView.SetFocus (); + + Assert.True (titleView.HasFocus); + Assert.Equal (Side.Top, titleView.TabSide); + Assert.Equal (LineStyle.Rounded, titleView.BorderStyle); + Assert.Equal (new Thickness (1, 1, 1, 0), titleView.Border.Thickness); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┐ + ┊╭────╮┊ + ┊│Tab1│┊ + ┊ ┊ + └┄┄┄┄┄┄┘ + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void Standalone_With_HotKey_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (8, 5); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + + TitleView titleView = new () { Text = "_Tab1" }; + + superView.Add (titleView); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┐ + ┊╭────╮┊ + ┊│Tab1│┊ + ┊ ┊ + └┄┄┄┄┄┄┘ + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void UpdateLayout_Top_Focused_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (10, 7); + + View superView = new () { Driver = driver, CanFocus = true, Width = Dim.Fill (), Height = Dim.Fill () }; + + TitleView titleView = new () { TabDepth = 3 }; + + superView.Add (titleView); + + titleView.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭───╮ + │Tab│ + │ │ + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void UpdateLayout_Top_Unfocused_DrawsWithContentSideBorder () + { + IDriver driver = CreateTestDriver (10, 7); + + View superView = new () { Driver = driver, CanFocus = true, Width = Dim.Fill (), Height = Dim.Fill () }; + + TitleView titleView = new () { TabSide = Side.Top, TabDepth = 3 }; + + superView.Add (titleView); + + titleView.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 0, + + HasFocus = false, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭───╮ + │Tab│ + ╰───╯ + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void UpdateLayout_Top_Depth5_Focused_DrawsExtraPadding () + { + IDriver driver = CreateTestDriver (10, 7); + + View superView = new () { Driver = driver, CanFocus = true, Width = Dim.Fill (), Height = Dim.Fill () }; + + TitleView titleView = new () { TabSide = Side.Top, TabDepth = 5 }; + + superView.Add (titleView); + + titleView.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 4, 10, 3), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭───╮ + │ │ + │Tab│ + │ │ + │ │ + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void UpdateLayout_Left_Focused_DrawsVertically () + { + IDriver driver = CreateTestDriver (10, 7); + + View superView = new () { Driver = driver, CanFocus = true, Width = Dim.Fill (), Height = Dim.Fill () }; + + TitleView titleView = new () + { + TabSide = Side.Left, + TabDepth = 3, + + // Disable so TitleView renders its own borders in this standalone test + SuperViewRendersLineCanvas = false + }; + + superView.Add (titleView); + + titleView.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (2, 0, 8, 7), + TabOffset = 0, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + superView.Layout (); + superView.Draw (); + + // Focused left: contentSide (right) thickness = 0 → no right border + DriverAssert.AssertDriverContentsAre (""" + ╭── + │T + │a + │b + ╰── + """, + output, + driver); + + superView.Dispose (); + } + + [Fact] + public void UpdateLayout_HiddenWhenClippedOffscreen () + { + TitleView titleView = new () { TabSide = Side.Top, TabDepth = 3 }; + + // Tab offset pushes header entirely outside the view bounds + titleView.UpdateLayout (new TabLayoutContext + { + BorderBounds = new Rectangle (0, 2, 10, 5), + TabOffset = 20, + + HasFocus = true, + LineStyle = LineStyle.Rounded, + Title = "Tab", + ScreenOrigin = Point.Empty + }); + + Assert.False (titleView.Visible); + + titleView.Dispose (); + } + + #endregion + + [Fact] + public void App_EnableForDesign_DrawsCorrectly () + { + IApplication? app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + IDriver? driver = app.Driver; + Runnable runnable = new (); + + TitleView titleView = new (); + titleView.EnableForDesign (); + + runnable.Add (titleView); + app.Begin (runnable); + app.LayoutAndDraw (); + + Assert.True (titleView.HasFocus); + Assert.Equal (Side.Top, titleView.TabSide); + + DriverAssert.AssertDriverContentsAre (""" + ╭─────╮ + │Title│ + """, + output, + driver); + + titleView.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Arrangement/ArrangerButtonTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Arrangement/ArrangerButtonTests.cs new file mode 100644 index 0000000000..8c5b76ac23 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Arrangement/ArrangerButtonTests.cs @@ -0,0 +1,344 @@ +// Claude - Opus 4.5 + +namespace ViewBaseTests.Arrangement; + +/// +/// Tests for orientation, direction, and key binding behavior. +/// +[Trait ("Category", "Adornment")] +[Trait ("Category", "Arrangement")] +public class ArrangerButtonTests +{ + #region Orientation and Direction + + [Fact] + public void LeftSize_Sets_Horizontal_Backward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.LeftSize }; + + Assert.Equal (Orientation.Horizontal, button.Orientation); + Assert.Equal (NavigationDirection.Backward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void RightSize_Sets_Horizontal_Forward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.RightSize }; + + Assert.Equal (Orientation.Horizontal, button.Orientation); + Assert.Equal (NavigationDirection.Forward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void TopSize_Sets_Vertical_Backward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.TopSize }; + + Assert.Equal (Orientation.Vertical, button.Orientation); + Assert.Equal (NavigationDirection.Backward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void BottomSize_Sets_Vertical_Forward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.BottomSize }; + + Assert.Equal (Orientation.Vertical, button.Orientation); + Assert.Equal (NavigationDirection.Forward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void Move_Sets_Vertical_Forward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.Move }; + + Assert.Equal (Orientation.Vertical, button.Orientation); + Assert.Equal (NavigationDirection.Forward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void AllSize_Sets_Vertical_Forward () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.AllSize }; + + Assert.Equal (Orientation.Vertical, button.Orientation); + Assert.Equal (NavigationDirection.Forward, button.Direction); + + button.Dispose (); + } + + #endregion + + #region KeyBindings — Arrow Keys Bound to Directional Commands + + [Fact] + public void Move_BindsAllFourArrowKeys_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.Move }; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + + button.Dispose (); + } + + [Fact] + public void AllSize_BindsAllFourArrowKeys_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.AllSize }; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + + button.Dispose (); + } + + [Fact] + public void LeftSize_BindsLeftRight_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.LeftSize }; + + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + + button.Dispose (); + } + + [Fact] + public void LeftSize_DoesNotBindUpDown () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.LeftSize }; + + Assert.False (button.KeyBindings.TryGet (Key.CursorUp, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorDown, out _)); + + button.Dispose (); + } + + [Fact] + public void RightSize_BindsLeftRight_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.RightSize }; + + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + + button.Dispose (); + } + + [Fact] + public void RightSize_DoesNotBindUpDown () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.RightSize }; + + Assert.False (button.KeyBindings.TryGet (Key.CursorUp, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorDown, out _)); + + button.Dispose (); + } + + [Fact] + public void TopSize_BindsUpDown_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.TopSize }; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + + button.Dispose (); + } + + [Fact] + public void TopSize_DoesNotBindLeftRight () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.TopSize }; + + Assert.False (button.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorRight, out _)); + + button.Dispose (); + } + + [Fact] + public void BottomSize_BindsUpDown_ToDirectionalCommands () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.BottomSize }; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + + button.Dispose (); + } + + [Fact] + public void BottomSize_DoesNotBindLeftRight () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.BottomSize }; + + Assert.False (button.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorRight, out _)); + + button.Dispose (); + } + + #endregion + + #region KeyBindings — Space Retained, Enter Removed + + [Fact] + public void ButtonType_RemoveSpace_RemovesEnter () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.Move }; + + // Space should remain bound (inherited from Button) + IEnumerable> bindings = button.KeyBindings.GetBindings (); + bool hasSpace = bindings.Any (kvp => kvp.Key.KeyCode == KeyCode.Space); + bool hasEnter = bindings.Any (kvp => kvp.Key.KeyCode == KeyCode.Enter); + + Assert.False (hasSpace, "ArrangerButton should not have Space bound"); + Assert.False (hasEnter, "ArrangerButton should not have Enter bound"); + + button.Dispose (); + } + + #endregion + + #region ButtonType Change Rebinds Keys + + [Fact] + public void ChangingButtonType_RebindsKeys () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.LeftSize }; + + // Initially: only Left/Right bound + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + Assert.False (button.KeyBindings.TryGet (Key.CursorUp, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorDown, out _)); + + // Change to TopSize: only Up/Down bound + button.ButtonType = ArrangeButtons.TopSize; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + Assert.False (button.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorRight, out _)); + + // Orientation and Direction should also have changed + Assert.Equal (Orientation.Vertical, button.Orientation); + Assert.Equal (NavigationDirection.Backward, button.Direction); + + button.Dispose (); + } + + [Fact] + public void ChangingButtonType_ToMove_BindsAllArrowKeys () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.TopSize }; + + // Initially: only Up/Down + Assert.False (button.KeyBindings.TryGet (Key.CursorLeft, out _)); + Assert.False (button.KeyBindings.TryGet (Key.CursorRight, out _)); + + // Change to Move: all four + button.ButtonType = ArrangeButtons.Move; + + AssertKeyBoundToCommand (button, Key.CursorUp, Command.Up); + AssertKeyBoundToCommand (button, Key.CursorDown, Command.Down); + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + + button.Dispose (); + } + + [Fact] + public void SettingSameButtonType_DoesNotRebind () + { + ArrangerButton button = new () { ButtonType = ArrangeButtons.LeftSize }; + + // Setting same value is idempotent — no exception + button.ButtonType = ArrangeButtons.LeftSize; + + AssertKeyBoundToCommand (button, Key.CursorLeft, Command.Left); + AssertKeyBoundToCommand (button, Key.CursorRight, Command.Right); + Assert.False (button.KeyBindings.TryGet (Key.CursorUp, out _)); + + button.Dispose (); + } + + #endregion + + #region Glyph / Text + + [Fact] + public void Text_ReturnsCorrectGlyph_ForEachButtonType () + { + ArrangerButton button = new (); + + button.ButtonType = ArrangeButtons.Move; + Assert.Equal ($"{Glyphs.Move}", button.Text); + + button.ButtonType = ArrangeButtons.AllSize; + Assert.Equal ($"{Glyphs.SizeBottomRight}", button.Text); + + button.ButtonType = ArrangeButtons.LeftSize; + Assert.Equal ($"{Glyphs.SizeHorizontal}", button.Text); + + button.ButtonType = ArrangeButtons.RightSize; + Assert.Equal ($"{Glyphs.SizeHorizontal}", button.Text); + + button.ButtonType = ArrangeButtons.TopSize; + Assert.Equal ($"{Glyphs.SizeVertical}", button.Text); + + button.ButtonType = ArrangeButtons.BottomSize; + Assert.Equal ($"{Glyphs.SizeVertical}", button.Text); + + button.Dispose (); + } + + #endregion + + #region Constructor Defaults + + [Fact] + public void Constructor_SetsExpectedDefaults () + { + ArrangerButton button = new (); + + Assert.True (button.CanFocus); + Assert.Equal (1, button.Width!.GetAnchor (0)); + Assert.Equal (1, button.Height!.GetAnchor (0)); + Assert.True (button.NoDecorations); + Assert.True (button.NoPadding); + Assert.Null (button.ShadowStyle); + Assert.False (button.Visible); + + button.Dispose (); + } + + #endregion + + #region Helpers + + private static void AssertKeyBoundToCommand (ArrangerButton button, Key key, Command expectedCommand) + { + Assert.True (button.KeyBindings.TryGet (key, out KeyBinding binding), $"Expected key {key} to be bound on {button.ButtonType}"); + Assert.Contains (expectedCommand, binding.Commands); + } + + #endregion +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Arrangement/BorderArrangementKeyboardTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Arrangement/BorderArrangementKeyboardTests.cs index 04fcfd308f..883a78ebb1 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Arrangement/BorderArrangementKeyboardTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Arrangement/BorderArrangementKeyboardTests.cs @@ -381,4 +381,377 @@ public void EnterArrangeMode_Keyboard_OnlyShowsButtonsForEnabledArrangements () // Cleanup superView.Dispose (); } + + // Claude - Opus 4.5 + + /// + /// Helper to set up a view in arrange mode. + /// + private static (View superView, View view, BorderView borderView, Arranger arranger) SetupArrangeMode (ViewArrangement arrangement) + { + var superView = new View { Width = 80, Height = 25, CanFocus = true }; + + var view = new View + { + Arrangement = arrangement, + BorderStyle = LineStyle.Single, + CanFocus = true, + X = 20, + Y = 10, + Width = 40, + Height = 10 + }; + superView.Add (view); + superView.BeginInit (); + superView.EndInit (); + superView.Layout (); + superView.SetFocus (); + + var borderView = (BorderView)view.Border.View!; + Arranger arranger = borderView.Arranger; + arranger.EnterArrangeMode (ViewArrangement.Fixed); + + return (superView, view, borderView, arranger); + } + + /// + /// Tests that GetFocusedArrangement returns the correct arrangement for each focused button, + /// proving that Tab navigation updates Arranging correctly when focus moves between buttons. + /// + [Fact] + public void FocusingEachButton_UpdatesGetFocusedArrangement () + { + // Arrange - set up with multiple arrangement options + (View superView, View _, BorderView borderView, Arranger arranger) = + SetupArrangeMode (ViewArrangement.Movable | ViewArrangement.LeftResizable | ViewArrangement.RightResizable); + + // Act & Assert - Focus each button and verify GetFocusedArrangement returns correct value + ArrangerButton? moveButton = borderView.SubViews.OfType ().FirstOrDefault (b => b.ButtonType == ArrangeButtons.Move); + Assert.NotNull (moveButton); + moveButton.SetFocus (); + Assert.Equal (ViewArrangement.Movable, arranger.GetFocusedArrangement ()); + + ArrangerButton? leftButton = borderView.SubViews.OfType ().FirstOrDefault (b => b.ButtonType == ArrangeButtons.LeftSize); + Assert.NotNull (leftButton); + leftButton.SetFocus (); + Assert.Equal (ViewArrangement.LeftResizable, arranger.GetFocusedArrangement ()); + + ArrangerButton? rightButton = borderView.SubViews.OfType ().FirstOrDefault (b => b.ButtonType == ArrangeButtons.RightSize); + Assert.NotNull (rightButton); + rightButton.SetFocus (); + Assert.Equal (ViewArrangement.RightResizable, arranger.GetFocusedArrangement ()); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that focusing different buttons and calling HandleArrangeModeTab cycles + /// the Arranging state, proving Tab updates state correctly. + /// Also proves that repeated navigation stays within the arranger. + /// + [Fact] + public void Tab_CyclesFocus_AndUpdatesArranging () + { + // Arrange + (View superView, View _, BorderView borderView, Arranger arranger) = SetupArrangeMode (ViewArrangement.Movable | ViewArrangement.Resizable); + + List visibleButtons = borderView.SubViews.OfType ().Where (b => b.Visible).ToList (); + int buttonCount = visibleButtons.Count; + Assert.True (buttonCount >= 2, $"Need at least 2 visible buttons, got {buttonCount}"); + + // Simulate what Tab does: focus each button in order and verify Arranging updates + HashSet seenArrangements = []; + + for (var i = 0; i < buttonCount * 2; i++) + { + ArrangerButton button = visibleButtons [i % buttonCount]; + button.SetFocus (); + + // GetFocusedArrangement reads the currently focused button + ViewArrangement arrangement = arranger.GetFocusedArrangement (); + Assert.NotEqual (ViewArrangement.Fixed, arrangement); + seenArrangements.Add (arrangement); + } + + // Assert - cycling through all buttons should have visited multiple arrangements + Assert.True (seenArrangements.Count >= 2, $"Expected at least 2 different arrangements, saw {seenArrangements.Count}"); + + // All arrangements should be valid (not Fixed) + foreach (ViewArrangement arr in seenArrangements) + { + Assert.NotEqual (ViewArrangement.Fixed, arr); + } + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that Shift+Tab (backward) cycles through buttons and Arranging reflects each, + /// proving that repeated backward navigation stays within the arranger. + /// + [Fact] + public void ShiftTab_CyclesFocus_AndUpdatesArranging () + { + // Arrange + (View superView, View _, BorderView borderView, Arranger arranger) = SetupArrangeMode (ViewArrangement.Movable | ViewArrangement.Resizable); + + List visibleButtons = borderView.SubViews.OfType ().Where (b => b.Visible).ToList (); + int buttonCount = visibleButtons.Count; + Assert.True (buttonCount >= 2, $"Need at least 2 visible buttons, got {buttonCount}"); + + // Simulate backward Tab: focus each button in reverse order + HashSet seenArrangements = []; + + for (int i = buttonCount * 2 - 1; i >= 0; i--) + { + ArrangerButton button = visibleButtons [i % buttonCount]; + button.SetFocus (); + + ViewArrangement arrangement = arranger.GetFocusedArrangement (); + Assert.NotEqual (ViewArrangement.Fixed, arrangement); + seenArrangements.Add (arrangement); + } + + // Assert + Assert.True (seenArrangements.Count >= 2, $"Expected at least 2 different arrangements, saw {seenArrangements.Count}"); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that arrow keys operate on the view while in arrange mode (movable view). + /// + [Fact] + public void ArrowKeys_MoveView_WhenMovableArrangement () + { + // Arrange + (View superView, View view, BorderView _, Arranger arranger) = SetupArrangeMode (ViewArrangement.Movable); + + int originalX = view.X.GetAnchor (0); + int originalY = view.Y.GetAnchor (0); + + // Act - Press arrow keys + arranger.HandleArrangeModeRight (); + arranger.HandleArrangeModeDown (); + + // Assert - View should have moved + Assert.Equal (originalX + 1, view.X.GetAnchor (0)); + Assert.Equal (originalY + 1, view.Y.GetAnchor (0)); + + // Act - Move back + arranger.HandleArrangeModeLeft (); + arranger.HandleArrangeModeUp (); + + // Assert - View should be back at original position + Assert.Equal (originalX, view.X.GetAnchor (0)); + Assert.Equal (originalY, view.Y.GetAnchor (0)); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that arrow keys resize the view when in Resizable arrangement mode. + /// Uses Resizable-only arrangement (no Movable) so Arranging is set to Resizable directly. + /// + [Fact] + public void ArrowKeys_ResizeView_WhenResizableArrangement () + { + // Arrange - Resizable only, so Arranging starts as Resizable + (View superView, View view, BorderView _, Arranger arranger) = SetupArrangeMode (ViewArrangement.Resizable); + + // Verify arrange mode is set to the full resizable arrangement + Assert.True (arranger.IsArranging); + + int originalWidth = view.Width!.GetAnchor (0); + int originalHeight = view.Height!.GetAnchor (0); + + // Act - Press Right and Down to resize + arranger.HandleArrangeModeRight (); + arranger.HandleArrangeModeDown (); + + // Assert - View should have grown + Assert.Equal (originalWidth + 1, view.Width.GetAnchor (0)); + Assert.Equal (originalHeight + 1, view.Height.GetAnchor (0)); + + // Act - Press Left and Up to shrink + arranger.HandleArrangeModeLeft (); + arranger.HandleArrangeModeUp (); + + // Assert - View should be back to original size + Assert.Equal (originalWidth, view.Width.GetAnchor (0)); + Assert.Equal (originalHeight, view.Height.GetAnchor (0)); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that ExitArrangeMode (Quit command) exits arrange mode and resets state. + /// + [Fact] + public void Quit_ExitsArrangeMode_ResetsArranging () + { + // Arrange + var superView = new View { Width = 80, Height = 25 }; + + var view = new View + { + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + BorderStyle = LineStyle.Single, + X = 20, + Y = 10, + Width = 40, + Height = 10 + }; + superView.Add (view); + superView.BeginInit (); + superView.EndInit (); + + var borderView = (BorderView)view.Border.View!; + Arranger arranger = borderView.Arranger; + arranger.EnterArrangeMode (ViewArrangement.Fixed); + + // Verify we are in arrange mode + Assert.True (arranger.IsArranging); + Assert.NotEqual (ViewArrangement.Fixed, arranger.Arranging); + + // Act - Exit arrange mode (simulates Command.Quit / Esc key) + bool? result = arranger.ExitArrangeMode (); + + // Assert + Assert.True (result); + Assert.False (arranger.IsArranging); + Assert.Equal (ViewArrangement.Fixed, arranger.Arranging); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that ExitArrangeMode removes all arranger buttons from the border. + /// + [Fact] + public void Quit_ExitsArrangeMode_RemovesArrangerButtons () + { + // Arrange + var superView = new View { Width = 80, Height = 25 }; + + var view = new View + { + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + BorderStyle = LineStyle.Single, + X = 20, + Y = 10, + Width = 40, + Height = 10 + }; + superView.Add (view); + superView.BeginInit (); + superView.EndInit (); + + var borderView = (BorderView)view.Border.View!; + Arranger arranger = borderView.Arranger; + arranger.EnterArrangeMode (ViewArrangement.Fixed); + + // Verify buttons exist + Assert.True (borderView.SubViews.OfType ().Any (), "Arranger buttons should exist before exit"); + + // Act - Exit arrange mode + arranger.ExitArrangeMode (); + + // Assert - All arranger buttons should be removed + Assert.False (borderView.SubViews.OfType ().Any (), "Arranger buttons should be removed after exit"); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that ExitArrangeMode clears HotKeyBindings and resets CanFocus. + /// + [Fact] + public void Quit_ExitsArrangeMode_ClearsBindingsAndCanFocus () + { + // Arrange + var superView = new View { Width = 80, Height = 25 }; + + var view = new View + { + Arrangement = ViewArrangement.Movable, + BorderStyle = LineStyle.Single, + X = 20, + Y = 10, + Width = 40, + Height = 10 + }; + superView.Add (view); + superView.BeginInit (); + superView.EndInit (); + + var borderView = (BorderView)view.Border.View!; + Arranger arranger = borderView.Arranger; + arranger.EnterArrangeMode (ViewArrangement.Fixed); + + // Verify keyboard mode state + Assert.True (borderView.CanFocus, "Border should be focusable during arrange mode"); + + // Act + arranger.ExitArrangeMode (); + + // Assert + Assert.False (borderView.CanFocus, "Border should not be focusable after exit"); + Assert.Empty (borderView.HotKeyBindings.GetBindings ()); + + // Cleanup + superView.Dispose (); + } + + /// + /// Tests that after ExitArrangeMode, the view position and size remain unchanged + /// (quit does not undo any prior arrangement changes). + /// + [Fact] + public void Quit_ExitsArrangeMode_PreservesViewPositionAndSize () + { + // Arrange + var superView = new View { Width = 80, Height = 25 }; + + var view = new View + { + Arrangement = ViewArrangement.Movable, + BorderStyle = LineStyle.Single, + X = 20, + Y = 10, + Width = 40, + Height = 10 + }; + superView.Add (view); + superView.BeginInit (); + superView.EndInit (); + + var borderView = (BorderView)view.Border.View!; + Arranger arranger = borderView.Arranger; + arranger.EnterArrangeMode (ViewArrangement.Fixed); + + // Move the view + arranger.HandleArrangeModeRight (); + arranger.HandleArrangeModeDown (); + + int movedX = view.X.GetAnchor (0); + int movedY = view.Y.GetAnchor (0); + + // Act - Exit arrange mode + arranger.ExitArrangeMode (); + + // Assert - Position should be preserved (not reverted) + Assert.Equal (movedX, view.X.GetAnchor (0)); + Assert.Equal (movedY, view.Y.GetAnchor (0)); + + // Cleanup + superView.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/AdornmentSubViewLineCanvasTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/AdornmentSubViewLineCanvasTests.cs new file mode 100644 index 0000000000..c119623d47 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/AdornmentSubViewLineCanvasTests.cs @@ -0,0 +1,285 @@ +// Copilot + +using UnitTests; + +namespace ViewBaseTests.Draw; + +/// +/// Tests that SubViews of adornments with = true +/// get their border lines auto-joined with the SuperView's border lines. +/// BUG (#4854): runs AFTER +/// in the draw pipeline, so merged lines arrive too late. +/// +public class AdornmentSubViewLineCanvasTests (ITestOutputHelper output) : TestDriverBase +{ + /// + /// Simplest repro: A 7×3 View with only a top border line. A SubView in the Border + /// adds a double-line segment via SuperViewRendersLineCanvas. The ═ should appear + /// but doesn't because the merge happens after the LineCanvas was already rendered. + /// + [Fact] + public void BorderSubView_Lines_Not_Rendered () + { + // Copilot + + IDriver driver = CreateTestDriver (7, 3); + driver.Clip = new Region (driver.Screen); + + View view = new () + { + Id = "view", + Driver = driver, + Width = 7, + Height = 3, + BorderStyle = LineStyle.Single + }; + view.Border.Thickness = new Thickness (0, 1, 0, 0); + + View sub = new () + { + Id = "sub", + X = 2, + Y = 0, + Width = 3, + Height = 1, + SuperViewRendersLineCanvas = true + }; + view.Border.GetOrCreateView ().Add (sub); + + view.BeginInit (); + view.EndInit (); + view.Layout (); + + Rectangle subScreen = sub.FrameToScreen (); + + sub.LineCanvas.AddLine (new Point (subScreen.X, subScreen.Y), 3, Orientation.Horizontal, LineStyle.Double); + + view.Draw (); + + // Expected: ──═══── (double-line from SubView merged with view's single-line) + DriverAssert.AssertDriverContentsAre (""" + ──═══── + """, + output, + driver); + } + + [Fact] + public void LineCanvas_Drawn_By_Padding_SubView_ClippedWhenIntrudingIntoBorder () + { + IDriver driver = CreateTestDriver (4, 3); + + View superView = new () + { + Id = "superView", + Driver = driver, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Double + }; + superView.Padding.Thickness = new Thickness (0, 1, 0, 0); + + View subViewOfPadding = new () + { + Id = "subViewOfPadding", + X = -1, + Y = 0, + Width = 6, + Height = 1, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + subViewOfPadding.Border.Thickness = new Thickness (1); + subViewOfPadding.Border.Settings = BorderSettings.Default; + superView.Padding.GetOrCreateView ().Add (subViewOfPadding); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╔══╗ + ║──║ + ╚══╝ + """, + output, + driver); + } + + [Fact] + public void LineCanvas_Drawn_By_Border_SubView_ClippedWhenIntrudingInto_Margin_Of_SuperView () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver = CreateTestDriver (5, 5); + app.Driver!.Force16Colors = true; + + Window top = new () + { + Id = "top", + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None, + }; + + View superView = new () + { + Id = "tabs", + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Double, + Arrangement = ViewArrangement.Overlapped | ViewArrangement.Resizable + }; + superView.Border.Thickness = new Thickness (1, 0, 1, 0); + superView.Margin.Thickness = new Thickness (1); + top.Add (superView); + + View view = new () + { + Id = "tab", + Title = "abcdef", + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (), + SuperViewRendersLineCanvas = true + }; + view.Border.Thickness = new Thickness (0, 3, 0, 0); + view.Border.LineStyle = LineStyle.Single; + view.Border.Settings = BorderSettings.Tab; + ((BorderView)view.Border.View!).TabOffset = -2; + ((BorderView)view.Border.View!).TabLength = 5; + + superView.Add (view); + + app.Begin (top); + + // Should be: + // ╥─╥ + // ║c║ + // ╨─╨ + DriverAssert.AssertDriverContentsWithFrameAre (""" + ┌╥─╥┐ + │║c║│ + └╨─╨┘ + """, + output, + app.Driver); + + //DriverAssert.AssertDriverOutputIs (""" + // \x1b[39m\x1b[49m ┌╥─╥┐│║c║│└╨─╨┘ + // """, + // output, + // app.Driver); + } + + [Fact] + public void LineCanvas_Drawn_By_Padding_AutoJoinsWhenIntrudingIntoBorder () + { + IDriver driver = CreateTestDriver (4, 3); + + View superView = new () + { + Id = "superView", + Driver = driver, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Double + }; + superView.Padding.Thickness = new Thickness (0, 1, 0, 0); + + superView.Padding.GetOrCreateView ().DrawingContent += (s, e) => + { + var pv = s as PaddingView; + + pv?.Adornment?.Parent?.LineCanvas.AddLine (new Point (pv.FrameToScreen ().Location.X - 1, + pv.FrameToScreen ().Location.Y), + 4, + Orientation.Horizontal, + LineStyle.Single); + + e.Cancel = true; + }; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╔══╗ + ╟──╢ + ╚══╝ + """, + output, + driver); + } + + /// + /// Proves a Label with its own Border and SuperViewRendersLineCanvas = true + /// can serve as a tab header: the Label's border auto-joins with the View's + /// border, and the Label's text renders inside. + /// This is the foundation for replacing custom tab line drawing in BorderView + /// with a simple Label SubView. + /// + [Fact] + public void Label_With_Border_AutoJoins_Parent_Top () + { + // Copilot + + // 13×5 View with border thickness 3 on top (room for tab header). + // Label "Test" at offset 1 in the top border, with its own single-line border + // and SuperViewRendersLineCanvas = true. + // + // The Label's border auto-joins with the View's content border line. + // The ┴ junctions where Label sides meet the content border prove auto-join works. + + IDriver driver = CreateTestDriver (13, 5); + + View view = new () + { + Id = "view", + Driver = driver, + Width = 13, + Height = 5, + BorderStyle = LineStyle.Single + }; + view.Border.Thickness = new Thickness (1, 3, 1, 1); + view.Border.Settings = BorderSettings.Default; + + Label tabLabel = new () + { + Id = "tabLabel", + Text = "Test", + X = 1, + Y = 0, + Width = 6, + Height = 3, + BorderStyle = LineStyle.Single, + SuperViewRendersLineCanvas = true + }; + tabLabel.Border.Thickness = new Thickness (1); + tabLabel.Border.Settings = BorderSettings.Default; + view.Border.GetOrCreateView ().Add (tabLabel); + + view.BeginInit (); + view.EndInit (); + view.Layout (); + view.Draw (); + + output.WriteLine ("Driver output:"); + output.WriteLine (driver.ToString ()); + + // The Label's border lines auto-join with the View's border: + // - ┴ junctions where Label side borders meet the content border (row 2) + // - ┌ and ┐ corners on the Label's top border (row 0) + // - View's border starts at the content border line (row 2) since + // the top 3 rows are all border thickness. + DriverAssert.AssertDriverContentsAre (""" + ┌────┐ + │Test│ + ┌┴────┴─────┐ + │ │ + └───────────┘ + """, + output, + driver); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Mouse/MouseHoldRepeatTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Mouse/MouseHoldRepeatTests.cs index 203a55a9f7..aa1ac58b36 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Mouse/MouseHoldRepeatTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Mouse/MouseHoldRepeatTests.cs @@ -12,28 +12,17 @@ namespace ViewBaseTests.MouseTests; [Trait ("Category", "Input")] public class MouseHoldRepeatTests (ITestOutputHelper output) { - private readonly ITestOutputHelper _output = output; - [Fact] public void MouseHoldRepeat_True_Press_Release_Starts_And_Stops_Timer () { // Arrange - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; TimedEvents timedEvents = new (); ApplicationMouse mouseGrabber = new (); view.MouseHoldRepeater = new MouseHoldRepeaterImpl (view, timedEvents, mouseGrabber); - Mouse mouse = new () - { - Position = new Point (5, 5), - Flags = MouseFlags.LeftButtonPressed - }; + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed }; // Act - Press button view.NewMouseEvent (mouse); @@ -56,12 +45,7 @@ public void MouseHoldRepeat_True_Press_Release_Starts_And_Stops_Timer () public void MouseHoldRepeat_True_Press_Release_Raises_Activating_Once () { // Arrange - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; TimedEvents timedEvents = new (); ApplicationMouse mouseGrabber = new (); @@ -75,11 +59,7 @@ public void MouseHoldRepeat_True_Press_Release_Raises_Activating_Once () e.Handled = true; }; - Mouse mouse = new () - { - Position = new Point (5, 5), - Flags = MouseFlags.LeftButtonPressed - }; + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed }; // Act - Press button view.NewMouseEvent (mouse); @@ -102,9 +82,7 @@ public void MouseHoldRepeat_False_Press_Release_Raises_Activating_Once () // Arrange View view = new () { - Width = 10, - Height = 10, - MouseHoldRepeat = null // false is the default; here for clarity + Width = 10, Height = 10, MouseHoldRepeat = null // false is the default; here for clarity }; TimedEvents timedEvents = new (); @@ -119,11 +97,7 @@ public void MouseHoldRepeat_False_Press_Release_Raises_Activating_Once () e.Handled = true; }; - Mouse mouse = new () - { - Position = new Point (5, 5), - Flags = MouseFlags.LeftButtonPressed - }; + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed }; // Act - Press button view.NewMouseEvent (mouse); @@ -144,11 +118,7 @@ public void MouseHoldRepeat_False_Press_Release_Raises_Activating_Once () public void MouseHoldRepeat_True_Then_False_Press_Release_Raises_Activating_Once () { // Arrange - View view = new () - { - Width = 10, - Height = 10 - }; + View view = new () { Width = 10, Height = 10 }; TimedEvents timedEvents = new (); ApplicationMouse mouseGrabber = new (); @@ -162,11 +132,7 @@ public void MouseHoldRepeat_True_Then_False_Press_Release_Raises_Activating_Once e.Handled = true; }; - Mouse mouse = new () - { - Position = new Point (5, 5), - Flags = MouseFlags.LeftButtonPressed - }; + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed }; // Act - Enable MouseHoldRepeat then disable it view.MouseHoldRepeat = MouseFlags.LeftButtonReleased; @@ -191,12 +157,7 @@ public void MouseHoldRepeat_True_Then_False_Press_Release_Raises_Activating_Once public void MouseHoldRepeat_True_Two_Press_Release_Cycles_Raises_Activating_Twice () { // Arrange - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; TimedEvents timedEvents = new (); ApplicationMouse mouseGrabber = new (); @@ -210,10 +171,7 @@ public void MouseHoldRepeat_True_Two_Press_Release_Cycles_Raises_Activating_Twic e.Handled = true; }; - Mouse mouse = new () - { - Position = new Point (5, 5) - }; + Mouse mouse = new () { Position = new Point (5, 5) }; // Act - First press/release cycle mouse.Flags = MouseFlags.LeftButtonPressed; @@ -244,12 +202,7 @@ public void MouseHoldRepeat_True_Two_Press_Release_Cycles_Raises_Activating_Twic public void MouseHoldRepeat_True_Press_Wait_Release_Raises_Activating_Multiple_Times () { // Arrange - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; TimedEvents timedEvents = new (); ApplicationMouse mouseGrabber = new (); @@ -263,11 +216,7 @@ public void MouseHoldRepeat_True_Press_Wait_Release_Raises_Activating_Multiple_T e.Handled = true; }; - Mouse mouse = new () - { - Position = new Point (5, 5), - Flags = MouseFlags.LeftButtonPressed - }; + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed }; // Act - Press button view.NewMouseEvent (mouse); @@ -292,7 +241,7 @@ public void MouseHoldRepeat_True_Press_Wait_Release_Raises_Activating_Multiple_T // Note: The timer invokes commands through MouseIsHeldDownTick which calls // RaiseCommandsBoundToButtonFlags internally Assert.True (activatingCount >= 3, $"Expected >= 3 activations, got {activatingCount}"); - _output.WriteLine ($"Expected >= 3 activations, got {activatingCount}"); + output.WriteLine ($"Expected >= 3 activations, got {activatingCount}"); view.Dispose (); } @@ -301,12 +250,7 @@ public void MouseHoldRepeat_True_Press_Wait_Release_Raises_Activating_Multiple_T public void MouseHoldRepeat_Changing_In_SubViews_Works_Correctly () { // Arrange - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonPressed - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonPressed }; Exception? exception = Record.Exception (() => new View { MouseHoldRepeat = view.MouseHoldRepeat }); // Inherit from parent Assert.Null (exception); @@ -338,13 +282,7 @@ public void MouseHoldRepeat_True_AppInjection_Press_Release_Raises_Activating_On IRunnable runnable = new Runnable (); - View view = new () - { - Width = 10, - Height = 10, - MouseHighlightStates = mouseState, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHighlightStates = mouseState, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; (runnable as View)?.Add (view); app.Begin (runnable); @@ -357,22 +295,12 @@ public void MouseHoldRepeat_True_AppInjection_Press_Release_Raises_Activating_On }; // Act - Press at (0, 0) - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 0) }); Assert.Equal (0, activatingCount); // Should not fire on press // Act - Release at (0, 0) - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 0) }); // Assert - Activating should be raised exactly once on release // Note: Clicked event is synthesized but ignored when MouseHoldRepeat is true @@ -391,12 +319,7 @@ public void MouseHoldRepeat_True_AppInjection_Two_Press_Release_Cycles_Raises_Ac IRunnable runnable = new Runnable (); - View view = new () - { - Width = 10, - Height = 10, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; (runnable as View)?.Add (view); app.Begin (runnable); @@ -409,36 +332,16 @@ public void MouseHoldRepeat_True_AppInjection_Two_Press_Release_Cycles_Raises_Ac }; // Act - First press/release cycle - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (0, 0) - }); - - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 0) }); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 0) }); Assert.Equal (1, activatingCount); // First release // Act - Second press/release cycle (will synthesize DoubleClicked) - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (0, 0) - }); - - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 0) }); + + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 0) }); // Assert - Activating should be raised twice (once per release) // DoubleClicked is synthesized but ignored when MouseHoldRepeat is true @@ -459,13 +362,7 @@ public void MouseHoldRepeat_True_AppInjection_Press_Wait_Release_Raises_Activati IRunnable runnable = new Runnable (); - View view = new () - { - Width = 10, - Height = 10, - MouseHighlightStates = mouseState, - MouseHoldRepeat = MouseFlags.LeftButtonReleased - }; + View view = new () { Width = 10, Height = 10, MouseHighlightStates = mouseState, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; (runnable as View)?.Add (view); app.Begin (runnable); @@ -473,10 +370,9 @@ public void MouseHoldRepeat_True_AppInjection_Press_Wait_Release_Raises_Activati view.MouseHoldRepeater = new MouseHoldRepeaterImpl (view, app.TimedEvents, app.Mouse); // Configure a simple repeating timeout for predictable testing (100ms interval) - view.MouseHoldRepeater.Timeout = new () + view.MouseHoldRepeater.Timeout = new Timeout { - Span = TimeSpan.FromMilliseconds (TIME_OUT_INTERVAL), - Callback = null! // Will be set by MouseHoldRepeaterImpl + Span = TimeSpan.FromMilliseconds (TIME_OUT_INTERVAL), Callback = null! // Will be set by MouseHoldRepeaterImpl }; var activatingCount = 0; @@ -488,12 +384,7 @@ public void MouseHoldRepeat_True_AppInjection_Press_Wait_Release_Raises_Activati }; // Act - Press button - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 0) }); Assert.Equal (0, activatingCount); // Should not fire on press @@ -504,20 +395,15 @@ public void MouseHoldRepeat_True_AppInjection_Press_Wait_Release_Raises_Activati { time.Advance (TimeSpan.FromMilliseconds (TIME_OUT_INTERVAL)); app.TimedEvents?.RunTimers (); - _output.WriteLine ($"After tick {i}: activatingCount={activatingCount}"); + output.WriteLine ($"After tick {i}: activatingCount={activatingCount}"); } // Act - Release button - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 0) }); // Assert - Should have >= 5 activations (5 from timer ticks + 1 from release) Assert.True (activatingCount >= 5, $"Expected >= 5 activations, got {activatingCount}"); - _output.WriteLine ($"Expected >= 5 activations, got {activatingCount}"); + output.WriteLine ($"Expected >= 5 activations, got {activatingCount}"); (runnable as View)?.Dispose (); } @@ -534,9 +420,7 @@ public void MouseHoldRepeat_False_AppInjection_Press_Release_Raises_Activating_O View view = new () { - Width = 10, - Height = 10, - MouseHoldRepeat = null // Default behavior + Width = 10, Height = 10, MouseHoldRepeat = null // Default behavior }; (runnable as View)?.Add (view); app.Begin (runnable); @@ -550,22 +434,12 @@ public void MouseHoldRepeat_False_AppInjection_Press_Release_Raises_Activating_O }; // Act - Press at (0, 0) - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = new Point (0, 0) }); Assert.Equal (0, activatingCount); // Default changed: should NOT fire on press (issue #4674) // Act - Release at (0, 0) - synthesizes Clicked event - app.InjectMouse ( - new () - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (0, 0) - }); + app.InjectMouse (new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = new Point (0, 0) }); // Assert - Activating should fire once on release Assert.Equal (1, activatingCount); @@ -574,4 +448,150 @@ public void MouseHoldRepeat_False_AppInjection_Press_Release_Raises_Activating_O } #endregion + + #region Visibility/Enabled Change During Hold + + // Claude - Opus 4.6 + + [Fact] + public void MouseHoldRepeat_ViewBecomesInvisible_StopsRepeat () + { + // Arrange + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; + + TimedEvents timedEvents = new (); + ApplicationMouse mouseGrabber = new (); + view.MouseHoldRepeater = new MouseHoldRepeaterImpl (view, timedEvents, mouseGrabber); + + var activatingCount = 0; + + view.Activating += (_, e) => + { + activatingCount++; + e.Handled = true; + }; + + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed, View = view }; + + // Act - Press button to start hold repeat + view.NewMouseEvent (mouse); + Assert.NotEmpty (timedEvents.Timeouts); + + // Simulate one tick to confirm repeat is working + KeyValuePair timeout = Assert.Single (timedEvents.Timeouts); + timeout.Value.Callback?.Invoke (); + Assert.True (activatingCount >= 1, "Should have at least one activation from tick"); + + int countBeforeHide = activatingCount; + + // Act - Make view invisible while mouse is held down + view.Visible = false; + + // Simulate another tick — should stop the repeat, not fire again + if (timedEvents.Timeouts.Count > 0) + { + KeyValuePair timeout2 = timedEvents.Timeouts.First (); + timeout2.Value.Callback?.Invoke (); + } + + // Assert - No additional activations after becoming invisible + Assert.Equal (countBeforeHide, activatingCount); + + // Assert - Timer should be stopped + Assert.Empty (timedEvents.Timeouts); + + view.Dispose (); + } + + [Fact] + public void MouseHoldRepeat_ViewBecomesDisabled_StopsRepeat () + { + // Arrange + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; + + TimedEvents timedEvents = new (); + ApplicationMouse mouseGrabber = new (); + view.MouseHoldRepeater = new MouseHoldRepeaterImpl (view, timedEvents, mouseGrabber); + + var activatingCount = 0; + + view.Activating += (_, e) => + { + activatingCount++; + e.Handled = true; + }; + + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed, View = view }; + + // Act - Press button to start hold repeat + view.NewMouseEvent (mouse); + Assert.NotEmpty (timedEvents.Timeouts); + + // Simulate one tick to confirm repeat is working + KeyValuePair timeout = Assert.Single (timedEvents.Timeouts); + timeout.Value.Callback?.Invoke (); + Assert.True (activatingCount >= 1, "Should have at least one activation from tick"); + + int countBeforeDisable = activatingCount; + + // Act - Disable view while mouse is held down + view.Enabled = false; + + // Simulate another tick — should stop the repeat, not fire again + if (timedEvents.Timeouts.Count > 0) + { + KeyValuePair timeout2 = timedEvents.Timeouts.First (); + timeout2.Value.Callback?.Invoke (); + } + + // Assert - No additional activations after becoming disabled + Assert.Equal (countBeforeDisable, activatingCount); + + // Assert - Timer should be stopped + Assert.Empty (timedEvents.Timeouts); + + view.Dispose (); + } + + [Fact] + public void MouseHoldRepeat_ViewBecomesInvisible_NoReleaseNeeded () + { + // Arrange - This verifies that the repeat stops without needing + // a mouse release event, which won't arrive because NewMouseEvent + // aborts for invisible views. + View view = new () { Width = 10, Height = 10, MouseHoldRepeat = MouseFlags.LeftButtonReleased }; + + TimedEvents timedEvents = new (); + ApplicationMouse mouseGrabber = new (); + view.MouseHoldRepeater = new MouseHoldRepeaterImpl (view, timedEvents, mouseGrabber); + + Mouse mouse = new () { Position = new Point (5, 5), Flags = MouseFlags.LeftButtonPressed, View = view }; + + // Act - Press button to start hold repeat + view.NewMouseEvent (mouse); + Assert.NotEmpty (timedEvents.Timeouts); + + // Act - Make view invisible (simulates what Tabs does when hiding scroll buttons) + view.Visible = false; + + // Act - Try to send release — NewMouseEvent will abort for invisible views, + // so this should NOT stop the timer through normal release handling + mouse.Flags = MouseFlags.LeftButtonReleased; + mouse.Handled = false; + view.NewMouseEvent (mouse); + + // The timer tick should detect the view is invisible and stop + if (timedEvents.Timeouts.Count > 0) + { + KeyValuePair timeout = timedEvents.Timeouts.First (); + timeout.Value.Callback?.Invoke (); + } + + // Assert - Timer should be stopped after tick detects invisible view + Assert.Empty (timedEvents.Timeouts); + + view.Dispose (); + } + + #endregion } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Navigation/AdornmentNavigationTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Navigation/AdornmentNavigationTests.cs index 4fb32b2941..becc244c8c 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Navigation/AdornmentNavigationTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Navigation/AdornmentNavigationTests.cs @@ -1,8 +1,8 @@ namespace ViewBaseTests.Navigation; /// -/// Tests for navigation into and out of Adornments (Padding, Border, Margin). -/// These tests prove that navigation to/from adornments is broken and need to be fixed. +/// Tests for navigation into and out of Adornments (Padding, Border, Margin). +/// These tests prove that navigation to/from adornments is broken and need to be fixed. /// public class AdornmentNavigationTests { @@ -14,13 +14,7 @@ public class AdornmentNavigationTests public void AdvanceFocus_Into_Padding_With_Focusable_SubView () { // Setup: View with a focusable subview in Padding - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Padding.Thickness = new Thickness (1); @@ -78,13 +72,7 @@ public void AdvanceFocus_Into_Padding_With_Focusable_SubView () public void AdvanceFocus_Out_Of_Padding_To_Content () { // Setup: View with focusable padding that has focus - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Padding.Thickness = new Thickness (1); @@ -138,13 +126,7 @@ public void AdvanceFocus_Out_Of_Padding_To_Content () public void AdvanceFocus_Backward_Into_Padding () { // Setup: View with focusable subviews in both content and padding - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Padding.Thickness = new Thickness (1); @@ -198,13 +180,7 @@ public void AdvanceFocus_Backward_Into_Padding () public void Padding_CanFocus_True_TabStop_TabStop_Should_Be_In_FocusChain () { // Setup: View with focusable Padding - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Padding.Thickness = new Thickness (1); view.Padding.GetOrCreateView (); @@ -234,13 +210,7 @@ public void Padding_CanFocus_True_TabStop_TabStop_Should_Be_In_FocusChain () public void AdvanceFocus_Into_Border_With_Focusable_SubView () { // Setup: View with a focusable subview in Border - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Border.Thickness = new Thickness (1); @@ -277,7 +247,7 @@ public void AdvanceFocus_Into_Border_With_Focusable_SubView () view.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabGroup); // Expected: One of them should have focus - var hasFocus = contentButton.HasFocus || borderButton.HasFocus; + bool hasFocus = contentButton.HasFocus || borderButton.HasFocus; Assert.True (hasFocus, "Either content or border button should have focus"); // Advance again @@ -304,13 +274,7 @@ public void AdvanceFocus_Into_Border_With_Focusable_SubView () public void Border_CanFocus_True_TabStop_TabGroup_Should_NOT_Be_In_FocusChain () { // Setup: View with focusable Border (default TabStop is TabGroup for Border) - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Border.GetOrCreateView (); view.Border.Thickness = new Thickness (1); view.Border.View?.CanFocus = true; @@ -337,13 +301,7 @@ public void Border_CanFocus_True_TabStop_TabGroup_Should_NOT_Be_In_FocusChain () public void Margin_CanFocus_True_Should_NOT_Be_In_FocusChain () { // Setup: View with focusable Margin - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Margin.GetOrCreateView (); view.Margin.Thickness = new Thickness (1); @@ -372,13 +330,7 @@ public void Margin_CanFocus_True_Should_NOT_Be_In_FocusChain () public void AdvanceFocus_Nested_Views_With_Adornment_SubViews () { // Setup: Nested views where parent has adornment subviews - View parent = new () - { - Id = "parent", - Width = 30, - Height = 30, - CanFocus = true - }; + View parent = new () { Id = "parent", Width = 30, Height = 30, CanFocus = true }; parent.Padding.Thickness = new Thickness (2); @@ -451,19 +403,11 @@ public void AdvanceFocus_Nested_Views_With_Adornment_SubViews () // Expected: Navigation should reach all elements including adornment subviews // This will likely show incomplete navigation, proving the bug exists - Assert.True ( - focusedIds.Count > 0, - "At least some navigation should occur (this test documents current behavior)" - ); + Assert.True (focusedIds.Count > 0, "At least some navigation should occur (this test documents current behavior)"); parent.Dispose (); } - #endregion - - #region TabGroup Behavior Tests - - #endregion #region Edge Cases @@ -474,13 +418,7 @@ public void AdvanceFocus_Nested_Views_With_Adornment_SubViews () public void AdvanceFocus_Padding_With_No_Thickness_Should_Not_Participate () { // Setup: View with Padding that has no thickness but has subviews - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; // Padding has default Thickness.Empty View paddingButton = new () @@ -531,13 +469,7 @@ public void AdvanceFocus_Padding_With_No_Thickness_Should_Not_Participate () public void AdvanceFocus_Disabled_Adornment_SubView_Should_Be_Skipped () { // Setup: View with disabled subview in Padding - View view = new () - { - Id = "view", - Width = 10, - Height = 10, - CanFocus = true - }; + View view = new () { Id = "view", Width = 10, Height = 10, CanFocus = true }; view.Padding.Thickness = new Thickness (1); diff --git a/Tests/UnitTestsParallelizable/ViewBase/Navigation/AllViewsNavigationTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Navigation/AllViewsNavigationTests.cs index 9c30a9b6a6..c8fba47055 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Navigation/AllViewsNavigationTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Navigation/AllViewsNavigationTests.cs @@ -26,7 +26,7 @@ public void AllViews_AtLeastOneNavKey_Advances (Type viewType) if (view is IDesignable designable) { - designable.EnableForDesign (); + // designable.EnableForDesign (); } IApplication app = Application.Create (); diff --git a/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs b/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs index cc87acda8f..abef857eaf 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/ViewCommandTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using UnitTests.Parallelizable; using Terminal.Gui.Tracing; using UnitTests; @@ -306,6 +305,576 @@ public void InvokeCommand_Command_Bound_Does_Not_Invoke_CommandNotBound () #endregion + #region Command Propagation Tests + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_DefaultIsEmpty () + { + View view = new (); + Assert.Equal ([], view.CommandsToBubbleUp); + } + + // Claude - Sonnet 4.5 + [Fact] + public void Accept_Command_DoesNotBubbleByDefault () + { + View superView = new (); + View subView = new (); + superView.Add (subView); + + var superViewAcceptingCalledCount = 0; + superView.Accepting += (_, _) => superViewAcceptingCalledCount++; + + subView.InvokeCommand (Command.Accept); + + Assert.Equal (0, superViewAcceptingCalledCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void Activate_Command_DoesNotBubbleByDefault () + { + View superView = new (); + View subView = new (); + superView.Add (subView); + + var superViewActivatingCalledCount = 0; + superView.Activating += (_, _) => superViewActivatingCalledCount++; + + subView.InvokeCommand (Command.Activate); + + Assert.Equal (0, superViewActivatingCalledCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_CanDisableAllPropagation () + { + View superView = new () { CommandsToBubbleUp = [] }; + View subView = new (); + superView.Add (subView); + + var superViewAcceptingCalledCount = 0; + superView.Accepting += (_, _) => superViewAcceptingCalledCount++; + + subView.InvokeCommand (Command.Accept); + + Assert.Equal (0, superViewAcceptingCalledCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_CanBeCustomized () + { + View superView = new () { CommandsToBubbleUp = [Command.Accept, Command.Activate] }; + View subView = new (); + superView.Add (subView); + + var superViewActivatingCalledCount = 0; + superView.Activating += (_, _) => superViewActivatingCalledCount++; + + subView.InvokeCommand (Command.Activate); + + Assert.Equal (1, superViewActivatingCalledCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblingUp_Fires_Activated_On_SuperView () + { + View superView = new () { CommandsToBubbleUp = [Command.Activate] }; + View subView = new (); + superView.Add (subView); + + var activatedCount = 0; + superView.Activated += (_, _) => activatedCount++; + + subView.InvokeCommand (Command.Activate); + + Assert.Equal (1, activatedCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblingUp_Fires_Activated_In_Deep_Hierarchy () + { + View grandSuperView = new () { CommandsToBubbleUp = [Command.Activate] }; + View superView = new () { CommandsToBubbleUp = [Command.Activate] }; + View subView = new (); + grandSuperView.Add (superView); + superView.Add (subView); + + var grandActivatedCount = 0; + grandSuperView.Activated += (_, _) => grandActivatedCount++; + + subView.InvokeCommand (Command.Activate); + + Assert.Equal (1, grandActivatedCount); + } + + // Claude - Opus 4.6 + [Fact] + public void ConsumeDispatch_Blocks_Further_Bubbling () + { + // OptionSelector uses ConsumeDispatch=true — activation should NOT + // propagate from its inner CheckBox to OptionSelector's SuperView + View superView = new () { CommandsToBubbleUp = [Command.Activate] }; + OptionSelector selector = new () { Labels = ["Option1", "Option2"] }; + superView.Add (selector); + + var superViewActivatingCount = 0; + superView.Activating += (_, _) => superViewActivatingCount++; + + // Activate an inner CheckBox with a binding (required for dispatch to occur) + CheckBox innerCb = selector.SubViews.OfType ().First (); + KeyBinding binding = new ([Command.Activate], Key.Space, innerCb); + CommandContext ctx = new (Command.Activate, new WeakReference (innerCb), binding); + innerCb.InvokeCommand (Command.Activate, ctx); + + // Consume-dispatch blocks propagation to SuperView + Assert.Equal (0, superViewActivatingCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_StopsWhenHandled () + { + View superView = new () { CommandsToBubbleUp = [Command.Accept] }; + View subView = new (); + superView.Add (subView); + + var superViewAcceptingCalledCount = 0; + superView.Accepting += (_, _) => superViewAcceptingCalledCount++; + + // SubView handles the command + subView.Accepting += (_, e) => e.Handled = true; + + subView.InvokeCommand (Command.Accept); + + // Should NOT propagate because subView handled it + Assert.Equal (0, superViewAcceptingCalledCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_WorksInDeepHierarchy () + { + View grandSuperView = new () { CommandsToBubbleUp = [Command.Accept] }; + View superView = new () { CommandsToBubbleUp = [Command.Accept] }; + View subView = new (); + + grandSuperView.Add (superView); + superView.Add (subView); + + var grandSuperViewAcceptingCalledCount = 0; + grandSuperView.Accepting += (_, _) => grandSuperViewAcceptingCalledCount++; + + var grandSuperViewAcceptedCalledCount = 0; + grandSuperView.Accepted += (_, _) => grandSuperViewAcceptedCalledCount++; + + subView.InvokeCommand (Command.Accept); + + // Should propagate all the way up + Assert.Equal (1, grandSuperViewAcceptingCalledCount); + Assert.Equal (1, grandSuperViewAcceptedCalledCount); + } + + // Claude - Sonnet 4.5 + [Fact] + public void CommandsToBubbleUp_StopsAtIntermediateHandler () + { + View grandSuperView = new () { CommandsToBubbleUp = [Command.Accept] }; + View superView = new () { CommandsToBubbleUp = [Command.Accept] }; + View subView = new (); + + grandSuperView.Add (superView); + superView.Add (subView); + + var grandSuperViewAcceptingCalledCount = 0; + grandSuperView.Accepting += (_, _) => grandSuperViewAcceptingCalledCount++; + + // SuperView handles it, so shouldn't propagate further + superView.Accepting += (_, e) => e.Handled = true; + + subView.InvokeCommand (Command.Accept); + + Assert.Equal (0, grandSuperViewAcceptingCalledCount); + } + + #region Command Propagation Tests — Padding + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromPaddingSubView_ToOwner () + { + // Arrange: ownerView has CommandsToBubbleUp and a subview inside Padding + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Accept] }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act + paddingSubView.InvokeCommand (Command.Accept); + + // Assert + Assert.Equal (1, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_DoesNotBubbleFromPaddingSubView_WhenOwnerHasNoCommandsToBubbleUp () + { + // Arrange: ownerView does NOT have Accept in CommandsToBubbleUp + View ownerView = new () { Width = 10, Height = 10 }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act + paddingSubView.InvokeCommand (Command.Accept); + + // Assert - should NOT bubble + Assert.Equal (0, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblesFromPaddingSubView_ToOwner () + { + // Arrange + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Activate] }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatingCount = 0; + ownerView.Activating += (_, _) => ownerActivatingCount++; + + // Act + paddingSubView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (1, ownerActivatingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblesFromPaddingSubView_ToOwner_Activated () + { + // Arrange + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Activate] }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatedCount = 0; + ownerView.Activated += (_, _) => ownerActivatedCount++; + + // Act + paddingSubView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (1, ownerActivatedCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromPaddingSubView_ThroughOwner_ToGrandSuperView () + { + // Arrange: grandSuperView → ownerView (with Padding subview) + View grandSuperView = new () { Width = 20, Height = 20, CommandsToBubbleUp = [Command.Accept] }; + + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Accept] }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + grandSuperView.Add (ownerView); + grandSuperView.BeginInit (); + grandSuperView.EndInit (); + + var grandAcceptingCount = 0; + grandSuperView.Accepting += (_, _) => grandAcceptingCount++; + + var grandAcceptedCount = 0; + grandSuperView.Accepted += (_, _) => grandAcceptedCount++; + + // Act + paddingSubView.InvokeCommand (Command.Accept); + + // Assert — should bubble all the way up + Assert.Equal (1, grandAcceptingCount); + Assert.Equal (1, grandAcceptedCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_HandledInPaddingSubView_DoesNotBubbleToOwner () + { + // Arrange + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Accept] }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Handle it at the subView level + paddingSubView.Accepting += (_, e) => e.Handled = true; + + // Act + paddingSubView.InvokeCommand (Command.Accept); + + // Assert — should NOT propagate + Assert.Equal (0, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_DoesNotBubbleFromPaddingSubView_WhenOwnerHasNoCommandsToBubbleUp () + { + // Arrange: ownerView does NOT have Activate in CommandsToBubbleUp + View ownerView = new () { Width = 10, Height = 10 }; + ownerView.Padding.Thickness = new Thickness (1); + + View paddingSubView = new () { Width = 5, Height = 1 }; + ownerView.Padding.GetOrCreateView ().Add (paddingSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatingCount = 0; + ownerView.Activating += (_, _) => ownerActivatingCount++; + + // Act + paddingSubView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (0, ownerActivatingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromPaddingView_ToOwner () + { + // Arrange: the PaddingView itself (not a subview of it) invokes the command + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Accept] }; + ownerView.Padding.Thickness = new Thickness (1); + + var paddingView = (PaddingView)ownerView.Padding.GetOrCreateView (); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act — invoke directly on PaddingView + paddingView.InvokeCommand (Command.Accept); + + // Assert + Assert.Equal (1, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblesFromPaddingView_ToOwner () + { + // Arrange: the PaddingView itself invokes the command + View ownerView = new () { Width = 10, Height = 10, CommandsToBubbleUp = [Command.Activate] }; + ownerView.Padding.Thickness = new Thickness (1); + + var paddingView = (PaddingView)ownerView.Padding.GetOrCreateView (); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatingCount = 0; + ownerView.Activating += (_, _) => ownerActivatingCount++; + + // Act + paddingView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (1, ownerActivatingCount); + } + + #endregion Command Propagation Tests — Padding + + #region Command Propagation Tests — Border + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromBorderSubView_ToOwner () + { + // Arrange: ownerView has CommandsToBubbleUp and a subview inside Border + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single, CommandsToBubbleUp = [Command.Accept] }; + + View borderSubView = new () { Width = 1, Height = 1 }; + ownerView.Border.GetOrCreateView ().Add (borderSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act + borderSubView.InvokeCommand (Command.Accept); + + // Assert + Assert.Equal (1, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblesFromBorderSubView_ToOwner () + { + // Arrange + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single, CommandsToBubbleUp = [Command.Activate] }; + + View borderSubView = new () { Width = 1, Height = 1 }; + ownerView.Border.GetOrCreateView ().Add (borderSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatingCount = 0; + ownerView.Activating += (_, _) => ownerActivatingCount++; + + // Act + borderSubView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (1, ownerActivatingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_DoesNotBubbleFromBorderSubView_WhenOwnerHasNoCommandsToBubbleUp () + { + // Arrange: ownerView does NOT have Accept in CommandsToBubbleUp + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single }; + + View borderSubView = new () { Width = 1, Height = 1 }; + ownerView.Border.GetOrCreateView ().Add (borderSubView); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act + borderSubView.InvokeCommand (Command.Accept); + + // Assert — should NOT bubble + Assert.Equal (0, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromBorderSubView_ThroughOwner_ToGrandSuperView () + { + // Arrange: grandSuperView → ownerView (with Border subview) + View grandSuperView = new () { Width = 20, Height = 20, CommandsToBubbleUp = [Command.Accept] }; + + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single, CommandsToBubbleUp = [Command.Accept] }; + + View borderSubView = new () { Width = 1, Height = 1 }; + ownerView.Border.GetOrCreateView ().Add (borderSubView); + + grandSuperView.Add (ownerView); + grandSuperView.BeginInit (); + grandSuperView.EndInit (); + + var grandAcceptingCount = 0; + grandSuperView.Accepting += (_, _) => grandAcceptingCount++; + + // Act + borderSubView.InvokeCommand (Command.Accept); + + // Assert — should bubble all the way up + Assert.Equal (1, grandAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Accept_BubblesFromBorderView_ToOwner () + { + // Arrange: the BorderView itself (not a subview) invokes the command + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single, CommandsToBubbleUp = [Command.Accept] }; + + var borderView = (BorderView)ownerView.Border.GetOrCreateView (); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerAcceptingCount = 0; + ownerView.Accepting += (_, _) => ownerAcceptingCount++; + + // Act — invoke directly on BorderView + borderView.InvokeCommand (Command.Accept); + + // Assert + Assert.Equal (1, ownerAcceptingCount); + } + + // Claude - Opus 4.6 + [Fact] + public void Activate_BubblesFromBorderView_ToOwner () + { + // Arrange + View ownerView = new () { Width = 10, Height = 10, BorderStyle = LineStyle.Single, CommandsToBubbleUp = [Command.Activate] }; + + var borderView = (BorderView)ownerView.Border.GetOrCreateView (); + + ownerView.BeginInit (); + ownerView.EndInit (); + + var ownerActivatingCount = 0; + ownerView.Activating += (_, _) => ownerActivatingCount++; + + // Act + borderView.InvokeCommand (Command.Activate); + + // Assert + Assert.Equal (1, ownerActivatingCount); + } + + #endregion Command Propagation Tests — Border + + #endregion Command Propagation Tests + #region GetSupportedCommands Tests [Fact] @@ -1466,4 +2035,274 @@ public void Values_NonIValue_View_Has_Empty_Values () #endregion + #region Bridge Cancellation Bug (PopoverMenus.cs line 192) + + // Claude - Opus 4.6 + /// + /// Replicates the BUGBUG at PopoverMenus.cs line 192: when a + /// relays an Activated event to an owner, and the owner (or its ancestor) tries to + /// cancel via OnActivating, the originator's state has already changed because the + /// bridge fires from the post-event (Activated), not the pre-event (Activating). + /// Also verifies that a BridgedCancellation trace warning is emitted. + /// Topology (uses only View base classes): + /// + /// ancestor (Activating handler cancels for specific source) + /// └── owner ← Bridge ← container (CommandsToBubbleUp=[Activate]) + /// └── toggleView (IValue, mutates in OnActivated) + /// + /// Expected: cancelling at the ancestor's Activating should prevent the state change. + /// Actual: toggleView.Value has already incremented by the time ancestor's Activating fires. + /// + [Fact] + public void Bridge_Ancestor_Cancel_OnActivating_Does_Not_Prevent_Originator_State_Change () + { + ListBackend traceBackend = new (); + using IDisposable scope = Trace.PushScope (TraceCategory.Command, traceBackend); + + // Arrange: toggleView inside container, bridged to owner, owner inside ancestor. + ToggleView toggleView = new () { Id = "toggleView" }; + + View container = new () { Id = "container" }; + container.CommandsToBubbleUp = [Command.Activate]; + container.Add (toggleView); + + View owner = new () { Id = "owner" }; + + View ancestor = new () { Id = "ancestor" }; + ancestor.CommandsToBubbleUp = [Command.Activate]; + ancestor.Add (owner); + + using CommandBridge bridge = CommandBridge.Connect (owner, container, Command.Activate); + + // Track the toggleView.Value at the moment ancestor's Activating fires. + int? valueAtAncestorActivating = null; + var ancestorActivatingFired = false; + + ancestor.Activating += (_, args) => + { + ancestorActivatingFired = true; + valueAtAncestorActivating = toggleView.Value; + + // Cancel — this should prevent further processing, + // but cannot undo the toggleView's state change. + args.Handled = true; + }; + + Assert.Equal (0, toggleView.Value); + + // Act: Activate the toggleView directly (simulates a user click on the inner view). + toggleView.InvokeCommand (Command.Activate); + + // Assert: The bridge should have caused ancestor.Activating to fire. + Assert.True (ancestorActivatingFired, "ancestor.Activating should have fired via bridge"); + + // has already been incremented. The cancellation is too late. + Assert.Equal (1, toggleView.Value); // State change already happened + Assert.Equal (1, valueAtAncestorActivating); // Was already 1 when ancestor saw it + +#if DEBUG + + // Verify the BridgedCancellation trace warning was emitted. + Assert.Contains (traceBackend.Entries, e => e.Phase == "BridgedCancellation" && e.Message!.Contains ("OnActivated")); +#endif + } + + // Claude - Opus 4.6 + /// + /// Contrast test: in the normal (non-bridge) bubble-up path, cancelling at the ancestor's + /// OnActivating DOES prevent the originator's state change, because TryBubbleUp + /// calls SuperView.InvokeCommand during RaiseActivating (the pre-event phase). + /// The originator's OnActivated only fires if RaiseActivating succeeds. + /// No BridgedCancellation trace warning should appear. + /// Topology: + /// + /// ancestor (Activating handler cancels) + /// └── toggleView (IValue, mutates in OnActivated) + /// + /// This proves the asymmetry: direct containment bubbling supports cancellation; + /// bridge-based bubbling does not. + /// + [Fact] + public void Direct_Ancestor_Cancel_OnActivating_Prevents_Originator_State_Change () + { + ListBackend traceBackend = new (); + using IDisposable scope = Trace.PushScope (TraceCategory.Command, traceBackend); + + // Arrange: toggleView inside ancestor (direct containment, no bridge). + ToggleView toggleView = new () { Id = "toggleView" }; + + View ancestor = new () { Id = "ancestor" }; + ancestor.CommandsToBubbleUp = [Command.Activate]; + ancestor.Add (toggleView); + + int? valueAtAncestorActivating = null; + var ancestorActivatingFired = false; + + ancestor.Activating += (_, args) => + { + ancestorActivatingFired = true; + valueAtAncestorActivating = toggleView.Value; + + // Cancel — in the direct path, this DOES prevent + // the originator's OnActivated from firing. + args.Handled = true; + }; + + Assert.Equal (0, toggleView.Value); + + // Act: Activate the toggleView directly. + toggleView.InvokeCommand (Command.Activate); + + // Assert: ancestor.Activating should have fired via TryBubbleUp. + Assert.True (ancestorActivatingFired, "ancestor.Activating should have fired via TryBubbleUp"); + + // In the direct containment path, cancellation at the ancestor DOES work: + // toggleView.OnActivated never fires, so Value remains 0. + Assert.Equal (0, toggleView.Value); + Assert.Equal (0, valueAtAncestorActivating); + + // No BridgedCancellation warning — this is a direct containment path. + Assert.DoesNotContain (traceBackend.Entries, e => e.Phase == "BridgedCancellation"); + } + + // Claude - Opus 4.6 + /// + /// Accept-side analog of the bridge cancellation bug: when a + /// relays an Accepted event to an owner, and the owner (or its ancestor) tries to + /// cancel via OnAccepting, the originator's state has already changed because the + /// bridge fires from the post-event (Accepted). + /// Verifies that a BridgedCancellation trace warning is emitted. + /// Topology: + /// + /// ancestor (Accepting handler cancels) + /// └── owner ← Bridge(Accept) ← acceptToggleView (mutates in OnAccepted) + /// + /// + [Fact] + public void Bridge_Ancestor_Cancel_OnAccepting_Does_Not_Prevent_Originator_State_Change () + { + ListBackend traceBackend = new (); + using IDisposable scope = Trace.PushScope (TraceCategory.Command, traceBackend); + + // Arrange: acceptToggleView bridged to owner, owner inside ancestor. + AcceptToggleView acceptToggleView = new () { Id = "acceptToggleView" }; + + View owner = new () { Id = "owner" }; + + View ancestor = new () { Id = "ancestor" }; + ancestor.CommandsToBubbleUp = [Command.Accept]; + ancestor.Add (owner); + + using CommandBridge bridge = CommandBridge.Connect (owner, acceptToggleView, Command.Accept); + + int? valueAtAncestorAccepting = null; + var ancestorAcceptingFired = false; + + ancestor.Accepting += (_, args) => + { + ancestorAcceptingFired = true; + valueAtAncestorAccepting = acceptToggleView.AcceptedCount; + + // Cancel — this should prevent further processing, + // but cannot undo the acceptToggleView's state change. + args.Handled = true; + }; + + Assert.Equal (0, acceptToggleView.AcceptedCount); + + // Act: Accept on the remote view. + acceptToggleView.InvokeCommand (Command.Accept); + + // Assert: The bridge should have caused ancestor.Accepting to fire. + Assert.True (ancestorAcceptingFired, "ancestor.Accepting should have fired via bridge"); + + // By the time ancestor's Accepting handler fires, acceptToggleView.OnAccepted + // has already been called. The cancellation is too late. + Assert.Equal (1, acceptToggleView.AcceptedCount); // State change already happened + Assert.Equal (1, valueAtAncestorAccepting); // Was already 1 when ancestor saw it + + // Verify the BridgedCancellation trace warning was emitted. +#if DEBUG + Assert.Contains (traceBackend.Entries, e => e.Phase == "BridgedCancellation" && e.Message!.Contains ("OnAccepted")); +#endif + } + + // Claude - Opus 4.6 + /// + /// Contrast test for Accept: in the normal (non-bridge) bubble-up path, cancelling at the + /// ancestor's OnAccepting DOES prevent the originator's state change, because + /// TryBubbleUp calls SuperView.InvokeCommand during RaiseAccepting + /// (the pre-event phase). No BridgedCancellation trace warning should appear. + /// Topology: + /// + /// ancestor (Accepting handler cancels) + /// └── acceptToggleView (mutates in OnAccepted) + /// + /// + [Fact] + public void Direct_Ancestor_Cancel_OnAccepting_Prevents_Originator_State_Change () + { + ListBackend traceBackend = new (); + using IDisposable scope = Trace.PushScope (TraceCategory.Command, traceBackend); + + // Arrange: acceptToggleView inside ancestor (direct containment, no bridge). + AcceptToggleView acceptToggleView = new () { Id = "acceptToggleView" }; + + View ancestor = new () { Id = "ancestor" }; + ancestor.CommandsToBubbleUp = [Command.Accept]; + ancestor.Add (acceptToggleView); + + int? valueAtAncestorAccepting = null; + var ancestorAcceptingFired = false; + + ancestor.Accepting += (_, args) => + { + ancestorAcceptingFired = true; + valueAtAncestorAccepting = acceptToggleView.AcceptedCount; + + // Cancel — in the direct path, this DOES prevent + // the originator's OnAccepted from firing. + args.Handled = true; + }; + + Assert.Equal (0, acceptToggleView.AcceptedCount); + + // Act: Accept on the view directly. + acceptToggleView.InvokeCommand (Command.Accept); + + // Assert: ancestor.Accepting should have fired via TryBubbleUp. + Assert.True (ancestorAcceptingFired, "ancestor.Accepting should have fired via TryBubbleUp"); + + // In the direct containment path, cancellation at the ancestor DOES work: + // acceptToggleView.OnAccepted never fires, so AcceptedCount remains 0. + Assert.Equal (0, acceptToggleView.AcceptedCount); + Assert.Equal (0, valueAtAncestorAccepting); + + // No BridgedCancellation warning — this is a direct containment path. + Assert.DoesNotContain (traceBackend.Entries, e => e.Phase == "BridgedCancellation"); + } + + #endregion + + #region Bridge Cancellation Test Helpers + + /// + /// A minimal view that tracks Accept-side state changes. + /// Increments in to + /// provide a trackable state change for bridge cancellation tests. + /// + private class AcceptToggleView : View + { + /// Gets the number of times has been called. + public int AcceptedCount { get; private set; } + + /// + protected override void OnAccepted (ICommandContext? commandContext) + { + base.OnAccepted (commandContext); + AcceptedCount++; + } + } + + #endregion } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Viewport/ViewScrollbarTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Viewport/ViewScrollbarTests.cs index 0a22489fa8..8eac874e3d 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Viewport/ViewScrollbarTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Viewport/ViewScrollbarTests.cs @@ -1,3 +1,5 @@ +using System.Collections.ObjectModel; + namespace ViewBaseTests.Viewport; public class ViewScrollbarTests (ITestOutputHelper output) diff --git a/Tests/UnitTestsParallelizable/Views/ScrollButtonTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollButtonTests.cs new file mode 100644 index 0000000000..1fc993b201 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/ScrollButtonTests.cs @@ -0,0 +1,90 @@ + +namespace ViewsTests; + +/// +/// Tests for the bespoke behaviour of (glyph selection, orientation, +/// direction, and constructor defaults). Button base-class behaviour is covered by +/// . +/// +public class ScrollButtonTests +{ + [Fact] + public void Constructor_Defaults () + { + ScrollButton btn = new (); + + Assert.False (btn.CanFocus); + Assert.True (btn.NoDecorations); + Assert.True (btn.NoPadding); + Assert.Null (btn.ShadowStyle); + Assert.Equal (MouseFlags.LeftButtonReleased, btn.MouseHoldRepeat); + Assert.Equal (Orientation.Horizontal, btn.Orientation); + + btn.Dispose (); + } + + [Theory] + [InlineData (Orientation.Horizontal, NavigationDirection.Backward)] + [InlineData (Orientation.Horizontal, NavigationDirection.Forward)] + [InlineData (Orientation.Vertical, NavigationDirection.Backward)] + [InlineData (Orientation.Vertical, NavigationDirection.Forward)] + public void Title_Reflects_Direction_And_Orientation (Orientation orientation, NavigationDirection direction) + { + string expected = (orientation, direction) switch + { + (Orientation.Horizontal, NavigationDirection.Backward) => Glyphs.LeftArrow.ToString (), + (Orientation.Horizontal, NavigationDirection.Forward) => Glyphs.RightArrow.ToString (), + (Orientation.Vertical, NavigationDirection.Backward) => Glyphs.UpArrow.ToString (), + (Orientation.Vertical, NavigationDirection.Forward) => Glyphs.DownArrow.ToString (), + _ => throw new ArgumentOutOfRangeException () + }; + + ScrollButton btn = new () { Orientation = orientation, Direction = direction }; + + Assert.Equal (expected, btn.Title); + + btn.Dispose (); + } + + [Fact] + public void Setting_Direction_Updates_Glyph () + { + ScrollButton btn = new () { Orientation = Orientation.Horizontal }; + + btn.Direction = NavigationDirection.Backward; + Assert.Equal (Glyphs.LeftArrow.ToString (), btn.Title); + + btn.Direction = NavigationDirection.Forward; + Assert.Equal (Glyphs.RightArrow.ToString (), btn.Title); + + btn.Dispose (); + } + + [Fact] + public void Setting_Orientation_Updates_Glyph () + { + ScrollButton btn = new () { Direction = NavigationDirection.Forward }; + + btn.Orientation = Orientation.Horizontal; + Assert.Equal (Glyphs.RightArrow.ToString (), btn.Title); + + btn.Orientation = Orientation.Vertical; + Assert.Equal (Glyphs.DownArrow.ToString (), btn.Title); + + btn.Dispose (); + } + + [Fact] + public void Direction_SameValue_DoesNotChange_Title () + { + ScrollButton btn = new () { Orientation = Orientation.Vertical, Direction = NavigationDirection.Backward }; + string titleBefore = btn.Title; + + // Assign the same value — guard clause should prevent any mutation. + btn.Direction = NavigationDirection.Backward; + + Assert.Equal (titleBefore, btn.Title); + + btn.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsScrollingTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsScrollingTests.cs new file mode 100644 index 0000000000..63842ca810 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsScrollingTests.cs @@ -0,0 +1,1771 @@ +using UnitTests; + +namespace ViewsTests; + +// Claude - Opus 4.6 + +/// +/// Scrolling tests for the class, focused on . +/// Based on the scrolling drawings in plans/tabview-border-based-design.md. +/// +public class TabsScrollingTests (ITestOutputHelper output) : TestDriverBase +{ + /// + /// Step 0: All 5 tabs fit in 26 columns. No scroll needed. Tab1 selected. + /// + [Fact] + public void ScrollOffset_AllTabsFit_NoScroll_Tab1Selected () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╮────╮────╮────╮────╮ ┊ + ┊│Tab1│Tab2│Tab3│Tab4│Tab5│ ┊ + ┊│ ╰────┴────┴────┴────┴─╮┊ + ┊│Tab1 content │┊ + ┊│ │┊ + ┊╰──────────────────────────╯┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 1: Width reduced to 18. Tab1 selected. Tab4 clipped, Tab5 off-screen. + /// Right scroll indicator appears. + /// + [Fact] + public void ReducedWidth_Tab1Selected_RightIndicator () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 26, Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╮────╮────╮── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊│ ╰────┴────┴─► ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_ReducedWidth_Tab1Selected_Scroll_Right_1 () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 26, Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╮────╮────╮── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊│ ╰────┴────┴─► ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.ScrollOffset += 1; + superView.Layout (); + driver.ClearContents (); + superView.Draw (); + + Assert.Equal (1, tabs.ScrollOffset); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊────╮────╮────╮─── ┊ + ┊Tab1│Tab2│Tab3│Tab ┊ + ┊◄ ╰────┴────┴──► ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_ReducedWidth_Tab1Selected_Scroll_Right_Past_End () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 26, Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╮────╮────╮── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊│ ╰────┴────┴─► ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.ScrollOffset += 100; + superView.Layout (); + driver.ClearContents (); + superView.Draw (); + + // Assert.Equal (0, tabs.ScrollOffset); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊──╮────╮────╮────╮ ┊ + ┊b2│Tab3│Tab4│Tab5│ ┊ + ┊◄─┴────┴────┴────┤ ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_ReducedWidth_Tab5Selected_Scroll_Left_Past_Start () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 26, Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + tabs.Value = tab5; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊──╭────╭────╭────╮ ┊ + ┊b2│Tab3│Tab4│Tab5│ ┊ + ┊◄─┴────┴────╯ │ ┊ + ┊│Tab5 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.ScrollOffset -= 100; + superView.Layout (); + driver.ClearContents (); + superView.Draw (); + + // Assert.Equal (0, tabs.ScrollOffset); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╭────╭────╭── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊├────┴────┴────┴─► ┊ + ┊│Tab5 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 2: Width 18, Tab2 selected. No scroll offset change, just focus change. + /// + [Fact] + public void ScrollOffset_Width18_Tab2Selected_NoScrollChange () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab2; + + tabs.Width = 18; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╭────╮────╮── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊├────╯ ╰────┴─► ┊ + ┊│Tab2 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 4: Select Tab4 to scroll right, then select Tab2. EnsureTabVisible scrolls + /// the minimum needed. Tab1 is partially clipped, Tab2 is fully visible. + /// + [Fact] + public void ScrollOffset_SelectTab4ThenTab2_MinimalScroll () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + superView.Layout (); + + // Select Tab4 — EnsureTabVisible scrolls just enough to show Tab4 + tabs.Value = tab4; + superView.Layout (); + + // Select Tab2 — already visible, so no additional scrolling + tabs.Value = tab2; + superView.Layout (); + superView.Draw (); + + // _scrollOffset = 3 (minimum to show Tab4): Tab1 partially clipped, Tab5 partially visible + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊──╭────╮────╮────╮ ┊ + ┊b1│Tab2│Tab3│Tab4│ ┊ + ┊◄─╯ ╰────┴────► ┊ + ┊│Tab2 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 6: Select Tab4 with width 18. Tab1 partially clipped, Tab5 partially visible. + /// + [Fact] + public void ScrollOffset_Tab4Selected_ScrolledRight () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + // Select Tab4 — EnsureTabVisible should scroll right so Tab4 is visible + tabs.Value = tab4; + superView.Layout (); + superView.Draw (); + + // _scrollOffset = 3 (minimum to show Tab4): Tab1 clipped by 3 chars, Tab5 at edge + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊──╭────╭────╭────╮ ┊ + ┊b1│Tab2│Tab3│Tab4│ ┊ + ┊◄─┴────┴────╯ ► ┊ + ┊│Tab4 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 9: Select Tab1 after being scrolled. EnsureTabVisible scrolls back to offset 0. + /// + [Fact] + public void ScrollOffset_SelectTab1_ScrollsBackToStart () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + + tabs.Width = 18; + + // Scroll right by selecting Tab4 + tabs.Value = tab4; + superView.Layout (); + + // Select Tab1 — should scroll back to start + tabs.Value = tab1; + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╮────╮────╮── ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊│ ╰────┴────┴─► ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊╰────────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Step 11: Width back to 26. All tabs fit. No scroll indicators. Tab4 selected. + /// + [Fact] + public void FullWidth_AllFit_NoScrollIndicators () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab4; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────╭────╭────╭────╮────╮ ┊ + ┊│Tab1│Tab2│Tab3│Tab4│Tab5│ ┊ + ┊├────┴────┴────╯ ╰────┴─╮┊ + ┊│Tab4 content │┊ + ┊│ │┊ + ┊╰──────────────────────────╯┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + /// + /// Verifies scrolling back to first tab resets offsets. + /// + [Fact] + public void TabOffsets_AfterScrollBack_ResetToNatural () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + superView.Layout (); + + // Scroll right + tabs.Value = tab5; + superView.Layout (); + + // Scroll back + tabs.Value = tab1; + superView.Layout (); + + // Offsets should be back to natural (scrollOffset = 0) + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + Assert.Equal (15, ((BorderView)tab4.Border.View!).TabOffset); + Assert.Equal (20, ((BorderView)tab5.Border.View!).TabOffset); + + tabs.Dispose (); + } + + /// + /// Verifies TabOffset values are correct after scrolling right. + /// + [Fact] + public void TabOffsets_AfterScrollRight_AreNegativeForOffScreenTabs () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 18, Height = Dim.Fill () }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + superView.Layout (); + + // All tabs at natural offsets (scroll offset = 0) + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + + // Select tab5 — should scroll right since tab5 is off-screen at width 18 + tabs.Value = tab5; + superView.Layout (); + + // Tab1 should now have negative offset (scrolled off-screen) + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0, "Tab1 should have negative offset after scrolling right"); + Assert.True (((BorderView)tab5.Border.View!).TabOffset >= 0, "Tab5 should have non-negative offset (visible)"); + + tabs.Dispose (); + } + + /// + /// Creates 5 tabs with titles "Tab1" through "Tab5". Each has TabLength = 6 (4-char title + 2 border cells). + /// Total header span = 26 (5 × 6 − 4 shared edges). + /// + private static (View tab1, View tab2, View tab3, View tab4, View tab5) CreateFiveTabs () + { + View tab1 = new () { Title = "Tab1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab2", Text = "Tab2 content" }; + View tab3 = new () { Title = "Tab3", Text = "Tab3 content" }; + View tab4 = new () { Title = "Tab4", Text = "Tab4 content" }; + View tab5 = new () { Title = "Tab5", Text = "Tab5 content" }; + + return (tab1, tab2, tab3, tab4, tab5); + } + + #region Scrolling Tests (Side.Top) + + [Fact] + public void Top_TabsFit_NoScrollOffset () + { + // Three tabs that fit within 20 columns — no scrolling should occur + IDriver driver = CreateTestDriver (20, 5); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // All offsets should be normal (no scroll applied) + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Top_TabsOverflow_SelectingLastTab_ScrollsRight () + { + // Narrow Tabs (10 wide) with 3 tabs (each ~6 wide = 15 total span). + // Selecting the last tab should scroll so it's visible. + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Select tab3 — should trigger EnsureTabVisible which scrolls right + tabs.Value = tab3; + tabs.Layout (); + + // Tab3's absolute offset is 10, length is 6, so tabEnd = 16. + // Viewport width is 10, so _scrollOffset = 16 - 10 = 6. + // tab1.TabOffset = 0 - 6 = -6 + // tab2.TabOffset = 5 - 6 = -1 + // tab3.TabOffset = 10 - 6 = 4 + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0, "Tab1 should have scrolled off-screen (negative offset)"); + Assert.True (((BorderView)tab3.Border.View!).TabOffset >= 0, "Tab3 should be visible (non-negative offset)"); + + tabs.Dispose (); + } + + [Fact] + public void Top_ScrolledRight_SelectingFirstTab_ScrollsBackLeft () + { + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Scroll right by selecting tab3 + tabs.Value = tab3; + tabs.Layout (); + + // Now scroll back by selecting tab1 + tabs.Value = tab1; + tabs.Layout (); + + // Tab1 should be at offset 0 (scrolled back to start) + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Top_ScrolledRight_MiddleTabOffset_IsCorrect () + { + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill () }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭────╮──── + │Tab1│Tab2 + │ ╰───► + │ │ + ╰────────╯ + """, + output, + driver); + + // Select tab3 to scroll right + tabs.Value = tab3; + tabs.Layout (); + driver.ClearContents (); + tabs.Draw (); + //// 0123456789 + //DriverAssert.AssertDriverContentsAre (""" + // ◄ ► + // ────╭────╮ + // Tab2│Tab3│ + // ┬───╯ │ + // ╰────────╯ + // """, + // output, + // driver); + + // Verify exact offsets: _scrollOffset = tabEnd(tab3) - viewportWidth = 16 - 10 = 6 + Assert.Equal (-6, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (-1, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (4, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Top_ManyTabs_SelectingMiddleTab_ScrollsMinimally () + { + IDriver driver = CreateTestDriver (14, 5); + Tabs tabs = new () { Driver = driver, Width = 14, Height = 5 }; + + View tab1 = new () { Title = "A" }; + View tab2 = new () { Title = "B" }; + View tab3 = new () { Title = "C" }; + View tab4 = new () { Title = "D" }; + View tab5 = new () { Title = "E" }; + + // Each tab title is 1 char → TabLength = 3 (1 + 2 borders) + // Cumulative offsets: 0, 2, 4, 6, 8; last tabEnd = 8 + 3 = 11 + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Layout (); + + // All 5 tabs fit in 14 columns (total span = 11), so no scrolling + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (2, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (4, ((BorderView)tab3.Border.View!).TabOffset); + Assert.Equal (6, ((BorderView)tab4.Border.View!).TabOffset); + Assert.Equal (8, ((BorderView)tab5.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Top_InsertTab_UpdatesScrollBarSizing () + { + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + tabs.Add (tab1); + tabs.Layout (); + + // Single tab fits — offset is 0 + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + + // Insert many tabs to cause overflow + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.InsertTab (1, tab2); + tabs.InsertTab (2, tab3); + tabs.Layout (); + + // Tab1 should still be at 0 (no scroll yet since tab1 is selected) + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + + // Select tab3 to trigger scroll + tabs.Value = tab3; + tabs.Layout (); + + // Tab1 should now have a negative offset + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0); + + tabs.Dispose (); + } + + #endregion + + #region Scrolling Tests (Side.Bottom) + + [Fact] + public void Bottom_TabsFit_NoScrollOffset () + { + IDriver driver = CreateTestDriver (20, 5); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5, TabSide = Side.Bottom }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Bottom_TabsOverflow_SelectingLastTab_Scrolls () + { + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5, TabSide = Side.Bottom }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0, "Tab1 should have negative offset"); + Assert.True (((BorderView)tab3.Border.View!).TabOffset >= 0, "Tab3 should be visible"); + + tabs.Dispose (); + } + + [Fact] + public void Bottom_ScrolledRight_SelectingFirstTab_ScrollsBack () + { + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5, TabSide = Side.Bottom }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + tabs.Value = tab1; + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Bottom_AllFit_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill (), TabSide = Side.Bottom }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab1; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭──────────────────────────╮┊ + ┊│Tab1 content │┊ + ┊│ │┊ + ┊│ ╭────┬────┬────┬────┬─╯┊ + ┊│Tab1│Tab2│Tab3│Tab4│Tab5│ ┊ + ┊╰────╯────╯────╯────╯────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// When tabs overflow the available width with , a forward scroll indicator + /// (►) should appear on the separator line at the right edge. The separator line is the boundary + /// between content and tabs — for Bottom, it's the top line of the tab header area. + /// + [Fact] + public void Bottom_TabsOverflow_ScrollIndicatorAppearsOnSeparator () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 18, Height = Dim.Fill (), TabSide = Side.Bottom }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab1; + + superView.Layout (); + superView.Draw (); + + // The ► indicator should be on the separator line (the line between content and tabs). + // For Side.Bottom, the separator is the top border of the tab headers. + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────────────────╮ ┊ + ┊│Tab1 content │ ┊ + ┊│ │ ┊ + ┊│ ╭────┬────┬─► ┊ + ┊│Tab1│Tab2│Tab3│Ta ┊ + ┊╰────╯────╯────╯── ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// When scrolled right with , a backward scroll indicator (◄) should + /// appear on the separator line at the left edge. + /// + [Fact] + public void Bottom_ScrolledRight_BothScrollIndicatorsAppear () + { + IDriver driver = CreateTestDriver (30, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = 18, Height = Dim.Fill (), TabSide = Side.Bottom }; + superView.Add (tabs); + + (View tab1, View tab2, View tab3, View tab4, View tab5) = CreateFiveTabs (); + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab3; + tabs.ScrollOffset = 1; + + superView.Layout (); + superView.Draw (); + + // Both ◄ and ► should appear on the separator line (between content and tab headers). + // For Side.Bottom, the separator is the line with ╭────┬── connectors. + // They must NOT appear on the content border (the ╭─────╮ line at top). + var outputStr = driver.ToString (); + string [] lines = outputStr.Split ('\n'); + + // Find lines containing the indicators + var backOnSeparator = false; + var forwardOnSeparator = false; + + foreach (string line in lines) + { + // The separator line contains tab junction glyphs (┬, ╭ adjacent to tab text) + bool isSeparatorLine = line.Contains ('┬') || (line.Contains ('╭') && line.Contains ('┤')); + + if (line.Contains ('◄')) + { + backOnSeparator = isSeparatorLine; + } + + if (line.Contains ('►')) + { + forwardOnSeparator = isSeparatorLine; + } + } + + Assert.True (backOnSeparator, "◄ indicator should be on the separator line, not the content border"); + Assert.True (forwardOnSeparator, "► indicator should be on the separator line, not the content border"); + + tabs.Dispose (); + } + + #endregion + + #region Scrolling Tests (Side.Left) + + [Fact] + public void Left_TabsFit_NoScrollOffset () + { + IDriver driver = CreateTestDriver (20, 20); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 20, TabSide = Side.Left }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Left_TabsOverflow_SelectingLastTab_Scrolls () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Left }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0, "Tab1 should have negative offset"); + Assert.True (((BorderView)tab3.Border.View!).TabOffset >= 0, "Tab3 should be visible"); + + tabs.Dispose (); + } + + [Fact] + public void Left_ScrolledDown_SelectingFirstTab_ScrollsBack () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Left }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + tabs.Value = tab1; + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Left_AllFit_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (20, 20); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill (), TabSide = Side.Left }; + superView.Add (tabs); + + View tab1 = new () { Title = "T1", Text = "Content" }; + View tab2 = new () { Title = "T2", Text = "Content" }; + View tab3 = new () { Title = "T3", Text = "Content" }; + tabs.Add (tab1, tab2, tab3); + tabs.Value = tab1; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────────────────╮┊ + ┊│T Content │┊ + ┊│1 │┊ + ┊╰─╮ │┊ + ┊│T│ │┊ + ┊│2│ │┊ + ┊╰─┤ │┊ + ┊│T│ │┊ + ┊│3│ │┊ + ┊╰─┤ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ │ │┊ + ┊ ╰──────────────╯┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + #endregion + + #region Scrolling Tests (Side.Right) + + [Fact] + public void Right_TabsFit_NoScrollOffset () + { + IDriver driver = CreateTestDriver (20, 20); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 20, TabSide = Side.Right }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Right_TabsOverflow_SelectingLastTab_Scrolls () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Right }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + Assert.True (((BorderView)tab1.Border.View!).TabOffset < 0, "Tab1 should have negative offset"); + Assert.True (((BorderView)tab3.Border.View!).TabOffset >= 0, "Tab3 should be visible"); + + tabs.Dispose (); + } + + [Fact] + public void Right_ScrolledDown_SelectingFirstTab_ScrollsBack () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Right }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.Value = tab3; + tabs.Layout (); + + tabs.Value = tab1; + tabs.Layout (); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + [Fact] + public void Right_AllFit_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (20, 20); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = Dim.Fill (), TabSide = Side.Right }; + superView.Add (tabs); + + View tab1 = new () { Title = "T1", Text = "Content" }; + View tab2 = new () { Title = "T2", Text = "Content" }; + View tab3 = new () { Title = "T3", Text = "Content" }; + tabs.Add (tab1, tab2, tab3); + tabs.Value = tab1; + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ + ┊╭────────────────╮┊ + ┊│Content T│┊ + ┊│ 1│┊ + ┊│ ╭─╯┊ + ┊│ │T│┊ + ┊│ │2│┊ + ┊│ ├─╯┊ + ┊│ │T│┊ + ┊│ │3│┊ + ┊│ ├─╯┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊│ │ ┊ + ┊╰──────────────╯ ┊ + └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘ + """, + output, + driver); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// When tabs overflow the available height with , a forward scroll indicator + /// (▼) should appear on the separator line at the bottom edge. The separator line is the vertical + /// boundary between content and tabs — for Right, it's the left line of the tab header area. + /// + [Fact] + public void Right_TabsOverflow_ScrollIndicatorAppearsOnSeparator () + { + IDriver driver = CreateTestDriver (20, 12); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = 10, TabSide = Side.Right }; + superView.Add (tabs); + + View tab1 = new () { Title = "T1", Text = "Content" }; + View tab2 = new () { Title = "T2", Text = "Content" }; + View tab3 = new () { Title = "T3", Text = "Content" }; + View tab4 = new () { Title = "T4", Text = "Content" }; + View tab5 = new () { Title = "T5", Text = "Content" }; + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab1; + + superView.Layout (); + superView.Draw (); + + // The ▼ indicator should be on the separator column (vertical boundary between content and tabs). + // For Side.Right, the separator is the left border of the tab headers — column where ├ and ╭ appear. + // It must NOT appear on the content border's left edge (column 0 of the border viewport). + var outputStr = driver.ToString (); + string [] lines = outputStr.Split ('\n'); + + // Find the column where tab junctions (├ or ╭) appear — that's the separator column + int separatorCol = -1; + + foreach (string line in lines) + { + int idx = line.IndexOf ('├'); + + if (idx < 0) + { + idx = line.IndexOf ('╭'); + + // Skip the content border's ╭ at the start — we want the one in the tab area + if (idx >= 0 && idx < line.Length - 3 && line [idx + 1] == '─') + { + idx = line.IndexOf ('╭', idx + 1); + } + } + + if (idx < 0) + { + continue; + } + separatorCol = idx; + + break; + } + + Assert.True (separatorCol >= 0, "Could not find separator column (├ or ╭ glyph) in output"); + + // Find the ▼ indicator and verify it's on the separator column + int downArrowCol = -1; + + foreach (string line in lines) + { + int idx = line.IndexOf ('▼'); + + if (idx < 0) + { + continue; + } + downArrowCol = idx; + + break; + } + + Assert.True (downArrowCol >= 0, "▼ indicator not found in output"); + Assert.Equal (separatorCol, downArrowCol); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// When scrolled down with , a backward scroll indicator (▲) should + /// appear on the separator column, and a forward indicator (▼) at the bottom. + /// Both must be on the separator column, not on the content border. + /// + [Fact] + public void Right_ScrolledDown_BothScrollIndicatorsAppear () + { + IDriver driver = CreateTestDriver (20, 12); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + Tabs tabs = new () { Driver = driver, Width = Dim.Fill (), Height = 10, TabSide = Side.Right }; + superView.Add (tabs); + + View tab1 = new () { Title = "T1", Text = "Content" }; + View tab2 = new () { Title = "T2", Text = "Content" }; + View tab3 = new () { Title = "T3", Text = "Content" }; + View tab4 = new () { Title = "T4", Text = "Content" }; + View tab5 = new () { Title = "T5", Text = "Content" }; + tabs.Add (tab1, tab2, tab3, tab4, tab5); + tabs.Value = tab3; + tabs.ScrollOffset = 1; + + superView.Layout (); + superView.Draw (); + + // Both ▲ and ▼ should appear on the separator column (where ├ junctions are). + // They must NOT appear on column 0 (content border left edge). + var outputStr = driver.ToString (); + string [] lines = outputStr.Split ('\n'); + + // Find separator column from junction glyphs + int separatorCol = -1; + + foreach (string line in lines) + { + int idx = line.IndexOf ('├'); + + if (idx < 0) + { + continue; + } + separatorCol = idx; + + break; + } + + Assert.True (separatorCol >= 0, "Could not find separator column (├ glyph) in output"); + + // Verify ▲ is on the separator column + int upCol = -1; + + foreach (string line in lines) + { + int idx = line.IndexOf ('▲'); + + if (idx < 0) + { + continue; + } + upCol = idx; + + break; + } + + Assert.True (upCol >= 0, "▲ indicator not found in output"); + Assert.Equal (separatorCol, upCol); + + // Verify ▼ is on the separator column + int downCol = -1; + + foreach (string line in lines) + { + int idx = line.IndexOf ('▼'); + + if (idx < 0) + { + continue; + } + downCol = idx; + + break; + } + + Assert.True (downCol >= 0, "▼ indicator not found in output"); + Assert.Equal (separatorCol, downCol); + + tabs.Dispose (); + } + + #endregion + + #region TabSpacing Tests + + // Claude - Opus 4.6 + /// + /// Verifies the default TabSpacing is -1 and produces the current overlap behavior: + /// adjacent tabs share one border edge. + /// + [Fact] + public void TabSpacing_Default_IsNegativeOne () + { + IDriver driver = CreateTestDriver (30, 5); + Tabs tabs = new () { Driver = driver, Width = 30, Height = 5 }; + + Assert.Equal (-1, tabs.TabSpacing); + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Default overlap: each tab shares one border edge with its neighbor + // Tab1 width=6, offset=0; Tab2 offset=6-1=5; Tab3 offset=5+(6-1)=10 + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// Setting TabSpacing = -1 explicitly should produce the same offsets as the default. + /// + [Fact] + public void TabSpacing_NegativeOne_MatchesOriginalBehavior () + { + IDriver driver = CreateTestDriver (30, 5); + Tabs tabs = new () { Driver = driver, Width = 30, Height = 5, TabSpacing = -1 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Same as default: 0, 5, 10 + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// TabSpacing = 0 means tabs are placed edge-to-edge with no overlap or gap. + /// Each tab starts at the full width of the previous tab. + /// + [Fact] + public void TabSpacing_Zero_TabsEdgeToEdge () + { + IDriver driver = CreateTestDriver (30, 5); + Tabs tabs = new () { Driver = driver, Width = 30, Height = 5, TabSpacing = 0 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Each tab EffectiveTabLength is 6 (4 chars + 2 border cells). + // With spacing=0: Tab1 at 0, Tab2 at 6, Tab3 at 12. + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (6, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (12, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// TabSpacing = 1 inserts a 1-cell gap between adjacent tab headers. + /// + [Fact] + public void TabSpacing_Positive_InsertsGap () + { + IDriver driver = CreateTestDriver (40, 5); + Tabs tabs = new () { Driver = driver, Width = 40, Height = 5, TabSpacing = 1 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Each tab EffectiveTabLength is 6. + // With spacing=1: Tab1 at 0, Tab2 at 6+1=7, Tab3 at 7+6+1=14. + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (7, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (14, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + // Claude - Opus 4.6 + /// + /// Verifies GetTotalHeaderSpan computes correctly for various TabSpacing values. + /// 3 tabs, each EffectiveTabLength = 6. + /// + [Theory] + [InlineData (-1, 16)] // (6-1)+(6-1)+(6-1)+1 = 5+5+5+1 = 16 + [InlineData (0, 18)] // (6+0)+(6+0)+(6+0) = 18 + [InlineData (1, 20)] // (6+1)+(6+1)+(6+1)-1 = 7+7+7-1 = 20 + [InlineData (2, 22)] // (6+2)+(6+2)+(6+2)-2 = 8+8+8-2 = 22 + public void TabSpacing_TotalHeaderSpan_CorrectForAllValues (int spacing, int expectedSpan) + { + IDriver driver = CreateTestDriver (40, 5); + Tabs tabs = new () { Driver = driver, Width = 40, Height = 5, TabSpacing = spacing }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // GetTotalHeaderSpan is private — verify indirectly via tab offsets + last tab length + int lastOffset = ((BorderView)tab3.Border.View!).TabOffset; + int lastLength = ((BorderView)tab3.Border.View!).EffectiveTabLength; + int computedSpan = lastOffset + lastLength; + + Assert.Equal (expectedSpan, computedSpan); + + tabs.Dispose (); + } + + #endregion + + #region ScrollOffset Clamping Tests + + [Fact] + public void ScrollOffset_NegativeValue_ClampedToZero () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (20, 5); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + tabs.Add (tab1, tab2); + tabs.Layout (); + + tabs.ScrollOffset = -5; + + Assert.Equal (0, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_Zero_IsValid () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (20, 5); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + tabs.Add (tab1, tab2); + tabs.Layout (); + + tabs.ScrollOffset = 0; + + Assert.Equal (0, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_ExceedsScrollableContent_IsClamped () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Total header span is 16 (3 tabs × 6 - 2 shared edges). + // Setting an absurdly high value should be clamped. + tabs.ScrollOffset = 100; + + Assert.True (tabs.ScrollOffset < 100, "ScrollOffset should be clamped when exceeding scrollable content"); + Assert.True (tabs.ScrollOffset >= 0, "ScrollOffset should never be negative after clamping"); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_ValidMiddleValue_IsAccepted () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // A value within the valid range should be accepted as-is + tabs.ScrollOffset = 3; + + Assert.Equal (3, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_SameValue_DoesNotChange () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tabs.ScrollOffset = 3; + int offsetAfterFirst = tabs.ScrollOffset; + + // Setting same value again should be a no-op + tabs.ScrollOffset = 3; + + Assert.Equal (offsetAfterFirst, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_NoTabs_StaysAtZero () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + tabs.Layout (); + + tabs.ScrollOffset = 5; + + // With no tabs, there's nothing to scroll + Assert.Equal (0, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_AllTabsFit_ValueIsAccepted () + { + // Claude - Opus 4.6 + // When all tabs fit, there's no scrollbar constraining the value, + // so the setter accepts it. EnsureTabVisible will keep it at 0 + // when selecting tabs, but direct assignment is not clamped here. + IDriver driver = CreateTestDriver (20, 5); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5 }; + + View tab1 = new () { Title = "A" }; + View tab2 = new () { Title = "B" }; + tabs.Add (tab1, tab2); + tabs.Layout (); + + // Two tiny tabs (TabLength = 3 each, total span = 5) fit in 20 columns. + tabs.ScrollOffset = 3; + + Assert.Equal (3, tabs.ScrollOffset); + + tabs.Dispose (); + } + + [Fact] + public void ScrollOffset_UpdatesTabOffsets () + { + // Claude - Opus 4.6 + IDriver driver = CreateTestDriver (10, 5); + Tabs tabs = new () { Driver = driver, Width = 10, Height = 5 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + // Before scrolling — natural offsets + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + + // After scrolling — offsets shift by scroll amount + tabs.ScrollOffset = 4; + + Assert.Equal (-4, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (1, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (6, ((BorderView)tab3.Border.View!).TabOffset); + + tabs.Dispose (); + } + + #endregion +} diff --git a/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs b/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs new file mode 100644 index 0000000000..faa766fd38 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TabView/TabsTests.cs @@ -0,0 +1,907 @@ +using UnitTests; + +namespace ViewsTests; + +// Claude - Opus 4.6 + +/// +/// Tests for the class. +/// +public class TabsTests (ITestOutputHelper output) : TestDriverBase +{ + [Fact] + public void Add_View_ConfiguresAsTabs () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.True (tab1.CanFocus); + Assert.Equal (TabBehavior.TabStop, tab1.TabStop); + Assert.Equal (BorderSettings.Tab | BorderSettings.Title, tab1.Border.Settings); + Assert.Equal (ViewArrangement.Overlapped, tab1.Arrangement); + Assert.True (tab1.SuperViewRendersLineCanvas); + } + + [Fact] + public void Add_View_Value_Is_First_Added () + { + Tabs tabs = new (); + + View tab1 = new () { Title = "Tab1" }; + tabs.Add (tab1); + Assert.True (tab1.HasFocus); + Assert.Same (tab1, tabs.Value); + + View tab2 = new () { Title = "Tab2" }; + tabs.Add (tab2); + Assert.True (tab1.HasFocus); + Assert.Same (tab1, tabs.Value); + } + + [Fact] + public void Add_View_Value_Is_First_Added_SuperView () + { + var superView = new View { CanFocus = true }; + Tabs tabs = new (); + superView.Add (tabs); + + View tab1 = new () { Title = "Tab1" }; + tabs.Add (tab1); + Assert.True (tab1.HasFocus); + Assert.Same (tab1, tabs.Value); + + View tab2 = new () { Title = "Tab2" }; + tabs.Add (tab2); + superView.Layout (); + + Assert.True (tab1.HasFocus); + Assert.Same (tab1, tabs.Value); + } + + [Fact] + public void Add_View_SetsBorderThickness_Bottom () + { + Tabs tabs = new () { TabSide = Side.Bottom }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (new Thickness (1, 1, 1, 3), tab1.Border.Thickness); + } + + [Fact] + public void Add_View_SetsBorderThickness_Left () + { + Tabs tabs = new () { TabSide = Side.Left }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (new Thickness (3, 1, 1, 1), tab1.Border.Thickness); + } + + [Fact] + public void Add_View_SetsBorderThickness_Right () + { + Tabs tabs = new () { TabSide = Side.Right }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (new Thickness (1, 1, 3, 1), tab1.Border.Thickness); + } + + [Fact] + public void Add_View_SetsBorderThickness_Top () + { + Tabs tabs = new () { TabSide = Side.Top }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (new Thickness (1, 3, 1, 1), tab1.Border.Thickness); + } + + [Fact] + public void Add_View_SetsLineStyle () + { + Tabs tabs = new () { TabLineStyle = LineStyle.Single }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (LineStyle.Single, tab1.BorderStyle); + } + + [Fact] + public void Add_View_SetsTabSide () + { + Tabs tabs = new () { TabSide = Side.Bottom }; + View tab1 = new () { Title = "Tab1" }; + + tabs.Add (tab1); + + Assert.Equal (Side.Bottom, ((BorderView)tab1.Border.View!).TabSide); + } + + [Fact] + public void Constructor_SetsExpectedDefaults () + { + Tabs tabs = new (); + + Assert.True (tabs.CanFocus); + Assert.Equal (Side.Top, tabs.TabSide); + Assert.Equal (LineStyle.Rounded, tabs.TabLineStyle); + Assert.Null (tabs.Value); + } + + [Fact] + public void EnableForDesign_CreatesTabs () + { + Tabs tabs = new (); + bool result = ((IDesignable)tabs).EnableForDesign (); + + Assert.True (result); + Assert.Equal (4, tabs.TabCollection.Count ()); + Assert.NotNull (tabs.Value); + } + + [Fact] + public void EnableForDesign_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (46, 22); + + View superView = new () { Driver = driver, CanFocus = true, Width = Dim.Fill (), Height = Dim.Fill () }; + Tabs tabs = new (); + superView.Add (tabs); + + tabs.EnableForDesign (); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭─────────╮──────────╮────────────╮──────────╮ + │Attribute│Line Style│Tab Settings│Add/Remove│ + │ ╰──────────┴────────────┴──────────┤ + │ │ + │┌───────────────────────────────────────────│ + │├┤Style├────────┐ │ + ││☐ Bold │ │ + ││☐ Faint │ │ + ││☐ Italic │ │ + ││☐ Underline │ │ + ││☐ Blink │ │ + ││☐ Reverse │ │ + ││☐ Strikethrough│ │ + ││ │ │ + ││ │ │ + ││ │ │ + ││ │ │ + │├───────────────┘ │ + ││ Sample Text │ + │└───────────────────────────────────────────│ + │ │ + ╰────────────────────────────────────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void App_EnableForDesign_DrawsCorrectly () + { + IApplication? app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + IDriver? driver = app.Driver; + Runnable runnable = new (); + + Tabs tabs = new (); + tabs.EnableForDesign (); + + runnable.Add (tabs); + app.Begin (runnable); + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭─────────╮──────────╮────────────╮──────────╮ + │Attribute│Line Style│Tab Settings│Add/Remove│ + │ ╰──────────┴────────────┴──────────┴─────────────────────────────────╮ + │ │ + │┌────────────────────────────────────────────────────────────────────────────┐│ + ││┌┤Foreground├─────────────────────────────────────────────┬┤Style├────────┐ ││ + │││H:▲ 0 │☐ Bold │ ││ + │││S:▲ 0 │☐ Faint │ ││ + │││V: ▲100 │☐ Italic │ ││ + │││Name: White │☐ Underline │ ││ + │││Hex:#FFFFFF ■ │☐ Blink │ ││ + ││├┼Background┼─────────────────────────────────────────────┤☐ Reverse │ ││ + │││H:▲ 0 │☐ Strikethrough│ ││ + │││S:▲ 0 │ │ ││ + │││V:▲ 0 │ │ ││ + │││Name: Black │ │ ││ + │││Hex:#000000 ■ │ │ ││ + ││└─────────────────────────────────────────────────────────┴───────────────┘ ││ + ││ Sample Text ││ + │└────────────────────────────────────────────────────────────────────────────┘│ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void IndexOf_ReturnsCorrectIndex () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + + Assert.Equal (0, tabs.IndexOf (tab1)); + Assert.Equal (1, tabs.IndexOf (tab2)); + Assert.Equal (2, tabs.IndexOf (tab3)); + } + + [Fact] + public void IndexOf_ReturnsMinusOne_ForUnknownView () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View unknown = new () { Title = "Unknown" }; + + tabs.Add (tab1); + + Assert.Equal (-1, tabs.IndexOf (unknown)); + } + + [Fact] + public void Remove_View_UpdatesTabCollection () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Remove (tab2); + + List ordered = tabs.TabCollection.ToList (); + Assert.Equal (2, ordered.Count); + Assert.Same (tab1, ordered [0]); + Assert.Same (tab3, ordered [1]); + + Assert.Equal (0, tabs.IndexOf (tab1)); + Assert.Equal (1, tabs.IndexOf (tab3)); + } + + [Fact] + public void TabCollection_ReturnsViewsInLogicalOrder () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + + List ordered = tabs.TabCollection.ToList (); + Assert.Equal (3, ordered.Count); + Assert.Same (tab1, ordered [0]); + Assert.Same (tab2, ordered [1]); + Assert.Same (tab3, ordered [2]); + } + + [Fact] + public void TabLineStyle_Change_UpdatesAllTabs () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + + tabs.TabLineStyle = LineStyle.Double; + + Assert.Equal (LineStyle.Double, tab1.BorderStyle); + Assert.Equal (LineStyle.Double, tab2.BorderStyle); + } + + // Claude - Opus 4.6 + [Fact] + public void TabLineStyle_None_ThenBack_RestoresRendering () + { + IDriver driver = CreateTestDriver (14, 5); + + Tabs tabs = new () { Driver = driver, Width = 14, Height = 5 }; + + View tab1 = new () { Title = "Tab1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab2", Text = "Tab2 content" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab1; + + // Draw with default Rounded style and capture expected output + tabs.Layout (); + tabs.Draw (); + + // Verify initial thickness is correct for top tabs (1, TabDepth=3, 1, 1) + Assert.Equal (new Thickness (1, 3, 1, 1), tab1.Border.Thickness); + Assert.Equal (new Thickness (1, 3, 1, 1), tab2.Border.Thickness); + + // Change to None and back to Rounded + tabs.TabLineStyle = LineStyle.None; + tabs.TabLineStyle = LineStyle.Rounded; + + // Thickness must be restored to tab-specific values, not generic (1,1,1,1) + Assert.Equal (new Thickness (1, 3, 1, 1), tab1.Border.Thickness); + Assert.Equal (new Thickness (1, 3, 1, 1), tab2.Border.Thickness); + + // Re-draw and verify rendering matches original + driver.ClearContents (); + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭────╮────╮ + │Tab1│Tab2│ + │ ╰────┴──╮ + │Tab1 content│ + ╰────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void TabSide_Change_UpdatesAllTabs () + { + Tabs tabs = new () { TabSide = Side.Top }; + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + + tabs.TabSide = Side.Bottom; + + Assert.Equal (Side.Bottom, ((BorderView)tab1.Border.View!).TabSide); + Assert.Equal (Side.Bottom, ((BorderView)tab2.Border.View!).TabSide); + Assert.Equal (new Thickness (1, 1, 1, 3), tab1.Border.Thickness); + Assert.Equal (new Thickness (1, 1, 1, 3), tab2.Border.Thickness); + } + + [Fact] + public void Top_ThreeTabs_Tab2Focused_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (20, 5); + + Tabs tabs = new () { Driver = driver, Width = 20, Height = 5 }; + + View tab1 = new () { Title = "Tab1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab2", Text = "Tab2 content" }; + View tab3 = new () { Title = "Tab3", Text = "Tab3 content" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Value = tab2; + + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsWithFrameAre ("╭────╭────╮────╮ \r\n" + + "│Tab1│Tab2│Tab3│ \r\n" + + "├────╯ ╰────┴───╮\r\n" + + "│Tab2 content │\r\n" + + "╰──────────────────╯", + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void Top_TwoTabs_Tab1Focused_HotKey_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (14, 5); + + Tabs tabs = new () { Driver = driver, Width = 14, Height = 5 }; + + View tab1 = new () { Title = "Tab_1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab _2", Text = "Tab2 content" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab1; + + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭────╮─────╮ + │Tab1│Tab 2│ + │ ╰─────┴─╮ + │Tab1 content│ + ╰────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void Top_TwoTabs_Tab1Focused_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (14, 5); + + Tabs tabs = new () { Driver = driver, Width = 14, Height = 5 }; + + View tab1 = new () { Title = "Tab1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab2", Text = "Tab2 content" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab1; + + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭────╮────╮ + │Tab1│Tab2│ + │ ╰────┴──╮ + │Tab1 content│ + ╰────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void Top_TwoTabs_Tab2Focused_DrawsCorrectly () + { + IDriver driver = CreateTestDriver (14, 5); + + Tabs tabs = new () { Driver = driver, Width = 14, Height = 5 }; + + View tab1 = new () { Title = "Tab1", Text = "Tab1 content" }; + View tab2 = new () { Title = "Tab2", Text = "Tab2 content" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab2; + + tabs.Layout (); + tabs.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ╭────╭────╮ + │Tab1│Tab2│ + ├────╯ ╰──╮ + │Tab2 content│ + ╰────────────╯ + """, + output, + driver); + + tabs.Dispose (); + } + + [Fact] + public void UpdateTabOffsets_ComputesCumulativeOffsets () + { + Tabs tabs = new (); + + // "Tab1" title -> TabLength = 6 (4 chars + 2 border cells) + View tab1 = new () { Title = "Tab1" }; + + // "Tab2" title -> TabLength = 6 + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + + // Tab1 starts at 0 + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + + // Tab2 starts at TabLength-1 = 5 (sharing one edge) + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + } + + [Fact] + public void UpdateTabOffsets_ThreeTabs () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + + Assert.Equal (0, ((BorderView)tab1.Border.View!).TabOffset); + Assert.Equal (5, ((BorderView)tab2.Border.View!).TabOffset); + Assert.Equal (10, ((BorderView)tab3.Border.View!).TabOffset); + } + + [Fact] + public void Value_SetsFocus () + { + IDriver driver = CreateTestDriver (); + + Tabs tabs = new () { Driver = driver, Width = 40, Height = 10 }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + tabs.Layout (); + + tabs.Value = tab2; + + Assert.Same (tab2, tabs.Value); + } + + [Fact] + public void ValueChanged_Fires () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab1; + + View? newValue = null; + tabs.ValueChanged += (_, args) => newValue = args.NewValue; + + tabs.Value = tab2; + + Assert.Same (tab2, newValue); + } + + [Fact] + public void ValueChanging_CanCancel () + { + Tabs tabs = new (); + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + tabs.Value = tab1; + + tabs.ValueChanging += (_, args) => args.Handled = true; + + tabs.Value = tab2; + + // Change was cancelled, value remains tab1 + Assert.Same (tab1, tabs.Value); + } + + [Fact] + public void Nav_Top_Command_Right_SelectsNextTab () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Top }; + + View tab1 = new () { Title = "Tab_11", CanFocus = true }; + tabs.Add (tab1); + View tab2 = new () { Title = "Tab_2", CanFocus = true }; + tabs.Add (tab2); + + (tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.SetFocus (); + Assert.True ((tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + tabs.InvokeCommand (Command.Right); + + Assert.True ((tab2.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Top_Command_Left_SelectsPreviousTab () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Top }; + + View tab1 = new () { Title = "Tab_11", CanFocus = true }; + tabs.Add (tab1); + View tab2 = new () { Title = "Tab_2", CanFocus = true }; + tabs.Add (tab2); + + (tab2.Border.View?.SubViews.ElementAt (0) as TitleView)?.SetFocus (); + Assert.True ((tab2.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + tabs.InvokeCommand (Command.Left); + + Assert.True ((tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Top_CursorRight_SelectsNextTab () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Top }; + + View tab1 = new () { Title = "Tab_11", CanFocus = true }; + tabs.Add (tab1); + View tab2 = new () { Title = "Tab_2", CanFocus = true }; + tabs.Add (tab2); + + (tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.SetFocus (); + Assert.True ((tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + (tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.NewKeyDownEvent (Key.CursorRight); + + Assert.True ((tab2.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + + tabs.Dispose (); + } + + [Theory] + [InlineData (Side.Left)] + [InlineData (Side.Right)] + public void Nav_Left_Or_Right_CursorDown_OnTabTitle_MovesToNextTab (Side tabSide) + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = tabSide }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tab1.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorDown); + + Assert.True (tab2.Border.View?.HasFocus ?? tab2.HasFocus); + + tabs.Dispose (); + } + + [Theory] + [InlineData (Side.Left)] + [InlineData (Side.Right)] + public void Nav_Left_Or_Right_CursorDown_OnLastTabTitle_WrapsToFirstTab (Side tabSide) + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = tabSide }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + tabs.Layout (); + + tab2.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorDown); + + Assert.True (tab1.Border.View?.HasFocus ?? tab1.HasFocus); + + tabs.Dispose (); + } + + [Theory] + [InlineData (Side.Left)] + [InlineData (Side.Right)] + public void Nav_Left_Or_Right_CursorUp_OnTabTitle_MovesToPreviousTab (Side tabSide) + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = tabSide }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + View tab3 = new () { Title = "Tab3" }; + + tabs.Add (tab1, tab2, tab3); + tabs.Layout (); + + tab3.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorUp); + + Assert.True (tab2.Border.View?.HasFocus ?? tab2.HasFocus); + + tabs.Dispose (); + } + + [Theory] + [InlineData (Side.Left)] + [InlineData (Side.Right)] + public void Nav_Left_Or_Right_CursorUp_OnFirstTabTitle_WrapsToLastTab (Side tabSide) + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = tabSide }; + + View tab1 = new () { Title = "Tab1" }; + View tab2 = new () { Title = "Tab2" }; + + tabs.Add (tab1, tab2); + tabs.Layout (); + + tab1.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorUp); + + Assert.True (tab2.Border.View?.HasFocus ?? tab2.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Left_CursorRight_OnTabTitle_MovesIntoTabContent () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Left }; + + View tab1 = new () { Title = "Tab1", CanFocus = true }; + View contentButton = new () { Title = "OK", CanFocus = true, Width = 4, Height = 1 }; + tab1.Add (contentButton); + + tabs.Add (tab1); + tabs.Layout (); + + tab1.Border.View?.SetFocus (); + Assert.True ((tab1.Border.View as BorderView)?.TitleView?.HasFocus); + + tabs.NewKeyDownEvent (Key.CursorRight); + + Assert.True (contentButton.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Left_CursorLeft_OnTabTitle_MovesToPreviousTab () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Left }; + + View tab1 = new () { Title = "Tab1", CanFocus = true }; + tabs.Add (tab1); + View tab2 = new () { Title = "Tab2", CanFocus = true }; + tabs.Add (tab2); + tabs.Layout (); + + (tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.SetFocus (); + Assert.True ((tab1.Border.View?.SubViews.ElementAt (0) as TitleView)?.HasFocus); + Assert.True (tab1.HasFocus); + + tabs.NewKeyDownEvent (Key.CursorLeft); + + Assert.True (tab2.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Right_CursorLeft_OnTabTitle_MovesIntoTabContent () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Right }; + + View tab1 = new () { Title = "Tab1" }; + View contentButton = new () { Title = "OK", CanFocus = true, Width = 4, Height = 1 }; + tab1.Add (contentButton); + + tabs.Add (tab1); + tabs.Layout (); + + tab1.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorLeft); + + Assert.True (contentButton.HasFocus); + + tabs.Dispose (); + } + + [Fact] + public void Nav_Right_CursorRight_OnTabTitle_MovesToNextTab () + { + IDriver driver = CreateTestDriver (20, 10); + Tabs tabs = new () { Driver = driver, Width = 20, Height = 10, TabSide = Side.Right }; + + View tab1 = new () { Title = "Tab1" }; + tabs.Add (tab1); + View tab2 = new () { Title = "Tab2" }; + tabs.Add (tab2); + tabs.Layout (); + + tab1.Border.View?.SetFocus (); + + tabs.NewKeyDownEvent (Key.CursorRight); + + Assert.True (tab2.HasFocus); + + tabs.Dispose (); + } + + // BUGBUG: This test should be failing because the same basic thing happens in user testing... + [Fact] + public void TitleView_Border_Does_Not_Overflow_Into_Tabs_Border () + { + IDriver driver = CreateTestDriver (7, 8); + + View superView = new () + { + Driver = driver, + CanFocus = true, + Width = Dim.Fill (), + Height = Dim.Fill (), + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + BorderStyle = LineStyle.Dotted + }; + + View tabHost = new () + { + Title = "H", + Height = Dim.Fill (), + Width = Dim.Fill (), + BorderStyle = LineStyle.Double, + SuperViewRendersLineCanvas = true + }; + superView.Add (tabHost); + + View tab = new () { Title = "A", Height = Dim.Fill (), Width = Dim.Fill () }; + tab.Border.Settings = BorderSettings.Tab | BorderSettings.Title; + tab.Border.LineStyle = LineStyle.Single; + tab.Border.Thickness = new Thickness (1, 3, 1, 1); + ((BorderView)tab.Border.View!).TabSide = Side.Top; + ((BorderView)tab.Border.View!).TabOffset = 0; + + tabHost.Add (tab); + + superView.Layout (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┐ + ┊╔╡H╞╗┊ + ┊║┌─┐║┊ + ┊║│A│║┊ + ┊║├─┤║┊ + ┊║└─┘║┊ + ┊╚═══╝┊ + └┄┄┄┄┄┘ + """, + output, + driver); + + ((BorderView)tab.Border.View!).TabOffset = 1; + superView.Layout (); + driver.ClearContents (); + superView.Draw (); + + DriverAssert.AssertDriverContentsAre (""" + ┌┄┄┄┄┄┐ + ┊╔╡H╞╗┊ + ┊║ ┌─║┊ + ┊║ │A║┊ + ┊║┌┴┬║┊ + ┊║└─┘║┊ + ┊╚═══╝┊ + └┄┄┄┄┄┘ + """, + output, + driver); + superView.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/TabViewCommandTests.cs b/Tests/UnitTestsParallelizable/Views/TabViewCommandTests.cs deleted file mode 100644 index 39a83b3b6c..0000000000 --- a/Tests/UnitTestsParallelizable/Views/TabViewCommandTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using JetBrains.Annotations; - -namespace ViewsTests; - -[TestSubject (typeof (TabView))] -public class TabViewCommandTests -{ - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 - [Fact] - public void TabView_Command_Activate_SwitchesTab () - { - TabView tabView = new (); - Tab tab1 = new () { Text = "Tab1" }; - Tab tab2 = new () { Text = "Tab2" }; - tabView.AddTab (tab1, true); - tabView.AddTab (tab2, false); - tabView.BeginInit (); - tabView.EndInit (); - - // Activate switches to selected tab - // Verify setup - Assert.Equal (tab1, tabView.SelectedTab); - - tabView.Dispose (); - } - - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 - [Fact] - public void TabView_Command_Accept_FocusesTabContent () - { - TabView tabView = new (); - Tab tab = new () { Text = "Tab1" }; - Button button = new () { Text = "Button" }; - tab.View = button; - tabView.AddTab (tab, true); - tabView.BeginInit (); - tabView.EndInit (); - - // Accept focuses tab content - var acceptingFired = false; - - tabView.Accepting += (_, e) => - { - acceptingFired = true; - e.Handled = true; - }; - - bool? result = tabView.InvokeCommand (Command.Accept); - - Assert.True (acceptingFired); - Assert.True (result); - - tabView.Dispose (); - } - - // Claude - Opus 4.5 - // Behavior documented in docfx/docs/command.md - View Command Behaviors table - // This test verifies current behavior which may change per issue #4473 - [Fact] - public void TabView_Tab_Navigation_ChangesSelection () - { - TabView tabView = new (); - Tab tab1 = new () { Text = "Tab1" }; - Tab tab2 = new () { Text = "Tab2" }; - tabView.AddTab (tab1, true); - tabView.AddTab (tab2, false); - tabView.BeginInit (); - tabView.EndInit (); - - // Tab navigation changes selected tab - Assert.Equal (tab1, tabView.SelectedTab); - - // Select second tab - tabView.SelectedTab = tab2; - Assert.Equal (tab2, tabView.SelectedTab); - - tabView.Dispose (); - } - - // Claude - Opus 4.5 - // Regression test for infinite loop when activating a tab - // https://github.com/gui-cs/Terminal.Gui/issues/XXXX - [Fact] - public void TabView_Tab_Activating_DoesNotCauseInfiniteLoop () - { - TabView tabView = new () { Width = 40, Height = 10 }; - Tab tab1 = new () { Text = "Tab1" }; - Tab tab2 = new () { Text = "Tab2" }; - tabView.AddTab (tab1, true); - tabView.AddTab (tab2, false); - tabView.BeginInit (); - tabView.EndInit (); - tabView.LayoutSubViews (); // Trigger layout so Tab_Selecting is subscribed - - // Verify setup - Assert.Equal (tab1, tabView.SelectedTab); - - // Simulate tab activation (what happens when user clicks a tab or presses Enter/Space on it) - // This should switch to the tab without causing infinite recursion - var activationCount = 0; - tab2.Activating += (_, _) => activationCount++; - - // Invoke Activate command on tab2 - bool? result = tab2.InvokeCommand (Command.Activate); - - // Should activate exactly once - Assert.Equal (1, activationCount); - Assert.True (result); - - // Should have switched to tab2 - Assert.Equal (tab2, tabView.SelectedTab); - - tabView.Dispose (); - } -} diff --git a/Tests/UnitTestsParallelizable/Views/TabViewDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Views/TabViewDefaultKeyBindingsTests.cs deleted file mode 100644 index 4e028b9bd6..0000000000 --- a/Tests/UnitTestsParallelizable/Views/TabViewDefaultKeyBindingsTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Claude - Opus 4.6 - -using System.Reflection; - -namespace ViewsTests; - -/// -/// Tests for static property. -/// -public class TabViewDefaultKeyBindingsTests -{ - [Fact] - public void TabView_DefaultKeyBindings_IsNotNull () => Assert.NotNull (TabView.DefaultKeyBindings); - - [Fact] - public void TabView_DefaultKeyBindings_AllKeyStringsParseable () - { - foreach ((Command command, PlatformKeyBinding platformBinding) in TabView.DefaultKeyBindings!) - { - Key [] [] allKeyArrays = [platformBinding.All ?? [], platformBinding.Windows ?? [], platformBinding.Linux ?? [], platformBinding.Macos ?? []]; - - foreach (Key [] keyArray in allKeyArrays) - { - foreach (Key key in keyArray) - { - Assert.NotEqual (Key.Empty, key); - } - } - } - } - - [Fact] - public void TabView_DefaultKeyBindings_AllCommandNamesParseable () - { - foreach (Command command in TabView.DefaultKeyBindings!.Keys) - { - Assert.True (Enum.IsDefined (command), $"Command name '{command}' should parse to a Command enum value."); - } - } - - [Fact] - public void TabView_DefaultKeyBindings_DoesNotHaveConfigurationPropertyAttribute () - { - PropertyInfo? property = typeof (TabView).GetProperty (nameof (TabView.DefaultKeyBindings), BindingFlags.Public | BindingFlags.Static); - - Assert.NotNull (property); - - var attr = property!.GetCustomAttribute (); - - Assert.Null (attr); - } -} diff --git a/Tests/UnitTestsParallelizable/Views/TabViewTests.cs b/Tests/UnitTestsParallelizable/Views/TabViewTests.cs deleted file mode 100644 index 56a547db8a..0000000000 --- a/Tests/UnitTestsParallelizable/Views/TabViewTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -using JetBrains.Annotations; -using UnitTests; - -namespace ViewsTests; - -[TestSubject (typeof (TabView))] -public class TabViewTests : TestDriverBase -{ - /// - /// Verifies that measures tab text width using grapheme-aware - /// string.GetColumns() rather than EnumerateRunes().Sum(GetColumns). - /// A ZWJ family emoji should occupy 2 cells as a tab name, not 8. - /// - [Fact] - public void ShowTopLine_True_TabTextWidth_GraphemeCluster () - { - // setup - IDriver driver = CreateTestDriver (); - var tv = new TabView () - { - Driver = driver, - Id = "tv" - }; - tv.BeginInit (); - tv.EndInit (); - - string family = "\U0001F468\u200D\U0001F469\u200D\U0001F466\u200D\U0001F466"; // 👨‍👩‍👦‍👦 - - tv.AddTab ( - new () { Id = "emojiTab", DisplayText = family, View = new TextField { Id = "tf", Width = 4, Text = "hi" } }, - false - ); - tv.AddTab (new () { Id = "tab2", DisplayText = "B", View = new Label { Id = "lbl", Text = "hi2" } }, false); - tv.Width = 20; - tv.Height = 5; - - // execute - tv.Layout (); - tv.SetClipToScreen (); - tv.Draw (); - - // verify - string actual = driver.ToString ()!; - string [] lines = actual.Replace ("\r\n", "\n").Split ('\n'); - string? headerRow = lines.FirstOrDefault (l => l.Contains ('B') && l.Length > 1); - Assert.NotNull (headerRow); - - int bIndex = headerRow.IndexOf ('B'); - int bColumnPosition = headerRow [..bIndex].GetColumns (); - - Assert.True ( - bColumnPosition <= 8, - $"Tab 'B' should be near the start (emoji tab is 2 cells wide), but found at column {bColumnPosition}. Row: '{headerRow}'" - ); - } -} diff --git a/Tests/UnitTestsParallelizable/Views/TreeTableSourceTests.cs b/Tests/UnitTestsParallelizable/Views/TreeTableSourceTests.cs index 7539e4ec66..f43d6be1b6 100644 --- a/Tests/UnitTestsParallelizable/Views/TreeTableSourceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TreeTableSourceTests.cs @@ -17,13 +17,13 @@ public void CursorRight_ExpandsTreeNode_IncreaseRowCount () TableView tv = GetTreeTableView (out _); // Initially 2 root nodes visible. - Assert.Equal (2, tv.Table!.Rows); + Assert.Equal (2, tv.Table?.Rows); // CursorRight expands the selected (first) root node. tv.NewKeyDownEvent (Key.CursorRight); // Lost Highway has 2 child cars, so total rows = 4. - Assert.Equal (4, tv.Table.Rows); + Assert.Equal (4, tv.Table?.Rows); } [Fact] @@ -33,11 +33,11 @@ public void CursorLeft_CollapsesExpandedNode_RestoresRowCount () // Expand the first root node. tv.NewKeyDownEvent (Key.CursorRight); - Assert.Equal (4, tv.Table!.Rows); + Assert.Equal (4, tv.Table?.Rows); // CursorLeft collapses it. tv.NewKeyDownEvent (Key.CursorLeft); - Assert.Equal (2, tv.Table.Rows); + Assert.Equal (2, tv.Table?.Rows); } [Fact] @@ -49,12 +49,12 @@ public void MouseClick_OnExpandIndicator_ExpandsTreeNode () // Column 2 is the '+' expand indicator within the Name cell. tv.NewMouseEvent (new Mouse { Position = new Point (2, 2), Flags = MouseFlags.LeftButtonClicked }); - Assert.Equal (4, tv.Table!.Rows); + Assert.Equal (4, tv.Table?.Rows); // Clicking the same spot again collapses. tv.NewMouseEvent (new Mouse { Position = new Point (2, 2), Flags = MouseFlags.LeftButtonClicked }); - Assert.Equal (2, tv.Table.Rows); + Assert.Equal (2, tv.Table?.Rows); } [Fact] diff --git a/docfx/docs/arrangement.md b/docfx/docs/arrangement.md index 0fb73a2643..87e864b596 100644 --- a/docfx/docs/arrangement.md +++ b/docfx/docs/arrangement.md @@ -526,7 +526,7 @@ container.Add(window1, window2); **Z-Order:** - Order in `SubViews` determines Z-order - Later views appear above earlier views -- Use [View.BringSubviewToFront](~/api/Terminal.Gui.ViewBase.yml) to change Z-order +- Use [View.MoveSubViewToEnd](~/api/Terminal.Gui.ViewBase.View.yml) / [View.MoveSubViewToStart](~/api/Terminal.Gui.ViewBase.View.yml) to change Z-order **Navigation:** - `Tab` / `Shift+Tab` - Navigate within current overlapped view @@ -690,14 +690,15 @@ This ensures [LineCanvas](~/api/Terminal.Gui.Drawing.LineCanvas.yml) properly ha For overlapped views, manage Z-order with: ```csharp -// Bring a view to the front -container.BringSubviewToFront(window1); +// Bring a view to the front (end of SubViews = highest Z-order) +container.MoveSubViewToEnd (window1); -// Send a view to the back -container.SendSubviewToBack(window2); +// Send a view to the back (start of SubViews = lowest Z-order) +container.MoveSubViewToStart (window2); -// Check current order -int index = container.SubViews.IndexOf(window1); +// Move one position towards front/back +container.MoveSubViewTowardsEnd (window1); +container.MoveSubViewTowardsStart (window2); ``` ### Arrangement Events @@ -705,14 +706,10 @@ int index = container.SubViews.IndexOf(window1); Monitor arrangement changes by handling layout events: ```csharp -view.FrameChanged += (s, e) => +view.FrameChanged += (_, e) => { - Console.WriteLine($"View moved/resized to {e.NewValue}"); -}; - -view.LayoutComplete += (s, e) => -{ - // Layout has completed after arrangement change + // Fires when Frame changes (move or resize) + Console.WriteLine ($"View moved/resized to {e.CurrentValue}"); }; ``` diff --git a/docfx/docs/borders.md b/docfx/docs/borders.md new file mode 100644 index 0000000000..b632cc7024 --- /dev/null +++ b/docfx/docs/borders.md @@ -0,0 +1,438 @@ +# Borders Deep Dive + +[Border](~/api/Terminal.Gui.ViewBase.Border.yml) is the adornment that draws the visual frame, title, and tab header for a [View](~/api/Terminal.Gui.ViewBase.View.yml). It is one of the three adornment layers (Margin → Border → Padding) that surround a View's content area. + +This deep dive covers Border's rendering modes, the tab header system, and how `LineCanvas` auto-join produces flowing connected tab styles. + +## Table of Contents + +- [Border Basics](#border-basics) +- [Title Rendering by Thickness](#title-rendering-by-thickness) +- [Tab Style Borders](#tab-style-borders) +- [Tab Header Geometry](#tab-header-geometry) +- [Focus and Attributes](#focus-and-attributes) +- [Tab Offset and Clipping](#tab-offset-and-clipping) +- [Auto-Join with SuperViewRendersLineCanvas](#auto-join-with-superviewrenderslinecanvas) +- [Border Line Positioning](#border-line-positioning) +- [Implementation: TitleView](#implementation-tabtitleview) +- [Arrangement (Move and Resize)](#arrangement-move-and-resize) + +--- + +## Border Basics + +Every [View](~/api/Terminal.Gui.ViewBase.View.yml) has a `Border` adornment accessible via `View.Border`. The border's appearance is controlled by: + +- **`View.BorderStyle`** ([BorderStyle](~/api/Terminal.Gui.ViewBase.BorderStyle.yml)) — Helper property that sets `Border.LineStyle`, `Border.Settings`, and `Border.Thickness` to common presets for different line styles. +- **`View.Border.Settings`** ([BorderSettings](~/api/Terminal.Gui.ViewBase.BorderSettings.yml)) — Flags controlling title and tab rendering. +- **`View.Border.LineStyle`** ([LineStyle](~/api/Terminal.Gui.ViewBase.LineStyle.yml)) — Which line-drawing characters to use for the border. +- **`View.Border.Thickness`** ([Thickness](~/api/Terminal.Gui.Drawing.Thickness.yml)) — How many rows/columns each side occupies. + +When `BorderStyle` is set to a non-`None` value, it implicitly sets `Border.Settings` to include `BorderSettings.Title`, enabling title rendering based on the thickness of the top border. + +The border is rendered by [BorderView](~/api/Terminal.Gui.ViewBase.BorderView.yml), the internal `AdornmentView` created when `Border.GetOrCreateView()` is called (or implicitly when `BorderStyle` is set). + +--- + +## `BorderSettings.Default | BorderSettings.Title` — Title Rendering by Thickness + +The `Thickness` on the title side determines how many rows (or columns) the border occupies and how the title is rendered within that space. + +### `Thickness.Top == 1` — Title Inline on Border Line + +The title sits directly on the single top border line with `┤` and `├` connectors: + +``` +┌┤Title├──┐ +│ │ +└─────────┘ +``` + +### `Thickness.Top == 2` — Title with Cap Line (No Closing Edge) + +Two rows: a cap line above the title, then the main border line with the title. Corner connectors (`┘`/`└`) terminate — there is no closing line: + +``` + ╭─────╮ +╭┘Title└──╮ +│ │ +╰─────────╯ +``` + +### `Thickness.Top == 3` — Title in Enclosed Rectangle + +Three rows: top cap, title row, and a closing line. T-junction connectors (`┤`/`├`) continue through: + +``` + ╭─────╮ +╭┤Title├──╮ +│╰─────╯ │ +│ │ +╰─────────╯ +``` + +### `Thickness.Top ≥ 4` — Same as 3 with Extra Space + +Identical rendering to thickness 3, with additional empty rows above. The title rectangle is the same shape, just positioned higher. + +--- + +## `BorderSettings.Default | BorderSettings.Title | BorderSettings.Tab` - Tab Style Borders + +When `Border.Settings` includes `BorderSettings.Tab`, the border renders a **tab header** — a small rectangle containing the View's `Title` that protrudes from one side of the content border. This is the foundation for building tabbed interfaces. + +### Enabling Tab Style + +```csharp +view.BorderStyle = LineStyle.Rounded; +view.Border.Settings = BorderSettings.Tab | BorderSettings.Title; +view.Border.TabSide = Side.Top; +view.Border.TabOffset = 0; +view.Border.Thickness = new Thickness (1, 3, 1, 1); // 3 on the tab side +``` + +### Key Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Border.Settings` | `BorderSettings` | Must include `BorderSettings.Tab` to enable tab rendering | +| `BorderView.TabSide` | `Side` | Which side the tab header appears on (`Top`, `Bottom`, `Left`, `Right`) | +| `BorderView.TabOffset` | `int` | Offset along the tab side where the header starts (can be negative) | +| `BorderView.TabLength` | `int?` | Total length of the tab including borders. `null` = auto-compute from `Title` | +| `BorderView.TitleView` | `View?` | The `View` rendering the tab title (for custom mouse handling) | +| `Tabs.TabSpacing` | `int` | Gap between adjacent tab headers. `-1` = shared edge (default), `0` = edge-to-edge, `1+` = gap | + +When both `Tab` and `Title` are set, `TabLength` auto-computes as `Title.GetColumns() + 2` (title text width + two border columns). When only `Tab` is set without `Title`, `TabLength` defaults to `2` (just the border columns, no text). + +--- + +## Tab Header Geometry + +The tab-side thickness determines the **depth** of the header (`depth = sideThickness`). The `TitleView`'s border thickness caps its visual structure at depth ≥ 3 (cap line + title + optional closing edge), but the header is positioned `depth - 1` cells outward from the content border, so thickness > 3 adds empty space between the header and the content border. + +| Title-Side Thickness | Depth | Header Structure | +|----------------------|-------|------------------| +| 1 | 1 | No header (content border line only) | +| 2 | 2 | Cap line + title row | +| 3 | 3 | Cap line + title row + closing edge (focus-toggled) | +| 4+ | N | Same structure as 3, with extra space between header and content | + +### Visual Examples by Side (Thickness = 3, Depth = 2) + +All examples use `BorderStyle = Rounded`, `TabOffset = 0`. + +#### `Side.Top` + +**Unfocused** (closed — header closing line drawn): +``` +╭───╮ +│Tab│ +├───┴───╮ +│content│ +╰───────╯ +``` + +**Focused** (open — header closing line suppressed): +``` +╭───╮ +│Tab│ +│ ╰───╮ +│content│ +╰───────╯ +``` + +#### `Side.Bottom` + +**Unfocused:** +``` +╭───────╮ +│content│ +├───┬───╯ +│Tab│ +╰───╯ +``` + +**Focused:** +``` +╭───────╮ +│content│ +│ ╭───╯ +│Tab│ +╰───╯ +``` + +#### `Side.Left` + +Tab text is rendered vertically using `TextDirection.TopBottom_LeftRight`. + +**Unfocused:** +``` +╭─┬───────╮ +│T├content│ +│a│ │ +│b│ │ +╰─┴───────╯ +``` + +**Focused:** +``` +╭─────────╮ +│T content│ +│a │ +│b │ +╰─────────╯ +``` + +#### `Side.Right` + +**Unfocused:** +``` +╭───────┬─╮ +│content│T│ +│ │a│ +│ │b│ +╰───────┴─╯ +``` + +**Focused:** +``` +╭─────────╮ +│content T│ +│ a│ +│ b│ +╰─────────╯ +``` + +### Summary: Which Line Gets Suppressed + +| `TabSide` | Thickness = 3 on | Focused suppresses | TabOffset axis | +|-----------|------------------|--------------------|----------------| +| Top | `Thickness.Top` | Bottom line of header | Horizontal | +| Bottom | `Thickness.Bottom` | Top line of header | Horizontal | +| Left | `Thickness.Left` | Right line of header | Vertical | +| Right | `Thickness.Right` | Left line of header | Vertical | + +--- + +## Focus and Attributes + +Focus state affects tab rendering in two ways: + +### Border Geometry (Depth ≥ 3) + +At depth ≥ 3, the TitleView has a content-side edge (the line adjacent to the content area): +- **Focused**: Content-side edge is **suppressed** (open gap), visually connecting the header to the content +- **Unfocused**: Content-side edge is **drawn** (closed separator), visually separating the header from content + +**At depth < 3**, focused and unfocused tabs render with **identical border geometry** — only the title text attributes differentiate them. + +### Title Text Attributes + +The tab title text always uses the owning View's focus-appropriate attributes: + +| View State | Title Text | Hotkey Character | +|------------|------------|------------------| +| Focused | `VisualRole.Focus` | `VisualRole.HotFocus` | +| Unfocused | `VisualRole.Normal` | `VisualRole.HotNormal` | + +The `TitleView` uses `SuperViewRendersLineCanvas = true` and inherits color attributes from the owning View's scheme via the adornment hierarchy. + +--- + +## Tab Offset and Clipping + +`TabOffset` positions the tab header along the tab side. It can be positive (shifted right/down), zero (at the start), or negative (shifted left/up, partially off-screen). + +### Positive Offset (`TabOffset = 2`) + +``` + ╭───╮ + │Tab│ +╭─┴───┴──╮ +│content │ +╰────────╯ +``` + +### Negative Offset (`TabOffset = -1`) + +``` +───╮ +Tab│ +╭──┴────╮ +│content│ +╰───────╯ +``` + +### Fully Clipped (`TabOffset = -5`, tab length = 5) + +``` +╭───────╮ +│content│ +╰───────╯ +``` + +### Clipping Mechanism + +The `TitleView` is positioned at the **unclipped** header rectangle coordinates. The View system's natural viewport clipping handles partial visibility — both border lines and text are clipped automatically. No manual substring calculations or cap-line extensions are needed. + +--- + +## Auto-Join with SuperViewRendersLineCanvas + +The tab header is rendered by a `TitleView` SubView that has `SuperViewRendersLineCanvas = true`. This means its border lines merge into the parent View's `LineCanvas` instead of rendering independently. + +### How LineCanvas Auto-Join Works + +When two border lines overlap at the same `(x, y)` on the same `LineCanvas`, the system resolves them into the correct junction glyph: + +| Overlap | Result | +|---------|--------| +| `╮` + `╭` | `┬` (top T-junction) | +| `╯` + `╰` | `┴` (bottom T-junction) | +| horizontal end + vertical | `├` or `┤` | +| two verticals | continuous `│` | + +### Multi-Tab Auto-Join + +When multiple tab Views share a `LineCanvas` (via a common SuperView with `SuperViewRendersLineCanvas = true`), adjacent tab headers overlap by one column. LineCanvas automatically produces the flowing connected style: + +``` +Tab1's header: Tab2's header: Combined result: +╭────╮ ╭────╮ ╭────┬────╮ +│Tab1│ │Tab2│ │Tab1│Tab2│ +╰────╯ ╰────╯ ╰────┴────╯ + ↑ ↑ + Tab1 right overlaps Tab2 left + ╮ + ╭ → ┬ (top), ╯ + ╰ → ┴ (bottom) +``` + +With the selected tab open and unselected tabs closed: +``` +╭────┬────╮ +│Tab1│Tab2│ +│ ╰────┴───────╮ +│content for Tab1 │ +╰─────────────────╯ +``` + +--- + +## Border Line Positioning + +When `BorderSettings.Tab` is set, border line positioning differs from the standard model. + +**Non-tab sides** (the 3 sides without the tab): The content border line is drawn at the **outer edge** of the thickness. + +**Tab side**: The content border line is drawn at `thickness - 1` from the outer edge. The rows/columns between the outer edge and the content border line form the **tab header region**. + +| Title-Side Thickness | Content Border Position (Side.Top) | Tab Header Region | Depth | +|----------------------|------------------------------------|-------------------|-------| +| 1 | y = 0 | None (border only) | 1 | +| 2 | y = 1 | y = 0 (1 row) | 2 | +| 3 | y = 2 | y = 0–1 (2 rows) | 3 | +| 4 | y = 3 | y = 0–2 (3 rows) | 4 | +| N | y = N − 1 | y = 0 to N − 2 | N | + +General rule: content border at `y = thickness − 1`. Depth = thickness. The `TitleView` border structure is the same for depth ≥ 3 (cap + title + optional closing edge), but the header rectangle grows outward with increasing depth. + +--- + +## Implementation: TitleView + +The tab header is rendered by [TitleView](~/api/Terminal.Gui.ViewBase.TitleView.yml), a public sealed `View` subclass that implements [ITitleView](~/api/Terminal.Gui.ViewBase.ITitleView.yml). It handles both the header border frame and the title text. + +### Configuration + +```csharp +TitleView: + CanFocus = true + TabStop = TabBehavior.TabStop + SuperViewRendersLineCanvas = true // border lines merge into parent LineCanvas + BorderStyle = parentLineStyle // matching parent's line style + Border.Settings = BorderSettings.None // no title rendering on the view's own border + Border.Thickness = ComputeTitleViewThickness (side, depth, hasFocus) + Orientation = Horizontal or Vertical // based on TabSide +``` + +### TitleView Border Thickness (Side.Top) + +Computed by `TitleView.ComputeTitleViewThickness(side, depth, hasFocus)`: + +| Depth | Focus | Border.Thickness (left, top, right, bottom) | Notes | +|-------|-------|---------------------------------------------|-------| +| ≥ 3 | focused | `(1, 1, 1, 0)` | No bottom = open gap connecting header to content | +| ≥ 3 | unfocused | `(1, 1, 1, 1)` | Bottom = closed separator | +| 2 | any | `(1, 1, 1, 0)` | Cap line, no closing edge | +| 1 | any | `(1, 0, 1, 0)` | Side edges only, no cap | + +Other sides rotate accordingly (cap is always the outward edge, content-side is the inward edge). + +### Key Methods + +| Method | Purpose | +|--------|---------| +| `DrawTabBorder()` | Main entry. Computes geometry, positions TitleView, draws content border segments. | +| `EnsureTitleView()` | Lazy-creates the TitleView with correct configuration. | +| `TitleView.ComputeHeaderRect()` | Computes the unclipped header rectangle in content coordinates (static). | +| `TitleView.ComputeTitleViewThickness()` | Maps depth + side + focus → `Thickness` for the TitleView's border (static). | +| `TitleView.UpdateLayout()` | Sets frame, border thickness, text, orientation, and visibility from `TabLayoutContext`. | +| `AddTabSideContentBorder()` | Draws content border with gap/separator segments on the tab side (static). | + +### Draw Pipeline + +The tab rendering relies on the View draw pipeline ordering that enables adornment SubView border lines to auto-join with the parent View's border. The pipeline order is: + +``` +DoDrawAdornments → DoClearViewport → DoDrawSubViews → DoDrawText → DoDrawContent +→ DoDrawAdornmentsSubViews → DoRenderLineCanvas → DoDrawComplete +``` + +The key change: `DoDrawAdornmentsSubViews` now runs **before** `DoRenderLineCanvas`, so SubView border lines are merged into the parent's `LineCanvas` before it is rendered to screen. + +--- + +## Arrangement (Move and Resize) + +The [BorderView](~/api/Terminal.Gui.ViewBase.BorderView.yml) provides the interactive surface for mouse-driven move and resize operations. This is powered by the [Arranger](~/api/Terminal.Gui.ViewBase.Arranger.yml) class, which is lazily created by `BorderView` and handles all mouse hit-testing and drag operations. + +For a comprehensive guide to the arrangement system (including keyboard-based arrangement, overlapped layouts, and splitter patterns), see the [View Arrangement Deep Dive](arrangement.md). + +### How It Works + +1. Set [View.Arrangement](~/api/Terminal.Gui.ViewBase.View.yml) to enable move/resize flags +2. The Border must be visible (non-zero `Thickness`) for mouse interaction +3. `BorderView.Arranger` handles mouse events on the border edges + +### Quick Reference + +| Flag | Mouse Behavior | +|------|----------------| +| `ViewArrangement.Movable` | Drag the top border to move the view | +| `ViewArrangement.Resizable` | Drag any border edge to resize | +| `ViewArrangement.LeftResizable` | Drag the left border edge to resize width | +| `ViewArrangement.BottomResizable` | Drag the bottom border edge to resize height | + +When both `Movable` and `Resizable` are set, `Movable` takes precedence on the top edge (it cannot be resized). + +### Keyboard Arrangement + +Press `Ctrl+F5` (default, configurable via `Application.DefaultKeyBindings`) to enter **Arrange Mode**. Visual indicators appear on the border: + +- `◊` (move indicator) in the top-left corner +- `⇲` (resize indicator) in the bottom-right corner +- `↔` / `↕` (edge indicators) on resizable edges + +Use arrow keys to move or resize, `Tab` to cycle between modes, and `Esc` to exit. + +### Example + +```csharp +Window window = new () +{ + Title = "Drag Me!", + X = 10, Y = 5, + Width = 40, Height = 15, + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, + BorderStyle = LineStyle.Double +}; +``` diff --git a/docfx/docs/command.md b/docfx/docs/command.md index 0c41fc1190..8d22627873 100644 --- a/docfx/docs/command.md +++ b/docfx/docs/command.md @@ -624,7 +624,7 @@ When an inner activates (via click/space), th | **** | Removed | Removed | Not bound | Not bound | Not bound | | | | **** | Not bound | Not bound | Not bound | Not bound | Not bound | Not bound (removed) | | | **** | Not bound | Not bound | Forwards to next focusable peer | Not bound | Not bound | Not bound | Not bound | -| **** | Not bound | Not bound | | Handled by SubViews | Handled by SubViews | Handled by SubViews | Not bound | +| **** | Not bound | Not bound | | Handled by SubViews | Handled by SubViews | Handled by SubViews | Not bound | | **** | Handled by SubViews | Handled by SubViews | | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | | **** | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | Handled by SubViews | diff --git a/docfx/docs/drawing.md b/docfx/docs/drawing.md index 77981b2621..99e790604e 100644 --- a/docfx/docs/drawing.md +++ b/docfx/docs/drawing.md @@ -49,40 +49,42 @@ See [Layout](layout.md) for more details of the Terminal.Gui coordinate system. 2) Setting formatting options, such as `TextFormatter.Alignment`. 3) Calling `TextFormatter.Draw()`(Terminal.Gui.IDriver, System.Drawing.Rectangle,Terminal.Gui.Attribute,Terminal.Gui.Attribute,System.Drawing.Rectangle). -## Line drawing +## Line Drawing + +See [LineCanvas Deep Dive](#linecanvas-deep-dive) below. + +## View.Draw — Per-View Drawing Flow -1) Add the lines via -2) Either render the line canvas via `LineCanvas.GetMap()` or let the do so automatically (which enables automatic line joining across Views). +When `View.Draw()` is called on a view that has or `SubViewNeedsDraw` set, it executes these steps in order: -## When Drawing Occurs +1. **Draw Adornments** — Draws the and frames (fills and line art). Non-transparent is also drawn here. Transparent margins (those with shadows) are deferred to a second pass. +2. **Clip to Viewport** — Sets the clip region to the view's Viewport, preventing content from drawing outside it. +3. **Clear Viewport** — Fills the viewport with the background color. +4. **Draw SubViews** — Draws SubViews in reverse Z-order (earliest added = highest Z = drawn last, on top). For SubViews with `SuperViewRendersLineCanvas = true`, their is merged into the parent's canvas for unified intersection resolution. Overlapped SubViews' canvases are collected for painters'-algorithm compositing. +5. **Draw Text** — Renders `View.Text` via . +6. **Draw Content** — Raises `DrawingContent` (override `OnDrawingContent` for custom drawing). +7. **Draw Adornment SubViews** — Draws SubViews of and (e.g., tab headers, diagnostic indicators). Their lines are merged into the parent's canvas. +8. **Render LineCanvas** — Resolves all lines (including merged lines from adornments and SubViews) into glyphs via `GetCellMap`, then composites overlapped canvases using the painters' algorithm. +9. **Cache Clip for Margin** — If the has a shadow, the current clip is cached for the second-pass shadow render. +10. **DrawComplete & Clip Exclusion** — Raises `DrawComplete`. For opaque views, the entire frame is excluded from the clip. For transparent views, only the actually-drawn cells are excluded. This ensures later-drawn (lower-Z) views don't overwrite this view's content. -The MainLoop will iterate over all Views in the view hierarchy performing the following steps: +### Peer-View Draw Loop -0) Determines if or `SubViewNeedsDraw` are set. If neither is set, processing stops. -1) Sets the clip to the view's Frame. -2) Draws the and (but NOT the Margin). -3) Sets the clip to the view's Viewport. -4) Sets the Normal color scheme. -5) Calls Draw on any `SubViews`. -6) Draws `Text`. -7) Draws any non-text content (the base View does nothing.) -8) Sets the clip back to the view's Frame. -9) Draws (which may have been added to by any of the steps above). -10) Draws the and SubViews (just the subviews). (but NOT the Margin). -11) Draws the (but only if it does NOT have a shadow; margins with shadows are drawn later). -12) If the Margin has a shadow, the current Clip is cached so the shadow can be rendered in a second pass. -13) DrawComplete is raised. -14) The current View's Frame NOT INCLUDING the Margin is excluded from the current Clip region. +The static `View.Draw(views, force)` method orchestrates drawing a set of peer views (views sharing the same SuperView): -Most of the steps above can be overridden by developers using the standard [Terminal.Gui Cancellable Work Pattern](cancellable-work-pattern.md). For example, the base always clears the viewport. To override this, a subclass can override `OnClearingViewport()` to simply return `true`. Or, a user of `View` can subscribe to the `ClearingViewport` event and set the `Cancel` argument to `true`. +1. Each peer view's `Draw()` is called in order. +2. After all peers complete, `MarginView.DrawMargins()` performs a **second pass** that draws transparent margins (shadows). This ensures shadows render on top of all other content. The cached clip from step 9 above is restored for each margin so the shadow draws into the correct region. +3. `NeedsDraw` flags are cleared on all peers. -Then, after all views have been drawn, `Margin.DrawShadows` iterates the view hierarchy a second time, drawing only the shadows for margins that have a set. This second pass uses the cached Clip region from step 12 and ensures shadows render on top of all other content. Margins without shadows are fully drawn in the first pass and are not part of this second pass. +### Declaring that Drawing is Needed -### Declaring that drawing is needed +Call `SetNeedsDraw()` when something changes within a view's content area. Call `SetNeedsLayout()` when the viewport size needs recalculation. Both propagate up the view hierarchy via `SubViewNeedsDraw`. -If a View need to redraw because something changed within it's Content Area it can call `SetNeedsDraw()`. If a View needs to be redrawn because something has changed the size of the Viewport, it can call `SetNeedsLayout()`. +**Note**: These methods do not cause immediate drawing. They mark the view for redraw in the next MainLoop iteration. To force immediate drawing (typically only in tests), call . -**Note**: Calling `SetNeedsDraw()` does not immediately cause drawing to occur. It marks the view as needing to be redrawn, which will happen in the next MainLoop iteration. To force immediate drawing (typically only needed in tests), call . +### Overriding Draw Behavior + +Most draw steps can be overridden using the [Cancellable Work Pattern](cancellable-work-pattern.md). For example, to prevent the viewport from being cleared, override `OnClearingViewport()` to return `true`, or subscribe to the `ClearingViewport` event and set `Cancel = true`. ## Clipping @@ -212,9 +214,154 @@ Terminal.Gui supports text formatting using class defines the common set of glyphs used to draw checkboxes, lines, borders, etc... The default glyphs can be changed per-ThemeScope via . -## Line Drawing +## LineCanvas Deep Dive + +### What LineCanvas Does + +Terminal UI borders are built from Unicode box-drawing characters: `─`, `│`, `┌`, `┐`, `┼`, `├`, and dozens more. When two borders meet, the correct junction glyph must be selected. Doing this by hand is tedious and error-prone. + + solves this. You describe *lines* — start point, length, orientation, style — and the canvas automatically resolves every intersection into the correct Unicode glyph. Where a horizontal and vertical line meet, it produces a `┼`. Where three lines meet, a `├`. Corners, T-junctions, and crosses are all handled automatically. + +### Basic Usage + +```csharp +LineCanvas lc = new (); + +// Draw a 10-cell horizontal line +lc.AddLine (new Point (0, 0), 10, Orientation.Horizontal, LineStyle.Single); + +// Draw a 5-cell vertical line crossing at (4, 0) +lc.AddLine (new Point (4, 0), 5, Orientation.Vertical, LineStyle.Single); + +// Resolve all intersections and get the glyphs +Dictionary cells = lc.GetCellMap (); +// At (4, 0), this returns a ┬ (top-tee), not a ─ or │ +``` + +Each is always horizontal or vertical. It has a `Start` point, a `Length` (positive = right/down, negative = left/up, zero = a single junction point), an `Orientation`, a , and an optional color . + +### How Intersection Resolution Works + +When you call `GetCellMap()`, the canvas walks every point within its bounds: + +1. **Collect intersections.** For each point, every line that passes through it produces an describing *how* the line relates to that point — does it pass over horizontally? Start here going right? End here from below? + +2. **Determine glyph type.** The set of intersection types at a point is analyzed to decide the glyph category: corner, T-junction, cross, straight line, etc. For example, `{StartRight, StartDown}` = upper-left corner (`┌`). + +3. **Select style variant.** The of the intersecting lines determines which Unicode variant to render: single (`─`), double (`═`), heavy (`━`), dashed, dotted, or rounded. + +4. **Filter exclusions.** Points in the exclusion region (see [Exclude](#linecanvasexclude--output-filter) below) are removed from the output. + +### Line Styles + + determines the glyph variant used for each line segment and intersection: + +| Style | Horizontal | Vertical | Corner | +|-------|-----------|----------|--------| +| `Single` | `─` | `│` | `┌` | +| `Double` | `═` | `║` | `╔` | +| `Heavy` | `━` | `┃` | `┏` | +| `Rounded` | `─` | `│` | `╭` | +| `Dashed` | `╌` | `╎` | `┌` | +| `Dotted` | `┄` | `┆` | `┌` | + +When lines of different styles intersect, the canvas selects the appropriate mixed-style glyph (e.g., a single horizontal meeting a double vertical produces `╥`). + +### Merging Canvases Across Views + +Every has its own `LineCanvas`. During drawing, the framework merges canvases so that lines from different views auto-join seamlessly. + +When `SuperViewRendersLineCanvas` is `true` on a SubView, its lines are merged into the SuperView's canvas via `LineCanvas.Merge()`. All lines then participate in a single intersection-resolution pass. This is how adjacent tab headers, nested frames, and other multi-view border compositions achieve connected line art. + +SubViews that render their own `LineCanvas` independently (the default) are composited using a painters' algorithm during `View.RenderLineCanvas()`. Higher-Z views take priority; lower-Z cells only render if they provide richer junctions. + +### `LineStyle.None` — A Convention, Not an Eraser + + has **no special handling** inside . When passed to `AddLine`, the line is stored and participates in intersection resolution like any other. Because `None` doesn't match any styled-glyph check, it falls through to the default glyphs and **renders identically to `LineStyle.Single`**. + +The "eraser" behavior in the LineDrawing scenario is implemented by the *consumer*, not by LineCanvas: + +```csharp +// LineDrawing scenario — eraser logic on mouse-up +if (_currentLine.Style == LineStyle.None) +{ + // Physically remove overlapping segments from the line collection + area.CurrentLayer = new LineCanvas ( + area.CurrentLayer.Lines.Exclude ( + _currentLine.Start, + _currentLine.Length, + _currentLine.Orientation)); +} +``` + +This calls to split and remove overlapping lines. The `None`-styled line itself is never kept. + +> **Key point:** If you add a `LineStyle.None` line without eraser handling, it renders as a visible single-style line. To suppress lines, use the mechanisms described below. + +### Suppressing and Removing Lines + +LineCanvas provides three distinct mechanisms for controlling what gets drawn. Each operates at a different stage and has different semantics. Using the wrong one produces subtle bugs. + +#### `LineCanvas.Exclude` — Output Filter + + suppresses resolved cells from `GetCellMap` output **without affecting the underlying geometry**. Lines still exist and still auto-join through excluded positions. + +```csharp +LineCanvas lc = new (); +lc.AddLine (new (0, 0), 10, Orientation.Horizontal, LineStyle.Single); + +// Exclude positions 3–5 (a title label occupies those cells) +lc.Exclude (new Region (new Rectangle (3, 0, 3, 1))); + +// GetCellMap returns 7 cells (positions 0–2 and 6–9). +// The line auto-joins correctly on either side because +// the full line still participates in intersection resolution. +``` + +**Use this when something else is drawn at a position** — for example, a title label on a border. The border joins correctly on either side because the line is continuous behind the label. + +**Do not use this as an eraser.** Because lines auto-join through excluded regions, phantom geometry leaks into junction decisions. A vertical line crossing an excluded horizontal line resolves as `┼` instead of `│`. + +#### `Reserve` — Compositing Metadata + + marks positions as intentionally empty for multi-canvas compositing. It has **no effect** on the canvas that calls it — `GetCellMap` does not check reserved cells. + +Reserved cells are consumed during `View.RenderLineCanvas`, which layers multiple independently-resolved canvases. Reserved cells claim positions so that cells from lower-Z canvases do not show through: + +```csharp +// In a focused tab's border rendering: +// Reserve the gap where the header connects to the content area. +// This prevents the content area's top border from showing through. +lc.Reserve (new Rectangle (gapStart, borderY, gapWidth, 1)); +``` + +#### `StraightLineExtensions.Exclude` — Geometry Surgery + + physically splits or removes lines from a collection. This is the correct tool for erasing geometry: + +```csharp +LineCanvas lc = new (); +lc.AddLine (new (0, 0), 10, Orientation.Horizontal, LineStyle.Single); + +// Erase positions 4–5 by rebuilding the line collection +IEnumerable remaining = lc.Lines.Exclude ( + new Point (4, 0), 2, Orientation.Horizontal); + +LineCanvas erased = new (remaining); +// erased contains two separate segments: 0–3 and 6–9. +// A vertical line through x=4 correctly renders as │, not ┼. +``` + +> **Warning:** There are two unrelated methods named `Exclude`. `LineCanvas.Exclude (Region)` filters output while preserving auto-join. `StraightLineExtensions.Exclude (...)` physically removes geometry. They have opposite semantics. + +#### Choosing the Right Mechanism -Terminal.Gui supports drawing lines and shapes using box-drawing glyphs. The class provides *auto join*, a smart TUI drawing system that automatically selects the correct line/box drawing glyphs for intersections making drawing complex shapes easy. See . +| Scenario | Mechanism | Why | +|----------|-----------|-----| +| Hide cells behind a title label | `LineCanvas.Exclude (Region)` | Lines auto-join through the label — the border looks continuous | +| Erase drawn lines | `StraightLineExtensions.Exclude (...)` | Geometry is physically removed — no phantom junctions | +| Gap where a focused tab meets content | `Reserve (Rectangle)` | Claims positions during compositing so lower-Z borders don't bleed through | +| "No border" on a view | Don't call `AddLine` | Check `if (style != LineStyle.None)` before adding | ## Thickness diff --git a/docfx/docs/index.md b/docfx/docs/index.md index 0e1f5b7a95..26b80200f4 100644 --- a/docfx/docs/index.md +++ b/docfx/docs/index.md @@ -22,6 +22,7 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [ANSI Handling](~/docs/ansihandling.md) - Terminal escape sequence parsing, encoding, and state management - [Application](~/docs/application.md) - Application lifecycle, initialization, and main loop - [Arrangement](~/docs/arrangement.md) - View arrangement and positioning strategies +- [Borders](~/docs/borders.md) - Border rendering, tab headers, LineCanvas auto-join, and focus-aware styling - [Cancellable Work Pattern](~/docs/cancellable-work-pattern.md) - Core design pattern for extensible workflows - [Command](~/docs/command.md) - Command execution, key bindings, and the Activating/Accepting concepts - [Command Diagrams](~/docs/command-diagrams.md) - Visual diagrams of command flow and processing diff --git a/docfx/docs/toc.yml b/docfx/docs/toc.yml index 4cd1d756d8..3dfd660a78 100644 --- a/docfx/docs/toc.yml +++ b/docfx/docs/toc.yml @@ -18,6 +18,8 @@ href: application.md - name: Arrangement href: arrangement.md +- name: Borders + href: borders.md - name: Cancellable Work Pattern href: cancellable-work-pattern.md - name: Command diff --git a/plans/fix-border-subview-linecanvas-clipping.md b/plans/fix-border-subview-linecanvas-clipping.md new file mode 100644 index 0000000000..dc73ab5f24 --- /dev/null +++ b/plans/fix-border-subview-linecanvas-clipping.md @@ -0,0 +1,239 @@ +# Fix: Border SubView LineCanvas Lines Not Clipped at Parent Bounds + +## Bug Summary + +When a SubView of a Border has `SuperViewRendersLineCanvas = true` and its own border +(`BorderStyle != None`), and the SubView's frame extends past the parent Border's bounds, +the SubView's border lines bleed into the parent's border columns. For example, a `║` +becomes `╫` because the SubView's `─` merges unclipped into the parent's LineCanvas. + +**Failing test:** `AdornmentSubViewLineCanvasTests.BorderSubView_WithBorder_ClippedWhenExceedingParentBounds` + +## Root Cause + +The merge at `View.Drawing.Adornments.cs:50` is unclipped: + +```csharp +// Line 43: clip set to border's frame (only affects raster drawing via Driver.Clip) +Region? saved = borderView.AddFrameToClip (); +// Line 44: subviews are drawn (their LineCanvas lines are generated) +borderView.DoDrawSubViews (); + +// Line 50: ALL lines from borderView's LineCanvas are merged — NO BOUNDS CHECK +LineCanvas.Merge (borderView.LineCanvas); +``` + +`LineCanvas.Merge()` (LineCanvas.cs:510-524) copies every `StraightLine` unconditionally. +`Driver.Clip` (set by `AddFrameToClip`) only restricts raster output (`AddStr`, `Move`), +not LineCanvas data. The merged lines participate in intersection resolution and produce +corrupted junction glyphs where they cross the parent's border lines. + +The class docs (LineCanvas.cs:44-48) describe a `Merge(LineCanvas, Region?)` overload for +clipped merging, but **this overload does not exist**. + +## Draw Pipeline Context + +``` +View.Draw(): + 1. DoDrawAdornments() — Parent's border adds lines to this.LineCanvas + 2. AddViewportToClip() — Clip to viewport (raster only) + 3. DoDrawSubViews() — Content subviews drawn + 4. SetClip → AddFrameToClip() — Clip to frame (raster only) + 5. DoDrawAdornmentsSubViews() — Border subview lines merged into this.LineCanvas ← BUG + 6. DoRenderLineCanvas() — Resolves all lines and renders to screen +``` + +The merge in step 5 must restrict lines to the border's content area before they enter +the parent's LineCanvas in step 6. + +## Fix Options + +### Option A: Clipped `Merge` overload on LineCanvas + +Implement the documented but missing `Merge(LineCanvas, Rectangle clipBounds)` overload. +It would trim or discard each incoming `StraightLine` to fit within `clipBounds` before +adding it. + +**Where to change:** +- `LineCanvas.cs` — Add `Merge(LineCanvas, Rectangle)` that clips each line using + `StraightLineExtensions`-style logic (trim Start/Length to stay within bounds). +- `View.Drawing.Adornments.cs:50` — Pass the border view's frame rect: + ```csharp + Rectangle borderBounds = borderView.FrameToScreen (); + LineCanvas.Merge (borderView.LineCanvas, borderBounds); + ``` +- Same pattern for Padding merge at line 84. + +**Pros:** +- Clean, self-contained — clipping logic lives in LineCanvas where it belongs. +- The documentation already describes this overload; just implement it. +- Lines are trimmed *before* intersection resolution, so no corrupted junctions. +- `StraightLineExtensions.Exclude` already has line-splitting logic that can be reused + to clip lines against a rectangle boundary. + +**Cons:** +- Trimming lines can produce different junction types at the clip boundary (the docs + warn about this). A line that was `PassOverHorizontal` may become `StartRight` after + clipping, which could change the resolved glyph. This is acceptable — the clipped + edge is at the parent's border, which already has its own lines providing the correct + junction context. +- Must handle both horizontal and vertical lines, and both positive/negative lengths. + +**Complexity:** Medium. The line-trimming math is straightforward (clamp start/end to +bounds, recompute length). `StraightLineExtensions` already demonstrates the pattern. + +--- + +### Option B: Exclude-based approach — add exclusion region to parent LineCanvas + +Instead of clipping lines before merge, merge everything, then exclude the out-of-bounds +cells from the parent's LineCanvas output. + +**Where to change:** +- `View.Drawing.Adornments.cs:50` — After merge, compute the region outside the border + view's frame and call `LineCanvas.Exclude()` on those areas. + +**Pros:** +- Simpler implementation — no line-splitting math. +- Uses existing `Exclude` API. + +**Cons:** +- **Does not fix the bug.** `Exclude` hides cells from `GetCellMap` output but lines + still participate in intersection resolution. The out-of-bounds `─` still crosses the + parent's `║` during resolution, producing `╫` — even though the `╫` cell at the + parent's border column would be excluded, the parent's own `║` line at that position + would ALSO be excluded because exclusion is position-based, not line-based. +- Would need careful region math to only exclude the *SubView's* cells outside bounds + without excluding the parent's own border cells at those positions. +- Fragile and semantically wrong — the problem is that lines exist where they shouldn't, + not that their output needs hiding. + +**Verdict: Not viable** without significant additional work to make exclusion line-aware. + +--- + +### Option C: Clip in `DoDrawSubViews` — restrict the SubView's own LineCanvas generation + +Prevent the SubView from generating LineCanvas lines outside the border's frame in the +first place, by clipping the SubView's layout/frame before it draws. + +**Where to change:** +- `View.Drawing.Adornments.cs:44` or the SubView's own `Draw()` — Constrain the + SubView's effective frame to the intersection of its frame and the border view's frame + before drawing. + +**Pros:** +- Fixes the problem at the source — lines are never generated outside bounds. +- No post-hoc filtering or trimming needed. + +**Cons:** +- Changing the SubView's frame/layout is invasive and could have side effects on hit + testing, mouse events, and other layout-dependent behavior. +- The SubView's `BorderView.OnDrawingContent` adds lines based on the SubView's + `FrameToScreen()`. Changing the frame changes the border geometry, not just clips it. +- Would need to be undone after drawing, adding complexity. +- Conceptually wrong — layout shouldn't change during draw. + +**Verdict: Too invasive.** Mixing layout mutation with draw is a design smell. + +--- + +### Option D: Filter during `RenderLineCanvas` — clip at output time + +Instead of clipping during merge, filter the resolved `cellMap` in `RenderLineCanvas` +to only include cells within the view's frame. + +**Where to change:** +- `View.Drawing.LineCanvas.cs:48-60` — Skip cells outside `FrameToScreen()`. + +**Pros:** +- Simple one-line check in the render loop. +- No changes to LineCanvas data structure. + +**Cons:** +- **Does not fix junction corruption.** The out-of-bounds lines still participate in + intersection resolution. Even if the corrupted `╫` cell is not rendered, the parent's + `║` line at that position may resolve differently because of the intersecting `─`. + The resolved glyph at the parent's border column would be wrong even if we skip + rendering out-of-bounds cells. +- Only addresses the symptom (rendering) not the cause (unclipped lines in the canvas). + +**Verdict: Insufficient.** Junction corruption happens during resolution, not rendering. + +## Recommendation + +**Option A** is the correct fix. It addresses the root cause (unclipped lines entering +the parent's LineCanvas), uses the existing documented API contract, and produces correct +junction glyphs because the parent's own border lines are the only lines at the boundary +during intersection resolution. + +### Implementation sketch + +```csharp +// LineCanvas.cs — new overload +public void Merge (LineCanvas lineCanvas, Rectangle clipBounds) +{ + foreach (StraightLine line in lineCanvas._lines) + { + // Clip the line to clipBounds; may produce 0 or 1 clipped line + StraightLine? clipped = ClipLine (line, clipBounds); + + if (clipped is { }) + { + AddLine (clipped); + } + } + + // Exclusion regions are position-based — intersect with clipBounds + if (lineCanvas._exclusionRegion is { }) + { + Region clippedExclusion = lineCanvas._exclusionRegion.Clone (); + clippedExclusion.Intersect (clipBounds); + _exclusionRegion ??= new Region (); + _exclusionRegion.Union (clippedExclusion); + } +} + +private static StraightLine? ClipLine (StraightLine line, Rectangle bounds) +{ + Rectangle lineBounds = line.Bounds; + Rectangle clipped = Rectangle.Intersect (lineBounds, bounds); + + if (clipped.IsEmpty) + { + return null; + } + + // Recompute Start and Length from the clipped rectangle + Point newStart = line.Orientation == Orientation.Horizontal + ? new Point (clipped.X, clipped.Y) + : new Point (clipped.X, clipped.Y); + + int newLength = line.Orientation == Orientation.Horizontal + ? clipped.Width + : clipped.Height; + + // Preserve direction (sign of Length) + if (line.Length < 0) + { + newLength = -newLength; + // Adjust start for negative-direction lines + // ... (handle negative length start offset) + } + + return new StraightLine (newStart, newLength, line.Orientation, line.Style, line.Attribute); +} +``` + +Call site in `View.Drawing.Adornments.cs`: + +```csharp +if (borderView.LineCanvas.Bounds != Rectangle.Empty) +{ + Rectangle clipBounds = borderView.FrameToScreen (); + LineCanvas.Merge (borderView.LineCanvas, clipBounds); + borderView.LineCanvas.Clear (); +} +``` + +Same for the Padding merge at line 82-86. diff --git a/plans/refactor-border-tab-to-borderview.md b/plans/refactor-border-tab-to-borderview.md new file mode 100644 index 0000000000..c3b84e3250 --- /dev/null +++ b/plans/refactor-border-tab-to-borderview.md @@ -0,0 +1,359 @@ +# Plan: Move Tab-Related Functionality from Border to BorderView + +## Problem + +`Border` is instantiated on **every** `View` — it should be as lightweight as possible. Currently it carries tab-related members (`TabSide`, `TabOffset`, `TabLength`, `TabEnd`, `EffectiveTabLength`, `SettingsChanged`) that are only meaningful when `BorderSettings.Tab` is active. This functionality belongs on `BorderView`, which is lazily created only when needed. + +## Goals + +1. **Minimize Border's footprint** — Border stores only `Thickness`, `LineStyle`, and `Settings`. +2. **Move tab configuration to BorderView** — `TabSide`, `TabOffset`, `TabLength`, `EffectiveTabLength` become properties on `BorderView`. `TabEnd` is deleted (zero consumers). +3. **Update all consumers** — backwards compatibility is not a concern. All call sites change from `view.Border.TabSide` to `((BorderView)view.Border.View!).TabSide` (or use a helper/local). +4. **No behavioral changes** — rendering, tests, and UICatalog scenarios produce identical output. + +## Approach + +**Option B: Move completely, remove from Border.** All tab properties are deleted from `Border` and added to `BorderView`. Every consumer is updated. This gives the cleanest separation. + +--- + +## Current State Inventory + +### Members on `Border` today + +| Member | Kind | Tab-Only? | Consumers | +|--------|------|-----------|-----------| +| `Thickness` | inherited | No | Everywhere — **stays** | +| `LineStyle` | property | No | Everywhere — **stays** | +| `Settings` | property | No (but triggers tab setup) | Everywhere — **stays** | +| `SettingsChanged` | event | Yes (only subscriber: BorderView) | 1 internal — **remove** | +| `TabSide` | property | **Yes** | ~48 locations — **move** | +| `TabOffset` | property | **Yes** | ~80 locations — **move** | +| `TabLength` | property | **Yes** | ~13 locations — **move** | +| `TabEnd` | computed property | **Yes** | 0 consumers — **delete** | +| `EffectiveTabLength` | internal property | **Yes** | ~11 locations — **move** | + +### Members on `BorderView` today + +BorderView already has all the tab **rendering** logic. It reads tab configuration from `Border` via its `Adornment` reference. After this refactor, it owns the configuration too. + +--- + +## Execution Order + +Work in three phases, building green after each. + +### Phase 1: `EffectiveTabLength` and `TabEnd` + +Low-risk warm-up. `EffectiveTabLength` is `internal` and `TabEnd` is dead code. + +### Phase 2: `TabSide`, `TabOffset`, `TabLength` + +The bulk of the work — these are public properties with many consumers. + +### Phase 3: `SettingsChanged` event + +Cleanup — replace the event with a direct call. + +--- + +## Phase 1: Move `EffectiveTabLength`, Delete `TabEnd` + +### Step 1.1: Add `EffectiveTabLength` to `BorderView` + +Add to `BorderView.cs` (tab support region): + +```csharp +internal int EffectiveTabLength +{ + get + { + if (TabLength is { } explicitLength) + { + return explicitLength; + } + + if (TitleView is not (ITitleView itv and View tv)) + { + return 0; + } + + if (itv.MeasuredTabLength > 0) + { + return itv.MeasuredTabLength; + } + + // TitleView hasn't been laid out yet — set text and orientation, then measure. + tv.Text = Adornment?.Parent?.Title ?? string.Empty; + itv.Orientation = TabSide is Side.Left or Side.Right ? Orientation.Vertical : Orientation.Horizontal; + + int measured = TabSide is Side.Top or Side.Bottom ? tv.GetAutoWidth () : tv.GetAutoHeight (); + itv.MeasuredTabLength = measured; + + return measured; + } +} +``` + +Note: This initially reads `TabSide` and `TabLength` from `Border` (via `Adornment`). After Phase 2, these become local properties and the reads simplify. + +### Step 1.2: Delete `EffectiveTabLength` from `Border` + +Remove the full `EffectiveTabLength` property from `Border.cs`. + +### Step 1.3: Update consumers of `EffectiveTabLength` + +All consumers currently access `border.EffectiveTabLength` or `tab.Border.EffectiveTabLength`. + +**Library code (`Tabs.cs`)** — 4 reads. Pattern: `tab.Border.EffectiveTabLength`. Change to: + +```csharp +((BorderView)tab.Border.View!).EffectiveTabLength +``` + +Or introduce a local helper in `Tabs.cs`: + +```csharp +private static BorderView GetBorderView (View tab) => (BorderView)tab.Border.View!; +``` + +Then: `GetBorderView (tab).EffectiveTabLength` + +**Library code (`BorderView.cs`)** — 1 read in `DrawTabBorder`. Change `border.EffectiveTabLength` → `EffectiveTabLength` (now local). + +**Tests** — ~5 assertions. Change `view.Border.EffectiveTabLength` → cast and access. + +### Step 1.4: Delete `TabEnd` from `Border` + +Remove the `TabEnd` computed property entirely. It has **zero consumers**. + +### Step 1.5: Build and test + +```bash +dotnet build --no-restore +dotnet test --project Tests/UnitTestsParallelizable --no-build +``` + +--- + +## Phase 2: Move `TabSide`, `TabOffset`, `TabLength` + +### Step 2.1: Add properties to `BorderView` + +Add to `BorderView.cs` (tab support region): + +```csharp +public Side TabSide +{ + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } +} = Side.Top; + +public int TabOffset +{ + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } +} + +public int? TabLength +{ + get; + set + { + if (field == value) + { + return; + } + + field = value; + Adornment?.Parent?.SetNeedsLayout (); + } +} +``` + +### Step 2.2: Delete properties from `Border` + +Remove `TabSide`, `TabOffset`, `TabLength` (including backing fields, setters, and XML docs) from `Border.cs`. + +### Step 2.3: Update `BorderView` internal reads + +All reads in `BorderView.cs` that go through `border.TabSide`, `border.TabOffset`, `border.TabLength` change to `TabSide`, `TabOffset`, `TabLength` (now `this`). Affected methods: + +| Method | Properties read | +|--------|----------------| +| `ConfigureForTabMode()` | `border.TabSide` → `TabSide` | +| `UpdateTitleViewLayout()` | `border.TabSide`, `border.TabOffset`, `border.TabLength` → local | +| `GetTabBorderBounds()` | `border.TabSide` → `TabSide` | +| `DrawTabBorder()` | `border.TabSide`, `border.TabOffset`, `border.EffectiveTabLength` → local | +| `GetTabDepth()` | Uses `Adornment.Thickness` only — **no change** | +| `IsFocusedOrLastTab()` | No tab config reads — **no change** | + +Also update `EffectiveTabLength` getter (from Phase 1) to read `TabSide`/`TabLength` from `this` instead of from Border. + +### Step 2.4: Update `Tabs.cs` + +This is the primary external consumer. All patterns are `view.Border.TabSide`, `tab.Border.TabOffset`, etc. + +Add a static helper (or extension) to reduce cast noise: + +```csharp +// In Tabs.cs (private helper) +private static BorderView GetBorderView (View tab) => (BorderView)tab.Border.View!; +``` + +Then update all call sites: + +| Old | New | +|-----|-----| +| `view.Border.TabSide = _tabSide` | `GetBorderView (view).TabSide = _tabSide` | +| `tab.Border.TabOffset = offset` | `GetBorderView (tab).TabOffset = offset` | +| `tab.Border.TabLength = null` | `GetBorderView (tab).TabLength = null` | +| `tab.Border.EffectiveTabLength` | `GetBorderView (tab).EffectiveTabLength` | + +Approximate count: ~15 sites in `Tabs.cs`. + +### Step 2.5: Update `BorderEditor.cs` + +`BorderEditor.cs` in `Examples/UICatalog/Scenarios/EditorsAndHelpers/` casts to `Border` and reads/writes `TabSide`, `TabOffset`. Change to cast to `BorderView` via `AdornmentToEdit.View`: + +```csharp +// Old: +((Border)AdornmentToEdit).TabSide +// New: +((BorderView)AdornmentToEdit.View!).TabSide +``` + +Approximate count: ~5 sites. + +### Step 2.6: Update `Adornments.cs` scenario + +`Examples/UICatalog/Scenarios/Adornments.cs` reads `window.Border.TabSide`, `window.Border.TabOffset`, `window.Border.TabLength`. Change to access via `BorderView`: + +```csharp +BorderView bv = (BorderView)window.Border.View!; +bv.TabSide ... +bv.TabOffset ... +``` + +Approximate count: ~5 sites. + +### Step 2.7: Update `UICatalogRunnable.cs` + +Has 2 commented-out references to `Border.TabSide`. Update or remove the comments. + +### Step 2.8: Update tests + +Tests that access `view.Border.TabSide`, `view.Border.TabOffset`, `view.Border.TabLength` need updating: + +| Test file | Approx sites | +|-----------|-------------| +| `TabsTests.cs` | ~10 | +| `TabsScrollingTests.cs` | ~55 | +| `BorderViewTests.cs` | ~30 | +| `TitleViewTests.cs` | ~5 | +| `TabCompositionTests.cs` | ~3 | +| `AdornmentSubViewLineCanvasTests.cs` | ~2 | + +Pattern: add a local helper or inline cast. For test files with many accesses, a helper at the top of the class: + +```csharp +private static BorderView Bv (View v) => (BorderView)v.Border.View!; +``` + +### Step 2.9: Update docs + +- `docfx/docs/borders.md` — update "Key Properties" table and code examples to use `BorderView` access pattern. +- `Border.cs` XML docs — remove tab-related examples and references. +- `BorderView.cs` XML docs — add docs for the new properties. +- `BorderSettings.cs` — update `Tab` doc to reference `BorderView.TabSide` etc. instead of `Border.TabSide`. + +### Step 2.10: Build and test + +```bash +dotnet build --no-restore +dotnet test --project Tests/UnitTestsParallelizable --no-build +dotnet test --project Tests/UnitTests --no-build +``` + +--- + +## Phase 3: Remove `SettingsChanged` Event + +### Step 3.1: Replace event with direct call + +In `Border.Settings` setter, replace: + +```csharp +SettingsChanged?.Invoke (this, EventArgs.Empty); +``` + +with: + +```csharp +(View as BorderView)?.ConfigureForTabMode (); +``` + +Make `ConfigureForTabMode` `internal` (currently `private`). + +### Step 3.2: Remove event + subscription + +- Delete `public event EventHandler? SettingsChanged;` from `Border.cs`. +- Delete `border.SettingsChanged += OnSettingsChanged;` from `BorderView` constructor. +- Delete the `OnSettingsChanged` bridge method from `BorderView`. + +### Step 3.3: Build and test + +```bash +dotnet build --no-restore +dotnet test --project Tests/UnitTestsParallelizable --no-build +``` + +--- + +## Files Changed + +| File | Phase | Change | +|------|-------|--------| +| `Terminal.Gui/ViewBase/Adornment/Border.cs` | 1,2,3 | Remove `TabSide`, `TabOffset`, `TabLength`, `TabEnd`, `EffectiveTabLength`, `SettingsChanged`; update `Settings` setter | +| `Terminal.Gui/ViewBase/Adornment/BorderView.cs` | 1,2 | Add `TabSide`, `TabOffset`, `TabLength`, `EffectiveTabLength`; update all internal reads to use local props; make `ConfigureForTabMode` internal | +| `Terminal.Gui/ViewBase/Adornment/BorderSettings.cs` | 2 | Update XML doc for `Tab` to reference `BorderView` | +| `Terminal.Gui/Views/Tabs.cs` | 1,2 | Add helper; update ~15 call sites | +| `Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs` | 2 | Update ~5 call sites | +| `Examples/UICatalog/Scenarios/Adornments.cs` | 2 | Update ~5 call sites | +| `Examples/UICatalog/UICatalogRunnable.cs` | 2 | Update 2 commented-out references | +| `Tests/.../TabsTests.cs` | 2 | Update ~10 sites | +| `Tests/.../TabsScrollingTests.cs` | 2 | Update ~55 sites | +| `Tests/.../BorderViewTests.cs` | 1,2 | Update ~30 sites | +| `Tests/.../TitleViewTests.cs` | 2 | Update ~5 sites | +| `Tests/.../TabCompositionTests.cs` | 2 | Update ~3 sites | +| `Tests/.../AdornmentSubViewLineCanvasTests.cs` | 2 | Update ~2 sites | +| `docfx/docs/borders.md` | 2 | Update property table and examples | + +--- + +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| `Tabs.cs` accesses tab properties before `BorderView` exists | `Tabs.cs` always sets `Border.Settings = Tab \| Title` first, which triggers `GetOrCreateView()` — `BorderView` exists before any tab property access | +| Tests that set `Border.TabOffset` without first enabling `BorderSettings.Tab` | These tests must also set `Border.Settings` to include `Tab` (which creates `BorderView`) before accessing tab properties. Fix in test updates. | +| Forgot a consumer — compile error | Good: compile errors are easy to find and fix. No silent runtime breakage. | +| `ConfigureForTabMode` visibility change | Making it `internal` is safe — it's only called from within the assembly |